Sync dev → main: 신규 스킬 6종 (emergency-room-beds · korean-cinema-search · kstartup-search · local-election-candidate-search · ohou-today-deal · sh-notice-search) + k-skill-qa-bot + daiso/danawa 보강 (#271)

* docs(flight-ticket-search): register skill in README table and add feature guide

PR #224 머지 시 README "어떤 걸 할 수 있나" 표와 "포함된 기능" 리스트, 그리고
docs/features/flight-ticket-search.md 가이드가 등록되지 않아 main에 있는 다른
모든 스킬과 달리 사용자/에이전트가 README만 봐서는 이 스킬을 발견할 수 없는
상태였다. 누락분을 hotfix로 보강한다.

- README 표에 `flight-ticket-search` 행 추가 (마이리얼트립 옆 항공 클러스터)
- README "포함된 기능" 리스트에 가이드 링크 추가
- docs/features/flight-ticket-search.md 신규 작성:
  · 사용 시나리오, 구현 표면(fast-flights==2.2, 사용자 venv 격리)
  · search / compare-month / compare-range / compare-years CLI 예시
  · 응답 필드, IATA 입력 가이드, 예약 링크 정책
  · 검증된 노선 목록, 실패 모드, 비범위, 출처

검증:
- node --test scripts/skill-docs.test.js → 138/138 pass
- ./scripts/validate-skills.sh → skill layout looks valid

코드 변경 없음 → changeset 불필요.

* feat(daiso-product-search): replace blocked-API fallback with Bearer token auth

selStrPkupStck는 더 이상 차단 상태가 아니며, /api/auth/request로 비로그인 JWT를
발급받아 AES-128-CBC(키: PRE_AUTH_ENC_KEY)로 암호화한 Bearer 토큰으로 접근한다.
403 응답 시 토큰을 재발급해 1회 재시도한다. pickupEligibility(selPkupStr) 폴백
로직은 제거했다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Preserve Daiso pickup answers when Bearer auth degrades

Keep exact stock lookup on the official Bearer-token path while restoring the public selPkupStr fallback for repeated auth blocks.

Constraint: PR #250 review required Bearer auth to remain primary without removing the resilient pickup eligibility API.

Rejected: Throwing after the retry | it collapses callers back to a brittle single upstream-auth dependency.

Confidence: high

Scope-risk: narrow

Directive: Keep pickupStock quantity semantics separate from pickupEligibility yes/no fallback.

Tested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100.

Not-tested: Live forced 403 from Daiso upstream; covered with injected fetch regression tests.

* Prove Daiso stock retry sends auth headers

Strengthen the retry regression so the Bearer-token contract cannot regress while still returning success from mocked stock responses.\n\nConstraint: PR #250 review requested explicit Authorization, X-DM-UID, and request body assertions on the retry path.\nRejected: Counting requests only | it allowed header/body regressions to pass.\nConfidence: high\nScope-risk: narrow\nDirective: Keep auth-header assertions on both initial and retry stock requests when editing this flow.\nTested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100; repeated-403 fixture probe.\nNot-tested: Live repeated upstream 403 because forcing Daiso production auth failure is not available without changing upstream state.

* Preserve Daiso caller headers through Bearer stock lookup

Keep advanced caller headers on the authenticated stock endpoint while generated Bearer and X-DM-UID values remain authoritative. Document the degraded selPkupStr fallback order in skill and source docs so the public workflow matches the restored API surface.\n\nConstraint: PR #250 review required resilient Bearer-primary stock lookup plus selPkupStr fallback and header/body contract coverage.\nRejected: Replacing caller headers with only auth headers | It regressed tracing/test-control header pass-through.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Authorization and X-DM-UID generated by the auth flow even when callers provide same-named headers.\nTested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; node --test scripts/skill-docs.test.js; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100.\nNot-tested: Forced live upstream repeated 403; covered by injected fixture tests.

* fix(danawa-price-search): capture .ico.* payment-condition badges and surface as row labels

PR #226 row 파서에 결제조건 배지(`.ico.cash`/`.ico.point`/`.ico.coupon`/`.ico.card`) selector가 누락돼, 카드 결제 불가능한 현금/쿠폰/포인트 전용가가 일반 최저가로 노출되는 결함을 고친다.

- `offers()` row 파싱부에 결제조건 배지 화이트리스트 캡처 블록 추가 (클래스 `cash`/`point`/`coupon`/`discount`/`card`/`membership` 또는 텍스트 `현금`/`포인트`/`쿠폰`/`할인`만 인정 — 빠른배송/안내/상품리뷰 노이즈 차단)
- row dict 신규 필드 6개: `payment_badges`, `cash_only`, `point_only`, `coupon_only`, `card_only_badge`, `is_conditional_price`
- 반환 dict에 `normal_count`, `conditional_count` 추가
- `SKILL.md` / `docs/features/danawa-price-search.md` 갱신 (Output shape · Response style · Workflow · Failure modes에 결제조건 정책과 표 예시 명시)

정렬 정책은 그대로 `total_price` 단일 기준이며, 결제조건은 row 단위 플래그/라벨로만 노출해 호출자가 결제수단에 맞춰 직접 판단하도록 한다.

회귀 (pcode=75001853, 갤럭시 S25 256GB 자급제 `offers --limit 5`):
- 1위 킴스클럽 979,000원 / `cash_only=True` / `payment_badges=["현금"]`
- 2위 롯데ON 1,072,080원 / `cash_only=False` / `payment_badges=[]`
- 3~5위 일반가 row 모두 `payment_badges` 빈 리스트 (노이즈 0건)

Closes #252

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Ensure captured Danawa payment badges stay conditional

Classify every whitelisted payment badge into normalized condition types so callers cannot count captured discount, membership, or text-only card rows as normal prices.

Constraint: PR #253 review required TDD follow-up on feature/#252 without changing total_price sorting.\nRejected: Removing discount and membership from the whitelist | would lose Danawa condition labels already captured by the parser.\nConfidence: high\nScope-risk: narrow\nDirective: Keep payment_badge whitelist and payment_condition_types in sync whenever adding new badge classes or text keywords.\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_danawa_price_search; live offers 75001853 --limit 5; npm run lint; npm run typecheck; npm run test; architect verification CLEAR.\nNot-tested: Danawa markup variants not represented by current live page or synthetic badge fixtures.

* Keep icon-only Danawa payment badges visible

Class-only Danawa payment icons can carry eligibility information without visible text, so synthesize display labels from the same normalized condition map used for types and booleans. This keeps raw row labels, condition fields, and returned-window counts aligned for downstream table renderers.\n\nConstraint: PR #253 review follow-up requires TDD coverage before parser changes.\nRejected: Leaving payment_badges text-only | icon-only conditional rows would still render without visible payment labels.\nConfidence: high\nScope-risk: narrow\nDirective: Derive future payment badge labels, types, and booleans from one canonical mapping.\nTested: python3 -m py_compile danawa-price-search/scripts/danawa_search.py scripts/test_danawa_price_search.py; PYTHONPATH=.:scripts python3 -m unittest scripts.test_danawa_price_search; python3 danawa-price-search/scripts/danawa_search.py offers 75001853 --limit 5; npm run lint; npm run typecheck; npm run test\nNot-tested: Danawa icon-only markup was verified with synthetic fixtures rather than a live page snapshot.

* Merge pull request #249 from NomaDamas/feature/#248

Feature/#248

* Restore SH notice lookup without proxy policy drift

Reintroduce SH notice search as a direct public HTML client so the skill complies with the free-API proxy boundary while preserving verifiable keyword, pagination, and attachment behavior.

Constraint: i-sh.co.kr board is public unauthenticated HTML, so k-skill-proxy must not host the scraper.\nRejected: Re-adding /v1/sh-notice proxy routes | public HTML scraping in proxy violates repository policy.\nConfidence: high\nScope-risk: moderate\nDirective: Keep SH public HTML access local/direct unless a key-required official free API is discovered and documented.\nTested: npm run ci; npm run lint --workspace sh-notice-search; npm test --workspace sh-notice-search; live SH smoke for 행복주택, 매입임대, 신혼희망타운, page 1/page 5, 1/6/9/11/0 attachment details.\nNot-tested: authenticated SH flows, 청약 application/submission, direct attachment downloads.

* Preserve public SH helper semantics

Route exported URL builders through the same normalization as the CLI/API so natural category aliases cannot bypass srchTp title narrowing or category mapping.\n\nConstraint: PR #254 review found exported helper callers could pass Korean/English public category inputs and get broken or broadened SH URLs.\nRejected: Keep normalized-only fast paths | exported helpers are public API and must protect natural inputs.\nConfidence: high\nScope-risk: narrow\nDirective: Keep exported helper behavior aligned with normalizeSearchOptions and normalizeDetailOptions when adding new public aliases.\nTested: npm test --workspace sh-notice-search; npm run lint --workspace sh-notice-search; npm run typecheck; npm run ci; node helper smoke for 임대 search/detail URLs.\nNot-tested: Live SH network smoke was not rerun for this helper-only change.

* Preserve SH parser helper aliases

Route exported parser helpers through the same public normalizers used by the SH fetch and URL-builder APIs so natural category aliases stay consistent across the package surface.

Constraint: PR #254 Round 2 review found parser helpers still treated raw category aliases as pre-normalized inputs.
Rejected: Keep parser helpers normalized-only | inconsistent with exported URL builders and public helper ergonomics.
Confidence: high
Scope-risk: narrow
Directive: Keep exported SH helper entry points on canonical normalizeSearchOptions/normalizeDetailOptions unless a separate internal-only API is introduced.
Tested: npm test --workspace sh-notice-search; npm run lint --workspace sh-notice-search; npm run typecheck; npm pack --workspace sh-notice-search --dry-run; npm run ci; parser smoke for Korean 임대 list/detail helpers; Ralph architect verification CLEAR; post-deslop regression npm run ci
Not-tested: Live SH network smoke for this follow-up; fixture and injected-fetch coverage exercised the helper contract.

* Make SH parser failures explicit

Warn when SH returns block or maintenance HTML without the expected public board markup, and constrain exposed preview links to the SH converter origin/path.\n\nConstraint: Round 3 review required TDD coverage for block/maintenance HTML and untrusted preview URLs.\nRejected: Throwing on unexpected HTML | Existing parser helpers return partial fixture-friendly results, so warnings preserve compatibility while exposing failure evidence.\nConfidence: high\nScope-risk: narrow\nDirective: Keep SH public HTML lookup direct; do not add proxy routing unless a key-required official free API is adopted.\nTested: npm run lint --workspace sh-notice-search; npm test --workspace sh-notice-search; npm run typecheck; npm pack --workspace sh-notice-search --dry-run; npm run ci; Node smoke for blocked HTML warnings and external preview filtering.\nNot-tested: Live blocked/NetFunnel SH response, because no live blocked page was available during implementation.

* ci: install beautifulsoup4 so danawa price search tests can import bs4

The new scripts/test_danawa_price_search.py imports danawa_search.py,
which requires beautifulsoup4. CI only runs npm ci, so the bs4 import
fails with 'beautifulsoup4 is required: python -m pip install
beautifulsoup4' and the validate job exits with code 1.

Install beautifulsoup4 via pip before running npm run ci so the
Python test suite can import danawa_search and run the new payment
badge regression tests.

* Revert "ci: install beautifulsoup4 so danawa price search tests can import bs4"

This reverts commit 8330e5adf7.

* test: install beautifulsoup4 inside npm test before Python tests

The new scripts/test_danawa_price_search.py imports danawa_search.py,
which requires beautifulsoup4. CI runs npm ci + npm run ci and does
not install Python packages, so the bs4 import fails at module load.

Install beautifulsoup4 via 'pip install --user' as the first step of
the test script so it is available when Python unittests import the
danawa helper. Local dev environments are unaffected because pip
install is idempotent and quiet.

* feat(qa-bot): add k-skill-qa-bot under tools/

External macOS daemon that clones NomaDamas/k-skill main every 3 days, runs
each skill through codex exec, has an LLM judge grade pass/fail/skip via
codex exec --output-schema, and files dedup'd GitHub issues for true failures.

Layout:
- install.sh copies tools/k-skill-qa-bot/ to ~/.local/share/k-skill-qa-bot/
  and registers a LaunchAgent at ~/Library/LaunchAgents/.
- update-clone.sh has a hard guard: refuses any K_SKILL_CLONE outside
  K_QA_HOME/k-skill-clone unless ALLOW_EXTERNAL_CLONE_TARGET=1.
- Force-skip 10 destructive/login-required skills (ktx-booking, srt-booking,
  catchtable-sniper, kakaotalk-mac, hipass-receipt, toss-securities, etc.)
  so the bot never triggers reservation abuse.
- Deprecated skills (strike-through + 지원 중단 in README) auto-detected
  and skipped, never failed.
- First-run safety: CREATE_ISSUES=false by default.
- mkdir-based concurrency lock with atomic stale reclaim.
- Issue dedup: sha1(skill_name + symptom_class)[:12] body marker.
- Deterministic gates override LLM judge to FAIL on exit_code != 0, missing
  VERDICT line, or near-timeout duration.

* Support nearby ER status checks

Add an E-Gen based emergency-room skill that resolves a user location, queries the public nearby emergency-room list, and reports operation flags while documenting that exact remaining bed counts are not exposed by this surface.

Constraint: Issue #255 requested NEMC emergency bed status using public monitoring/E-Gen surfaces.
Rejected: Scraping private monitoring dashboards or claiming exact bed utilization | public endpoints expose operation flags, not per-hospital remaining bed counts.
Confidence: high
Scope-risk: narrow
Directive: Preserve the public-data limitation text unless a verified official bed-count endpoint is added.
Tested: npm run lint --workspace emergency-room-beds; npm test --workspace emergency-room-beds; node --test scripts/skill-docs.test.js; npm run typecheck; npm pack --workspace emergency-room-beds --dry-run; ./scripts/validate-skills.sh; live E-Gen coordinate smoke.
Not-tested: npm run ci end-to-end due local Python 3.14 pip/pyexpat import error before tests.

* Prevent ER status ambiguity from reaching users

Constraint: Health-adjacent public E-Gen/Kakao data can be absent, delayed, schema-drifted, or partially unknown.

Rejected: Mapping all non-Y operation flags to false | It misrepresents missing upstream data as a negative operating status.

Rejected: Treating unknown E-Gen payloads as empty results | It hides upstream failure behind a false no-results response.

Confidence: high

Scope-risk: narrow

Directive: Keep unknown health availability data explicit and preserve upstream failure evidence.

Tested: npm run lint --workspace emergency-room-beds; npm test --workspace emergency-room-beds; node --test scripts/skill-docs.test.js; npm run typecheck; npm pack --workspace emergency-room-beds --dry-run; ./scripts/validate-skills.sh; direct Node smoke for tri-state/schema/coordinate guards.

Not-tested: npm run ci due pre-existing local Python 3.14 pyexpat/libexpat bootstrap failure noted on PR.

Co-authored-by: OmX <omx@oh-my-codex.dev>

* fix(ci): exclude tools/ from skill validator

The tools/ directory hosts repo tooling (e.g. k-skill-qa-bot), not
skills, so validate-skills.sh should skip it like other non-skill
top-level directories.

* 영화관 검색 스킬 추가 (#260)

* Add korean cinema search skill

* Document playDate for cinema skill

* feat(kstartup-search): 창업진흥원 K-Startup 조회 스킬 + 프록시 라우트 4종 (#259)

* feat(kstartup-search): 창업진흥원 K-Startup 조회 스킬과 프록시 라우트 추가

공공데이터포털 dataset 15125364 (창업진흥원_K-Startup(사업소개,사업공고,콘텐츠 등)_조회서비스) 의
4개 endpoint 를 k-skill-proxy 경유로 조회하는 스킬을 추가한다.

- 신규 라우트: GET /v1/kstartup/{business-info,announcements,contents,statistics}
  - 각각 getBusinessInformation01/getAnnouncementInformation01/getContentInformation01/
    getStatisticalInformation01 으로 중계
  - ServiceKey 는 서버 측 DATA_GO_KR_API_KEY 로 주입, returnType=json 강제
  - 정상 응답만 캐시, data.go.kr 에러 envelope (resultCode != "00", errMsg 등) 은 캐시 우회
- helper: kstartup-search/scripts/run_kstartup.py (stdlib only)
  - 일반 조회는 hosted proxy 사용 → 사용자 키 불필요
  - --direct 옵션은 사용자가 본인 KSKILL_KSTARTUP_API_KEY (혹은 DATA_GO_KR_API_KEY) 로
    upstream 직접 호출 + --dry-run 시 키 redact
- 입력 검증: page/perPage 정수·범위, YYYYMMDD 날짜 + 시작일 ≤ 종료일, Y/N 대문자화,
  텍스트 필드 길이 상한, biz_yr 4자리
- 테스트: k-skill-proxy 서버 테스트 10건 신규 (normalizer, 라우트, 캐시 분리,
  returnType=json 강제, 503/400/502, 키 누수 회귀), Python unittest 13건
- 문서: SKILL.md, docs/features/kstartup-search.md, README 표/리스트,
  docs/sources.md, .changeset/kstartup-search.md (k-skill-proxy minor)

* docs(kstartup-search): docs/setup·security·k-skill-setup·proxy README 에 K-Startup 항목 추가

seoul-density · KOSIS · NTS 선례와 동일한 위치·문구로 다음을 보강한다.

- docs/setup.md: dotenv 예시에 KSKILL_KSTARTUP_API_KEY 추가, credential 표에 K-Startup 행 추가, "다음에 볼 문서" 리스트 추가
- docs/security-and-secrets.md: standard variable names 에 KSKILL_KSTARTUP_API_KEY 추가, hosted proxy 사용 스킬 목록·proxy 운영 prose 에 K-Startup 추가, dotenv 예시 추가
- k-skill-setup/SKILL.md: credential resolution prose 와 시크릿 요약 표에 K-Startup 안내 추가
- packages/k-skill-proxy/README.md: 라우트 목록에 /v1/kstartup/{business-info,announcements,contents,statistics} 추가
- docs/features/k-skill-proxy.md: 라우트 목록에 같은 4개 추가

* fix(kstartup-search): strict calendar-date validation in Python helper

validate_yyyymmdd() previously only checked month in [1,12] and day in [1,31],
which accepted impossible dates like 20240230 or 20240431 in --direct mode.
The proxy-side normalizer in packages/k-skill-proxy/src/kstartup.js already
uses Date.UTC() to reject such inputs, so this aligns the --direct path with
the proxy path and eliminates validator drift.

Uses datetime.date(year, month, day) and raises HelperError on ValueError.

Adds regression test covering impossible calendar dates (Feb 30, Apr 31,
month 13, day 0) and the leap-year boundary (2024-02-29 valid, 2023-02-29
not).

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>

* fix(qa-bot): upgrade judge to gpt-5.5 and run codex with sandbox bypass

PR #257 follow-up. Two changes:

1. JUDGE_MODEL default: gpt-5.4-mini -> gpt-5.5

   The cheaper judge was misclassifying every wrong-output verdict because
   the offline matcher fell through to the dumb 'VERDICT: FAIL in transcript'
   check. Re-running the same 10 historical fail cases with gpt-5.5 +
   real LLM judge correctly reclassified 7 of them as pass (the codex agent
   actually accomplished the skill goal) and the remaining 3 as
   network-error / partial-success / skip with accurate reasons.

2. Drop -s read-only, add --dangerously-bypass-approvals-and-sandbox

   The read-only codex sandbox was triggering spurious DNS resolution
   failures inside the test runs (host blocked at the syscall level even
   for legitimate proxy / public-API calls). Live re-test with the bypass
   flag and provider pin produced clean transcripts: cheap-gas-nearby,
   daangn-realty-search, han-river-water-level, naver-news-search,
   naver-shopping-search, seoul-density, seoul-subway-arrival all PASS.
   The QA bot is sandboxed externally by launchd anyway.

3. New CODEX_PROVIDER env (default: openai)

   Lets users pin the codex model_provider explicitly so the bot does not
   accidentally route through a private OpenAI-compatible proxy that may
   not have keys registered for all model names.

* Add Ohou today deal skill

* fix spacing in package.json

* fix(qa-bot): per-skill test_prompt overrides and smarter judge

11 skills that need specific inputs (not just a 'demonstrate' query) now
ship with a hardcoded test_prompt in config/skill-overrides.yml:

  flight-ticket-search           ICN -> NRT, 2026-08-20 one-way
  nts-business-registration      124-81-00998 (Samsung Electronics)
  korean-stock-search            005930 Samsung 5-day quote
  joseon-sillok-search           키워드 훈민정음
  korean-law-search              산업안전보건법 제5조
  library-book-search            코스모스 칼 세이건
  lotto-results                  latest round
  k-schoollunch-menu             서울특별시교육청 초등학교 오늘 식단
  delivery-tracking              CJ dummy invoice (negative case ok)
  ticket-availability            YES24 / 인터파크 sample
  zipcode-search                 서울특별시 강남구 테헤란로 152

These were previously synthesized from the SKILL.md first When-to-use bullet,
which is a one-line teaser without concrete inputs. The agent would then
either ask the user for the missing input (partial-success) or fall back
to a generic demo (often producing a VERDICT: FAIL response). Both got
mis-classified as fail by the judge.

qa_utils.synthesize_test_prompt now honors default_inputs.test_prompt as a
verbatim override (only appending the VERDICT line if the override does not
already include it).

Two additional fixes for negative-case correctness:

1. judge-prompt.md: explicitly tells the judge that the agent's literal
   VERDICT: PASS / VERDICT: FAIL is just a hint, not binding. A skill that
   correctly returns 'no such business number' or 'invoice not found' for
   a deliberately invalid input is PASS, not fail.

2. judge-skill.py: drop the deterministic gate that flipped pass to fail
   when 'VERDICT: PASS' literal was missing from the transcript. That gate
   was producing false fails for negative-case tests where the agent
   correctly responded with VERDICT: FAIL because the skill rejected an
   invalid input. The judge LLM (gpt-5.5) is now trusted to evaluate the
   transcript against the SKILL.md 'Done when' criteria.

Verified live:

- nts-business-registration with valid number  -> pass/success (0.99)
- nts-business-registration with fake number   -> pass/success (0.99)
- flight-ticket-search ICN->NRT 2026-08-20     -> pass/success (0.99)

* fix(ohou-today-deal): address PR #264 review (live UA, explicit feed selection, argv validators)

- HIGH: switch fetch_html() to well-formed bot UA with contact URL
  (k-skill-ohou-today-deal/1.0 (+https://github.com/NomaDamas/k-skill)).
  ohou.se Akamai bot manager 403s anonymous UAs but allows identified
  bot UAs that include a contact URL. Live default workflow now returns
  74 deals end-to-end instead of failing with HTTP 403.
- MEDIUM: extract_deals() now explicitly selects React Query entries with
  queryKey == ['today-deal-feed'] or ['special-today-deal-feed'] and
  reads only state.data.todayDealFeed.slots[type=='DEAL']. Unrelated
  DEAL-shaped nodes from navigation/banner modules are excluded.
  Legacy fixture/JSON-payload fallback path preserved for tests that
  construct simplified payloads.
- LOW: --limit now requires a positive integer; --min-discount is
  constrained to 0..100. Both validated via argparse.ArgumentTypeError
  so users get a clear CLI error instead of silent slicing or nonsensical
  thresholds.
- Tests: add 9 new unit tests covering explicit feed selection,
  navigation/GOODS exclusion, fallback compatibility, and argv validators.
  Strengthen skill-docs.test.js to lock the special-today-deal-feed
  surface and well-formed UA signature.
- Docs: update SKILL.md and feature doc to document the explicit
  today-deal-feed + special-today-deal-feed extraction boundary and the
  Akamai UA policy.

* Merge pull request #263 from NomaDamas/feature/#257

Feature/#257

* 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>

* chore(changesets): rename daiso bearer-auth changeset to avoid name collision with consumed main release

PR #245 already consumed .changeset/issue-207-daiso-pickup-eligibility.md
into daiso-product-search v0.3.0 on main. The dev branch later modified that
same changeset file in d7263a5 to describe the newer Bearer-auth fix, which
collides with main's deletion on the next dev→main sync.

Renaming the still-unreleased Bearer-auth note to
issue-207-daiso-bearer-auth.md preserves the release entry for the next
version-packages run and clears the modify/delete conflict on PR #271
without losing the changelog content.

* fix(kstartup-search): implement promised client-side filter to deliver on SKILL.md L121

Live data revealed two unmet contracts in the kstartup-search helper:

1. SKILL.md L121 promised the helper re-applies supt_regin / aply_trgt /
   biz_enyy filters on the client side because K-Startup upstream ignores
   them server-side. The helper had no such logic — calling
   `--supt-regin 서울특별시 --rcrt-prgs-yn Y` returned 경북/충북/충남
   announcements as-is, silently misleading callers.

2. The upstream `supt_regin` field is stored as the short form
   (`서울`, `경기`, `충북`, ...) but every CLI example in the skill used
   the standard 광역지자체 long form (`서울특별시`), which would never
   substring-match even after a client filter was added.

Add `apply_client_filters()` that runs after `urlopen` returns. It honors
the SKILL.md contract literally: substring match per token, AND-joined
across comma-separated user values, with a 17-region (+`전국`) shortname
normalisation table so both `--supt-regin 서울특별시` and
`--supt-regin 서울` resolve to upstream's `서울`. Filtered responses
expose a new `client_filter: {fields, upstream_returned, after_filter}`
metadata block so callers can detect "first page was depleted by filter"
and page through.

Tests: 9 new ClientFilterTests + 2 normalisation tests on top of the
existing 14 (25 total, all passing).

Live smoke (against a dev proxy with DATA_GO_KR_API_KEY activated for
dataset 15125364): `--supt-regin 서울특별시 --rcrt-prgs-yn Y --per-page 10`
now returns 4 actual 서울 announcements (upstream returned 10 mixed-region
rows; client filter narrowed to 4), with detl_pg_url to k-startup.go.kr.

Confidence: high. Scope-risk: narrow — purely additive on the response
path; other endpoints (business-info / contents / statistics) pass
through unchanged.

---------

Co-authored-by: arnold714 <arnold714@naver.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: chanmin <cmju@cowave.kr>
Co-authored-by: OmX <omx@oh-my-codex.dev>
Co-authored-by: hmmhmmhm/ <hmmhmmhm@naver.com>
Co-authored-by: 배기민 <53887180+BAEM1N@users.noreply.github.com>
Co-authored-by: lee-ji-hong <zhffktkdlekghksxk@naver.com>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-19 11:08:10 +09:00 committed by GitHub
commit 271ea185c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
114 changed files with 10386 additions and 520 deletions

View file

@ -0,0 +1,5 @@
---
"daiso-product-search": minor
---
Restore Daiso store pickup stock quantities through the official non-login Bearer flow (`/api/auth/request` + AES-128-CBC token) while keeping the resilient `selPkupStr` fallback API. `getStorePickupStock()` now retries once with a fresh token on 401/403 and returns structured `retrievalStatus: "blocked"` after repeated auth blocks instead of throwing. `getStorePickupEligibility()` remains public, and `lookupStoreProductAvailability()` fills `pickupEligibility` when exact pickup stock remains unavailable.

View file

@ -0,0 +1,5 @@
---
"emergency-room-beds": minor
---
Add an E-Gen based nearby emergency-room status skill and package.

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add `/v1/kstartup/{business-info,announcements,contents,statistics}` routes that wrap the data.go.kr `15125364` (창업진흥원_K-Startup) Open API. The routes inject `DATA_GO_KR_API_KEY` server-side, return 503 when the key (or the per-dataset 활용신청) is missing, and cache successful JSON responses while bypassing the cache for upstream error envelopes (`resultCode != "00"`).

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

@ -0,0 +1,5 @@
---
"sh-notice-search": minor
---
Add a policy-compliant SH public notice search skill and direct HTML lookup client.

View file

@ -27,7 +27,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 고속버스 예매 | `express-bus-booking` | KOBUS 고속버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [고속버스 예매 가이드](docs/features/express-bus-booking.md) |
| 시외버스 예매 | `intercity-bus-booking` | 티머니 시외버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [시외버스 예매 가이드](docs/features/intercity-bus-booking.md) |
| 자연휴양림 빈 객실 조회 | `foresttrip-vacancy` | 공식 숲나들e 자연휴양림 예약 가능 객실 조회 자동화 (예약/결제 제외) | 필요 | [자연휴양림 빈 객실 조회 가이드](docs/features/foresttrip-vacancy.md) |
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송/삭제 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |
@ -40,10 +40,13 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 등기부등본 자동화 | `iros-registry-automation` | 인터넷등기소(IROS)에서 법인/부동산 등기부등본 장바구니, 수동 결제 후 열람·저장 흐름을 보조 | 필요(수동 로그인·결제/TouchEn) | [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md) |
| 법인등기 신청 컨설팅 | `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) |
| 개별공시지가 조회 | `gongsijiga-search` | realtyprice.kr 공개 API에서 지번 단위 개별공시지가(원/㎡) 다년도 추이·전년 대비 변동률 조회 | 불필요 | [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md) |
| SH 청약·주택 공고문 조회 | `sh-notice-search` | 서울주택도시개발공사(SH) 공개 공고/공지 게시판을 직접 조회해 키워드·공고 종류별 목록, 상세 본문, 첨부 미리보기 메타데이터 확인 | 불필요 | [SH 청약·주택 공고문 조회 가이드](docs/features/sh-notice-search.md) |
| LH 청약 공고문 조회 | `lh-notice-search` | 한국토지주택공사(LH) 임대/분양/주거복지(신혼희망타운)/토지/상가 공고를 지역·상태·공고유형·키워드로 조회하고 마감 여부를 KST 기준으로 표시 | 불필요 | [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md) |
| 법원 경매 부동산 매각공고 조회 | `court-auction-notice-search` | 대법원경매정보(courtauction.go.kr) 부동산 매각공고를 매각기일·법원·기일/기간 입찰 조건으로 검색해 사건번호·용도·주소·감정평가액·최저매각가격을 펼치고, 사건번호로 직접 사건정보·물건내역·매각기일이력을 조회 | 불필요 | [법원 경매 부동산 매각공고 조회 가이드](docs/features/court-auction-notice-search.md) |
| 기부처 조회 | `donation-place-search` | 지역·관심 분야 기준 기부처 후보와 공식 페이지/1365 확인용 검색 링크 안내 (기부·결제 자동화 제외) | 불필요 | [기부처 조회 가이드](docs/features/donation-place-search.md) |
@ -62,6 +65,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 근처 가장 싼 주유소 찾기 | `cheap-gas-nearby` | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| 근처 공중화장실 찾기 | `public-restroom-nearby` | 현재 위치 기준 근처 공중화장실/개방화장실 조회 | 불필요 | [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md) |
| 근처 공영주차장 찾기 | `parking-lot-search` | 현재 위치 기준 근처 공영주차장 위치·요금·운영시간 조회 | 불필요 | [근처 공영주차장 찾기 가이드](docs/features/parking-lot-search.md) |
| 근처 응급실 병상 상태 확인 | `emergency-room-beds` | 현재 위치 기준 가까운 응급실 운영·입원실/병상 운영 플래그와 갱신시각 조회 (정확한 잔여 병상 수/가동률은 공개 E-Gen nearby 목록에 없음) | 불필요 | [근처 응급실 병상 상태 확인 가이드](docs/features/emergency-room-beds.md) |
| 한국 마라톤 일정 조회 | `korean-marathon-schedule` | 고러닝 공개 페이지와 대한철인3종협회 일정에서 마라톤·철인3종 대회 일정, 장소, 신청 마감일, 종목 조회 | 불필요 | [한국 마라톤 일정 조회 가이드](docs/features/korean-marathon-schedule.md) |
| KBO 경기 결과 조회 | `kbo-results` | 날짜별 KBO 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| KBL 경기 결과 조회 | `kbl-results` | 날짜별 KBL 경기 일정, 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [KBL 경기 결과 가이드](docs/features/kbl-results.md) |
@ -78,14 +82,17 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~`blue-ribbon-nearby`~~ | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
| 근처 술집 조회 | `kakao-bar-nearby` | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | `zipcode-search` | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 픽업 가능 여부 확인 (정확한 매장별 재고 수량은 다이소몰 보안 정책으로 2026-05-05 부터 차단됨) | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 강남언니 병원 조회 | `gangnamunni-clinic-search` | 강남언니 공개 검색 페이지에서 성형외과·피부과 병원 후보, 평점, 리뷰 수, 지원 언어, 공개 링크 조회 | 불필요 | [강남언니 병원 조회 가이드](docs/features/gangnamunni-clinic-search.md) |
| 마켓컬리 상품 조회 | `market-kurly-search` | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
| 올리브영 검색 | `olive-young-search` | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
| 영화관 검색 | `korean-cinema-search` | CGV·메가박스·롯데시네마 영화관, 상영작, 시간표, 잔여석 조회 | 불필요 | [영화관 검색 가이드](docs/features/korean-cinema-search.md) |
| 올라포케 역삼 포케 | `hola-poke-yeoksam` | 올라포케 역삼점 메뉴, 매장 정보, 이벤트 참여 흐름 안내 | 불필요 | [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md) |
| 마이리얼트립 MCP 검색 | `myrealtrip-search` | 공식 MCP 서버로 항공권, 숙소, 투어·티켓·액티비티 검색과 상세·옵션 확인 | 불필요 | [마이리얼트립 MCP 검색 가이드](docs/features/myrealtrip-search.md) |
| 항공권 가격 조회 | `flight-ticket-search` | `fast-flights` 기반 Google Flights 공개 검색으로 항공권 후보, 예약 검색 링크, 날짜/월/연도별 최저가·평균가 비교 (조회 전용, 예매·결제 없음) | 불필요 | [항공권 가격 조회 가이드](docs/features/flight-ticket-search.md) |
| 택배 배송조회 | `delivery-tracking` | CJ대한통운·우체국 송장 번호로 배송 상태 조회 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
| 쿠팡 상품 검색 | `coupang-product-search` | 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 선택사항 (운영 키 있으면 로컬 HMAC 경로, 없으면 hosted fallback) | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
| 오늘의집 오늘의딜 조회 | `ohou-today-deal` | 오늘의집 공개 오늘의딜 특가 상품의 할인율·가격·리뷰·링크 조회 | 불필요 | [오늘의집 오늘의딜 조회 가이드](docs/features/ohou-today-deal.md) |
| 번개장터 검색 | `bunjang-search` | 번개장터 검색, 상세조회, 선택적 찜/채팅, AI TOON export | 불필요 | [번개장터 검색 가이드](docs/features/bunjang-search.md) |
| 당근 중고거래 검색 | `daangn-used-goods-search` | 당근 중고거래 공개 웹 데이터 표면으로 키워드·지역 기반 매물 검색과 상세 조회 | 불필요 | [당근 중고거래 검색 가이드](docs/features/daangn-used-goods-search.md) |
| 당근부동산 검색 | `daangn-realty-search` | 당근부동산 공개 웹 데이터 표면으로 지역 기반 부동산 매물 검색과 상세 확인 | 불필요 | [당근부동산 검색 가이드](docs/features/daangn-realty-search.md) |
@ -150,10 +157,13 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](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)
- [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md)
- [SH 청약·주택 공고문 조회 가이드](docs/features/sh-notice-search.md)
- [법원 경매 부동산 매각공고 조회 가이드](docs/features/court-auction-notice-search.md)
- [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md)
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)
@ -169,6 +179,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md)
- [근처 공영주차장 찾기 가이드](docs/features/parking-lot-search.md)
- [근처 응급실 병상 상태 확인 가이드](docs/features/emergency-room-beds.md)
- [한국 마라톤 일정 조회 가이드](docs/features/korean-marathon-schedule.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [KBL 경기 결과 가이드](docs/features/kbl-results.md)
@ -192,10 +203,13 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [강남언니 병원 조회 가이드](docs/features/gangnamunni-clinic-search.md)
- [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md)
- [올리브영 검색 가이드](docs/features/olive-young-search.md)
- [영화관 검색 가이드](docs/features/korean-cinema-search.md)
- [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md)
- [마이리얼트립 MCP 검색 가이드](docs/features/myrealtrip-search.md)
- [항공권 가격 조회 가이드](docs/features/flight-ticket-search.md)
- [택배 배송조회](docs/features/delivery-tracking.md)
- [쿠팡 상품 검색](docs/features/coupang-product-search.md)
- [오늘의집 오늘의딜 조회](docs/features/ohou-today-deal.md)
- [번개장터 검색 가이드](docs/features/bunjang-search.md)
- [당근 중고거래 검색 가이드](docs/features/daangn-used-goods-search.md)
- [당근부동산 검색 가이드](docs/features/daangn-realty-search.md)

View file

@ -1,6 +1,6 @@
---
name: daiso-product-search
description: Look up Daiso products by store name and product keyword using official Daiso Mall store/search/stock surfaces. Reports whether a product is registered as pickup-eligible at a specific Daiso store; the official store-level pickup quantity API has been blocked since 2026-05-05, so exact per-store stock counts are unavailable while that block remains.
description: Look up Daiso products by store name and product keyword using official Daiso Mall store/search/stock surfaces. Use when the user wants to know whether a product is available at a specific Daiso store.
license: MIT
metadata:
category: retail
@ -17,31 +17,20 @@ metadata:
- 공식 매장 검색으로 매장 코드를 찾는다.
- 공식 상품 검색으로 상품 후보를 찾는다.
- 공식 매장 픽업 재고 표면으로 해당 매장의 재고를 확인한다.
- 다이소몰이 매장 픽업 재고 표면을 `Unauthorized` 로 차단하면 차단 상태를 그대로 보고하고 세션 우회는 시도하지 않는다.
- 매장 픽업 재고가 차단되면 공식 픽업 가능 매장 목록(`selPkupStr`) 으로 해당 매장에 상품이 픽업 가능 매장으로 등록되어 있는지 여부를 확인해 `pickupEligibility` 로 답한다. 정확한 수량은 여전히 알 수 없다.
- **공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로만 답한다.**
## When to use
- "강남역2호점에서 리들샷 픽업 가능해?" (픽업 가능 여부 확인)
- "이 상품 어느 매장에서 픽업 가능한지 확인해줘" (픽업 가능 매장 목록)
- "다이소 매장명 주면 그 매장에서 살 수 있는지 봐줘"
- 공식 매장 픽업 재고 API 가 응답하면 수량까지, 차단되면 픽업 가능 여부(yes/no)까지
- "강남역2호점에 리들샷 있어?"
- "다이소 특정 매장 재고 확인해줘"
- "이 상품 어느 매장에 있는지 확인해줘"
- "다이소 매장명 주면 그 매장 재고 봐줘"
## When not to use
- **"강남역2호점에 리들샷 몇 개 있어?"** 처럼 정확한 재고 수량을 보장해야 하는 경우 — 2026-05-05 부터 공식 매장 픽업 재고 API 가 `Unauthorized` 로 차단되어 수량을 답할 수 없다.
- 매장명도 상품명도 전혀 없는 상태에서 바로 재고를 단정해야 하는 경우
- 결제/주문/픽업 예약까지 자동화해야 하는 경우
- 매장 내 진열 위치(aisle/매대)를 알려줘야 하는 경우
- 비공식 크롤링·세션 우회·계정 로그인 우회 결과를 사용해야 하는 경우
## Scope and limits (must read before answering)
- `pickupStock``retrievalStatus: "resolved"` 로 응답하면 정확한 매장 픽업 재고 수량을 줄 수 있다.
- `pickupStock``retrievalStatus: "blocked"` 면 수량은 더 이상 답하지 않는다. `pickupEligibility.pickupEligible` 로 그 매장에서 픽업 가능한 상품인지(yes/no)만 답한다.
- `onlineStock``referenceOnly: true` 다이소몰 온라인몰 재고 참고값일 뿐 매장 재고가 아니다. 매장 재고처럼 단정하지 않는다.
- 차단 우회는 시도하지 않는다.
- 비공식 크롤링 결과를 우선해야 하는 경우
## Prerequisites
@ -73,8 +62,9 @@ metadata:
- product search summary: `https://www.daisomall.co.kr/ssn/search/Search`
- product search list: `https://www.daisomall.co.kr/ssn/search/SearchGoods`
- product summary list: `https://www.daisomall.co.kr/ssn/search/GoodsMummResult`
- store pickup stock: `https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck`
- store pickup eligibility (pickup-capable stores for a product): `https://www.daisomall.co.kr/api/ms/msg/selPkupStr`
- auth (비로그인 JWT 발급): `https://www.daisomall.co.kr/api/auth/request`
- store pickup stock: `https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck` ← **인증 필요**
- pickup eligibility fallback: `https://www.daisomall.co.kr/api/ms/msg/selPkupStr`
- optional online stock cross-check: `https://www.daisomall.co.kr/api/pdo/selOnlStck`
## Workflow
@ -118,7 +108,17 @@ console.log(productResult.items)
### 3. Check the store pickup stock
공식 매장 픽업 재고 API로 해당 매장의 재고를 확인한다. 2026-05-05 기준 이 엔드포인트가 `Unauthorized` 로 차단될 수 있으므로, `stock.retrievalStatus === "blocked"` 또는 `stock.status === "unavailable"` 이면 정확한 매장 수량을 단정하지 않는다. `stock.status` 는 조회 결과 범주이고, 실제 재고 여부는 `stock.inStock` 또는 `stock.inventoryStatus` 로 판단한다.
`selStrPkupStck``Authorization` 헤더 없이 호출하면 **403**을 반환한다.
로그인 없이 `/api/auth/request`로 비로그인 JWT를 발급받아 AES-CBC로 암호화한 뒤 Bearer 헤더로 전달한다.
**Bearer 토큰 생성 방법:**
1. `GET /api/auth/request` → 응답 바디: JWT 평문, 응답 헤더 `x-dm-uid` 보존 (유효 30초)
2. 랜덤 16바이트 IV 생성 후 JWT를 AES-128-CBC / PKCS7 / 키 `"PRE_AUTH_ENC_KEY"`로 암호화
3. `bearer = base64(IV) + base64(암호문)` 으로 조합 후 `Authorization: Bearer <bearer>`, `X-DM-UID: <uid>` 헤더로 전달
바디는 `{pdNo, strCd}` 쌍 배열로 여러 매장을 한 번에 조회할 수 있다.
응답의 `stck` 필드가 `"0"` 또는 빈 값이면 재고 없음.
```js
const { getStorePickupStock } = require("daiso-product-search")
@ -129,29 +129,9 @@ const stock = await getStorePickupStock({
})
console.log(stock)
// 품절 예시: { status: "available", retrievalStatus: "resolved", inventoryStatus: "out_of_stock", quantity: 0, inStock: false }
// 차단 예시: { status: "unavailable", retrievalStatus: "blocked", inventoryStatus: "unknown", reason: "unauthorized", quantity: null, inStock: null }
```
### 4. Fall back to pickup eligibility when stock is blocked
매장 픽업 재고가 `Unauthorized` 로 차단되면 공식 픽업 가능 매장 목록 표면으로 **해당 매장이 그 상품의 픽업 가능 매장에 들어 있는지** 만이라도 확인할 수 있다. 수량은 알 수 없지만 "그 매장에서 이 상품을 픽업으로 살 수 있는지" 는 답할 수 있다.
```js
const { getStorePickupEligibility } = require("daiso-product-search")
const eligibility = await getStorePickupEligibility({
pdNo: "1049275",
strCd: "10224",
storeName: "강남역2호점"
})
console.log(eligibility)
```
`pickupEligible``true` 이면 그 매장에서 픽업 가능, `false` 면 픽업 불가, `null` 이면 확인 불가다. `false` 는 검색 범위가 충분할 때만 확정값으로 해석한다. `retrievalStatus: "insufficient_coverage"` 는 매장명/키워드가 없거나 첫 페이지가 전체 결과를 덮지 못해 부재를 증명하지 못했다는 뜻이다. `eligibleStoreCount``eligibleStores` 로 다른 후보 매장도 함께 보여줄 수 있다.
### 5. Use the end-to-end helper when both names are already known
### 4. Use the end-to-end helper when both names are already known
```js
const { lookupStoreProductAvailability } = require("daiso-product-search")
@ -164,27 +144,23 @@ const result = await lookupStoreProductAvailability({
console.log(result.selectedStore)
console.log(result.selectedProduct)
console.log(result.pickupStock)
console.log(result.pickupEligibility)
```
`pickupStock.retrievalStatus === "blocked"` 일 때만 `pickupEligibility` 가 채워진다. `includePickupEligibility: false` 옵션으로 끌 수 있다.
### 6. Respond conservatively
### 5. Respond conservatively
응답은 짧고 명확하게 정리한다.
- 매장명
- 상품명
- 매장 재고 수량, 재고 없음, 또는 `retrievalStatus: "blocked"` / `Unauthorized` 로 인한 확인 불가
- 픽업 재고가 차단된 경우 `pickupEligibility.pickupEligible` 로 그 매장의 픽업 가능 여부만이라도 표시
- 필요하면 `referenceOnly: true` 로 표시된 온라인 재고 참고값
- 매장 재고 수량 또는 재고 없음
- 필요하면 온라인 재고 참고값
- **공식 표면이 매장 내 진열 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 분명히 말한다.**
## Done when
- 매장명과 상품명이 모두 확인되었다.
- 공식 표면으로 매장 후보와 상품 후보를 찾았다.
- 공식 매장 재고 결과 또는 `Unauthorized` 차단 상태를 최소 1회 반환했다.
- 공식 매장 재고 결과를 최소 1회 반환했다.
- 위치 정보가 없으면 없다고 분명히 고지했다.
## Failure modes
@ -192,11 +168,15 @@ console.log(result.pickupEligibility)
- 매장명이 너무 넓으면 같은 상권의 여러 지점이 동시에 잡힐 수 있다.
- 상품명이 너무 넓으면 다른 용량/호수 후보가 많이 섞일 수 있다.
- 공식 재고는 시점 차이로 실제 방문 시 수량이 달라질 수 있다.
- `selStrPkupStck``Unauthorized` 로 차단되면 매장 픽업 수량은 확인 불가로 답하고, 온라인 재고를 매장 재고처럼 단정하지 않는다.
- 현재 확인된 공식 표면은 **매장 내 aisle/진열 위치**를 직접 주지 않을 수 있다.
- `selStrPkupStck` 403 → `/api/auth/request` 재호출 후 Bearer를 새로 빌드해 재시도한다.
- Bearer 재시도 후에도 401/403이면 재고 수량은 `retrievalStatus: "blocked"` 로 표시하고, `selPkupStr` 기반 `pickupEligibility`(픽업 가능 여부)만 보조 정보로 제공한다.
## Notes
- 조회형 스킬이다.
- 공식 표면 우선 원칙을 유지한다.
- 공식 표면이 위치를 주지 않으면 억지 추정을 하지 않는다.
- 인증 키(`PRE_AUTH_ENC_KEY`)는 JS 번들에 하드코딩되어 있으며 변경될 수 있다.
- `selStrPkupStck` 호출 시: `/api/auth/request` 호출 후 Bearer를 만들어 시도한다.
- fallback order: Bearer 재고 조회 → 401/403 시 토큰 재발급 후 1회 재시도 → 구조화된 blocked 재고 → 선택적 `selPkupStr` 픽업 가능 여부.

View file

@ -49,7 +49,7 @@ metadata:
- 상품 상세 페이지: `https://prod.danawa.com/info/?pcode=...`
- 가격비교 AJAX: `https://prod.danawa.com/info/ajax/getAllPriceCompareMallList.ajax.php`
AJAX endpoint는 HTML fragment를 반환한다. helper는 `.diff_item`, 쇼핑몰 로고 `alt`, `em.prc_c`/`em.prc_t`, 배송 문구, 카드 할인 라인, 무이자 할부 레이어, 다나와 bridge link를 파싱한다.
AJAX endpoint는 HTML fragment를 반환한다. helper는 `.diff_item`, 쇼핑몰 로고 `alt`, `em.prc_c`/`em.prc_t`, 배송 문구, 결제조건 배지(`.ico.cash`/`.ico.point`/`.ico.coupon`/`.ico.discount`/`.ico.card`/`.ico.membership` 등), 카드 할인 라인, 무이자 할부 레이어, 다나와 bridge link를 파싱한다.
## Commands
@ -94,10 +94,15 @@ helper는 JSON만 출력한다. 결과를 확인한 뒤 사용자에게는 한
"title": "...",
"source_url": "...",
"count": 0,
"offers": []
"normal_count": 0,
"conditional_count": 0,
"offers": [],
"meta": { "sort": "total_price" }
}
```
`offers[]`는 **배송비 포함 실구매가(`total_price`) 오름차순**으로 정렬된다. `count` / `normal_count` / `conditional_count``limit` 적용 후 실제 반환된 `offers[]` window 기준이다. 결제조건(현금/쿠폰/포인트/할인/특정카드/멤버십 한정)이 붙은 row도 같은 정렬에 그대로 참여한다 — 가장 싸면 1위로 올라온다. 결제조건은 분리 그룹이나 추가 필터링 없이 row 단위 `payment_badges` / `payment_condition_types` / `payment_condition_label` / `cash_only` / `point_only` / `coupon_only` / `card_only_badge` / `discount_badge` / `membership_badge` / `is_conditional_price` 필드로 노출한다. 호출자는 사용자의 결제 수단에 따라 직접 판단한다.
`offers[]` 주요 필드:
- `mall`
@ -111,9 +116,19 @@ helper는 JSON만 출력한다. 결과를 확인한 뒤 사용자에게는 한
- `card_discount`, `card_discount_text`
- `installment`
- `installment_detail`
- `payment_badges` — Danawa가 가격 옆에 노출한 결제조건 배지의 표시 라벨 목록. 배지 텍스트가 비어 있고 `.ico.cash`처럼 클래스만 있는 경우도 정규화 라벨을 합성한다 (예: `["현금"]`, `["포인트"]`, `["쿠폰"]`, `["카드"]`, `["할인"]`, `["멤버십"]`)
- `payment_condition_types` — 화이트리스트 배지를 정규화한 조건 타입 목록 (`cash`/`point`/`coupon`/`card`/`discount`/`membership`)
- `payment_condition_label` — 사용자 응답용 결제조건 라벨 (예: `현금`, `할인`, `멤버십`, 복수 조건이면 `현금, 할인`)
- `cash_only` — 현금 결제 전용가
- `point_only` — 포인트 차감 적용가
- `coupon_only` — 쿠폰 적용가
- `card_only_badge` — 특정 카드 한정 노출가
- `discount_badge` — 할인 조건 배지 노출가
- `membership_badge` — 멤버십 조건 배지 노출가
- `is_conditional_price``payment_condition_types`가 하나 이상 있으면 True. **일반 결제가가 아니므로 카드 일반 결제 시 가격이 다르거나 불가능할 수 있음**
- `url`
항상 무료배송 여부, 배송비 포함 실구매가, 카드별 할인 가격, 무이자 할부 문구를 함께 확인한다.
항상 무료배송 여부, 배송비 포함 실구매가, 카드별 할인 가격, 무이자 할부 문구, **그리고 `payment_badges`/`payment_condition_label`/`is_conditional_price`를 함께 확인한다.** 조건부 가격을 일반가처럼 1위로 노출하면 비교 결과가 거짓이 된다.
### `compare`
@ -124,17 +139,20 @@ helper는 JSON만 출력한다. 결과를 확인한 뒤 사용자에게는 한
Discord/Telegram/chat 응답에서는 표 형식을 우선한다.
```md
| 순위 | 판매처 | 상품가 | 배송 | 실구매가 | 카드할인가 | 무이자 | 링크 |
|---:|---|---:|---|---:|---:|---|---|
| 1 | G마켓 | 217,950원 | 무료배송 | 217,950원 | - | 최대 24개월 | 보기 |
| 2 | 옥션 | 305,722원 | 무료배송 | 305,722원 | 우리카드 303,720원 | 최대 24개월 | 보기 |
| 순위 | 판매처 | 상품가 | 결제조건 | 배송 | 실구매가 | 카드할인가 | 무이자 | 링크 |
|---:|---|---:|---|---|---:|---:|---|---|
| 1 | 킴스클럽 | 979,000원 | **현금 전용** | 유/무료 | 979,000원 | - | - | 보기 |
| 2 | 롯데ON | 1,073,890원 | 일반 | 무료배송 | 1,073,890원 | - | - | 보기 |
| 3 | G마켓 | 1,089,590원 | 일반 | 무료배송 | 1,089,590원 | - | 최대 24개월 | 보기 |
| 4 | 옥션 | 1,121,780원 | **쿠폰 적용가** | 무료배송 | 1,121,780원 | 우리카드 303,720원 | 최대 24개월 | 보기 |
```
정렬 기준:
1. 기본 순위는 `total_price` 오름차순이다.
1. **`total_price` 오름차순 단일 기준.** 결제조건(현금/쿠폰/포인트/할인/특정카드/멤버십 한정)이 붙은 row도 같은 정렬에 그대로 참여한다 — 가장 싸면 1위로 올라온다. 결제조건은 분리 그룹화하지 않고 표의 "결제조건" 컬럼에 행별로 표시한다 (`payment_condition_label`이 있으면 그 값을 우선 표시, 없으면 "일반"; 세부 매핑은 `cash` → "현금 전용", `coupon` → "쿠폰 적용가", `point` → "포인트 적용가", `card` → 카드명/카드 조건, `discount` → "할인 조건", `membership` → "멤버십 조건"). 사용자는 자기 결제 수단에 따라 직접 판단한다.
2. `card_price`가 있고 카드 적용 시 승자가 바뀌면 표 아래에 "카드 기준 최저가"를 별도로 적는다.
3. 무이자 할부는 결제 조건이 달라질 수 있으므로 Danawa 노출 문구 기준이라고 밝힌다.
4. 1위가 조건부 가격이면 요약 문장에 결제수단 단서를 짧게 덧붙인다. 예: "**최저 실구매가: 킴스클럽 979,000원 / 현금 결제 한정**, 카드 결제 기준 최저가는 롯데ON 1,073,890원". 카드 결제 가능한 최저가도 같이 알려 사용자가 결제수단별 결과를 한 번에 비교할 수 있게 한다.
요약 예시:
@ -152,14 +170,15 @@ Discord/Telegram/chat 응답에서는 표 형식을 우선한다.
2. `python scripts/danawa_search.py search "<검색어>" --limit 5`로 후보를 확인한다.
3. 후보가 명확하면 해당 `pcode``offers`를 실행한다.
4. 후보가 애매하면 상위 3~5개 상품명/가격/`pcode`를 보여주고 선택을 요청한다.
5. 오퍼는 배송비 포함 실구매가 기준으로 표 정렬한다.
6. 카드 할인가가 있으면 카드 기준 최저가도 별도 요약한다.
5. 오퍼는 **`total_price` 오름차순 단일 기준으로 정렬한다 (결제조건 분리 그룹화하지 않음).** 결제조건은 표의 "결제조건" 컬럼과 row 단위 플래그로만 표기하고, 1위가 현금/쿠폰가여도 그대로 1위로 노출한다.
6. 카드 할인가가 있으면 카드 기준 최저가도 별도 요약한다. 1위가 조건부 가격이면 "카드 결제 기준 최저가"도 요약 문장에 함께 적어 결제수단별 최저가를 한 번에 알게 한다.
7. 조회 시점 기준이며 가격/배송/카드 혜택은 변동될 수 있음을 짧게 덧붙인다.
## Failure modes
- 검색 결과가 0개면 검색어를 더 구체화한다.
- Danawa HTML/AJAX 구조가 바뀌면 selector가 깨져 `offers`가 비거나 필드가 누락될 수 있다.
- 다나와가 새로운 결제조건 배지 클래스나 문구를 도입하면 결제조건 배지 화이트리스트(`cash`/`point`/`coupon`/`discount`/`card`/`membership` 클래스, `현금`/`포인트`/`쿠폰`/`할인`/`카드`/`멤버십` 텍스트 키워드)와 `payment_condition_types`/`payment_condition_label` 매핑을 함께 갱신해야 한다.
- 검색 결과 가격과 오퍼 AJAX 가격은 갱신 시점·카드가·제휴 링크 기준 차이로 다를 수 있다.
- 카드 할인과 무이자 문구는 Danawa가 노출한 경우에만 확정적으로 보여준다.
- 공개 표면 기반이므로 고빈도 요청에는 throttling/backoff를 추가해야 한다.

View file

@ -203,6 +203,43 @@ def offers(pcode: str, limit: int = 20, include_shipping: bool = False) -> Dict[
installment_el = div.select_one(".btn_foi .txt")
installment_detail_el = div.select_one(".foi_layer .ly_cont")
link = div.select_one("a.priceCompareBuyLink")
# 결제조건 ico만 캡처. 다른 ico(빠른배송, 안내, 상품리뷰 등)는 노이즈라 제외.
# 클래스만 있고 텍스트가 비어 있는 아이콘도 row 라벨이 누락되지 않도록
# 같은 정규화 테이블에서 표시 라벨/타입/boolean 필드를 모두 파생한다.
payment_condition_labels = {
"cash": "현금",
"point": "포인트",
"coupon": "쿠폰",
"card": "카드",
"discount": "할인",
"membership": "멤버십",
}
payment_condition_types: List[str] = []
payment_badges: List[str] = []
for el in div.select(".prc_line .ico, .d_dsc .ico"):
classes = set(el.get("class") or [])
text = clean_text(el.get_text(" ", strip=True)) or ""
matched_types = [
kind
for kind, label in payment_condition_labels.items()
if kind in classes or label in text
]
if not matched_types:
continue
for kind in matched_types:
if kind not in payment_condition_types:
payment_condition_types.append(kind)
label = payment_condition_labels[kind]
if label not in payment_badges:
payment_badges.append(label)
cash_only = "cash" in payment_condition_types
point_only = "point" in payment_condition_types
coupon_only = "coupon" in payment_condition_types
card_only_badge = "card" in payment_condition_types
discount_badge = "discount" in payment_condition_types
membership_badge = "membership" in payment_condition_types
payment_condition_label = ", ".join(payment_badges) or None
is_conditional_price = bool(payment_condition_types)
rows.append(
{
"mall": clean_text(mall),
@ -220,12 +257,44 @@ def offers(pcode: str, limit: int = 20, include_shipping: bool = False) -> Dict[
"card_discount_text": f"{price - card_price:,}" if card_price else None,
"installment": clean_text(installment_el.get_text(" ", strip=True)) if installment_el else None,
"installment_detail": clean_text(installment_detail_el.get_text(" ", strip=True)) if installment_detail_el else None,
"payment_badges": payment_badges,
"cash_only": cash_only,
"point_only": point_only,
"coupon_only": coupon_only,
"card_only_badge": card_only_badge,
"discount_badge": discount_badge,
"membership_badge": membership_badge,
"payment_condition_types": payment_condition_types,
"payment_condition_label": payment_condition_label,
"is_conditional_price": is_conditional_price,
"url": abs_url(link.get("href") if link else None),
}
)
rows.sort(key=lambda row: (row["total_price"] is None, row["total_price"] or row["price"], row["price"], row["mall"] or ""))
# 정렬은 단순히 배송비 포함 실구매가 오름차순. 결제조건(현금/쿠폰/포인트/특정카드)은
# 분리 그룹으로 묶지 않고 row 단위로 payment_badges / payment_condition_types /
# payment_condition_label 및 세부 boolean 플래그로 노출한다. 호출자(또는 사용자)는 자기 결제수단에 맞춰 판단한다.
rows.sort(key=lambda row: (
row["total_price"] is None,
row["total_price"] or row["price"],
row["price"],
row["mall"] or "",
))
rows = rows[:limit]
return {"pcode": str(pcode), "title": meta.get("sProductFullName"), "source_url": meta["source_url"], "count": len(rows), "offers": rows, "meta": {"extraction": "danawa-price-ajax", "include_shipping": include_shipping, "sort": "total_price", "ts": int(time.time())}}
return {
"pcode": str(pcode),
"title": meta.get("sProductFullName"),
"source_url": meta["source_url"],
"count": len(rows),
"normal_count": sum(1 for r in rows if not r.get("is_conditional_price")),
"conditional_count": sum(1 for r in rows if r.get("is_conditional_price")),
"offers": rows,
"meta": {
"extraction": "danawa-price-ajax",
"include_shipping": include_shipping,
"sort": "total_price",
"ts": int(time.time()),
},
}
def compare(query: str, limit: int, offer_limit: int) -> Dict[str, Any]:

View file

@ -4,14 +4,11 @@
- 다이소 매장명으로 공식 매장 후보 찾기
- 상품명/검색어로 공식 상품 후보 찾기
- 특정 매장의 **매장 픽업 재고 수량** 확인 (공식 `selStrPkupStck` 표면이 응답할 때 한정)
- 매장 픽업 재고가 `Unauthorized` 로 차단되면 `retrievalStatus: "blocked"` 차단 상태를 명확히 표시하고, 공식 픽업 가능 매장 목록(`selPkupStr`)으로 그 매장의 **픽업 가능 여부(yes/no)** 만이라도 `pickupEligibility` 로 확인
- 특정 매장의 **매장 픽업 재고 수량** 확인 (Bearer 토큰 인증 기반 공식 `selStrPkupStck` 표면)
- 필요하면 `referenceOnly: true` 온라인 재고 참고값 함께 확인
## 이 기능으로 할 수 없는 일 (스킬 범위 한계)
- **`selStrPkupStck` 가 차단된 동안에는 정확한 매장별 재고 수량을 답할 수 없습니다.** 2026-05-05 부터 공식 매장 픽업 재고 API 가 `Unauthorized (401/403)` 로 차단되어 있고, 이 스킬은 세션 우회·CAPTCHA 우회·로그인 강제 등 anti-bot 우회를 시도하지 않습니다.
- 차단 상태에서는 `pickupEligibility.pickupEligible` 로 "그 매장이 그 상품의 픽업 가능 매장으로 등록되어 있는지(yes/no)" 까지만 답합니다. **수량(예: "3개 남음")은 답하지 않습니다.**
- 매장 내 진열 위치(aisle/매대)는 공식 표면이 제공하지 않으므로 답하지 않습니다.
- 결제·주문·픽업 예약 자동화는 범위가 아닙니다.
- 비공식 크롤링·헤드리스 브라우저 우회·계정 세션 재사용은 범위가 아닙니다.
@ -36,8 +33,9 @@
- store detail: `https://www.daisomall.co.kr/api/dl/dla-api/selStrInfo`
- product search list: `https://www.daisomall.co.kr/ssn/search/SearchGoods`
- product summary list: `https://www.daisomall.co.kr/ssn/search/GoodsMummResult`
- store pickup stock: `https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck`
- store pickup eligibility (특정 상품의 픽업 가능 매장 목록): `https://www.daisomall.co.kr/api/ms/msg/selPkupStr`
- auth (비로그인 JWT 발급): `https://www.daisomall.co.kr/api/auth/request`
- store pickup stock: `https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck` (Bearer 인증 필요)
- pickup eligibility fallback: `https://www.daisomall.co.kr/api/ms/msg/selPkupStr`
- optional online stock: `https://www.daisomall.co.kr/api/pdo/selOnlStck`
## 기본 흐름
@ -46,11 +44,12 @@
2. 상품명이 없으면 상품명/검색어를 한 번 더 물어봅니다.
3. `selStr` 로 매장 후보를 찾고, 필요하면 `selStrInfo` 로 매장 상세를 확인합니다.
4. `SearchGoods` 로 상품 후보를 찾습니다.
5. `selStrPkupStck` 로 해당 매장의 상품 재고를 확인합니다.
6. `selStrPkupStck``Unauthorized` 로 차단되면 매장 픽업 재고는 `unavailable/blocked/unauthorized` 로 보고하고 세션 우회를 시도하지 않습니다.
7. 6번 차단이 발생하면 공식 `selPkupStr` 표면으로 그 상품의 **픽업 가능 매장 목록**을 받아 사용자가 고른 매장이 그 안에 들어 있는지(=`pickupEligibility.pickupEligible`) 만이라도 답합니다. 수량은 여전히 알 수 없습니다.
8. 필요하면 `SearchGoods` 응답의 `onldPdNo` 를 함께 보존해 `selOnlStck` 온라인 재고 교차 확인에 사용합니다.
9. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
5. `GET /api/auth/request` 로 비로그인 JWT를 받아 AES-128-CBC / 키 `"PRE_AUTH_ENC_KEY"` 로 암호화한 뒤 Bearer 헤더를 빌드합니다.
6. `selStrPkupStck` 에 Bearer 헤더를 실어 해당 매장의 상품 재고를 확인합니다.
7. 403 응답이 오면 `/api/auth/request` 를 재호출해 Bearer를 새로 빌드한 뒤 한 번 재시도합니다.
8. Bearer 재시도 후에도 401/403이면 `pickupStock.retrievalStatus: "blocked"` 를 반환하고, 선택적으로 `selPkupStr` 기반 `pickupEligibility` 로 픽업 가능 여부를 보조 확인합니다.
9. 필요하면 `SearchGoods` 응답의 `onldPdNo` 를 함께 보존해 `selOnlStck` 온라인 재고 교차 확인에 사용합니다.
10. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
## 예시
@ -67,7 +66,6 @@ async function main() {
store: result.selectedStore,
product: result.selectedProduct,
pickupStock: result.pickupStock,
pickupEligibility: result.pickupEligibility,
onlineStock: result.onlineStock
})
}
@ -85,18 +83,19 @@ main().catch((error) => {
- 재고 수량은 실시간 100% 보장값이 아니므로, 필요하면 `방문 직전 다시 확인` 문구를 같이 줍니다.
- 공식 표면이 매장 내 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 답합니다.
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 상품 재고 여부는 `inStock` 또는 `inventoryStatus` 로 설명하고, `status: "available"` 만으로 재고가 있다고 말하지 않습니다.
- 매장 픽업 재고가 `Unauthorized` 로 차단된 경우에는 `다이소몰이 현재 매장 픽업 재고 API를 차단해 정확한 매장 재고 수량은 확인할 수 없다`고 답하고, 결과의 `retrievalStatus: "blocked"` 와 온라인 재고의 `referenceOnly: true` 참고값을 구분합니다.
- 픽업 재고가 차단되어도 `pickupEligibility.pickupEligible === true``이 상품은 해당 매장의 픽업 가능 매장 목록에 등록되어 있어 픽업 자체는 가능합니다. 다만 정확한 수량은 확인할 수 없습니다.` 정도로 보수적으로 답합니다. `pickupEligible === false``해당 매장은 이 상품의 픽업 가능 매장에 등록되어 있지 않습니다.` 라고 답합니다. `null` 이면 차단 또는 `insufficient_coverage` 로 확인 불가로 답하고, 특히 검색 키워드가 없거나 첫 페이지가 전체 결과를 덮지 못한 경우에는 불가로 단정하지 않습니다.
- 인증 키(`PRE_AUTH_ENC_KEY`)는 JS 번들에 하드코딩되어 있으며 변경될 수 있습니다. 403이 지속되면 키가 교체된 것일 수 있습니다.
## 라이브 확인 메모
2026-03-27 기준으로 `selStrPkupStck` 는 실제 매장 픽업 재고를 반환했지만, 2026-05-05 기준 이 엔드포인트가 `Unauthorized` 로 차단되는 사례가 확인되었습니다.
2026-03-27 기준으로 `selStrPkupStck` 는 실제 매장 픽업 재고를 반환했습니다.
2026-05-15 기준 Bearer 토큰 인증(`/api/auth/request` + AES-128-CBC)으로 정상 접근 가능합니다.
현재 운영 원칙은 다음과 같습니다.
- `POST /api/ms/msg/selStr` → 매장 후보 확인
- `GET /ssn/search/SearchGoods?searchTerm=...` → 상품 후보 및 `onldPdNo` 확인
- `POST /api/pd/pdh/selStrPkupStck` → 성공하면 `status: "available"`, `retrievalStatus: "resolved"` 로 조회 성공을 표시하고, 실제 재고 여부는 `inStock` / `inventoryStatus` 로 표시
- `selStrPkupStck``401`/`403` 또는 `{ "success": false, "message": "Unauthorized" }` 를 반환하면 `status: "unavailable"`, `retrievalStatus: "blocked"`, `inventoryStatus: "unknown"`, `reason: "unauthorized"` 로 표시
- `POST /api/ms/msg/selPkupStr` → 픽업 재고가 차단되면 호출. 매장 픽업 가능 매장 목록을 받아 `pickupEligibility.pickupEligible`(true/false/null), `eligibleStoreCount`, `eligibleStores`, `matchedStore`, `searchedKeyword`, `totalCount` 로 응답 (수량 미제공). 검색 범위가 불충분하면 `retrievalStatus: "insufficient_coverage"``pickupEligible: null` 을 반환합니다.
- `GET /api/auth/request` → 비로그인 JWT 발급, 헤더 `x-dm-uid` 보존 (유효 30초)
- JWT를 AES-128-CBC / 키 `"PRE_AUTH_ENC_KEY"` 로 암호화 → `bearer = base64(IV) + base64(암호문)` 조합
- `POST /api/pd/pdh/selStrPkupStck` + `Authorization: Bearer <bearer>`, `X-DM-UID: <uid>` → 성공 시 `status: "available"`, `retrievalStatus: "resolved"`. 실제 재고 여부는 `inStock` / `inventoryStatus` 로 표시
- 403 → `/api/auth/request` 재호출 후 Bearer 재빌드 후 1회 재시도
- `POST /api/pdo/selOnlStck` → 가능한 경우 온라인 재고 참고값 표시

View file

@ -36,8 +36,15 @@ python3 danawa-price-search/scripts/danawa_search.py compare "갤럭시 S25" --l
- `card_price`: 카드 적용 표시가
- `card_discount`: 표시가와 카드가 차액
- `installment`: 무이자 할부 문구
- `payment_badges`: Danawa가 가격 옆에 노출한 결제조건 배지의 표시 라벨 목록. 배지 텍스트가 비어 있고 `.ico.cash`처럼 클래스만 있는 경우도 정규화 라벨을 합성합니다 (예: `["현금"]`, `["쿠폰"]`, `["포인트"]`, `["카드"]`, `["할인"]`, `["멤버십"]`)
- `payment_condition_types`: 화이트리스트 배지를 정규화한 조건 타입 목록 (`cash`/`point`/`coupon`/`card`/`discount`/`membership`)
- `payment_condition_label`: 사용자 응답용 결제조건 라벨. 복수 조건이면 쉼표로 연결
- `cash_only` / `point_only` / `coupon_only` / `card_only_badge` / `discount_badge` / `membership_badge`: 각각 현금·포인트·쿠폰·특정 카드·할인·멤버십 조건 가격 여부
- `is_conditional_price`: `payment_condition_types`가 하나 이상 있으면 True. 일반 카드 결제로는 가격이 다르거나 적용 불가할 수 있음
- `url`: 다나와 경유 링크
`count`, `normal_count`, `conditional_count``limit` 적용 후 실제 반환된 `offers[]` 기준입니다.
사용자에게는 `total_price` 기준으로 정렬한 Markdown 표를 먼저 보여주고, 카드가는 별도 열에 표시합니다.
## 주의사항
@ -45,3 +52,4 @@ python3 danawa-price-search/scripts/danawa_search.py compare "갤럭시 S25" --l
- 다나와의 공개 HTML/AJAX 구조가 바뀌면 selector와 파싱 규칙을 갱신해야 합니다.
- 자동 구매, 로그인, CAPTCHA 우회, 결제 단계 자동화는 이 스킬의 범위가 아닙니다.
- 동일 상품명이라도 옵션/용량/모델명이 섞일 수 있으므로 검색 후보를 먼저 확인한 뒤 가격비교를 진행합니다.
- 결제조건 배지(현금/쿠폰/포인트/할인/특정 카드/멤버십 한정)는 사용자 응답 표에 반드시 `payment_condition_label` 기반 라벨로 표시해야 합니다. 정렬은 `total_price` 단일 기준이라 조건부 가격이 1위로 올라올 수 있고, 라벨이 없으면 카드 결제 사용자에게 적용 불가능한 가격을 일반 최저가로 안내하게 됩니다.

View file

@ -0,0 +1,65 @@
# 근처 응급실 병상 상태 확인
`emergency-room-beds` 스킬은 사용자가 알려준 위치 기준으로 가까운 응급실을 찾고, E-Gen 공개 응급실 찾기 표면에서 제공하는 응급실/입원실 운영 상태 플래그를 정리한다.
## 핵심 원칙
- 위치를 자동 추적하지 않는다. 위치가 없으면 먼저 현재 위치를 질문한다.
- 데이터 출처는 NEMC/E-Gen 공개 페이지와 E-Gen nearby 응급실 목록 endpoint다.
- E-Gen nearby 목록은 응급실 운영 여부와 입원실/병상 운영 플래그를 제공하지만, 병원별 정확한 실시간 잔여 병상 수나 병상 가동률 수치를 제공하지 않는다.
- 긴급 상황에서는 결과와 별개로 119 또는 병원 대표전화 확인을 안내한다.
## 사용 예
```text
현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 응급실 상태를 찾아볼게요.
```
위치를 받으면 `emergency-room-beds` 패키지의 `searchNearbyEmergencyRoomsByLocationQuery()`를 사용한다.
## Node.js 예시
```js
const { searchNearbyEmergencyRoomsByLocationQuery } = require("emergency-room-beds");
async function main() {
const result = await searchNearbyEmergencyRoomsByLocationQuery("광화문", {
limit: 3,
radius: 5
});
console.log(result.anchor);
console.log(result.items.map((item) => ({
name: item.name,
distanceKm: item.distanceKm,
emergencyRoomOperating: item.bedStatus.emergencyRoomOperating,
inpatientBedsOperating: item.bedStatus.inpatientBedsOperating,
updatedAt: item.updatedAt,
phone: item.phone,
mapUrl: item.mapUrl
})));
console.log(result.meta.bedCountLimitation);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 응답 필드
- 병원명, 거리, 응급의료기관 등급, 병원 유형
- 응급실 운영 여부 (`emergencyRoomOperating`)
- 입원실/병상 운영 플래그 (`inpatientBedsOperating`)
- 권역외상센터/소아전문/소아야간진료 여부
- 주소, 대표전화, 갱신시각, 지도 링크
- 공개 데이터 한계 문구: 정확한 실시간 잔여 병상 수/가동률 미제공
## 참고 표면
- NEMC 모니터링: <https://dw.nemc.or.kr/nemcMonitoring/mainmgr/Main.do>
- E-Gen 응급실 찾기: <https://www.e-gen.or.kr/egen/search_emergency_room.do>
- E-Gen nearby endpoint: `https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`

View file

@ -0,0 +1,179 @@
# 항공권 가격 조회 (`flight-ticket-search`)
[`fast-flights`](https://pypi.org/project/fast-flights/) 라이브러리를 통해 Google Flights 공개 검색 표면을 조회해 항공권 후보, 예약 검색 링크, 날짜·월·연도별 최저가·평균가 비교를 보수적으로 제공하는 스킬입니다. API key, 로그인, 결제, CAPTCHA 우회 없이 무료 공개 표면만 사용합니다.
## 사용 시나리오
- "인천에서 나리타 다음 달 최저가 알려줘"
- "6월 ICN-NRT 월별 비교"
- "올해랑 내년 6월 1일 항공권 가격 비교"
- "ICN-LAX 비즈니스 가격 대략 비교해줘"
- "서울에서 도쿄 왕복 예약 링크 줘"
## 구현 표면
브라우저 자동화나 로그인을 사용하지 않습니다.
1. `fast-flights==2.2` 가 Google Flights 의 공개 검색 결과를 파싱합니다.
2. 예약 링크는 특정 판매자 결제 deep link 가 아니라 **Google Flights 검색 결과 링크**입니다. 실제 구매·결제·좌석 선택은 사용자가 브라우저에서 직접 진행합니다.
3. 첫 실행 시 `~/.cache/k-skill/flight-ticket-search/venv``fast-flights` 가 격리 설치되고 이후 그 venv 로 재실행합니다. 저장소에는 의존성 vendoring 이나 API key 를 두지 않습니다.
## 로컬 실행
### 단일 검색
편도:
```bash
python3 flight-ticket-search/scripts/flight_ticket_search.py search \
--from ICN \
--to NRT \
--date 2026-06-01 \
--adults 1 \
--seat economy \
--limit 5 \
--format markdown
```
왕복:
```bash
python3 flight-ticket-search/scripts/flight_ticket_search.py search \
--from ICN \
--to NRT \
--date 2026-06-01 \
--return-date 2026-06-08 \
--adults 1 \
--seat economy \
--limit 5
```
### 월별 비교
지정 월의 날짜들을 실제 검색해 각 날짜의 최저가·평균가를 비교합니다. 기본은 주 1회 샘플링입니다.
```bash
python3 flight-ticket-search/scripts/flight_ticket_search.py compare-month \
--from ICN \
--to NRT \
--month 2026-06 \
--sample weekly \
--limit 5
```
일별 전체 조회가 필요하면 `--sample daily` 를 씁니다. 28~31 회 요청이 발생하므로 rate limit 보호를 위해 `--sleep` 을 1.5 초 이상 유지합니다.
```bash
python3 flight-ticket-search/scripts/flight_ticket_search.py compare-month \
--from ICN \
--to NRT \
--month 2026-06 \
--sample daily \
--sleep 2 \
--limit 10
```
### 사용자 정의 범위 비교
"다음주부터 2주간", "6월 1일부터 20일까지"처럼 범위를 받을 때 사용합니다.
```bash
python3 flight-ticket-search/scripts/flight_ticket_search.py compare-range \
--from ICN \
--to BKK \
--start-date 2026-06-01 \
--end-date 2026-06-20 \
--step-days 3 \
--limit 5
```
`--step-days 1` 은 일별 비교, `7` 은 주별 비교입니다.
### 연도 비교
같은 월일을 여러 연도에 대해 조회합니다.
```bash
python3 flight-ticket-search/scripts/flight_ticket_search.py compare-years \
--from ICN \
--to NRT \
--years 2026,2027 \
--month-day 06-01 \
--limit 5
```
## 출력 해석
### 단일 검색 응답 주요 필드
- `meta.booking_search_url` — Google Flights 예약 검색 링크
- `meta.price_band` — Google 이 표시하는 `low` / `typical` / `high` 가격 band
- `stats.min_price`, `stats.avg_price`, `stats.max_price`
- `flights[].name`, `departure`, `arrival`, `duration`, `stops`, `price_text`
- `flights[].quality``complete` 또는 `partial` (Google Flights 응답 일부가 누락될 수 있음을 표시)
### 비교 검색 응답 주요 필드
- `stats.min_price` — 샘플 날짜 중 최저가
- `stats.avg_of_daily_min` — 날짜별 최저가의 평균
- `stats.max_of_daily_min` — 날짜별 최저가 중 최고값
- `cheapest_dates[]` — 가장 싼 날짜와 예약 검색 링크
- `rows[]` — 날짜별 성공/실패 및 요약
- `failures[]` — 너무 먼 미래 날짜 등 실패 케이스 (숨기지 않고 보고)
## 입력 가이드
- 출발/도착 공항 IATA 코드: `ICN`, `GMP`, `PUS`, `NRT`, `HND`, `LAX`, `CJU`
- 출발일: `YYYY-MM-DD`
- 선택: 왕복 귀국일, 성인 수(기본 1), 좌석 등급(`economy` / `premium-economy` / `business` / `first`), 비교 샘플 방식(`weekly` / `daily`)
사용자가 도시명만 말하면 IATA 코드를 추론합니다. 흔한 기본값:
- 서울/인천 국제선: `ICN`
- 서울 국내선/제주: `GMP`
- 도쿄: 나리타 `NRT` 또는 하네다 `HND` — 명시 없으면 사용자에게 확인
- 제주: `CJU`
## 예약 링크 정책
- `booking_search_url` 은 Google Flights 검색 URL 입니다.
- 특정 항공사/OTA 결제 단계 deep link 를 자동 추출하거나 클릭하지 않습니다.
- 결제·예약 확정·로그인·여권 정보 입력은 스킬 범위 밖입니다.
- 사용자가 예약까지 원하면 링크를 열어 직접 확인하도록 안내합니다.
## 검증된 노선 (2026-05-10 로컬 프로브 기준)
- 국내선: `GMP-CJU`, `ICN-CJU`
- 동북아: `ICN-NRT`, `ICN-PVG`, `ICN-HKG`, `ICN-TPE`
- 동남아: `ICN-SIN`, `ICN-BKK`
- 중동: `ICN-DXB`
- 북미: `ICN-LAX`, `ICN-JFK`
- 유럽: `ICN-LHR`, `ICN-CDG`, `ICN-FRA`
- 오세아니아: `ICN-SYD`
- 남미: `ICN-GRU`
- 왕복/좌석 등급/성인 다수: `ICN↔NRT`, `GMP↔CJU`, business, 성인 2명
## 실패 모드
- Google Flights HTML/프론트엔드 구조 변경으로 항공사명·시간 파싱이 비거나 `partial` 로 떨어질 수 있습니다.
- 일부 노선은 가격만 나오고 항공편 상세가 누락될 수 있습니다.
- 잘못된 IATA 코드, 동일 출도착 공항, 실제 항공편이 없는 구간은 실패합니다.
- 너무 먼 미래 날짜는 upstream 에 결과가 없을 수 있습니다.
- 비교 기능은 날짜별 실시간 조회라 요청 수가 많습니다. daily 월별 비교는 30 회 안팎의 요청이 발생합니다.
- `fast-flights` fallback 이 외부 fetch helper 를 쓰는 경우 `401 no token provided` 가 날 수 있어, 동일 입력의 실사용성이 낮은 케이스면 사전 validation 으로 막거나 잠시 후 재시도합니다.
- Skyscanner: CAPTCHA/403 으로 직접 provider 부적합 (사용하지 않음).
- Kiwi Tequila API: 무료 계정 API key 가 필요해 기본 no-key 경로에서는 사용하지 않습니다.
## 비범위
- 실제 예약/결제/취소/좌석 지정 자동화
- 로그인 회원가, 카드 할인, 쿠폰, 마일리지 적용가 확정
- CAPTCHA, fingerprint, bot-block 우회
- 스카이스캐너 직접 조회 (CAPTCHA/403 으로 안정 provider 가 아님)
## 출처
- 스킬 정의: [`flight-ticket-search/SKILL.md`](../../flight-ticket-search/SKILL.md)
- 헬퍼 스크립트: [`flight-ticket-search/scripts/flight_ticket_search.py`](../../flight-ticket-search/scripts/flight_ticket_search.py)
- `fast-flights`: <https://pypi.org/project/fast-flights/>
- Google Flights: <https://www.google.com/travel/flights>

View file

@ -36,6 +36,10 @@ client/skill -> k-skill-proxy -> upstream public API
- `GET /v1/data4library/book-detail` (도서관 정보나루 도서 상세 조회, `DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/libraries-by-book` (도서 소장 도서관 조회, `DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-exists` (도서관별 도서 소장여부, `DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/kstartup/business-info` (창업진흥원 K-Startup 통합공고 지원사업 정보, `DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/announcements` (창업진흥원 K-Startup 지원사업 공고 정보, `DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/contents` (창업진흥원 K-Startup 창업 콘텐츠 정보, `DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/statistics` (창업진흥원 K-Startup 통계보고서 정보, `DATA_GO_KR_API_KEY`)
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
## 권장 환경변수

View file

@ -7,6 +7,7 @@
- 키워드로 전체 대화 검색
- 나와의 채팅으로 안전하게 테스트 전송
- 사용자 확인 후 특정 채팅방으로 메시지 전송
- 로컬 메시지 ID로 후보를 고른 뒤 현재 보이는 정확히 하나의 UI bubble 삭제 자동화
## 먼저 필요한 것
@ -32,6 +33,7 @@ mas install 869223134
- 검색 키워드
- 최근 범위(`--since 1h`, `--since 7d` 등)
- 전송 메시지 본문
- 삭제할 메시지 ID 또는 최근 보낸 메시지 삭제 여부
- 테스트 여부(`--me`, `--dry-run`)
## 기본 흐름
@ -41,7 +43,8 @@ mas install 869223134
3. `user_id` 자동 감지가 실패하면 helper `python3 scripts/kakaotalk_mac.py auth --refresh` 로 복구한다.
4. 읽기/검색은 JSON 모드로 실행한 뒤 사람이 읽기 쉽게 요약한다.
5. 전송은 먼저 `--me` 또는 `--dry-run` 으로 테스트한다.
6. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
6. 삭제는 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 을 먼저 실행한다.
7. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
## 예시
@ -57,6 +60,9 @@ kakaocli messages --chat "지수" --since 1d --json
kakaocli search "회의" --json
kakaocli send --me _ "테스트 메시지"
kakaocli send --dry-run "팀 공지방" "오늘 3시에 만나요"
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --dry-run
```
## helper 가 해결하는 문제
@ -74,11 +80,27 @@ helper `scripts/kakaotalk_mac.py` 는 그 얇은 read-only 어댑터 역할을
- 검증된 DB 경로와 SQLCipher key 를 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
- 이후 read-only helper 명령 `chats`, `messages`, `search`, `schema` 를 cached `--db` / `--key` 와 함께 다시 실행한다.
## 메시지 삭제
`delete` / `delete-last` 는 카카오톡 Mac UI의 삭제 메뉴를 Accessibility 로 자동화한다. 메시지 조회와 outbound 여부 검증은 로컬 DB에서 하고, 실제 삭제는 현재 보이는 UI bubble 에서 수행한다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "팀 공지방" --limit 20 --json
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --everyone
```
- `--everyone` 은 내가 보낸 메시지에만 허용된다.
- UI 삭제 단계는 활성 채팅방을 확인하고, 선택된 outbound DB 메시지의 정규화된 텍스트가 대화 transcript 영역에서 정확히 하나의 visible targetable message bubble 과 일치할 때만 진행한다. 로컬 DB message id가 UI bubble identity를 직접 증명하는 것은 아니므로, 메시지 텍스트가 비어 있거나 첨부/비텍스트이거나 보이지 않거나 정규화 후 같은 텍스트가 여러 개이거나 최종 확인 버튼을 클릭할 수 없으면 실패한다.
- `chats`, `messages`, `search`, `schema` 는 read-only 이지만 `delete` / `delete-last` 는 side effect 이다.
## 주의할 점
- **Full Disk Access** 가 없으면 읽기 명령도 실패할 수 있다.
- **Accessibility** 가 없으면 전송과 harvest 계열 자동화가 실패한다.
- **Accessibility** 가 없으면 전송, 삭제, harvest 계열 자동화가 실패한다.
- macOS 전용이므로 Windows/Linux 대체 구현으로 넘어가지 않는다.
- 다른 사람에게 보내는 메시지는 자동 전송하지 말고 확인을 먼저 받는다.
- 삭제 자동화는 되돌리기 어렵기 때문에 `--dry-run` 으로 대상과 범위를 확인한다.
- helper cache 는 로컬 auth material 을 담으므로 본인 장비에서만 보관한다.
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.

View file

@ -0,0 +1,95 @@
# 영화관 검색 가이드
원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) 와 npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 사용해 CGV, 메가박스, 롯데시네마의 영화관 검색, 상영작, 시간표, 잔여석 조회를 한다.
## 가장 중요한 규칙
`k-skill` 안에 별도 영화관 수집기를 추가하지 않는다.
기본 경로는 **MCP 서버를 직접 설치하지 않고 CLI로 먼저 확인하는 방식**이다.
1. `npx --yes daiso ...`
2. 필요하면 `git clone https://github.com/hmmhmmhm/daiso-mcp.git && cd daiso-mcp && npm install && npm run build`
3. clone fallback에서는 `node dist/bin.js ...`
## 빠른 확인
날짜가 있는 요청은 Asia/Seoul 기준 `YYYYMMDD` 로 정규화하고 `--playDate <YYYYMMDD>` 를 항상 붙인다. 예를 들어 오늘을 물으면 KST 오늘 날짜를 계산해서 넣는다.
```bash
npx --yes daiso health
npx --yes daiso get /api/cgv/theaters --keyword 강남 --limit 5 --json
npx --yes daiso get /api/cgv/movies --keyword 강남 --playDate <YYYYMMDD> --json
npx --yes daiso get /api/cgv/timetable --keyword 강남 --playDate <YYYYMMDD> --json
npx --yes daiso get /api/megabox/theaters --keyword 코엑스 --limit 5 --json
npx --yes daiso get /api/megabox/movies --keyword 코엑스 --playDate <YYYYMMDD> --json
npx --yes daiso get /api/megabox/seats --keyword 코엑스 --playDate <YYYYMMDD> --limit 10 --json
npx --yes daiso get /api/lottecinema/theaters --keyword 월드타워 --limit 5 --json
npx --yes daiso get /api/lottecinema/movies --keyword 월드타워 --playDate <YYYYMMDD> --json
npx --yes daiso get /api/lottecinema/seats --keyword 월드타워 --playDate <YYYYMMDD> --limit 10 --json
```
## 원본 저장소 clone fallback
```bash
git clone https://github.com/hmmhmmhm/daiso-mcp.git
cd daiso-mcp
npm install
npm run build
node dist/bin.js health
node dist/bin.js get /api/cgv/theaters --keyword 강남 --limit 5 --json
node dist/bin.js get /api/cgv/timetable --keyword 강남 --playDate <YYYYMMDD> --json
node dist/bin.js get /api/megabox/seats --keyword 코엑스 --playDate <YYYYMMDD> --limit 10 --json
node dist/bin.js get /api/lottecinema/seats --keyword 월드타워 --playDate <YYYYMMDD> --limit 10 --json
```
## 입력값
- 체인: CGV, 메가박스, 롯데시네마
- 지역 또는 지점: 강남, 코엑스, 월드타워 등
- 영화명: 잔여석이나 시간표를 특정 영화로 좁힐 때 사용
- 날짜: 사용자가 날짜를 말하면 그 날짜를 우선하고, 없으면 Asia/Seoul 기준 오늘을 `YYYYMMDD` 로 계산한다.
| 체인 | 후보 조회 | 상영작 | 시간표 또는 잔여석 | 날짜 |
| --- | --- | --- | --- | --- |
| CGV | `keyword`, 선택 `limit` | `keyword` 또는 `theaterId`, `playDate` | `keyword` 또는 `theaterId`, `movieId`, `playDate` | 필수로 명시 |
| 메가박스 | `keyword`, 선택 `limit` | `keyword` 또는 `theaterId`, `playDate` | `keyword` 또는 `theaterId`, `movieId`, `playDate` | 필수로 명시 |
| 롯데시네마 | `keyword`, 선택 `limit` | `keyword` 또는 `theaterId`, `playDate` | `keyword` 또는 `theaterId`, `movieId`, `playDate` | 필수로 명시 |
## 사용 흐름
1. `npx --yes daiso health` 로 endpoint 상태를 확인한다.
2. `/api/cgv/theaters`, `/api/megabox/theaters`, `/api/lottecinema/theaters` 로 영화관 후보를 찾는다.
3. 날짜 표현은 Asia/Seoul 기준 `YYYYMMDD` 로 바꾼다.
4. `/api/cgv/movies`, `/api/megabox/movies`, `/api/lottecinema/movies` 로 상영작을 확인한다.
5. CGV는 `/api/cgv/timetable` 로 시간표를 본다.
6. 메가박스와 롯데시네마는 `/api/megabox/seats`, `/api/lottecinema/seats` 로 잔여석을 본다.
7. 예매와 결제는 자동화하지 않는다.
## 응답 원칙
- 기준 체인과 지점을 먼저 쓴다.
- 상영작과 시간표는 필요한 만큼만 보여준다.
- 잔여석은 조회 시점의 참고값으로 말한다.
- 영화관 공식 앱이나 웹에서 예매 직전 다시 확인하라고 안내한다.
## 실패 모드
- public endpoint가 일시적으로 5xx를 줄 수 있다.
- 넓은 지역 키워드는 여러 지점을 섞을 수 있다.
- 시간표와 잔여석은 빠르게 바뀔 수 있다.
- theaterId, movieId가 있으면 keyword보다 그 값을 우선한다.
## 출처
- 원본 repo: `https://github.com/hmmhmmhm/daiso-mcp`
- npm package: `https://www.npmjs.com/package/daiso`
- CGV theaters API: `https://mcp.aka.page/api/cgv/theaters`
- CGV movies API: `https://mcp.aka.page/api/cgv/movies`
- CGV timetable API: `https://mcp.aka.page/api/cgv/timetable`
- Megabox theaters API: `https://mcp.aka.page/api/megabox/theaters`
- Megabox movies API: `https://mcp.aka.page/api/megabox/movies`
- Megabox seats API: `https://mcp.aka.page/api/megabox/seats`
- Lotte Cinema theaters API: `https://mcp.aka.page/api/lottecinema/theaters`
- Lotte Cinema movies API: `https://mcp.aka.page/api/lottecinema/movies`
- Lotte Cinema seats API: `https://mcp.aka.page/api/lottecinema/seats`

View file

@ -0,0 +1,60 @@
# 창업진흥원 K-Startup 조회 가이드
공공데이터포털 데이터셋 `15125364` (창업진흥원_K-Startup(사업소개,사업공고,콘텐츠 등)_조회서비스) 기반 4개 endpoint를 `k-skill-proxy` 경유로 조회한다. **조회 전용** 이며 사업 신청·결제·계좌 연결은 자동화하지 않는다.
스킬 이름: `kstartup-search`
호출 helper: `kstartup-search/scripts/run_kstartup.py`
## 어떤 데이터를 조회하나
| 서브커맨드 | upstream operation | 설명 |
| --- | --- | --- |
| `business-info` | `getBusinessInformation01` | 통합공고 지원사업 정보 (예산, 규모, 수행기관, 사업절차, 문의처) |
| `announcements` | `getAnnouncementInformation01` | 지원사업 공고 정보 (공고명, 접수기간, 지역, 신청대상, 모집진행여부 등) |
| `contents` | `getContentInformation01` | 창업관련 콘텐츠 (공지·뉴스·우수사례) |
| `statistics` | `getStatisticalInformation01` | 창업관련 통계보고서 |
`announcements` 가 가장 활용도 높다. 지역·대상·기간·모집 진행 여부로 필터링해 답변할 공고 후보를 좁히고, 자세한 신청 절차는 응답의 `detl_pg_url` 로 사용자가 K-Startup 사이트에서 직접 확인한다.
> **주의**: `supt_regin`은 라이브 호출에서 upstream이 서버 측에서 적용하지 않는 사례가 관측됐다 (서울만 요청해도 타 지역 공고가 섞여 돌아온다). 지역 필터가 중요한 답변이라면 helper가 받은 응답 JSON을 client에서 `supt_regin` 으로 한 번 더 거른다.
## 사용자 시크릿
- 일반 조회는 hosted proxy(`https://k-skill-proxy.nomadamas.org`)가 K-Startup 인증키를 서버 측에서 주입한다. 사용자에게 키를 요구하지 않는다.
- `--direct` 사용 시에만 `KSKILL_KSTARTUP_API_KEY` (또는 `DATA_GO_KR_API_KEY` fallback) 가 필요하다.
- 자세한 credential resolution order 는 [공통 설정 가이드](../setup.md) 와 [보안/시크릿 정책](../security-and-secrets.md) 참고.
## 예시
```bash
# 서울 모집 중 공고 5건 (hosted proxy 사용, 사용자 키 불필요)
python3 kstartup-search/scripts/run_kstartup.py announcements \
--supt-regin 서울특별시 --rcrt-prgs-yn Y --per-page 5 --text
# 2024년 사업화 분야 통합공고
python3 kstartup-search/scripts/run_kstartup.py business-info \
--biz-yr 2024 --biz-category-cd cmrczn_Tab3
# 정책/공지 콘텐츠 dry-run (인증 호출 없이 URL 검증만)
python3 kstartup-search/scripts/run_kstartup.py contents \
--clss-cd notice_matr --per-page 10 --dry-run
# 본인 키로 직접 호출
python3 kstartup-search/scripts/run_kstartup.py announcements \
--supt-regin 부산광역시 --direct
```
## 실패 모드 요약
- `400 bad_request`: 잘못된 날짜/Y·N/페이지 범위, 시작일 > 종료일 등 입력 검증 실패.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없거나 `15125364` 활용신청이 미승인 상태.
- `502 upstream_error`: data.go.kr이 `resultCode != "00"` 또는 `errMsg` 를 반환 (API 키 미등록·만료·IP 미등록·요청 초과 등).
- 빈 `data` 배열: 필터에 맞는 공고나 콘텐츠가 없는 경우 → 키워드·지역·대상 범위를 완화한다.
- 데이터 갱신 주기: 공식 서비스설계서는 **일 1회**, 공공데이터포털 dataset 메타데이터에는 "실시간" 으로 표기돼 있다. 두 표면이 일치하지 않으니 분 단위 마감 시계열에는 쓰지 말고, 최종 마감·접수 상태는 응답의 `detl_pg_url` 에서 직접 확인한다.
## 한도와 출처
- 일 호출 한도: 개발계정 10,000, 운영계정 활용사례 등록 시 증가 가능.
- 라이선스: 이용허락범위 제한 없음 (data.go.kr 명시).
- 공식 표면: `https://www.data.go.kr/data/15125364/openapi.do`
- 서비스 URL: `https://apis.data.go.kr/B552735/kisedKstartupService01`

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

@ -0,0 +1,77 @@
# 오늘의집 오늘의딜 조회 가이드
## 이 기능으로 할 수 있는 일
`ohou-today-deal`은 오늘의집 공개 오늘의딜 페이지에서 특가 상품 정보를 읽어 할인율, 가격, 리뷰, 무료배송 여부, 링크를 정리하는 읽기 전용 스킬이다.
- 오늘의딜/스페셜딜 상품 목록 조회
- 할인율 높은 순, 낮은 가격 순, 리뷰 많은 순 정렬
- 키워드, 최소 할인율, 무료배송 필터
- 상품 링크 제공
## 먼저 필요한 것
- `python3`
- 인터넷 연결
- 별도 로그인/API 키 없음
## 공개 접근 경로
- 브라우저용 공개 URL: `https://ohou.se/commerces/today_deals`
- 페이지가 노출하는 canonical/OG URL: `https://store.ohou.se/today_deals`
- 데이터 표면: HTML 안의 Next.js `__NEXT_DATA__` 안 React Query `dehydratedState`에서 `today-deal-feed`, `special-today-deal-feed` queryKey 두 곳의 `todayDealFeed.slots`만 명시적으로 읽는다.
- HTTP 요청은 `User-Agent: k-skill-ohou-today-deal/1.0 (+https://github.com/NomaDamas/k-skill)` 헤더로 보낸다 (ohou.se 앞단 Akamai bot manager가 익명/단축 UA를 차단하기 때문에 봇 이름 + contact URL이 들어간 well-formed UA로 정직하게 자기소개한다 — 우회/조작이 아님).
이 기능은 화면 클릭, 로그인 세션, 장바구니, 결제 자동화를 하지 않는다.
## 예시
할인율 높은 오늘의딜 상위 5개:
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--sort discount \
--limit 5
```
러그 관련 무료배송 특가:
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--query 러그 \
--free-delivery \
--limit 5
```
30% 이상 할인 상품:
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--min-discount 30 \
--limit 10
```
오프라인 fixture로 검증:
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--html-file ./today-deals.html \
--limit 3
```
## 출력에서 확인할 점
- `items[].title`: 상품명
- `items[].brand`: 브랜드
- `items[].original_price`, `items[].selling_price`: 기본 가격
- `items[].best_price`, `items[].best_discount_rate`: 쿠폰/결제혜택 반영 최저가가 있을 때의 가격과 할인율
- `items[].review_count`, `items[].review_average`: 리뷰 정보
- `items[].free_delivery`: 무료배송 여부
- `items[].url`: 상품 페이지
## 주의할 점
- 가격, 쿠폰, 결제혜택, 품절 여부는 실시간으로 바뀔 수 있다.
- `best_price`는 오늘의집 페이지가 노출한 혜택 기준이며, 사용자별 쿠폰/결제수단에 따라 실제 결제가는 달라질 수 있다.
- HTML 구조나 `__NEXT_DATA__` 스키마가 바뀌면 파서 수정이 필요하다.
- 구매, 장바구니, 결제는 사용자가 직접 진행해야 한다.

View file

@ -0,0 +1,96 @@
# SH 청약·주택 공고문 조회 가이드
`sh-notice-search`는 서울주택도시개발공사(SH, `www.i-sh.co.kr`)의 공개 **공고 및 공지** HTML 게시판을 직접 조회하는 read-only 스킬이다. upstream이 인증/키 없이 열려 있는 공개 표면이므로 `k-skill-proxy`를 사용하지 않는다.
## 이 기능으로 할 수 있는 일
- SH 최신 공고/공지 목록 조회
- 키워드 검색: `행복주택`, `매입임대`, `신혼희망타운`
- 공고 종류 필터: 주택임대, 주택분양, 주택매입(주거복지 alias), 토지, 상가/공장 등
- 페이지네이션: SH 고정 10건 페이지에서 `page`로 이동
- 상세 조회: 본문 텍스트, 담당부서, 등록일, 조회수, 공식 상세 URL
- 첨부 메타데이터: 실제 `existFile()` 첨부 앵커와 `downList` 기반 파일명/미리보기 URL
## 가장 중요한 정책 경계
- SH 게시판은 공개 HTML이라 proxy에 넣지 않는다.
- 별도 API key가 필요한 공식 무료 API가 발견되는 경우에만 해당 경로를 좁은 allowlist proxy route로 검토한다.
- 본 구현은 청약 신청, 로그인, 서류 제출, 결제, 마이페이지 자동화를 하지 않는다.
## 공개 접근 경로
기본 임대 게시판:
```text
https://www.i-sh.co.kr/app/lay2/program/S1T294C297/www/brd/m_247/list.do?multi_itm_seq=2
```
상세:
```text
https://www.i-sh.co.kr/app/lay2/program/S1T294C297/www/brd/m_247/view.do?multi_itm_seq=2&seq=<seq>
```
검색 파라미터:
| 목적 | 파라미터 |
| --- | --- |
| 제목 검색 | `srchWord=<검색어>&srchTp=0` |
| 내용 검색 | `srchWord=<검색어>&srchTp=1` |
| 페이지 | `page=<번호>` |
| 분류 | 공식 탭별 `multi_itm_seq` 및 board path |
SH 게시판은 `srchWord`만 보내면 검색어를 무시하고 전체 목록을 반환할 수 있으므로, 패키지는 키워드가 있을 때 `srchTp`를 반드시 보낸다.
## 사용 예시
```bash
node packages/sh-notice-search/src/cli.js 행복주택 --category 임대 --limit 5
node packages/sh-notice-search/src/cli.js 매입임대 --category 주거복지 --page 2
node packages/sh-notice-search/src/cli.js --seq 304371 --category 임대
```
```js
const { searchNotices, getNoticeDetail } = require("sh-notice-search")
const list = await searchNotices({ keyword: "행복주택", category: "임대", page: 1 })
const detail = await getNoticeDetail({ seq: list.items[0].seq, category: "임대" })
```
## 출력 필드
목록:
- `seq`, `title`, `department`, `registered_date`, `views`
- `category`, `category_name`
- `status` / `status_basis` (제목 기반 보수적 분류)
- `detail_url`
상세:
- `content_text`
- `attachments[]`: `filename`, `file_seq`, `file_size`, `file_type`, `preview_url`
- `detail_url`
직접 다운로드 URL은 노출하지 않고, 공식 상세/미리보기 URL을 사용자 브라우저로 handoff한다.
## 상태와 공고 종류 필터
공고 종류는 SH 공식 탭과 일치하는 board path를 사용한다. `주거복지`는 공개 탭명이 아니므로 사용자 alias로만 받고 현재 SH의 `주택매입` 탭에 매핑한다.
상태(`진행`, `마감`, `당첨자`)는 공개 목록에 별도 컬럼이 없어 제목 텍스트 기반으로만 보수적으로 분류한다. 정확한 접수기간/마감일은 상세 본문이나 첨부 공고문을 확인해야 한다.
## 실패 모드
- SH HTML 구조, board path, `getDetailView()`, `existFile()`, `downList` 구조 변경
- IP rate limit, NetFunnel queue/throttle, 점검 페이지, CAPTCHA/login wall
- 첨부 미리보기/다운로드 direct-link 정책 변경
- `pageSize`를 10보다 크게 지정해도 SH는 한 페이지 10건만 제공
- 상태 분류는 제목 추론이라 상세 공고문 날짜와 다를 수 있음
## Done when
- 직접 공개 SH URL에서 목록/상세를 조회했다.
- 키워드 검색에 `srchTp`가 포함되어 의도된 hit count로 좁혀졌다.
- 페이지가 필요한 경우 `page`를 사용했다.
- 첨부가 아이콘 템플릿이 아니라 실제 `existFile()` 기준으로 추출되었다.

View file

@ -75,6 +75,7 @@ npx --yes skills add <owner/repo> \
--skill korea-weather \
--skill cheap-gas-nearby \
--skill public-restroom-nearby \
--skill emergency-room-beds \
--skill fine-dust-location \
--skill han-river-water-level \
--skill subway-lost-property \
@ -83,12 +84,14 @@ npx --yes skills add <owner/repo> \
--skill market-kurly-search \
--skill gangnamunni-clinic-search \
--skill olive-young-search \
--skill korean-cinema-search \
--skill hola-poke-yeoksam \
--skill blue-ribbon-nearby \
--skill kakao-bar-nearby \
--skill zipcode-search \
--skill delivery-tracking \
--skill coupang-product-search \
--skill ohou-today-deal \
--skill bunjang-search \
--skill used-car-price-search \
--skill korean-spell-check \
@ -204,6 +207,40 @@ node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 5 --jso
node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
### `korean-cinema-search` upstream CLI quickstart
`korean-cinema-search` 는 upstream 원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) / npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 그대로 사용한다.
- 기본 경로는 **MCP 서버 직접 설치가 아니라 CLI first** 다.
- 가장 빠른 smoke test 는 `npx --yes daiso health`
- CGV, 메가박스, 롯데시네마의 영화관, 상영작, 시간표, 잔여석 조회를 다룬다.
- 날짜가 있는 요청은 Asia/Seoul 기준 `YYYYMMDD` 로 바꿔 `--playDate <YYYYMMDD>` 를 명시한다.
- 예매와 결제는 자동화하지 않는다.
- 반복 사용이면 `npm install -g daiso`
- public endpoint는 upstream 상태에 따라 간헐적인 `5xx/503` 이 날 수 있으니 먼저 한두 번 재시도한다.
- 재시도 후에도 불안정하거나 버전 고정/원본 확인이 필요하면 `git clone https://github.com/hmmhmmhm/daiso-mcp.git && cd daiso-mcp && npm install && npm run build` clone fallback으로 전환한 뒤 `node dist/bin.js ...` 로 실행한다.
```bash
npx --yes daiso health
npx --yes daiso get /api/cgv/theaters --keyword 강남 --limit 5 --json
npx --yes daiso get /api/cgv/timetable --keyword 강남 --playDate <YYYYMMDD> --json
npx --yes daiso get /api/megabox/theaters --keyword 코엑스 --limit 5 --json
npx --yes daiso get /api/megabox/seats --keyword 코엑스 --playDate <YYYYMMDD> --limit 10 --json
npx --yes daiso get /api/lottecinema/theaters --keyword 월드타워 --limit 5 --json
npx --yes daiso get /api/lottecinema/seats --keyword 월드타워 --playDate <YYYYMMDD> --limit 10 --json
```
clone fallback 예시:
```bash
git clone https://github.com/hmmhmmhm/daiso-mcp.git
cd daiso-mcp
npm install
npm run build
node dist/bin.js health
node dist/bin.js get /api/cgv/timetable --keyword 강남 --playDate <YYYYMMDD> --json
```
### `bunjang-search` upstream CLI quickstart
`bunjang-search` 는 upstream 원본 [`pinion05/bunjangcli`](https://github.com/pinion05/bunjangcli) / npm package [`bunjang-cli`](https://www.npmjs.com/package/bunjang-cli) 를 그대로 사용한다.

View file

@ -38,6 +38,7 @@
- 다이소 상품 조회 스킬 출시
- 마켓컬리 상품 조회 스킬 출시
- 올리브영 검색 스킬 출시
- 영화관 검색 스킬 출시
- 올라포케 역삼 포케 스킬 출시
- 쿠팡 상품 검색 스킬 출시 (retention-corp/coupang_partners 로컬 MCP 호환 레이어 기반)
- 번개장터 검색 스킬 출시
@ -45,6 +46,7 @@
- 한국어 맞춤법 검사 스킬 출시
- 한국어 글자 수 세기 스킬 출시
- 긱뉴스 조회 스킬 출시
- 오늘의집 오늘의딜 조회 스킬 출시
## v1.5 candidates

View file

@ -28,6 +28,8 @@ KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# 일반 KOSIS 조회는 hosted proxy 사용. direct/bigdata 또는 proxy 서버 운영 때만 필요.
KSKILL_KOSIS_API_KEY=replace-me
# 일반 K-Startup 조회는 hosted proxy 사용. --direct 호출 때만 필요.
KSKILL_KSTARTUP_API_KEY=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
@ -36,7 +38,7 @@ KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=
```
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크, 창업진흥원 K-Startup 조회도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
## Missing secret handling policy
@ -70,6 +72,7 @@ KSKILL_PROXY_BASE_URL=
- `KSKILL_FORESTTRIP_ID`
- `KSKILL_FORESTTRIP_PASSWORD`
- `KSKILL_KOSIS_API_KEY` (KOSIS `bigdata`/`--direct`, 또는 proxy 서버 `KOSIS_API_KEY` 대체 env)
- `KSKILL_KSTARTUP_API_KEY` (창업진흥원 K-Startup `--direct` 호출용. 일반 조회는 hosted proxy의 `DATA_GO_KR_API_KEY` 가 처리)
- `LAW_OC`
- `KIPRIS_PLUS_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`
@ -77,6 +80,6 @@ KSKILL_PROXY_BASE_URL=
- `KRX_API_KEY`
- `KSKILL_PROXY_BASE_URL`
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 창업진흥원 K-Startup 조회도 `k-skill-proxy``/v1/kstartup/*` 라우트를 거쳐 `ServiceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `KSKILL_KSTARTUP_API_KEY``--direct` 호출 문맥에서만 사용자 쪽에 둔다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -28,6 +28,8 @@ KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# KOSIS 일반 조회는 hosted proxy 사용. bigdata/--direct 때만 채운다.
KSKILL_KOSIS_API_KEY=replace-me
# 창업진흥원 K-Startup 일반 조회는 hosted proxy 사용. --direct 때만 채운다.
KSKILL_KSTARTUP_API_KEY=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
@ -95,6 +97,7 @@ bash scripts/check-setup.sh
| 도서관 도서 조회 | 사용자 시크릿 불필요 (프록시에 `DATA4LIBRARY_AUTH_KEY`가 설정된 hosted/self-host 사용) |
| 의약품 안전 체크 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host 사용) |
| 식품 안전 체크 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`와 선택적 `FOODSAFETYKOREA_API_KEY`가 설정된 hosted/self-host 사용) |
| 창업진흥원 K-Startup 조회 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host 사용; `--direct` 호출 때만 `KSKILL_KSTARTUP_API_KEY`) |
## 다음에 볼 문서
@ -120,6 +123,8 @@ bash scripts/check-setup.sh
- [도서관 도서 조회 가이드](features/library-book-search.md)
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)
- [식품 안전 체크 가이드](features/mfds-food-safety.md)
- [창업진흥원 K-Startup 조회 가이드](features/kstartup-search.md)
- [지방선거 후보자 조회 가이드](features/local-election-candidate-search.md)
- [보안/시크릿 정책](security-and-secrets.md)
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.

View file

@ -110,8 +110,9 @@
- 다이소몰 상품 검색 요약: https://www.daisomall.co.kr/ssn/search/Search
- 다이소몰 상품 검색 목록: https://www.daisomall.co.kr/ssn/search/SearchGoods
- 다이소몰 상품 요약 목록: https://www.daisomall.co.kr/ssn/search/GoodsMummResult
- 다이소몰 매장 픽업 재고: https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck (2026-05-05 기준 Unauthorized 차단 가능)
- 다이소몰 매장 픽업 가능 매장 목록: https://www.daisomall.co.kr/api/ms/msg/selPkupStr (특정 상품의 픽업 가능 매장 리스트, 매장 수량은 미제공)
- 다이소몰 비로그인 인증: https://www.daisomall.co.kr/api/auth/request (응답 바디: JWT 평문, 응답 헤더 x-dm-uid; AES-128-CBC / 키 PRE_AUTH_ENC_KEY 로 암호화 후 Bearer 헤더로 전달)
- 다이소몰 매장 픽업 재고: https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck (Authorization: Bearer 헤더 필요)
- 다이소몰 매장 픽업 가능 여부 fallback: https://www.daisomall.co.kr/api/ms/msg/selPkupStr (Bearer 재고 조회가 401/403으로 계속 막힐 때 `pickupEligibility` 보조 정보로 사용)
- 다이소몰 온라인 재고: https://www.daisomall.co.kr/api/pdo/selOnlStck
- 강남언니 공개 검색: https://www.gangnamunni.com/search?q=<keyword>
- 강남언니 공개 병원 페이지: https://www.gangnamunni.com/hospitals/<id>
@ -124,12 +125,26 @@
- olive-young products API: https://mcp.aka.page/api/oliveyoung/products
- olive-young inventory API: https://mcp.aka.page/api/oliveyoung/inventory
- daiso/olive-young public MCP endpoint: https://mcp.aka.page/mcp
- korean-cinema upstream repo (`hmmhmmhm/daiso-mcp`): https://github.com/hmmhmmhm/daiso-mcp
- korean-cinema CLI package (`daiso`): https://www.npmjs.com/package/daiso
- CGV theaters API: https://mcp.aka.page/api/cgv/theaters
- CGV movies API: https://mcp.aka.page/api/cgv/movies
- CGV timetable API: https://mcp.aka.page/api/cgv/timetable
- Megabox theaters API: https://mcp.aka.page/api/megabox/theaters
- Megabox movies API: https://mcp.aka.page/api/megabox/movies
- Megabox seats API: https://mcp.aka.page/api/megabox/seats
- Lotte Cinema theaters API: https://mcp.aka.page/api/lottecinema/theaters
- Lotte Cinema movies API: https://mcp.aka.page/api/lottecinema/movies
- Lotte Cinema seats API: https://mcp.aka.page/api/lottecinema/seats
- hola-poke-yeoksam reference repo: https://github.com/mnspkm/hola-poke-yeoksam-skill
- hola-poke-yeoksam remote MCP endpoint: https://hola-poke-yeoksam-skill.onrender.com/mcp
- retention-corp/coupang_partners (Coupang Partners client and local MCP-compatible layer): https://github.com/retention-corp/coupang_partners
- coupang_partners local MCP contract: local://coupang-mcp
- coupang_partners hosted fallback (credentialless, allowlist-gated): https://a.retn.kr/v1/public/assist
- coupang_partners hosted fallback PR (merged): https://github.com/retention-corp/coupang_partners/pull/1
- 오늘의집 오늘의딜 공개 페이지: https://ohou.se/commerces/today_deals
- 오늘의집 오늘의딜 canonical/OG URL: https://store.ohou.se/today_deals
- 오늘의집 오늘의딜 데이터 표면: HTML `__NEXT_DATA__``today-deal-feed`
- bunjang-cli package: https://www.npmjs.com/package/bunjang-cli
- bunjang-cli repo: https://github.com/pinion05/bunjangcli
- 당근 메인: https://www.daangn.com/
@ -191,3 +206,7 @@
- 도서관 정보나루 도서 상세 endpoint: https://data4library.kr/api/srchDtlList
- 도서관 정보나루 도서 소장 도서관 endpoint: https://data4library.kr/api/libSrchByBook
- 도서관 정보나루 도서관별 도서 소장여부 endpoint: https://data4library.kr/api/bookExist
- 공공데이터포털 데이터셋(창업진흥원 K-Startup 조회서비스): https://www.data.go.kr/data/15125364/openapi.do
- K-Startup Open API base URL: https://apis.data.go.kr/B552735/kisedKstartupService01 — `k-skill-proxy``/v1/kstartup/business-info`, `/v1/kstartup/announcements`, `/v1/kstartup/contents`, `/v1/kstartup/statistics` 가 각각 `getBusinessInformation01`, `getAnnouncementInformation01`, `getContentInformation01`, `getStatisticalInformation01` 로 중계한다 (returnType=json 고정, ServiceKey 서버 측 주입)
- K-Startup 공식 포털: https://www.k-startup.go.kr — 응답의 `detl_pg_url` 가 가리키는 사용자 진입점

View file

@ -0,0 +1,92 @@
---
name: emergency-room-beds
description: Use when the user asks for nearby Korean emergency rooms, 응급실, ER, or emergency bed/병상 status near a location. Ask for the user's current location first unless a location was already provided.
license: MIT
metadata:
category: health
locale: ko-KR
phase: v1
---
# Emergency Room Beds
## What this skill does
사용자가 알려준 현재 위치를 기준으로 **근처 응급실**과 공개 E-Gen 응급실 상태 플래그를 찾는다.
- 위치는 자동 추정하지 않는다.
- 위치가 없으면 먼저 현재 위치를 묻는다.
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 잡는다.
- 응급실 목록은 E-Gen 공개 응급실 찾기 표면을 사용한다.
- 응급실 운영 여부, 입원실/병상 운영 플래그, 권역외상센터/소아전문 여부, 데이터 갱신시각을 보여준다.
- **정확한 실시간 잔여 병상 수나 병상 가동률을 확정해서 말하지 않는다.** 공개 E-Gen nearby 목록은 병상 수치가 아니라 운영 플래그를 제공한다.
## When to use
- "근처 응급실 찾아줘"
- "응급실 병상 상태 확인해줘"
- "광화문 주변 응급실 어디가 가까워?"
- "현재 위치 근처 응급실 운영 여부 알려줘"
## Mandatory first question
위치 정보가 없으면 먼저 물어본다.
`현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 응급실 상태를 찾아볼게요.`
## Official/public surfaces
- NEMC 모니터링: `https://dw.nemc.or.kr/nemcMonitoring/mainmgr/Main.do`
- E-Gen 응급실 찾기: `https://www.e-gen.or.kr/egen/search_emergency_room.do`
- E-Gen nearby list endpoint: `https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
## Workflow
1. 사용자의 현재 위치를 확보한다.
2. `emergency-room-beds` 패키지의 `searchNearbyEmergencyRoomsByLocationQuery()`를 사용한다.
3. 보통 3~5개 이내로 거리순 결과를 정리한다.
4. 반드시 "공개 E-Gen nearby 목록 기준이며 정확한 잔여 병상 수/가동률은 제공되지 않는다"고 밝힌다.
5. 긴급 상황이면 119 또는 병원 전화 확인을 권한다.
## Responding
결과는 짧고 실용적으로 정리한다.
- 병원명 / 거리
- 응급의료기관 등급 / 병원 유형
- 응급실 운영 여부
- 입원실/병상 운영 플래그
- 권역외상센터/소아전문 여부
- 주소 / 대표전화
- 갱신시각
- 지도 링크
## Node.js example
```js
const { searchNearbyEmergencyRoomsByLocationQuery } = require("emergency-room-beds");
async function main() {
const result = await searchNearbyEmergencyRoomsByLocationQuery("광화문", {
limit: 3,
radius: 5
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Done when
- 위치 기준 anchor를 확인했다.
- 가까운 응급실을 찾았거나, 못 찾은 이유와 다음 검색 범위를 제시했다.
- 공개 데이터의 한계(정확한 잔여 병상 수/가동률 미제공)를 명확히 밝혔다.
- 긴급 상황에서는 119/전화 확인 안내를 포함했다.

View file

@ -101,6 +101,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 호출하고, `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 는 프록시 서버에서만 주입/관리하므로 사용자 쪽에 둘 필요가 없다.
창업진흥원 K-Startup 조회는 `k-skill-proxy``/v1/kstartup/*` 라우트를 호출하고, `ServiceKey`(`DATA_GO_KR_API_KEY`)는 프록시 서버에서만 주입/관리하므로 일반 조회는 사용자 쪽에 키가 필요 없다. `--direct` 호출을 쓸 때만 `KSKILL_KSTARTUP_API_KEY` 를 채운다.
한국 특허 정보 검색은 KIPRIS Plus Open API 경로를 쓸 때 `KIPRIS_PLUS_API_KEY` 를 채운다. helper는 이 값을 읽어 실제 요청에서 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 그대로 넣어도 된다.
@ -123,6 +125,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
- 도서관 도서 조회: 사용자 시크릿 불필요 (`DATA4LIBRARY_AUTH_KEY`는 proxy 서버만)
- 의약품 안전 체크: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`는 proxy 서버만)
- 식품 안전 체크: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`와 선택적 `FOODSAFETYKOREA_API_KEY`는 proxy 서버만)
- 창업진흥원 K-Startup 조회: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`는 proxy 서버만; `--direct` 호출 때만 `KSKILL_KSTARTUP_API_KEY`)
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 서울 지하철: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`)
- 서울 실시간 혼잡도: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`)

View file

@ -1,6 +1,6 @@
---
name: kakaotalk-mac
description: Use kakaocli on macOS to read KakaoTalk chats, search messages, and send replies after explicit confirmation.
description: Use kakaocli on macOS to read KakaoTalk chats, search messages, send replies after explicit confirmation, and delete sent messages with explicit operator intent.
license: MIT
metadata:
category: messaging
@ -12,7 +12,7 @@ metadata:
## What this skill does
`kakaocli` 를 사용해 macOS에서 카카오톡 대화 목록을 확인하고, 메시지를 검색하고, 필요할 때 답장을 보낸다.
`kakaocli` 와 이 저장소 helper를 사용해 macOS에서 카카오톡 대화 목록을 확인하고, 메시지를 검색하고, 필요할 때 답장하거나 보낸 메시지를 삭제한다.
이 스킬은 **macOS + 카카오톡 Mac 앱 설치**를 전제로 한다. 공식 Kakao API를 쓰는 것이 아니라 로컬 데이터베이스 읽기와 macOS 접근성 자동화 위에서 동작하므로, 권한과 안전 규칙을 먼저 확인해야 한다.
@ -47,6 +47,7 @@ metadata:
- 채팅방 이름 또는 검색 키워드
- 읽기 범위: 최근 N개, `--since 1h`, `--since 7d`
- 전송할 메시지 본문
- 삭제할 로컬 메시지 ID 또는 최근 보낸 메시지 삭제 여부
- 테스트 여부 (`--me`, `--dry-run`)
## Workflow
@ -87,7 +88,7 @@ kakaocli status
기본 규칙:
- `status` / `auth` / `chats` 같은 읽기 명령도 Full Disk Access 가 필요하다.
- `send`, `harvest`, `inspect` 류 작업은 Accessibility 권한까지 필요하다.
- `send`, `delete`, `delete-last`, `harvest`, `inspect` 류 작업은 Accessibility 권한까지 필요하다.
### 3. Verify read access before attempting side effects
@ -166,7 +167,25 @@ kakaocli send --dry-run "채팅방 이름" "보낼 문장"
kakaocli send "채팅방 이름" "보낼 문장"
```
### 7. Use login storage only when the user explicitly wants auto-login
### 7. Delete a sent message only with explicit operator intent
삭제는 카카오톡 Mac UI(우클릭 → 삭제)를 접근성으로 자동화한다. 먼저 `messages --json` 으로 로컬 메시지 ID와 보낸 메시지 여부를 확인하고, 항상 `--dry-run` 으로 대상 채팅방/메시지를 확인한 뒤 실행한다. `--everyone` 은 내가 보낸 메시지에만 허용된다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "채팅방 이름" --limit 20 --json
python3 scripts/kakaotalk_mac.py delete "채팅방 이름" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "채팅방 이름" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "채팅방 이름" --dry-run
python3 scripts/kakaotalk_mac.py delete-last "채팅방 이름" --everyone
```
주의:
- helper의 `chats`, `messages`, `search`, `schema` 는 read-only 경로다. `delete` / `delete-last` 는 UI side effect 이므로 Accessibility 권한과 명시적 실행 의도가 필요하다.
- 메시지 ID는 로컬 DB의 `messages --json` 출력 기준이며 UI에서 동일한 DB row를 직접 증명할 수 있다는 뜻은 아니다. 실행 계약은 선택된 outbound DB 메시지의 정규화된 텍스트가 현재 활성 채팅방 transcript 영역에서 정확히 하나의 visible targetable message bubble 과 일치할 때만 삭제하는 것이다.
- 대상 메시지 텍스트가 비어 있거나 첨부/비텍스트 메시지이거나, 정규화 후 같은 텍스트가 여러 개 있거나, 대상 bubble 이 보이지 않거나, 활성 채팅방/삭제 범위/최종 확인 버튼을 확인할 수 없으면 삭제 자동화는 실패한다.
### 8. Use login storage only when the user explicitly wants auto-login
자동 로그인 편의를 원할 때만 자격증명을 저장한다.
@ -182,6 +201,7 @@ kakaocli login --status
- 읽기 요청이면 상태 확인 + 대화/메시지 조회 결과가 정리되어 있다
- 검색 요청이면 키워드 기준 결과가 정리되어 있다
- 전송 요청이면 테스트(`--me` 또는 `--dry-run`)와 사용자 확인이 끝난 뒤 실제 전송 여부가 명확하다
- 삭제 요청이면 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 검증 뒤 실행 결과가 명확하다
## Failure modes
@ -196,6 +216,7 @@ kakaocli login --status
- 이 스킬은 macOS 전용이다.
- 다른 사람에게 보내는 메시지는 항상 confirm before sending 원칙을 지킨다.
- 삭제는 되돌리기 어렵고 UI 상태에 민감하므로 먼저 `--dry-run` 으로 대상과 범위를 확인한다.
- 첫 검증은 `kakaocli status``kakaocli auth` 부터 시작하는 편이 안전하다.
- `kakaocli auth``User ID: auto-detection failed` 로 멈추면 helper 경로를 우선 사용한다.
- helper cache 는 로컬 SQLCipher key 를 포함하므로 본인 계정에서만 유지하고 공유하지 않는다.

View file

@ -11,6 +11,7 @@ import re
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Iterable, Sequence
@ -49,6 +50,14 @@ class ResolvedAuth:
source: str
@dataclass
class DeleteTarget:
message_id: int
text: str
timestamp: str | None
is_from_me: bool
def parse_plist_xml(xml_text: str) -> Any:
tokens = tokenize_plist_xml(xml_text)
if not tokens:
@ -575,6 +584,317 @@ def build_passthrough_command(command: str, auth: ResolvedAuth, forwarded_args:
]
def load_messages_for_delete(chat: str, auth: ResolvedAuth, *, limit: int) -> list[dict[str, Any]]:
result = run_command(
build_passthrough_command("messages", auth, ["--chat", chat, "--limit", str(limit), "--json"]),
check=True,
)
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise AuthResolutionError(f"Could not parse kakaocli messages JSON: {exc}") from exc
if not isinstance(payload, list):
raise AuthResolutionError("kakaocli messages --json did not return a JSON array")
return [item for item in payload if isinstance(item, dict)]
def select_delete_target(
messages: Sequence[dict[str, Any]],
*,
message_id: int | None,
delete_last: bool,
everyone: bool,
) -> DeleteTarget:
if delete_last:
candidates = [message for message in messages if bool(message.get("is_from_me"))]
if not candidates:
raise AuthResolutionError("No outbound messages were found for delete-last.")
raw = max(candidates, key=_delete_last_sort_key)
else:
if message_id is None:
raise AuthResolutionError("message_id is required for delete.")
raw = next((message for message in messages if _message_id(message) == message_id), None)
if raw is None:
raise AuthResolutionError(f"Message id {message_id} was not found in the fetched chat history.")
selected_id = _message_id(raw)
if selected_id is None:
raise AuthResolutionError("Selected message is missing an id.")
is_from_me = bool(raw.get("is_from_me"))
if not is_from_me:
raise AuthResolutionError(
"Delete automation only supports messages sent by this KakaoTalk account; "
"--everyone also requires an outbound message."
)
text = raw.get("text")
normalized_text = _normalize_delete_text(text)
if normalized_text is None:
raise AuthResolutionError(
"Delete automation requires a selected outbound message with non-empty text; "
"non-text, attachment, or empty-text messages are not safe UI delete targets."
)
matching_text_ids = [
_message_id(message)
for message in messages
if _normalize_delete_text(message.get("text")) == normalized_text
]
if len([item for item in matching_text_ids if item is not None]) > 1:
raise AuthResolutionError(
"Refusing to automate deletion because multiple fetched messages have the same normalized visible text. "
"Open the chat with only the target visible or use delete-last for the latest outbound message."
)
text = normalized_text
timestamp = raw.get("timestamp")
return DeleteTarget(
message_id=selected_id,
text=text,
timestamp=str(timestamp) if timestamp is not None else None,
is_from_me=is_from_me,
)
def _normalize_delete_text(value: Any) -> str | None:
if not isinstance(value, str):
return None
normalized = " ".join(value.split())
return normalized or None
def _message_id(message: dict[str, Any]) -> int | None:
value = message.get("id")
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
return None
def _delete_last_sort_key(message: dict[str, Any]) -> tuple[float, int]:
timestamp_score = _timestamp_sort_score(message.get("timestamp"))
message_id = _message_id(message) or 0
return (timestamp_score, message_id)
def _timestamp_sort_score(value: Any) -> float:
if isinstance(value, bool) or value is None:
return 0.0
if isinstance(value, (int, float)):
return float(value)
if not isinstance(value, str):
return 0.0
normalized = value.strip()
if not normalized:
return 0.0
if normalized.isdigit():
return float(normalized)
try:
if normalized.endswith("Z"):
normalized = f"{normalized[:-1]}+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
except ValueError:
return 0.0
def build_delete_osascript(chat: str, target: DeleteTarget, *, everyone: bool) -> str:
scope_labels = (
["모두에게서 삭제", "Delete for Everyone", "Delete for everyone"]
if everyone
else ["나에게서만 삭제", "Delete for Me", "Delete for me", "삭제", "Delete"]
)
labels = ", ".join(_applescript_string(label) for label in scope_labels)
return f"""
on normalizeText(rawText)
set normalizedText to rawText as text
set normalizedText to do shell script "python3 -c 'import sys; print(\\" \\".join(sys.stdin.read().split()))'" with input normalizedText
return normalizedText
end normalizeText
set chatName to {_applescript_string(chat)}
set messageText to {_applescript_string(target.text)}
set normalizedChatName to normalizeText(chatName)
set normalizedMessageText to normalizeText(messageText)
set deleteLabels to {{{labels}}}
tell application "KakaoTalk" to activate
delay 0.5
tell application "System Events"
tell process "KakaoTalk"
set frontmost to true
keystroke "f" using command down
delay 0.2
keystroke chatName
delay 0.2
key code 36
delay 0.8
key code 36
delay 1.0
set activeChatMatches to {{}}
try
set frontWindowName to name of front window as text
if normalizeText(frontWindowName) is normalizedChatName then set end of activeChatMatches to front window
end try
try
repeat with chatCandidate in static texts of front window
try
set chatCandidateValue to value of chatCandidate as text
if normalizeText(chatCandidateValue) is normalizedChatName then set end of activeChatMatches to chatCandidate
end try
end repeat
end try
try
repeat with headerGroup in groups of front window
try
repeat with chatCandidate in static texts of headerGroup
try
set chatCandidateValue to value of chatCandidate as text
if normalizeText(chatCandidateValue) is normalizedChatName then set end of activeChatMatches to chatCandidate
end try
end repeat
end try
end repeat
end try
if (count of activeChatMatches) is 0 then error "Could not verify the active KakaoTalk chat."
set messageListCandidates to {{}}
try
repeat with scrollArea in scroll areas of front window
try
if (count of static texts of scrollArea) is greater than 0 then set end of messageListCandidates to scrollArea
end try
try
repeat with messageGroup in groups of scrollArea
try
if (count of static texts of messageGroup) is greater than 0 then set end of messageListCandidates to messageGroup
end try
end repeat
end try
end repeat
end try
if (count of messageListCandidates) is 0 then error "Could not find the KakaoTalk message transcript area."
set matchingElements to {{}}
repeat with messageListCandidate in messageListCandidates
try
repeat with candidate in static texts of messageListCandidate
try
set candidateValue to value of candidate as text
set candidateActionNames to name of actions of candidate
if normalizeText(candidateValue) is normalizedMessageText then
if candidateActionNames contains "AXShowMenu" then
if matchingElements does not contain candidate then set end of matchingElements to candidate
end if
end if
end try
end repeat
end try
try
repeat with messageGroup in groups of messageListCandidate
try
repeat with candidate in static texts of messageGroup
try
set candidateValue to value of candidate as text
set candidateActionNames to name of actions of candidate
if normalizeText(candidateValue) is normalizedMessageText then
if candidateActionNames contains "AXShowMenu" then
if matchingElements does not contain candidate then set end of matchingElements to candidate
end if
end if
end try
end repeat
end try
end repeat
end try
end repeat
if (count of matchingElements) is 0 then error "Target message text was not visible as one exact targetable message bubble in the active chat."
if (count of matchingElements) is greater than 1 then error "Target message text matched multiple visible targetable message bubbles."
set targetElement to item 1 of matchingElements
perform action "AXShowMenu" of targetElement
delay 0.3
try
click menu item "삭제" of menu 1
on error
click menu item "Delete" of menu 1
end try
delay 0.5
set didChooseDeleteScope to false
repeat with labelText in deleteLabels
try
click button (labelText as text) of window 1
set didChooseDeleteScope to true
exit repeat
end try
try
click menu item (labelText as text) of menu 1
set didChooseDeleteScope to true
exit repeat
end try
end repeat
if didChooseDeleteScope is false then error "Could not choose the requested delete scope."
delay 0.3
set didConfirmDelete to false
try
click button "삭제" of window 1
set didConfirmDelete to true
end try
if didConfirmDelete is false then
try
click button "Delete" of window 1
set didConfirmDelete to true
end try
end if
if didConfirmDelete is false then error "Could not confirm the KakaoTalk delete dialog."
end tell
end tell
""".strip()
def _applescript_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def run_delete_automation(chat: str, target: DeleteTarget, *, everyone: bool) -> subprocess.CompletedProcess[str]:
script = build_delete_osascript(chat, target, everyone=everyone)
return run_command(["/usr/bin/osascript", "-e", script], check=True)
def handle_delete_command(args: argparse.Namespace) -> int:
delete_last = args.command == "delete-last"
message_id = None if delete_last else args.message_id
resolved = resolve_auth(
refresh=args.refresh_auth,
cache_path=Path(args.cache_path).expanduser(),
user_id_override=args.user_id,
uuid_override=args.uuid,
max_user_id=args.max_user_id,
workers=args.workers,
chunk_size=args.chunk_size,
)
messages = load_messages_for_delete(args.chat, resolved, limit=args.limit)
target = select_delete_target(messages, message_id=message_id, delete_last=delete_last, everyone=args.everyone)
if args.dry_run:
scope = "everyone" if args.everyone else "me"
print(
f"DRY RUN: Would delete message_id={target.message_id} "
f"from chat '{args.chat}' for {scope}: {target.text}"
)
return 0
run_delete_automation(args.chat, target, everyone=args.everyone)
print(f"Deleted message_id={target.message_id} from chat '{args.chat}'.")
return 0
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
@ -596,6 +916,11 @@ def main(argv: Sequence[str] | None = None) -> int:
print(render_auth(resolved, output_format=args.format, cache_path=cache_path))
return 0
if args.command in {"delete", "delete-last"}:
if forwarded_args:
raise AuthResolutionError(f"Unexpected delete arguments: {' '.join(forwarded_args)}")
return handle_delete_command(args)
resolved = resolve_auth(
refresh=args.refresh_auth,
cache_path=cache_path,
@ -628,6 +953,17 @@ def build_parser() -> argparse.ArgumentParser:
add_auth_options(passthrough)
passthrough.add_argument("--refresh-auth", action="store_true", help="Refresh cached auth before running.")
delete_parser = subparsers.add_parser("delete", help="Delete one KakaoTalk message by local message id via UI automation.")
add_auth_options(delete_parser)
add_delete_options(delete_parser)
delete_parser.add_argument("chat", help="Chat name to open (substring match).")
delete_parser.add_argument("message_id", type=positive_int, help="Local KakaoTalk message id from messages --json.")
delete_last_parser = subparsers.add_parser("delete-last", help="Delete the latest outbound message in a chat via UI automation.")
add_auth_options(delete_last_parser)
add_delete_options(delete_last_parser)
delete_last_parser.add_argument("chat", help="Chat name to open (substring match).")
return parser
@ -640,6 +976,13 @@ def add_auth_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--chunk-size", type=positive_int, default=DEFAULT_CHUNK_SIZE)
def add_delete_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--everyone", action="store_true", help="Use KakaoTalk's delete-for-everyone UI option.")
parser.add_argument("--dry-run", action="store_true", help="Validate and print the deletion plan without touching the UI.")
parser.add_argument("--refresh-auth", action="store_true", help="Refresh cached auth before resolving message metadata.")
parser.add_argument("--limit", type=positive_int, default=200, help="Messages to inspect when resolving delete target metadata.")
def non_negative_int(value: str) -> int:
integer = int(value)
if integer < 0:

View file

@ -0,0 +1,187 @@
---
name: korean-cinema-search
description: CGV, 메가박스, 롯데시네마 영화관 검색, 상영작, 시간표, 잔여석 조회가 필요할 때 사용한다.
license: MIT
metadata:
category: entertainment
locale: ko-KR
phase: v1
---
# Korean Cinema Search
## What this skill does
upstream 원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) 와 npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 사용해 **CGV, 메가박스, 롯데시네마 영화관 검색, 상영작, 시간표, 잔여석 조회**를 안내한다.
이 저장소는 upstream 코드를 vendoring 하지 않는다. 기본 경로는 **MCP 서버를 직접 설치하지 않고 CLI로 먼저 확인하는 방식**이다.
핵심 조회 경로:
- CGV: `/api/cgv/theaters`, `/api/cgv/movies`, `/api/cgv/timetable`
- 메가박스: `/api/megabox/theaters`, `/api/megabox/movies`, `/api/megabox/seats`
- 롯데시네마: `/api/lottecinema/theaters`, `/api/lottecinema/movies`, `/api/lottecinema/seats`
- health check: `npx --yes daiso health`
## When to use
- "강남 근처 CGV 찾아줘"
- "오늘 메가박스 코엑스 상영작 알려줘"
- "롯데시네마 월드타워 잔여석 확인해줘"
- "주변 영화관 시간표 비교해줘"
## When not to use
- 예매, 결제, 좌석 선점, 로그인 자동화
- 영화관 계정이나 멤버십 권한이 필요한 기능
- upstream 서버 코드를 이 저장소에 복사해서 유지하려는 경우
## Prerequisites
- 인터넷 연결
- `node` 20 권장
- `npx` 또는 `npm`
- 필요하면 `git`
## Preferred setup: CLI first
먼저 MCP 연결이 아니라 upstream CLI로 공개 endpoint를 확인한다.
날짜가 있는 요청은 Asia/Seoul 기준 `YYYYMMDD` 로 정규화하고 `--playDate <YYYYMMDD>` 를 항상 붙인다. 사용자가 오늘이라고 말하거나 날짜를 생략하면 KST 오늘 날짜를 계산한다.
```bash
npx --yes daiso health
npx --yes daiso get /api/cgv/theaters --keyword 강남 --limit 5 --json
npx --yes daiso get /api/cgv/movies --keyword 강남 --playDate <YYYYMMDD> --json
npx --yes daiso get /api/cgv/timetable --keyword 강남 --playDate <YYYYMMDD> --json
npx --yes daiso get /api/megabox/theaters --keyword 코엑스 --limit 5 --json
npx --yes daiso get /api/megabox/movies --keyword 코엑스 --playDate <YYYYMMDD> --json
npx --yes daiso get /api/megabox/seats --keyword 코엑스 --playDate <YYYYMMDD> --limit 10 --json
npx --yes daiso get /api/lottecinema/theaters --keyword 월드타워 --limit 5 --json
npx --yes daiso get /api/lottecinema/movies --keyword 월드타워 --playDate <YYYYMMDD> --json
npx --yes daiso get /api/lottecinema/seats --keyword 월드타워 --playDate <YYYYMMDD> --limit 10 --json
```
반복 사용이면 전역 설치도 가능하다.
```bash
npm install -g daiso
export NODE_PATH="$(npm root -g)"
daiso health
```
## Fallback: clone the original repository
public endpoint 재시도나 버전 고정이 필요하면 원본 저장소를 clone 해서 build 결과물 `dist/bin.js` 를 직접 실행한다.
```bash
git clone https://github.com/hmmhmmhm/daiso-mcp.git
cd daiso-mcp
npm install
npm run build
node dist/bin.js health
node dist/bin.js get /api/cgv/theaters --keyword 강남 --limit 5 --json
node dist/bin.js get /api/cgv/timetable --keyword 강남 --playDate <YYYYMMDD> --json
node dist/bin.js get /api/megabox/seats --keyword 코엑스 --playDate <YYYYMMDD> --limit 10 --json
node dist/bin.js get /api/lottecinema/seats --keyword 월드타워 --playDate <YYYYMMDD> --limit 10 --json
```
## Required inputs
### 1. Cinema chain
체인이 없으면 먼저 묻는다.
- 권장 질문: `어느 영화관을 볼까요? CGV, 메가박스, 롯데시네마 중 하나를 알려주세요.`
### 2. Theater or area keyword
지역이나 지점명이 없으면 바로 조회하지 말고 기준 위치를 받는다.
- 권장 질문: `어느 지역이나 지점을 기준으로 볼까요? 예: 강남, 코엑스, 월드타워`
### 3. Movie title when seats are requested
잔여석 질문인데 영화명이 없으면 먼저 영화 후보를 조회하거나 영화명을 물어본다.
### 4. Date
사용자가 날짜를 말하면 그 날짜를 우선한다. 날짜가 없으면 Asia/Seoul 기준 오늘을 `YYYYMMDD` 로 계산해 `--playDate <YYYYMMDD>` 로 넘긴다.
| 체인 | 후보 조회 | 상영작 | 시간표 또는 잔여석 | 날짜 |
| --- | --- | --- | --- | --- |
| CGV | `keyword`, 선택 `limit` | `keyword` 또는 `theaterId`, `playDate` | `keyword` 또는 `theaterId`, `movieId`, `playDate` | 필수로 명시 |
| 메가박스 | `keyword`, 선택 `limit` | `keyword` 또는 `theaterId`, `playDate` | `keyword` 또는 `theaterId`, `movieId`, `playDate` | 필수로 명시 |
| 롯데시네마 | `keyword`, 선택 `limit` | `keyword` 또는 `theaterId`, `playDate` | `keyword` 또는 `theaterId`, `movieId`, `playDate` | 필수로 명시 |
## Workflow
### 1. Check server health
```bash
npx --yes daiso health
```
### 2. Resolve theater candidates
```bash
npx --yes daiso get /api/cgv/theaters --keyword 강남 --limit 5 --json
```
후보가 여러 개면 상위 2개에서 3개만 요약하고 다시 확인받는다.
### 3. Resolve movie candidates
```bash
npx --yes daiso get /api/cgv/movies --keyword 강남 --playDate <YYYYMMDD> --json
```
영화 후보가 많으면 제목과 등급만 짧게 정리한다.
### 4. Check timetable or seats
CGV는 시간표 중심으로 본다.
```bash
npx --yes daiso get /api/cgv/timetable --keyword 강남 --playDate <YYYYMMDD> --json
```
메가박스와 롯데시네마는 잔여석 endpoint를 사용할 수 있다.
```bash
npx --yes daiso get /api/megabox/seats --keyword 코엑스 --playDate <YYYYMMDD> --limit 10 --json
npx --yes daiso get /api/lottecinema/seats --keyword 월드타워 --playDate <YYYYMMDD> --limit 10 --json
```
### 5. Respond conservatively
최종 응답은 짧게 정리한다.
- 영화관 체인
- 기준 지역이나 지점
- 상영작 또는 선택 영화
- 시간표와 잔여석
- 조회 시각과 공개 endpoint 특성상 변동 가능하다는 점
예매와 결제는 자동화하지 않는다.
## Done when
- `hmmhmmhm/daiso-mcp` 원본 repo와 `daiso` CLI 사용 경로를 명시했다.
- MCP 서버를 직접 설치하는 대신 CLI first 흐름을 제시했다.
- CGV, 메가박스, 롯데시네마 조회 범위를 구분했다.
- 영화관 검색, 상영작, 시간표, 잔여석 중 필요한 호출을 실제로 안내했다.
- 예매와 결제 자동화가 범위 밖임을 명시했다.
## Failure modes
- public endpoint는 upstream 상태에 따라 간헐적인 5xx를 줄 수 있다.
- 지역 키워드가 넓으면 다른 지점이 섞일 수 있다.
- 시간표와 잔여석은 시점에 따라 달라진다.
- 일부 체인은 상영작, 시간표, 잔여석 endpoint의 입력값이 다르므로 theaterId, movieId가 있으면 그 값을 우선 사용한다.
## Notes
- 원본 프로젝트: `https://github.com/hmmhmmhm/daiso-mcp`
- npm package: `https://www.npmjs.com/package/daiso`
- 이 저장소는 upstream 코드를 vendoring 하지 않고 skill/docs만 유지한다.

197
kstartup-search/SKILL.md Normal file
View file

@ -0,0 +1,197 @@
---
name: kstartup-search
description: 공공데이터포털 창업진흥원 K-Startup Open API(15125364)로 통합 공고 사업 정보·지원사업 공고·창업 콘텐츠·통계보고서를 k-skill-proxy 경유로 조회한다. 검색 전용.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 창업진흥원 K-Startup 조회
## What this skill does
공공데이터포털의 **창업진흥원_K-Startup(사업소개,사업공고,콘텐츠 등)_조회서비스** (`kisedKstartupService01`, dataset `15125364`)를 `k-skill-proxy` 경유로 호출해 다음 4개 endpoint를 조회한다.
- `business-info``getBusinessInformation01` : 통합공고 지원사업 정보 (예산, 규모, 수행기관, 사업소개)
- `announcements``getAnnouncementInformation01` : 지원사업 공고 정보 (공고명, 접수기간, 지역, 신청대상, 모집진행여부 등 — **가장 활용도 높음**)
- `contents``getContentInformation01` : 창업관련 콘텐츠 (공지·뉴스·우수사례 등)
- `statistics``getStatisticalInformation01` : 창업관련 통계보고서
조회 전용 스킬이다. 사업 신청·지원금 청구·콘텐츠 게시 같은 쓰기 동작은 다루지 않는다.
## When to use
- "이번 달 마감 예정인 청년 창업지원 공고 찾아줘"
- "서울 소재 모집 진행 중인 1인 창조기업 지원사업 알려줘"
- "K-Startup에서 사업화 단계 통합공고 사업 목록 뽑아줘"
- "창업진흥원 최신 통계보고서 5건 보여줘"
## When not to use
- 사업 신청·결제·자동 지원·계좌 연계 같은 쓰기 동작 (지원 화면은 사용자가 K-Startup 웹에서 직접 진행한다)
- K-Startup 외부 사이트(중기부, 창조경제혁신센터, 지자체 단독 공고) 조회 — 통합공고에 등록된 일부만 K-Startup API로 노출된다
- 마감일·모집 상태를 분 단위로 추적해야 하는 작업 — 데이터 갱신은 공식 서비스설계서 기준 **일 1회**다 (공공데이터포털 dataset 메타데이터에는 "실시간"으로 표기되지만 두 표면이 일치하지 않는다)
## Prerequisites
- 인터넷 연결
- `python3` (stdlib only)
- 설치된 스킬 안의 `scripts/run_kstartup.py`
- hosted/self-host `k-skill-proxy``/v1/kstartup/*` 라우트 접근 가능 (4개)
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org`.
- `KSKILL_KSTARTUP_API_KEY``--direct`로 K-Startup을 직접 호출할 때만 필요. 공공데이터포털에서 `창업진흥원_K-Startup(사업소개,사업공고, 콘텐츠 등)_조회서비스` (`15125364`) 활용신청이 본인 계정으로 승인돼 있어야 한다(자동승인, 무료).
- 프록시 운영자는 `DATA_GO_KR_API_KEY` 환경변수에 같은 조건의 키를 두고 활용신청을 추가해 둔다.
### Credential resolution order (`--direct` 전용)
1. 이미 환경변수에 있으면 그대로 사용한다.
2. 에이전트 vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)에서 꺼내 환경변수로 주입.
3. `~/.config/k-skill/secrets.env` (plain dotenv, 권한 `0600`).
4. 아무것도 없으면 사용자에게 묻고 2 또는 3에 저장.
일반 조회 helper는 proxy URL만 읽고, K-Startup 인증키는 프록시 서버에서만 주입한다. `--direct` 호출에서만 `KSKILL_KSTARTUP_API_KEY`를 읽는다.
## Inputs
서브커맨드: `business-info`, `announcements`, `contents`, `statistics`.
공통 옵션:
- `--page N` (기본 1, ≥ 1)
- `--per-page N` (기본 10, 1100)
- `--text` 사람용 요약 / `--json` 구조화 결과(기본)
- `--dry-run` 인증키 없이 요청 URL/파라미터만 출력
- `--timeout N` HTTP 타임아웃 초 (기본 30)
- `--proxy-base-url URL` 기본 hosted proxy 대신 self-host/alternate proxy
- `--direct` proxy 우회, `KSKILL_KSTARTUP_API_KEY`로 직접 호출
서브커맨드별 필터:
- `business-info`
- `--biz-yr 2024` (사업 연도, 4자리)
- `--biz-category-cd cmrczn_Tab3` (사업 구분 코드)
- `--supt-biz-titl-nm "1인 창조기업"` (사업 명)
- `announcements`
- `--biz-pbanc-nm "키워드"` (지원 사업 공고 명)
- `--supt-regin 서울특별시` (지역명. **K-Startup upstream이 이 필터를 서버 측에서 적용하지 않는 사례가 있다** — 응답을 받은 뒤 client에서 `supt_regin` 으로 한 번 더 거른다)
- `--supt-biz-clsfc 사업화` (지원 분야)
- `--pbanc-rcpt-bgng-dt 20240101` / `--pbanc-rcpt-end-dt 20241231` (공고 접수 시작/종료, YYYYMMDD)
- `--aply-trgt 일반인,예비창업자` (신청 대상)
- `--biz-enyy 예비창업자,1년미만` (창업 기간)
- `--biz-trgt-age "만 20세 이상 ~ 만 39세 이하"` (대상 연령)
- `--rcrt-prgs-yn Y|N` (모집진행여부)
- `--intg-pbanc-yn Y|N` (통합 공고 여부)
- `contents`
- `--clss-cd notice_matr` (콘텐츠 구분 코드: notice_matr 등)
- `--titl-nm "공모전"` (제목 키워드)
- `statistics`
- `--titl-nm "창업기업 실태조사"` (통계 자료 명)
- `--file-nm "PDF"` (파일 명/내용 키워드)
## Workflow
### 1. Ensure proxy access is available
일반 조회는 기본 hosted `k-skill-proxy`를 사용하므로 사용자 K-Startup 키가 필요 없다. self-host를 쓰면 `KSKILL_PROXY_BASE_URL`을 설정한다. `--direct`가 필요할 때만 `KSKILL_KSTARTUP_API_KEY`를 credential resolution order에 따라 확보한다.
### 2. Pick the right operation
- 마감 임박/지역 필터/대상별 공고 추천 → `announcements`
- 사업의 전반적 소개·예산 규모 → `business-info`
- 정책 공지·우수사례 → `contents`
- 보고서/통계 데이터 → `statistics`
### 3. Fetch a small bounded slice first
`--per-page 10` 정도로 먼저 한 페이지를 받아 응답 스키마를 확인한 뒤, 필터를 좁히거나 페이지를 넘긴다.
```bash
python3 scripts/run_kstartup.py announcements \
--supt-regin 서울특별시 --rcrt-prgs-yn Y --per-page 5 --text
```
### 4. Filter on the client side for richer questions
API는 단순 필드 매칭만 지원하고, **그중 `supt_regin` 같은 일부 필터는 upstream이 서버 측에서 적용하지 않는 사례가 관측된다.** `--supt-regin 서울특별시`로 호출해도 타 지역 공고가 섞여 돌아오는 경우가 있어서, `supt_regin`·`aply_trgt`·`biz_enyy` 필드는 helper가 받은 응답을 client에서 한 번 더 거른다.
- 응답 `supt_regin`은 upstream이 축약형(`서울`, `경기`, `충북`)으로 돌려준다. helper는 사용자가 `--supt-regin 서울특별시` 같은 표준 광역지자체명을 줘도 17개 광역시·도(+ `전국`) 매핑 테이블로 자동 정규화해 매치한다.
- client filter가 적용되면 응답 JSON에 `client_filter: {fields, upstream_returned, after_filter}` 블록이 함께 붙는다. `upstream_returned`는 같지만 `after_filter`가 작으면 첫 페이지로는 부족하니 `--page`를 늘려 추가 페이지를 받는다.
- 쉼표로 여러 값을 주면 AND 매치다 (`--aply-trgt 예비창업자,1년미만` → 두 토큰 모두 row에 있어야 통과).
- `pbanc_rcpt_end_dt``YYYYMMDD` 문자열이라 KST 기준으로 직접 비교한다. "이번 주 마감", "30대 대상", "특정 키워드 포함" 같은 복합 조건은 helper가 안 거르므로 응답 JSON에서 agent가 직접 처리한다.
### 5. Cite the source
응답을 요약할 때는 endpoint 이름, 호출 page/perPage, 응답의 `pbanc_sn` 또는 `detl_pg_url`을 함께 적는다. 상세는 https://www.k-startup.go.kr 의 해당 URL로 안내한다.
## CLI examples
```bash
# 서울 모집 중 공고 5건
python3 scripts/run_kstartup.py announcements \
--supt-regin 서울특별시 --rcrt-prgs-yn Y --per-page 5 --text
# 2024년 사업화 분야 통합공고
python3 scripts/run_kstartup.py business-info \
--biz-yr 2024 --biz-category-cd cmrczn_Tab3 --json
# 정책·공지 최신 콘텐츠
python3 scripts/run_kstartup.py contents \
--clss-cd notice_matr --per-page 10 --text
# 창업기업 실태조사 통계보고서
python3 scripts/run_kstartup.py statistics \
--titl-nm "창업기업 실태조사" --per-page 5 --json
# 인증키 없이 dry-run 으로 요청 점검
python3 scripts/run_kstartup.py announcements \
--supt-regin 부산광역시 --dry-run
```
## Direct proxy examples
```bash
curl -fsS "$KSKILL_PROXY_BASE_URL/v1/kstartup/announcements?supt_regin=$(python3 -c 'import urllib.parse;print(urllib.parse.quote(\"서울특별시\"))')&rcrt_prgs_yn=Y&perPage=5"
```
## Failure modes
- `400 bad_request`: 잘못된 날짜(`YYYYMMDD` 아님), 잘못된 `Y/N`, perPage 범위 초과, 시작일 > 종료일 → 메시지대로 입력 보정.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY`가 없거나 해당 데이터셋 활용신청이 미승인.
- `502 upstream_error`: data.go.kr 응답이 `resultCode != "00"` 또는 `errMsg`/`SERVICE_KEY_IS_NOT_REGISTERED_ERROR` 등 인증/한도 오류.
- data.go.kr 에러 코드: 10(잘못된 파라미터), 20(접근거부), 22(요청제한 초과), 30(미등록 키), 31(만료), 32(미등록 IP).
- `502 upstream_invalid_response`: data.go.kr이 JSON 대신 HTML/XML 본문을 보낸 경우(점검·차단 등). `upstream_body` 앞 500자가 함께 반환된다.
- 빈 `data` 배열: 필터에 일치하는 공고/콘텐츠 없음. 키워드/지역/대상 범위를 완화한다.
- 일 갱신 1회(서비스설계서 기준): 같은 날 같은 공고의 마감일·상태가 갱신되지 않을 수 있으므로, 마감/접수 상태는 응답의 `detl_pg_url` 페이지에서 최종 확인한다.
## Done when
- 사용자가 찾는 endpoint (`business-info` / `announcements` / `contents` / `statistics`)를 골랐다.
- 작은 슬라이스로 첫 페이지를 받아 응답 스키마/필드를 확인했다.
- 필터를 좁히거나 클라이언트에서 후처리해 답변에 필요한 핵심 행만 남겼다.
- 결과에 출처(endpoint, page/perPage, `detl_pg_url` 또는 `pbanc_sn`)를 명시했다.
## Maintainer review notes
K-Startup 인증키 없이도 다음 검증이 가능하다.
- `./scripts/validate-skills.sh`
- `python3 -m py_compile kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py`
- `python3 kstartup-search/scripts/run_kstartup.py --help`
- `python3 kstartup-search/scripts/run_kstartup.py announcements --supt-regin 서울특별시 --dry-run`
- `PYTHONPATH=kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_*.py' -v`
- `node --test packages/k-skill-proxy/test/server.test.js` (K-Startup 라우트 5개 신규 케이스 포함)
- `npm run ci`
라이브 스모크는 hosted proxy 환경에 `DATA_GO_KR_API_KEY` 가 설정되고 `15125364` 활용신청이 승인된 뒤에 수행한다.
## Safety notes
- 조회 전용 스킬. 사업 신청·계좌 연결·결제 자동화는 하지 않는다.
- 응답에 K-Startup 사이트 URL이 있으면 그대로 안내하고, 실제 신청은 사용자가 브라우저에서 직접 진행한다.
- 인증키는 프록시 서버에서만 다루며, `--dry-run` 시에도 helper는 `<DRY-RUN>`로 대체한다.

View file

@ -0,0 +1,410 @@
#!/usr/bin/env python3
"""K-Startup (data.go.kr 15125364) CLI helper for the kstartup-search skill.
조회 전용. 일반 호출은 k-skill-proxy 경유, `--direct` 사용자 API 키로 직접 호출.
stdlib only (urllib, json, argparse, ssl).
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, Iterable, List, Optional, Tuple
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
KSTARTUP_UPSTREAM_BASE_URL = "https://apis.data.go.kr/B552735/kisedKstartupService01"
DEFAULT_SECRETS_PATH = os.path.expanduser("~/.config/k-skill/secrets.env")
OPERATIONS: Dict[str, Dict[str, Any]] = {
"business-info": {
"path": "getBusinessInformation01",
"allowed": ("biz_category_cd", "supt_biz_titl_nm", "biz_yr"),
},
"announcements": {
"path": "getAnnouncementInformation01",
"allowed": (
"intg_pbanc_yn", "intg_pbanc_biz_nm", "biz_pbanc_nm",
"supt_biz_clsfc", "aply_trgt_ctnt", "supt_regin",
"pbanc_rcpt_bgng_dt", "pbanc_rcpt_end_dt",
"aply_trgt", "biz_enyy", "biz_trgt_age", "prfn_matr",
"rcrt_prgs_yn",
),
},
"contents": {
"path": "getContentInformation01",
"allowed": ("clss_cd", "titl_nm"),
},
"statistics": {
"path": "getStatisticalInformation01",
"allowed": ("titl_nm", "file_nm"),
},
}
YN_FIELDS = {"intg_pbanc_yn", "rcrt_prgs_yn"}
DATE_FIELDS = {"pbanc_rcpt_bgng_dt", "pbanc_rcpt_end_dt"}
# Fields where the K-Startup upstream is observed to ignore the server-side
# filter and return non-matching rows. SKILL.md L121 promises that the helper
# re-applies these filters on the client side after receiving the response.
#
# - supt_regin: upstream returns mixed regions even when supt_regin is set.
# - aply_trgt: upstream returns rows whose aply_trgt does not contain the
# requested target (e.g. asking for "예비창업자" returns rows
# with only "일반인,일반기업").
# - biz_enyy: upstream returns rows whose biz_enyy does not include the
# requested founding period bucket.
#
# Matching policy: substring match against the comma-separated list inside
# each row's field. Multiple requested values (comma-separated by the user)
# are AND-joined: every requested token must appear somewhere in the row.
# This mirrors how the K-Startup web UI narrows results.
CLIENT_FILTER_FIELDS = {"supt_regin", "aply_trgt", "biz_enyy"}
REGION_SHORTNAME = {
"서울특별시": "서울", "서울시": "서울", "서울": "서울",
"부산광역시": "부산", "부산시": "부산", "부산": "부산",
"대구광역시": "대구", "대구시": "대구", "대구": "대구",
"인천광역시": "인천", "인천시": "인천", "인천": "인천",
"광주광역시": "광주", "광주시": "광주", "광주": "광주",
"대전광역시": "대전", "대전시": "대전", "대전": "대전",
"울산광역시": "울산", "울산시": "울산", "울산": "울산",
"세종특별자치시": "세종", "세종시": "세종", "세종": "세종",
"경기도": "경기", "경기": "경기",
"강원특별자치도": "강원", "강원도": "강원", "강원": "강원",
"충청북도": "충북", "충북": "충북",
"충청남도": "충남", "충남": "충남",
"전북특별자치도": "전북", "전라북도": "전북", "전북": "전북",
"전라남도": "전남", "전남": "전남",
"경상북도": "경북", "경북": "경북",
"경상남도": "경남", "경남": "경남",
"제주특별자치도": "제주", "제주도": "제주", "제주": "제주",
"전국": "전국",
}
class HelperError(RuntimeError):
"""User-facing CLI error."""
def load_secrets(path: str = DEFAULT_SECRETS_PATH) -> Dict[str, str]:
"""Read dotenv-like secrets file. Returns {} if missing."""
data: Dict[str, str] = {}
if not os.path.exists(path):
return data
try:
with open(path, "r", encoding="utf-8") as fh:
for raw_line in fh:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if value.startswith('"') and value.endswith('"') and len(value) >= 2:
value = value[1:-1]
if value.startswith("'") and value.endswith("'") and len(value) >= 2:
value = value[1:-1]
if key:
data[key] = value
except OSError:
return data
return data
def resolve_api_key(args: argparse.Namespace) -> Optional[str]:
"""`--direct` 전용 API 키 해석. env > secrets file 순서."""
env_key = os.environ.get("KSKILL_KSTARTUP_API_KEY") or os.environ.get("DATA_GO_KR_API_KEY")
if env_key:
return env_key.strip() or None
secrets = load_secrets(args.secrets_path or DEFAULT_SECRETS_PATH)
return (secrets.get("KSKILL_KSTARTUP_API_KEY") or secrets.get("DATA_GO_KR_API_KEY") or "").strip() or None
def validate_yyyymmdd(value: str, field: str) -> str:
digits = "".join(c for c in value if c.isdigit())
if len(digits) != 8:
raise HelperError(f"{field} must be YYYYMMDD (got: {value!r})")
year = int(digits[0:4])
month = int(digits[4:6])
day = int(digits[6:8])
try:
datetime.date(year, month, day)
except ValueError as exc:
raise HelperError(f"{field} must be a valid YYYYMMDD date (got: {value!r})") from exc
return digits
def build_query(args: argparse.Namespace, operation: str) -> Dict[str, Any]:
if operation not in OPERATIONS:
raise HelperError(f"Unknown operation: {operation}")
if args.page < 1:
raise HelperError("--page must be >= 1")
if args.per_page < 1 or args.per_page > 100:
raise HelperError("--per-page must be in [1, 100]")
query: Dict[str, Any] = {
"page": args.page,
"perPage": args.per_page,
"returnType": "json",
}
for field in OPERATIONS[operation]["allowed"]:
attr = field.lower()
raw = getattr(args, attr, None)
if raw is None or str(raw).strip() == "":
continue
value = str(raw).strip()
if field in DATE_FIELDS:
value = validate_yyyymmdd(value, field)
elif field in YN_FIELDS:
upper = value.upper()
if upper not in {"Y", "N"}:
raise HelperError(f"{field} must be Y or N (got: {value!r})")
value = upper
elif field == "biz_yr":
if not (len(value) == 4 and value.isdigit()):
raise HelperError(f"biz_yr must be 4 digits (got: {value!r})")
query[field] = value
if (
operation == "announcements"
and query.get("pbanc_rcpt_bgng_dt")
and query.get("pbanc_rcpt_end_dt")
and query["pbanc_rcpt_bgng_dt"] > query["pbanc_rcpt_end_dt"]
):
raise HelperError("pbanc_rcpt_bgng_dt must be <= pbanc_rcpt_end_dt")
return query
def encode_query(query: Dict[str, Any]) -> str:
pairs: List[Tuple[str, str]] = [(k, str(v)) for k, v in query.items()]
return urllib.parse.urlencode(pairs, doseq=False, safe="")
def build_url(operation: str, query: Dict[str, Any], *, direct: bool, api_key: Optional[str], proxy_base_url: str) -> str:
if direct:
if not api_key:
raise HelperError(
"KSKILL_KSTARTUP_API_KEY (또는 DATA_GO_KR_API_KEY) 가 없습니다. "
"공공데이터포털 15125364 활용신청 후 키를 발급받아 환경변수나 ~/.config/k-skill/secrets.env 에 두세요."
)
path = OPERATIONS[operation]["path"]
with_key = dict(query)
with_key["ServiceKey"] = api_key
return f"{KSTARTUP_UPSTREAM_BASE_URL}/{path}?{encode_query(with_key)}"
base = proxy_base_url.rstrip("/")
return f"{base}/v1/kstartup/{operation}?{encode_query(query)}"
def http_get(url: str, *, timeout: int) -> Tuple[int, str, str]:
headers = {
"accept": "application/json",
"user-agent": "k-skill/kstartup-search",
}
request = urllib.request.Request(url, headers=headers, method="GET")
context = ssl.create_default_context()
try:
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
body = response.read().decode("utf-8", errors="replace")
return response.status, response.headers.get("content-type", ""), body
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
return exc.code, exc.headers.get("content-type", "") if exc.headers else "", body
except urllib.error.URLError as exc:
raise HelperError(f"network error: {exc.reason}") from exc
def _normalise_filter_token(field: str, token: str) -> str:
if field == "supt_regin":
return REGION_SHORTNAME.get(token, token)
return token
def _row_matches_token(row: Dict[str, Any], field: str, token: str) -> bool:
raw = row.get(field)
if raw is None:
return False
haystack = str(raw)
needle = _normalise_filter_token(field, token)
return needle in haystack
def _row_matches_field(row: Dict[str, Any], field: str, requested: str) -> bool:
tokens = [t.strip() for t in requested.split(",") if t.strip()]
if not tokens:
return True
return all(_row_matches_token(row, field, token) for token in tokens)
def apply_client_filters(
payload: Dict[str, Any],
args: argparse.Namespace,
operation: str,
) -> Dict[str, Any]:
if operation != "announcements":
return payload
requested: Dict[str, str] = {}
for field in CLIENT_FILTER_FIELDS:
value = getattr(args, field, None)
if value is None:
continue
text = str(value).strip()
if text:
requested[field] = text
if not requested:
return payload
data = payload.get("data")
if not isinstance(data, list):
return payload
upstream_count = len(data)
filtered = [
row for row in data
if isinstance(row, dict)
and all(_row_matches_field(row, field, value) for field, value in requested.items())
]
payload["data"] = filtered
payload["currentCount"] = len(filtered)
payload["client_filter"] = {
"fields": requested,
"upstream_returned": upstream_count,
"after_filter": len(filtered),
"note": "Applied after upstream response because K-Startup ignores some server-side filters.",
}
return payload
def summarise(operation: str, payload: Dict[str, Any]) -> str:
items: Iterable[Dict[str, Any]] = []
if isinstance(payload, dict):
data = payload.get("data") or payload.get("items")
if isinstance(data, list):
items = data
elif isinstance(payload.get("response"), dict):
response = payload["response"]
body = response.get("body") or {}
items = body.get("items") or []
items = list(items or [])
if not items:
return "[summary] 매칭되는 항목이 없습니다. 필터를 완화하거나 페이지를 넘기세요."
lines = [f"[summary] operation={operation} count={len(items)} (page={payload.get('query', {}).get('page', payload.get('page'))} perPage={payload.get('query', {}).get('perPage', payload.get('perPage'))})"]
for index, item in enumerate(items, start=1):
title = (
item.get("biz_pbanc_nm")
or item.get("supt_biz_titl_nm")
or item.get("titl_nm")
or item.get("intg_pbanc_biz_nm")
or "(제목 없음)"
)
region = item.get("supt_regin") or item.get("biz_category_cd") or item.get("clss_cd") or ""
period = ""
if item.get("pbanc_rcpt_bgng_dt") or item.get("pbanc_rcpt_end_dt"):
period = f" {item.get('pbanc_rcpt_bgng_dt','?')} ~ {item.get('pbanc_rcpt_end_dt','?')}"
url = item.get("detl_pg_url") or ""
lines.append(f" {index:>2}. {title} {region}{period}")
if url:
lines.append(f"{url}")
return "\n".join(lines)
def _add_filter_args(parser: argparse.ArgumentParser, operation: str) -> None:
allowed = OPERATIONS[operation]["allowed"]
for field in allowed:
flag = "--" + field.replace("_", "-").lower()
parser.add_argument(flag, dest=field.lower(), default=None,
help=f"K-Startup field: {field}")
def make_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="run_kstartup.py",
description="창업진흥원 K-Startup Open API (data.go.kr 15125364) 조회 helper",
)
subparsers = parser.add_subparsers(dest="operation", required=True)
for operation in OPERATIONS:
sub = subparsers.add_parser(operation, help=f"K-Startup {operation} endpoint")
sub.add_argument("--page", type=int, default=1)
sub.add_argument("--per-page", dest="per_page", type=int, default=10)
format_group = sub.add_mutually_exclusive_group()
format_group.add_argument("--text", action="store_true", help="사람용 요약")
format_group.add_argument("--json", action="store_true", help="구조화 JSON 출력 (기본)")
sub.add_argument("--dry-run", action="store_true", dest="dry_run",
help="요청 URL/파라미터만 출력, 네트워크 호출 없음")
sub.add_argument("--timeout", type=int, default=30)
sub.add_argument("--proxy-base-url", default=os.environ.get("KSKILL_PROXY_BASE_URL", DEFAULT_PROXY_BASE_URL))
sub.add_argument("--direct", action="store_true",
help="proxy 우회, KSKILL_KSTARTUP_API_KEY 로 직접 호출")
sub.add_argument("--secrets-path", default=DEFAULT_SECRETS_PATH,
help=f"--direct 시 secrets 파일 경로 (기본 {DEFAULT_SECRETS_PATH})")
_add_filter_args(sub, operation)
return parser
def run(argv: Optional[List[str]] = None) -> int:
parser = make_parser()
args = parser.parse_args(argv)
operation = args.operation
try:
query = build_query(args, operation)
except HelperError as exc:
print(f"[error] {exc}", file=sys.stderr)
return 2
if args.dry_run:
if args.direct:
preview = build_url(operation, query, direct=True, api_key="<DRY-RUN>", proxy_base_url=args.proxy_base_url)
else:
preview = build_url(operation, query, direct=False, api_key=None, proxy_base_url=args.proxy_base_url)
preview = preview.replace(os.environ.get("KSKILL_KSTARTUP_API_KEY", ""), "<DRY-RUN>") if os.environ.get("KSKILL_KSTARTUP_API_KEY") else preview
preview = preview.replace(os.environ.get("DATA_GO_KR_API_KEY", ""), "<DRY-RUN>") if os.environ.get("DATA_GO_KR_API_KEY") else preview
result = {"operation": operation, "url": preview, "query": query, "direct": bool(args.direct)}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
api_key = resolve_api_key(args) if args.direct else None
try:
url = build_url(operation, query, direct=args.direct, api_key=api_key, proxy_base_url=args.proxy_base_url)
except HelperError as exc:
print(f"[error] {exc}", file=sys.stderr)
return 3
try:
status, content_type, body = http_get(url, timeout=args.timeout)
except HelperError as exc:
print(f"[error] {exc}", file=sys.stderr)
return 4
payload: Any
try:
payload = json.loads(body) if body else {}
except json.JSONDecodeError:
print(f"[error] upstream returned non-JSON content-type={content_type!r} status={status}", file=sys.stderr)
print(body[:500])
return 5
if not isinstance(payload, dict):
payload = {"raw": payload}
payload.setdefault("query", query)
payload = apply_client_filters(payload, args, operation)
if args.text:
print(summarise(operation, payload))
else:
print(json.dumps(payload, ensure_ascii=False, indent=2))
if status >= 400:
return 6
return 0
if __name__ == "__main__":
raise SystemExit(run())

View file

View file

@ -0,0 +1,319 @@
"""Unit tests for kstartup-search helper.
stdlib unittest only; runs without DATA_GO_KR_API_KEY or network access.
"""
import argparse
import json
import os
import sys
import unittest
from io import StringIO
from unittest import mock
SCRIPT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts")
sys.path.insert(0, SCRIPT_DIR)
import run_kstartup # noqa: E402
def make_args(operation: str, **overrides):
defaults = {
"operation": operation,
"page": 1,
"per_page": 10,
"text": False,
"json": False,
"dry_run": True,
"timeout": 30,
"proxy_base_url": "https://example.test",
"direct": False,
"secrets_path": "/tmp/__nonexistent__.env",
}
for field in run_kstartup.OPERATIONS[operation]["allowed"]:
defaults[field.lower()] = None
defaults.update(overrides)
return argparse.Namespace(**defaults)
class BuildQueryTests(unittest.TestCase):
def test_announcements_normalizes_dates_and_yn(self):
args = make_args(
"announcements",
pbanc_rcpt_bgng_dt="2024-01-01",
pbanc_rcpt_end_dt="2024-12-31",
rcrt_prgs_yn="y",
supt_regin="서울특별시",
)
query = run_kstartup.build_query(args, "announcements")
self.assertEqual(query["pbanc_rcpt_bgng_dt"], "20240101")
self.assertEqual(query["pbanc_rcpt_end_dt"], "20241231")
self.assertEqual(query["rcrt_prgs_yn"], "Y")
self.assertEqual(query["supt_regin"], "서울특별시")
self.assertEqual(query["returnType"], "json")
self.assertEqual(query["page"], 1)
self.assertEqual(query["perPage"], 10)
def test_business_info_requires_4digit_year(self):
args = make_args("business-info", biz_yr="24")
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(args, "business-info")
def test_announcements_rejects_inverted_date_range(self):
args = make_args(
"announcements",
pbanc_rcpt_bgng_dt="20240601",
pbanc_rcpt_end_dt="20240101",
)
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(args, "announcements")
def test_announcements_rejects_impossible_calendar_date(self):
# Calendar-impossible dates (Feb 30, Apr 31, month 13, day 0) must be
# rejected by the Python helper so `--direct` mode does not drift from
# the proxy-side Date.UTC() validation in kstartup.js.
impossible_values = ["20240230", "20240431", "20241301", "20240100"]
for value in impossible_values:
args = make_args("announcements", pbanc_rcpt_bgng_dt=value)
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(args, "announcements")
# Leap-day boundary: 2024-02-29 is valid (leap), 2023-02-29 is not.
args_leap_ok = make_args("announcements", pbanc_rcpt_bgng_dt="20240229")
query = run_kstartup.build_query(args_leap_ok, "announcements")
self.assertEqual(query["pbanc_rcpt_bgng_dt"], "20240229")
args_leap_bad = make_args("announcements", pbanc_rcpt_bgng_dt="20230229")
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(args_leap_bad, "announcements")
def test_invalid_yn_raises(self):
args = make_args("announcements", rcrt_prgs_yn="maybe")
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(args, "announcements")
def test_per_page_bounds(self):
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(make_args("announcements", per_page=0), "announcements")
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(make_args("announcements", per_page=101), "announcements")
def test_contents_filter_passthrough(self):
args = make_args("contents", clss_cd="notice_matr", titl_nm="공모전")
query = run_kstartup.build_query(args, "contents")
self.assertEqual(query["clss_cd"], "notice_matr")
self.assertEqual(query["titl_nm"], "공모전")
class BuildUrlTests(unittest.TestCase):
def test_proxy_url(self):
args = make_args("announcements", supt_regin="서울특별시", rcrt_prgs_yn="Y")
query = run_kstartup.build_query(args, "announcements")
url = run_kstartup.build_url("announcements", query, direct=False, api_key=None, proxy_base_url=args.proxy_base_url)
self.assertTrue(url.startswith("https://example.test/v1/kstartup/announcements?"))
self.assertIn("rcrt_prgs_yn=Y", url)
self.assertNotIn("ServiceKey", url, "proxy URL must never carry ServiceKey client-side")
def test_direct_url_includes_service_key(self):
args = make_args("statistics", direct=True, titl_nm="창업기업 실태조사")
query = run_kstartup.build_query(args, "statistics")
url = run_kstartup.build_url("statistics", query, direct=True, api_key="dummy-key", proxy_base_url=args.proxy_base_url)
self.assertIn("apis.data.go.kr/B552735/kisedKstartupService01/getStatisticalInformation01", url)
self.assertIn("ServiceKey=dummy-key", url)
def test_direct_without_key_raises(self):
args = make_args("contents", direct=True)
query = run_kstartup.build_query(args, "contents")
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_url("contents", query, direct=True, api_key=None, proxy_base_url=args.proxy_base_url)
class SecretsLoaderTests(unittest.TestCase):
def test_returns_empty_when_missing(self):
self.assertEqual(run_kstartup.load_secrets("/tmp/__nonexistent_kstartup__.env"), {})
def test_parses_dotenv(self):
path = "/tmp/__kstartup_test_secrets__.env"
with open(path, "w", encoding="utf-8") as fh:
fh.write("# comment\nKSKILL_KSTARTUP_API_KEY=abc\nDATA_GO_KR_API_KEY=\"xyz\"\nEMPTY=\n")
try:
data = run_kstartup.load_secrets(path)
self.assertEqual(data["KSKILL_KSTARTUP_API_KEY"], "abc")
self.assertEqual(data["DATA_GO_KR_API_KEY"], "xyz")
self.assertEqual(data["EMPTY"], "")
finally:
os.unlink(path)
class DryRunIntegrationTests(unittest.TestCase):
def test_dry_run_outputs_proxy_url(self):
buf = StringIO()
with mock.patch.object(sys, "stdout", buf):
rc = run_kstartup.run([
"announcements",
"--supt-regin", "서울특별시",
"--rcrt-prgs-yn", "Y",
"--per-page", "5",
"--dry-run",
"--proxy-base-url", "https://example.test",
])
self.assertEqual(rc, 0)
out = buf.getvalue()
payload = json.loads(out)
self.assertEqual(payload["operation"], "announcements")
self.assertTrue(payload["url"].startswith("https://example.test/v1/kstartup/announcements?"))
self.assertEqual(payload["query"]["rcrt_prgs_yn"], "Y")
self.assertNotIn("ServiceKey", payload["url"])
def test_dry_run_direct_redacts_key(self):
buf = StringIO()
env = dict(os.environ)
env["KSKILL_KSTARTUP_API_KEY"] = "super-secret"
with mock.patch.dict(os.environ, env, clear=True):
with mock.patch.object(sys, "stdout", buf):
rc = run_kstartup.run([
"contents",
"--clss-cd", "notice_matr",
"--direct",
"--dry-run",
])
self.assertEqual(rc, 0)
payload = json.loads(buf.getvalue())
self.assertTrue(
"ServiceKey=<DRY-RUN>" in payload["url"]
or "ServiceKey=%3CDRY-RUN%3E" in payload["url"],
f"redacted ServiceKey not found in {payload['url']!r}",
)
self.assertNotIn("super-secret", payload["url"])
class ClientFilterTests(unittest.TestCase):
@staticmethod
def _payload(rows):
return {
"currentCount": len(rows),
"data": list(rows),
"totalCount": 999,
"page": 1,
"perPage": len(rows),
}
def test_supt_regin_drops_other_regions(self):
payload = self._payload([
{"biz_pbanc_nm": "서울 청년창업", "supt_regin": "서울"},
{"biz_pbanc_nm": "경북 모집", "supt_regin": "경북"},
{"biz_pbanc_nm": "충북 K-바이오", "supt_regin": "충북"},
])
args = make_args("announcements", supt_regin="서울특별시")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual(result["currentCount"], 1)
self.assertEqual(result["data"][0]["biz_pbanc_nm"], "서울 청년창업")
self.assertEqual(result["client_filter"]["upstream_returned"], 3)
self.assertEqual(result["client_filter"]["after_filter"], 1)
self.assertEqual(result["client_filter"]["fields"]["supt_regin"], "서울특별시")
def test_supt_regin_normalises_long_official_names(self):
rows = [
("서울특별시", "서울"),
("부산광역시", "부산"),
("경기도", "경기"),
("강원특별자치도", "강원"),
("전북특별자치도", "전북"),
("제주특별자치도", "제주"),
("세종특별자치시", "세종"),
]
for long_name, short_name in rows:
payload = self._payload([
{"biz_pbanc_nm": "match", "supt_regin": short_name},
{"biz_pbanc_nm": "other", "supt_regin": "전국"},
])
args = make_args("announcements", supt_regin=long_name)
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual(
[row["biz_pbanc_nm"] for row in result["data"]],
["match"],
f"long name {long_name!r} should match upstream short form {short_name!r}",
)
def test_supt_regin_short_form_also_works(self):
payload = self._payload([
{"biz_pbanc_nm": "match", "supt_regin": "서울"},
{"biz_pbanc_nm": "other", "supt_regin": "경기"},
])
args = make_args("announcements", supt_regin="서울")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["match"])
def test_supt_regin_handles_nationwide_rows_explicitly(self):
payload = self._payload([
{"biz_pbanc_nm": "전국 공모", "supt_regin": "전국"},
{"biz_pbanc_nm": "서울 공모", "supt_regin": "서울특별시"},
])
args = make_args("announcements", supt_regin="서울특별시")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["서울 공모"])
def test_aply_trgt_substring_match_in_comma_list(self):
payload = self._payload([
{"biz_pbanc_nm": "예비창업자 대상", "aply_trgt": "일반인,일반기업,예비창업자"},
{"biz_pbanc_nm": "일반 대상", "aply_trgt": "일반인,일반기업"},
])
args = make_args("announcements", aply_trgt="예비창업자")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual(len(result["data"]), 1)
self.assertEqual(result["data"][0]["biz_pbanc_nm"], "예비창업자 대상")
def test_multiple_filters_are_anded(self):
payload = self._payload([
{"biz_pbanc_nm": "ok", "supt_regin": "서울특별시", "aply_trgt": "예비창업자"},
{"biz_pbanc_nm": "wrong-region", "supt_regin": "경기도", "aply_trgt": "예비창업자"},
{"biz_pbanc_nm": "wrong-target", "supt_regin": "서울특별시", "aply_trgt": "일반인"},
])
args = make_args(
"announcements",
supt_regin="서울특별시",
aply_trgt="예비창업자",
)
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["ok"])
def test_comma_separated_request_requires_all_tokens(self):
payload = self._payload([
{"biz_pbanc_nm": "match-all", "biz_enyy": "예비창업자,1년미만,2년미만"},
{"biz_pbanc_nm": "missing-one", "biz_enyy": "예비창업자"},
])
args = make_args("announcements", biz_enyy="예비창업자,1년미만")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["match-all"])
def test_no_client_filter_args_is_passthrough(self):
payload = self._payload([{"biz_pbanc_nm": "x", "supt_regin": "전국"}])
args = make_args("announcements")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual(result["currentCount"], 1)
self.assertNotIn("client_filter", result)
def test_non_announcements_operations_are_passthrough(self):
payload = self._payload([{"titl_nm": "공모전 공지"}])
args = make_args("contents")
result = run_kstartup.apply_client_filters(payload, args, "contents")
self.assertEqual(result["currentCount"], 1)
self.assertNotIn("client_filter", result)
def test_empty_filter_value_is_treated_as_unset(self):
payload = self._payload([{"supt_regin": "경기도"}])
args = make_args("announcements", supt_regin=" ")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertNotIn("client_filter", result)
def test_missing_field_in_row_is_not_matched(self):
payload = self._payload([
{"biz_pbanc_nm": "has-field", "supt_regin": "서울특별시"},
{"biz_pbanc_nm": "no-field"},
])
args = make_args("announcements", supt_regin="서울특별시")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["has-field"])
if __name__ == "__main__":
unittest.main()

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.

192
ohou-today-deal/SKILL.md Normal file
View file

@ -0,0 +1,192 @@
---
name: ohou-today-deal
description: 오늘의집 공개 오늘의딜 페이지에서 로그인 없이 특가 상품을 조회하고 할인율, 가격, 리뷰, 링크를 정리하는 읽기 전용 스킬.
license: MIT
metadata:
category: retail
locale: ko-KR
phase: v1
---
# 오늘의집 오늘의딜 조회
## What this skill does
오늘의집 공개 오늘의딜 페이지(`https://ohou.se/commerces/today_deals`)의 서버 렌더링 초기 데이터(`__NEXT_DATA__`)를 읽어 특가 상품을 조회한다.
- 오늘의딜/스페셜딜 상품 목록 조회
- 할인율, 원가, 판매가, 쿠폰/결제혜택 반영 최저가 정리
- 브랜드, 리뷰 수, 평점, 무료배송 여부, 상품 링크 확인
- 키워드, 최소 할인율, 무료배송 필터
## When to use
- "오늘의집 오늘의딜 뭐 있어?"
- "오늘의집에서 할인율 높은 특가 상품 3개 보여줘"
- "오늘의집 무료배송 특가만 골라줘"
- "오늘의집에서 러그 특가 찾아줘"
## When not to use
- 로그인, 장바구니, 구매, 결제 자동화 — 이 스킬은 의도적으로 구매 플로우를 포함하지 않는다.
- 개인화 추천, 사용자별 쿠폰 적용 확정, 실시간 재고 보장.
- 법적 증빙 수준의 가격 확정 — 조회 시점 기준 참고용이다.
- 차단 우회, CAPTCHA 우회 — 표준 라이브러리 `urllib` 한 호출로 안 되면 실패 모드로 처리한다.
## Required inputs
별도 입력 없이 실행 가능. 선택적으로 아래를 지정할 수 있다:
- `--query`: 상품명/브랜드 키워드
- `--min-discount`: 최소 할인율 (0~100 정수)
- `--free-delivery`: 무료배송 상품만
- `--sort`: 정렬 기준 (`discount`, `price`, `review`, `annual-sales`)
- `--limit`: 결과 개수 (양의 정수, 기본 10)
- `--html-file`: 오프라인 HTML/JSON fixture 경로
## Official/public surface
- 오늘의집 오늘의딜 페이지: `https://ohou.se/commerces/today_deals`
- 현재 웹 페이지는 canonical/OG URL로 `https://store.ohou.se/today_deals`를 노출하지만, 브라우저 접근용 공개 URL은 `ohou.se/commerces/today_deals`다.
- 응답 HTML의 Next.js `__NEXT_DATA__` 안 React Query `dehydratedState`에서 `today-deal-feed`, `special-today-deal-feed` queryKey 두 곳의 `todayDealFeed.slots`만 명시적으로 읽는다. 다른 페이지 모듈(navigation, banner 등)에 `type: DEAL` 노드가 있어도 무시한다.
- HTTP 요청은 `User-Agent: k-skill-ohou-today-deal/1.0 (+https://github.com/NomaDamas/k-skill)`로 보낸다. ohou.se 앞단 Akamai bot manager는 익명/단축 UA를 차단하지만 봇 이름 + contact URL이 포함된 well-formed UA는 통과시키므로 우회/조작 없이 정직한 자기소개로 요청한다.
## Prerequisites
- `python3`
- 별도 로그인/API 키 없음
## Workflow
### 1. 오늘의딜 상품 조회
오늘의집 오늘의딜 공개 페이지에서 상품 목록을 가져온다. 기본 정렬은 할인율 높은 순이다.
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list --limit 10
```
응답 예시:
```json
{
"source": {
"name": "ohou-today-deal",
"url": "https://ohou.se/commerces/today_deals",
"fetched_at": "2026-05-18T01:44:16+00:00",
"surface": "__NEXT_DATA__ today-deal-feed + special-today-deal-feed"
},
"filters": {"query": null, "min_discount": null, "free_delivery": false, "sort": "discount", "limit": 10},
"count": 10,
"total_count": 72,
"filtered_count": 72,
"items": [
{
"id": "823405",
"title": "삼익가구 BEST상품 총집합",
"brand": "삼익가구",
"url": "https://ohou.se/productions/823405/selling",
"original_price": 449000,
"selling_price": 132000,
"discount_rate": 70,
"best_price": 118800,
"best_discount_rate": 73,
"best_discount_description": "쿠폰 할인가",
"review_count": 53818,
"review_average": 4.7,
"free_delivery": false,
"sold_out": false
}
]
}
```
### 2. 할인율 높은 순 정렬
`bestDiscountPrice.discountRate`(쿠폰/결제혜택 반영 할인율)가 있으면 우선 사용하고, 없으면 상품 기본 `discountRate`를 사용한다.
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--sort discount \
--limit 5
```
정렬 옵션: `discount`(할인율), `price`(낮은 가격), `review`(리뷰 많은 순), `annual-sales`(연간 판매량).
### 3. 키워드·할인율·무료배송 필터
상품명 또는 브랜드에 키워드가 포함된 상품만 걸러내고, 최소 할인율과 무료배송 조건을 조합할 수 있다.
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--query 러그 \
--min-discount 30 \
--free-delivery \
--limit 5
```
### 4. 오프라인 fixture로 검증
실제 네트워크 없이 저장된 HTML/JSON 파일로 동일한 파싱을 테스트한다.
```bash
python3 ohou-today-deal/scripts/ohou_today_deal.py list \
--html-file ./today-deals.html \
--limit 3
```
## Output format
기본 출력은 들여쓰기 JSON (`indent=2`). 파이프/스크립트에서 사용할 때는 출력을 `jq` 등으로 후처리한다.
주요 필드:
| 필드 | 설명 |
|---|---|
| `source.fetched_at` | 조회 시각 (UTC ISO 8601) |
| `count` | 반환된 상품 수 |
| `total_count` | 전체 오늘의딜 상품 수 |
| `filtered_count` | 필터 적용 후 상품 수 |
| `items[].best_price` | 쿠폰/결제혜택 반영 최저가 (없으면 null) |
| `items[].best_discount_rate` | 혜택 반영 할인율 (없으면 null) |
| `items[].free_delivery` | 무료배송 여부 |
| `items[].sold_out` | 품절 여부 |
## Endpoints used
이 스킬이 호출하는 공개 endpoint:
| Method | URL | 용도 |
|---|---|---|
| GET | `https://ohou.se/commerces/today_deals` | 오늘의딜 공개 HTML (서버 렌더링) |
비로그인 / 무인증. 헤더는 `User-Agent` + `Accept` 만.
## Response policy
- 상위 3~5개만 먼저 보여준다.
- 상품명, 브랜드, 할인가, 원가, 할인율, 평점/리뷰 수, 무료배송 여부, 링크를 정리한다.
- 가격, 할인, 품절, 쿠폰/결제혜택은 "조회 시각 기준"으로 변동 가능하다고 명시한다.
- 구매/장바구니/결제는 자동화하지 말고 상품 링크만 제공한다.
- "지금 사라" 같은 행위 유도 금지 — 사용자가 직접 페이지에서 구매한다.
## Done when
- 오늘의딜 상품 후보가 JSON 또는 요약 목록으로 반환된다.
- 할인율/가격 기준과 조회 시점이 분리되어 설명된다.
- 로그인, 구매, 결제, 개인화 기능을 시도하지 않았다.
## Failure modes
- **`__NEXT_DATA__` 미발견**: 오늘의집이 Next.js SSR 구조를 변경하거나, 서버 렌더링 대신 클라이언트 렌더링으로 전환하면 `ValueError` 발생. 스킬 파서 수정이 필요하다.
- **today-deal-feed queryKey 미발견**: React Query 키 이름이 바뀌면 `extract_deals()`는 빈 리스트를 반환한다 (`total_count: 0`). `TODAY_DEAL_FEED_KEYS` 상수를 새 키 이름으로 업데이트해야 한다.
- **HTTP 403**: ohou.se 앞단 Akamai bot manager가 요청을 차단한 경우. `User-Agent` 헤더가 변경되어 봇 자기소개 + contact URL 시그니처를 잃었을 가능성이 높다. 우회 시도하지 않고 에러 출력 후 종료한다.
- **HTTP 4xx/5xx (기타)**: 일시 장애. 우회 시도하지 않고 에러 출력 후 종료.
- **빈 응답 (`total_count: 0`)**: 오늘의딜이 아직 업데이트되지 않았거나, 페이지 구조가 바뀐 경우. 브라우저에서 직접 확인을 안내한다.
- **가격/쿠폰 변동**: `best_price`는 조회 시점 기준이며, 사용자별 쿠폰/결제수단에 따라 실제 결제가는 다를 수 있다.
- **필드 누락**: 일부 상품에 `bestDiscountPrice`, `badgeProperties.isFreeDelivery`, `scrapInfo` 등이 없을 수 있다. null로 처리된다.
## Notes
- read-only 스킬이다.
- 화면 선택자보다 서버 렌더링 초기 JSON을 우선한다.
- 새 dependency 없이 Python 표준 라이브러리만 사용한다.

View file

@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""ohou-today-deal — 오늘의집 공개 오늘의딜 특가 상품 조회 CLI.
조회 전용. 로그인·장바구니·구매·결제 자동화 없음.
__NEXT_DATA__ 서버 렌더링 초기 데이터만 읽는 read-only 스킬.
Usage:
ohou-today-deal list [--limit N] [--sort discount|price|review|annual-sales]
ohou-today-deal list --query 러그 --min-discount 30 --free-delivery
ohou-today-deal list --html-file ./fixture.html
Supported surface:
https://ohou.se/commerces/today_deals (공개 HTML)
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import urllib.request
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
DEFAULT_URL = "https://ohou.se/commerces/today_deals"
DEFAULT_USER_AGENT = (
"k-skill-ohou-today-deal/1.0 (+https://github.com/NomaDamas/k-skill)"
)
TODAY_DEAL_FEED_KEYS: tuple[tuple[str, ...], ...] = (
("today-deal-feed",),
("special-today-deal-feed",),
)
@dataclass(frozen=True)
class OhouDeal:
id: str
title: str
brand: str | None
url: str
image_url: str | None
original_price: int | None
selling_price: int | None
discount_rate: int | None
best_price: int | None
best_discount_rate: int | None
best_discount_description: str | None
review_count: int
review_average: float | None
scrap_count: int | None
annual_sales: int | None
free_delivery: bool
sold_out: bool
start_at: str | None
end_at: str | None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def _to_int(value: Any) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _to_float(value: Any) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def fetch_html(url: str = DEFAULT_URL, timeout: int = 20) -> str:
# ohou.se Akamai 정책: 익명 UA(`python-urllib`, `Mozilla/5.0` 단독)는 403,
# 봇 이름+contact URL이 포함된 well-formed UA는 허용. 우회가 아닌 자기소개.
request = urllib.request.Request(
url,
headers={
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "text/html,application/json",
},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
charset = response.headers.get_content_charset() or "utf-8"
return response.read().decode(charset, errors="replace")
def extract_next_data(document: str) -> dict[str, Any]:
stripped = document.lstrip()
if stripped.startswith("{"):
return json.loads(stripped)
match = re.search(
r'<script\b[^>]*\bid=["\']__NEXT_DATA__[^>]*>(.*?)</script>',
document,
re.DOTALL,
)
if not match:
raise ValueError("Could not find __NEXT_DATA__ in Today Deal HTML")
return json.loads(html.unescape(match.group(1)))
def _walk(value: Any):
"""스택 기반 DFS로 JSON 트리의 모든 dict 노드를 순회한다.
__NEXT_DATA__는 깊고 거대한 트리 구조를 가질 있어
재귀 대신 반복문을 사용해 sys.getrecursionlimit() 제한을 회피한다.
"""
stack = [value]
while stack:
curr = stack.pop()
if isinstance(curr, dict):
yield curr
stack.extend(curr.values())
elif isinstance(curr, list):
stack.extend(reversed(curr))
def _looks_like_deal_node(node: dict[str, Any]) -> bool:
deal = node.get("deal")
return (
node.get("type") == "DEAL"
and isinstance(deal, dict)
and bool(deal.get("id"))
and bool(deal.get("name"))
)
def _iter_today_deal_feeds(payload: dict[str, Any]):
"""__NEXT_DATA__에서 today-deal-feed / special-today-deal-feed 쿼리만 골라낸다.
React Query dehydrated state는 `props.pageProps.dehydratedState.queries`
`[{queryKey, state: {data: {...}}}]` 형태로 저장된다. queryKey가 일치하는
엔트리의 `state.data.todayDealFeed.slots` today-deal 콘텐츠로 인정한다.
"""
allowed = {tuple(key) for key in TODAY_DEAL_FEED_KEYS}
queries = (
payload.get("props", {})
.get("pageProps", {})
.get("dehydratedState", {})
.get("queries", [])
)
if not isinstance(queries, list):
return
for entry in queries:
if not isinstance(entry, dict):
continue
query_key = entry.get("queryKey")
if not isinstance(query_key, list):
continue
if tuple(query_key) not in allowed:
continue
state = entry.get("state") or {}
data = state.get("data") or {}
feed = data.get("todayDealFeed") or {}
slots = feed.get("slots")
if isinstance(slots, list):
yield tuple(query_key), slots
def _normalize_deal(node: dict[str, Any]) -> OhouDeal:
deal = node.get("deal", {})
price = deal.get("price") or {}
best_price = node.get("bestDiscountPrice") or {}
brand = deal.get("brand") or {}
review = deal.get("reviewStatistic") or {}
scrap = deal.get("scrapInfo") or {}
badge = deal.get("badgeProperties") or {}
annual_sales = node.get("salesStats", {}).get("annualCumulativeSales")
deal_id = str(deal.get("id", ""))
return OhouDeal(
id=deal_id,
title=str(deal.get("name") or node.get("title") or ""),
brand=brand.get("name"),
url=f"https://ohou.se/productions/{deal_id}/selling",
image_url=deal.get("imageUrl"),
original_price=_to_int(price.get("representativeOriginalPrice")),
selling_price=_to_int(price.get("representativeSellingPrice")),
discount_rate=_to_int(price.get("discountRate")),
best_price=_to_int(best_price.get("price")),
best_discount_rate=_to_int(best_price.get("discountRate")),
best_discount_description=best_price.get("discountPlanDescription"),
review_count=_to_int(review.get("reviewCount")) or 0,
review_average=_to_float(review.get("reviewAverage")),
scrap_count=_to_int(scrap.get("scrapCount")),
annual_sales=_to_int(annual_sales),
free_delivery=bool(badge.get("isFreeDelivery")),
sold_out=bool(deal.get("isSoldOut")),
start_at=node.get("startAt"),
end_at=node.get("endAt"),
)
def extract_deals(payload: dict[str, Any]) -> list[OhouDeal]:
seen: set[str] = set()
deals: list[OhouDeal] = []
found_feed = False
for _query_key, slots in _iter_today_deal_feeds(payload):
found_feed = True
for node in slots:
if not isinstance(node, dict) or not _looks_like_deal_node(node):
continue
deal = _normalize_deal(node)
if deal.id in seen:
continue
seen.add(deal.id)
deals.append(deal)
# Fixture/legacy fallback: payload에 React Query dehydratedState가 없거나
# queryKey 구조가 다른 경우(테스트 fixture, 단순화된 페이로드)에 한해서만
# 전체 트리를 DFS로 훑어 DEAL 노드를 수집한다. 라이브 페이지는 항상
# 위쪽 명시적 분기에서 잡히므로 이 fallback은 영향받지 않는다.
if not found_feed:
for node in _walk(payload):
if not _looks_like_deal_node(node):
continue
deal = _normalize_deal(node)
if deal.id in seen:
continue
seen.add(deal.id)
deals.append(deal)
return deals
def filter_deals(
deals: list[OhouDeal],
*,
query: str | None = None,
min_discount: int | None = None,
free_delivery: bool = False,
include_sold_out: bool = False,
) -> list[OhouDeal]:
"""단일 루프로 모든 필터 조건을 검사한다."""
needle = query.casefold() if query else None
filtered: list[OhouDeal] = []
for deal in deals:
if not include_sold_out and deal.sold_out:
continue
if needle and needle not in deal.title.casefold() and needle not in (deal.brand or "").casefold():
continue
if min_discount is not None and (deal.best_discount_rate or deal.discount_rate or 0) < min_discount:
continue
if free_delivery and not deal.free_delivery:
continue
filtered.append(deal)
return filtered
def sort_deals(deals: list[OhouDeal], sort_key: str) -> list[OhouDeal]:
if sort_key == "discount":
return sorted(
deals,
key=lambda deal: (deal.best_discount_rate or deal.discount_rate or -1, deal.review_count),
reverse=True,
)
if sort_key == "price":
return sorted(deals, key=lambda deal: deal.best_price or deal.selling_price or sys.maxsize)
if sort_key == "review":
return sorted(deals, key=lambda deal: (deal.review_count, deal.review_average or 0), reverse=True)
if sort_key == "annual-sales":
return sorted(deals, key=lambda deal: deal.annual_sales or -1, reverse=True)
return deals
def build_payload(args: argparse.Namespace) -> dict[str, Any]:
document = Path(args.html_file).read_text(encoding="utf-8") if args.html_file else fetch_html(args.url)
payload = extract_next_data(document)
deals = extract_deals(payload)
filtered = filter_deals(
deals,
query=args.query,
min_discount=args.min_discount,
free_delivery=args.free_delivery,
include_sold_out=args.include_sold_out,
)
sorted_deals = sort_deals(filtered, args.sort)
limited_deals = sorted_deals[: args.limit]
kst = timezone(timedelta(hours=9))
now_utc = datetime.now(timezone.utc)
return {
"source": {
"name": "ohou-today-deal",
"url": args.url,
"fetched_at": now_utc.isoformat(),
"fetched_at_kst": now_utc.astimezone(kst).strftime("%Y-%m-%d %H:%M:%S KST"),
"surface": "__NEXT_DATA__ today-deal-feed + special-today-deal-feed",
},
"filters": {
"query": args.query,
"min_discount": args.min_discount,
"free_delivery": args.free_delivery,
"include_sold_out": args.include_sold_out,
"sort": args.sort,
"limit": args.limit,
},
"count": len(limited_deals),
"total_count": len(deals),
"filtered_count": len(filtered),
"items": [deal.to_dict() for deal in limited_deals],
}
def _positive_int(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"expected integer, got {value!r}") from exc
if parsed <= 0:
raise argparse.ArgumentTypeError(f"must be a positive integer, got {parsed}")
return parsed
def _discount_rate(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"expected integer, got {value!r}") from exc
if not 0 <= parsed <= 100:
raise argparse.ArgumentTypeError(
f"discount rate must be between 0 and 100, got {parsed}"
)
return parsed
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Read Ohouse today deal products from public HTML.")
subparsers = parser.add_subparsers(dest="command", required=True)
list_parser = subparsers.add_parser("list", help="오늘의집 오늘의딜 상품 목록")
list_parser.add_argument("--url", default=DEFAULT_URL)
list_parser.add_argument("--html-file", help="테스트/오프라인 검증용 HTML 또는 JSON 파일")
list_parser.add_argument("--query", help="상품명 또는 브랜드 키워드")
list_parser.add_argument("--min-discount", type=_discount_rate, help="최소 할인율 (0~100)")
list_parser.add_argument("--free-delivery", action="store_true", help="무료배송 상품만")
list_parser.add_argument("--include-sold-out", action="store_true", help="품절 상품 포함")
list_parser.add_argument(
"--sort",
choices=["default", "discount", "price", "review", "annual-sales"],
default="discount",
)
list_parser.add_argument("--limit", type=_positive_int, default=10, help="결과 개수 (양의 정수)")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> None:
args = parse_args(argv)
if args.command == "list":
print(json.dumps(build_payload(args), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

39
package-lock.json generated
View file

@ -650,6 +650,10 @@
"resolved": "packages/donation-place-search",
"link": true
},
"node_modules/emergency-room-beds": {
"resolved": "packages/emergency-room-beds",
"link": true
},
"node_modules/enquirer": {
"version": "2.4.1",
"dev": true,
@ -1086,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,
@ -1561,6 +1569,10 @@
"version": "2.7.2",
"license": "MIT"
},
"node_modules/sh-notice-search": {
"resolved": "packages/sh-notice-search",
"link": true
},
"node_modules/shebang-command": {
"version": "2.0.0",
"dev": true,
@ -1787,6 +1799,13 @@
"node": ">=18"
}
},
"packages/emergency-room-beds": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/gangnamunni-clinic-search": {
"version": "0.1.0",
"license": "MIT",
@ -1885,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",
@ -1906,6 +1935,16 @@
"node": ">=18"
}
},
"packages/sh-notice-search": {
"version": "0.1.0",
"license": "MIT",
"bin": {
"sh-notice-search": "src/cli.js"
},
"engines": {
"node": ">=18"
}
},
"packages/toss-securities": {
"version": "0.3.0",
"license": "MIT",

View file

@ -10,10 +10,10 @@
"scripts": {
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/ticket_availability.py scripts/test_ticket_availability.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py 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",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ticket_availability && 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' && 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",
"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 && npm pack --workspace local-election-candidate-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -20,10 +20,8 @@ npm install
- 매장명과 상품명 둘 다 필요합니다.
- 공식 다이소몰 표면을 우선 사용합니다.
- 현재 확인된 공식 표면은 **매장 픽업 재고**를 제공하지만, 다이소몰 보안 정책에 따라 `Unauthorized` 로 차단될 수 있습니다.
- 매장 픽업 재고가 차단되면 `pickupStock.status === "unavailable"`, `retrievalStatus === "blocked"`, `reason === "unauthorized"` 로 반환하고, 공식 픽업 가능 매장 목록(`selPkupStr`) 으로 그 매장의 **픽업 가능 여부** 만이라도 `pickupEligibility` 로 회수합니다. 수량은 여전히 알 수 없습니다.
- `selStrPkupStck` 는 Bearer 토큰 인증이 필요합니다. `/api/auth/request` 로 비로그인 JWT를 받아 AES-128-CBC / 키 `"PRE_AUTH_ENC_KEY"` 로 암호화한 뒤 Bearer 헤더로 전달합니다. 401/403 응답 시 토큰을 재발급해 1회 재시도합니다. 그래도 인증이 막히면 수량 조회는 `retrievalStatus: "blocked"` 로 반환하고 `selPkupStr` 픽업 가능 여부 폴백을 사용할 수 있습니다.
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 실제 재고 여부는 `inStock` 또는 `inventoryStatus` (`"in_stock"`, `"out_of_stock"`, `"unknown"`) 를 기준으로 판단합니다.
- 가능한 경우 `onlineStock.referenceOnly === true` 인 온라인 재고 참고값을 함께 확인할 수 있지만, 매장 재고로 단정해서는 안 됩니다.
- 공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로 응답해야 합니다.
## 사용 예시
@ -41,6 +39,8 @@ async function main() {
console.log(result.selectedStore)
console.log(result.selectedProduct)
console.log(result.pickupStock)
console.log(result.pickupEligibility)
console.log(result.onlineStock)
}
main().catch((error) => {
@ -75,44 +75,6 @@ main().catch((error) => {
}
```
2026-05-05 현재 `selStrPkupStck``Unauthorized` 로 차단되는 경우가 확인되어, 이 패키지는 해당 응답을 예외로 전파하지 않고 아래 형태로 정규화합니다. 이 동작은 세션 우회 없이 공식 표면의 제한을 보수적으로 보고하기 위한 것입니다.
```json
{
"pickupStock": {
"strCd": "10224",
"pdNo": "1049275",
"quantity": null,
"inStock": null,
"status": "unavailable",
"retrievalStatus": "blocked",
"inventoryStatus": "unknown",
"reason": "unauthorized",
"message": "Daiso Mall blocked store pickup stock lookup with Unauthorized."
}
}
```
2026-05-08 부터는 매장 픽업 재고가 차단되면 공식 픽업 가능 매장 목록 표면(`selPkupStr`)을 추가로 호출해 그 매장이 해당 상품의 픽업 가능 매장에 들어 있는지 여부만이라도 회수합니다. 수량은 여전히 알 수 없지만, "그 매장에서 이 상품을 픽업으로 살 수 있는지" 는 답할 수 있게 됩니다.
```json
{
"pickupEligibility": {
"pdNo": "1049275",
"strCd": "10224",
"pickupEligible": true,
"eligibleStoreCount": 1,
"matchedStore": {
"strCd": "10224",
"name": "강남역2호점",
"pickupAvailable": true,
"openTime": "10:00",
"closeTime": "22:00"
},
"retrievalStatus": "resolved"
}
}
```
## 공개 API
@ -121,15 +83,15 @@ main().catch((error) => {
- `searchProducts(query, options?)`
- 반환되는 각 상품 후보는 `pdNo` 와 함께 `onldPdNo` 를 포함할 수 있습니다. 다이소몰 온라인 재고 표면이 별도 마스터 상품 번호를 요구하는 경우 이 값을 그대로 `getOnlineStock()` 에 넘기면 됩니다.
- `getStorePickupStock({ pdNo, strCd }, options?)`
- 호출 전 `/api/auth/request` 로 Bearer 토큰을 자동 빌드합니다. 401/403 응답 시 토큰을 재발급해 1회 재시도합니다.
- 성공한 조회는 `status: "available"`, `retrievalStatus: "resolved"` 를 포함합니다. 여기서 `status` 는 조회 성공 범주이며 상품 재고 여부가 아닙니다.
- 실제 재고 여부는 `inStock` 또는 `inventoryStatus` 로 확인합니다. 수량이 0이면 `status: "available"` 이면서 `inventoryStatus: "out_of_stock"` 일 수 있습니다.
- 다이소몰이 매장 픽업 재고를 `401`/`403` 또는 `{ "success": false, "message": "Unauthorized" }` 로 차단하면 `status: "unavailable"`, `retrievalStatus: "blocked"`, `inventoryStatus: "unknown"` 결과를 반환합니다.
- 인증이 계속 막히면 예외 대신 `status: "unavailable"`, `retrievalStatus: "blocked"`, `inventoryStatus: "unknown"` 를 반환합니다.
- `getStorePickupEligibility({ pdNo, strCd, storeName?, keyword?, pageSize? }, options?)`
- 공식 `POST /api/ms/msg/selPkupStr` 표면을 호출해 해당 상품의 픽업 가능 매장 목록을 받아 `pickupEligible` 여부를 판정합니다.
- `storeName` 이 주어지면 매장명에서 `N호점` 같은 접미사를 제거해 `keyword` 로 자동 변환합니다. `keyword` 를 직접 넘기면 그대로 사용합니다. `strCd` 조회에서 `storeName`/`keyword` 가 없거나 첫 페이지가 전체 결과를 다 덮지 못하면 확정 `false` 대신 `pickupEligible: null`, `retrievalStatus: "insufficient_coverage"` 를 반환합니다.
- 응답은 `pickupEligible`(`true`/`false`/`null`), `eligibleStoreCount`, `eligibleStores`, `matchedStore`, `searchedKeyword`, `pageSize`, `totalCount`, `retrievalStatus`, `raw` 를 포함합니다.
- 정확한 수량은 제공되지 않습니다. 수량 확인은 `selStrPkupStck` 를 통해야 하며 차단 시에는 확인 불가입니다.
- `selPkupStr` 로 특정 상품의 픽업 가능 매장 목록을 조회해 선택 매장이 픽업 가능 매장인지 확인합니다.
- 수량은 제공하지 않으며 `pickupEligible` (`true`/`false`/`null`) 과 `retrievalStatus` (`"resolved"`, `"blocked"`, `"insufficient_coverage"`) 로 폴백 판단을 전달합니다.
- `getOnlineStock({ pdNo, onldPdNo? }, options?)`
- 반환값은 `referenceOnly: true` 를 포함합니다. 온라인 재고는 다이소몰 온라인몰 재고 참고값이며 특정 매장의 픽업/진열 재고가 아닙니다.
- `lookupStoreProductAvailability({ storeQuery, productQuery, includePickupEligibility?, ...options })`
- `pickupStock.retrievalStatus === "blocked"` 일 때만 `selPkupStr` 폴백을 호출해 `pickupEligibility` 를 채웁니다. `includePickupEligibility: false` 로 끌 수 있습니다.
- `lookupStoreProductAvailability({ storeQuery, productQuery, ...options })`
- 매장·상품 검색 → Bearer 인증 → 픽업 재고 조회를 한 번에 처리합니다.
- 픽업 재고 인증이 계속 막혀 `pickupStock.retrievalStatus === "blocked"` 이면 `pickupEligibility``selPkupStr` 기반 픽업 가능 여부를 채웁니다. 필요 없으면 `includePickupEligibility: false` 를 전달합니다.

View file

@ -1,3 +1,4 @@
const crypto = require("node:crypto")
const {
BASE_API_URL,
BASE_SEARCH_URL,
@ -26,10 +27,37 @@ const DEFAULT_BROWSER_HEADERS = {
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
}
const PRE_AUTH_ENC_KEY = Buffer.from("PRE_AUTH_ENC_KEY", "utf8")
function selectPickupPreferredProduct(products) {
return products.find((product) => product.pickupAvailable) || products[0]
}
async function requestText(url, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.")
}
const response = await fetchImpl(url, {
method: options.method || "GET",
headers: { ...DEFAULT_BROWSER_HEADERS, ...(options.headers || {}) },
signal: options.signal
})
const text = await response.text()
if (!response.ok) {
throw new DaisoRequestError(`Daiso request failed with ${response.status} for ${url}`, {
status: response.status,
url
})
}
return { text, response }
}
async function requestJson(url, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch
@ -67,11 +95,29 @@ async function requestJson(url, options = {}) {
return payload
}
function isPickupStockUnauthorizedError(error) {
return (
error instanceof DaisoRequestError &&
(error.status === 401 || error.status === 403) &&
(!error.payload || /unauthorized/i.test(String(error.payload.message || "")))
async function buildBearerToken(options = {}) {
const { text: jwt, response } = await requestText(`${BASE_API_URL}/auth/request`, options)
const uid = response.headers.get("x-dm-uid") || ""
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv("aes-128-cbc", PRE_AUTH_ENC_KEY, iv)
const encrypted = Buffer.concat([cipher.update(jwt.trim(), "utf8"), cipher.final()])
const bearer = Buffer.from(iv).toString("base64") + Buffer.from(encrypted).toString("base64")
return { bearer, uid }
}
function isAuthBlockedError(error) {
return error instanceof DaisoRequestError && (error.status === 401 || error.status === 403)
}
function normalizeAuthBlockedStock(request, error) {
return normalizeStorePickupStockResponse(
{
success: false,
message: "Unauthorized",
status: error && error.status,
upstreamPayload: error && error.payload ? error.payload : null
},
request
)
}
@ -115,25 +161,37 @@ async function searchProducts(query, options = {}) {
}
async function getStorePickupStock(request, options = {}) {
try {
const body = [{ pdNo: String(request.pdNo), strCd: String(request.strCd) }]
async function requestStockWithFreshToken() {
const { bearer, uid } = await buildBearerToken(options)
const payload = await requestJson(`${BASE_API_URL}/pd/pdh/selStrPkupStck`, {
...options,
method: "POST",
body: [
{
pdNo: String(request.pdNo),
strCd: String(request.strCd)
}
]
headers: {
...(options.headers || {}),
Authorization: `Bearer ${bearer}`,
"X-DM-UID": uid
},
body
})
return normalizeStorePickupStockResponse(payload, request)
}
try {
return await requestStockWithFreshToken()
} catch (error) {
if (isPickupStockUnauthorizedError(error)) {
return normalizeStorePickupStockResponse(
error.payload || { success: false, message: "Unauthorized", status: error.status },
request
)
if (!isAuthBlockedError(error)) {
throw error
}
}
try {
return await requestStockWithFreshToken()
} catch (error) {
if (isAuthBlockedError(error)) {
return normalizeAuthBlockedStock(request, error)
}
throw error
@ -279,6 +337,7 @@ async function lookupStoreProductAvailability(options = {}) {
options
)
}
const onlineStock = await onlineStockPromise
return {

View file

@ -26,9 +26,27 @@ const searchGoodsPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "se
const storeDetailPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-detail.json"), "utf8"))
const storePickupStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-pickup-stock.json"), "utf8"))
const onlineStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "online-stock.json"), "utf8"))
const storePickupEligibilityPayload = JSON.parse(
fs.readFileSync(path.join(fixturesDir, "store-pickup-eligibility.json"), "utf8")
)
const storePickupEligibilityPayload = {
data: [
{
strCd: "10224",
strNm: "강남역2호점",
strAddr: "서울특별시 강남구 강남대로",
strDtlAddr: "지하 1층",
strTno: "02-1234-5678",
pkupYn: "Y",
opngTime: "1000",
clsngTime: "2200",
km: "0.2",
strLttd: "37.498095",
strLitd: "127.02761",
totalCnt: 1,
currentPageCnt: 1
}
],
success: true
}
const liveSearchGoodsPayload = {
resultSet: {
result: [
@ -144,6 +162,22 @@ const pickupSelectionSearchGoodsPayload = {
}
}
function makeResponse(body, options = {}) {
return new Response(JSON.stringify(body), {
status: options.status || 200,
headers: {
"content-type": "application/json"
}
})
}
function makeAuthResponse() {
return new Response("test.jwt.token", {
status: 200,
headers: { "content-type": "text/plain", "x-dm-uid": "test-uid-123" }
})
}
test("normalizeStoreSearchResponse prefers the closest exact-name store match", () => {
const items = normalizeStoreSearchResponse(storeSearchPayload, "강남역2호점")
@ -263,6 +297,10 @@ test("public client helpers can consume injected fetch fixtures", async () => {
const originalFetch = global.fetch
global.fetch = async (url) => {
if (String(url).includes("/api/auth/request")) {
return makeAuthResponse()
}
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo")) {
return makeResponse(storeSearchPayload)
}
@ -318,380 +356,40 @@ test("public client helpers can consume injected fetch fixtures", async () => {
}
})
test("getStorePickupStock converts Daiso pickup-stock 401 responses to unavailable results", async () => {
test("getStorePickupStock builds a Bearer token and retries with a fresh token on 403", async () => {
const originalFetch = global.fetch
const stockRequests = []
let authCallCount = 0
global.fetch = async (url) => {
assert.match(String(url), /\/api\/pd\/pdh\/selStrPkupStck$/)
return makeResponse({ success: false, message: "Unauthorized" }, { status: 401 })
global.fetch = async (url, init = {}) => {
if (String(url).includes("/api/auth/request")) {
authCallCount++
return makeAuthResponse()
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
stockRequests.push({ headers: init.headers, body: JSON.parse(init.body) })
if (stockRequests.length === 1) {
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
}
return makeResponse(storePickupStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const pickupStock = await getStorePickupStock({ pdNo: "1049275", strCd: "10224" })
assert.equal(pickupStock.status, "unavailable")
assert.equal(pickupStock.retrievalStatus, "blocked")
assert.equal(pickupStock.inventoryStatus, "unknown")
assert.equal(pickupStock.reason, "unauthorized")
assert.equal(pickupStock.quantity, null)
assert.equal(pickupStock.inStock, null)
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability keeps online-stock fallback when Daiso pickup stock is unauthorized", async () => {
const originalFetch = global.fetch
global.fetch = async (url) => {
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
return makeResponse(storeSearchPayload)
assert.equal(stockRequests.length, 2)
assert.equal(authCallCount, 2)
for (const request of stockRequests) {
assert.match(request.headers.Authorization, /^Bearer /)
assert.equal(request.headers["X-DM-UID"], "test-uid-123")
assert.deepEqual(request.body, [{ pdNo: "1049275", strCd: "10224" }])
}
if (String(url).includes("/ssn/search/SearchGoods")) {
return makeResponse(searchGoodsPayload)
}
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
return makeResponse(storeDetailPayload)
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
assert.equal(availability.pickupStock.status, "unavailable")
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
assert.equal(availability.pickupStock.inventoryStatus, "unknown")
assert.equal(availability.pickupStock.reason, "unauthorized")
assert.equal(availability.onlineStock.quantity, 13047)
assert.equal(availability.onlineStock.referenceOnly, true)
assert.notEqual(availability.pickupEligibility, null)
assert.equal(availability.pickupEligibility.pickupEligible, true)
assert.equal(availability.pickupEligibility.matchedStore.strCd, "10224")
assert.equal(availability.pickupEligibility.retrievalStatus, "resolved")
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability still resolves pickup eligibility when online stock fails", async () => {
const originalFetch = global.fetch
global.fetch = async (url) => {
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
return makeResponse(storeSearchPayload)
}
if (String(url).includes("/ssn/search/SearchGoods")) {
return makeResponse(searchGoodsPayload)
}
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
return makeResponse(storeDetailPayload)
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse({ success: false, message: "Internal Server Error" }, { status: 500 })
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
assert.equal(availability.pickupEligibility.pickupEligible, true)
assert.equal(availability.pickupEligibility.matchedStore.strCd, "10224")
assert.equal(availability.onlineStock, null)
} finally {
global.fetch = originalFetch
}
})
test("normalizePickupEligibilityResponse marks selected store as eligible when present in list", () => {
const eligibility = normalizePickupEligibilityResponse(storePickupEligibilityPayload, {
pdNo: "1049275",
strCd: "10224"
})
assert.equal(eligibility.pickupEligible, true)
assert.equal(eligibility.eligibleStoreCount, 1)
assert.equal(eligibility.matchedStore.strCd, "10224")
assert.equal(eligibility.matchedStore.name, "강남역2호점")
assert.equal(eligibility.matchedStore.pickupAvailable, true)
assert.equal(eligibility.matchedStore.openTime, "10:00")
assert.equal(eligibility.retrievalStatus, "resolved")
})
test("normalizePickupEligibilityResponse marks selected store as NOT eligible when absent from list", () => {
const eligibility = normalizePickupEligibilityResponse(storePickupEligibilityPayload, {
pdNo: "1049275",
strCd: "99999"
})
assert.equal(eligibility.pickupEligible, false)
assert.equal(eligibility.eligibleStoreCount, 1)
assert.equal(eligibility.matchedStore, null)
assert.equal(eligibility.retrievalStatus, "resolved")
})
test("normalizePickupEligibilityResponse requires pickupAvailable for positive eligibility", () => {
const payload = {
...storePickupEligibilityPayload,
data: storePickupEligibilityPayload.data.map((item) => ({ ...item, pkupYn: "N" }))
}
const eligibility = normalizePickupEligibilityResponse(payload, {
pdNo: "1049275",
strCd: "10224",
keyword: "강남역",
pageSize: 50
})
assert.equal(eligibility.pickupEligible, false)
assert.equal(eligibility.matchedStore.strCd, "10224")
assert.equal(eligibility.matchedStore.pickupAvailable, false)
assert.equal(eligibility.retrievalStatus, "resolved")
})
test("normalizePickupEligibilityResponse avoids a definitive miss when the first page may be incomplete", () => {
const payload = {
...storePickupEligibilityPayload,
data: [
{
...storePickupEligibilityPayload.data[0],
totalCnt: 2,
currentPageCnt: 1,
strCd: "10000",
strNm: "서울테스트점"
}
]
}
const eligibility = normalizePickupEligibilityResponse(payload, {
pdNo: "1049275",
strCd: "10224",
keyword: "서울",
pageSize: 1
})
assert.equal(eligibility.pickupEligible, null)
assert.equal(eligibility.reason, "search_page_not_exhausted")
assert.equal(eligibility.retrievalStatus, "insufficient_coverage")
assert.equal(eligibility.searchedKeyword, "서울")
assert.equal(eligibility.totalCount, 2)
})
test("normalizePickupEligibilityResponse handles upstream failure as blocked retrieval", () => {
const eligibility = normalizePickupEligibilityResponse(
{ success: false, message: "Upstream error" },
{ pdNo: "1049275", strCd: "10224" }
)
assert.equal(eligibility.pickupEligible, null)
assert.equal(eligibility.eligibleStoreCount, null)
assert.equal(eligibility.eligibleStores.length, 0)
assert.equal(eligibility.matchedStore, null)
assert.equal(eligibility.retrievalStatus, "blocked")
assert.equal(eligibility.reason, "upstream_error")
})
test("getStorePickupEligibility posts pdNo and a derived store keyword to selPkupStr", async () => {
const originalFetch = global.fetch
let capturedBody = null
let capturedUrl = null
global.fetch = async (url, init = {}) => {
capturedUrl = String(url)
capturedBody = JSON.parse(init.body)
return makeResponse(storePickupEligibilityPayload)
}
try {
const eligibility = await getStorePickupEligibility({
pdNo: "1049275",
strCd: "10224",
storeName: "강남역2호점"
})
assert.match(capturedUrl, /\/api\/ms\/msg\/selPkupStr$/)
assert.equal(capturedBody.pdNo, "1049275")
assert.equal(capturedBody.keyword, "강남역")
assert.equal(capturedBody.currentPage, 1)
assert.equal(typeof capturedBody.pageSize, "number")
assert.equal(eligibility.pickupEligible, true)
assert.equal(eligibility.matchedStore.strCd, "10224")
} finally {
global.fetch = originalFetch
}
})
test("getStorePickupEligibility does not emit a definitive false without a store keyword", async () => {
const originalFetch = global.fetch
let fetchCalled = false
global.fetch = async () => {
fetchCalled = true
return makeResponse({ ...storePickupEligibilityPayload, data: [] })
}
try {
const eligibility = await getStorePickupEligibility({
pdNo: "1049275",
strCd: "10224"
})
assert.equal(fetchCalled, false)
assert.equal(eligibility.pickupEligible, null)
assert.equal(eligibility.retrievalStatus, "insufficient_coverage")
assert.equal(eligibility.reason, "missing_search_keyword")
} finally {
global.fetch = originalFetch
}
})
test("getStorePickupEligibility surfaces upstream HTTP errors as blocked retrieval", async () => {
const originalFetch = global.fetch
global.fetch = async () =>
makeResponse({ success: false, message: "Internal Server Error" }, { status: 500 })
try {
const eligibility = await getStorePickupEligibility({
pdNo: "1049275",
strCd: "10224",
storeName: "강남역2호점"
})
assert.equal(eligibility.pickupEligible, null)
assert.equal(eligibility.matchedStore, null)
assert.equal(eligibility.retrievalStatus, "blocked")
assert.equal(eligibility.reason, "upstream_error")
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability skips pickup eligibility lookup when pickup stock resolves", async () => {
const originalFetch = global.fetch
let eligibilityCalled = false
global.fetch = async (url) => {
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
return makeResponse(storeSearchPayload)
}
if (String(url).includes("/ssn/search/SearchGoods")) {
return makeResponse(searchGoodsPayload)
}
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
return makeResponse(storeDetailPayload)
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
return makeResponse(storePickupStockPayload)
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
eligibilityCalled = true
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
assert.equal(availability.pickupStock.retrievalStatus, "resolved")
assert.equal(eligibilityCalled, false)
assert.equal(availability.pickupEligibility, null)
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability respects includePickupEligibility=false even when pickup stock is blocked", async () => {
const originalFetch = global.fetch
let eligibilityCalled = false
global.fetch = async (url) => {
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
return makeResponse(storeSearchPayload)
}
if (String(url).includes("/ssn/search/SearchGoods")) {
return makeResponse(searchGoodsPayload)
}
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
return makeResponse(storeDetailPayload)
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
eligibilityCalled = true
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100",
includePickupEligibility: false
})
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
assert.equal(eligibilityCalled, false)
assert.equal(availability.pickupEligibility, null)
assert.equal(pickupStock.quantity, 3)
assert.equal(pickupStock.retrievalStatus, "resolved")
} finally {
global.fetch = originalFetch
}
@ -701,6 +399,10 @@ test("lookupStoreProductAvailability falls back to pdNo when live SearchGoods re
const originalFetch = global.fetch
global.fetch = async (url, init = {}) => {
if (String(url).includes("/api/auth/request")) {
return makeAuthResponse()
}
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo")) {
return makeResponse(storeSearchPayload)
}
@ -750,6 +452,10 @@ test("lookupStoreProductAvailability prefers pickup-capable products over higher
const originalFetch = global.fetch
global.fetch = async (url, init = {}) => {
if (String(url).includes("/api/auth/request")) {
return makeAuthResponse()
}
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo")) {
return makeResponse(storeSearchPayload)
}
@ -822,6 +528,10 @@ test("lookupStoreProductAvailability reuses a product candidate's online stock i
}
global.fetch = async (url, init = {}) => {
if (String(url).includes("/api/auth/request")) {
return makeAuthResponse()
}
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo")) {
return makeResponse(storeSearchPayload)
}
@ -881,11 +591,223 @@ test("lookupStoreProductAvailability reuses a product candidate's online stock i
}
})
function makeResponse(body, options = {}) {
return new Response(JSON.stringify(body), {
status: options.status || 200,
headers: {
"content-type": "application/json"
test("getStorePickupStock sends Bearer auth headers and returns blocked after repeated auth failures", async () => {
const originalFetch = global.fetch
const stockRequests = []
let authCallCount = 0
global.fetch = async (url, init = {}) => {
if (String(url).includes("/api/auth/request")) {
authCallCount++
return makeAuthResponse()
}
})
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
stockRequests.push({ headers: init.headers, body: JSON.parse(init.body) })
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
}
return new Response("not found", { status: 404 })
}
try {
const pickupStock = await getStorePickupStock({ pdNo: "1049275", strCd: "10224" })
assert.equal(authCallCount, 2)
assert.equal(stockRequests.length, 2)
for (const request of stockRequests) {
assert.match(request.headers.Authorization, /^Bearer /)
assert.equal(request.headers["X-DM-UID"], "test-uid-123")
assert.deepEqual(request.body, [{ pdNo: "1049275", strCd: "10224" }])
}
assert.equal(pickupStock.status, "unavailable")
assert.equal(pickupStock.retrievalStatus, "blocked")
assert.equal(pickupStock.reason, "unauthorized")
} finally {
global.fetch = originalFetch
}
})
test("getStorePickupStock preserves caller headers while auth headers take precedence", async () => {
const originalFetch = global.fetch
let capturedHeaders = null
global.fetch = async (url, init = {}) => {
if (String(url).includes("/api/auth/request")) {
return makeAuthResponse()
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
capturedHeaders = init.headers
return makeResponse(storePickupStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
await getStorePickupStock(
{ pdNo: "1049275", strCd: "10224" },
{
headers: {
"X-Trace-Id": "trace-207",
Authorization: "Bearer caller-value",
"X-DM-UID": "caller-uid"
}
}
)
assert.equal(capturedHeaders["X-Trace-Id"], "trace-207")
assert.match(capturedHeaders.Authorization, /^Bearer /)
assert.notEqual(capturedHeaders.Authorization, "Bearer caller-value")
assert.equal(capturedHeaders["X-DM-UID"], "test-uid-123")
} finally {
global.fetch = originalFetch
}
})
test("getStorePickupEligibility posts pdNo and a derived store keyword to selPkupStr", async () => {
const originalFetch = global.fetch
let capturedBody = null
let capturedUrl = null
global.fetch = async (url, init = {}) => {
capturedUrl = String(url)
capturedBody = JSON.parse(init.body)
return makeResponse(storePickupEligibilityPayload)
}
try {
const eligibility = await getStorePickupEligibility({
pdNo: "1049275",
strCd: "10224",
storeName: "강남역2호점"
})
assert.match(capturedUrl, /\/api\/ms\/msg\/selPkupStr$/)
assert.equal(capturedBody.pdNo, "1049275")
assert.equal(capturedBody.keyword, "강남역")
assert.equal(capturedBody.currentPage, 1)
assert.equal(typeof capturedBody.pageSize, "number")
assert.equal(eligibility.pickupEligible, true)
assert.equal(eligibility.matchedStore.strCd, "10224")
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability falls back to pickup eligibility when Bearer stock remains forbidden", async () => {
const originalFetch = global.fetch
let eligibilityCalled = false
global.fetch = async (url) => {
if (String(url).includes("/api/auth/request")) {
return makeAuthResponse()
}
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
return makeResponse(storeSearchPayload)
}
if (String(url).includes("/ssn/search/SearchGoods")) {
return makeResponse(searchGoodsPayload)
}
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
return makeResponse(storeDetailPayload)
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
eligibilityCalled = true
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
assert.equal(eligibilityCalled, true)
assert.equal(availability.pickupEligibility.pickupEligible, true)
assert.equal(availability.pickupEligibility.matchedStore.strCd, "10224")
assert.equal(availability.onlineStock.quantity, 13047)
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability falls back to pickup eligibility when token issuance is forbidden", async () => {
const originalFetch = global.fetch
let eligibilityCalled = false
global.fetch = async (url) => {
if (String(url).includes("/api/auth/request")) {
return new Response("forbidden", { status: 403, headers: { "content-type": "text/plain" } })
}
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
return makeResponse(storeSearchPayload)
}
if (String(url).includes("/ssn/search/SearchGoods")) {
return makeResponse(searchGoodsPayload)
}
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
return makeResponse(storeDetailPayload)
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
eligibilityCalled = true
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
assert.equal(availability.pickupStock.inventoryStatus, "unknown")
assert.equal(eligibilityCalled, true)
assert.equal(availability.pickupEligibility.pickupEligible, true)
} finally {
global.fetch = originalFetch
}
})
test("normalizePickupEligibilityResponse keeps blocked fallback shape stable", () => {
const eligibility = normalizePickupEligibilityResponse(
{ success: false, message: "Upstream error" },
{ pdNo: "1049275", strCd: "10224" }
)
assert.equal(eligibility.pickupEligible, null)
assert.equal(eligibility.eligibleStoreCount, null)
assert.deepEqual(eligibility.eligibleStores, [])
assert.equal(eligibility.matchedStore, null)
assert.equal(eligibility.retrievalStatus, "blocked")
assert.equal(eligibility.reason, "upstream_error")
})

View file

@ -0,0 +1,62 @@
# emergency-room-beds
Nearby Korean emergency-room lookup backed by E-Gen's public emergency-room search surface.
## What it can and cannot report
- It resolves a user-provided location to coordinates, then calls E-Gen's public nearby emergency-room list endpoint.
- It reports distance, hospital category, address, phone, update time, and operation flags such as emergency-room operation and inpatient-bed operation.
- Operation flags are tri-state: `true` for upstream `Y`, `false` for upstream `N`, and `null` when E-Gen omits or changes a flag value.
- It does **not** claim exact real-time remaining bed counts. The public E-Gen nearby list exposes operation flags, not per-hospital remaining bed numbers.
- For emergencies, call 119 or the hospital directly. Public E-Gen/Kakao data can lag, fail, or be incomplete and is not medical advice.
## Public surfaces
- NEMC monitoring entry point: `https://dw.nemc.or.kr/nemcMonitoring/mainmgr/Main.do`
- E-Gen emergency-room search page: `https://www.e-gen.or.kr/egen/search_emergency_room.do`
- E-Gen nearby emergency-room list endpoint: `https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do`
- Kakao Map mobile search: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map place panel JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
## Usage
```js
const { searchNearbyEmergencyRoomsByLocationQuery } = require("emergency-room-beds");
async function main() {
const result = await searchNearbyEmergencyRoomsByLocationQuery("광화문", {
limit: 3,
radius: 5
});
console.log(result.anchor);
console.log(result.items);
console.log(result.meta.bedCountLimitation);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Public API
- `parseCoordinateQuery(locationQuery)`
- `buildEmergencyRoomListRequest(options)`
- `normalizeEmergencyRoomRows(payload, origin, options)`
- `searchNearbyEmergencyRoomsByCoordinates(options)`
- `searchNearbyEmergencyRoomsByLocationQuery(locationQuery, options)`
## Result fields
Each item includes:
- `name`, `emergencyGrade`, `hospitalType`
- `address`, `phone`, `latitude`, `longitude`, `distanceKm`
- `bedStatus.emergencyRoomOperating`
- `bedStatus.inpatientBedsOperating`
- `bedStatus.traumaCenter`
- `bedStatus.pediatricSpecialty`
- `bedStatus.currentGeneralCareAvailable`
- `updatedAt`, `sourceUrl`, `mapUrl`

View file

@ -0,0 +1,32 @@
{
"name": "emergency-room-beds",
"version": "0.1.0",
"description": "Public E-Gen nearby emergency room status lookup for Korean location queries",
"license": "MIT",
"main": "src/index.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",
"korea",
"emergency-room",
"e-gen",
"hospital"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,275 @@
const {
isValidLatitude,
isValidLongitude,
normalizeAnchorPanel,
normalizeEmergencyRoomRows,
parseCoordinateQuery,
parseSearchResultsHtml,
rankAnchorCandidates
} = require("./parse");
const SEARCH_VIEW_URL = "https://m.map.kakao.com/actions/searchView";
const PLACE_PANEL_URL_BASE = "https://place-api.map.kakao.com/places/panel3";
const EGEN_EMERGENCY_ROOM_LIST_URL = "https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do";
const EGEN_REFERER_URL = "https://www.e-gen.or.kr/egen/search_emergency_room.do";
const BED_COUNT_LIMITATION = "E-Gen nearby ER list exposes operation flags, not exact real-time remaining bed counts.";
const DEFAULT_BROWSER_HEADERS = {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
};
const DEFAULT_PANEL_HEADERS = {
...DEFAULT_BROWSER_HEADERS,
accept: "application/json, text/plain, */*",
appVersion: "6.6.0",
origin: "https://place.map.kakao.com",
pf: "PC",
referer: "https://place.map.kakao.com/"
};
const DEFAULT_JSON_HEADERS = {
accept: "application/json, text/javascript, */*; q=0.01",
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
origin: "https://www.e-gen.or.kr",
referer: EGEN_REFERER_URL,
"user-agent": DEFAULT_BROWSER_HEADERS["user-agent"],
"x-requested-with": "XMLHttpRequest"
};
async function request(url, options = {}, responseType = "json") {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.");
}
const response = await fetchImpl(url, {
method: options.method,
body: options.body,
headers: {
...(options.headerSet || (responseType === "json" ? DEFAULT_JSON_HEADERS : DEFAULT_BROWSER_HEADERS)),
...(options.headers || {})
},
signal: options.signal
});
if (!response.ok) {
const error = new Error(`Request failed with ${response.status} for ${url}`);
error.status = response.status;
error.url = url;
throw error;
}
return responseType === "json" ? response.json() : response.text();
}
function normalizeBoundedInteger(value, defaultValue, label, min, max) {
if (value === undefined || value === null || value === "") {
return defaultValue;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
throw new Error(`${label} must be between ${min} and ${max}.`);
}
return parsed;
}
function normalizeCoordinate(value, label, isValid) {
const parsed = Number(value);
if (!isValid(parsed)) {
throw new Error(`${label} must be between ${label === "latitude" ? "-90 and 90" : "-180 and 180"}.`);
}
return parsed;
}
function normalizeCoordinates(options = {}) {
const latitude = Number(options.latitude ?? options.lat);
const longitude = Number(options.longitude ?? options.lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("latitude and longitude must be finite numbers.");
}
return {
latitude: normalizeCoordinate(latitude, "latitude", isValidLatitude),
longitude: normalizeCoordinate(longitude, "longitude", isValidLongitude)
};
}
function normalizeOrder(order) {
const value = String(order || "distance").trim();
if (!["distance", "accuracy"].includes(value)) {
throw new Error("order must be one of: distance, accuracy.");
}
return value;
}
function normalizeEmergencyGradeCodes(value) {
if (Array.isArray(value)) {
return value.map((entry) => String(entry).trim()).filter(Boolean).join(",");
}
return String(value || "").trim();
}
function buildEmergencyRoomListRequest(options = {}) {
const { latitude, longitude } = normalizeCoordinates(options);
const radius = normalizeBoundedInteger(options.radius ?? options.maxDistanceKm, 3, "radius", 1, 50);
const currentPageNum = normalizeBoundedInteger(options.currentPageNum ?? options.pageNo, 1, "currentPageNum", 1, 1000);
const body = new URLSearchParams();
body.set("lat", String(latitude));
body.set("lon", String(longitude));
body.set("emoggrdcStr", normalizeEmergencyGradeCodes(options.emergencyGradeCodes ?? options.emoggrdcStr));
body.set("silson24", options.silson24 ? "Y" : "N");
body.set("emogdesc", String(options.hospitalName || options.emogdesc || "").trim());
body.set("radius", String(radius));
body.set("order", normalizeOrder(options.order));
body.set("currentPageNum", String(currentPageNum));
return {
url: options.apiBaseUrl || EGEN_EMERGENCY_ROOM_LIST_URL,
method: "POST",
body
};
}
async function fetchEmergencyRoomList(options = {}) {
const requestOptions = buildEmergencyRoomListRequest(options);
return request(
requestOptions.url,
{
...options,
method: requestOptions.method,
body: requestOptions.body,
headerSet: DEFAULT_JSON_HEADERS
},
"json",
);
}
async function fetchSearchResults(query, options = {}) {
const url = new URL(SEARCH_VIEW_URL);
url.searchParams.set("q", String(query || "").trim());
return request(url.toString(), options, "text");
}
async function fetchPlacePanel(confirmId, options = {}) {
return request(`${PLACE_PANEL_URL_BASE}/${confirmId}`, { ...options, headerSet: DEFAULT_PANEL_HEADERS }, "json");
}
function isRecoverablePlacePanelError(error) {
const status = Number(error?.status);
return status === 404 || status === 410;
}
async function resolveAnchor(locationQuery, options = {}) {
const anchorSearchHtml = await fetchSearchResults(locationQuery, options);
const anchorCandidates = parseSearchResultsHtml(anchorSearchHtml);
const rankedCandidates = rankAnchorCandidates(locationQuery, anchorCandidates);
for (const candidate of rankedCandidates) {
let anchorPanel;
try {
anchorPanel = await fetchPlacePanel(candidate.id, options);
} catch (error) {
if (isRecoverablePlacePanelError(error)) {
continue;
}
throw error;
}
const anchor = normalizeAnchorPanel(anchorPanel, candidate);
if (Number.isFinite(anchor.latitude) && Number.isFinite(anchor.longitude)) {
return { anchor, anchorCandidates: rankedCandidates };
}
}
throw new Error(`No usable Kakao Map place panel was available for ${locationQuery}.`);
}
function buildMeta(payload, options, total) {
const limit = normalizeBoundedInteger(options.limit, 5, "limit", 1, 50);
const radius = normalizeBoundedInteger(options.radius ?? options.maxDistanceKm, 3, "radius", 1, 50);
return {
total,
upstreamTotal: payload?.paging?.totalCount ?? null,
limit,
radius,
source: "e-gen",
sourceUrl: EGEN_REFERER_URL,
dashboardUrl: "https://dw.nemc.or.kr/nemcMonitoring/mainmgr/Main.do",
bedCountLimitation: BED_COUNT_LIMITATION
};
}
async function searchNearbyEmergencyRoomsByCoordinates(options = {}) {
const { latitude, longitude } = normalizeCoordinates(options);
const limit = normalizeBoundedInteger(options.limit, 5, "limit", 1, 50);
const payload = await fetchEmergencyRoomList({ ...options, latitude, longitude });
const allItems = normalizeEmergencyRoomRows(payload, { latitude, longitude }, options);
return {
anchor: {
name: options.anchorName || "입력 좌표",
address: options.anchorAddress || null,
latitude,
longitude
},
items: allItems.slice(0, limit),
meta: buildMeta(payload, { ...options, limit }, allItems.length)
};
}
async function searchNearbyEmergencyRoomsByLocationQuery(locationQuery, options = {}) {
const coordinateQuery = parseCoordinateQuery(locationQuery);
if (coordinateQuery) {
return searchNearbyEmergencyRoomsByCoordinates({
...options,
...coordinateQuery,
anchorName: "입력 좌표"
});
}
const { anchor, anchorCandidates } = await resolveAnchor(locationQuery, options);
const result = await searchNearbyEmergencyRoomsByCoordinates({
...options,
latitude: anchor.latitude,
longitude: anchor.longitude,
anchorName: anchor.name,
anchorAddress: anchor.address
});
return {
...result,
anchor,
meta: {
...result.meta,
anchorCandidates: anchorCandidates.length
}
};
}
module.exports = {
BED_COUNT_LIMITATION,
DEFAULT_JSON_HEADERS,
EGEN_EMERGENCY_ROOM_LIST_URL,
buildEmergencyRoomListRequest,
fetchEmergencyRoomList,
normalizeEmergencyRoomRows,
parseCoordinateQuery,
searchNearbyEmergencyRoomsByCoordinates,
searchNearbyEmergencyRoomsByLocationQuery
};

View file

@ -0,0 +1,295 @@
const SEARCH_ITEM_PATTERN = /<li\s+class="search_item\s+base"([\s\S]*?)<\/li>/giu;
const TAG_PATTERN = /<[^>]+>/g;
const NON_WORD_PATTERN = /[^\p{L}\p{N}]+/gu;
function decodeHtml(value) {
return String(value || "")
.replace(/&amp;/g, "&")
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
function stripTags(value) {
return decodeHtml(String(value || "").replace(TAG_PATTERN, " "))
.replace(/\s+/g, " ")
.trim();
}
function normalizeText(value) {
return String(value || "")
.normalize("NFKC")
.toLowerCase()
.replace(NON_WORD_PATTERN, "");
}
function extractAttribute(fragment, name) {
const match = fragment.match(new RegExp(`${name}="([^"]*)"`, "iu"));
return match ? decodeHtml(match[1]).trim() : "";
}
function extractInnerText(fragment, className) {
const match = fragment.match(
new RegExp(`<[^>]+class="[^"]*${className}[^"]*"[^>]*>([\\s\\S]*?)<\\/[^>]+>`, "iu"),
);
return match ? stripTags(match[1]) : "";
}
function parseSearchResultsHtml(html) {
const items = [];
let match;
while ((match = SEARCH_ITEM_PATTERN.exec(String(html || ""))) !== null) {
const fragment = match[1];
const id = extractAttribute(fragment, "data-id");
const name = extractAttribute(fragment, "data-title") || extractInnerText(fragment, "tit_g");
if (!id || !name) {
continue;
}
const addressMatches = [...fragment.matchAll(/<span class="txt_g">([\s\S]*?)<\/span>/giu)]
.map((entry) => stripTags(entry[1]))
.filter(Boolean);
items.push({
id,
name,
category: extractInnerText(fragment, "txt_ginfo"),
address: addressMatches.at(-1) || "",
phone: extractAttribute(fragment, "data-phone") || extractInnerText(fragment, "num_phone") || null
});
}
return items;
}
function scoreAnchorCandidate(query, item) {
const normalizedQuery = normalizeText(query);
const normalizedName = normalizeText(item.name);
const normalizedAddress = normalizeText(item.address);
let score = 0;
if (!normalizedQuery) {
return score;
}
if (normalizedName === normalizedQuery) {
score += 1000;
}
if (normalizedName.startsWith(normalizedQuery)) {
score += 800;
}
if (normalizedName.includes(normalizedQuery)) {
score += 600;
}
if (normalizedAddress.includes(normalizedQuery)) {
score += 120;
}
return score;
}
function rankAnchorCandidates(query, items) {
return [...(items || [])].sort((left, right) => {
const scoreDelta = scoreAnchorCandidate(query, right) - scoreAnchorCandidate(query, left);
if (scoreDelta !== 0) {
return scoreDelta;
}
return left.name.localeCompare(right.name, "ko");
});
}
function normalizeAnchorPanel(panel, searchItem = {}) {
const summary = panel.summary || {};
return {
id: String(summary.confirm_id || searchItem.id || ""),
name: summary.name || searchItem.name || "",
category: summary.category?.name3 || summary.category?.name2 || searchItem.category || "",
address: summary.address?.disp || searchItem.address || "",
phone: summary.phone_numbers?.[0]?.tel || searchItem.phone || null,
latitude: toNumber(summary.point?.lat),
longitude: toNumber(summary.point?.lon),
sourceUrl: summary.confirm_id ? `https://place.map.kakao.com/${summary.confirm_id}` : null
};
}
function parseCoordinateQuery(locationQuery) {
const match = String(locationQuery || "")
.trim()
.match(/^(-?\d+(?:\.\d+)?)\s*[,/ ]\s*(-?\d+(?:\.\d+)?)$/);
if (!match) {
return null;
}
const latitude = Number(match[1]);
const longitude = Number(match[2]);
if (!isValidCoordinatePair(latitude, longitude)) {
return null;
}
return { latitude, longitude };
}
function toNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(String(value).replace(/,/g, ""));
return Number.isFinite(parsed) ? parsed : null;
}
function isValidLatitude(value) {
return Number.isFinite(value) && value >= -90 && value <= 90;
}
function isValidLongitude(value) {
return Number.isFinite(value) && value >= -180 && value <= 180;
}
function isValidCoordinatePair(latitude, longitude) {
return isValidLatitude(latitude) && isValidLongitude(longitude);
}
function toBooleanYesNo(value) {
const normalized = String(value ?? "").trim().toUpperCase();
if (normalized === "Y") {
return true;
}
if (normalized === "N") {
return false;
}
return null;
}
function buildMapUrl(name, latitude, longitude) {
return `https://map.kakao.com/link/map/${encodeURIComponent(name)},${latitude},${longitude}`;
}
function parseEgenTimestamp(value) {
const text = String(value || "").trim();
const match = text.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/);
if (!match) {
return null;
}
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}+09:00`;
}
function haversineDistanceMeters(latitudeA, longitudeA, latitudeB, longitudeB) {
const earthRadiusMeters = 6371008.8;
const toRadians = (value) => (value * Math.PI) / 180;
const deltaLatitude = toRadians(latitudeB - latitudeA);
const deltaLongitude = toRadians(longitudeB - longitudeA);
const originLatitude = toRadians(latitudeA);
const targetLatitude = toRadians(latitudeB);
const value =
Math.sin(deltaLatitude / 2) ** 2 +
Math.cos(originLatitude) * Math.cos(targetLatitude) * Math.sin(deltaLongitude / 2) ** 2;
return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
}
function getEmergencyRoomRows(payload) {
if (Array.isArray(payload)) {
return payload;
}
if (Array.isArray(payload?.list)) {
return payload.list;
}
throw new Error("Unexpected E-Gen emergency room payload shape.");
}
function normalizeEmergencyRoomRows(payload, origin, options = {}) {
const latitude = Number(origin?.latitude);
const longitude = Number(origin?.longitude);
const radius = Number.isFinite(Number(options.radius ?? options.maxDistanceKm)) ? Number(options.radius ?? options.maxDistanceKm) : null;
if (!isValidCoordinatePair(latitude, longitude)) {
throw new Error("normalizeEmergencyRoomRows requires valid origin coordinates.");
}
return getEmergencyRoomRows(payload)
.map((row) => {
const itemLatitude = toNumber(row.LAT ?? row.lat);
const itemLongitude = toNumber(row.LON ?? row.lon);
if (!isValidCoordinatePair(itemLatitude, itemLongitude)) {
return null;
}
const distanceKm = toNumber(row.DISTANCE2 ?? row.DISTANCE) ?? haversineDistanceMeters(latitude, longitude, itemLatitude, itemLongitude) / 1000;
const name = String(row.TITLE || row.name || "").trim();
if (!name) {
return null;
}
return {
id: String(row.EMOGCODE || row.id || ""),
name,
emergencyGrade: row.CATEGORY1 || null,
hospitalType: row.CATEGORY2 || null,
address: row.ADDRROAD || row.ADDRLAGE || null,
phone: row.TEL || null,
latitude: itemLatitude,
longitude: itemLongitude,
distanceKm: Math.round(distanceKm * 1000) / 1000,
bedStatus: {
emergencyRoomOperating: toBooleanYesNo(row.EMOGERYN),
inpatientBedsOperating: toBooleanYesNo(row.EMOGPRYN),
traumaCenter: toBooleanYesNo(row.EMOGTRYN),
pediatricSpecialty: toBooleanYesNo(row.CHILD_SPCLTY_AT),
currentGeneralCareAvailable: toBooleanYesNo(row.OPERATIONYN),
pediatricNightCare: toBooleanYesNo(row.NIGHTCAREYN),
holidayOpen: toBooleanYesNo(row.HOLIDAYYN),
silson24Linked: toBooleanYesNo(row.SILSON24_CHK)
},
schedules: {
monday: row.MONDAY || null,
tuesday: row.TUESDAY || null,
wednesday: row.WEDNESDAY || null,
thursday: row.THURSDAY || null,
friday: row.FRIDAY || null,
saturday: row.SATURDAY || null,
sunday: row.SUNDAY || null,
holiday: row.HOLIDAY || null,
note: row.OPN_BIGO || null
},
updatedAt: parseEgenTimestamp(row.EMOGUPDT),
sourceUrl: "https://www.e-gen.or.kr/egen/search_emergency_room.do",
mapUrl: buildMapUrl(name, itemLatitude, itemLongitude)
};
})
.filter(Boolean)
.filter((item) => radius === null || item.distanceKm <= radius)
.sort((left, right) => left.distanceKm - right.distanceKm || left.name.localeCompare(right.name, "ko"));
}
module.exports = {
buildMapUrl,
isValidCoordinatePair,
isValidLatitude,
isValidLongitude,
normalizeAnchorPanel,
normalizeEmergencyRoomRows,
parseCoordinateQuery,
parseEgenTimestamp,
parseSearchResultsHtml,
rankAnchorCandidates
};

View file

@ -0,0 +1,10 @@
{
"summary": {
"confirm_id": "1001",
"name": "광화문",
"category": { "name2": "관광명소", "name3": "역사유적지" },
"address": { "disp": "서울특별시 종로구 세종대로 172" },
"phone_numbers": [{ "tel": "02-120" }],
"point": { "lat": 37.57371315593711, "lon": 126.97833785777944 }
}
}

View file

@ -0,0 +1,7 @@
<ul>
<li class="search_item base" data-id="1001" data-title="광화문">
<strong class="tit_g">광화문</strong>
<span class="txt_ginfo">역사유적지</span>
<span class="txt_g">서울특별시 종로구 세종대로 172</span>
</li>
</ul>

View file

@ -0,0 +1,75 @@
{
"list": [
{
"CATEGORY1": "지역응급의료센터",
"CATEGORY2": "상급종합병원",
"TITLE": "강북삼성병원",
"EMOGCODE": "A1100006",
"ADDRROAD": "서울특별시 종로구 새문안로 29 (평동)",
"TEL": "02-2001-2001",
"LAT": "37.568497631233",
"LON": "126.967938054517",
"DISTANCE": 1,
"DISTANCE2": 1.004,
"EMOGERYN": "Y",
"EMOGPRYN": "Y",
"EMOGTRYN": null,
"CHILD_SPCLTY_AT": null,
"OPERATIONYN": "N",
"NIGHTCAREYN": "N",
"HOLIDAYYN": "N",
"SILSON24_CHK": "Y",
"EMOGUPDT": "20260311142633",
"MONDAY": "08:30~17:00",
"TUESDAY": "08:30~17:00",
"WEDNESDAY": "08:30~17:00",
"THURSDAY": "08:30~17:00",
"FRIDAY": "08:30~17:00",
"SATURDAY": "08:30~12:30"
},
{
"CATEGORY1": "권역응급의료센터",
"CATEGORY2": "상급종합병원",
"TITLE": "서울대학교병원",
"EMOGCODE": "A1100017",
"ADDRROAD": "서울특별시 종로구 대학로 101 (연건동)",
"TEL": "02-1588-5700",
"LAT": "37.579666089243",
"LON": "126.998963084121",
"DISTANCE": 2.4,
"DISTANCE2": 2.447,
"EMOGERYN": "Y",
"EMOGPRYN": "Y",
"EMOGTRYN": null,
"CHILD_SPCLTY_AT": "Y",
"OPERATIONYN": "Y",
"NIGHTCAREYN": "N",
"HOLIDAYYN": "N",
"SILSON24_CHK": "Y",
"EMOGUPDT": "20260504090610",
"MONDAY": "08:00~18:00",
"TUESDAY": "08:00~18:00",
"WEDNESDAY": "08:00~18:00",
"THURSDAY": "08:00~18:00",
"FRIDAY": "08:00~18:00",
"SATURDAY": "08:00~13:30"
},
{
"CATEGORY1": "지역응급의료기관",
"CATEGORY2": "종합병원",
"TITLE": "반경밖병원",
"EMOGCODE": "A9999999",
"ADDRROAD": "서울특별시 강남구 테헤란로 1",
"TEL": "02-0000-0000",
"LAT": "37.500000",
"LON": "127.100000",
"DISTANCE": 15,
"DISTANCE2": 15.1,
"EMOGERYN": "N",
"EMOGPRYN": "N",
"OPERATIONYN": "N",
"EMOGUPDT": "20250101010101"
}
],
"paging": { "totalCount": 3, "currentPageNum": 1 }
}

View file

@ -0,0 +1,314 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
buildEmergencyRoomListRequest,
normalizeEmergencyRoomRows,
parseCoordinateQuery,
searchNearbyEmergencyRoomsByCoordinates,
searchNearbyEmergencyRoomsByLocationQuery
} = require("../src/index");
const fixturesDir = path.join(__dirname, "fixtures");
const anchorSearchHtml = fs.readFileSync(path.join(fixturesDir, "anchor-search.html"), "utf8");
const anchorPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "anchor-panel.json"), "utf8"));
const emergencyRoomList = JSON.parse(fs.readFileSync(path.join(fixturesDir, "emergency-room-list.json"), "utf8"));
const ORIGIN = {
latitude: 37.57371315593711,
longitude: 126.97833785777944
};
test("parseCoordinateQuery recognizes latitude/longitude pairs", () => {
assert.deepEqual(parseCoordinateQuery("37.573713, 126.978338"), {
latitude: 37.573713,
longitude: 126.978338
});
assert.equal(parseCoordinateQuery("999, 999"), null);
assert.equal(parseCoordinateQuery("광화문"), null);
});
test("buildEmergencyRoomListRequest targets E-Gen's public nearby ER endpoint", () => {
const request = buildEmergencyRoomListRequest({
...ORIGIN,
radius: 10,
order: "accuracy",
currentPageNum: 2,
emergencyGradeCodes: ["A", "C"],
hospitalName: "서울"
});
assert.equal(request.url, "https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do");
assert.equal(request.method, "POST");
assert.equal(request.body.get("lat"), String(ORIGIN.latitude));
assert.equal(request.body.get("lon"), String(ORIGIN.longitude));
assert.equal(request.body.get("radius"), "10");
assert.equal(request.body.get("order"), "accuracy");
assert.equal(request.body.get("currentPageNum"), "2");
assert.equal(request.body.get("emoggrdcStr"), "A,C");
assert.equal(request.body.get("emogdesc"), "서울");
});
test("normalizeEmergencyRoomRows exposes nearby ER and inpatient bed operation flags", () => {
const items = normalizeEmergencyRoomRows(emergencyRoomList, ORIGIN, { radius: 5 });
assert.equal(items.length, 2);
assert.deepEqual(items.map((item) => [item.id, item.name, item.emergencyGrade, item.distanceKm]), [
["A1100006", "강북삼성병원", "지역응급의료센터", 1.004],
["A1100017", "서울대학교병원", "권역응급의료센터", 2.447]
]);
assert.deepEqual(items[0].bedStatus, {
emergencyRoomOperating: true,
inpatientBedsOperating: true,
traumaCenter: null,
pediatricSpecialty: null,
currentGeneralCareAvailable: false,
pediatricNightCare: false,
holidayOpen: false,
silson24Linked: true
});
assert.equal(items[1].bedStatus.pediatricSpecialty, true);
assert.equal(items[0].updatedAt, "2026-03-11T14:26:33+09:00");
assert.equal(items[0].mapUrl, "https://map.kakao.com/link/map/%EA%B0%95%EB%B6%81%EC%82%BC%EC%84%B1%EB%B3%91%EC%9B%90,37.568497631233,126.967938054517");
});
test("normalizeEmergencyRoomRows preserves unknown operation flags as null", () => {
const payload = {
list: [
{
TITLE: "상태미상병원",
EMOGCODE: "UNKNOWN1",
LAT: String(ORIGIN.latitude),
LON: String(ORIGIN.longitude),
EMOGERYN: "",
EMOGPRYN: "UNKNOWN",
EMOGTRYN: "N"
}
]
};
const [item] = normalizeEmergencyRoomRows(payload, ORIGIN);
assert.equal(item.bedStatus.emergencyRoomOperating, null);
assert.equal(item.bedStatus.inpatientBedsOperating, null);
assert.equal(item.bedStatus.traumaCenter, false);
});
test("normalizeEmergencyRoomRows skips invalid upstream hospital coordinates", () => {
const items = normalizeEmergencyRoomRows(
{
list: [
{
TITLE: "좌표오류병원",
EMOGCODE: "BADCOORD1",
LAT: "91",
LON: String(ORIGIN.longitude),
EMOGERYN: "Y"
},
{
TITLE: "정상좌표병원",
EMOGCODE: "GOODCOORD1",
LAT: String(ORIGIN.latitude),
LON: String(ORIGIN.longitude),
EMOGERYN: "Y"
}
]
},
ORIGIN,
);
assert.deepEqual(items.map((item) => item.id), ["GOODCOORD1"]);
});
test("searchNearbyEmergencyRoomsByCoordinates rejects unknown E-Gen payload shapes", async () => {
await assert.rejects(
searchNearbyEmergencyRoomsByCoordinates({
...ORIGIN,
fetchImpl: async () => makeResponse({ error: "blocked" })
}),
/Unexpected E-Gen emergency room payload shape/
);
});
test("searchNearbyEmergencyRoomsByCoordinates posts to E-Gen and returns normalized items", async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url: String(url), options });
return makeResponse(emergencyRoomList);
};
const result = await searchNearbyEmergencyRoomsByCoordinates({
...ORIGIN,
limit: 1,
radius: 5,
fetchImpl
});
assert.equal(result.items.length, 1);
assert.equal(result.items[0].name, "강북삼성병원");
assert.equal(result.meta.source, "e-gen");
assert.equal(result.meta.bedCountLimitation, "E-Gen nearby ER list exposes operation flags, not exact real-time remaining bed counts.");
assert.equal(calls[0].url, "https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do");
assert.equal(calls[0].options.method, "POST");
assert.equal(calls[0].options.body.get("radius"), "5");
});
test("searchNearbyEmergencyRoomsByLocationQuery resolves a Kakao anchor before querying E-Gen", async () => {
const calls = [];
const fetchImpl = async (url, options = {}) => {
const resolved = String(url);
calls.push({ url: resolved, options });
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
return makeResponse(anchorSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return makeResponse(anchorPanel, "application/json");
}
if (resolved === "https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do") {
assert.equal(options.body.get("lat"), String(ORIGIN.latitude));
assert.equal(options.body.get("lon"), String(ORIGIN.longitude));
return makeResponse(emergencyRoomList);
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyEmergencyRoomsByLocationQuery("광화문", {
limit: 2,
radius: 5,
fetchImpl
});
assert.equal(result.anchor.name, "광화문");
assert.equal(result.anchor.address, "서울특별시 종로구 세종대로 172");
assert.equal(result.items.length, 2);
assert.deepEqual(calls.map((call) => call.url), [
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
"https://place-api.map.kakao.com/places/panel3/1001",
"https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do"
]);
});
test("searchNearbyEmergencyRoomsByLocationQuery skips stale Kakao panels only", async () => {
const multiSearchHtml = `
<ul>
<li class="search_item base" data-id="stale" data-title="광화문">
<strong class="tit_g">광화문</strong>
<span class="txt_g">서울특별시 종로구 세종대로 172</span>
</li>
<li class="search_item base" data-id="1001" data-title="광화문">
<strong class="tit_g">광화문</strong>
<span class="txt_g">서울특별시 종로구 세종대로 172</span>
</li>
</ul>
`;
const calls = [];
const fetchImpl = async (url, options = {}) => {
const resolved = String(url);
calls.push(resolved);
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView")) {
return makeResponse(multiSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/stale") {
return makeResponse("gone", "text/plain", { ok: false, status: 410 });
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return makeResponse(anchorPanel, "application/json");
}
if (resolved === "https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do") {
assert.equal(options.body.get("lat"), String(ORIGIN.latitude));
assert.equal(options.body.get("lon"), String(ORIGIN.longitude));
return makeResponse(emergencyRoomList);
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyEmergencyRoomsByLocationQuery("광화문", { fetchImpl });
assert.equal(result.anchor.id, "1001");
assert.deepEqual(calls, [
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
"https://place-api.map.kakao.com/places/panel3/stale",
"https://place-api.map.kakao.com/places/panel3/1001",
"https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do"
]);
});
test("searchNearbyEmergencyRoomsByLocationQuery fails fast on Kakao rate limits", async () => {
const fetchImpl = async (url) => {
const resolved = String(url);
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView")) {
return makeResponse(anchorSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return makeResponse("rate limited", "text/plain", { ok: false, status: 429 });
}
throw new Error(`unexpected url: ${resolved}`);
};
await assert.rejects(
searchNearbyEmergencyRoomsByLocationQuery("광화문", { fetchImpl }),
(error) => error.status === 429 && /place-api\.map\.kakao\.com/.test(error.url)
);
});
test("searchNearbyEmergencyRoomsByCoordinates validates bounded inputs", async () => {
await assert.rejects(
searchNearbyEmergencyRoomsByCoordinates({ latitude: "x", longitude: 126.9 }),
/latitude and longitude must be finite numbers/
);
await assert.rejects(
searchNearbyEmergencyRoomsByCoordinates({ ...ORIGIN, limit: 0 }),
/limit must be between 1 and 50/
);
await assert.rejects(
searchNearbyEmergencyRoomsByCoordinates({ ...ORIGIN, radius: 0 }),
/radius must be between 1 and 50/
);
await assert.rejects(
searchNearbyEmergencyRoomsByCoordinates({ latitude: 91, longitude: 126.9 }),
/latitude must be between -90 and 90/
);
await assert.rejects(
searchNearbyEmergencyRoomsByCoordinates({ latitude: 37.5, longitude: 181 }),
/longitude must be between -180 and 180/
);
assert.throws(
() => buildEmergencyRoomListRequest({ latitude: -91, longitude: 126.9 }),
/latitude must be between -90 and 90/
);
});
function makeResponse(body, contentType = "application/json;charset=UTF-8", responseOptions = {}) {
return {
ok: responseOptions.ok ?? true,
status: responseOptions.status ?? 200,
headers: {
get(name) {
if (String(name).toLowerCase() === "content-type") {
return contentType;
}
return null;
}
},
async text() {
return typeof body === "string" ? body : JSON.stringify(body);
},
async json() {
return typeof body === "string" ? JSON.parse(body) : body;
}
};
}

View file

@ -25,6 +25,10 @@
- `GET /v1/kosis/search` — KOSIS 통계표 검색(`KOSIS_API_KEY`)
- `GET /v1/kosis/meta` — KOSIS 통계표 메타데이터(`KOSIS_API_KEY`)
- `GET /v1/kosis/data` — KOSIS 통계 데이터 셀 조회(`KOSIS_API_KEY`)
- `GET /v1/kstartup/business-info` — 창업진흥원 K-Startup 통합공고 지원사업 정보(`DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/announcements` — 창업진흥원 K-Startup 지원사업 공고 정보(`DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/contents` — 창업진흥원 K-Startup 창업 콘텐츠 정보(`DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/statistics` — 창업진흥원 K-Startup 통계보고서 정보(`DATA_GO_KR_API_KEY`)
- `GET /v1/naver-shopping/search` — 네이버 검색 Open API 쇼핑 검색 우선, 키가 없으면 네이버 쇼핑 공개 BFF JSON 기반 상품/가격 후보 조회
- `GET /v1/naver-news/search` — 네이버 검색 Open API 뉴스 검색(`news.json`) 기반 최신 뉴스 기사 제목/요약/링크/발행시각 조회(`NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` 필요)
- `GET /v1/data4library/library-search` — 도서관 정보나루 정보공개 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -0,0 +1,261 @@
const KSTARTUP_UPSTREAM_BASE_URL = "https://apis.data.go.kr/B552735/kisedKstartupService01";
const KSTARTUP_OPERATIONS = new Map([
["business-info", { path: "getBusinessInformation01", allowed: new Set(["page", "perPage", "returnType", "biz_category_cd", "supt_biz_titl_nm", "biz_yr"]) }],
["announcements", {
path: "getAnnouncementInformation01",
allowed: new Set([
"page", "perPage", "returnType",
"intg_pbanc_yn", "intg_pbanc_biz_nm", "biz_pbanc_nm",
"supt_biz_clsfc", "aply_trgt_ctnt", "supt_regin",
"pbanc_rcpt_bgng_dt", "pbanc_rcpt_end_dt",
"aply_trgt", "biz_enyy", "biz_trgt_age", "prfn_matr",
"rcrt_prgs_yn"
])
}],
["contents", { path: "getContentInformation01", allowed: new Set(["page", "perPage", "returnType", "clss_cd", "titl_nm"]) }],
["statistics", { path: "getStatisticalInformation01", allowed: new Set(["page", "perPage", "returnType", "titl_nm", "file_nm"]) }]
]);
const KSTARTUP_INTEGER_FIELDS = new Set(["page", "perPage"]);
const KSTARTUP_DATE_FIELDS = new Set(["pbanc_rcpt_bgng_dt", "pbanc_rcpt_end_dt"]);
const KSTARTUP_YN_FIELDS = new Set(["intg_pbanc_yn", "rcrt_prgs_yn"]);
const KSTARTUP_TEXT_FIELD_LIMITS = {
supt_biz_titl_nm: 300,
intg_pbanc_biz_nm: 300,
biz_pbanc_nm: 300,
supt_biz_clsfc: 100,
aply_trgt_ctnt: 300,
supt_regin: 200,
aply_trgt: 200,
biz_enyy: 200,
biz_trgt_age: 200,
prfn_matr: 200,
biz_category_cd: 50,
clss_cd: 50,
titl_nm: 300,
file_nm: 1000
};
const KSTARTUP_MAX_PER_PAGE = 100;
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function normalizeKstartupYear(value) {
const raw = trimOrNull(value);
if (!raw) {
return null;
}
if (!/^\d{4}$/.test(raw)) {
throw new Error("Provide biz_yr as a 4-digit year (e.g. 2024).");
}
return raw;
}
function normalizeKstartupDate(value, field) {
const raw = trimOrNull(value);
if (!raw) {
return null;
}
const normalized = raw.replace(/[^0-9]/g, "");
if (!/^\d{8}$/.test(normalized)) {
throw new Error(`Provide ${field} as YYYYMMDD.`);
}
const year = Number.parseInt(normalized.slice(0, 4), 10);
const month = Number.parseInt(normalized.slice(4, 6), 10);
const day = Number.parseInt(normalized.slice(6, 8), 10);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
throw new Error(`Provide ${field} as a valid YYYYMMDD date.`);
}
return normalized;
}
function normalizeKstartupYn(value, field) {
const raw = trimOrNull(value);
if (!raw) {
return null;
}
const upper = raw.toUpperCase();
if (upper !== "Y" && upper !== "N") {
throw new Error(`Provide ${field} as Y or N.`);
}
return upper;
}
function normalizeKstartupInteger(value, field, { min, max }) {
const raw = trimOrNull(value);
if (raw === null) {
return null;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || String(parsed) !== raw.replace(/^\+/, "")) {
throw new Error(`Provide ${field} as a positive integer.`);
}
if (parsed < min) {
throw new Error(`Provide ${field} >= ${min}.`);
}
if (max !== undefined && parsed > max) {
throw new Error(`Provide ${field} <= ${max}.`);
}
return parsed;
}
function normalizeKstartupText(value, field) {
const raw = trimOrNull(value);
if (raw === null) {
return null;
}
const maxLength = KSTARTUP_TEXT_FIELD_LIMITS[field];
if (maxLength && raw.length > maxLength) {
throw new Error(`Provide ${field} up to ${maxLength} characters.`);
}
return raw;
}
function normalizeKstartupReturnType() {
// K-Startup proxy forces returnType=json so callers cannot ask for XML
// through the proxy. Use --direct mode to fetch XML directly.
return "json";
}
function normalizeKstartupQuery(operation, query = {}) {
const definition = KSTARTUP_OPERATIONS.get(operation);
if (!definition) {
throw new Error(`Unknown K-Startup operation: ${operation}`);
}
const normalized = {};
const page = normalizeKstartupInteger(query.page, "page", { min: 1 });
normalized.page = page === null ? 1 : page;
const perPage = normalizeKstartupInteger(query.perPage ?? query.per_page, "perPage", { min: 1, max: KSTARTUP_MAX_PER_PAGE });
normalized.perPage = perPage === null ? 10 : perPage;
normalized.returnType = normalizeKstartupReturnType();
for (const field of definition.allowed) {
if (field === "page" || field === "perPage" || field === "returnType") {
continue;
}
const raw = query[field] ?? query[field.toLowerCase()];
let value = null;
if (field === "biz_yr") {
value = normalizeKstartupYear(raw);
} else if (KSTARTUP_DATE_FIELDS.has(field)) {
value = normalizeKstartupDate(raw, field);
} else if (KSTARTUP_YN_FIELDS.has(field)) {
value = normalizeKstartupYn(raw, field);
} else if (KSTARTUP_INTEGER_FIELDS.has(field)) {
value = normalizeKstartupInteger(raw, field, { min: 1 });
} else {
value = normalizeKstartupText(raw, field);
}
if (value !== null && value !== undefined) {
normalized[field] = value;
}
}
if (
normalized.pbanc_rcpt_bgng_dt &&
normalized.pbanc_rcpt_end_dt &&
normalized.pbanc_rcpt_bgng_dt > normalized.pbanc_rcpt_end_dt
) {
throw new Error("Provide pbanc_rcpt_bgng_dt earlier than or equal to pbanc_rcpt_end_dt.");
}
return normalized;
}
async function proxyKstartupRequest({ operation, query, serviceKey, fetchImpl = global.fetch }) {
if (!serviceKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server."
})
};
}
const definition = KSTARTUP_OPERATIONS.get(operation);
if (!definition) {
return {
statusCode: 404,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "not_found",
message: "That K-Startup route is not exposed by this proxy."
})
};
}
const url = new URL(`${KSTARTUP_UPSTREAM_BASE_URL}/${definition.path}`);
url.searchParams.set("ServiceKey", serviceKey);
for (const [key, value] of Object.entries(query || {})) {
if (value === undefined || value === null || value === "" || key === "ServiceKey") {
continue;
}
url.searchParams.set(key, String(value));
}
// Always force JSON regardless of upstream defaults or caller overrides.
url.searchParams.set("returnType", "json");
const response = await fetchImpl(url, {
method: "GET",
headers: {
accept: "application/json",
"user-agent": "k-skill-proxy/kstartup"
},
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
function isKstartupErrorBody(body) {
const text = String(body || "").trim();
if (!text) {
return true;
}
if (/<errMsg>|<returnAuthMsg>|SERVICE_KEY_IS_NOT_REGISTERED|LIMITED_NUMBER_OF_SERVICE_REQUESTS|DEADLINE_HAS_EXPIRED|SERVICE_ACCESS_DENIED/i.test(text)) {
return true;
}
if (!(text.startsWith("{") || text.startsWith("["))) {
return false;
}
try {
const payload = JSON.parse(text);
if (!payload || typeof payload !== "object") {
return false;
}
if (payload.error || payload.errMsg || payload.returnAuthMsg) {
return true;
}
if (payload.response && payload.response.header) {
const code = String(payload.response.header.resultCode ?? "").trim();
return code && code !== "00";
}
return false;
} catch {
return false;
}
}
module.exports = {
KSTARTUP_OPERATIONS,
KSTARTUP_UPSTREAM_BASE_URL,
normalizeKstartupQuery,
proxyKstartupRequest,
isKstartupErrorBody
};

View file

@ -27,6 +27,11 @@ const {
normalizeNtsBusinessValidateQuery,
proxyNtsBusinessRequest
} = require("./nts-business");
const {
isKstartupErrorBody,
normalizeKstartupQuery,
proxyKstartupRequest
} = require("./kstartup");
const { fetchNearbyParkingLots } = require("./parking-lots");
const { searchRegionCode } = require("./region-lookup");
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
@ -1607,7 +1612,8 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
naverShoppingConfigured: true,
naverSearchApiConfigured: naverSearchKeysPresent,
naverNewsApiConfigured: naverSearchKeysPresent,
ntsBusinessConfigured: Boolean(config.molitApiKey)
ntsBusinessConfigured: Boolean(config.molitApiKey),
kstartupConfigured: Boolean(config.molitApiKey)
},
auth: {
tokenRequired: false
@ -2939,6 +2945,155 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
reply
}));
async function handleKstartupRoute({ operation, route, request, reply }) {
let normalized;
try {
normalized = normalizeKstartupQuery(operation, request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
normalized.returnType = "json";
const cacheKey = makeCacheKey({ route, ...normalized });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
let upstream;
try {
upstream = await proxyKstartupRequest({
operation,
query: normalized,
serviceKey: config.molitApiKey
});
} catch (error) {
reply.code(502);
return {
error: "proxy_error",
message: "K-Startup upstream request failed.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (upstream.statusCode === 503) {
reply.code(503);
let upstreamPayload = null;
try { upstreamPayload = JSON.parse(upstream.body); } catch { upstreamPayload = null; }
return {
error: upstreamPayload?.error || "upstream_not_configured",
message: upstreamPayload?.message || "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let parsed = null;
try {
parsed = JSON.parse(upstream.body);
} catch {
reply.code(upstream.statusCode >= 400 ? upstream.statusCode : 502);
return {
error: "upstream_invalid_response",
message: "K-Startup upstream did not return valid JSON.",
upstream_status: upstream.statusCode,
upstream_body: upstream.body.slice(0, 500),
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (upstream.statusCode < 200 || upstream.statusCode >= 300 || isKstartupErrorBody(upstream.body)) {
reply.code(upstream.statusCode >= 400 ? upstream.statusCode : 502);
return {
...parsed,
error: parsed?.error || "upstream_error",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
}
const payload = {
...parsed,
query: normalized,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
}
app.get("/v1/kstartup/business-info", async (request, reply) => handleKstartupRoute({
operation: "business-info",
route: "kstartup-business-info",
request,
reply
}));
app.get("/v1/kstartup/announcements", async (request, reply) => handleKstartupRoute({
operation: "announcements",
route: "kstartup-announcements",
request,
reply
}));
app.get("/v1/kstartup/contents", async (request, reply) => handleKstartupRoute({
operation: "contents",
route: "kstartup-contents",
request,
reply
}));
app.get("/v1/kstartup/statistics", async (request, reply) => handleKstartupRoute({
operation: "statistics",
route: "kstartup-statistics",
request,
reply
}));
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
let normalized;
@ -4145,6 +4300,7 @@ module.exports = {
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
normalizeKosisSearchQuery,
normalizeKstartupQuery,
normalizeKoreanStockLookupQuery,
normalizeKoreanStockSearchQuery,
normalizeLhNoticeDetailQuery,
@ -4169,6 +4325,7 @@ module.exports = {
proxyNeisSchoolInfoRequest,
proxyKmaWeatherRequest,
proxyKosisRequest,
proxyKstartupRequest,
fetchNaverShoppingSearch,
proxyOpinetRequest,
proxySeoulCityDataRequest,

View file

@ -15,6 +15,7 @@ const {
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
normalizeKosisSearchQuery,
normalizeKstartupQuery,
normalizeNtsBusinessStatusQuery,
normalizeNtsBusinessValidateQuery,
proxyAirKoreaRequest,
@ -4650,3 +4651,204 @@ test("health endpoint reports lhNoticeConfigured when DATA_GO_KR_API_KEY is set"
assert.equal(response.statusCode, 200);
assert.equal(response.json().upstreams.lhNoticeConfigured, true);
});
test("K-Startup normalizer enforces enums, ranges, and date order", () => {
const normalized = normalizeKstartupQuery("announcements", {
page: "2",
perPage: "20",
supt_regin: "서울특별시",
pbanc_rcpt_bgng_dt: "2024-01-01",
pbanc_rcpt_end_dt: "2024-12-31",
rcrt_prgs_yn: "y",
biz_yr: "2024"
});
assert.equal(normalized.page, 2);
assert.equal(normalized.perPage, 20);
assert.equal(normalized.supt_regin, "서울특별시");
assert.equal(normalized.pbanc_rcpt_bgng_dt, "20240101");
assert.equal(normalized.pbanc_rcpt_end_dt, "20241231");
assert.equal(normalized.rcrt_prgs_yn, "Y");
assert.equal(normalized.returnType, "json");
assert.equal(normalized.biz_yr, undefined, "biz_yr is not in announcements allowlist");
const businessInfo = normalizeKstartupQuery("business-info", { biz_yr: "2024", biz_category_cd: "cmrczn_Tab3" });
assert.equal(businessInfo.biz_yr, "2024");
assert.equal(businessInfo.biz_category_cd, "cmrczn_Tab3");
assert.throws(() => normalizeKstartupQuery("announcements", { rcrt_prgs_yn: "maybe" }), /rcrt_prgs_yn/);
assert.throws(() => normalizeKstartupQuery("announcements", { pbanc_rcpt_bgng_dt: "20241301" }), /pbanc_rcpt_bgng_dt/);
assert.throws(() => normalizeKstartupQuery("announcements", {
pbanc_rcpt_bgng_dt: "20240601", pbanc_rcpt_end_dt: "20240101"
}), /earlier than or equal/);
assert.throws(() => normalizeKstartupQuery("announcements", { perPage: "0" }), /perPage/);
assert.throws(() => normalizeKstartupQuery("announcements", { perPage: "101" }), /perPage/);
assert.throws(() => normalizeKstartupQuery("unknown-op", {}), /Unknown K-Startup operation/);
assert.throws(() => normalizeKstartupQuery("business-info", { biz_yr: "24" }), /biz_yr/);
});
test("K-Startup announcements route proxies GET with server-side ServiceKey", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return new Response(
JSON.stringify({
currentCount: 1,
matchCount: 1,
data: [{ biz_pbanc_nm: "테스트 공고", supt_regin: "서울특별시" }],
page: 1, perPage: 10, totalCount: 1
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const response = await app.inject({
method: "GET",
url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("서울특별시") + "&rcrt_prgs_yn=Y"
});
const body = response.json();
assert.equal(response.statusCode, 200);
assert.equal(body.data[0].biz_pbanc_nm, "테스트 공고");
assert.equal(body.proxy.cache.hit, false);
assert.equal(body.query.rcrt_prgs_yn, "Y");
const upstreamUrl = new URL(calls[0].url);
assert.equal(upstreamUrl.origin + upstreamUrl.pathname,
"https://apis.data.go.kr/B552735/kisedKstartupService01/getAnnouncementInformation01");
assert.equal(upstreamUrl.searchParams.get("ServiceKey"), "data-go-key");
assert.equal(upstreamUrl.searchParams.get("rcrt_prgs_yn"), "Y");
assert.equal(upstreamUrl.searchParams.get("returnType"), "json");
const cached = await app.inject({
method: "GET",
url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("서울특별시") + "&rcrt_prgs_yn=Y"
});
assert.equal(cached.statusCode, 200);
assert.equal(cached.json().proxy.cache.hit, true);
assert.equal(calls.length, 1, "second call must come from cache, not upstream");
});
test("K-Startup route reports 503 when DATA_GO_KR_API_KEY is missing", async (t) => {
const app = buildServer({ env: {} });
t.after(async () => { await app.close(); });
const response = await app.inject({ method: "GET", url: "/v1/kstartup/business-info?page=1" });
assert.equal(response.statusCode, 503);
const body = response.json();
assert.equal(body.error, "upstream_not_configured");
assert.match(body.message, /DATA_GO_KR_API_KEY/);
});
test("K-Startup route returns 400 for invalid params before hitting upstream", async (t) => {
const originalFetch = global.fetch;
let called = false;
global.fetch = async () => { called = true; return new Response("{}", { status: 200 }); };
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const response = await app.inject({
method: "GET",
url: "/v1/kstartup/announcements?pbanc_rcpt_bgng_dt=2024-13-01"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
assert.equal(called, false, "must not call upstream on bad request");
});
test("K-Startup route surfaces data.go.kr error envelopes without caching", async (t) => {
const originalFetch = global.fetch;
let calls = 0;
global.fetch = async () => {
calls += 1;
return new Response(
JSON.stringify({
response: { header: { resultCode: "30", resultMsg: "SERVICE_KEY_IS_NOT_REGISTERED_ERROR" } }
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const first = await app.inject({ method: "GET", url: "/v1/kstartup/contents?page=1" });
assert.equal(first.statusCode, 502);
assert.equal(first.json().error, "upstream_error");
const second = await app.inject({ method: "GET", url: "/v1/kstartup/contents?page=1" });
assert.equal(second.statusCode, 502);
assert.equal(calls, 2, "upstream error responses must not be cached");
});
test("K-Startup unknown operation returns 404 via proxyKstartupRequest", async () => {
const { proxyKstartupRequest } = require("../src/kstartup");
const result = await proxyKstartupRequest({ operation: "bogus", query: {}, serviceKey: "k" });
assert.equal(result.statusCode, 404);
assert.match(result.body, /not_found/);
});
test("health endpoint reports kstartupConfigured when DATA_GO_KR_API_KEY is set", async (t) => {
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { await app.close(); });
const response = await app.inject({ method: "GET", url: "/health" });
assert.equal(response.statusCode, 200);
assert.equal(response.json().upstreams.kstartupConfigured, true);
});
test("K-Startup cache keys partition by query so distinct filters trigger distinct upstream calls", async (t) => {
const originalFetch = global.fetch;
const upstreamCalls = [];
global.fetch = async (url) => {
upstreamCalls.push(String(url));
return new Response(JSON.stringify({ data: [] }), {
status: 200, headers: { "content-type": "application/json" }
});
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
await app.inject({ method: "GET", url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("서울특별시") });
await app.inject({ method: "GET", url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("부산광역시") });
await app.inject({ method: "GET", url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("서울특별시") + "&rcrt_prgs_yn=Y" });
await app.inject({ method: "GET", url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("서울특별시") });
assert.equal(upstreamCalls.length, 3,
"3 distinct queries must call upstream 3 times; the 4th repeats query #1 and must hit the cache");
});
test("K-Startup proxy forces returnType=json even when caller asks for xml", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
return new Response(JSON.stringify({ data: [] }), {
status: 200, headers: { "content-type": "application/json" }
});
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const response = await app.inject({
method: "GET",
url: "/v1/kstartup/contents?returnType=xml&clss_cd=notice_matr"
});
assert.equal(response.statusCode, 200);
const upstreamUrl = new URL(calls[0]);
assert.equal(upstreamUrl.searchParams.get("returnType"), "json",
"proxy must rewrite returnType to json regardless of client input");
});
test("K-Startup integer fields reject non-numeric input before upstream call", async (t) => {
const originalFetch = global.fetch;
let called = false;
global.fetch = async () => { called = true; return new Response("{}", { status: 200 }); };
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
for (const bad of ["abc", "0", "-1"]) {
const response = await app.inject({ method: "GET", url: "/v1/kstartup/announcements?page=" + bad });
assert.equal(response.statusCode, 400, `page=${bad} must 400`);
}
assert.equal(called, false, "upstream must not be called for any invalid integer input");
});

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

View file

@ -0,0 +1,41 @@
# sh-notice-search
Public SH(서울주택도시개발공사) notice lookup client for the `sh-notice-search` k-skill.
## Source
- List/detail pages: `https://www.i-sh.co.kr/app/lay2/program/.../www/brd/.../{list,view}.do`
- Default category: `주택임대` (`multi_itm_seq=2`)
- Keyword search: SH requires both `srchWord` and `srchTp`; this client defaults keyword searches to title scope (`srchTp=0`).
This is an unauthenticated public HTML surface. No proxy or API key is required. The client does not automate application, login, document submission, payment, or My Page flows.
## Usage
```js
const { searchNotices, getNoticeDetail } = require("sh-notice-search")
const list = await searchNotices({ keyword: "행복주택", category: "임대", page: 1 })
const detail = await getNoticeDetail({ seq: list.items[0].seq, category: "임대" })
```
CLI:
```bash
sh-notice-search 행복주택 --category 임대 --limit 5
sh-notice-search 매입임대 --category 주거복지 --status 진행
sh-notice-search --seq 304371 --category 임대
```
## Returned fields
List rows include `seq`, `title`, `department`, `registered_date`, `views`, `category`, `status`, and the official `detail_url`.
Detail rows include `content_text` plus attachment metadata: `filename`, `file_seq`, `file_size`, `file_type`, and official SH `preview_url`. Direct download URLs are intentionally not exposed because SH file-download behavior can be session/policy dependent; hand off official preview/detail URLs to the user's browser.
## Boundaries
- `pageSize`/`limit` is capped at 10 because the SH board returns a fixed 10 rows per page.
- Status filtering uses a conservative title-text classifier because the public board list has no first-class status field.
- Category aliases map to official board tabs (`주택임대`, `주택분양`, `주택매입`, `토지`, etc.). The `주거복지` alias maps to SH's public `주택매입` tab.
- Public HTML structure, NetFunnel/rate limits, and attachment preview policy can change.

View file

@ -0,0 +1,36 @@
{
"name": "sh-notice-search",
"version": "0.1.0",
"description": "Public SH Seoul Housing notice lookup client for k-skill",
"license": "MIT",
"main": "src/index.js",
"bin": {
"sh-notice-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",
"sh",
"seoul",
"housing",
"notices",
"korea"
],
"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,72 @@
#!/usr/bin/env node
const { getNoticeDetail, searchNotices } = require("./index")
async function main(options = parseArgs(process.argv.slice(2)), io = console) {
const result = options.seq || options.id || options.noticeSeq
? await getNoticeDetail(options)
: await searchNotices(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 === "--query" || arg === "-q" || arg === "--keyword") options.keyword = argv[++i] || ""
else if (arg === "--category" || arg === "--kind") options.category = argv[++i] || ""
else if (arg === "--status") options.status = argv[++i] || ""
else if (arg === "--page") options.page = argv[++i] || ""
else if (arg === "--limit" || arg === "--page-size") options.limit = argv[++i] || ""
else if (arg === "--srch-tp" || arg === "--search-type") options.searchType = argv[++i] || ""
else if (arg === "--seq" || arg === "--id") options.seq = argv[++i] || ""
else if (arg === "--include-html") options.includeHtml = true
else if (arg === "--help" || arg === "-h") {
printHelp()
process.exit(0)
} else if (/^\d{4,}$/.test(arg) && !options.seq && (argv[i - 1] === "detail" || argv[i - 1] === "--detail")) {
options.seq = arg
} else if (arg === "detail" || arg === "--detail") {
// marker only; following numeric argument can be seq
} else if (!options.keyword) {
options.keyword = arg
}
}
return options
}
function printHelp() {
console.log(`Usage: sh-notice-search [keyword] [options]
Search public SH notices:
sh-notice-search 행복주택 --category 임대 --limit 5
sh-notice-search 매입임대 --category 주거복지 --status 진행
Fetch one detail:
sh-notice-search --seq 304371 --category 임대
Options:
-q, --query <text> Keyword. Defaults to title search when present.
--search-type <type> title/제목 or content/내용.
--category <category> all, rent/임대, sale/분양, welfare/주거복지, land/토지, etc.
--status <status> open/진행, closed/마감, announced/당첨자 (title classifier).
--page <number> Page number (default: 1).
--limit <number> Returned rows; capped at SH fixed page size 10.
--seq <number> Fetch detail by SH notice seq.
--include-html Include raw HTML in output for diagnostics.
`)
}
function formatError(error) {
return error && error.stack ? error.stack : String(error)
}
function run(argv = process.argv.slice(2), io = console) {
return main(parseArgs(argv), io).catch((error) => {
io.error(formatError(error))
process.exitCode = 1
})
}
if (require.main === module) run()
module.exports = { parseArgs, printHelp, formatError, main, run }

View file

@ -0,0 +1,545 @@
const SH_BASE_URL = "https://www.i-sh.co.kr"
const DEFAULT_CATEGORY = "rent"
const DEFAULT_PAGE_SIZE = 10
const MAX_PAGE_SIZE = 10
const DEFAULT_TIMEOUT_MS = 20000
const CATEGORY_CONFIGS = {
all: {
key: "all",
name: "전체",
path: "/app/lay2/program/S1T294C295/www/brd/m_241",
multiItmSeqs: "1,2,4,8,16,32,64,128,256,512",
aliases: ["all", "전체"]
},
sale: {
key: "sale",
name: "주택분양",
path: "/app/lay2/program/S1T294C296/www/brd/m_244",
multiItmSeq: "1",
aliases: ["sale", "분양", "주택분양", "분양주택"]
},
rent: {
key: "rent",
name: "주택임대",
path: "/app/lay2/program/S1T294C297/www/brd/m_247",
multiItmSeq: "2",
aliases: ["rent", "임대", "주택임대", "임대주택"]
},
purchase: {
key: "purchase",
name: "주택매입",
path: "/app/lay2/program/S1T294C3379/www/brd/m_247",
multiItmSeq: "512",
aliases: ["purchase", "매입", "주택매입", "매입임대", "welfare", "주거복지"]
},
movein: {
key: "movein",
name: "입주안내",
path: "/app/lay2/program/S1T294C298/www/brd/m_248",
multiItmSeq: "4",
aliases: ["movein", "입주", "입주안내"]
},
land: {
key: "land",
name: "토지",
path: "/app/lay2/program/S1T294C299/www/brd/m_255",
multiItmSeq: "8",
aliases: ["land", "토지"]
},
commercial: {
key: "commercial",
name: "상가/공장",
path: "/app/lay2/program/S1T294C300/www/brd/m_256",
multiItmSeq: "16",
aliases: ["commercial", "상가", "공장", "상가/공장"]
},
compensation: {
key: "compensation",
name: "보상/이주",
path: "/app/lay2/program/S1T294C301/www/brd/m_257",
multiItmSeq: "32",
aliases: ["compensation", "보상", "이주", "보상/이주"]
},
design: {
key: "design",
name: "현상설계",
path: "/app/lay2/program/S1T294C302/www/brd/m_258",
multiItmSeq: "64",
aliases: ["design", "현상설계", "설계"]
},
etc: {
key: "etc",
name: "기타",
path: "/app/lay2/program/S1T294C304/www/brd/m_260",
multiItmSeq: "256",
aliases: ["etc", "기타"]
}
}
const CATEGORY_ALIAS = Object.fromEntries(
Object.values(CATEGORY_CONFIGS).flatMap((config) => config.aliases.map((alias) => [normalizeToken(alias), config.key]))
)
const STATUS_ALIASES = {
open: "open",
ongoing: "open",
active: "open",
"진행": "open",
"공고중": "open",
"모집중": "open",
closed: "closed",
close: "closed",
ended: "closed",
"마감": "closed",
"종료": "closed",
"결과": "closed",
announced: "announced",
"발표": "announced",
"당첨": "announced",
"당첨자": "announced"
}
function normalizeToken(value) {
return String(value == null ? "" : value).replace(/\s+/g, "").trim().toLowerCase()
}
function cleanText(value) {
return decodeHtml(String(value == null ? "" : value).replace(/\s+/g, " ").trim())
}
function trimOrNull(value) {
const text = cleanText(value)
return text || null
}
function decodeHtml(value) {
if (value === undefined || value === null) return ""
return String(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(/<[^>]+>/g, " "))
.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 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 parsePositiveInteger(value, { defaultValue, min = 1, max, 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 (Number.isFinite(max) && parsed > max) return max
return parsed
}
function normalizeCategory(value) {
const token = normalizeToken(value || DEFAULT_CATEGORY)
const key = CATEGORY_ALIAS[token] || CATEGORY_CONFIGS[token]?.key
if (!key) throw new Error(`Unsupported SH category: ${value}`)
return key
}
function normalizeSearchType(value, hasKeyword) {
const token = normalizeToken(value)
if (!token) return hasKeyword ? "0" : null
if (["title", "제목", "0"].includes(token)) return "0"
if (["content", "contents", "본문", "내용", "1"].includes(token)) return "1"
throw new Error("srchTp must be title/content or 제목/내용.")
}
function normalizeStatus(value) {
const token = normalizeToken(value)
if (!token) return null
const status = STATUS_ALIASES[token]
if (!status) throw new Error(`Unsupported SH status: ${value}`)
return status
}
function normalizeSearchOptions(options = {}) {
const keyword = trimOrNull(options.keyword ?? options.q ?? options.query ?? options.srchWord)
if (keyword && keyword.length > 100) throw new Error("srchWord must be 100 characters or fewer.")
const category = normalizeCategory(options.category ?? options.kind ?? options.noticeType)
return {
keyword,
srchTp: normalizeSearchType(options.srchTp ?? options.searchType ?? options.type, Boolean(keyword)),
page: parsePositiveInteger(options.page ?? options.pageNo, { defaultValue: 1, min: 1, max: 1000, label: "page" }),
pageSize: parsePositiveInteger(options.pageSize ?? options.limit, { defaultValue: DEFAULT_PAGE_SIZE, min: 1, max: MAX_PAGE_SIZE, label: "pageSize" }),
category,
status: normalizeStatus(options.status),
timeoutMs: parsePositiveInteger(options.timeoutMs, { defaultValue: DEFAULT_TIMEOUT_MS, min: 1, max: 120000, label: "timeoutMs" }),
fetcher: options.fetcher,
signal: options.signal,
includeHtml: Boolean(options.includeHtml)
}
}
function normalizeDetailOptions(options = {}) {
const seq = trimOrNull(options.seq ?? options.noticeSeq ?? options.id)
if (!seq) throw new Error("seq is required")
if (!/^\d{1,20}$/.test(seq)) throw new Error("seq must be digits only.")
const category = normalizeCategory(options.category ?? options.kind ?? options.noticeType)
return {
seq,
category,
timeoutMs: parsePositiveInteger(options.timeoutMs, { defaultValue: DEFAULT_TIMEOUT_MS, min: 1, max: 120000, label: "timeoutMs" }),
fetcher: options.fetcher,
signal: options.signal,
includeHtml: Boolean(options.includeHtml)
}
}
function buildSearchUrl(options = {}) {
const normalized = normalizeSearchOptions(options)
const config = CATEGORY_CONFIGS[normalized.category]
const url = new URL(`${SH_BASE_URL}${config.path}/list.do`)
if (config.multiItmSeqs) url.searchParams.set("multi_itm_seqs", config.multiItmSeqs)
if (config.multiItmSeq) url.searchParams.set("multi_itm_seq", config.multiItmSeq)
url.searchParams.set("page", String(normalized.page || 1))
if (normalized.keyword) url.searchParams.set("srchWord", normalized.keyword)
if (normalized.srchTp) url.searchParams.set("srchTp", normalized.srchTp)
return url
}
function buildDetailUrl(options = {}) {
const normalized = normalizeDetailOptions(options)
const config = CATEGORY_CONFIGS[normalized.category]
const url = new URL(`${SH_BASE_URL}${config.path}/view.do`)
if (config.multiItmSeq) url.searchParams.set("multi_itm_seq", config.multiItmSeq)
url.searchParams.set("seq", normalized.seq)
return url
}
function extractTotalCount(html) {
const match = String(html || "").match(/총\s*<strong[^>]*>\s*([0-9,]+)\s*<\/strong>\s*건/i) || stripTags(html).match(/총\s*([0-9,]+)\s*건/)
return match ? Number.parseInt(match[1].replace(/,/g, ""), 10) : null
}
function classifyNoticeStatus(title) {
const text = cleanText(title)
if (/당첨|발표/.test(text)) return "announced"
if (/마감|계약결과|결과|완료|종료/.test(text)) return "closed"
if (/모집공고|입주자\s*모집|신청|접수|공고/.test(text)) return "open"
return "unknown"
}
function statusMatches(itemStatus, requestedStatus) {
if (!requestedStatus) return true
if (requestedStatus === "closed") return itemStatus === "closed"
if (requestedStatus === "announced") return itemStatus === "announced"
return itemStatus === requestedStatus
}
function findUpstreamBlockMarkers(html) {
const text = stripTags(html)
const markers = [
["NetFunnel", /NetFunnel/i],
["CAPTCHA", /captcha|보안문자/i],
["로그인", /로그인|login/i],
["점검", /점검|maintenance/i],
["대기열", /대기열|queue/i],
["차단", /차단|block/i]
]
return markers.filter(([, pattern]) => pattern.test(text)).map(([label]) => label)
}
function buildUnexpectedHtmlWarnings(html, expectedMarkupFound, label) {
if (expectedMarkupFound) return []
const markers = findUpstreamBlockMarkers(html)
if (markers.length > 0) {
return [`unexpected SH ${label} HTML; possible block/maintenance markers: ${markers.join(", ")}`]
}
return [`unexpected SH ${label} HTML; expected public SH ${label} markup was not found.`]
}
function parseListRows(html, options = {}) {
const normalized = normalizeSearchOptions(options)
const config = CATEGORY_CONFIGS[normalized.category]
const listAreaMatch = String(html || "").match(/<div\b[^>]*id=["']listTb["'][^>]*>[\s\S]*?<tbody[^>]*>([\s\S]*?)<\/tbody>[\s\S]*?<\/div>/i)
const tbodyMatch = listAreaMatch || String(html || "").match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i)
const tbody = tbodyMatch ? tbodyMatch[1] : String(html || "")
const rows = []
let rowMatch
const rowRegex = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi
while ((rowMatch = rowRegex.exec(tbody))) {
const row = rowMatch[1]
const seqMatch = row.match(/getDetailView\(\s*['"]?(\d+)['"]?\s*\)/i)
if (!seqMatch) continue
const cells = [...row.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map((match) => match[1])
if (cells.length < 5) continue
const titleAnchor = cells[1].match(/<a\b[^>]*>([\s\S]*?)<\/a>/i)
const rawTitle = (titleAnchor ? titleAnchor[1] : cells[1]).replace(/<span\b[^>]*class=["'][^"']*icoNew[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ")
const title = trimOrNull(stripTags(rawTitle).replace(/^NEW\s*/i, ""))
const seq = seqMatch[1]
const status = classifyNoticeStatus(title)
const item = {
seq,
number: trimOrNull(stripTags(cells[0])),
title,
department: trimOrNull(stripTags(cells[2])),
registered_date: trimOrNull(stripTags(cells[3])),
views: parseNumberOrNull(stripTags(cells[4])),
is_new: /icoNew|>\s*NEW\s*</i.test(cells[1]),
category: config.key,
category_name: config.name,
status,
status_basis: "title_text_classifier",
detail_url: buildDetailUrl({ seq, category: config.key }).toString()
}
if (statusMatches(item.status, normalized.status)) rows.push(compactObject(item))
}
return rows
}
function parseNumberOrNull(value) {
const text = cleanText(value)
return /^[0-9,]+$/.test(text) ? Number.parseInt(text.replace(/,/g, ""), 10) : null
}
function parseListHtml(html, options = {}) {
const normalized = normalizeSearchOptions(options)
const items = parseListRows(html, normalized).slice(0, normalized.pageSize)
const hasExpectedListMarkup = /<div\b[^>]*id=["']listTb["']/i.test(String(html || "")) || /<tbody[^>]*>[\s\S]*getDetailView\(/i.test(String(html || ""))
const result = {
query: {
keyword: normalized.keyword || null,
srch_tp: normalized.srchTp || null,
category: normalized.category,
category_name: CATEGORY_CONFIGS[normalized.category].name,
status: normalized.status || null
},
summary: {
page: normalized.page,
page_size: normalized.pageSize,
returned_count: items.length,
total_count: extractTotalCount(html)
},
source: {
name: "sh-public-html",
url: buildSearchUrl(normalized).toString(),
proxy: false
},
warnings: buildUnexpectedHtmlWarnings(html, hasExpectedListMarkup, "list"),
items
}
if (normalized.status) {
result.warnings.push("SH public board has no first-class status field; status filtering uses a conservative title-text classifier.")
}
if (normalized.includeHtml) result.html = html
return result
}
function parseAttachmentDownList(html) {
const match = String(html || "").match(/downList["\']?\s*[:=]\s*(\[[\s\S]*?\])\s*[;,}]/)
if (!match) return []
try {
const parsed = JSON.parse(match[1])
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function isAttachmentIconLabel(value) {
const text = trimOrNull(value)
return !text || /^\.(?:pdf|hwp|hwpx|docx?|xlsx?|pptx?|txt|zip|jpg|jpeg|png|gif|mp[34]|etc)$/i.test(text)
}
function parseAttachments(html) {
const downList = parseAttachmentDownList(html)
const byFileSeq = new Map(downList.map((file) => [String(file.fileSeq || ""), file]))
const attachments = []
const source = String(html || "").replace(/<!--[\s\S]*?-->/g, " ")
const rowRegex = /<tr\b[^>]*>[\s\S]*?<th\b[^>]*>\s*첨부(?:파일)?\s*<\/th>[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>[\s\S]*?<\/tr>/gi
let match
while ((match = rowRegex.exec(source))) {
const cell = match[1]
const anchors = [...cell.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)].map((anchorMatch) => {
const attrs = anchorMatch[1]
return {
className: getHtmlAttr(attrs, "class"),
href: getHtmlAttr(attrs, "href"),
onclick: getHtmlAttr(attrs, "onclick"),
text: trimOrNull(stripTags(anchorMatch[2]))
}
})
const previewUrls = anchors
.map((anchor) => anchor.href)
.filter((href) => /htmlConverter\.do/i.test(href))
.map(normalizeAttachmentPreviewUrl)
const fileAnchors = anchors.filter((anchor) => /\bbtnAttach\b/i.test(anchor.className) && /existFile\(\s*['"]?\d+['"]?\s*\)/i.test(anchor.onclick) && !isAttachmentIconLabel(anchor.text))
fileAnchors.forEach((anchor, index) => {
const previewUrl = previewUrls[index] || null
const fileSeq = previewUrl && new URL(previewUrl).searchParams.get("file_seq")
const meta = byFileSeq.get(String(fileSeq || "")) || {}
attachments.push(compactObject({
filename: cleanText(meta.oriFileNm || anchor.text),
file_seq: fileSeq || (meta.fileSeq ? String(meta.fileSeq) : null),
file_size: parseNumberOrNull(meta.fileSize),
file_type: trimOrNull(meta.fileTp),
preview_url: previewUrl
}))
})
}
return attachments
}
function normalizeAttachmentPreviewUrl(href) {
try {
const url = new URL(href, SH_BASE_URL)
if (url.origin !== SH_BASE_URL) return null
if (url.pathname !== "/app/com/util/htmlConverter.do") return null
return url.toString()
} catch {
return null
}
}
function extractDepartment(html) {
const personInfoMatch = String(html || "").match(/<ul\b[^>]*class=["'][^"']*personInfo[^"']*["'][^>]*>([\s\S]*?)<\/ul>/i)
if (!personInfoMatch) return null
const departmentMatch = personInfoMatch[1].match(/담당부서\s*<\/span>\s*:\s*([^<]+)/i) || stripTags(personInfoMatch[1]).match(/담당부서\s*:\s*([^:]+?)(?:담당자|연락처|$)/)
return departmentMatch ? trimOrNull(departmentMatch[1]) : null
}
function parseDetailHtml(html, options = {}) {
const normalized = normalizeDetailOptions(options)
const config = CATEGORY_CONFIGS[normalized.category]
const source = String(html || "")
const titleMatch = String(html || "").match(/<div\b[^>]*class=["'][^"']*detailTable[^"']*firgs0401Table[^"']*["'][^>]*>[\s\S]*?<caption>([\s\S]*?)<\/caption>/i) ||
String(html || "").match(/<thead>[\s\S]*?<th\b[^>]*colspan=["']2["'][^>]*>([\s\S]*?)<\/th>/i)
const registeredMatch = String(html || "").match(/<strong>\s*등록일\s*:\s*<\/strong>\s*([0-9]{4}[-.][0-9]{2}[-.][0-9]{2})/i)
const viewsMatch = String(html || "").match(/<strong>\s*조회수\s*:\s*<\/strong>\s*([0-9,]+)/i)
const contentMatch = String(html || "").match(/<td\b[^>]*class=["']cont["'][^>]*>([\s\S]*?)<\/td>/i)
const title = trimOrNull(stripTags(titleMatch ? titleMatch[1] : ""))
const attachments = parseAttachments(html)
const detail = compactObject({
seq: normalized.seq,
title,
registered_date: registeredMatch ? registeredMatch[1].replace(/\./g, "-") : null,
views: viewsMatch ? Number.parseInt(viewsMatch[1].replace(/,/g, ""), 10) : null,
department: extractDepartment(html),
category: config.key,
category_name: config.name,
status: classifyNoticeStatus(title),
status_basis: "title_text_classifier",
content_text: trimOrNull(stripTags(contentMatch ? contentMatch[1] : "")),
detail_url: buildDetailUrl(normalized).toString(),
warnings: buildUnexpectedHtmlWarnings(html, /detailTable|class=["']cont["']|firgs0401Table/i.test(source), "detail")
})
detail.attachments = attachments
if (normalized.includeHtml) detail.html = html
return detail
}
function createTimeoutSignal(timeoutMs) {
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout !== "function") return null
const n = Number(timeoutMs)
return Number.isFinite(n) && n > 0 ? AbortSignal.timeout(n) : null
}
async function fetchText(url, options = {}) {
const fetcher = options.fetcher || global.fetch
if (!fetcher) throw new Error("fetch is required")
const signal = options.signal || createTimeoutSignal(options.timeoutMs || DEFAULT_TIMEOUT_MS)
let response
try {
response = await fetcher(url.toString(), {
headers: {
"user-agent": "Mozilla/5.0 (compatible; k-skill/sh-notice-search)",
accept: "text/html,application/xhtml+xml"
},
signal
})
} catch (error) {
throw new Error(`SH upstream request failed: ${error.message}`)
}
const text = await response.text()
if (!response.ok) {
throw new Error(`SH upstream responded with HTTP ${response.status}: ${text.slice(0, 200)}`)
}
return text
}
async function searchNotices(options = {}) {
const normalized = normalizeSearchOptions(options)
const html = await fetchText(buildSearchUrl(normalized), normalized)
return parseListHtml(html, normalized)
}
async function getNoticeDetail(options = {}) {
const normalized = normalizeDetailOptions(options)
const html = await fetchText(buildDetailUrl(normalized), normalized)
return {
notice: parseDetailHtml(html, normalized),
query: {
seq: normalized.seq,
category: normalized.category,
category_name: CATEGORY_CONFIGS[normalized.category].name
},
source: {
name: "sh-public-html",
url: buildDetailUrl(normalized).toString(),
proxy: false
}
}
}
module.exports = {
SH_BASE_URL,
DEFAULT_CATEGORY,
CATEGORY_CONFIGS,
STATUS_ALIASES,
cleanText,
stripTags,
normalizeCategory,
normalizeSearchOptions,
normalizeDetailOptions,
buildSearchUrl,
buildDetailUrl,
extractTotalCount,
classifyNoticeStatus,
parseListRows,
parseListHtml,
parseAttachmentDownList,
parseAttachments,
parseDetailHtml,
createTimeoutSignal,
searchNotices,
getNoticeDetail
}

View file

@ -0,0 +1,261 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const { spawnSync } = require("node:child_process")
const {
CATEGORY_CONFIGS,
buildSearchUrl,
buildDetailUrl,
normalizeSearchOptions,
normalizeDetailOptions,
parseListHtml,
parseAttachments,
parseDetailHtml,
searchNotices,
getNoticeDetail
} = require("../src/index")
const LIST_HTML = `<!doctype html><html><body>
<form name="mainform" action="./list.do" method="post">
<input type="hidden" name="page" id="page" value="1" />
<input type="hidden" name="multi_itm_seq" value="2" />
<select name="srchTp" id="s_keyword"><option value="0" selected>제목</option><option value="1"></option></select>
<input type="text" value="행복주택" name="srchWord" />
</form>
<div class="topTxt"><p> <strong class="cBrown bold">95</strong> [1/10]</p></div>
<div id="listTb" class="listTable colRm"><table>
<caption>주택임대 공고 공지 목록</caption>
<thead><tr><th>번호</th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
<tr><td>95</td><td class="txtL"><a href="#" onclick="javascript:getDetailView('304371');return false;"><span class="icoNew">NEW</span> </a></td><td></td><td class="num">2026-05-14</td><td class="num">872</td></tr>
<tr><td>94</td><td class="txtL"><a href="#" onclick="javascript:getDetailView('304346');return false;"> </a></td><td></td><td class="num">2026-05-14</td><td class="num">1,210</td></tr>
</tbody></table></div>
</body></html>`
const DETAIL_HTML = `<!doctype html><html><body>
<script>
const initParam = { downList: [{"brdId":"GS0401","seq":"304371","fileSeq":"1","fileSize":"131614","oriFileNm":"2025년 2차 행복주택 예비3차 계약결과.pdf","fileTp":"A"},{"brdId":"GS0401","seq":"304371","fileSeq":"2","fileSize":"2816","oriFileNm":"추가 안내문.hwp","fileTp":"A"}] };
</script>
<div class="detailTable gs0401Table firgs0401Table"><table>
<caption>행복주택 예비자 계약결과 알림</caption>
<thead><tr><th scope="col" colspan="2">행복주택 예비자 계약결과 알림</th></tr></thead>
<tbody>
<tr><td colspan="2"><ul><li><strong>등록일 : </strong>2026-05-14</li><li><strong> : </strong>875</li></ul></td></tr>
<tr><th scope="row">첨부</th><td>
<!-- icon template should not be parsed as a real attachment
<a href="#" class="btnAttach v1">.pdf</a><a href="#" class="btnAttach v2">.hwp</a>
-->
<a href="#" class="btnAttach v1" onclick="existFile('0'); return false;">2025 2 행복주택 예비3차 계약결과.pdf</a>
<a href="/app/com/util/htmlConverter.do?brd_id=GS0401&amp;seq=304371&amp;data_tp=A&amp;file_seq=1" class="btn btnWhite h32 icoView">미리보기</a>
<a href="#" class="btnAttach v2" onclick="existFile('1'); return false;">추가 안내문.hwp</a>
<a href="/app/com/util/htmlConverter.do?brd_id=GS0401&amp;seq=304371&amp;data_tp=A&amp;file_seq=2" class="btn btnWhite h32 icoView">미리보기</a>
</td></tr>
<tr><td colspan="2" class="cont"><p>행복주택 예비자 계약결과알림</p><p>2025 2 .</p></td></tr>
</tbody></table></div>
<form name="mainform"><input type="hidden" name="srchWord" id="srchWord" value="행복주택" /></form>
<ul class="personInfo"><li><span>담당부서</span> : </li></ul>
</body></html>`
const BLOCKED_HTML = `<!doctype html><html><body>
<main>
<h1>서비스 점검 안내</h1>
<p>NetFunnel 대기열 또는 로그인 확인 다시 이용해 주세요.</p>
</main>
</body></html>`
test("normalizeSearchOptions defaults keyword searches to SH title scope", () => {
const options = normalizeSearchOptions({ keyword: "행복주택", limit: 50, page: "2" })
assert.equal(options.keyword, "행복주택")
assert.equal(options.srchTp, "0")
assert.equal(options.page, 2)
assert.equal(options.pageSize, 10)
assert.equal(options.category, "rent")
})
test("normalizeSearchOptions maps content scope, category aliases, and status", () => {
const options = normalizeSearchOptions({ q: "매입임대", searchType: "내용", category: "주거복지", status: "진행" })
assert.equal(options.srchTp, "1")
assert.equal(options.category, "purchase")
assert.equal(options.status, "open")
})
test("normalizeSearchOptions rejects invalid bounded inputs", () => {
assert.throws(() => normalizeSearchOptions({ q: "x".repeat(101) }), /100 characters/)
assert.throws(() => normalizeSearchOptions({ page: "abc" }), /valid page/)
assert.throws(() => normalizeSearchOptions({ category: "unknown" }), /Unsupported SH category/)
assert.throws(() => normalizeSearchOptions({ status: "maybe" }), /Unsupported SH status/)
})
test("buildSearchUrl targets the public SH list page directly and sets srchTp", () => {
const url = buildSearchUrl(normalizeSearchOptions({ q: "행복주택", category: "rent" }))
assert.equal(url.hostname, "www.i-sh.co.kr")
assert.equal(url.pathname, CATEGORY_CONFIGS.rent.path + "/list.do")
assert.equal(url.searchParams.get("srchWord"), "행복주택")
assert.equal(url.searchParams.get("srchTp"), "0")
assert.equal(url.searchParams.get("multi_itm_seq"), "2")
})
test("buildSearchUrl normalizes public helper inputs before building URLs", () => {
const koreanAlias = buildSearchUrl({ keyword: "행복주택", category: "임대", page: 1 })
const englishAlias = buildSearchUrl({ keyword: "행복주택", category: "rent", page: 1 })
assert.equal(koreanAlias.pathname, CATEGORY_CONFIGS.rent.path + "/list.do")
assert.equal(koreanAlias.searchParams.get("srchWord"), "행복주택")
assert.equal(koreanAlias.searchParams.get("srchTp"), "0")
assert.equal(koreanAlias.searchParams.get("multi_itm_seq"), "2")
assert.equal(englishAlias.searchParams.get("srchTp"), "0")
})
test("buildDetailUrl normalizes public helper inputs before building URLs", () => {
const url = buildDetailUrl({ seq: "304371", category: "임대" })
assert.equal(url.hostname, "www.i-sh.co.kr")
assert.equal(url.pathname, CATEGORY_CONFIGS.rent.path + "/view.do")
assert.equal(url.searchParams.get("multi_itm_seq"), "2")
assert.equal(url.searchParams.get("seq"), "304371")
})
test("buildSearchUrl uses official category-specific board paths", () => {
const sale = buildSearchUrl(normalizeSearchOptions({ category: "분양" }))
const welfare = buildSearchUrl(normalizeSearchOptions({ category: "welfare" }))
assert.equal(sale.pathname, CATEGORY_CONFIGS.sale.path + "/list.do")
assert.equal(sale.searchParams.get("multi_itm_seq"), "1")
assert.equal(welfare.pathname, CATEGORY_CONFIGS.purchase.path + "/list.do")
assert.equal(welfare.searchParams.get("multi_itm_seq"), "512")
})
test("parseListHtml returns rows, total count, category, and detail URLs", () => {
const result = parseListHtml(LIST_HTML, normalizeSearchOptions({ q: "행복주택", category: "rent" }))
assert.equal(result.summary.total_count, 95)
assert.equal(result.summary.returned_count, 2)
assert.equal(result.items[0].seq, "304371")
assert.equal(result.items[0].title, "행복주택 예비자 계약결과 알림")
assert.equal(result.items[0].views, 872)
assert.equal(result.items[0].is_new, true)
assert.equal(result.items[0].category, "rent")
assert.equal(result.items[0].category_name, "주택임대")
assert.match(result.items[0].detail_url, /www\.i-sh\.co\.kr/)
})
test("parseListHtml normalizes public helper inputs before parsing", () => {
const result = parseListHtml(LIST_HTML, { keyword: "행복주택", category: "임대", page: 1 })
assert.equal(result.query.category, "rent")
assert.equal(result.query.category_name, "주택임대")
assert.equal(result.query.srch_tp, "0")
assert.equal(result.summary.page, 1)
assert.equal(result.summary.page_size, 10)
assert.equal(result.items[0].category, "rent")
assert.equal(result.items[0].category_name, "주택임대")
assert.match(result.source.url, /srchTp=0/)
})
test("parseListHtml warns when SH returns block or maintenance HTML without list markup", () => {
const result = parseListHtml(BLOCKED_HTML, { keyword: "행복주택", category: "임대" })
assert.equal(result.summary.returned_count, 0)
assert.equal(result.summary.total_count, null)
assert.match(result.warnings.join("\n"), /unexpected SH list HTML.*NetFunnel.*로그인.*점검/i)
})
test("parseListHtml applies conservative status filtering after parsing", () => {
const closed = parseListHtml(LIST_HTML, normalizeSearchOptions({ status: "closed" }))
const open = parseListHtml(LIST_HTML, normalizeSearchOptions({ status: "open" }))
assert.equal(closed.items.length, 1)
assert.match(closed.items[0].title, /계약결과/)
assert.equal(open.items.length, 0)
})
test("parseDetailHtml normalizes public helper inputs before parsing", () => {
const detail = parseDetailHtml(DETAIL_HTML, { seq: "304371", category: "임대" })
assert.equal(detail.seq, "304371")
assert.equal(detail.category, "rent")
assert.equal(detail.category_name, "주택임대")
assert.equal(detail.attachments.length, 2)
assert.match(detail.detail_url, /multi_itm_seq=2/)
})
test("parseDetailHtml warns when SH returns block or maintenance HTML without detail markup", () => {
const detail = parseDetailHtml(BLOCKED_HTML, { seq: "304371", category: "임대" })
assert.equal(detail.seq, "304371")
assert.equal(detail.title, undefined)
assert.deepEqual(detail.attachments, [])
assert.match(detail.warnings.join("\n"), /unexpected SH detail HTML.*NetFunnel.*로그인.*점검/i)
})
test("parseAttachments exposes only SH-origin htmlConverter preview URLs", () => {
const html = DETAIL_HTML.replace(
"/app/com/util/htmlConverter.do?brd_id=GS0401&amp;seq=304371&amp;data_tp=A&amp;file_seq=1",
"https://evil.example/htmlConverter.do?brd_id=GS0401&amp;seq=304371&amp;data_tp=A&amp;file_seq=1"
)
const attachments = parseAttachments(html)
assert.equal(attachments[0].filename, "2025년 2차 행복주택 예비3차 계약결과.pdf")
assert.equal(attachments[0].preview_url, undefined)
assert.equal(attachments[0].file_seq, undefined)
assert.equal(attachments[1].preview_url, "https://www.i-sh.co.kr/app/com/util/htmlConverter.do?brd_id=GS0401&seq=304371&data_tp=A&file_seq=2")
})
test("parseDetailHtml extracts real attachments by existFile onclick, not icon templates", () => {
const detail = parseDetailHtml(DETAIL_HTML, normalizeDetailOptions({ seq: "304371", category: "rent" }))
assert.equal(detail.seq, "304371")
assert.equal(detail.title, "행복주택 예비자 계약결과 알림")
assert.equal(detail.registered_date, "2026-05-14")
assert.equal(detail.views, 875)
assert.equal(detail.department, "공공주택공급부")
assert.match(detail.content_text, /입주자모집 계약 결과/)
assert.equal(detail.attachments.length, 2)
assert.deepEqual(detail.attachments[0], {
filename: "2025년 2차 행복주택 예비3차 계약결과.pdf",
file_seq: "1",
file_size: 131614,
file_type: "A",
preview_url: "https://www.i-sh.co.kr/app/com/util/htmlConverter.do?brd_id=GS0401&seq=304371&data_tp=A&file_seq=1"
})
assert.equal(Object.hasOwn(detail.attachments[0], "download_url"), false)
})
test("searchNotices and getNoticeDetail fetch official SH HTML with caller-injected fetch", async () => {
const calls = []
const fetcher = async (url, options) => {
calls.push({ url: String(url), options })
return { ok: true, status: 200, statusText: "OK", text: async () => String(url).includes("view.do") ? DETAIL_HTML : LIST_HTML }
}
const list = await searchNotices({ keyword: "행복주택", fetcher })
const detail = await getNoticeDetail({ seq: list.items[0].seq, fetcher })
assert.equal(calls[0].url, buildSearchUrl(normalizeSearchOptions({ keyword: "행복주택" })).toString())
assert.match(calls[0].options.headers["user-agent"], /k-skill\/sh-notice-search/)
assert.equal(list.items.length, 2)
assert.equal(detail.notice.attachments.length, 2)
})
test("CLI parses options and prints help", () => {
const cli = require("../src/cli")
assert.deepEqual(cli.parseArgs(["행복주택", "--category", "임대", "--status", "마감", "--page", "5", "--limit", "20"]), {
keyword: "행복주택",
category: "임대",
status: "마감",
page: "5",
limit: "20"
})
const help = spawnSync(process.execPath, ["src/cli.js", "--help"], {
cwd: __dirname + "/..",
encoding: "utf8"
})
assert.equal(help.status, 0)
assert.match(help.stdout, /Usage: sh-notice-search/)
})

View file

@ -1210,6 +1210,69 @@ test("olive-young-search skill documents the upstream daiso CLI flow for stores,
}
});
test("repository docs advertise the korean-cinema-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-cinema-search.md");
const skillPath = path.join(repoRoot, "korean-cinema-search", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-cinema-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected korean-cinema-search/SKILL.md to exist");
assert.match(readme, /\| 영화관 검색 \|/);
assert.match(readme, /\[영화관 검색 가이드\]\(docs\/features\/korean-cinema-search\.md\)/);
assert.match(install, /--skill korean-cinema-search/);
assert.match(install, /--playDate <YYYYMMDD>/);
assert.match(install, /5xx|retry|재시도/);
assert.match(install, /git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git && cd daiso-mcp && npm install && npm run build/);
assert.match(install, /node dist\/bin\.js get \/api\/cgv\/timetable --keyword 강남 --playDate <YYYYMMDD> --json/);
assert.match(roadmap, /영화관 검색 스킬 출시/);
assert.match(sources, /https:\/\/github\.com\/hmmhmmhm\/daiso-mcp/);
assert.match(sources, /https:\/\/www\.npmjs\.com\/package\/daiso/);
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/cgv\/theaters/);
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/megabox\/theaters/);
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/lottecinema\/theaters/);
});
test("korean-cinema-search skill documents the upstream daiso CLI flow for Korean cinemas", () => {
const skillPath = path.join(repoRoot, "korean-cinema-search", "SKILL.md");
const featureDoc = read(path.join("docs", "features", "korean-cinema-search.md"));
assert.ok(fs.existsSync(skillPath), "expected korean-cinema-search/SKILL.md to exist");
const skill = read(path.join("korean-cinema-search", "SKILL.md"));
assert.match(skill, /^name: korean-cinema-search$/m);
assert.match(skill, /^description: .*CGV.*메가박스.*롯데시네마.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /hmmhmmhm\/daiso-mcp/);
assert.match(doc, /npm install -g daiso|npx --yes daiso|npx daiso/);
assert.match(doc, /MCP 서버를 .*직접 설치.*않고.*CLI/u);
assert.match(doc, /Asia\/Seoul|KST/);
assert.match(doc, /YYYYMMDD/);
assert.match(doc, /--playDate <YYYYMMDD>/);
assert.match(doc, /\/api\/cgv\/movies --keyword 강남 --playDate <YYYYMMDD> --json/);
assert.match(doc, /\/api\/cgv\/timetable --keyword 강남 --playDate <YYYYMMDD> --json/);
assert.match(doc, /\/api\/megabox\/seats --keyword 코엑스 --playDate <YYYYMMDD> --limit 10 --json/);
assert.match(doc, /\/api\/lottecinema\/seats --keyword 월드타워 --playDate <YYYYMMDD> --limit 10 --json/);
assert.match(doc, /CGV/);
assert.match(doc, /메가박스/);
assert.match(doc, /롯데시네마/);
assert.match(doc, /\/api\/cgv\/theaters/);
assert.match(doc, /\/api\/cgv\/movies/);
assert.match(doc, /\/api\/cgv\/timetable/);
assert.match(doc, /\/api\/megabox\/theaters/);
assert.match(doc, /\/api\/megabox\/movies/);
assert.match(doc, /\/api\/megabox\/seats/);
assert.match(doc, /\/api\/lottecinema\/theaters/);
assert.match(doc, /\/api\/lottecinema\/movies/);
assert.match(doc, /\/api\/lottecinema\/seats/);
assert.match(doc, /예매|결제/);
}
});
test("repository docs advertise the bunjang-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -1342,6 +1405,54 @@ test("coupang-product-search docs drop non-allowlisted coupang-mcp-fallback and
}
});
test("repository docs advertise the ohou-today-deal skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "ohou-today-deal.md");
const skillPath = path.join(repoRoot, "ohou-today-deal", "SKILL.md");
const helperPath = path.join(repoRoot, "ohou-today-deal", "scripts", "ohou_today_deal.py");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/ohou-today-deal.md to exist");
assert.ok(fs.existsSync(skillPath), "expected ohou-today-deal/SKILL.md to exist");
assert.ok(fs.existsSync(helperPath), "expected ohou-today-deal helper script to exist");
assert.match(readme, /\| 오늘의집 오늘의딜 조회 \| `ohou-today-deal` \|/);
assert.match(readme, /\[오늘의집 오늘의딜 조회 가이드\]\(docs\/features\/ohou-today-deal\.md\)/);
assert.match(install, /--skill ohou-today-deal/);
assert.match(roadmap, /오늘의집 오늘의딜 조회 스킬 출시/);
assert.match(sources, /ohou\.se\/commerces\/today_deals/);
assert.match(sources, /store\.ohou\.se\/today_deals/);
});
test("ohou-today-deal docs lock the public Next data read-only workflow", () => {
const skill = read(path.join("ohou-today-deal", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "ohou-today-deal.md"));
const helper = read(path.join("ohou-today-deal", "scripts", "ohou_today_deal.py"));
const sources = read(path.join("docs", "sources.md"));
for (const doc of [skill, featureDoc]) {
assert.match(doc, /https:\/\/ohou\.se\/commerces\/today_deals/);
assert.match(doc, /https:\/\/store\.ohou\.se\/today_deals/);
assert.match(doc, /__NEXT_DATA__/);
assert.match(doc, /today-deal-feed/);
assert.match(doc, /ohou_today_deal\.py list/);
assert.match(doc, /(로그인|API key|API 키).*(불필요|없음)|(불필요|없음).*(로그인|API key|API 키)/);
assert.match(doc, /(구매|장바구니|결제).*자동화.*(하지 않는다|하지 말고|제외)/);
}
assert.match(helper, /DEFAULT_URL = "https:\/\/ohou\.se\/commerces\/today_deals"/);
assert.match(helper, /__NEXT_DATA__/);
assert.match(helper, /today-deal-feed/);
assert.match(helper, /special-today-deal-feed/);
assert.match(
helper,
/k-skill-ohou-today-deal\/1\.0 \(\+https:\/\/github\.com\/NomaDamas\/k-skill\)/,
);
assert.match(sources, /ohou\.se\/commerces\/today_deals/);
assert.match(sources, /store\.ohou\.se\/today_deals/);
});
test("root pack:dry-run script covers all publishable workspaces", () => {
const packageJson = readJson("package.json");
const packScript = packageJson.scripts["pack:dry-run"];
@ -3882,6 +3993,7 @@ const README_SKILL_NAME_COLUMN_MAPPING = [
["다이소 상품 조회", "daiso-product-search"],
["마켓컬리 상품 조회", "market-kurly-search"],
["올리브영 검색", "olive-young-search"],
["영화관 검색", "korean-cinema-search"],
["올라포케 역삼 포케", "hola-poke-yeoksam"],
["택배 배송조회", "delivery-tracking"],
["쿠팡 상품 검색", "coupang-product-search"],

View file

@ -0,0 +1,161 @@
import importlib.util
import json
import pathlib
import unittest
from unittest import mock
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "danawa-price-search" / "scripts" / "danawa_search.py"
spec = importlib.util.spec_from_file_location("danawa_search", MODULE_PATH)
danawa_search = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(danawa_search)
def diff_item(*, mall="테스트몰", price="100,000원", badge_html=""):
return f"""
<div class="diff_item">
<div class="d_mall"><img alt="{mall}" /></div>
<div class="prc_line">
{badge_html}
<em class="prc_c">{price}</em>
</div>
<div class="ship">무료배송</div>
<a class="priceCompareBuyLink" href="/bridge/test"></a>
</div>
"""
class DanawaPaymentBadgeTest(unittest.TestCase):
def offers_from_rows(self, rows_html):
with mock.patch.object(
danawa_search,
"product_meta",
return_value={
"pcode": "75001853",
"source_url": "https://prod.danawa.com/info/?pcode=75001853",
"sProductFullName": "테스트 상품",
},
), mock.patch.object(danawa_search, "fetch", return_value=f"<div>{rows_html}</div>"):
return danawa_search.offers("75001853", limit=10)
def test_discount_badge_is_conditional_and_counted(self):
result = self.offers_from_rows(
diff_item(badge_html='<span class="ico discount">할인</span>')
)
self.assertEqual(result["normal_count"], 0)
self.assertEqual(result["conditional_count"], 1)
offer = result["offers"][0]
self.assertEqual(offer["payment_badges"], ["할인"])
self.assertTrue(offer["discount_badge"])
self.assertTrue(offer["is_conditional_price"])
self.assertIn("discount", offer["payment_condition_types"])
self.assertEqual(offer["payment_condition_label"], "할인")
def test_membership_badge_is_conditional_and_counted(self):
result = self.offers_from_rows(
diff_item(badge_html='<span class="ico membership">멤버십</span>')
)
self.assertEqual(result["normal_count"], 0)
self.assertEqual(result["conditional_count"], 1)
offer = result["offers"][0]
self.assertEqual(offer["payment_badges"], ["멤버십"])
self.assertTrue(offer["membership_badge"])
self.assertTrue(offer["is_conditional_price"])
self.assertIn("membership", offer["payment_condition_types"])
self.assertEqual(offer["payment_condition_label"], "멤버십")
def test_class_only_payment_badges_synthesize_display_labels(self):
cases = [
("cash", "현금", "cash_only"),
("point", "포인트", "point_only"),
("coupon", "쿠폰", "coupon_only"),
("card", "카드", "card_only_badge"),
("discount", "할인", "discount_badge"),
("membership", "멤버십", "membership_badge"),
]
for badge_type, label, boolean_field in cases:
with self.subTest(badge_type=badge_type):
result = self.offers_from_rows(
diff_item(badge_html=f'<span class="ico {badge_type}"></span>')
)
self.assertEqual(result["normal_count"], 0)
self.assertEqual(result["conditional_count"], 1)
offer = result["offers"][0]
self.assertEqual(offer["payment_badges"], [label])
self.assertEqual(offer["payment_condition_types"], [badge_type])
self.assertEqual(offer["payment_condition_label"], label)
self.assertTrue(offer[boolean_field])
self.assertTrue(offer["is_conditional_price"])
def test_payment_badges_are_deduped_with_canonical_class_order(self):
result = self.offers_from_rows(
diff_item(
badge_html=(
'<span class="ico cash"></span>'
'<span class="ico">현금</span>'
'<span class="ico card">카드</span>'
)
)
)
offer = result["offers"][0]
self.assertEqual(offer["payment_badges"], ["현금", "카드"])
self.assertEqual(offer["payment_condition_types"], ["cash", "card"])
self.assertEqual(offer["payment_condition_label"], "현금, 카드")
def test_text_only_card_badge_is_conditional_and_counted(self):
result = self.offers_from_rows(
diff_item(badge_html='<span class="ico">카드</span>')
)
self.assertEqual(result["normal_count"], 0)
self.assertEqual(result["conditional_count"], 1)
offer = result["offers"][0]
self.assertEqual(offer["payment_badges"], ["카드"])
self.assertTrue(offer["card_only_badge"])
self.assertTrue(offer["is_conditional_price"])
self.assertEqual(offer["payment_condition_types"], ["card"])
self.assertEqual(offer["payment_condition_label"], "카드")
def test_non_payment_ico_is_not_captured(self):
result = self.offers_from_rows(
diff_item(badge_html='<span class="ico quick">빠른배송</span>')
)
self.assertEqual(result["normal_count"], 1)
self.assertEqual(result["conditional_count"], 0)
offer = result["offers"][0]
self.assertEqual(offer["payment_badges"], [])
self.assertFalse(offer["is_conditional_price"])
self.assertEqual(offer["payment_condition_types"], [])
self.assertIsNone(offer["payment_condition_label"])
def test_cli_json_includes_normalized_payment_fields(self):
rows = diff_item(badge_html='<span class="ico cash">현금</span>')
with mock.patch.object(
danawa_search,
"product_meta",
return_value={
"pcode": "75001853",
"source_url": "https://prod.danawa.com/info/?pcode=75001853",
"sProductFullName": "테스트 상품",
},
), mock.patch.object(danawa_search, "fetch", return_value=f"<div>{rows}</div>"), mock.patch.object(
danawa_search.sys, "argv", ["danawa_search.py", "offers", "75001853", "--limit", "1"]
), mock.patch("builtins.print") as mocked_print:
self.assertEqual(danawa_search.main(), 0)
payload = json.loads(mocked_print.call_args.args[0])
offer = payload["offers"][0]
self.assertEqual(offer["payment_condition_types"], ["cash"])
self.assertEqual(offer["payment_condition_label"], "현금")
if __name__ == "__main__":
unittest.main()

View file

@ -242,13 +242,213 @@ class KakaoTalkMacHelperTests(unittest.TestCase):
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
kakaotalk_mac.build_passthrough_command("query", auth, ["DELETE FROM chat_logs"])
def test_build_parser_only_exposes_read_only_commands(self) -> None:
def test_build_parser_exposes_safe_helper_commands_without_raw_query(self) -> None:
parser = kakaotalk_mac.build_parser()
subcommands = parser._subparsers._group_actions[0].choices
self.assertEqual(sorted(subcommands), ["auth", "chats", "messages", "schema", "search"])
self.assertEqual(sorted(subcommands), ["auth", "chats", "delete", "delete-last", "messages", "schema", "search"])
self.assertNotIn("query", subcommands)
def test_build_parser_exposes_delete_commands_with_safe_dry_run(self) -> None:
parser = kakaotalk_mac.build_parser()
subcommands = parser._subparsers._group_actions[0].choices
self.assertIn("delete", subcommands)
self.assertIn("delete-last", subcommands)
parsed = parser.parse_args(["delete", "팀 공지방", "42", "--everyone", "--dry-run"])
self.assertEqual(parsed.command, "delete")
self.assertEqual(parsed.chat, "팀 공지방")
self.assertEqual(parsed.message_id, 42)
self.assertTrue(parsed.everyone)
self.assertTrue(parsed.dry_run)
def test_select_delete_target_by_message_id_requires_matching_outbound_message(self) -> None:
messages = [
{"id": 41, "text": "older", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 42, "text": "sent follow-up", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertEqual(target.message_id, 42)
self.assertEqual(target.text, "sent follow-up")
self.assertTrue(target.is_from_me)
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
kakaotalk_mac.select_delete_target(messages, message_id=404, delete_last=False, everyone=False)
def test_select_delete_target_rejects_non_outbound_message_before_delete_for_me(self) -> None:
messages = [{"id": 42, "text": "inbound", "is_from_me": False}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("sent by this KakaoTalk account", str(context.exception))
def test_select_delete_last_uses_most_recent_message_from_me(self) -> None:
messages = [
{"id": 100, "text": "latest inbound", "is_from_me": False, "timestamp": "2026-05-14T00:02:00Z"},
{"id": 99, "text": "latest outbound", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
{"id": 98, "text": "older outbound", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=True)
self.assertEqual(target.message_id, 99)
self.assertEqual(target.text, "latest outbound")
def test_select_delete_last_sorts_unordered_messages_by_timestamp_then_id(self) -> None:
messages = [
{"id": 40, "text": "older outbound", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 42, "text": "latest outbound", "is_from_me": True, "timestamp": "2026-05-14T00:02:00Z"},
{"id": 41, "text": "middle outbound", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=False)
self.assertEqual(target.message_id, 42)
self.assertEqual(target.text, "latest outbound")
def test_select_delete_last_uses_id_as_tiebreaker_for_equal_timestamps(self) -> None:
messages = [
{"id": 40, "text": "same time older id", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 43, "text": "same time newer id", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=False)
self.assertEqual(target.message_id, 43)
self.assertEqual(target.text, "same time newer id")
def test_select_delete_target_rejects_everyone_for_non_outbound_message(self) -> None:
messages = [{"id": 42, "text": "inbound", "is_from_me": False}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=True)
self.assertIn("--everyone", str(context.exception))
def test_build_delete_osascript_mentions_chat_text_and_delete_scope(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertIn("팀 공지방", script)
self.assertIn("테스트 메시지", script)
self.assertIn("모두에게서 삭제", script)
self.assertIn("Delete for Everyone", script)
self.assertIn("matchingElements", script)
self.assertIn("Could not choose the requested delete scope", script)
def test_build_delete_osascript_uses_fail_closed_exact_transcript_resolver(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertNotIn("entire contents of front window", script)
self.assertNotIn("contains messageText", script)
self.assertNotIn("contains chatName", script)
self.assertIn("set normalizedMessageText to normalizeText(messageText)", script)
self.assertIn("set normalizedChatName to normalizeText(chatName)", script)
self.assertIn("if normalizeText(candidateValue) is normalizedMessageText then", script)
self.assertIn("if normalizeText(chatCandidateValue) is normalizedChatName then", script)
self.assertIn("set messageListCandidates to", script)
self.assertIn("AXShowMenu", script)
self.assertIn("Target message text matched multiple visible targetable message bubbles", script)
self.assertIn("Could not verify the active KakaoTalk chat", script)
self.assertNotIn("set messageTimestamp to", script)
def test_run_delete_dry_run_validates_target_but_skips_ui_side_effect(self) -> None:
stdout = io.StringIO()
auth = make_resolved_auth()
messages = [{"id": 42, "text": "검증된 메시지", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"}]
with (
mock.patch.object(kakaotalk_mac, "resolve_auth", return_value=auth) as resolve_auth,
mock.patch.object(kakaotalk_mac, "load_messages_for_delete", return_value=messages) as load_messages,
mock.patch.object(kakaotalk_mac, "run_delete_automation") as run_delete,
mock.patch("sys.stdout", stdout),
):
exit_code = kakaotalk_mac.main(["delete", "팀 공지방", "42", "--everyone", "--dry-run"])
self.assertEqual(exit_code, 0)
resolve_auth.assert_called_once()
load_messages.assert_called_once_with("팀 공지방", auth, limit=200)
run_delete.assert_not_called()
self.assertIn("DRY RUN", stdout.getvalue())
self.assertIn("message_id=42", stdout.getvalue())
self.assertIn("검증된 메시지", stdout.getvalue())
def test_run_delete_dry_run_fails_when_message_id_is_missing(self) -> None:
stderr = io.StringIO()
with (
mock.patch.object(kakaotalk_mac, "resolve_auth", return_value=make_resolved_auth()),
mock.patch.object(kakaotalk_mac, "load_messages_for_delete", return_value=[]),
mock.patch("sys.stderr", stderr),
):
exit_code = kakaotalk_mac.main(["delete", "팀 공지방", "404", "--dry-run"])
self.assertEqual(exit_code, 1)
self.assertIn("Message id 404", stderr.getvalue())
def test_select_delete_target_rejects_duplicate_visible_text(self) -> None:
messages = [
{"id": 42, "text": "same", "is_from_me": True},
{"id": 41, "text": "same", "is_from_me": True},
]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=True)
self.assertIn("same normalized visible text", str(context.exception))
def test_select_delete_target_rejects_duplicate_normalized_visible_text(self) -> None:
messages = [
{"id": 42, "text": "same visible text", "is_from_me": True},
{"id": 41, "text": "same visible text", "is_from_me": True},
]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("same normalized visible text", str(context.exception))
def test_select_delete_target_rejects_empty_or_non_text_delete_target(self) -> None:
messages = [{"id": 42, "text": " ", "type": "photo", "is_from_me": True}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("non-empty text", str(context.exception))
def test_build_delete_osascript_fails_when_final_confirmation_is_missing(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertIn("set didConfirmDelete to false", script)
self.assertIn("set didConfirmDelete to true", script)
self.assertIn("if didConfirmDelete is false then error", script)
self.assertIn("Could not confirm the KakaoTalk delete dialog", script)
def test_build_parser_rejects_negative_max_user_id(self) -> None:
parser = kakaotalk_mac.build_parser()
stderr = io.StringIO()

View file

@ -0,0 +1,294 @@
import argparse
import contextlib
import importlib.util
import io
import json
import sys
import tempfile
from pathlib import Path
import unittest
REPO_ROOT = Path(__file__).resolve().parent.parent
HELPER_PATH = REPO_ROOT / "ohou-today-deal" / "scripts" / "ohou_today_deal.py"
spec = importlib.util.spec_from_file_location("ohou_today_deal", HELPER_PATH)
ohou_today_deal = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules["ohou_today_deal"] = ohou_today_deal
spec.loader.exec_module(ohou_today_deal)
def sample_payload():
return {
"pageProps": {
"dehydratedState": {
"queries": [
{
"state": {
"data": {
"feed": [
{
"title": "러그 특가",
"startAt": "2026-05-17T15:00:00Z",
"endAt": "2026-05-20T15:00:00Z",
"type": "DEAL",
"deal": {
"id": "1215312",
"name": "디아망 방수러그",
"imageUrl": "https://example.com/rug.png",
"isSoldOut": False,
"price": {
"representativeOriginalPrice": "41040",
"representativeSellingPrice": "24800",
"discountRate": "39",
},
"brand": {"name": "체고루루"},
"badgeProperties": {"isFreeDelivery": True},
"reviewStatistic": {"reviewCount": 7504, "reviewAverage": 4.8},
"scrapInfo": {"scrapCount": 64757},
},
"salesStats": {"annualCumulativeSales": "1000"},
"bestDiscountPrice": {
"price": "21500",
"discountRate": "47",
"discountPlanDescription": "쿠폰 할인가",
},
},
{
"title": "식기 특가",
"type": "DEAL",
"deal": {
"id": "4070154",
"name": "식탁 위에 핀 꽃 bowl",
"isSoldOut": False,
"price": {
"representativeOriginalPrice": "50000",
"representativeSellingPrice": "50000",
"discountRate": "0",
},
"brand": {"name": "미브래"},
"badgeProperties": {"isFreeDelivery": False},
"reviewStatistic": {"reviewCount": 0, "reviewAverage": 0},
},
"bestDiscountPrice": {"price": "43500", "discountRate": "13"},
},
]
}
}
}
]
}
}
}
class OhouTodayDealTest(unittest.TestCase):
def test_extract_deals_normalizes_public_today_deal_shape(self):
deals = ohou_today_deal.extract_deals(sample_payload())
self.assertEqual(len(deals), 2)
first = deals[0]
self.assertEqual(first.id, "1215312")
self.assertEqual(first.title, "디아망 방수러그")
self.assertEqual(first.brand, "체고루루")
self.assertEqual(first.original_price, 41040)
self.assertEqual(first.selling_price, 24800)
self.assertEqual(first.best_price, 21500)
self.assertEqual(first.best_discount_rate, 47)
self.assertTrue(first.free_delivery)
self.assertEqual(first.url, "https://ohou.se/productions/1215312/selling")
def test_filter_and_sort_deals(self):
deals = ohou_today_deal.extract_deals(sample_payload())
filtered = ohou_today_deal.filter_deals(
deals,
query="러그",
min_discount=40,
free_delivery=True,
)
sorted_deals = ohou_today_deal.sort_deals(deals, "discount")
self.assertEqual([deal.id for deal in filtered], ["1215312"])
self.assertEqual([deal.id for deal in sorted_deals], ["1215312", "4070154"])
def test_extract_next_data_accepts_html_script(self):
html_doc = (
'<html><script id="__NEXT_DATA__" type="application/json">'
+ json.dumps(sample_payload(), ensure_ascii=False)
+ "</script></html>"
)
payload = ohou_today_deal.extract_next_data(html_doc)
self.assertEqual(
payload["pageProps"]["dehydratedState"]["queries"][0]["state"]["data"]["feed"][0]["deal"]["id"],
"1215312",
)
def test_cli_prints_json_from_html_file(self):
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".html") as fixture:
fixture.write(
'<script id="__NEXT_DATA__" type="application/json">'
+ json.dumps(sample_payload(), ensure_ascii=False)
+ "</script>"
)
fixture.flush()
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
ohou_today_deal.main(["list", "--html-file", fixture.name, "--limit", "1"])
output = json.loads(stdout.getvalue())
self.assertEqual(output["count"], 1)
self.assertEqual(output["items"][0]["id"], "1215312")
def react_query_payload():
"""라이브 ohou.se 페이지와 동일한 React Query dehydratedState 구조.
- `today-deal-feed` queryKey: today-deal 슬롯 2 (DEAL 1, GOODS 1)
- `special-today-deal-feed` queryKey: special-deal 슬롯 1 (DEAL)
- `navigation` queryKey: 무관한 deal-like 노드 (필터로 걸러내야 )
"""
return {
"props": {
"pageProps": {
"dehydratedState": {
"queries": [
{
"queryKey": ["navigation"],
"state": {
"data": {
"promo": {
"type": "DEAL",
"deal": {
"id": "9999999",
"name": "광고 배너 — 필터되어야 함",
"price": {
"representativeOriginalPrice": "100000",
"representativeSellingPrice": "50000",
"discountRate": "50",
},
},
}
}
},
},
{
"queryKey": ["today-deal-feed"],
"state": {
"data": {
"todayDealFeed": {
"slots": [
{
"title": "오늘의딜 1",
"type": "DEAL",
"deal": {
"id": "111",
"name": "오늘의딜 상품 A",
"price": {
"representativeOriginalPrice": "10000",
"representativeSellingPrice": "7000",
"discountRate": "30",
},
"brand": {"name": "브랜드 A"},
"badgeProperties": {"isFreeDelivery": True},
"reviewStatistic": {"reviewCount": 10, "reviewAverage": 4.5},
},
},
{
"title": "오늘의딜 GOODS",
"type": "GOODS",
"goods": {"id": "GOODS-1"},
},
]
}
}
},
},
{
"queryKey": ["special-today-deal-feed"],
"state": {
"data": {
"todayDealFeed": {
"slots": [
{
"title": "스페셜 딜",
"type": "DEAL",
"deal": {
"id": "222",
"name": "스페셜 상품 B",
"price": {
"representativeOriginalPrice": "20000",
"representativeSellingPrice": "12000",
"discountRate": "40",
},
},
}
]
}
}
},
},
]
}
}
}
}
class OhouReactQueryShapeTest(unittest.TestCase):
def test_extract_deals_picks_only_today_deal_and_special_feeds(self):
deals = ohou_today_deal.extract_deals(react_query_payload())
ids = sorted(deal.id for deal in deals)
self.assertEqual(ids, ["111", "222"])
def test_navigation_deal_like_node_is_excluded(self):
deal_ids = {deal.id for deal in ohou_today_deal.extract_deals(react_query_payload())}
self.assertNotIn("9999999", deal_ids)
def test_non_deal_slot_types_are_excluded(self):
deal_ids = {deal.id for deal in ohou_today_deal.extract_deals(react_query_payload())}
self.assertNotIn("GOODS-1", deal_ids)
def test_fixture_payload_without_react_query_still_works(self):
deals = ohou_today_deal.extract_deals(sample_payload())
self.assertEqual(sorted(deal.id for deal in deals), ["1215312", "4070154"])
class OhouArgvalidatorTest(unittest.TestCase):
def test_limit_rejects_zero_and_negative(self):
for bad in ["0", "-1", "-100"]:
with self.subTest(value=bad):
with self.assertRaises(SystemExit):
ohou_today_deal.parse_args(["list", "--limit", bad])
def test_min_discount_rejects_out_of_range(self):
for bad in ["-1", "101", "200"]:
with self.subTest(value=bad):
with self.assertRaises(SystemExit):
ohou_today_deal.parse_args(["list", "--min-discount", bad])
def test_min_discount_accepts_boundary_values(self):
for good in ["0", "50", "100"]:
with self.subTest(value=good):
args = ohou_today_deal.parse_args(["list", "--min-discount", good])
self.assertEqual(args.min_discount, int(good))
def test_positive_int_helper_rejects_non_integer(self):
with self.assertRaises(argparse.ArgumentTypeError):
ohou_today_deal._positive_int("abc")
def test_discount_rate_helper_rejects_non_integer(self):
with self.assertRaises(argparse.ArgumentTypeError):
ohou_today_deal._discount_rate("abc")
if __name__ == "__main__":
unittest.main()

View file

@ -54,6 +54,7 @@ done < <(
! -name python-packages \
! -name scripts \
! -name examples \
! -name tools \
-print0
)

160
sh-notice-search/SKILL.md Normal file
View file

@ -0,0 +1,160 @@
---
name: sh-notice-search
description: 서울주택도시개발공사(SH) 공개 공고/공지 게시판에서 청약·주택 공고 목록, 상세 본문, 첨부 미리보기 메타데이터를 직접 조회한다.
license: MIT
metadata:
category: housing
locale: ko-KR
phase: v1
---
# SH Notice Search
## What this skill does
서울주택도시개발공사(SH, `www.i-sh.co.kr`)의 **공고 및 공지** 공개 HTML 게시판을 직접 읽어 청약·주택 공고 목록과 상세 본문, 첨부파일 메타데이터를 JSON으로 정리한다.
- 키워드로 SH 공고/공지 목록을 검색한다.
- 공식 게시판 분류(주택임대, 주택분양, 주택매입/주거복지, 토지, 상가/공장 등)를 선택한다.
- 상세 페이지에서 본문, 담당부서, 등록일, 조회수, 실제 첨부파일명을 추출한다.
- 첨부는 아이콘 템플릿이 아니라 `existFile('N')` onclick이 달린 실제 첨부 앵커와 `downList` 메타데이터를 기준으로 추출한다.
청약 신청, 서류 제출, 로그인 필요한 마이페이지 조회, 결제, 알림 발송은 하지 않는다.
## When to use
- "SH 행복주택 공고 찾아줘"
- "서울주택도시개발공사 매입임대 공고 보여줘"
- "SH 공고 seq 304371 상세와 첨부파일 알려줘"
- "SH 분양 공고 최신 목록 조회"
## Prerequisites
- 인터넷 연결
- Node.js 18+
- 이 저장소의 `sh-notice-search` npm package 또는 동일 로직
## Public access path discovered
### Primary source: official SH public HTML board
- default rent list: `https://www.i-sh.co.kr/app/lay2/program/S1T294C297/www/brd/m_247/list.do?multi_itm_seq=2`
- default rent detail: `https://www.i-sh.co.kr/app/lay2/program/S1T294C297/www/brd/m_247/view.do?multi_itm_seq=2&seq=<seq>`
- title keyword search: add `srchWord=<keyword>&srchTp=0`
- content keyword search: add `srchWord=<keyword>&srchTp=1`
- fixed board page size: 10 rows per page; use `page` for pagination.
Discovery result: direct unauthenticated fetches from `www.i-sh.co.kr` return list/detail HTML. A live smoke on 2026-05-15 showed `srchWord=행복주택` without `srchTp` returned the full rent board count, while `srchTp=0` narrowed the result set. Therefore the client always sends `srchTp` when a keyword is present.
No `k-skill-proxy` route is used because this upstream is public and does not require an API key.
## Supported category aliases
| Input aliases | Official tab |
| --- | --- |
| `rent`, `임대`, `주택임대` | 주택임대 (`multi_itm_seq=2`) |
| `sale`, `분양`, `주택분양` | 주택분양 (`multi_itm_seq=1`) |
| `purchase`, `매입`, `매입임대`, `welfare`, `주거복지` | 주택매입 (`multi_itm_seq=512`) |
| `land`, `토지` | 토지 |
| `commercial`, `상가`, `공장` | 상가/공장 |
| `compensation`, `보상`, `이주` | 보상/이주 |
| `design`, `현상설계` | 현상설계 |
| `etc`, `기타` | 기타 |
| `all`, `전체` | 전체 |
`주거복지`는 SH 공고 및 공지의 공개 탭명이 아니라 사용자 친화 alias이며, 현재는 SH의 공개 `주택매입` 탭으로 매핑한다. 답변할 때는 이 매핑을 밝힌다.
## Workflow
### 1. Search notices
```js
const { searchNotices } = require("sh-notice-search")
const result = await searchNotices({
keyword: "행복주택",
category: "임대",
page: 1,
limit: 5
})
console.log(result.items)
```
CLI:
```bash
node packages/sh-notice-search/src/cli.js 행복주택 --category 임대 --limit 5
node packages/sh-notice-search/src/cli.js 매입임대 --category 주거복지 --status 진행
```
Returned list fields include:
- `seq`
- `title`
- `department`
- `registered_date`
- `views`
- `is_new`
- `category`, `category_name`
- `status`, `status_basis`
- `detail_url`
### 2. Fetch detail
```js
const { getNoticeDetail } = require("sh-notice-search")
const detail = await getNoticeDetail({ seq: "304371", category: "임대" })
console.log(detail.notice.content_text)
console.log(detail.notice.attachments)
```
CLI:
```bash
node packages/sh-notice-search/src/cli.js --seq 304371 --category 임대
```
Attachment fields:
- `filename`
- `file_seq`
- `file_size`
- `file_type`
- `preview_url` (official SH preview/converter URL)
Direct download URLs are intentionally not returned. Hand off `detail_url` or `preview_url` to the user's browser.
### 3. Interpret status conservatively
The SH public board list does not expose a first-class status field like `접수중`/`마감`. The package can filter by `status`, but it is a title-text classifier:
- `open`/`진행`: titles with 모집공고, 입주자 모집, 신청, 접수, 공고
- `closed`/`마감`: titles with 마감, 계약결과, 결과, 완료, 종료
- `announced`/`당첨자`: titles with 당첨, 발표
When answering, disclose that status is inferred from the title unless the detailed 공고문 body states exact dates.
## Done when
- Official SH list/detail URLs were queried directly from the user machine.
- Keyword searches include `srchTp` so `srchWord` is not ignored.
- Pagination uses `page` and recognizes the fixed 10-row board page size.
- Attachments are extracted from actual `existFile()` anchors/downList metadata, not extension icon templates.
- Public source URLs are shown, and login/application automation is avoided.
## Failure modes
- SH can change board paths, table markup, JavaScript functions, or `downList` structure; parsing may become partial or fail.
- IP-rate-limit, NetFunnel throttling, maintenance pages, or temporary 4xx/5xx responses can block live fetches. Do not bypass CAPTCHA/login/queue protections.
- `srchWord` without `srchTp` is known to be ignored by the SH board; always send `srchTp=0` for title or `srchTp=1` for content.
- `pageSize` larger than 10 does not make SH return more rows. Use `page` for additional results.
- Attachment preview URLs may require browser handoff and can be governed by SH's current direct-link/download policy.
- Status is inferred from title text because the public list lacks an explicit status column.
## Notes
- Read-only lookup only.
- No proxy, no API key, no secrets.
- Do not automate 청약 신청, 로그인, 서류 제출, payment, or 마이페이지 flows.

3
tools/k-skill-qa-bot/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
test/pytest/__pycache__/
test/pytest/.pytest_cache/
test/stubs/gh.log

View file

@ -0,0 +1,34 @@
# tools/k-skill-qa-bot — Agent instructions
Source tree for **k-skill-qa-bot**, an automated QA daemon for the k-skill repository.
## What this is
- Source for an **external** macOS daemon installed at `~/.local/share/k-skill-qa-bot/`.
- Every 3 days (launchd LaunchAgent), the daemon:
1. Refreshes a shallow clone of `NomaDamas/k-skill` `main`.
2. Discovers every `<skill>/SKILL.md`, classifies each skill (read-only / location / login / destructive / api-key / proxy-dependent / deprecated).
3. Runs each suitable skill through `codex exec --dangerously-bypass-approvals-and-sandbox` with a smoke-test prompt synthesized from the skill's `## When to use`, while keeping the separate LLM judge on a read-only/no-approval Codex path.
4. An LLM judge (`codex exec --output-schema`) grades pass / fail / skip.
5. Failed skills are filed as dedup'd issues on `NomaDamas/k-skill`. Skipped skills (login required, deprecated, missing API key) never create issues.
## Install path
After running `install.sh`, the runtime lives at `~/.local/share/k-skill-qa-bot/`.
The k-skill repository itself is **never modified** by the bot — it is read-only SSOT. Test prompts are synthesized from each `SKILL.md`.
## Trust-boundary notes
- Smoke tests intentionally run unsandboxed and may contact public skill endpoints, plus git, Codex, GitHub, and k-skill-proxy health-check endpoints.
- A dedicated LaunchAgent is scheduling isolation only; it is not a separate OS user, container, or filesystem sandbox.
- The bot-managed clone is not write-protected from the unsandboxed smoke agent; treat it as mutable bot state rather than a write-protected filesystem boundary.
- The judge uses read-only/no-approval Codex settings, but is still a tool-capable Codex agent over untrusted transcripts and skill Markdown. Do not describe it as a no-tools or file-isolated model call unless the implementation changes to enforce that boundary.
## Design rules
- **SSOT**: All test prompts and skill metadata come from `SKILL.md` files in the bot's own shallow clone of `NomaDamas/k-skill` `main`. The k-skill repo gets no QA-bot-specific edits.
- **First-run safety**: `CREATE_ISSUES=false` is the default. Users must opt in by writing `CREATE_ISSUES=true` to `~/.local/share/k-skill-qa-bot/.env`.
- **Deprecated skills**: Detected by parsing the cloned `README.md` for `~~``~~` strike-through and `⚠️ 지원 중단` markers. Always SKIPPED, never failed.
- **Login / destructive skills**: Force-skipped via `config/skill-overrides.yml`. Never filed as issues.
- **`update-clone.sh` self-destruction guard**: Refuses to operate if `K_SKILL_CLONE` resolves to a directory that does not look like a managed-by-the-bot clone (no `state/clone-head` ancestor, or matches the development tree). Required after a real incident where the script git-reset the very tree it lived in.

View file

@ -0,0 +1,21 @@
.PHONY: help lint test test-bats test-pytest clean
help:
@echo "Targets: lint test test-bats test-pytest clean"
lint:
@shellcheck -e SC1091,SC2016,SC2012 bin/*.sh bin/lib/*.sh install.sh uninstall.sh 2>&1 || echo "(shellcheck warnings above)"
@python3 -m py_compile bin/*.py bin/lib/*.py 2>&1
test-bats:
@command -v bats >/dev/null || { echo "bats-core required (brew install bats-core)"; exit 1; }
@if ls test/bats/*.bats >/dev/null 2>&1; then bats test/bats/; else echo "(no bats tests yet)"; fi
test-pytest:
@command -v pytest >/dev/null || { echo "pytest required (pip install pytest)"; exit 1; }
@if ls test/pytest/test_*.py >/dev/null 2>&1; then pytest test/pytest/ -v; ec=$$?; [ $$ec -eq 5 ] && echo "(no pytest tests collected)" || exit $$ec; else echo "(no pytest tests yet)"; fi
test: test-bats test-pytest
clean:
@rm -rf test/pytest/__pycache__ test/pytest/.pytest_cache

View file

@ -0,0 +1,105 @@
# k-skill-qa-bot
Automated QA daemon for the **k-skill** skill library. Runs every 3 days via macOS launchd, tests every suitable skill via `codex exec --json --dangerously-bypass-approvals-and-sandbox`, has a read-only/no-approval LLM judge grade pass/fail/skip, and files dedup'd GitHub issues for skills that have broken.
## What it does
1. **Refreshes** a shallow clone of `NomaDamas/k-skill` `main` every 3 days.
2. **Discovers** every `<skill>/SKILL.md`.
3. **Classifies** each skill (read-only / location / login / destructive / api-key / proxy-dependent / deprecated).
4. **Runs** each suitable skill through `codex exec --json --dangerously-bypass-approvals-and-sandbox` with a smoke-test prompt synthesized from the skill's `## When to use` bullets. The daemon runs as a dedicated LaunchAgent with non-interactive approvals; avoiding the Codex sandbox prevents false DNS/network failures during skill smoke tests.
5. **Judges** the result via a second read-only/no-approval `codex exec` call using the configured judge model and a strict JSON Schema.
6. **Files** dedup'd issues on `NomaDamas/k-skill` for true failures (with `auto-qa` label). Skipped skills (deprecated, login-required, missing API key) never create issues.
The k-skill repo itself is **never modified** by the bot — it is read-only SSOT. Test prompts are synthesized from each `SKILL.md`.
## Install
Prereqs (one-time):
```bash
brew install bats-core coreutils gh jq python@3
pip3 install pyyaml jsonschema pytest
codex --version # codex-cli >= 0.130
codex login # one-time
gh auth login # one-time, needs `repo` scope
```
Then:
```bash
cd /path/to/k-skill
bash tools/k-skill-qa-bot/install.sh
```
Re-run `install.sh` to upgrade — it is idempotent and preserves `state/`.
## Configure
The default `CREATE_ISSUES=false` means **the first run does NOT file any issues**. After reviewing the first `summary.md`, opt in:
```bash
echo 'CREATE_ISSUES=true' >> ~/.local/share/k-skill-qa-bot/.env
```
Overridable variables (see `config/defaults.sh`):
| Var | Default | Meaning |
|---|---|---|
| `CREATE_ISSUES` | `false` | File GH issues for failures |
| `CODEX_MODEL` | `gpt-5.5` | Model for skill exec |
| `JUDGE_MODEL` | `gpt-5.5` | Model for LLM judge |
| `CODEX_PROVIDER` | `openai` | Codex model provider for skill exec and judge calls |
| `TIMEOUT_SECS` | `180` | Per-skill timeout |
| `JUDGE_TIMEOUT_SECS` | `60` | Per-judge timeout |
| `MAX_PARALLEL` | `4` | Concurrent skill tests |
| `LAST_RUN_MIN_AGE` | `259200` | Min seconds between runs (72h) |
| `GH_REPO` | `NomaDamas/k-skill` | Where to file issues |
`config/skill-overrides.yml` controls per-skill `force_skip` and category overrides. Destructive booking flows (`ktx-booking`, `srt-booking`, `catchtable-sniper`, etc.) and session-required skills (`kakaotalk-mac`, `hipass-receipt`, `toss-securities`, `iros-registry-automation`) are force-skipped by default so the bot never abuses an account.
## Logs and inspection
```bash
tail -f ~/Library/Logs/k-skill-qa-bot/stderr.log
cat ~/.local/share/k-skill-qa-bot/state/runs/$(ls -t ~/.local/share/k-skill-qa-bot/state/runs/ | head -1)/summary.md
```
The bot keeps the most recent 12 runs and purges older ones.
## Force a run
```bash
~/.local/share/k-skill-qa-bot/bin/run-qa.sh --force
~/.local/share/k-skill-qa-bot/bin/run-qa.sh --force --only kbo-results
~/.local/share/k-skill-qa-bot/bin/run-qa.sh --force --dry-run # no issues regardless of CREATE_ISSUES
```
## Uninstall
```bash
bash ~/.local/share/k-skill-qa-bot/uninstall.sh
bash ~/.local/share/k-skill-qa-bot/uninstall.sh --yes --purge --purge-logs
```
## Safety
- Skill smoke tests use `--dangerously-bypass-approvals-and-sandbox` because the Codex sandbox can block legitimate DNS/network lookups for public skill endpoints exercised by smoke tests.
- A dedicated LaunchAgent is scheduling isolation only; it is not a separate OS user, container, or filesystem sandbox.
- The bot-managed clone is not write-protected from the unsandboxed smoke agent; treat it as mutable bot state and judge only against inputs whose provenance is understood.
- The LLM judge stays on the safer `-s read-only` path with `approval_policy="never"`; read-only/no-approval limits writes and approval prompts, but does not make the judge a no-tools or file-isolated model call. Treat transcript and skill Markdown as untrusted input.
- 10 destructive/login-required skills are force-skipped before any codex call is issued.
- Deprecated skills (`~~name~~ ⚠️ 지원 중단` in README) are detected and skipped.
- `update-clone.sh` refuses any `K_SKILL_CLONE` outside `K_QA_HOME/k-skill-clone` unless `ALLOW_EXTERNAL_CLONE_TARGET=1` (prevents the script from git-reset'ing the wrong directory).
- `CREATE_ISSUES=false` first-run default prevents accidental issue spam.
- Local state only: `~/.local/share/k-skill-qa-bot/`. Expected network egress is limited to git fetch, codex API, gh API, k-skill-proxy health checks, and the public skill endpoints exercised by smoke tests.
## Troubleshooting
- `codex: command not found` → check the plist's `EnvironmentVariables.PATH`. Default is `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`.
- `gh: not authenticated` → run `gh auth login` with `repo` scope.
- `gtimeout: command not found``brew install coreutils`.
- LaunchAgent state via `launchctl print "gui/$(id -u)/org.nomadamas.k-skill-qa-bot" | head`.
- Force a re-run: `launchctl kickstart -k "gui/$(id -u)/org.nomadamas.k-skill-qa-bot"`.

View file

@ -0,0 +1,215 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE / "lib"))
import qa_utils # type: ignore # noqa: E402
LOCATION_REQUIRED = {
"blue-ribbon-nearby", "cheap-gas-nearby", "kakao-bar-nearby",
"public-restroom-nearby", "parking-lot-search", "fine-dust-location",
"daangn-cars-search", "daangn-jobs-search", "daangn-realty-search",
"daangn-used-goods-search", "donation-place-search",
"korean-transit-route", "delivery-tracking",
}
LOGIN_REQUIRED = {
"catchtable-sniper", "kakaotalk-mac", "hipass-receipt", "toss-securities",
"iros-registry-automation", "ktx-booking", "srt-booking",
"foresttrip-vacancy",
}
DESTRUCTIVE = {
"ktx-booking", "srt-booking", "express-bus-booking",
"intercity-bus-booking", "catchtable-sniper", "foresttrip-vacancy",
}
API_KEY_ENV_BY_SKILL = {
"k-dart": "API_K_DART",
"korean-patent-search": "KIPRIS_API_KEY",
"korean-transit-route": "ODSAY_API_KEY",
"korean-stock-search": "KRX_API_KEY",
"kosis-stats": "KOSIS_API_KEY",
}
PROXY_DEPENDENT = {
"blue-ribbon-nearby", "cheap-gas-nearby", "daangn-cars-search",
"daangn-jobs-search", "daangn-realty-search", "daangn-used-goods-search",
"daishin-report-search", "donation-place-search", "fine-dust-location",
"gangnamunni-clinic-search", "gongsijiga-search", "han-river-water-level",
"household-waste-info", "k-schoollunch-menu", "kbl-results", "kbo-results",
"kleague-results", "korea-weather", "korean-marathon-schedule",
"korean-stock-search", "korean-transit-route", "kosis-stats",
"lh-notice-search", "library-book-search", "mfds-drug-safety",
"mfds-food-safety", "naver-news-search", "naver-shopping-search",
"nts-business-registration", "seoul-density", "seoul-subway-arrival",
"toss-securities",
}
_API_VAR_RE = re.compile(r"\b(API_[A-Z][A-Z0-9_]+)\b")
def _is_read_only(flags: dict) -> bool:
return not (
flags["login"]
or flags["destructive"]
or flags["api_key"]
or flags["location"]
)
def _read_skill_md(md_path):
if not md_path:
return ""
p = Path(md_path)
if not p.is_file():
return ""
try:
return p.read_text(encoding="utf-8-sig", errors="replace")
except OSError:
return ""
def classify(entry: dict, overrides: dict, deprecated: set) -> dict:
name = entry.get("name") or ""
flags = {
"location": name in LOCATION_REQUIRED,
"login": name in LOGIN_REQUIRED,
"destructive": name in DESTRUCTIVE,
"api_key": name in API_KEY_ENV_BY_SKILL,
"proxy_dependent": name in PROXY_DEPENDENT,
"read_only": False,
}
env_required = []
if name in API_KEY_ENV_BY_SKILL:
env_required.append(API_KEY_ENV_BY_SKILL[name])
md_text = _read_skill_md(entry.get("skill_md_path"))
if "k-skill-proxy" in md_text:
flags["proxy_dependent"] = True
for m in _API_VAR_RE.finditer(md_text):
env_required.append(m.group(1))
flags["api_key"] = True
env_required = sorted(set(env_required))
flags["read_only"] = _is_read_only(flags)
skip_reason = None
override_applied = False
if name in deprecated:
skip_reason = "deprecated in README"
override_applied = True
else:
ov = overrides.get(name) if isinstance(overrides, dict) else None
if isinstance(ov, dict):
if isinstance(ov.get("category_override"), dict):
for k, v in ov["category_override"].items():
if k in flags:
flags[k] = bool(v)
flags["read_only"] = _is_read_only(flags)
override_applied = True
extra_env = ov.get("env_required")
if isinstance(extra_env, list) and extra_env:
env_required = sorted(set(env_required) | {str(e) for e in extra_env})
flags["api_key"] = True
if ov.get("force_skip"):
skip_reason = str(ov.get("reason") or "force_skip override")
override_applied = True
if skip_reason is None and (flags["login"] or flags["destructive"]):
skip_reason = "requires user login or executes destructive actions"
if skip_reason is None and flags["api_key"]:
missing = [v for v in env_required if not os.environ.get(v)]
if missing:
skip_reason = f"missing required env: {', '.join(missing)}"
description = ""
fm = entry.get("frontmatter")
if isinstance(fm, dict):
d = fm.get("description")
if isinstance(d, str):
description = d
when_to_use = entry.get("when_to_use") or []
default_inputs = {}
ov = overrides.get(name) if isinstance(overrides, dict) else None
if isinstance(ov, dict) and isinstance(ov.get("default_inputs"), dict):
default_inputs = ov["default_inputs"]
prompt = qa_utils.synthesize_test_prompt(
name=name,
when_to_use=when_to_use,
description=description,
category_flags=flags,
default_inputs=default_inputs,
)
return {
"name": name,
"category_flags": flags,
"env_required": env_required,
"default_test_prompt": prompt,
"skip_reason": skip_reason,
"override_applied": override_applied,
}
def _default_overrides_path() -> Path:
home = os.environ.get("K_QA_HOME")
if home:
p = Path(home) / "config" / "skill-overrides.yml"
if p.is_file():
return p
return _HERE.parent / "config" / "skill-overrides.yml"
def _default_readme_path() -> Path:
clone = os.environ.get("K_SKILL_CLONE")
if clone:
p = Path(clone) / "README.md"
if p.is_file():
return p
return Path("README.md")
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="Classify a k-skill manifest entry.")
ap.add_argument("--overrides", type=Path, default=None)
ap.add_argument("--readme", type=Path, default=None)
args = ap.parse_args(argv)
overrides_path = args.overrides or _default_overrides_path()
readme_path = args.readme or _default_readme_path()
raw = sys.stdin.read()
try:
entry = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"classify-skill.py: invalid JSON on stdin: {exc}", file=sys.stderr)
return 2
if not isinstance(entry, dict):
print("classify-skill.py: stdin JSON must be an object", file=sys.stderr)
return 2
try:
overrides = qa_utils.load_overrides(overrides_path)
except RuntimeError as exc:
print(f"classify-skill.py: {exc}", file=sys.stderr)
return 2
deprecated = qa_utils.parse_readme_deprecations(readme_path)
result = classify(entry, overrides, deprecated)
json.dump(result, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# shellcheck shell=bash
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=lib/env.sh
. "$HERE/lib/env.sh"
# shellcheck source=lib/log.sh
. "$HERE/lib/log.sh"
CLONE_ROOT="${1:-$K_SKILL_CLONE}"
if [ ! -d "$CLONE_ROOT" ]; then
log_error "clone root not found: $CLONE_ROOT"
exit 2
fi
exec python3 "$HERE/lib/parse_skill_md.py" "$CLONE_ROOT"

View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# shellcheck shell=bash
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=lib/env.sh
. "$HERE/lib/env.sh"
# shellcheck source=lib/log.sh
. "$HERE/lib/log.sh"
has() { command -v "$1" >/dev/null 2>&1; }
codex_ok=false; has codex && codex_ok=true
gh_ok=false; has gh && gh_ok=true
gtimeout_ok=false; has gtimeout && gtimeout_ok=true
git_ok=false; has git && git_ok=true
jq_ok=false; has jq && jq_ok=true
python_ok=false; has python3 && python_ok=true
gh_authed=false
if [ "$gh_ok" = true ]; then
gh auth status >/dev/null 2>&1 && gh_authed=true
fi
proxy_status=0
proxy_ok=false
if has curl; then
proxy_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${PROXY_URL}" || echo 0)
[ "$proxy_status" = "200" ] && proxy_ok=true
fi
github_ok=false
if has curl; then
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://api.github.com 2>/dev/null || echo 0)
if [ "$code" -ge 200 ] 2>/dev/null && [ "$code" -lt 500 ] 2>/dev/null; then
github_ok=true
fi
fi
disk_free_mb=$(df -Pm "$HOME" 2>/dev/null | awk 'NR==2 {print $4}')
[ -z "$disk_free_mb" ] && disk_free_mb=0
printf '{"codex":%s,"gh":%s,"gh_authed":%s,"gtimeout":%s,"git":%s,"jq":%s,"python3":%s,"proxy":{"ok":%s,"status":%s},"github":%s,"disk_free_mb":%s}\n' \
"$codex_ok" "$gh_ok" "$gh_authed" "$gtimeout_ok" "$git_ok" "$jq_ok" "$python_ok" \
"$proxy_ok" "${proxy_status:-0}" "$github_ok" "$disk_free_mb"
if [ "$codex_ok" = true ] && [ "$gh_ok" = true ] && [ "$gtimeout_ok" = true ] && [ "$github_ok" = true ] && [ "$python_ok" = true ] && [ "$git_ok" = true ] && [ "$jq_ok" = true ]; then
exit 0
else
exit 1
fi

View file

@ -0,0 +1,251 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
_HERE = Path(__file__).resolve().parent
_CFG = _HERE.parent / "config"
def _symptom_hash(name: str, symptom_class: str) -> str:
h = hashlib.sha1(f"{name}|{symptom_class}".encode("utf-8")).hexdigest()
return h[:12]
def _read_transcript_tail(path, max_chars: int = 16384, max_events: int = 80) -> str:
if not path or not Path(path).is_file():
return ""
lines = Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
tail = lines[-max_events:]
text = "\n".join(tail)
if len(text) > max_chars:
text = text[-max_chars:]
return text
def _extract_agent_text_from_event(ev) -> str:
if not isinstance(ev, dict):
return ""
if ev.get("type") == "item.completed":
item = ev.get("item") or {}
if isinstance(item, dict) and item.get("type") == "agent_message":
t = item.get("text")
if isinstance(t, str):
return t
if ev.get("type") == "agent_message":
msg = ev.get("message") or {}
for c in (msg.get("content") or []):
if isinstance(c, dict) and c.get("type") == "text":
t = c.get("text")
if isinstance(t, str):
return t
return ""
def _extract_final_assistant_text(jsonl_path) -> str:
if not jsonl_path or not Path(jsonl_path).is_file():
return ""
last = ""
for raw in Path(jsonl_path).read_text(encoding="utf-8", errors="replace").splitlines():
raw = raw.strip()
if not raw:
continue
try:
ev = json.loads(raw)
except json.JSONDecodeError:
continue
t = _extract_agent_text_from_event(ev)
if t:
last = t
return last
def _render_prompt(template: str, **vars) -> str:
out = template
for k, v in vars.items():
out = out.replace("{{" + k + "}}", str(v))
out = out.replace("{{ " + k + " }}", str(v))
return out
def _parse_codex_jsonl_final(stdout: str) -> str:
last = ""
for raw in stdout.splitlines():
raw = raw.strip()
if not raw:
continue
try:
ev = json.loads(raw)
except json.JSONDecodeError:
if raw.startswith("{"):
last = raw
continue
t = _extract_agent_text_from_event(ev)
if t:
last = t
return last
def _call_judge(prompt: str, schema_path, model: str, timeout: int) -> dict:
codex = shutil.which(os.environ.get("CODEX_BIN", "codex"))
gtimeout = shutil.which("gtimeout") or shutil.which("timeout")
if not codex:
return {"verdict": "fail", "reason": "codex CLI not found", "symptom_class": "cli-missing", "confidence": 1.0, "evidence_quote": ""}
provider = os.environ.get("CODEX_PROVIDER", "openai")
cmd = []
if gtimeout:
cmd += [gtimeout, str(timeout)]
cmd += [codex, "exec", "--json", "--ephemeral",
"-s", "read-only",
"--skip-git-repo-check", "-m", model,
"--output-schema", str(schema_path),
"-c", 'approval_policy="never"',
"-c", f'model_provider="{provider}"',
prompt]
try:
r = subprocess.run(cmd, capture_output=True, text=True,
stdin=subprocess.DEVNULL,
timeout=timeout + 30)
except subprocess.TimeoutExpired:
return {"verdict": "unknown", "reason": "judge timed out", "symptom_class": "timeout", "confidence": 0.5, "evidence_quote": ""}
text = _parse_codex_jsonl_final(r.stdout) or r.stdout
try:
return json.loads(text)
except json.JSONDecodeError:
pass
start = text.find("{")
end = text.rfind("}")
if 0 <= start < end:
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError:
pass
return {}
def _deterministic_override(receipt: dict, transcript_text: str, judge: dict, timeout_secs: int) -> dict:
out = dict(judge) if isinstance(judge, dict) else {}
out.setdefault("verdict", "unknown")
out.setdefault("reason", "no judge response")
out.setdefault("symptom_class", "unknown")
out.setdefault("confidence", 0.0)
out.setdefault("evidence_quote", "")
exit_code = receipt.get("exit_code")
duration_ms = receipt.get("duration_ms") or 0
if isinstance(exit_code, int) and exit_code != 0:
if exit_code in (124, 137):
out["verdict"] = "fail"
out["symptom_class"] = "timeout"
out["reason"] = f"codex exited {exit_code} (timeout)"
out["confidence"] = 1.0
elif out["verdict"] != "fail":
out["verdict"] = "fail"
if out.get("symptom_class") in (None, "", "success", "unknown"):
out["symptom_class"] = "wrong-output"
out["reason"] = f"codex exit_code={exit_code}: {out.get('reason','')}"
out["confidence"] = max(out.get("confidence", 0.0) or 0.0, 0.95)
if isinstance(duration_ms, int) and timeout_secs > 0:
if duration_ms >= timeout_secs * 900:
if out["verdict"] == "pass":
out["verdict"] = "fail"
out["symptom_class"] = "timeout"
out["reason"] = f"duration {duration_ms}ms near timeout"
out["confidence"] = max(out["confidence"], 0.8)
return out
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="Judge one k-skill smoke-test transcript")
ap.add_argument("--skill-md", type=Path, required=True)
ap.add_argument("--prompt-template", type=Path, default=_CFG / "judge-prompt.md")
ap.add_argument("--schema", type=Path, default=_CFG / "judge-schema.json")
ap.add_argument("--model", default=os.environ.get("JUDGE_MODEL", "gpt-5.5"))
ap.add_argument("--timeout", type=int, default=int(os.environ.get("JUDGE_TIMEOUT_SECS", "60")))
ap.add_argument("--timeout-secs", type=int, default=int(os.environ.get("TIMEOUT_SECS", "180")))
ap.add_argument("--offline", action="store_true",
help="Skip codex call; use deterministic gates only")
args = ap.parse_args(argv)
raw = sys.stdin.read()
receipt = json.loads(raw)
name = receipt.get("name", "")
if receipt.get("status") == "skip":
out = {
"name": name,
"verdict": "skip",
"reason": receipt.get("reason", "skipped"),
"symptom_class": receipt.get("symptom_class", "skipped"),
"symptom_hash": _symptom_hash(name, receipt.get("symptom_class", "skipped")),
"confidence": 1.0,
"evidence_quote": "",
"judge_model": "n/a",
"judge_duration_ms": 0,
}
json.dump(out, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
return 0
transcript_path = Path(receipt.get("transcript_path") or "")
transcript_tail = _read_transcript_tail(transcript_path)
final_text = _extract_final_assistant_text(transcript_path)
skill_md_text = ""
if args.skill_md and args.skill_md.is_file():
skill_md_text = args.skill_md.read_text(encoding="utf-8-sig", errors="replace")[:8000]
template = args.prompt_template.read_text(encoding="utf-8")
prompt = _render_prompt(
template,
skill_name=name,
skill_md=skill_md_text,
test_prompt=receipt.get("test_prompt", ""),
codex_transcript_tail=transcript_tail,
exit_code=str(receipt.get("exit_code", "")),
duration_ms=str(receipt.get("duration_ms", "")),
)
if args.offline:
judge = {}
if "VERDICT: PASS" in final_text and receipt.get("exit_code") == 0:
judge = {"verdict": "pass", "reason": "offline: VERDICT line found and exit 0",
"symptom_class": "success", "confidence": 0.9,
"evidence_quote": "VERDICT: PASS"}
elif "VERDICT: FAIL" in final_text:
judge = {"verdict": "fail", "reason": "offline: VERDICT: FAIL in transcript",
"symptom_class": "wrong-output", "confidence": 0.9,
"evidence_quote": "VERDICT: FAIL"}
judge_duration_ms = 0
judge_model = "offline"
else:
t0 = time.time()
judge = _call_judge(prompt, args.schema, args.model, args.timeout)
judge_duration_ms = int((time.time() - t0) * 1000)
judge_model = args.model
final = _deterministic_override(receipt, final_text, judge, args.timeout_secs)
final["name"] = name
final["symptom_hash"] = _symptom_hash(name, final.get("symptom_class", "unknown"))
final["judge_model"] = judge_model
final["judge_duration_ms"] = judge_duration_ms
json.dump(final, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,39 @@
# shellcheck shell=sh
: "${K_QA_HOME:=$HOME/.local/share/k-skill-qa-bot}"
: "${K_SKILL_CLONE:=$K_QA_HOME/k-skill-clone}"
: "${STATE_DIR:=$K_QA_HOME/state}"
: "${LOG_DIR:=$HOME/Library/Logs/k-skill-qa-bot}"
: "${CODEX_BIN:=codex}"
: "${CODEX_MODEL:=gpt-5.5}"
: "${JUDGE_MODEL:=gpt-5.5}"
: "${CODEX_PROVIDER:=openai}"
: "${TIMEOUT_SECS:=180}"
: "${JUDGE_TIMEOUT_SECS:=60}"
: "${PROXY_URL:=https://k-skill-proxy.nomadamas.org/health}"
: "${GH_REPO:=NomaDamas/k-skill}"
: "${LAST_RUN_MIN_AGE:=259200}"
: "${MAX_PARALLEL:=4}"
: "${CREATE_ISSUES:=false}"
: "${LOCK_STALE_SECS:=7200}"
: "${K_QA_VERBOSE:=0}"
if [ -f "${K_QA_HOME}/config/defaults.sh" ]; then
. "${K_QA_HOME}/config/defaults.sh"
fi
if [ -f "${K_QA_HOME}/.env" ]; then
set -a
. "${K_QA_HOME}/.env"
set +a
fi
export K_QA_HOME K_SKILL_CLONE STATE_DIR LOG_DIR
export CODEX_BIN CODEX_MODEL JUDGE_MODEL CODEX_PROVIDER TIMEOUT_SECS JUDGE_TIMEOUT_SECS
export PROXY_URL GH_REPO LAST_RUN_MIN_AGE MAX_PARALLEL CREATE_ISSUES
export LOCK_STALE_SECS K_QA_VERBOSE

View file

@ -0,0 +1,56 @@
# shellcheck shell=sh
acquire_lock() {
: "${STATE_DIR:?STATE_DIR must be set (source env.sh first)}"
: "${LOCK_STALE_SECS:=7200}"
_lock_dir="${STATE_DIR}/.lock"
mkdir -p "${STATE_DIR}" 2>/dev/null
if mkdir "${_lock_dir}" 2>/dev/null; then
echo "$$" > "${_lock_dir}/pid"
return 0
fi
_lock_pid=""
[ -f "${_lock_dir}/pid" ] && _lock_pid=$(cat "${_lock_dir}/pid" 2>/dev/null)
if [ -n "${_lock_pid}" ] && kill -0 "${_lock_pid}" 2>/dev/null; then
log_warn "lock held by pid ${_lock_pid}; not acquiring"
return 1
fi
_lock_age=0
if [ -d "${_lock_dir}" ]; then
_lock_mtime=$(stat -f %m "${_lock_dir}" 2>/dev/null || stat -c %Y "${_lock_dir}" 2>/dev/null || echo 0)
_now=$(date +%s)
_lock_age=$(( _now - _lock_mtime ))
fi
if [ "${_lock_age}" -ge "${LOCK_STALE_SECS}" ]; then
log_warn "reclaiming stale lock (age ${_lock_age}s, pid ${_lock_pid:-unknown})"
_lock_tmp="${STATE_DIR}/.lock.reclaim.$$"
if mkdir "${_lock_tmp}" 2>/dev/null; then
echo "$$" > "${_lock_tmp}/pid"
rm -rf "${_lock_dir}"
if mv "${_lock_tmp}" "${_lock_dir}" 2>/dev/null; then
return 0
fi
rm -rf "${_lock_tmp}"
fi
fi
log_warn "lock at ${_lock_dir} held; pid=${_lock_pid:-unknown} age=${_lock_age}s"
return 1
}
release_lock() {
: "${STATE_DIR:?STATE_DIR must be set}"
_lock_dir="${STATE_DIR}/.lock"
if [ -d "${_lock_dir}" ]; then
_held_pid=""
[ -f "${_lock_dir}/pid" ] && _held_pid=$(cat "${_lock_dir}/pid" 2>/dev/null)
if [ -z "${_held_pid}" ] || [ "${_held_pid}" = "$$" ]; then
rm -rf "${_lock_dir}"
fi
fi
}

View file

@ -0,0 +1,46 @@
# shellcheck shell=sh
_k_qa_log_timestamp() {
date -u +%Y-%m-%dT%H:%M:%SZ
}
_k_qa_log_caller() {
_name=$0
if [ -n "${BASH_VERSION-}" ]; then
eval 'if [ -n "${BASH_SOURCE[1]-}" ]; then _name=${BASH_SOURCE[1]##*/}; fi'
fi
printf '%s\n' "${_name##*/}"
}
_k_qa_log_emit() {
_lvl=$1
shift
_caller=$(_k_qa_log_caller)
_ts=$(_k_qa_log_timestamp)
printf '%s %-5s [%s] %s\n' "$_ts" "$_lvl" "$_caller" "$*" >&2
}
log_info() { _k_qa_log_emit INFO "$@"; }
log_warn() { _k_qa_log_emit WARN "$@"; }
log_error() { _k_qa_log_emit ERROR "$@"; }
log_debug() {
if [ "${K_QA_VERBOSE:-0}" != 0 ]; then
_k_qa_log_emit DEBUG "$@"
fi
}
log_capture() {
_out=$1
shift
if [ "${1-}" = "--" ]; then
shift
fi
_err=$(mktemp)
"$@" 2>"$_err" | tee "$_out"
_rc=$?
while IFS= read -r _line || [ -n "$_line" ]; do
log_error "$_line"
done <"$_err"
rm -f "$_err"
return "$_rc"
}

View file

@ -0,0 +1,114 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*(\n|$)", re.S)
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
_BULLET_RE = re.compile(r"^\s*[-*]\s+(.+?)\s*$")
def parse_frontmatter(text: str) -> dict:
m = _FRONTMATTER_RE.match(text)
if not m:
return {}
try:
import yaml
data = yaml.safe_load(m.group(1)) or {}
except ImportError:
return _parse_simple_yaml(m.group(1))
if isinstance(data, dict):
flat = {}
for k, v in data.items():
if isinstance(v, dict):
for sk, sv in v.items():
if sk not in flat:
flat[sk] = sv
else:
flat[k] = v
return flat
return {}
def _parse_simple_yaml(text: str) -> dict:
out = {}
for line in text.splitlines():
if ":" in line and not line.lstrip().startswith("#"):
k, _, v = line.partition(":")
v = v.strip().strip('"').strip("'")
out[k.strip()] = v
return out
def strip_body(text: str) -> str:
m = _FRONTMATTER_RE.match(text)
if m:
return text[m.end():]
return text
def extract_section_bullets(body: str, heading_keywords: list) -> list:
lines = body.splitlines()
in_section = False
section_depth = 0
bullets = []
for ln in lines:
h = _HEADING_RE.match(ln)
if h:
depth = len(h.group(1))
title = h.group(2).lower()
if any(kw.lower() in title for kw in heading_keywords):
in_section = True
section_depth = depth
continue
if in_section and depth <= section_depth:
break
elif in_section:
b = _BULLET_RE.match(ln)
if b:
bullets.append(b.group(1).strip())
return bullets
def discover(clone_root: Path) -> list:
entries = []
for child in sorted(clone_root.iterdir()):
if not child.is_dir() or child.name.startswith("."):
continue
skill_md = child / "SKILL.md"
if not skill_md.is_file():
continue
text = skill_md.read_text(encoding="utf-8-sig", errors="replace")
fm = parse_frontmatter(text)
body = strip_body(text)
entry = {
"name": fm.get("name") or child.name,
"dir": str(child),
"skill_md_path": str(skill_md),
"frontmatter": fm,
"when_to_use": extract_section_bullets(body, ["When to use"]),
"done_when": extract_section_bullets(body, ["Done when"]),
"failure_modes": extract_section_bullets(body, ["Failure modes"]),
"prerequisites": extract_section_bullets(body, ["Prerequisites", "Credential requirements"]),
}
entries.append(entry)
return entries
def main():
if len(sys.argv) < 2:
print("usage: parse_skill_md.py <clone-root>", file=sys.stderr)
sys.exit(2)
root = Path(sys.argv[1])
if not root.is_dir():
print(f"not a directory: {root}", file=sys.stderr)
sys.exit(2)
json.dump(discover(root), sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,99 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Iterable
DEFAULT_TEST_LOCATION = "서울역 (37.5665,126.9780)"
VERDICT_INSTRUCTION = (
"After your answer, end with a single line that is exactly one of: "
"VERDICT: PASS or VERDICT: FAIL."
)
_STRIKE_RE = re.compile(r"~~\s*`?([A-Za-z0-9][A-Za-z0-9_.\-]*)`?\s*~~")
_DEPRECATION_MARK_RE = re.compile(r"지원\s*중단")
def load_overrides(path):
p = Path(path)
if not p.is_file():
return {}
try:
import yaml
except ImportError as exc:
raise RuntimeError(
"PyYAML is required to load skill-overrides.yml — `pip install pyyaml`"
) from exc
data = yaml.safe_load(p.read_text(encoding="utf-8"))
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError(
f"skill-overrides.yml must be a YAML mapping at top level, got {type(data).__name__}"
)
return {k: v for k, v in data.items() if isinstance(v, dict)}
def parse_readme_deprecations(readme_path):
p = Path(readme_path)
if not p.is_file():
return set()
try:
text = p.read_text(encoding="utf-8")
except OSError:
return set()
deprecated = set()
for line in text.splitlines():
if not _DEPRECATION_MARK_RE.search(line):
continue
for match in _STRIKE_RE.finditer(line):
name = match.group(1).strip()
if name:
deprecated.add(name)
return deprecated
def _first_non_empty(values: Iterable[str]):
for v in values:
if isinstance(v, str) and v.strip():
return v.strip()
return None
def synthesize_test_prompt(name, when_to_use, description, category_flags, default_inputs):
flags = category_flags or {}
inputs = default_inputs or {}
override_prompt = inputs.get("test_prompt") if isinstance(inputs, dict) else None
if isinstance(override_prompt, str) and override_prompt.strip():
body = override_prompt.strip()
if VERDICT_INSTRUCTION in body or "VERDICT: PASS" in body:
return body
return f"{body} Use the `{name}` skill to answer this. {VERDICT_INSTRUCTION}"
query = (
_first_non_empty(when_to_use or [])
or (description.strip() if isinstance(description, str) and description.strip() else None)
or f"Demonstrate the {name} skill."
)
parts = []
if flags.get("location"):
loc = inputs.get("location") or DEFAULT_TEST_LOCATION
parts.append(f"내 현재 위치는 {loc} 이야.")
parts.append(query)
parts.append(f"Use the `{name}` skill to answer this. {VERDICT_INSTRUCTION}")
return " ".join(parts)
__all__ = [
"DEFAULT_TEST_LOCATION",
"VERDICT_INSTRUCTION",
"load_overrides",
"parse_readme_deprecations",
"synthesize_test_prompt",
]

View file

@ -0,0 +1,121 @@
#!/usr/bin/env bash
# shellcheck shell=bash
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=lib/env.sh
. "$HERE/lib/env.sh"
# shellcheck source=lib/log.sh
. "$HERE/lib/log.sh"
RUN_DIR=""
DRY_RUN=false
while [ $# -gt 0 ]; do
case "$1" in
--run-dir) RUN_DIR="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
*) echo "report-failures.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
[ -n "$RUN_DIR" ] || { echo "report-failures.sh: --run-dir <path> required" >&2; exit 2; }
if [ "$CREATE_ISSUES" != "true" ]; then
DRY_RUN=true
fi
SUMMARY="$RUN_DIR/summary.md"
mkdir -p "$STATE_DIR"
KNOWN="$STATE_DIR/known-failures.json"
[ -f "$KNOWN" ] || echo '{}' > "$KNOWN"
pass=0; fail=0; skip=0
fail_lines=""
skip_lines=""
shopt -s nullglob
for judge in "$RUN_DIR"/results/*.judge.json; do
name=$(jq -r .name "$judge")
verdict=$(jq -r .verdict "$judge")
sclass=$(jq -r .symptom_class "$judge")
reason=$(jq -r .reason "$judge")
case "$verdict" in
pass) pass=$((pass+1)) ;;
skip)
skip=$((skip+1))
skip_lines="${skip_lines}- ${name}: ${reason}"$'\n'
;;
fail|unknown)
fail=$((fail+1))
fail_lines="${fail_lines}- ${name} (${sclass}): ${reason}"$'\n'
;;
esac
done
{
echo "# k-skill-qa-bot run summary"
echo
echo "- run dir: \`$RUN_DIR\`"
echo "- pass: $pass"
echo "- fail: $fail"
echo "- skip: $skip"
echo "- create_issues: $CREATE_ISSUES (dry_run=$DRY_RUN)"
if [ -n "$fail_lines" ]; then
echo
echo "## Failures"
echo
printf '%s' "$fail_lines"
fi
if [ -n "$skip_lines" ]; then
echo
echo "## Skipped"
echo
printf '%s' "$skip_lines"
fi
} > "$SUMMARY"
if [ "$DRY_RUN" = "true" ]; then
log_info "dry-run: would file $fail issue(s)"
echo "$SUMMARY"
exit 0
fi
now_iso=$(date -u +%Y-%m-%dT%H:%M:%SZ)
for judge in "$RUN_DIR"/results/*.judge.json; do
verdict=$(jq -r .verdict "$judge")
sclass=$(jq -r .symptom_class "$judge")
[ "$verdict" != "fail" ] && [ "$verdict" != "unknown" ] && continue
name=$(jq -r .name "$judge")
hash=$(jq -r .symptom_hash "$judge")
reason=$(jq -r .reason "$judge")
evidence=$(jq -r .evidence_quote "$judge")
confidence=$(jq -r .confidence "$judge")
if [ "$verdict" = "unknown" ]; then
case "$confidence" in
0|0.0|0.0*|0.1*|0.2*|0.3*|0.4*|0.5*) continue ;;
esac
fi
existing=$(gh issue list --repo "$GH_REPO" --state open \
--label auto-qa --label "skill:${name}" \
--json number,body --limit 50 2>/dev/null \
| jq -r --arg h "$hash" '.[] | select(.body | contains("symptom_hash:" + $h)) | .number' \
| head -1)
body=$(printf '<!-- symptom_hash:%s -->\n\n**Last run:** %s\n**Verdict:** %s\n**Symptom:** %s\n**Reason:** %s\n**Confidence:** %s\n\n<details><summary>Evidence</summary>\n\n```\n%s\n```\n\n</details>\n\nSee SKILL.md: https://github.com/%s/blob/main/%s/SKILL.md\n' \
"$hash" "$now_iso" "$verdict" "$sclass" "$reason" "$confidence" "$evidence" "$GH_REPO" "$name")
if [ -n "$existing" ]; then
log_info "comment on issue #${existing} for ${name}"
echo "$body" | gh issue comment "$existing" --repo "$GH_REPO" --body-file -
else
title="[auto-qa] ${name} broken: ${sclass}"
log_info "create new issue for ${name}"
echo "$body" | gh issue create --repo "$GH_REPO" --title "$title" \
--label auto-qa --label "skill:${name}" --label "severity:fail" --body-file -
fi
done
echo "$SUMMARY"

View file

@ -0,0 +1,202 @@
#!/usr/bin/env bash
# shellcheck shell=bash
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=lib/env.sh
. "$HERE/lib/env.sh"
# shellcheck source=lib/log.sh
. "$HERE/lib/log.sh"
# shellcheck source=lib/lock.sh
. "$HERE/lib/lock.sh"
FORCE=false
DRY_RUN=false
ONLY=""
OFFLINE_JUDGE=false
while [ $# -gt 0 ]; do
case "$1" in
--force) FORCE=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--only) ONLY="${ONLY}${ONLY:+,}$2"; shift 2 ;;
--offline-judge) OFFLINE_JUDGE=true; shift ;;
*) echo "run-qa.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
mkdir -p "$STATE_DIR" "$LOG_DIR"
if ! acquire_lock; then
log_warn "another run-qa is in progress; exiting"
exit 0
fi
trap 'release_lock' EXIT INT TERM
LAST_RUN_FILE="$STATE_DIR/last_run"
if [ "$FORCE" = false ] && [ -f "$LAST_RUN_FILE" ]; then
last=$(head -1 "$LAST_RUN_FILE")
last_ts=$(echo "$last" | awk '{print $1}')
now=$(date +%s)
age=$((now - last_ts))
if [ "$age" -lt "$LAST_RUN_MIN_AGE" ]; then
log_info "too recent: last run ${age}s ago < ${LAST_RUN_MIN_AGE}s (use --force to override)"
exit 0
fi
fi
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_DIR="$STATE_DIR/runs/$RUN_ID"
mkdir -p "$RUN_DIR/results"
log_info "starting run ${RUN_ID} -> ${RUN_DIR}"
set +e
HEALTH_JSON=$("$HERE/health-check.sh")
HEALTH_RC=$?
set -e
echo "$HEALTH_JSON" > "$RUN_DIR/health.json"
if [ "$HEALTH_RC" -ne 0 ]; then
log_error "health-check failed; aborting"
cat "$RUN_DIR/health.json" >&2
exit 1
fi
if [ "${SKIP_CLONE:-false}" = true ]; then
log_info "SKIP_CLONE=true; using existing K_SKILL_CLONE=${K_SKILL_CLONE}"
elif [ ! -d "$K_SKILL_CLONE/.git" ]; then
if find "$K_SKILL_CLONE" -maxdepth 2 -name SKILL.md -print -quit 2>/dev/null | grep -q .; then
log_info "K_SKILL_CLONE=${K_SKILL_CLONE} has SKILL.md files but no .git; treating as offline fixture"
else
"$HERE/update-clone.sh"
fi
elif [ "$FORCE" = true ] || [ ! -f "$STATE_DIR/clone-head" ]; then
"$HERE/update-clone.sh"
fi
MANIFEST="$RUN_DIR/manifest.json"
"$HERE/discover-skills.sh" "$K_SKILL_CLONE" > "$MANIFEST"
total=$(jq 'length' "$MANIFEST")
log_info "discovered ${total} skills"
if [ -z "$ONLY" ]; then
entries_jsonl=$(jq -c '.[]' "$MANIFEST")
else
entries_jsonl=$(jq -c --arg only "$ONLY" '.[] | select(.name as $n | ($only | split(",") | index($n)))' "$MANIFEST")
fi
OVERRIDES_PATH="${OVERRIDES_PATH:-$HERE/../config/skill-overrides.yml}"
README_PATH="${README_PATH:-$K_SKILL_CLONE/README.md}"
process_one() {
entry="$1"
name=$(echo "$entry" | jq -r .name)
classification=$(echo "$entry" | "$HERE/classify-skill.py" \
--overrides "$OVERRIDES_PATH" \
--readme "$README_PATH" 2>/dev/null || true)
if [ -z "$classification" ]; then
log_error "classify failed for ${name}"
return
fi
echo "$classification" > "$RUN_DIR/results/${name}.classify.json"
echo "$classification" | "$HERE/test-skill.sh" --run-dir "$RUN_DIR" >/dev/null 2>&1 || true
if [ ! -f "$RUN_DIR/results/${name}.exec.json" ]; then
log_error "test-skill receipt missing for ${name}"
return
fi
judge_args=(--skill-md "$K_SKILL_CLONE/${name}/SKILL.md")
if [ "$OFFLINE_JUDGE" = true ]; then
judge_args+=(--offline)
fi
judge_out=$(cat "$RUN_DIR/results/${name}.exec.json" | "$HERE/judge-skill.py" "${judge_args[@]}" 2>/dev/null || true)
if [ -z "$judge_out" ]; then
log_error "judge failed for ${name}"
return
fi
echo "$judge_out" > "$RUN_DIR/results/${name}.judge.json"
}
export HERE RUN_DIR K_SKILL_CLONE OVERRIDES_PATH README_PATH OFFLINE_JUDGE
if [ -z "$entries_jsonl" ]; then
log_warn "no entries to process (filter=${ONLY})"
else
JOB_DIR=$(mktemp -d)
i=0
while IFS= read -r entry; do
[ -z "$entry" ] && continue
printf '%s' "$entry" > "$JOB_DIR/$(printf '%04d' "$i").json"
i=$((i+1))
done <<< "$entries_jsonl"
export HERE RUN_DIR K_SKILL_CLONE OVERRIDES_PATH README_PATH OFFLINE_JUDGE
WORKER="$JOB_DIR/worker.sh"
cat > "$WORKER" <<WORKER_EOF
#!/usr/bin/env bash
set -u
HERE="\$HERE"
. "\$HERE/lib/env.sh"
. "\$HERE/lib/log.sh"
entry_file="\$1"
entry="\$(cat "\$entry_file")"
name=\$(echo "\$entry" | jq -r .name)
classification=\$(echo "\$entry" | "\$HERE/classify-skill.py" \\
--overrides "\$OVERRIDES_PATH" \\
--readme "\$README_PATH" 2>/dev/null || true)
if [ -z "\$classification" ]; then
log_error "classify failed for \${name}"
exit 0
fi
echo "\$classification" > "\$RUN_DIR/results/\${name}.classify.json"
echo "\$classification" | "\$HERE/test-skill.sh" --run-dir "\$RUN_DIR" >/dev/null 2>&1 || true
if [ ! -f "\$RUN_DIR/results/\${name}.exec.json" ]; then
log_error "test-skill receipt missing for \${name}"
exit 0
fi
judge_args=(--skill-md "\$K_SKILL_CLONE/\${name}/SKILL.md")
if [ "\$OFFLINE_JUDGE" = true ]; then
judge_args+=(--offline)
fi
judge_out=\$(cat "\$RUN_DIR/results/\${name}.exec.json" | "\$HERE/judge-skill.py" "\${judge_args[@]}" 2>/dev/null || true)
if [ -z "\$judge_out" ]; then
log_error "judge failed for \${name}"
exit 0
fi
echo "\$judge_out" > "\$RUN_DIR/results/\${name}.judge.json"
WORKER_EOF
chmod +x "$WORKER"
WORKER_LOG="$RUN_DIR/worker-debug.log"
: > "$WORKER_LOG"
if [ "${MAX_PARALLEL:-1}" -le 1 ]; then
for f in "$JOB_DIR"/*.json; do
[ -f "$f" ] || continue
"$WORKER" "$f" >>"$WORKER_LOG" 2>&1
done
else
find "$JOB_DIR" -name '0*.json' -print0 \
| xargs -0 -P "$MAX_PARALLEL" -n 1 -I{} bash -c '"$0" "$1" >>"$2" 2>&1' "$WORKER" {} "$WORKER_LOG"
fi
rm -rf "$JOB_DIR"
fi
REPORT_ARGS=(--run-dir "$RUN_DIR")
[ "$DRY_RUN" = true ] && REPORT_ARGS+=(--dry-run)
"$HERE/report-failures.sh" "${REPORT_ARGS[@]}"
echo "$(date +%s) $RUN_ID" > "$LAST_RUN_FILE"
if [ -d "$STATE_DIR/runs" ]; then
find "$STATE_DIR/runs" -mindepth 1 -maxdepth 1 -type d -print0 \
| xargs -0 -I{} stat -f '%m %N' {} 2>/dev/null \
| sort -rn | tail -n +13 | awk '{print $2}' \
| xargs -I{} rm -rf {} 2>/dev/null || true
fi
log_info "run ${RUN_ID} complete; summary: $RUN_DIR/summary.md"
echo "$RUN_DIR/summary.md"

View file

@ -0,0 +1,85 @@
#!/usr/bin/env bash
# shellcheck shell=bash
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=lib/env.sh
. "$HERE/lib/env.sh"
# shellcheck source=lib/log.sh
. "$HERE/lib/log.sh"
RUN_DIR=""
while [ $# -gt 0 ]; do
case "$1" in
--run-dir) RUN_DIR="$2"; shift 2 ;;
*) echo "test-skill.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
[ -n "$RUN_DIR" ] || { echo "test-skill.sh: --run-dir <path> required" >&2; exit 2; }
mkdir -p "$RUN_DIR/results"
CLASSIFICATION="$(cat)"
NAME="$(echo "$CLASSIFICATION" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("name",""))')"
SKIP_REASON="$(echo "$CLASSIFICATION" | python3 -c 'import json,sys; v=json.load(sys.stdin).get("skip_reason"); print(v if v is not None else "")')"
PROMPT="$(echo "$CLASSIFICATION" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("default_test_prompt",""))')"
RECEIPT="$RUN_DIR/results/${NAME}.exec.json"
emit_receipt() {
python3 - "$RECEIPT" <<'PY' "$@"
import json, sys
out = sys.argv[1]
data = dict(zip(sys.argv[2::2], sys.argv[3::2]))
for k in ("exit_code","duration_ms"):
if k in data:
try: data[k] = int(data[k])
except ValueError: pass
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
f.write("\n")
PY
}
if [ -n "$SKIP_REASON" ]; then
emit_receipt name "$NAME" status skip reason "$SKIP_REASON" symptom_class skipped
log_info "skip ${NAME}: ${SKIP_REASON}"
exit 0
fi
JSONL="$RUN_DIR/results/${NAME}.codex.jsonl"
STDERR="$RUN_DIR/results/${NAME}.codex.stderr.log"
TIMEOUT_BIN="$(command -v gtimeout || command -v timeout || echo "")"
if [ -z "$TIMEOUT_BIN" ]; then
log_error "gtimeout/timeout not found; install GNU coreutils (brew install coreutils)"
emit_receipt name "$NAME" status fail exit_code 127 reason "gtimeout missing" symptom_class cli-missing
exit 0
fi
START_MS=$(python3 -c 'import time;print(int(time.time()*1000))')
set +e
"$TIMEOUT_BIN" --kill-after=15 "$TIMEOUT_SECS" \
"$CODEX_BIN" exec --json --dangerously-bypass-approvals-and-sandbox \
--skip-git-repo-check --ephemeral \
-C "$K_SKILL_CLONE" -m "$CODEX_MODEL" \
-c "model_provider=\"${CODEX_PROVIDER:-openai}\"" \
"$PROMPT" \
</dev/null >"$JSONL" 2>"$STDERR"
EXIT_CODE=$?
set -e
END_MS=$(python3 -c 'import time;print(int(time.time()*1000))')
DURATION_MS=$((END_MS - START_MS))
emit_receipt \
name "$NAME" \
status executed \
exit_code "$EXIT_CODE" \
duration_ms "$DURATION_MS" \
transcript_path "$JSONL" \
stderr_path "$STDERR" \
test_prompt "$PROMPT" \
codex_model "$CODEX_MODEL"
log_info "executed ${NAME}: exit=${EXIT_CODE} duration=${DURATION_MS}ms"

View file

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# shellcheck shell=bash
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=lib/env.sh
. "$HERE/lib/env.sh"
# shellcheck source=lib/log.sh
. "$HERE/lib/log.sh"
REMOTE_URL="${REMOTE_URL:-https://github.com/NomaDamas/k-skill.git}"
assert_safe_clone_target() {
_target="$1"
if [ -d "$_target" ]; then
_abs_target="$(cd "$_target" && pwd)"
else
_abs_target="$(cd "$(dirname "$_target")" 2>/dev/null && pwd)/$(basename "$_target")"
fi
_expected="${K_QA_HOME}/k-skill-clone"
if [ "$_abs_target" != "$_expected" ] && [ "${ALLOW_EXTERNAL_CLONE_TARGET:-0}" != 1 ]; then
log_error "REFUSING: K_SKILL_CLONE=${_abs_target} differs from expected ${_expected}."
log_error " Set ALLOW_EXTERNAL_CLONE_TARGET=1 only if you understand the destructive consequences."
log_error " This script will git-reset --hard + clean -fdx the target directory."
exit 91
fi
case "$_abs_target" in
""|"/"|"$HOME"|"/Users"|"/Users/")
log_error "REFUSING: K_SKILL_CLONE resolves to dangerous root path: ${_abs_target}"
exit 90
;;
esac
if [ -d "$_abs_target/.git" ]; then
_origin=$(git -C "$_abs_target" config --get remote.origin.url 2>/dev/null || echo "")
case "$_origin" in
*NomaDamas/k-skill*|*nomadamas/k-skill*) ;;
"")
log_warn "no remote.origin.url at $_abs_target; treating as fresh clone target"
;;
*)
log_error "REFUSING: existing repo at $_abs_target has remote ${_origin}; not NomaDamas/k-skill"
exit 92
;;
esac
fi
}
assert_safe_clone_target "$K_SKILL_CLONE"
mkdir -p "$STATE_DIR"
if [ ! -d "$K_SKILL_CLONE/.git" ]; then
log_info "cloning ${REMOTE_URL} (shallow, branch main) -> ${K_SKILL_CLONE}"
rm -rf "$K_SKILL_CLONE"
git clone --depth=1 --branch main "$REMOTE_URL" "$K_SKILL_CLONE" >&2
else
log_info "updating clone at ${K_SKILL_CLONE}"
git -C "$K_SKILL_CLONE" fetch --depth=1 origin main >&2
git -C "$K_SKILL_CLONE" reset --hard origin/main >&2
git -C "$K_SKILL_CLONE" clean -fdx >&2
fi
HEAD_SHA="$(git -C "$K_SKILL_CLONE" rev-parse HEAD)"
echo "$HEAD_SHA" > "$STATE_DIR/clone-head"
log_info "clone HEAD=${HEAD_SHA}"
SKILLS_DIR="$K_SKILL_CLONE/.agents/skills"
rm -rf "$SKILLS_DIR"
mkdir -p "$SKILLS_DIR"
for d in "$K_SKILL_CLONE"/*/; do
base="$(basename "$d")"
[ -f "$d/SKILL.md" ] || continue
ln -s "../../$base" "$SKILLS_DIR/$base"
done
log_info "symlinks ready in ${SKILLS_DIR}"

View file

@ -0,0 +1,30 @@
# shellcheck shell=sh
: "${K_QA_HOME:=$HOME/.local/share/k-skill-qa-bot}"
: "${K_SKILL_CLONE:=$K_QA_HOME/k-skill-clone}"
: "${STATE_DIR:=$K_QA_HOME/state}"
: "${LOG_DIR:=$HOME/Library/Logs/k-skill-qa-bot}"
: "${CODEX_BIN:=codex}"
: "${CODEX_MODEL:=gpt-5.5}"
: "${JUDGE_MODEL:=gpt-5.5}"
: "${CODEX_PROVIDER:=openai}"
: "${TIMEOUT_SECS:=180}"
: "${JUDGE_TIMEOUT_SECS:=60}"
: "${PROXY_URL:=https://k-skill-proxy.nomadamas.org/health}"
: "${GH_REPO:=NomaDamas/k-skill}"
: "${LAST_RUN_MIN_AGE:=259200}"
: "${MAX_PARALLEL:=4}"
: "${CREATE_ISSUES:=false}"
: "${LOCK_STALE_SECS:=7200}"
: "${K_QA_VERBOSE:=0}"
export K_QA_HOME K_SKILL_CLONE STATE_DIR LOG_DIR
export CODEX_BIN CODEX_MODEL JUDGE_MODEL CODEX_PROVIDER TIMEOUT_SECS JUDGE_TIMEOUT_SECS
export PROXY_URL GH_REPO LAST_RUN_MIN_AGE MAX_PARALLEL CREATE_ISSUES
export LOCK_STALE_SECS K_QA_VERBOSE

View file

@ -0,0 +1,44 @@
You are a strict QA judge for a skill in the **k-skill** library.
Decide whether the skill **{{skill_name}}** actually accomplished its stated purpose during the smoke test below. Output ONLY a JSON object matching the supplied JSON schema. No prose, no markdown.
## The skill being judged
````markdown
{{skill_md}}
````
## The test prompt that was sent to the agent
````
{{test_prompt}}
````
## Agent execution results
- Exit code: `{{exit_code}}`
- Duration: `{{duration_ms}} ms`
- Tail of agent transcript (last events from `codex exec --json`):
````
{{codex_transcript_tail}}
````
## Rubric
verdict ∈ {pass, fail, skip}:
- `pass` — agent accomplished the skill's stated goal (per `## Done when` and `## What this skill does`).
- **The agent's literal `VERDICT: PASS` / `VERDICT: FAIL` self-report is just a hint, NOT a binding decision.** Override it when the transcript shows the skill clearly worked.
- A "negative-case" outcome counts as PASS if the skill behaved correctly for that input. Examples:
- Skill returns "사업자등록번호 미등록" for a fake business number → that's the skill working correctly → **pass**.
- Skill returns "invoice not found" for a non-existent tracking number → correct behavior → **pass**.
- Skill correctly refuses a query that violates its safety policy → **pass**.
- Look at the SKILL.md `## Done when` / `## What this skill does` and ask: "did the skill perform the work it claims, given this specific input?"
- `fail` — skill genuinely did NOT accomplish its job (broken CLI, broken upstream API after retry, wrong/empty output that should have been correct, network error to a public endpoint that should be reachable, agent gave up without trying).
- `skip` — agent legitimately declined because of a prerequisite the bot couldn't satisfy (missing API key, login required, destructive action declined, mandatory user input absent that the test prompt did not provide).
symptom_class ∈ {success, auth-failure, network-error, cli-missing, wrong-output, timeout, partial-success, unknown}.
Provide reason (≤500 chars), confidence (0..1), evidence_quote (≤300 chars, verbatim transcript snippet, empty if none).
OUTPUT ONLY THE JSON OBJECT MATCHING THE SCHEMA.

View file

@ -0,0 +1,40 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "KSkillQAJudgeResponse",
"type": "object",
"additionalProperties": false,
"required": ["verdict", "symptom_class", "reason", "confidence", "evidence_quote"],
"properties": {
"verdict": {
"type": "string",
"enum": ["pass", "fail", "skip"]
},
"symptom_class": {
"type": "string",
"enum": [
"auth-failure",
"network-error",
"cli-missing",
"wrong-output",
"timeout",
"success",
"partial-success",
"unknown"
]
},
"reason": {
"type": "string",
"maxLength": 500,
"minLength": 1
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"evidence_quote": {
"type": "string",
"maxLength": 300
}
}
}

View file

@ -0,0 +1,78 @@
ktx-booking:
force_skip: true
reason: "destructive booking flow (requires Korail login + reservation)"
srt-booking:
force_skip: true
reason: "destructive booking flow (requires SRT login + reservation)"
express-bus-booking:
force_skip: true
reason: "destructive booking flow (Tmoney reservation)"
intercity-bus-booking:
force_skip: true
reason: "destructive booking flow (Tmoney reservation)"
foresttrip-vacancy:
force_skip: true
reason: "destructive booking flow (foresttrip.go.kr reservation)"
catchtable-sniper:
force_skip: true
reason: "destructive reservation flow (requires logged-in Chrome session)"
kakaotalk-mac:
force_skip: true
reason: "requires logged-in KakaoTalk macOS app session"
hipass-receipt:
force_skip: true
reason: "requires logged-in HiPass session in Chrome"
toss-securities:
force_skip: true
reason: "requires logged-in Toss Securities session"
iros-registry-automation:
force_skip: true
reason: "requires IROS login + manual payment"
bunjang-search:
test_path: "search-only"
flight-ticket-search:
default_inputs:
test_prompt: "인천공항(ICN)에서 나리타공항(NRT)으로 2026-08-20 출발 편도 항공권 조회해줘."
nts-business-registration:
default_inputs:
test_prompt: "사업자등록번호 124-81-00998 (삼성전자) 상태조회해줘 - 계속사업자인지 확인하고 결과를 정리해."
korean-stock-search:
default_inputs:
test_prompt: "삼성전자(종목코드 005930) 기본정보와 최근 일별 시세 5일치 보여줘."
joseon-sillok-search:
default_inputs:
test_prompt: "조선왕조실록에서 '훈민정음' 키워드로 검색해서 관련 기사 3개 정리해줘."
korean-law-search:
default_inputs:
test_prompt: "산업안전보건법 제5조 조문 내용 찾아서 보여줘."
library-book-search:
default_inputs:
test_prompt: "도서관 정보나루에서 '코스모스 칼 세이건' 책 검색해서 정보 보여줘."
lotto-results:
default_inputs:
test_prompt: "최신 회차(latest) 로또 당첨번호와 등수별 당첨금 정리해줘."
k-schoollunch-menu:
default_inputs:
test_prompt: "서울특별시교육청 소속 학교 중 아무 초등학교 한 곳 골라서 오늘 급식 식단 알려줘."
delivery-tracking:
default_inputs:
test_prompt: "CJ대한통운(cj) 송장번호 595300312345 (가공된 더미 번호) 배송 조회. 송장이 존재하지 않으면 그 사실을 정확히 응답해."
ticket-availability:
default_inputs:
test_prompt: "YES24 콘서트 ID 58026 또는 인터파크 공연 아무거나 하나 잔여석 조회 - 공연 URL이나 platform:id가 명확하지 않으면 현재 진행 중인 아무 공연 하나로 시연해줘."
zipcode-search:
default_inputs:
test_prompt: "주소 '서울특별시 강남구 테헤란로 152' 우편번호와 공식 영문주소 찾아줘."

77
tools/k-skill-qa-bot/install.sh Executable file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# shellcheck shell=bash
set -eu
SRC="$(cd "$(dirname "$0")" && pwd)"
DEST="$HOME/.local/share/k-skill-qa-bot"
LOG_DIR="$HOME/Library/Logs/k-skill-qa-bot"
LAUNCH_AGENTS="$HOME/Library/LaunchAgents"
PLIST_NAME="org.nomadamas.k-skill-qa-bot.plist"
SKIP_LAUNCHD=false
SKIP_CLONE=false
RUN_NOW=false
while [ $# -gt 0 ]; do
case "$1" in
--skip-launchd) SKIP_LAUNCHD=true; shift ;;
--skip-clone) SKIP_CLONE=true; shift ;;
--run-now) RUN_NOW=true; shift ;;
*) echo "install.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
echo "==> Installing k-skill-qa-bot to $DEST"
mkdir -p "$DEST" "$LOG_DIR" "$DEST/state/runs"
rsync -a --delete \
--exclude 'test/' \
--exclude '.git/' \
--exclude '__pycache__/' \
--exclude '.pytest_cache/' \
--exclude 'AGENTS.md' \
--exclude '.sisyphus/' \
"$SRC/" "$DEST/"
chmod +x "$DEST/bin/"*.sh "$DEST/bin/"*.py 2>/dev/null || true
if [ "$SKIP_CLONE" != true ]; then
echo "==> Cloning NomaDamas/k-skill (shallow)"
K_QA_HOME="$DEST" "$DEST/bin/update-clone.sh"
fi
if [ "$SKIP_LAUNCHD" != true ]; then
echo "==> Installing LaunchAgent"
mkdir -p "$LAUNCH_AGENTS"
sed "s|__HOME__|$HOME|g" "$DEST/launchd/$PLIST_NAME" > "$LAUNCH_AGENTS/$PLIST_NAME"
launchctl bootout "gui/$(id -u)/org.nomadamas.k-skill-qa-bot" 2>/dev/null || true
if ! launchctl bootstrap "gui/$(id -u)" "$LAUNCH_AGENTS/$PLIST_NAME"; then
echo " bootstrap failed; retrying after extra cleanup"
launchctl unload "$LAUNCH_AGENTS/$PLIST_NAME" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$LAUNCH_AGENTS/$PLIST_NAME"
fi
echo " LaunchAgent loaded at gui/$(id -u)/org.nomadamas.k-skill-qa-bot"
fi
echo "==> Health check"
"$DEST/bin/health-check.sh" || echo " (health-check returned nonzero — review output above)"
if [ "$RUN_NOW" = true ]; then
echo "==> Running QA pass now (--force)"
"$DEST/bin/run-qa.sh" --force
fi
cat <<EOF
Install complete.
Source: $SRC
Runtime: $DEST
Logs: $LOG_DIR
LaunchAgent: $LAUNCH_AGENTS/$PLIST_NAME
First run will be in dry-run mode (CREATE_ISSUES=false). To opt in to filing
issues on NomaDamas/k-skill, append \`CREATE_ISSUES=true\` to:
$DEST/.env
EOF

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.nomadamas.k-skill-qa-bot</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>$HOME/.local/share/k-skill-qa-bot/bin/run-qa.sh</string>
</array>
<key>StartInterval</key>
<integer>259200</integer>
<key>RunAtLoad</key>
<true/>
<key>WorkingDirectory</key>
<string>__HOME__/.local/share/k-skill-qa-bot</string>
<key>StandardOutPath</key>
<string>__HOME__/Library/Logs/k-skill-qa-bot/stdout.log</string>
<key>StandardErrorPath</key>
<string>__HOME__/Library/Logs/k-skill-qa-bot/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>__HOME__</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
</dict>
<key>Nice</key>
<integer>5</integer>
<key>LowPriorityIO</key>
<true/>
<key>ProcessType</key>
<string>Background</string>
<key>ThrottleInterval</key>
<integer>60</integer>
</dict>
</plist>
</content>
</invoke>

View file

@ -0,0 +1,27 @@
#!/usr/bin/env bats
setup() {
QA_BOT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
README="$QA_BOT_ROOT/README.md"
AGENTS="$QA_BOT_ROOT/AGENTS.md"
}
@test "README accurately documents judge trust boundary" {
run grep -F 'it only reads transcripts/prompts and emits JSON' "$README"
[ "$status" -ne 0 ]
grep -Fq 'read-only/no-approval limits writes and approval prompts, but does not make the judge a no-tools or file-isolated model call' "$README"
grep -Fq 'Treat transcript and skill Markdown as untrusted input' "$README"
}
@test "README accurately documents smoke-test egress and LaunchAgent boundary" {
grep -Fq 'public skill endpoints exercised by smoke tests' "$README"
grep -Fq 'bot-managed clone is not write-protected from the unsandboxed smoke agent' "$README"
grep -Fq 'A dedicated LaunchAgent is scheduling isolation only; it is not a separate OS user, container, or filesystem sandbox' "$README"
}
@test "QA-bot AGENTS guidance preserves split trust boundary" {
grep -Fq 'Smoke tests intentionally run unsandboxed and may contact public skill endpoints' "$AGENTS"
grep -Fq 'bot-managed clone is not write-protected from the unsandboxed smoke agent' "$AGENTS"
grep -Fq 'The judge uses read-only/no-approval Codex settings, but is still a tool-capable Codex agent over untrusted transcripts and skill Markdown' "$AGENTS"
}

View file

@ -0,0 +1,27 @@
#!/usr/bin/env bats
setup() {
QA_BOT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
ENV_SH="$QA_BOT_ROOT/bin/lib/env.sh"
}
@test "env.sh sets all default values when nothing else is set" {
run env -i HOME="$HOME" PATH="$PATH" ENV_SH="$ENV_SH" bash -c '. "$ENV_SH" && echo "$CODEX_MODEL|$MAX_PARALLEL|$GH_REPO|$LAST_RUN_MIN_AGE|$CREATE_ISSUES|$JUDGE_MODEL"'
[ "$status" -eq 0 ]
[ "$output" = "gpt-5.5|4|NomaDamas/k-skill|259200|false|gpt-5.5" ]
}
@test "env.sh respects existing environment variables" {
run env -i HOME="$HOME" PATH="$PATH" ENV_SH="$ENV_SH" MAX_PARALLEL=8 CODEX_MODEL=custom bash -c '. "$ENV_SH" && echo "$CODEX_MODEL|$MAX_PARALLEL"'
[ "$status" -eq 0 ]
[ "$output" = "custom|8" ]
}
@test "env.sh respects user .env overrides" {
TMP=$(mktemp -d)
echo 'MAX_PARALLEL=16' > "$TMP/.env"
run env -i HOME="$HOME" PATH="$PATH" ENV_SH="$ENV_SH" K_QA_HOME="$TMP" bash -c '. "$ENV_SH" && echo "$MAX_PARALLEL"'
[ "$status" -eq 0 ]
[ "$output" = "16" ]
rm -rf "$TMP"
}

View file

@ -0,0 +1,54 @@
#!/usr/bin/env bats
setup() {
QA_BOT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
TMP="$(mktemp -d)"
STUB="$TMP/codex"
CAPTURE="$TMP/argv.txt"
TRANSCRIPT="$TMP/transcript.jsonl"
SKILL_MD="$TMP/SKILL.md"
cat > "$STUB" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$@" > "$CODEX_ARGV_CAPTURE"
printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"{\"verdict\":\"pass\",\"reason\":\"judge accepted transcript\",\"symptom_class\":\"success\",\"confidence\":0.99,\"evidence_quote\":\"VERDICT: PASS\"}"}}'
SH
chmod +x "$STUB"
cat > "$TRANSCRIPT" <<'JSONL'
{"type":"item.completed","item":{"type":"agent_message","text":"VERDICT: PASS\nEverything worked."}}
JSONL
echo '# Test Skill' > "$SKILL_MD"
}
teardown() {
rm -rf "$TMP"
}
@test "judge-skill standalone defaults to gpt-5.5" {
receipt="{\"name\":\"demo\",\"status\":\"executed\",\"exit_code\":0,\"duration_ms\":100,\"transcript_path\":\"$TRANSCRIPT\",\"test_prompt\":\"run demo\"}"
run env -i HOME="$HOME" PATH="$PATH" CODEX_BIN="$STUB" CODEX_ARGV_CAPTURE="$CAPTURE" \
bash -c 'printf "%s" "$0" | "$1" --skill-md "$2"' "$receipt" "$QA_BOT_ROOT/bin/judge-skill.py" "$SKILL_MD"
[ "$status" -eq 0 ]
echo "$output" | python3 -c 'import json,sys; data=json.load(sys.stdin); assert data["judge_model"] == "gpt-5.5", data'
grep -qx -- '-m' "$CAPTURE"
grep -qx -- 'gpt-5.5' "$CAPTURE"
}
@test "judge-skill keeps judge codex execution read-only and pins provider" {
receipt="{\"name\":\"demo\",\"status\":\"executed\",\"exit_code\":0,\"duration_ms\":100,\"transcript_path\":\"$TRANSCRIPT\",\"test_prompt\":\"run demo\"}"
run env -i HOME="$HOME" PATH="$PATH" CODEX_BIN="$STUB" CODEX_ARGV_CAPTURE="$CAPTURE" CODEX_PROVIDER="example-provider" \
bash -c 'printf "%s" "$0" | "$1" --skill-md "$2" --timeout 5' "$receipt" "$QA_BOT_ROOT/bin/judge-skill.py" "$SKILL_MD"
[ "$status" -eq 0 ]
grep -qx -- '-s' "$CAPTURE"
grep -qx -- 'read-only' "$CAPTURE"
grep -qx -- '-c' "$CAPTURE"
grep -qx -- 'approval_policy="never"' "$CAPTURE"
grep -qx -- 'model_provider="example-provider"' "$CAPTURE"
if grep -qx -- '--dangerously-bypass-approvals-and-sandbox' "$CAPTURE"; then
echo "unexpected sandbox-bypass flag in judge argv"
return 1
fi
}

View file

@ -0,0 +1,40 @@
#!/usr/bin/env bats
setup() {
QA_BOT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
cd "$QA_BOT_ROOT"
}
@test "log_info writes nothing to stdout" {
run bash -c '. bin/lib/log.sh && log_info "hello" 2>/dev/null'
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "log_info writes ISO-8601 + INFO + message to stderr" {
run bash -c '. bin/lib/log.sh && log_info "hello world" 2>&1 1>/dev/null'
[ "$status" -eq 0 ]
echo "$output" | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z[[:space:]]+INFO'
echo "$output" | grep -q 'hello world'
}
@test "log_warn uses WARN prefix" {
run bash -c '. bin/lib/log.sh && log_warn "boom" 2>&1 1>/dev/null'
echo "$output" | grep -qE 'WARN[[:space:]]'
}
@test "log_error uses ERROR prefix" {
run bash -c '. bin/lib/log.sh && log_error "crash" 2>&1 1>/dev/null'
echo "$output" | grep -qE 'ERROR[[:space:]]'
}
@test "log_debug is silent when K_QA_VERBOSE=0" {
run bash -c 'K_QA_VERBOSE=0; . bin/lib/log.sh && log_debug "noisy" 2>&1'
[ -z "$output" ]
}
@test "log_debug emits when K_QA_VERBOSE=1" {
run bash -c 'K_QA_VERBOSE=1; . bin/lib/log.sh && log_debug "noisy" 2>&1 1>/dev/null'
echo "$output" | grep -qE 'DEBUG[[:space:]]'
echo "$output" | grep -q 'noisy'
}

View file

@ -0,0 +1,65 @@
#!/usr/bin/env bats
setup() {
QA_BOT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
TMP="$(mktemp -d)"
STUB_BIN="$TMP/bin"
mkdir -p "$STUB_BIN" "$TMP/clone" "$TMP/run"
CAPTURE="$TMP/argv.txt"
cat > "$STUB_BIN/codex" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$@" > "$CODEX_ARGV_CAPTURE"
printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"smoke ok"}}'
SH
chmod +x "$STUB_BIN/codex"
cat > "$STUB_BIN/gtimeout" <<'SH'
#!/usr/bin/env bash
if [ "$1" = "--kill-after=15" ]; then
shift 2
fi
exec "$@"
SH
chmod +x "$STUB_BIN/gtimeout"
}
teardown() {
rm -rf "$TMP"
}
@test "test-skill keeps smoke codex execution on the documented sandbox-bypass path" {
classification='{"name":"demo","skip_reason":null,"default_test_prompt":"run demo smoke"}'
run env -i HOME="$HOME" PATH="$STUB_BIN:$PATH" CODEX_BIN="codex" CODEX_ARGV_CAPTURE="$CAPTURE" \
K_QA_HOME="$TMP/home" K_SKILL_CLONE="$TMP/clone" CODEX_MODEL="smoke-model" CODEX_PROVIDER="smoke-provider" TIMEOUT_SECS="5" \
bash -c 'printf "%s" "$0" | "$1" --run-dir "$2"' "$classification" "$QA_BOT_ROOT/bin/test-skill.sh" "$TMP/run"
[ "$status" -eq 0 ]
[ -f "$TMP/run/results/demo.exec.json" ]
grep -qx -- 'exec' "$CAPTURE"
grep -qx -- '--json' "$CAPTURE"
grep -qx -- '--dangerously-bypass-approvals-and-sandbox' "$CAPTURE"
grep -qx -- '--skip-git-repo-check' "$CAPTURE"
grep -qx -- '--ephemeral' "$CAPTURE"
grep -qx -- '-C' "$CAPTURE"
grep -qx -- "$TMP/clone" "$CAPTURE"
grep -qx -- '-m' "$CAPTURE"
grep -qx -- 'smoke-model' "$CAPTURE"
grep -qx -- 'model_provider="smoke-provider"' "$CAPTURE"
grep -qx -- 'run demo smoke' "$CAPTURE"
if grep -qx -- '-s' "$CAPTURE"; then
echo "unexpected sandbox flag in smoke argv"
return 1
fi
if grep -qx -- 'read-only' "$CAPTURE"; then
echo "unexpected read-only sandbox in smoke argv"
return 1
fi
python3 - "$TMP/run/results/demo.exec.json" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as f:
data = json.load(f)
assert data["status"] == "executed", data
assert data["codex_model"] == "smoke-model", data
assert data["test_prompt"] == "run demo smoke", data
PY
}

Some files were not shown because too many files have changed in this diff Show more