Compare commits

..

231 commits

Author SHA1 Message Date
Jeffrey (Dongkyu) Kim
08533bd9eb
Merge pull request #325 from NomaDamas/dev
Merge dev into main
2026-06-21 17:39:44 +09:00
Jeffrey (Dongkyu) Kim
eacdfb882a
Merge pull request #323 from jwb0501/feature/talent-search-skills
잡코리아·사람인 인재검색 스킬 추가
2026-06-19 14:38:47 +09:00
Jeffrey (Dongkyu) Kim
b14f65361f Wire JobKorea talent helper into CI 2026-06-18 10:37:08 +09:00
wbjung
caa1f0fd0d Fix JobKorea fallback row parsing 2026-06-18 00:04:07 +09:00
wbjung
c619d3b7c7 Add recruiting talent search skills 2026-06-17 23:14:54 +09:00
Jeffrey (Dongkyu) Kim
e735abe8a4
Merge PR #322: replace KakaoTalk skill with katok
Feature/#320
2026-06-17 16:52:39 +09:00
Jeffrey (Dongkyu) Kim
c3f44eef14 feat(kakaotalk-mac): replace kakaocli workflow with katok
Closes #320

Plan: .omo/plans/issue-320-katok-skill.md
2026-06-16 13:44:56 +09:00
Jeffrey (Dongkyu) Kim
1f186af480
Merge pull request #319 from NomaDamas/dev
Release dev to main
2026-06-15 13:12:02 +09:00
Jeffrey (Dongkyu) Kim
5fd58facf4
Merge pull request #318 from NomaDamas/changeset-release/main
chore: version packages
2026-06-15 13:11:49 +09:00
Jeffrey (Dongkyu) Kim
e0d842435b Merge main into dev for PR 319 2026-06-14 18:11:45 +09:00
Jeffrey (Dongkyu) Kim
ece355b807 fix(korean-humanizer): quote frontmatter description
Quote the long Korean description so the `예:` segment is parsed as a scalar
instead of an invalid YAML mapping key. Also ignore local `.gjc/` runtime state.

Verified with scripts/validate-skills.sh and downstream benchmark eligibility
classification.
2026-06-13 00:21:13 +09:00
Jeffrey (Dongkyu) Kim
a633b001be
feat: add business due-diligence skills
Reviewed, resolved conflicts with latest dev, applied follow-up fixes, and verified with npm run ci.
2026-06-12 19:35:14 +09:00
Jeffrey (Dongkyu) Kim
c8bb7f9f35 Merge dev and address PR review fixes 2026-06-12 19:34:20 +09:00
github-actions[bot]
7586c0dea8 chore: version packages 2026-06-12 10:07:08 +00:00
Jeffrey (Dongkyu) Kim
66f12cb43d
dev → main: srt-booking 좌석 탐색, korean-humanizer 신규 스킬, toss-securities 공식 OpenAPI 클라이언트, korean-law k-skill-proxy 편입 (#314)
* feat(srt-booking): SRT 좌석 확인과 탐색 우선순위 개선 (#305)

* feat(srt): 좌석 조회와 탐색 우선순위 추가

SRT search 결과의 stable train_id로 객차별 좌석을 조회하고, 특정 호차/좌석 확인과 탐색 우선순위 옵션을 제공한다.

Constraint: SRT와 KTX는 별도 upstream 표면이므로 SRT HTML 파서와 테스트를 분리함
Rejected: KTX 좌석 helper 공유 | Korail API와 SRT 웹 좌석선택 HTML 계약이 달라 혼용하면 파서 안정성이 낮아짐
Confidence: medium
Scope-risk: moderate
Directive: SRT 좌석선택 HTML에서 노출되지 않는 속성은 추정하지 말고 명시적으로 처리할 것
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_srt_booking scripts.test_ktx_booking; python3 -m py_compile scripts/srt_booking.py scripts/srt_seats.py scripts/test_srt_booking.py
Not-tested: 실제 예약 API에 우선순위 좌석 선택을 연결하는 흐름

* fix(srt): 좌석 조회 JSON 출력 안정화

SRT 대기열 메시지가 stdout에 섞여 seats JSON을 깨는 실제 표면 문제를 막고, 누락된 좌석 방향/위치 속성을 unknown으로 정규화한다.

Constraint: issue #303 범위는 예약 부작용이 없는 좌석 조회 보조 흐름으로 제한됨
Rejected: 실제 예약 subcommand 추가 | 좌석 선점/예약은 외부 부작용이라 이번 acceptance criteria에 포함되지 않음
Confidence: high
Scope-risk: narrow
Directive: SRTrain upstream 출력이 추가되더라도 helper stdout은 JSON 전용으로 유지할 것
Tested: RED→GREEN in .omo/ulw-loop/evidence/srt-c002-red-green-tests.txt; live SRT tmux QA in .omo/ulw-loop/evidence/srt-c001-live-search-seats.txt; npm run ci in .omo/ulw-loop/evidence/srt-c003-regression-ci.txt
Not-tested: 실제 예약/결제/취소 부작용 흐름

* test(srt): split seat helper regression coverage

---------

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

* feat: add korean-humanizer skill

AI가 쓴 티가 나는 한국어 글을 자연스러운 사람 글로 고치는 프롬프트 기반 스킬.
blader/humanizer의 구조·방법론(패턴 카탈로그 + draft→audit→final 루프 +
false positive 가이드)을 한국어에 맞게 재구성했다.

- 한국어 특화 33개 패턴: 번역체(직역 조사·무생물 주어·"~들"·"가지다"·이중피동·
  명사화), AI 상투어, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재,
  줄표·가운뎃점·곡선따옴표 등
- Triage(최소 개입) 원칙: 서식만 문제면 산문은 그대로 두어 과교정 방지
- Length control: 목표 글자수 지정 시 ±5% 내로 맞추고 공백 포함/제외 수치 보고,
  korean-character-count 스킬과 연동

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

* feat(korean-humanizer): rebuild v2 on im-not-ai framework

Build on happy-nut's PR #311 korean-humanizer skill (cherry-picked,
authorship preserved) by re-centering it on the epoko77-ai/im-not-ai
(Humanize KR, MIT) methodology:

- 4대 철칙 (의미 불변 · 근거 기반 · 장르 유지 · 과윤문 금지 30%/50% 가드)
- S1/S2/S3 severity tiers and A~D quality grades
- A~J taxonomy with Korean-specific patterns (A-16 그/그녀 강박,
  A-18 관계절 좌향 수식, A-19 이중 조사, C-11 연결어미 뒤 쉼표, E-7 경어법)
- detect -> rewrite -> audit -> grade loop with self-check checklist
- references/ai-tell-taxonomy.md full A~J table
- docs/features/korean-humanizer.md crediting im-not-ai and happy-nut
- README row + link, regenerated plugin.json, docs regression test

Co-authored-by: happy-nut <happynut.dev@gmail.com>

* docs(korean-law-search): document official precedent API evidence (#313)

Enhance the existing korean-law-search skill and feature doc with the
official 법제처 Open API precedent endpoints and detail retrieval, without
adding a new skill, package, workspace, or changeset.

- Document 판례 목록 조회 (lawSearch.do?target=prec) and 판례 본문 조회
  (lawService.do?target=prec&ID=...) as official evidence behind the
  korean-law-mcp search_precedents/get_precedent_text path.
- Add supported precedent filters (query, court, case number, source
  name, date, sort) and precedent-specific failure modes (missing LAW_OC,
  upstream unavailable/rate-limit/timeout, empty results, body
  unavailable for some sources) plus the legal-advice boundary.
- Keep korean-law-mcp first and Beopmang as the only post-failure
  fallback; lawService.do?target=prec is official detail retrieval, not a
  Beopmang-style fallback.
- Extend the skill-docs regression test with stable endpoint/tool
  literals and concept-level filter/failure-mode/legal-boundary checks.

Closes #308

* feat(toss-securities): add official read-only OpenAPI client (#312)

Add an official Toss Securities Open API client alongside the existing
unofficial tossctl wrapper. The package ships read-only helpers backed by
the official REST API (https://openapi.tossinvest.com): OAuth2
client_credentials token issuance with an in-memory token cache, bearer +
X-Tossinvest-Account header handling, TossApiError/TossCredentialsError
with secret/token redaction, and 429 Retry-After/backoff retry.

Credentials are read from TOSSINVEST_CLIENT_ID/TOSSINVEST_CLIENT_SECRET
(optional TOSSINVEST_ACCOUNT/TOSSINVEST_API_BASE_URL) and sent directly to
Toss, never through a shared proxy. Order mutation remains out of scope;
the tossctl path is retained as a documented fallback.

Closes #306

* Revert "docs(korean-law-search): document official precedent API evidence (#313)"

This reverts commit 5faec8bb2a.

* feat(k-skill-proxy): fold Korean law lookups into k-skill-proxy, drop Beopmang (#315)

Add hosted korean-law proxy routes and make the korean-law-search skill
proxy-first, removing the unstable Beopmang fallback from the support list.

- proxy: new src/korean-law.js wrapping official 법제처 DRF lawSearch.do /
  lawService.do, injecting LAW_OC + browser User-Agent/Referer (the real
  cause of "사용자 정보 검증 실패") and retrying empty/HTML responses.
- proxy: /v1/korean-law/search and /v1/korean-law/detail routes + lawOc
  config + koreanLawConfigured health flag; 17 module + 6 route tests.
- skill/docs: korean-law-search becomes proxy-first (no per-user LAW_OC,
  no local CLI). Drop Beopmang everywhere; credit chrisryugj/korean-law-mcp
  as design reference and 법제처 open.law.go.kr as official source.
- ops: LAW_OC added to deploy doc KEYS, secret accessor loop, and the
  Cloud Run deploy workflow set-secrets.
- changeset: k-skill-proxy minor.

---------

Co-authored-by: iamiks <rmstjr1030@naver.com>
Co-authored-by: happy-nut <happynut.dev@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:06:18 +09:00
Jeffrey (Dongkyu) Kim
f485591ac2
feat(k-skill-proxy): fold Korean law lookups into k-skill-proxy, drop Beopmang (#315)
Add hosted korean-law proxy routes and make the korean-law-search skill
proxy-first, removing the unstable Beopmang fallback from the support list.

- proxy: new src/korean-law.js wrapping official 법제처 DRF lawSearch.do /
  lawService.do, injecting LAW_OC + browser User-Agent/Referer (the real
  cause of "사용자 정보 검증 실패") and retrying empty/HTML responses.
- proxy: /v1/korean-law/search and /v1/korean-law/detail routes + lawOc
  config + koreanLawConfigured health flag; 17 module + 6 route tests.
- skill/docs: korean-law-search becomes proxy-first (no per-user LAW_OC,
  no local CLI). Drop Beopmang everywhere; credit chrisryugj/korean-law-mcp
  as design reference and 법제처 open.law.go.kr as official source.
- ops: LAW_OC added to deploy doc KEYS, secret accessor loop, and the
  Cloud Run deploy workflow set-secrets.
- changeset: k-skill-proxy minor.
2026-06-12 18:07:03 +09:00
John
440cd697a7 feat: 사업자 실사 스킬군 — 단품 5종 + 복합 1종, proxy route 3개 추가 (#316)
사업자등록번호로 "이 사업자 실제 문제 없나"를 무료 공공 데이터로 교차 조회하는
스킬군을 기여한다. 점수·등급·"위험" 라벨 없이 사실+출처+조회시각만 병렬한다.

단품 스킬:
- national-pension-workplace  국민연금 가입 사업장 (proxy, 3046071)
- nts-tax-delinquency         국세 체납 명단공개 (무인증 직접)
- fsc-corporate-info          금융위 기업기본정보 (proxy, 15043184)
- g2b-sanctioned-supplier     조달청 부정당제재 (proxy, 15129466)
- localdata-business-status   지방행정 인허가 영업상태 208업종 (무인증 직접)

복합 스킬:
- biz-health-check  위 5종 + 기존 nts-business-registration을 한 번에 호출

proxy(packages/k-skill-proxy):
- keyed route 3개 추가 — 키는 서버의 DATA_GO_KR_API_KEY로만 주입(사용자 시크릿 없음)
- 연금 route는 basic+detail+monthly 3콜 오케스트레이션 + 월별중복 dedup
- server.test.js에 route 테스트 10건 추가 (정상/503 미설정/400/403 forbidden)

무인증 스킬은 stdlib(urllib)만 사용해 의존성 없이 직접 호출한다.
문서: docs/features ×6, README 표·링크, docs/sources.md 갱신, plugin.json 재생성.

활용신청(프록시 운영 서버 등록 필요): 3046071·15043184·15129466
(15081808 국세청 상태조회는 nts-business-registration용으로 이미 등록, 키 공유).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:46:30 +09:00
Jeffrey (Dongkyu) Kim
b6200892e3 Revert "docs(korean-law-search): document official precedent API evidence (#313)"
This reverts commit 5faec8bb2a.
2026-06-11 11:45:04 +09:00
Jeffrey (Dongkyu) Kim
79f6038328
feat(toss-securities): add official read-only OpenAPI client (#312)
Add an official Toss Securities Open API client alongside the existing
unofficial tossctl wrapper. The package ships read-only helpers backed by
the official REST API (https://openapi.tossinvest.com): OAuth2
client_credentials token issuance with an in-memory token cache, bearer +
X-Tossinvest-Account header handling, TossApiError/TossCredentialsError
with secret/token redaction, and 429 Retry-After/backoff retry.

Credentials are read from TOSSINVEST_CLIENT_ID/TOSSINVEST_CLIENT_SECRET
(optional TOSSINVEST_ACCOUNT/TOSSINVEST_API_BASE_URL) and sent directly to
Toss, never through a shared proxy. Order mutation remains out of scope;
the tossctl path is retained as a documented fallback.

Closes #306
2026-06-10 22:50:47 +09:00
Jeffrey (Dongkyu) Kim
5faec8bb2a
docs(korean-law-search): document official precedent API evidence (#313)
Enhance the existing korean-law-search skill and feature doc with the
official 법제처 Open API precedent endpoints and detail retrieval, without
adding a new skill, package, workspace, or changeset.

- Document 판례 목록 조회 (lawSearch.do?target=prec) and 판례 본문 조회
  (lawService.do?target=prec&ID=...) as official evidence behind the
  korean-law-mcp search_precedents/get_precedent_text path.
- Add supported precedent filters (query, court, case number, source
  name, date, sort) and precedent-specific failure modes (missing LAW_OC,
  upstream unavailable/rate-limit/timeout, empty results, body
  unavailable for some sources) plus the legal-advice boundary.
- Keep korean-law-mcp first and Beopmang as the only post-failure
  fallback; lawService.do?target=prec is official detail retrieval, not a
  Beopmang-style fallback.
- Extend the skill-docs regression test with stable endpoint/tool
  literals and concept-level filter/failure-mode/legal-boundary checks.

Closes #308
2026-06-10 22:50:34 +09:00
Jeffrey (Dongkyu) Kim
e25f8dd9ab feat(korean-humanizer): rebuild v2 on im-not-ai framework
Build on happy-nut's PR #311 korean-humanizer skill (cherry-picked,
authorship preserved) by re-centering it on the epoko77-ai/im-not-ai
(Humanize KR, MIT) methodology:

- 4대 철칙 (의미 불변 · 근거 기반 · 장르 유지 · 과윤문 금지 30%/50% 가드)
- S1/S2/S3 severity tiers and A~D quality grades
- A~J taxonomy with Korean-specific patterns (A-16 그/그녀 강박,
  A-18 관계절 좌향 수식, A-19 이중 조사, C-11 연결어미 뒤 쉼표, E-7 경어법)
- detect -> rewrite -> audit -> grade loop with self-check checklist
- references/ai-tell-taxonomy.md full A~J table
- docs/features/korean-humanizer.md crediting im-not-ai and happy-nut
- README row + link, regenerated plugin.json, docs regression test

Co-authored-by: happy-nut <happynut.dev@gmail.com>
2026-06-10 14:32:44 +09:00
happy-nut
a8a71eed11 feat: add korean-humanizer skill
AI가 쓴 티가 나는 한국어 글을 자연스러운 사람 글로 고치는 프롬프트 기반 스킬.
blader/humanizer의 구조·방법론(패턴 카탈로그 + draft→audit→final 루프 +
false positive 가이드)을 한국어에 맞게 재구성했다.

- 한국어 특화 33개 패턴: 번역체(직역 조사·무생물 주어·"~들"·"가지다"·이중피동·
  명사화), AI 상투어, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재,
  줄표·가운뎃점·곡선따옴표 등
- Triage(최소 개입) 원칙: 서식만 문제면 산문은 그대로 두어 과교정 방지
- Length control: 목표 글자수 지정 시 ±5% 내로 맞추고 공백 포함/제외 수치 보고,
  korean-character-count 스킬과 연동

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:22:54 +09:00
iamiks
52dbfee064
feat(srt-booking): SRT 좌석 확인과 탐색 우선순위 개선 (#305)
* feat(srt): 좌석 조회와 탐색 우선순위 추가

SRT search 결과의 stable train_id로 객차별 좌석을 조회하고, 특정 호차/좌석 확인과 탐색 우선순위 옵션을 제공한다.

Constraint: SRT와 KTX는 별도 upstream 표면이므로 SRT HTML 파서와 테스트를 분리함
Rejected: KTX 좌석 helper 공유 | Korail API와 SRT 웹 좌석선택 HTML 계약이 달라 혼용하면 파서 안정성이 낮아짐
Confidence: medium
Scope-risk: moderate
Directive: SRT 좌석선택 HTML에서 노출되지 않는 속성은 추정하지 말고 명시적으로 처리할 것
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_srt_booking scripts.test_ktx_booking; python3 -m py_compile scripts/srt_booking.py scripts/srt_seats.py scripts/test_srt_booking.py
Not-tested: 실제 예약 API에 우선순위 좌석 선택을 연결하는 흐름

* fix(srt): 좌석 조회 JSON 출력 안정화

SRT 대기열 메시지가 stdout에 섞여 seats JSON을 깨는 실제 표면 문제를 막고, 누락된 좌석 방향/위치 속성을 unknown으로 정규화한다.

Constraint: issue #303 범위는 예약 부작용이 없는 좌석 조회 보조 흐름으로 제한됨
Rejected: 실제 예약 subcommand 추가 | 좌석 선점/예약은 외부 부작용이라 이번 acceptance criteria에 포함되지 않음
Confidence: high
Scope-risk: narrow
Directive: SRTrain upstream 출력이 추가되더라도 helper stdout은 JSON 전용으로 유지할 것
Tested: RED→GREEN in .omo/ulw-loop/evidence/srt-c002-red-green-tests.txt; live SRT tmux QA in .omo/ulw-loop/evidence/srt-c001-live-search-seats.txt; npm run ci in .omo/ulw-loop/evidence/srt-c003-regression-ci.txt
Not-tested: 실제 예약/결제/취소 부작용 흐름

* test(srt): split seat helper regression coverage

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>
2026-06-09 10:57:54 +09:00
Jeffrey (Dongkyu) Kim
f5d37ddbee
Merge pull request #309 from NomaDamas/changeset-release/main
chore: version packages
2026-06-09 10:55:22 +09:00
github-actions[bot]
819be4897a chore: version packages 2026-06-06 03:08:25 +00:00
Jeffrey (Dongkyu) Kim
1efef285ba
Merge pull request #302 from NomaDamas/dev
Release: dev → main
2026-06-06 12:07:27 +09:00
Jeffrey (Dongkyu) Kim
acc66861ea Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	.changeset/issue-268-naver-map-route.md
2026-06-05 22:49:47 +09:00
Jeffrey (Dongkyu) Kim
bbba283151 Archive unsupported map skills 2026-06-05 22:24:15 +09:00
Jeffrey (Dongkyu) Kim
46f44ed724 chore: remove startup-support skill 2026-06-05 17:21:58 +09:00
Jeffrey (Dongkyu) Kim
cc64d66d56 test: isolate python test dependencies 2026-06-05 17:03:27 +09:00
Jeffrey (Dongkyu) Kim
7c1a4530cf test: remove runtime pip install from root ci 2026-06-05 17:00:02 +09:00
Jeffrey (Dongkyu) Kim
346ce7f516 fix(startup-support): use K-Startup proxy surface 2026-06-05 13:03:28 +09:00
Jeffrey (Dongkyu) Kim
e336f7898c chore: resolve integration diagnostics 2026-05-31 17:28:59 +09:00
Jeffrey (Dongkyu) Kim
352876f915 chore(plugin): include startup-support in manifest 2026-05-31 17:26:08 +09:00
Jeffrey (Dongkyu) Kim
581b0344ce Merge commit 'c27a3e9' into ulw-merge-seven-prs
# Conflicts:
#	package.json
2026-05-31 17:25:22 +09:00
Jeffrey (Dongkyu) Kim
cc768f4031 Merge commit 'cff6b29' into ulw-merge-seven-prs 2026-05-31 17:24:25 +09:00
Jeffrey (Dongkyu) Kim
51388a539e Merge commit 'b6b0c70' into ulw-merge-seven-prs
# Conflicts:
#	scripts/validate-skills.sh
2026-05-31 17:24:25 +09:00
Jeffrey (Dongkyu) Kim
fe08b8e068 Merge remote-tracking branch 'origin/pr/293' into ulw-merge-seven-prs 2026-05-31 17:24:05 +09:00
Jeffrey (Dongkyu) Kim
e76c125014 Merge remote-tracking branch 'origin/pr/295' into ulw-merge-seven-prs 2026-05-31 17:24:04 +09:00
Jeffrey (Dongkyu) Kim
a490fcb0c0 Merge remote-tracking branch 'origin/pr/296' into ulw-merge-seven-prs 2026-05-31 17:24:04 +09:00
Jeffrey (Dongkyu) Kim
9dc3577742 Merge remote-tracking branch 'origin/pr/298' into ulw-merge-seven-prs 2026-05-31 17:24:04 +09:00
Jeffrey (Dongkyu) Kim
c27a3e9151 fix(foresttrip-vacancy): preserve category-specific vacancy rows 2026-05-31 17:23:57 +09:00
Jeffrey (Dongkyu) Kim
cff6b29ff9 fix(startup-support): remove fabricated detail data 2026-05-31 17:23:57 +09:00
Jeffrey (Dongkyu) Kim
b6b0c70091 fix(plugin): satisfy Claude marketplace manifest schema 2026-05-31 17:23:57 +09:00
Donghun Seol
edb2892dda test(foresttrip-vacancy): harden availability output coverage 2026-05-29 21:05:31 +09:00
Donghun Seol
b2404e99be test(foresttrip-vacancy): add print_text regression coverage
라이브 불변식 검증(예비 제외·dedup·useDt 범위)이 통과한 뒤,
사용자 대면 출력 함수인 print_text가 테스트 0개였던 갭을 메운다.

- PrintTextTest 2개: 결과 렌더링 / 빈 결과 메시지
- stub_fetch는 fetch_one의 미사용 인자를 **_ 로 흡수해 lint 경고 제거

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:57:25 +09:00
Donghun Seol
00a6d9feae fix(foresttrip-vacancy): exclude 예비 rooms, gate useDt strictly, dedup duplicate rooms
월별예약조회 API가 srchDate 단일 일자 요청에도 5일 윈도우를 반환하고,
"예비"로 표기된 운영자 보유분이 raw 응답에 포함되며, 같은 객실이 다른
goodsId로 중복 표시되는 세 가지 문제를 한꺼번에 fix한다.

수정:
- collect_results 안에 strict useDt gate 추가 (today~last_day 범위 밖 행 차단)
- is_reserve_room() helper로 goodsNm에 "예비" 포함 객실 제외
- (forest_id, use_dt, name) 단위 dedup으로 중복 행 제거
- is_available()는 시그니처/로직 변경 없이 booking-state predicate 유지

추가:
- foresttrip-vacancy/tests/ 18개 단위 테스트 (mock + fixture 기반)
- IsReserveRoomTest, IsAvailableTest, CollectResultsFilterTest,
  StrictUseDtGateTest, GroundTruthTest 다섯 클래스
- 거제·구재봉 fixture로 사용자 라이브 검증 결과 회귀 보호
- package.json lint·test 스크립트에 등록

문서:
- SKILL.md: API 5일 윈도우/예비 객실/중복 dedup 자동 처리 명시 + 회복 시나리오 보강
- docs/features/foresttrip-vacancy.md: 기본 흐름 6단계와 주의할 점 보강

사용자 라이브 검증 ground truth (2026-05-12 기준):
- 거제자연휴양림 5/13 ~9개, 5/16 0개, 5/17 19개, 5/23 0개, 5/24 0개
- 구재봉자연휴양림 5/16 1개 (206호 쑥부쟁이방)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:57:25 +09:00
Jeffrey (Dongkyu) Kim
3d8008f2f2 Harden KTX seat detail trust boundaries
Fail closed when Korail reports remaining seats but returns malformed, sentinel-only, or schema-incomplete detail rows, and keep the seat research endpoints inside the same Dynapath/Sid request boundary as other protected mobile calls.\n\nConstraint: PR #298 review required TDD regressions for false empty-seat success and raw external payload failures.\nRejected: Preserve lenient normalization of partial seat rows | it can mislead automation with authoritative empty results.\nConfidence: high\nScope-risk: narrow\nDirective: Keep KTX seat detail parsing fail-closed unless live Korail evidence proves a specific empty or partial shape is valid.\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; node --test scripts/skill-docs.test.js; npm run typecheck; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; shellcheck scripts/validate-skills.sh; bash scripts/validate-skills.sh; PYENV_VERSION=3.11.9 npm run ci\nNot-tested: live Korail seat-detail network flow with production credentials
2026-05-29 01:55:52 +09:00
Jeffrey (Dongkyu) Kim
f8401ef405 Fail clearly at KTX seat payload boundaries
Boundary validation now distinguishes unavailable/malformed Korail car and seat-detail payloads from valid zero-seat results, so automation does not consume false successful empty-seat JSON.\n\nConstraint: PR #298 follow-up for issue #293 required TDD, clear errors for malformed details/metadata, and branch feature/#293.\nRejected: Per-car partial error payloads | broader interface change than the existing fail-fast command contract.\nRejected: Missing Korail car counts defaulting to zero | masks unknown external summary data as authoritative no-seat state.\nConfidence: high\nScope-risk: narrow\nDirective: Keep seat-detail and car-summary schema checks at the command boundary before normalizing rows.\nTested: Targeted malformed payload probe; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; node --test scripts/skill-docs.test.js; npm run typecheck; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; shellcheck scripts/validate-skills.sh; bash scripts/validate-skills.sh; PYENV_VERSION=3.11.9 npm run ci.\nNot-tested: Live Korail seat-detail endpoints requiring user credentials.
2026-05-29 01:41:33 +09:00
Jeffrey (Dongkyu) Kim
1d4b2eb723 Keep KTX seat lookup typing from obscuring review signals
The seat inspection follow-up already fixed the runtime schema issues, but review still had changed-area pyright noise around the dynamic Korail boundaries. Route those calls through explicit narrow dynamic seams so the remaining pyright output stays confined to the existing baseline.\n\nConstraint: PR #296 follow-up must preserve the existing korail2 dynamic runtime behavior while cleaning the reviewed changed area.\nRejected: Broad pyright baseline cleanup | outside issue #292 scope and higher risk for this follow-up.\nConfidence: high\nScope-risk: narrow\nDirective: Keep future Korail mobile endpoint additions behind explicit typed/dynamic boundaries so changed-area diagnostics remain actionable.\nTested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; git diff --check origin/dev...HEAD; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help; pyright changed-area grep against search/seat lines\nNot-tested: live authenticated Korail seat lookup; full pyright remains on existing dynamic Korail/test-helper baseline
2026-05-29 01:30:33 +09:00
Jeffrey (Dongkyu) Kim
002c6c13bc Make KTX seat summaries match active filters
Constraint: PR #296 follow-up must address reproducible review findings without live Korail credentials.
Rejected: Keeping available_seats unfiltered | It conflicted with --power-only user intent and review feedback.
Confidence: high
Scope-risk: narrow
Directive: Keep filtered availability and all_available_* semantics documented together when changing seats output.
Tested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; git diff --check origin/dev...HEAD; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help
Not-tested: live authenticated Korail seat lookup
2026-05-29 01:00:27 +09:00
Jeffrey (Dongkyu) Kim
20e8f3f8f0 Expose missing KTX seat detail payloads
Constraint: PR #298 review blocker required missing seat_infos.seat_info to fail clearly instead of reporting zero seats.
Rejected: Treating absent seat_info as an empty list | this masks Korail detail endpoint failures as authoritative no-seat results.
Confidence: high
Scope-risk: narrow
Directive: Preserve explicit empty seat_info lists as valid; only absent or malformed detail schema should fail.
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; node --test scripts/skill-docs.test.js; npm run typecheck; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; shellcheck scripts/validate-skills.sh; bash scripts/validate-skills.sh; PYENV_VERSION=3.11.9 npm run ci
Not-tested: plain npm run ci with local default Python 3.14 remains blocked by pyexpat/libexpat linkage before project tests
2026-05-29 00:27:02 +09:00
Jeffrey (Dongkyu) Kim
eb08ef6134 Keep malformed KTX seat payloads actionable
Convert malformed Korail seat-detail payloads into the existing CLI failure path so advisory lookup callers get a clear retryable error instead of an AttributeError.

Constraint: PR #295 review watch item identified successful-but-malformed seat_infos payloads as an unhelpful crash surface.

Rejected: Letting raw adapter fallbacks leak AttributeError | CLI users need actionable SystemExit diagnostics at the command boundary.

Confidence: high

Scope-risk: narrow

Directive: Keep detailed seat lookup advisory; validate raw Korail shapes before exposing fields to JSON callers.

Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; node --test scripts/skill-docs.test.js; npm run typecheck; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; shellcheck scripts/validate-skills.sh; bash scripts/validate-skills.sh; PATH=<pyenv 3.11.9 shim> npm run ci

Not-tested: Plain npm run ci with /opt/homebrew Python 3.14 due local pyexpat/libexpat linkage error reproduced before project tests.
2026-05-29 00:11:29 +09:00
iamiks
1d8fd333d8 Expose KTX malformed seat detail evidence
Keep seat-priority output from reporting upstream seat-detail schema failures as authoritative zero-seat availability. Preserve car-level remaining_seats and surface seat_lookup_error when detail payloads are malformed while seats remain.

Constraint: PR #295 review follow-up keeps advisory priority/filter behavior while avoiding false zero-seat results.

Rejected: Treat malformed or empty detail payloads as [] | masks Korail endpoint/schema failures as no availability.

Confidence: high

Scope-risk: narrow

Directive: Keep detailed seat lookup advisory unless reserve gains explicit car/seat selection support.

Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; git diff --check origin/dev...HEAD; npm run typecheck

Not-tested: live authenticated Korail seat-detail lookup
2026-05-29 00:09:29 +09:00
Jeffrey (Dongkyu) Kim
95c6222a65 Fail early on incomplete KTX seat lookup context
Constraint: PR #296 review watchlist noted raw Korail mobile context can otherwise send empty residual-seat payload fields upstream.
Rejected: Keep empty-string defaults in residual-seat payloads | That hides stale or malformed search detail context until the anti-bot-sensitive endpoint is called.
Confidence: high
Scope-risk: narrow
Directive: Keep residual-seat payload requirements explicit if Korail changes the mobile h_* field contract.
Tested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help
Not-tested: Live Korail authenticated seat lookup; credentials are not available in automation.
2026-05-29 00:06:55 +09:00
Jeffrey (Dongkyu) Kim
48a0420752 Expose KTX malformed seat detail evidence
Preserve car-level remaining_seats when Korail detail payloads drift, and mark the affected car with an explicit lookup error instead of reporting a false zero-seat result.\n\nConstraint: PR #293 review required malformed detail responses to be visible when car summary still reports remaining seats.\nRejected: silently normalizing malformed seat_infos to [] | it hides upstream schema drift as legitimate zero availability.\nConfidence: high\nScope-risk: narrow\nDirective: Keep seat-detail schema drift observable; do not collapse positive remaining seats and malformed detail payloads into successful empty results.\nTested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking\nTested: python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py\nTested: node --check scripts/skill-docs.test.js\nTested: node --test scripts/skill-docs.test.js\nNot-tested: live Korail seat-detail endpoint with production credentials
2026-05-29 00:06:55 +09:00
Jeffrey (Dongkyu) Kim
6a26a194c6 Fail early on incomplete KTX seat lookup context
Constraint: PR #296 review watchlist noted raw Korail mobile context can otherwise send empty residual-seat payload fields upstream.
Rejected: Keep empty-string defaults in residual-seat payloads | That hides stale or malformed search detail context until the anti-bot-sensitive endpoint is called.
Confidence: high
Scope-risk: narrow
Directive: Keep residual-seat payload requirements explicit if Korail changes the mobile h_* field contract.
Tested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help
Not-tested: Live Korail authenticated seat lookup; credentials are not available in automation.
2026-05-28 23:59:49 +09:00
Jeffrey (Dongkyu) Kim
6aff1e7301 Expose KTX malformed seat detail evidence
Preserve car-level remaining_seats when Korail detail payloads drift, and mark the affected car with an explicit lookup error instead of reporting a false zero-seat result.\n\nConstraint: PR #293 review required malformed detail responses to be visible when car summary still reports remaining seats.\nRejected: silently normalizing malformed seat_infos to [] | it hides upstream schema drift as legitimate zero availability.\nConfidence: high\nScope-risk: narrow\nDirective: Keep seat-detail schema drift observable; do not collapse positive remaining seats and malformed detail payloads into successful empty results.\nTested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking\nTested: python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py\nTested: node --check scripts/skill-docs.test.js\nTested: node --test scripts/skill-docs.test.js\nNot-tested: live Korail seat-detail endpoint with production credentials
2026-05-28 23:42:10 +09:00
iamiks
70b92d6b03 Clarify KTX seat filter failures
Constraint: PR #295 review found power-only summaries included non-power seats and empty car-list payloads looked successful.
Rejected: Document available_seats as unfiltered | would preserve a surprising JSON contract for power-only callers.
Rejected: Ignore local hidden directories one by one | hidden runtime/cache directories can keep reappearing outside git.
Confidence: high
Scope-risk: narrow
Directive: Keep seats output summaries aligned with active display filters, while --limit remains a detailed seats display cap.
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; git diff --check; node --test scripts/skill-docs.test.js; npm run typecheck; PIP_BREAK_SYSTEM_PACKAGES=1 npm run ci
Not-tested: live Korail production seat endpoint behavior
2026-05-28 23:07:09 +09:00
iamiks
9b9f5bc7a2 Stabilize KTX seat lookup review fixes
Constraint: PR #293 review found fallback korail2 imports failed under PYTHONNOUSERSITE and seat lookup needed clearer Dynapath/power-seat contracts.
Rejected: Leave research endpoints outside DYNAPATH_PATHS | would keep the auth-header behavior ambiguous for live seat lookup.
Confidence: high
Scope-risk: narrow
Directive: Keep fallback-only tests independent of installed korail2 so CI catches missing dependency behavior.
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help; node --test scripts/skill-docs.test.js
Not-tested: live Korail seat lookup against production endpoints
2026-05-28 22:59:24 +09:00
iamiks
ee51dbc2f1 chore(ci): 로컬 런타임 디렉터리 검증 제외
로컬 개발 환경에서 생성되는 .agents와 .venv를 최상위 스킬로
검사하지 않도록 제외해 skill layout 검증을 실제 스킬 디렉터리에 한정합니다.

Constraint: npm run ci가 validate-skills.sh를 필수로 실행함
Rejected: 로컬 .agents/.venv 삭제 | 사용자 환경 파일을 제거하는 방식은 부적절함
Confidence: high
Scope-risk: narrow
Directive: 새 로컬 런타임 디렉터리가 생기면 skill-docs.test.js 제외 목록과 함께 검토할 것
Tested: npm run ci
2026-05-28 22:49:31 +09:00
iamiks
24feb3edca feat(ktx-booking): 좌석 탐색 우선순위 개선
KTX 상세 좌석 조회에서 가운데 호차를 먼저 탐색하고,
같은 호차 안에서는 콘센트 좌석과 순방향 좌석을 우선 노출합니다.
#294 요구사항을 우선순위 함수와 command_seats 회귀 테스트로 고정합니다.

Constraint: #294는 기존 seats 공개 인터페이스 유지를 요구함
Rejected: 예약 API에 객차/좌석 번호를 직접 주입 | 현재 helper 예약 경로가 해당 선택 인자를 노출하지 않음
Confidence: high
Scope-risk: narrow
Directive: 좌석 우선순위 변경 시 command_seats 출력 순서 테스트를 함께 갱신할 것
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking
Tested: node --test scripts/skill-docs.test.js
Tested: npm run typecheck
Tested: npm run ci
2026-05-28 22:49:31 +09:00
iamiks
8420792a82 Expose KTX seat-level lookup before reservation
Add a seats command to inspect residual seats by car, normalize seat metadata, and surface power-outlet hints while preserving the existing search and reserve selector flow. Update skill docs so seat-number and good-seat requests route through the detailed lookup before reservation.

Constraint: Korail reservation remains separate from seat inspection; the new flow must not select, hold, or pay for seats.

Rejected: Reusing train-level has_general_seat flags for good-seat requests | those flags cannot answer car number, seat label, or outlet availability.

Confidence: high

Scope-risk: moderate

Directive: Keep search, seats, and reserve on the same train_type and stable train_id path so stale results fail explicitly.

Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js

Not-tested: npm run ci is blocked locally because gitignored .agents/ and .venv directories are treated as missing SKILL.md by scripts/validate-skills.sh.
2026-05-28 22:49:13 +09:00
seungwonme
3ba8899f63 feat(plugin): Claude Code 플러그인 + 마켓플레이스 매니페스트 추가
디렉토리 구조와 npm/changesets 릴리스 흐름을 건드리지 않고 루트의
한국 특화 Agent Skill 모음을 Claude Code 플러그인으로 설치 가능하게 한다.

- .claude-plugin/plugin.json: skills 배열로 루트 89개 skill을 노출하는 단일 번들
  (지원 중단된 blue-ribbon-nearby 제외)
- .claude-plugin/marketplace.json: repo 자체를 마켓플레이스로, 번들 플러그인 1개(source ./)
- scripts/generate-plugin-manifest.js: SKILL.md 스캔으로 skills 배열 생성, --check drift 게이트
- scripts/test_generate_plugin_manifest.js: discovery/제외/drift node --test 6케이스
- scripts/validate-skills.sh: dot 디렉토리 개별 나열을 '! -name .*'로 일반화 (.claude-plugin 오인 방지)
- package.json: generate:plugin-manifest 스크립트 + lint/test 등록, lint 끝에 drift 게이트
- README.md: 플러그인 설치 안내 추가

설치: /plugin marketplace add NomaDamas/k-skill -> /plugin install k-skill@k-skill
2026-05-27 23:27:56 +09:00
TaeyoungPark
9b2e0957f2 chore: merge upstream/dev into feat/myrealtrip-mcp-search 2026-05-27 16:33:36 +09:00
TaeyoungPark
0e30b79e83 fix: stabilize startup-support deadline filtering and tests 2026-05-27 16:30:29 +09:00
TaeyoungPark
807fa0c900 feat: Add startup-support API routes to k-skill-proxy
- Add startup-support API routes for Korean government startup programs
- Implement /v1/startup-support/list, /detail, /region, /deadline endpoints
- Integrate with existing k-skill-proxy infrastructure

Closes #startup-support
2026-05-27 15:41:11 +09:00
TaeyoungPark
d12bfa1fab feat: Add startup-support skill for Korean government startup programs
- Add startup-support skill to search Korean government startup support programs
- Implement Python script with multiple data sources (public data, local governments)
- Add k-skill-proxy routes for API endpoints
- Update documentation (README.md, docs/features/, docs/sources.md, etc.)
- Add comprehensive test suite

Closes #startup-support
2026-05-27 15:40:12 +09:00
Jeffrey (Dongkyu) Kim
0ea646a03d
Merge pull request #287 from NomaDamas/dev
ci(deploy): naverMapConfigured smoke test 예외 처리
2026-05-26 10:17:00 +09:00
Jeffrey (Dongkyu) Kim
abe26e411d ci(deploy): skip naverMapConfigured in smoke test until NCP keys are provisioned 2026-05-26 09:58:37 +09:00
Jeffrey (Dongkyu) Kim
234790a7ea
Merge pull request #286 from NomaDamas/changeset-release/main
chore: version packages
2026-05-26 09:53:29 +09:00
github-actions[bot]
d2db629640 chore: version packages 2026-05-26 00:52:47 +00:00
Jeffrey (Dongkyu) Kim
0300e9b91b
Merge pull request #285 from NomaDamas/dev
release: dev → main 통합 릴리스 (2026-05-25)
2026-05-26 09:51:49 +09:00
Jeffrey (Dongkyu) Kim
19af47399d merge: resolve conflicts with main (keep dev action versions + lint entries) 2026-05-26 09:33:04 +09:00
Jeffrey (Dongkyu) Kim
99340393de docs: 네이버맵 스킬 미작동 안내 추가 (NCP 키 미설정) 2026-05-25 18:31:03 +09:00
Jeffrey (Dongkyu) Kim
e90897a684
feat(#267): 카카오맵 스킬 (Kakao Local 장소검색 + Kakao Mobility 자동차 길찾기)
feat(#267): 카카오맵 스킬 (Kakao Local 장소검색 + Kakao Mobility 자동차 길찾기)
2026-05-25 17:18:27 +09:00
Jeffrey (Dongkyu) Kim
72a3fd7ca6 merge: resolve conflicts with dev after PR #282 merge 2026-05-25 17:16:53 +09:00
Jeffrey (Dongkyu) Kim
440f33b52f
feat(#268): 네이버맵 길찾기 MVP 스킬 + NCP Maps proxy 라우트 3종
feat(#268): 네이버맵 길찾기 MVP 스킬 + NCP Maps proxy 라우트 3종
2026-05-25 16:39:51 +09:00
Jeffrey (Dongkyu) Kim
ed30f22f86
fix(ci): add npm auth preflight to release workflow
fix(ci): add npm auth preflight to catch publish permission failures early
2026-05-25 15:19:04 +09:00
Jeffrey (Dongkyu) Kim
5e58d1fe86 fix(ci): add npm auth preflight to release workflow
The latest npm release (changeset publish) failed with E404 on 4 packages:
daiso-product-search, sh-notice-search, emergency-room-beds,
local-election-candidate-search.

Root cause: NPM_TOKEN lost publish access to these packages
(previously worked on May 19; failed on May 22).

This commit adds two preflight steps before changeset publish:
1. npm whoami — fail fast if the token is invalid or expired
2. npm access check — verify publish rights for every package
   whose local version differs from npm registry, before the
   changeset action runs

The actual publish fix requires rotating or re-authorizing the
NPM_TOKEN secret in GitHub → Settings → Secrets. Once the token
is valid, re-trigger the workflow to publish the 4 stuck packages.
2026-05-25 13:44:08 +09:00
Jeffrey (Dongkyu) Kim
51ea778a2d Keep Kakao waypoint validation at the proxy boundary
Constraint: Kakao Mobility waypoint coordinates share the same x,y shape as origin and destination.\nRejected: Letting out-of-range waypoints reach upstream | it spends quota on a deterministic bad request.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Kakao Mobility coordinate validation local before cache lookup or upstream fetch.\nTested: node --test packages/k-skill-proxy/test/server.test.js; npm test --workspace k-skill-proxy; npm run lint --workspace k-skill-proxy; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; manual Fastify inject invalid waypoint 400/0 upstream calls and valid waypoint 200/1 upstream call.\nNot-tested: npm run ci full root pipeline, because prior PR validation documented a local Python 3.14 pyexpat/pip install environment blocker.
2026-05-23 21:56:35 +09:00
Jeffrey (Dongkyu) Kim
2dbad40078 Keep Kakao radius filters local
Reject keyword radius without a coordinate center before Kakao Local calls so predictable client errors do not spend upstream quota.\n\nConstraint: PR #283 review round 3 requested local radius validation for issue #267.\nRejected: Letting Kakao Local reject radius-only keyword searches | wastes quota and weakens proxy determinism.\nConfidence: high\nScope-risk: narrow\nDirective: Keep coordinate-centered Kakao filters validated before cache lookup or upstream fetch.\nTested: node --test packages/k-skill-proxy/test/server.test.js; npm test --workspace k-skill-proxy; npm run lint --workspace k-skill-proxy; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; manual Fastify inject smoke.\nNot-tested: npm run ci remains blocked in local Python 3.14 pyexpat during pip install beautifulsoup4 after lint/typecheck.
2026-05-23 21:34:38 +09:00
Jeffrey (Dongkyu) Kim
68bd64ebd4 Preserve route proxy rate-limit semantics
Narrow the Naver Maps proxy contract to JSON reverse geocode responses and preserve upstream quota signals so client fallback can make accurate decisions.

Constraint: PR #282 review requested TDD fixes for XML contract mismatch, upstream 429 mapping, lint coverage, and route option documentation.

Rejected: XML passthrough in this follow-up | It would require a separate response-shaping contract and tests beyond the JSON proxy boundary.

Confidence: high

Scope-risk: narrow

Directive: Keep Naver Maps auth failures sanitized as 503 without upstream body snippets while preserving non-auth diagnostic snippets.

Tested: node --test packages/k-skill-proxy/test/server.test.js; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; PYENV_VERSION=3.12.0 npm run ci; architect verification CLEAR

Not-tested: Live NCP Maps calls with production credentials

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-23 19:57:21 +09:00
Jeffrey (Dongkyu) Kim
366d346f03 Keep Kakao route contracts local and explicit
Constraint: PR #283 review requested TDD fixes for Kakao Local distance sorting, Mobility toll avoidance, lint coverage, and coord2region routing coverage.
Rejected: Relying on upstream Kakao validation for sort=distance | it spends quota and returns a proxy/upstream error instead of local bad_request.
Rejected: Document-only toll avoidance correction | the skill already promises the behavior and Kakao Mobility exposes an explicit avoid option.
Confidence: high
Scope-risk: narrow
Directive: Preserve server-side KAKAO_REST_API_KEY injection only; never accept or forward caller apiKey query values.
Tested: node --test packages/k-skill-proxy/test/server.test.js; npm test --workspace k-skill-proxy; npm run lint --workspace k-skill-proxy; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; manual Fastify inject smoke for sort=distance and avoid forwarding; npm run ci through lint/typecheck until local Python pyexpat failure.
Not-tested: Full npm run ci completion due local Python 3.14 pyexpat ImportError during pip install.
2026-05-23 19:25:10 +09:00
Jeffrey (Dongkyu) Kim
73c3611e8a Protect Naver Maps credential boundary
Sanitize auth-failure upstream bodies while retaining non-auth diagnostics for operator debugging.

Constraint: PR #282 review requires Naver Maps 401/403 bodies to be hidden from public callers
Rejected: Blanket removal of all upstream snippets | non-auth 5xx diagnostics are still useful and covered by regression
Confidence: high
Scope-risk: narrow
Directive: Keep 401/403 response bodies out of public Naver Maps proxy payloads
Tested: node --test packages/k-skill-proxy/test/server.test.js; PYENV_VERSION=3.12.0 npm run ci; mocked app injection for 401 response
Not-tested: Live NCP Maps auth failure against production credentials
2026-05-23 18:07:19 +09:00
Jeffrey (Dongkyu) Kim
45084293f0
Feature/#270 (#281)
* Enable deterministic Middle Korean-style rewriting

Constraint: Issue #270 requested a new skill that converts incoming Korean text into 한국 중세 국어 style under non-interactive TDD automation.
Rejected: LLM-only prompt guidance | It would not provide deterministic CLI behavior or regression-testable output.
Confidence: high
Scope-risk: narrow
Directive: Keep this as creative style conversion, not an academically exact Middle Korean translator.
Tested: node --test scripts/test_korean_middle_korean.js; npm run lint; npm run typecheck; root node/python/workspace tests without pip bootstrap; npm run pack:dry-run; installed-skill smoke.
Not-tested: npm run test bootstrap step because python3 -m pip fails in this local Homebrew Python 3.14 environment due pyexpat/libexpat symbol mismatch before tests start.

* Align Middle Korean profile contract with implementation

Preserve the existing date-before-lexicon transform order and document it as the v1 contract instead of reordering an already-reviewed helper.

Constraint: PR #281 review requested the docs/contract mismatch be resolved with TDD evidence.

Rejected: Reordering the converter | would alter current output behavior beyond the approved follow-up.

Confidence: high

Scope-risk: narrow

Directive: Treat middle-korean-style-v1 output-changing rule order edits as contract changes that need regression tests and docs updates.

Tested: node --test scripts/test_korean_middle_korean.js; npm run lint; npm run typecheck; npm run pack:dry-run; npm run test without pip bootstrap commands; installed-skill smoke.

Not-tested: npm run test direct bootstrap remains blocked locally by Homebrew Python 3.14 pyexpat/libexpat symbol mismatch.

* Clarify middle Korean profile stability

Align the documented v1 contract with the intentionally broad deterministic replacer so future readers do not infer exact proper-noun preservation.

Constraint: PR #281 round 2 architect WATCH asked to weaken preservation guarantees or add stronger rule boundaries.

Rejected: Changing replacement behavior | The PR already verified the creative v1 output and only the contract wording was mismatched.

Confidence: high

Scope-risk: narrow

Directive: Treat output-changing edits to middle-korean-style-v1 as compatibility-affecting and update docs plus regression tests together.

Tested: node --test scripts/test_korean_middle_korean.js; node --check korean-middle-korean/scripts/korean_middle_korean.js; node --check scripts/korean_middle_korean.js; npm run lint; npm run typecheck; root/workspace post-bootstrap test chain; npm run pack:dry-run; installed skill smoke.

Not-tested: Direct npm run test still blocked before tests by local Homebrew Python 3.14 pyexpat/libexpat symbol mismatch.

* Protect structural spans during style conversion

Keep arbitrary text links, email addresses, and code spans usable while preserving the deterministic middle-korean-style-v1 prose transform.\n\nConstraint: PR #281 round 3 requested URL/email/code-like span protection after broad global replacement probes rewrote structural tokens.\nRejected: Narrowing all lexicon and particle rules with word-boundary heuristics | would change established v1 creative broad-replacement behavior beyond the reviewed issue.\nConfidence: high\nScope-risk: narrow\nDirective: Protect new structural span classes before broad replacements and add regression tests before extending the protected surface.\nTested: node --test scripts/test_korean_middle_korean.js; node --check korean-middle-korean/scripts/korean_middle_korean.js; node --check scripts/korean_middle_korean.js; CLI URL/email/code/Markdown probes; installed-skill smoke via ~/.agents/skills/korean-middle-korean/scripts/korean_middle_korean.js; npm run lint; npm run typecheck; root/workspace test chain without pip bootstrap; npm run pack:dry-run; post-deslop npm run lint && npm run typecheck && node --test scripts/test_korean_middle_korean.js && npm run pack:dry-run\nNot-tested: direct npm run test remains blocked before repo tests by local Homebrew Python 3.14 pyexpat/libexpat import error.
2026-05-23 17:54:56 +09:00
Jeffrey (Dongkyu) Kim
6d49a28d87 feat(kakao-map): Kakao Local/Mobility 프록시 라우트 + 장소·자동차 길찾기 스킬 (#267)
- packages/k-skill-proxy:
  - /v1/kakao-map/search/keyword (좌표 중심·반경·카테고리 필터)
  - /v1/kakao-map/search/category (좌표 중심 필수, FD6/CE7 등 공식 코드 화이트리스트)
  - /v1/kakao-map/coord2address (좌표→도로명/지번)
  - /v1/kakao-map/coord2region (좌표→법정동/행정동)
  - /v1/kakao-mobility/directions (자동차 길찾기, priority/car_fuel/waypoints/alternatives 옵션)
  - 모두 운영자 KAKAO_REST_API_KEY 서버측 주입, caller apiKey 무시
- kakao-map 스킬 + docs/features/kakao-map.md 신규
- proxy 테스트 10건 신규 (헤더 주입·캐시, 좌표·반경·정렬·카테고리·priority 검증, 503 missing-key 매트릭스, semantic failure non-cache, health 플래그)
- README/포함된 기능, packages/k-skill-proxy/README, docs/sources, changeset 동시 갱신

Closes #267
2026-05-23 17:51:06 +09:00
Jeffrey (Dongkyu) Kim
ff2aa91f83 feat(naver-map-route): NCP Maps Directions/Geocode/Reverse-Geocode 프록시 라우트 + MVP 길찾기 스킬 (#268)
- packages/k-skill-proxy: NAVER_MAP_CLIENT_ID/SECRET 서버측 보관, /v1/naver-map/{directions,geocode,reverse-geocode} 라우트 3종 추가
- naver-map-route: instruction-level MVP 스킬 (mock 기본, ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true + ROUTE_PLANNER_PROVIDER=naver 에서만 live)
- /route, /이동루트 수동 입력 처리, graceful fallback 정책 문서화
- proxy 테스트 8건 신규 (missing-key 503, 캐시, 좌표 검증, semantic failure non-cache, auth error sanitize, geocode 헤더 주입, reverse-geocode orders 검증, health 플래그)
- README 표/포함된 기능, packages/k-skill-proxy/README, docs/features/naver-map-route, docs/sources, changeset 동시 갱신

Closes #268
2026-05-23 17:36:55 +09:00
Jeffrey (Dongkyu) Kim
876077c7c9
Feature/#26269601403 (#280)
* ci: bump GHA actions to Node.js 24 runtime majors

GitHub Actions runner는 2026-06-02부터 Node.js 20 기반 action들을 강제로
Node.js 24로 돌리고, 2026-09-16에는 Node.js 20 자체를 runner에서 제거한다.
2026-05-22 Deploy k-skill-proxy run #26269601403에서 deprecation annotation
관측: 'actions/checkout@v4, google-github-actions/setup-gcloud@v2 ...running
on Node.js 20'.

이번 핫픽스는 우리 모든 워크플로의 action pin을 명시적으로 Node 24 major로
올려둔다. node24 runtime 확인은 action repo의 action.yml runs.using 값을
직접 조회해 검증했다.

변경 (10 replacements across 5 workflows):

- actions/checkout: v4 -> v5  (v5/v6 모두 node24, 안정 stable인 v5 채택)
- actions/setup-node: v4 -> v5  (v4은 node20, v5+가 node24)
- google-github-actions/setup-gcloud: v2 -> v3  (v2은 node20, v3이 node24)

이미 node24인 채로 pin돼 있어 손대지 않은 항목 (sanity):

- google-github-actions/auth@v3       (v3 = node24)
- google-github-actions/deploy-cloudrun@v3 (v3 = node24)
- changesets/action@v1                (v1.8.0 = node24, major pin이 자동 follow)
- googleapis/release-please-action@v4 (v4.4.1 = node24, major pin이 자동 follow)

검증:

- yaml grammar는 ast-grep yaml replace로 보존 (10 surgical replacements only).
- runtime은 'gh api .../action.yml | grep using:' 으로 모든 새 ref가 node24임을
  실제 확인. 추측 없음.
- 머지 후 첫 deploy run에서 deprecation annotation이 사라지는지 최종 검증.

* Keep workflow actions ahead of Node 20 removal

Release-please was still pinned to a Node 20 runtime major in the Python release scaffold, so the workflow set was not fully clean for the runner cutoff. Add a workflow regression test to keep the reviewed action majors on Node 24 refs.

Constraint: GitHub-hosted runners begin forcing Node 20 actions to Node 24 on 2026-06-02 and remove Node 20 on 2026-09-16.

Rejected: Leaving release-please-action@v4 as scaffold-only | it would become a latent release workflow break once Python packages are added.

Confidence: high

Scope-risk: narrow

Directive: Keep workflow action runtime-major claims backed by action.yml metadata checks and regression tests.

Tested: node --test scripts/workflow-actions.test.js; Ruby YAML.load_file for workflows; direct GitHub API action.yml runs.using checks; npm run ci attempted through lint/typecheck before local pip pyexpat blocker; equivalent Node/Python/workspace tests, validate-skills.sh, and npm run pack:dry-run passed.

Not-tested: npm run ci end-to-end due local Homebrew Python 3.14 pyexpat dynamic-link failure during pip install.

* Protect workflow action guardrails from commented uses refs

The Node 24 migration guard should catch reviewed stale action majors even when a valid workflow line carries an inline comment or YAML quotes. Reuse one extractor for fixtures and real workflow scans so the regression covers production behavior.\n\nConstraint: PR #279 review round 3 found inline-commented uses lines could be skipped by the text extractor.\nRejected: Full YAML parser adoption | unnecessary for the bounded guardrail and would add complexity to a no-dependency test.\nConfidence: high\nScope-risk: narrow\nDirective: Keep workflow action runtime guardrails deterministic and dependency-free unless broad runtime metadata validation is explicitly required.\nTested: node --test scripts/workflow-actions.test.js; npm run lint; npm run typecheck; direct root/workspace/Python test segments; validate-skills; pack:dry-run; git diff --check; package.json JSON parse.\nNot-tested: npm run test end-to-end past the initial pip install gate because local Homebrew Python 3.14 pyexpat linkage fails before repo tests run.

* Clarify curated workflow action runtime guard

Document the reviewed scope behind the Node runtime action guard so future maintainers do not mistake the hotfix inventory for exhaustive workflow enforcement.

Constraint: Follow-up to PR #280 review watchlist; keep behavior scoped to the reviewed Node 20 to Node 24 action migration set.

Rejected: Broadening the guard to every external action | outside this hotfix scope and explicitly deferred by review.

Confidence: high

Scope-risk: narrow

Directive: Expand the source URL map and tests if the guard becomes comprehensive runtime enforcement.

Tested: node --test scripts/workflow-actions.test.js; npm run lint; npm run typecheck; git diff --check; python3 -m json.tool package.json; downstream direct test fragments; workspace tests; ./scripts/validate-skills.sh; npm run pack:dry-run

Not-tested: npm run test remains blocked before repo tests by local Homebrew Python 3.14 pyexpat/pip import linker error
2026-05-23 10:25:24 +09:00
Jeffrey (Dongkyu) Kim
e6d7072e93
Feature/#274 (#277)
* Add Seoul Bike live station lookup

Expose narrow Seoul Open Data proxy surfaces for realtime bike availability, station master pages, and coordinate-based nearby lookups while keeping the upstream key server-side. Add a single Python skill entrypoint plus docs so agents can answer last-mile bike and dock availability questions.

Constraint: Issue #274 requires , TDD, three proxy routes, branch feature/#274, and PR to dev.
Rejected: Client-side Seoul OpenAPI key handling | would leak upstream credentials and violate existing proxy patterns.
Confidence: high
Scope-risk: moderate
Directive: Keep these routes read-only; do not add rental/booking mutations or user-key requirements.
Tested: node --test packages/k-skill-proxy/test/server.test.js --test-name-pattern 'seoul bike'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_seoul_bike; local fake-proxy smoke run; PATH="/Users/jeffrey/.pyenv/versions/3.11.9/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg08RBix6:/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: Live hosted Seoul Open Data request with production SEOUL_OPEN_API_KEY.

* Prevent Seoul Bike upstream errors from masquerading as empty availability

Constraint: Seoul Open API can return application-level error JSON with HTTP 200, so proxy routes must inspect RESULT envelopes before caching or normalizing rows.
Rejected: Treating missing rentBikeStatus.row as an empty success | it masks quota/service failures and caches false no-station results.
Confidence: high
Scope-risk: narrow
Directive: Preserve non-cacheable proxy error behavior for Seoul Open API semantic failures across realtime, stations, and nearby routes.
Tested: node --test packages/k-skill-proxy/test/server.test.js --test-name-pattern 'seoul bike'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_seoul_bike; local fake-proxy seoul_bike.py nearby smoke; PATH="/Users/jeffrey/.pyenv/versions/3.11.9/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg0j0fIum:/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; architect review APPROVED.
Not-tested: Live Seoul Open API error response from production service.

* Reject ambiguous Seoul Bike integer input

Tighten the public Seoul Bike query boundary so malformed integer strings cannot be partially parsed into valid requests.

Constraint: PR #277 review found parseInt accepted partially numeric query values on Seoul Bike routes.\nRejected: Keep parseInt with bounds checks | bounds still allow misleading values like 10abc and 1.5.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Seoul Bike public query aliases strict; do not reintroduce partial numeric parsing.\nTested: node --test packages/k-skill-proxy/test/server.test.js --test-name-pattern 'seoul bike'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_seoul_bike; explicit app.inject invalid-query smoke; PATH="/Users/jeffrey/.pyenv/versions/3.11.9/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg0uv50Mt:/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\nNot-tested: live hosted Seoul Open API traffic

* Protect hosted Seoul Bike proxy secrets

Sanitize Seoul Bike upstream fetch and parse failures before they can reach the global error handler, and reject blank nearby coordinates before JavaScript can coerce them to zero.\n\nConstraint: PR #277 round-3 review found server-side Seoul Open API keys could leak through exception messages containing keyed upstream URLs.\nRejected: Letting the global error handler format Seoul Bike upstream exceptions | it echoes exception messages and can expose the hosted proxy API key.\nConfidence: high\nScope-risk: narrow\nDirective: Keep server-side API-key-bearing upstream URLs out of client-visible error messages and logs for hosted no-user-key routes.\nTested: node --test packages/k-skill-proxy/test/server.test.js --test-name-pattern 'seoul bike'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_seoul_bike; explicit app.inject smoke for sanitized Seoul Bike failures and blank coordinates; local fake-proxy seoul-bike nearby smoke; PATH="/Users/jeffrey/.pyenv/versions/3.11.9/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg0mxZmWx:/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.\nNot-tested: Live Seoul Open API network failure from production Cloud Run.
2026-05-22 13:54:36 +09:00
github-actions[bot]
6551004967
chore: version packages (#278)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-22 11:42:03 +09:00
Jeffrey (Dongkyu) Kim
01cd887579
release: dev → main — Cloud Run 자동 배포 전환 + 신규 스킬 다수 (#276)
* 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.

* ci(k-skill-proxy): replace local pm2+cloudflared with Cloud Run auto-deploy via GitHub Actions

main에 머지되면 GitHub Actions가 자동으로 Workload Identity Federation으로 GCP 인증 후
Artifact Registry에 컨테이너 이미지를 빌드/푸시하고 Cloud Run(asia-northeast1) 서비스
k-skill-proxy를 재배포한다. 시크릿은 GCP Secret Manager에서 런타임에 주입된다.

- add .github/workflows/deploy-k-skill-proxy.yml (WIF, on push to main)
- add packages/k-skill-proxy/Dockerfile (multi-stage node:20-alpine, port bridge)
- add docs/deploy-k-skill-proxy.md (1회성 GCP 셋업 + 운영 점검 절차)
- remove ecosystem.config.cjs (PM2 root config)
- remove scripts/run-k-skill-proxy.sh (local secrets.env source + node launcher)
- remove wrangler devDependency (unused Cloudflare Workers CLI)
- update AGENTS.md, CLAUDE.md, CONTRIBUTING.md, docs/features/k-skill-proxy.md,
  packages/k-skill-proxy/README.md to describe the new Cloud Run + GHA flow
- clean dead k-skill-proxy-cloudrun entries from .gitignore

* docs(AGENTS): proxy 운영 전반(회전·롤백·비상 수동 배포 포함) docs/deploy-k-skill-proxy.md 참고 명시

* test(skill-docs): update stale CONTRIBUTING.md assertion for Cloud Run migration

80e7805(ci(k-skill-proxy): replace local pm2+cloudflared with Cloud Run auto-deploy)
가 CONTRIBUTING.md의 '프록시 서버 개발과 배포' 섹션을 Cloud Run + GCP Secret
Manager 흐름으로 다시 썼는데, 같은 섹션을 검증하는 skill-docs.test.js의 어서션은
구버전(`~/.local/share/k-skill-proxy`) 그대로였다. PR #276 CI에서 이 stale
어서션이 fail하여 머지를 막고 있었다.

기존 한 줄 regex(localhost 시크릿 경로)를 새 사실에 맞춰 두 개의 어서션으로 교체:

1. 프로덕션이 Google Cloud Run(asia-northeast1) + k-skill-proxy.nomadamas.org에서
   운영된다는 문구를 강제한다.
2. 시크릿이 GCP Secret Manager에 있고 운영 점검 절차가
   docs/deploy-k-skill-proxy.md에 있다는 문구를 강제한다.

이렇게 하면 문서가 다시 옛 로컬 흐름으로 돌아가거나 운영 가이드 링크가 빠지는
회귀가 발생할 때 CI가 잡아준다.

---------

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>
2026-05-22 11:40:40 +09:00
Jeffrey (Dongkyu) Kim
6dc9d9d9c6 test(skill-docs): update stale CONTRIBUTING.md assertion for Cloud Run migration
80e7805(ci(k-skill-proxy): replace local pm2+cloudflared with Cloud Run auto-deploy)
가 CONTRIBUTING.md의 '프록시 서버 개발과 배포' 섹션을 Cloud Run + GCP Secret
Manager 흐름으로 다시 썼는데, 같은 섹션을 검증하는 skill-docs.test.js의 어서션은
구버전(`~/.local/share/k-skill-proxy`) 그대로였다. PR #276 CI에서 이 stale
어서션이 fail하여 머지를 막고 있었다.

기존 한 줄 regex(localhost 시크릿 경로)를 새 사실에 맞춰 두 개의 어서션으로 교체:

1. 프로덕션이 Google Cloud Run(asia-northeast1) + k-skill-proxy.nomadamas.org에서
   운영된다는 문구를 강제한다.
2. 시크릿이 GCP Secret Manager에 있고 운영 점검 절차가
   docs/deploy-k-skill-proxy.md에 있다는 문구를 강제한다.

이렇게 하면 문서가 다시 옛 로컬 흐름으로 돌아가거나 운영 가이드 링크가 빠지는
회귀가 발생할 때 CI가 잡아준다.
2026-05-21 16:11:45 +09:00
Jeffrey (Dongkyu) Kim
1d6f97bb8a Merge branch 'main' into dev: resolve release conflicts + drop pm2 leftovers
PR #271 + #272로 main에 신규 스킬 6종 + version bump가 이미 머지되어
같은 .changeset/*.md 와 package.json 이 양쪽에서 충돌. Resolution:

- .changeset/*.md : main 채택(이미 consume된 changeset 삭제 유지)
- packages/*/package.json (emergency-room-beds, local-election-candidate-search,
  sh-notice-search) : main의 bump된 버전(0.2.0) 채택
- packages/*/CHANGELOG.md : main 채택 (release-please/changeset이 생성한 내용 유지)
- root package.json : dev 채택 (8d52850 'fix spacing in package.json' 의 올바른
  4-space 들여쓰기 유지. main은 indentation fix가 lost된 상태였음)

추가 정리:
- 80e7805 'replace local pm2+cloudflared with Cloud Run' 커밋이 메시지엔
  'remove ecosystem.config.cjs / scripts/run-k-skill-proxy.sh' 라 적었으나
  실제 git rm 이 누락돼 있었음. 이번 merge 커밋에서 같이 제거.
2026-05-21 15:46:48 +09:00
Jeffrey (Dongkyu) Kim
9164835e9e docs(AGENTS): proxy 운영 전반(회전·롤백·비상 수동 배포 포함) docs/deploy-k-skill-proxy.md 참고 명시 2026-05-21 14:52:43 +09:00
Jeffrey (Dongkyu) Kim
80e7805681 ci(k-skill-proxy): replace local pm2+cloudflared with Cloud Run auto-deploy via GitHub Actions
main에 머지되면 GitHub Actions가 자동으로 Workload Identity Federation으로 GCP 인증 후
Artifact Registry에 컨테이너 이미지를 빌드/푸시하고 Cloud Run(asia-northeast1) 서비스
k-skill-proxy를 재배포한다. 시크릿은 GCP Secret Manager에서 런타임에 주입된다.

- add .github/workflows/deploy-k-skill-proxy.yml (WIF, on push to main)
- add packages/k-skill-proxy/Dockerfile (multi-stage node:20-alpine, port bridge)
- add docs/deploy-k-skill-proxy.md (1회성 GCP 셋업 + 운영 점검 절차)
- remove ecosystem.config.cjs (PM2 root config)
- remove scripts/run-k-skill-proxy.sh (local secrets.env source + node launcher)
- remove wrangler devDependency (unused Cloudflare Workers CLI)
- update AGENTS.md, CLAUDE.md, CONTRIBUTING.md, docs/features/k-skill-proxy.md,
  packages/k-skill-proxy/README.md to describe the new Cloud Run + GHA flow
- clean dead k-skill-proxy-cloudrun entries from .gitignore
2026-05-21 13:45:06 +09:00
github-actions[bot]
34a0928edd
chore: version packages (#272)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-19 11:10:07 +09:00
Jeffrey (Dongkyu) Kim
271ea185c4
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>
2026-05-19 11:08:10 +09:00
Jeffrey (Dongkyu) Kim
2f68b1ab4b 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.
2026-05-19 00:21:21 +09:00
Jeffrey (Dongkyu) Kim
7c2dc59c6c 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.
2026-05-18 23:29:09 +09:00
Jeffrey (Dongkyu) Kim
68abad3de0
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>
2026-05-18 23:11:23 +09:00
Jeffrey (Dongkyu) Kim
5b08b4c86e
Merge pull request #263 from NomaDamas/feature/#257
Feature/#257
2026-05-18 21:18:56 +09:00
Jeffrey (Dongkyu) Kim
6831b3147e
Merge pull request #264 from lee-ji-hong/feature/ohou-today-skill
오늘의딜 특가 상품 조회 스킬 추가
2026-05-18 17:52:13 +09:00
Jeffrey (Dongkyu) Kim
4a78169220 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.
2026-05-18 16:36:56 +09:00
Jeffrey (Dongkyu) Kim
7e95ac69ab
Merge pull request #265 from NomaDamas/fix/qa-bot-test-prompts
fix(qa-bot): hardcoded test_prompt for input-driven skills and negative-case judge rubric
2026-05-18 15:54:17 +09:00
Jeffrey (Dongkyu) Kim
cf8e96acdc 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)
2026-05-18 15:53:26 +09:00
lee-ji-hong
8d52850fec fix spacing in package.json 2026-05-18 15:49:20 +09:00
lee-ji-hong
ca5aefd990 Add Ohou today deal skill 2026-05-18 15:40:18 +09:00
Jeffrey (Dongkyu) Kim
b46bb6a0d7
Merge pull request #261 from NomaDamas/fix/qa-bot-judge-gpt-5.5
fix(qa-bot): upgrade judge to gpt-5.5 and bypass codex sandbox
2026-05-18 14:31:44 +09:00
Jeffrey (Dongkyu) Kim
136d2afce1 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.
2026-05-18 14:26:32 +09:00
배기민
540e80b804
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>
2026-05-18 11:43:33 +09:00
hmmhmmhm/
e5b4465630
영화관 검색 스킬 추가 (#260)
* Add korean cinema search skill

* Document playDate for cinema skill
2026-05-18 11:42:40 +09:00
Jeffrey (Dongkyu) Kim
0721252fb6
Merge pull request #258 from NomaDamas/feature/#255
Feature/#255
2026-05-17 23:04:10 +09:00
Jeffrey (Dongkyu) Kim
638be3bad9
Merge pull request #257 from NomaDamas/feat/k-skill-qa-bot
feat(qa-bot): add k-skill-qa-bot under tools/
2026-05-17 22:41:46 +09:00
Jeffrey (Dongkyu) Kim
8838131565 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.
2026-05-17 22:40:26 +09:00
Jeffrey (Dongkyu) Kim
4c7bbc0bd3 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>
2026-05-17 19:00:44 +09:00
Jeffrey (Dongkyu) Kim
4e2d1faf19 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.
2026-05-17 18:37:07 +09:00
Jeffrey (Dongkyu) Kim
7f73e55011 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.
2026-05-17 18:24:11 +09:00
Jeffrey (Dongkyu) Kim
5591502f9e
Merge pull request #253 from Combi153/feature/#252
fix(danawa-price-search): .ico.* 결제조건 배지 캡처해 row 단위 라벨로 노출
2026-05-17 16:27:54 +09:00
Jeffrey (Dongkyu) Kim
2227a3bd60 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.
2026-05-17 16:25:33 +09:00
Jeffrey (Dongkyu) Kim
fe0f122e84 Revert "ci: install beautifulsoup4 so danawa price search tests can import bs4"
This reverts commit 8330e5adf7.
2026-05-17 16:24:49 +09:00
Jeffrey (Dongkyu) Kim
fadc23c3ff Merge branch 'dev' into feature/#252
Resolves package.json conflict by keeping both:
- test: scripts.test_danawa_price_search (from this PR)
- pack:dry-run: sh-notice-search workspace (from dev #251)
2026-05-17 16:19:47 +09:00
Jeffrey (Dongkyu) Kim
8330e5adf7 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.
2026-05-17 16:17:27 +09:00
Jeffrey (Dongkyu) Kim
0895136e51
Merge pull request #251 from NomaDamas/feature/#207
Feature/#207
2026-05-17 16:16:23 +09:00
Jeffrey (Dongkyu) Kim
e066742b47
Merge pull request #254 from NomaDamas/feature/#246
Feature/#246
2026-05-17 16:15:53 +09:00
Jeffrey (Dongkyu) Kim
dc6cf4b879 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.
2026-05-15 19:57:25 +09:00
Jeffrey (Dongkyu) Kim
f26efea98d 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.
2026-05-15 19:46:27 +09:00
Jeffrey (Dongkyu) Kim
f139d604cf 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.
2026-05-15 19:35:03 +09:00
Jeffrey (Dongkyu) Kim
c83e194a84 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.
2026-05-15 19:22:55 +09:00
Jeffrey (Dongkyu) Kim
5a6dcedb99
Merge pull request #249 from NomaDamas/feature/#248
Feature/#248
2026-05-15 18:02:04 +09:00
Jeffrey (Dongkyu) Kim
945bd32296 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.
2026-05-15 17:50:58 +09:00
Jeffrey (Dongkyu) Kim
56a00005ef 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.
2026-05-15 17:33:48 +09:00
chanmin
5ff8859b2a 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>
2026-05-15 17:12:23 +09:00
Jeffrey (Dongkyu) Kim
1476011e4f 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.
2026-05-15 16:35:16 +09:00
Jeffrey (Dongkyu) Kim
eb83296cc6 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.
2026-05-15 16:18:13 +09:00
Jeffrey (Dongkyu) Kim
d7263a54b9 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.
2026-05-15 16:04:31 +09:00
arnold714
2641f43863 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>
2026-05-15 15:33:24 +09:00
Jeffrey (Dongkyu) Kim
47ed22b076
Merge pull request #247 from NomaDamas/fix/readme-flight-ticket-search
docs(flight-ticket-search): register skill in README table and add feature guide
2026-05-15 01:36:50 +09:00
Jeffrey (Dongkyu) Kim
80303f55f4
Merge pull request #245 from NomaDamas/changeset-release/main
chore: version packages
2026-05-15 01:36:35 +09:00
Jeffrey (Dongkyu) Kim
6df974df36 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 불필요.
2026-05-15 01:35:43 +09:00
github-actions[bot]
94e4d81f0b chore: version packages 2026-05-14 15:43:24 +00:00
Jeffrey (Dongkyu) Kim
9cb2ea037e
Merge pull request #244 from NomaDamas/dev
Sync dev → main: 신규 스킬 8종 + NTS proxy 라우팅 + k-skill-setup 보강
2026-05-15 00:42:35 +09:00
Jeffrey Han
d965c2ab62 Ship seoul-density script next to its SKILL.md
SKILL.md instructs callers to run $SKILL_DIR/scripts/seoul_density.py but
the script only lived under the repo-root scripts/ tree, so the skill
broke as soon as it was synced into ~/.claude/skills/seoul-density or
~/.agents/skills/seoul-density. Mirror the file into the skill directory
to match the pattern used by nts-business-registration, ticket-availability,
and the daangn-* skills, restoring the single-entrypoint flow described
in the SKILL.md.
2026-05-15 00:32:54 +09:00
Jeffrey (Dongkyu) Kim
9287ce1418
Merge pull request #239 from NomaDamas/feature/#228
Feature/#228
2026-05-15 00:22:59 +09:00
Jeffrey (Dongkyu) Kim
8baf3adc23 Merge branch 'dev' into feature/#228 2026-05-15 00:19:51 +09:00
Jeffrey (Dongkyu) Kim
729a94071a
Merge pull request #241 from Romano1994/feat/seoul-density
Add seoul-density skill and proxy route for Seoul realtime hotspot crowd levels
2026-05-15 00:18:08 +09:00
Jeffrey (Dongkyu) Kim
2a3877381b
Merge pull request #243 from NomaDamas/feature/#242
Feature/#242
2026-05-15 00:14:47 +09:00
Jeffrey (Dongkyu) Kim
641d96b8fc Harden NTS validate privacy boundary
Prevent proxy exception messages from exposing upstream URLs, align validate field bounds across proxy and Python helpers, and make the hosted validate privacy path explicit in docs.

Constraint: non-interactive PR #243 follow-up with no production DATA_GO_KR_API_KEY authority.

Rejected: returning raw upstream fetch errors | could leak serviceKey if custom fetch/proxy errors include full URLs.

Rejected: leaving helper-copy drift to manual cmp checks | behavior test now loads the skill-local helper directly.

Confidence: high

Scope-risk: narrow

Directive: keep validate uncached and avoid echoing representative/date/address inputs in proxy responses.

Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_nts_business_registration; npm run test --workspace k-skill-proxy -- --test-name-pattern 'NTS business'; mocked fetch-exception smoke; git diff --check origin/dev...HEAD; npm run ci

Not-tested: live data.go.kr calls, no production DATA_GO_KR_API_KEY authority
2026-05-14 22:20:33 +09:00
Jeffrey (Dongkyu) Kim
521524edeb Harden NTS validate privacy boundaries
Keep status lookups cacheable while making authenticity validation non-cacheable and redacting validate-only sensitive fields from proxy-shaped and upstream-echoed responses. Treat semantic NTS non-OK payloads as upstream errors so transient service failures are not cached.

Constraint: Review follow-up required TDD for privacy-sensitive validate behavior and semantic upstream failures.

Rejected: Reusing the status response-shaping path for validate | it retains or echoes representative/date/address inputs beyond the upstream request.

Confidence: high

Scope-risk: narrow

Directive: Do not re-enable validate success caching or echo full normalized validate inputs without a fresh privacy review.

Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_nts_business_registration; npm run test --workspace k-skill-proxy -- --test-name-pattern 'NTS business'; mocked validate smoke for no-cache/redaction; npm run ci

Not-tested: Live data.go.kr NTS calls; no production DATA_GO_KR_API_KEY authority in automation.
2026-05-14 21:55:32 +09:00
Jeffrey (Dongkyu) Kim
cd3366a9dc Route NTS business checks through the proxy
Add the NTS business registration skill and proxy endpoints so agents can verify business-number status and authenticity without exposing data.go.kr keys to users.\n\nConstraint: data.go.kr publicDataPk=15081808 requires a server-side API key, so the route belongs behind k-skill-proxy.\nRejected: caller-supplied service keys | would violate the proxy credential boundary and duplicate user setup.\nConfidence: high\nScope-risk: moderate\nDirective: Keep future NTS fields normalized at the proxy boundary and never accept client serviceKey overrides.\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_nts_business_registration; npm run test --workspace k-skill-proxy -- --test-name-pattern 'NTS business'; buildServer smoke inject; npm run ci\nNot-tested: live data.go.kr request, because this session has no production DATA_GO_KR_API_KEY authority.
2026-05-14 21:40:26 +09:00
romano1994
315dbbb66b Add seoul-density skill and proxy route for Seoul realtime hotspot crowd levels 2026-05-14 15:37:54 +09:00
Jeffrey (Dongkyu) Kim
ca9a7df933
Keep dev CI/CD workflows plannable
Fix the Python release workflow and ticket helper import behavior so remote CI can run to completion after recent dev merges.

Constraint: Python release automation remains scaffold-only until python-packages/* contains a real pyproject.toml
Rejected: Installing ad-hoc Python dependencies in CI | the repository does not yet have a Python package dependency contract
Confidence: high
Scope-risk: narrow
Directive: Keep workflow-time package detection in a checked-out job, not job-level hashFiles guards
Tested: PR #240 GitHub Actions validate; local npm run ci
Not-tested: Actual PyPI publication because no Python package release exists yet
2026-05-14 12:31:04 +09:00
Jeffrey (Dongkyu) Kim
b7169efd95 Let ticket tests run without httpx installed
Make httpx a checked runtime dependency instead of an import-time requirement so CI can import and test the mocked ticket availability helpers in a clean Python environment.

Constraint: ticket availability runtime still uses httpx for live read-only endpoint calls
Rejected: Adding a repository-wide Python dependency installer | this repo has no concrete Python package dependency flow yet
Confidence: high
Scope-risk: narrow
Directive: Keep live ticket lookup dependency failures explicit at command execution time
Tested: python3 -m py_compile scripts/ticket_availability.py ticket-availability/scripts/ticket_availability.py; PYTHONPATH=. python3 -m unittest scripts.test_ticket_availability; npm run ci
Not-tested: live YES24/Interpark calls without httpx, expected to fail with dependency guidance
2026-05-14 12:30:05 +09:00
Jeffrey (Dongkyu) Kim
3f9aee6111 Make Python release workflow plan safely
Move Python package detection into an explicit setup job so GitHub Actions can plan the release workflow instead of failing before jobs/logs are created.

Constraint: Python release flow is scaffold-only until a real python-packages/* pyproject exists
Rejected: Keeping job-level hashFiles guards | GitHub reported zero-second workflow-file failures with no jobs or logs
Confidence: high
Scope-risk: narrow
Directive: Keep release-please publish work gated behind detected concrete Python package paths
Tested: ruby YAML parse; npm run ci
Not-tested: actual release-please publication because no Python package exists yet
2026-05-14 12:26:57 +09:00
Jeffrey (Dongkyu) Kim
a22cfdc3dc
Merge pull request #233 from NomaDamas/feature/#220
Feature/#220
2026-05-14 12:24:08 +09:00
Jeffrey (Dongkyu) Kim
1d310eec39 Rebase Gangnam Unni search onto latest dev
Keep the Gangnam Unni package dry-run coverage while incorporating the latest dev validation scripts.\n\nConstraint: PR #233 became conflicting after dev advanced with ticket availability and Daangn skills.\nRejected: Taking either package script side wholesale | would drop either Gangnam Unni pack coverage or current dev test coverage.\nConfidence: high\nScope-risk: narrow\nDirective: Preserve additive root script checks for independently merged skills.\nTested: package.json JSON parse; git diff --check.\nNot-tested: Full npm run ci pending after merge commit.
2026-05-14 12:23:09 +09:00
Jeffrey (Dongkyu) Kim
3ccb44afda Constrain report fetch credentials
Scope caller-owned GitHub credentials to API requests, add exact-file contents fallback for known report fetches, and report actual inspected detail attempts. This tightens the public mirror boundary without adding proxy auth or broadening release metadata.

Constraint: public GitHub mirror remains keyless by default; optional caller tokens must stay least-privilege.

Rejected: forwarding GitHub auth headers to all GitHub-operated hosts | raw.githubusercontent.com does not need API credentials for the verified path.

Confidence: high

Scope-risk: narrow

Directive: Keep optional credentials host-scoped unless a future caller explicitly opts into raw-host forwarding.

Tested: npm run lint --workspace daishin-report-search; npm run test --workspace daishin-report-search; npm pack --workspace daishin-report-search --dry-run; npm run ci; injected raw/API header and contents fallback smoke; live exact-report and latest-list CLI smokes; architect/code-reviewer verification.

Not-tested: authenticated live GitHub token path with a real token.
2026-05-14 09:57:45 +09:00
Jeffrey (Dongkyu) Kim
3e1d3a21a2 Make report discovery failures actionable
Constraint: GitHub tree discovery is public and keyless by default, so latest/search must not require the proxy or a token.\nRejected: Proxying the public GitHub API | violates the repo no-proxy policy for fully public endpoints.\nConfidence: high\nScope-risk: narrow\nDirective: Keep caller-supplied GitHub tokens optional and never persist or echo request headers in source errors.\nTested: npm run lint --workspace daishin-report-search; npm run test --workspace daishin-report-search; npm pack --workspace daishin-report-search --dry-run; npm run ci; live CLI latest structured 403 smoke; live exact-report smoke; injected rate/header/entity smokes; architect verification CLEAR.\nNot-tested: Authenticated live latest search with a real GitHub token.
2026-05-14 09:45:50 +09:00
Jeffrey (Dongkyu) Kim
448d0c8acc Bound report search inputs to protect public fetches
Clamp list sizing in the library, keep CLI values uncoerced until validation, and preserve malformed numeric entities so mirrored HTML cannot crash parsing. Document the limits and provenance caveat surfaced in review.

Constraint: GitHub mirror access is public and unauthenticated, so defensive client-side fetch bounds matter more than adding proxy/token behavior in this follow-up.
Rejected: Coercing CLI numeric flags with Number before library validation | this preserved Infinity/huge values and bypassed the shared bounds.
Confidence: high
Scope-risk: narrow
Directive: Keep future daishin-report-search listing options bounded before they drive raw GitHub fetch loops.
Tested: npm run lint --workspace daishin-report-search; npm run test --workspace daishin-report-search; npm pack --workspace daishin-report-search --dry-run; npm run ci; exact-report live smoke for 20260511082352 with includeExplain; local injected-fetch smoke for clamped CLI/library behavior and malformed entities.
Not-tested: live latest-list smoke remained blocked by upstream unauthenticated GitHub API 403 rate limiting.

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-14 09:32:36 +09:00
Jeffrey (Dongkyu) Kim
d48a962d91 Make Daishin reports discoverable from the public mirror
Constraint: Issue #228 requires a skill that discovers latest report pages from the provided GitHub Pages mirror and makes them agent-readable.\nRejected: Screen-scraping GitHub Pages directory listings | the GitHub recursive tree API is a more stable public index.\nConfidence: high\nScope-risk: narrow\nDirective: Keep this skill on public unauthenticated GitHub/raw endpoints unless upstream starts requiring an API key; do not add a proxy route for public pages.\nTested: npm run lint --workspace daishin-report-search; npm run test --workspace daishin-report-search; npm pack --workspace daishin-report-search --dry-run; npm run ci; live CLI list/detail smoke tests.\nNot-tested: Authenticated GitHub API higher-rate-limit path.
2026-05-14 09:19:28 +09:00
Jeffrey (Dongkyu) Kim
577091aa44
Merge pull request #234 from Hybirdss/feat/ticket-availability
feat(ticket-availability): YES24·인터파크 공연 일정·잔여석 조회 (조회 전용)
2026-05-14 00:58:16 +09:00
Jeffrey (Dongkyu) Kim
a5eb876511 Rebase ticket availability onto latest dev
Keep the ticket availability validation entries while incorporating the latest dev Daangn skill checks.\n\nConstraint: PR #234 became conflicting again after dev advanced.\nRejected: Taking dev package scripts unchanged | would drop ticket availability validation.\nConfidence: high\nScope-risk: narrow\nDirective: Preserve additive root script checks for independently merged skills.\nTested: package.json JSON parse; git diff --check.\nNot-tested: Full npm run ci pending after merge commit.
2026-05-14 00:57:09 +09:00
Jeffrey (Dongkyu) Kim
609f6150b5
Merge pull request #237 from taeyoung1005/feature/daangn-search-skills
feat: add Daangn search skills
2026-05-14 00:55:49 +09:00
Jeffrey (Dongkyu) Kim
3109b6684a Keep Daangn jobs detail resilient
Add an HTML metadata fallback because the public jobs detail _data route currently returns an empty response while the redirected public page still exposes read-only title/meta data.

Constraint: PR 237 must remain read-only and avoid proxy/auth additions for public Daangn surfaces
Rejected: Treating the 204 _data response as acceptable | it breaks the documented detail command
Confidence: high
Scope-risk: narrow
Directive: Keep Daangn jobs detail on public HTML/meta fallback unless a stable JSON detail surface is verified
Tested: npm run ci; live daangn_jobs.py search/detail smoke
Not-tested: authenticated or interactive Daangn actions, intentionally out of scope
2026-05-14 00:40:00 +09:00
Jeffrey (Dongkyu) Kim
45dcfd0897 Unblock ticket availability PR against current dev
Preserve the ticket availability checks while keeping the newer manus bundle CI additions from dev.\n\nConstraint: PR #234 was non-mergeable because root package scripts changed on both head and dev.\nRejected: Taking either side wholesale | would drop either ticket availability validation or manus bundle validation.\nConfidence: high\nScope-risk: narrow\nDirective: Keep root lint/test script additions additive when merging independent skills.\nTested: node JSON parse for package.json; git diff --check.\nNot-tested: Full npm run ci pending after merge commit.
2026-05-14 00:38:28 +09:00
Jeffrey (Dongkyu) Kim
860bf53ed3 Unblock Gangnam Unni PR against current dev
Preserve the PR's workspace release coverage while keeping the newer manus bundle test entry from dev.\n\nConstraint: PR #233 was non-mergeable because package.json changed on both head and dev.\nRejected: Taking either side wholesale | would drop either gangnamunni pack coverage or manus bundle test coverage.\nConfidence: high\nScope-risk: narrow\nDirective: Keep additive package script conflicts merged rather than replacing workspace entries.\nTested: node JSON parse for package.json; git diff --check.\nNot-tested: Full npm run ci pending after merge commit.
2026-05-14 00:36:55 +09:00
Jeffrey (Dongkyu) Kim
0ddb23d2af
Merge pull request #238 from NomaDamas/changeset-release/main
chore: version packages
2026-05-14 00:34:23 +09:00
github-actions[bot]
20522ab43c chore: version packages 2026-05-13 08:02:54 +00:00
Jeffrey (Dongkyu) Kim
3a4e409887
Merge pull request #232 from NomaDamas/dev
Merge dev into main
2026-05-13 17:02:08 +09:00
Jeffrey (Dongkyu) Kim
5da1f0e240 Make KOBUS seat holds reproducible for checkout handoff
Capture the verified KOBUS non-member flow in a reusable helper that searches schedules, creates a temporary seat hold, saves an official payment-page autosubmit helper, and records cancellation fields.

Constraint: KOBUS requires session-backed POST fields and returns pcpyNoAll/satsNoAll from setPcpy.ajax before checkout entry.
Rejected: Opening payment page by URL alone | stplcfmpym.do requires the selected schedule, fare, seat, and hold POST body.
Confidence: high
Scope-risk: narrow
Directive: Never submit card/payment fields automatically; cancel abandoned holds with cancPcpy.ajax.
Tested: python3 -m py_compile express-bus-booking/scripts/kobus_express_booking.py; live 서울 센트럴시티(021)→광주 유스퀘어(500) 20260520 --hold-first-seat returned MSG_CD=S0000 pcpyNoAll and rendered payment-info page; /mrs/cancPcpy.ajax returned MSG_CD=S0000; ./scripts/validate-skills.sh
Not-tested: final payment submission, mobile in-app browser behavior, mixed passenger discounts
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: OmX <omx@oh-my-codex.local>
2026-05-13 16:37:10 +09:00
Jeffrey (Dongkyu) Kim
53887e992f Make KOBUS seat holds reproducible for checkout handoff
Capture the verified KOBUS non-member flow in a reusable helper that searches schedules, creates a temporary seat hold, saves an official payment-page autosubmit helper, and records cancellation fields.

Constraint: KOBUS requires session-backed POST fields and returns pcpyNoAll/satsNoAll from setPcpy.ajax before checkout entry.
Rejected: Opening payment page by URL alone | stplcfmpym.do requires the selected schedule, fare, seat, and hold POST body.
Confidence: high
Scope-risk: narrow
Directive: Never submit card/payment fields automatically; cancel abandoned holds with cancPcpy.ajax.
Tested: python3 -m py_compile express-bus-booking/scripts/kobus_express_booking.py; live 서울 센트럴시티(021)→광주 유스퀘어(500) 20260520 --hold-first-seat returned MSG_CD=S0000 pcpyNoAll and rendered payment-info page; /mrs/cancPcpy.ajax returned MSG_CD=S0000; ./scripts/validate-skills.sh
Not-tested: final payment submission, mobile in-app browser behavior, mixed passenger discounts
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: OmX <omx@oh-my-codex.local>
2026-05-13 16:36:23 +09:00
Jeffrey (Dongkyu) Kim
49bf262bb9 Route shared key APIs through the proxy
Move KOSIS general lookups and Kakao Local geocoding behind k-skill-proxy so users do not need to manage those API keys for common skill flows. Keep KOSIS bigdata/direct calls user-keyed because userStatsId is account-specific.

Constraint: Free API proxy policy allows proxying upstreams that require API keys while keeping routes narrow, cache-backed, and public.

Rejected: Proxy ODsay transit routing | Basic quota is low, time-limited, and IP-whitelist-bound, so centralizing it would create quota and operations risk.

Confidence: high

Scope-risk: moderate

Directive: Keep KOSIS bigdata direct unless a per-user credential design is added; do not route broad Kakao surfaces without explicit allowlists and rate limits.

Tested: npm run ci; local KOSIS proxy smoke via /v1/kosis/search and /v1/kosis/meta; local Kakao proxy smoke via /v1/kakao-local/geocode q=서울역.

Not-tested: Production proxy deployment after main merge/cron update.
2026-05-13 16:31:29 +09:00
TaeyoungPark
95d5e9d05b docs: document Daangn search skills 2026-05-13 16:01:42 +09:00
TaeyoungPark
12adac4eb8 feat: add Daangn search skills 2026-05-13 15:48:49 +09:00
Jeffrey (Dongkyu) Kim
34127550fa Let intercity booking helper create temporary seat holds
Extend the Tmoney intercity helper from read-only timetable lookup to the browser-equivalent seat-stage and temporary hold flow, saving the official card-information page and cancel/back fields while still avoiding card submission.

Constraint: readPcpySats.do creates a live sats_Pcpy_Id hold, so abandoned test holds must be released with the official pcpyCanc=C back flow.

Rejected: Automating final payment | card submission is irreversible and remains a manual user action.

Confidence: high

Scope-risk: narrow

Directive: Treat holds as short-lived; hand off immediately and cancel abandoned holds.

Tested: python3 -m py_compile intercity-bus-booking/scripts/intercity_bus_search.py ~/.agents/skills/intercity-bus-booking/scripts/intercity_bus_search.py; live --hold-first-seat for 동서울→속초 20260520 produced sats_Pcpy_Id and card-info page; posted cancel/back fields and verified timetable remained 24/28; ./scripts/validate-skills.sh; node --test scripts/skill-docs.test.js; npm run lint

Not-tested: card info entry, final payment, mixed passenger hold payloads
2026-05-13 15:43:26 +09:00
Jeffrey (Dongkyu) Kim
b4aae5b295 Make intercity timetable lookup follow Tmoney form contract
Add a read-only timetable helper that starts a cookie-backed Tmoney session, submits the hidden browser fields required by readAlcnList.do, and parses readSasFeeInf schedule rows into JSON.

Constraint: Tmoney returns a generic errorCont page unless bef_Aft_Dvs and req_Rec_Num are posted with the timetable form.

Rejected: Browser automation-first lookup | official HTTP flow works when the browser-submitted hidden fields are included.

Confidence: high

Scope-risk: narrow

Directive: Do not automate final card submission or payment from this skill without explicit user confirmation.

Tested: python3 -m py_compile intercity-bus-booking/scripts/intercity_bus_search.py; python3 intercity-bus-booking/scripts/intercity_bus_search.py --depart-code 0511601 --arrive-code 2482701 --depart-name 동서울 --arrive-name 속초 --date 20260520 --limit 1; rsync to ~/.claude/skills and ~/.agents/skills; ./scripts/validate-skills.sh; node --test scripts/skill-docs.test.js; npm run lint

Not-tested: temporary seat hold, cancellation, card entry, and payment flows
2026-05-13 14:49:22 +09:00
Jeffrey (Dongkyu) Kim
dd11fa9d30 Resolve dev-to-main integration conflicts
Merged origin/main into dev for PR #232 while preserving the dev-side contribution guide, KOSIS/Danawa CI coverage, and new workspace pack checks alongside main's Manus bundle workflow and docs.

Constraint: PR #232 targets main from dev and GitHub reported mergeable=false.

Rejected: choosing either side wholesale | would drop either dev's new skill validation or main's Manus bundle automation.

Confidence: high

Scope-risk: narrow

Directive: Keep root package scripts as the union of active workspace/package checks when resolving future branch integrations.

Tested: npm run ci

Not-tested: live GitHub mergeability after push before remote checks complete

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-13 14:47:03 +09:00
Hybirdss
83079cd4c8 feat(ticket-availability): YES24·인터파크 공연 일정·잔여석 조회 (조회 전용)
- 공개 endpoint (YES24 axPerfDay/PlayTime/RemainSeat, 인터파크 playSeq/REMAINSEAT) 만 단일 HTTP 호출
- httpx only, CloakBrowser/Playwright 없음, 로그인·시크릿·쿠키 없음
- 예매·결제·좌석 선택·자동화 의도적 제외 (공연법 §4조의2 매크로 부정구매 형사처벌)
- 20 unit test (mocked httpx) + validate-skills.sh PASS
- README + docs/features 가이드 추가
2026-05-13 02:50:26 +09:00
Jeffrey (Dongkyu) Kim
6389b73e28 Record Gangnam Unni sources for auditability
Constraint: PR #233 round-2 review requested central docs/sources.md ledger coverage for the new public Gangnam Unni search surface.
Rejected: Broader skill/package changes | The approved follow-up only needed source-ledger docs and stable regression coverage.
Confidence: high
Scope-risk: narrow
Directive: Keep source-ledger tests focused on stable public URLs and do not assert package versions or changeset file presence.
Tested: node --test scripts/skill-docs.test.js; npm test --workspace gangnamunni-clinic-search; node packages/gangnamunni-clinic-search/src/cli.js "강남 성형외과" --limit 1; npm run ci pre- and post-deslop
Not-tested: CI on GitHub Actions
2026-05-13 02:29:07 +09:00
Jeffrey (Dongkyu) Kim
fe8cb7db6e Harden Gangnam Unni clinic lookup for review follow-up
Address PR review blockers by aligning install docs, preserving raw Next.js JSON parsing semantics, bounding upstream fetches, and reducing sensitive query leakage in errors.\n\nConstraint: Issue #220 follow-up required TDD, full CI, live CLI smoke, deslop pass, push to feature/#220, and one signed PR comment.\nRejected: Pre-decoding the entire __NEXT_DATA__ script body before JSON.parse | corrupts valid JSON strings containing literal entity-looking text.\nConfidence: high\nScope-risk: narrow\nDirective: Keep entity-decoded parsing as a tested compatibility fallback only; do not make it the primary parse path.\nTested: npm test --workspace gangnamunni-clinic-search; node --test scripts/skill-docs.test.js; node packages/gangnamunni-clinic-search/src/cli.js "강남 성형외과" --limit 1; npm run ci twice, including post-deslop.\nNot-tested: Browser-rendered Gangnam Unni UI beyond the public Next.js payload smoke.
2026-05-13 02:13:39 +09:00
Jeffrey (Dongkyu) Kim
da0632c73d Enable public Gangnam Unni clinic lookup
Add a read-only Gangnam Unni search skill and npm helper that parses the public Next.js search payload, documents the discovered access path, and keeps login/app-only medical actions out of scope.\n\nConstraint: Issue #220 requested a Gangnam Unni plastic-surgery clinic lookup skill with TDD and PR delivery.\nRejected: Proxy route | upstream is an unauthenticated public web surface, so proxy policy says direct user-machine calls.\nConfidence: high\nScope-risk: narrow\nDirective: Keep this skill read-only; do not add login, booking, consultation, payment, or medical-advice automation.\nTested: npm test --workspace gangnamunni-clinic-search; live CLI search for "강남 성형외과"; npm run ci twice after implementation/deslop review.\nNot-tested: Logged-in/app-only Gangnam Unni flows, intentionally out of scope.
2026-05-13 01:49:16 +09:00
Jeffrey (Dongkyu) Kim
fc8edd61df
Merge pull request #224 from taeyoung1005/feat/flight-ticket-search
feat: 항공권 조회 스킬 추가
2026-05-12 19:21:34 +09:00
Jeffrey (Dongkyu) Kim
3e22a78bf3
Merge pull request #225 from taeyoung1005/feat/korean-bus-booking-skills
한국 고속버스·시외버스 예매 스킬 추가
2026-05-12 19:20:22 +09:00
Jeffrey (Dongkyu) Kim
568a4453b9
Merge pull request #226 from taeyoung1005/feat/danawa-price-search
feat: 다나와 최저가 비교 스킬 추가
2026-05-12 19:18:43 +09:00
Jeffrey (Dongkyu) Kim
552e5c646b
Merge pull request #229 from taeyoung1005/feat/myrealtrip-mcp-search
마이리얼트립 MCP 검색 스킬 추가
2026-05-12 19:17:16 +09:00
Jeffrey (Dongkyu) Kim
14fd8a4980
Merge pull request #231 from ce-dric/feat/ncard-v2
feat: N카드 할인 예매 지원 추가 (ktx-booking)
2026-05-12 19:16:07 +09:00
Jeffrey (Dongkyu) Kim
84b3c993df Validate flight search arguments before bootstrap
Parse CLI arguments before installing the cached fast-flights runtime, add numeric bounds, validate date relationships early, and always use the pinned private runtime instead of arbitrary global installs.

Constraint: PR #224 helper should keep --help and invalid input paths offline and deterministic.

Rejected: Importing any global fast_flights package opportunistically | version drift can break TFS URL generation and query behavior.

Confidence: high

Scope-risk: narrow

Directive: Keep provider execution behind explicit valid commands; do not bootstrap dependencies for help or parser errors.

Tested: python3 -m py_compile flight-ticket-search/scripts/flight_ticket_search.py; FLIGHT_TICKET_SEARCH_BOOTSTRAPPED=1 python3 flight-ticket-search/scripts/flight_ticket_search.py --help; invalid return-date and step-days parser checks; git diff --check

Not-tested: Live Google Flights fetch through fast-flights.
2026-05-12 19:11:29 +09:00
Jeffrey (Dongkyu) Kim
fed3d9e2e7 Align Danawa offers with total price contract
Sort offer rows by computed delivered total before limiting results, reject non-positive limits, and align the feature docs with the actual compare flags and url field.

Constraint: PR #226 documents total_price-first comparison and the helper must match that contract.

Rejected: Leaving Danawa minPrice order untouched | it can surface a higher delivered total before a cheaper offer.

Confidence: high

Scope-risk: narrow

Directive: Preserve total_price as the user-facing ranking key unless Danawa exposes a better delivered-price endpoint.

Tested: python3 -m py_compile danawa-price-search/scripts/danawa_search.py; ./scripts/validate-skills.sh; git diff --check

Not-tested: Full live Danawa offer scrape after patch.
2026-05-12 19:11:28 +09:00
Jeffrey (Dongkyu) Kim
e1a6031569 Cover MyRealTrip MCP wrapper parsing
Add focused unit tests for JSON object validation, key=value decoding, and override merge behavior so the new wrapper has offline regression coverage.

Constraint: PR #229 introduces a public MCP wrapper whose live endpoint should not be required for CI coverage.

Rejected: Live MCP smoke as the only validation | upstream availability would make the regression path flaky.

Confidence: high

Scope-risk: narrow

Directive: Keep wrapper argument parsing covered without requiring network or mcp package installation.

Tested: python3 -m unittest scripts.test_myrealtrip_mcp; node --test scripts/skill-docs.test.js; ./scripts/validate-skills.sh

Not-tested: Live MyRealTrip MCP endpoint call.
2026-05-12 19:10:00 +09:00
Jeffrey (Dongkyu) Kim
7a0cefb832 Cover NCard error branches
Add regression coverage for missing NCard package handling and zero-based NCard selection so the KTX helper keeps clear failures around optional korail2-ncard support.

Constraint: PR #231 adds optional NCard behavior that must still be safe when korail2-ncard is not installed.

Rejected: Changing runtime NCard behavior now | existing implementation already returns explicit SystemExit messages and only lacked regression coverage.

Confidence: high

Scope-risk: narrow

Directive: Keep NCard fallback behavior tested separately from normal korail2 imports.

Tested: python3 -m pytest scripts/test_ktx_booking.py -q

Not-tested: Live Korail NCard reservation against production account.
2026-05-12 19:09:45 +09:00
Jeffrey (Dongkyu) Kim
25db06795e Prevent invalid flight queries from bootstrapping runtime
Validate flight-ticket CLI inputs before initializing the fast-flights runtime so bad dates, same-airport routes, and impossible ranges fail quickly without network or environment-dependent setup.

Constraint: PR #224 adds a crawler-style skill whose helper must handle invalid user input conservatively before external provider access.

Rejected: Add broad round-trip comparison support | outside the minimal merge-blocking correctness fix.

Confidence: high

Scope-risk: narrow

Directive: Keep provider/runtime initialization after argparse help and local input validation.

Tested: python3 -m py_compile flight-ticket-search/scripts/flight_ticket_search.py; CLI help and invalid-input smoke checks; ./scripts/validate-skills.sh; npm run typecheck; npm run lint; npm run test

Not-tested: Live Google Flights search because local Homebrew Python has a pyexpat/libexpat linkage failure during dependency bootstrap.

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-12 19:09:19 +09:00
Jeffrey (Dongkyu) Kim
d31157cba3 Reduce N-card number exposure in KTX booking
Prefer N-card selection by list index so full card numbers are not echoed through JSON output or required in shell history. Keep direct card-number input as a compatibility escape hatch with an explicit warning.\n\nConstraint: PR #231 scope is limited to ktx-booking docs, helper, and tests.\nRejected: Require users to copy full card numbers from ncard-list | exposes sensitive identifiers in logs and shell history.\nConfidence: high\nScope-risk: narrow\nDirective: Keep N-card list outputs masked; prefer index-based selection for future reservation flows.\nTested: python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; PYTHONPATH=scripts python3 -m unittest scripts.test_ktx_booking; npm run lint; npm run typecheck; npm test\nNot-tested: Live Korail N-card reservation; requires real user credentials and owned N-card.
2026-05-12 19:06:27 +09:00
Jeffrey (Dongkyu) Kim
5680497cb3 Keep Danawa price search mergeable and consistent
Merge the current dev branch so PR #226 no longer conflicts with the SH notice removal and marathon-schedule additions, while keeping the Danawa helper/documentation aligned with its 실구매가 contract. The PR diff is narrowed to Danawa files, README discovery, and the root lint hook for the new helper.

Constraint: PR #226 targets dev and GitHub reported DIRTY before this worker fix.

Rejected: Leaving inherited court-auction release metadata and SH-notice whitespace in the PR | they were unrelated to the Danawa skill and disappeared after reconciling with current dev.

Confidence: high

Scope-risk: narrow

Directive: Keep Danawa examples and JSON field docs synchronized with danawa_search.py CLI/output names.

Tested: python3 -m py_compile danawa-price-search/scripts/danawa_search.py; python3 danawa-price-search/scripts/danawa_search.py --help; python3 danawa-price-search/scripts/danawa_search.py search '에어팟 프로 2세대' --limit 1; npm run ci; git diff --check

Not-tested: Live Danawa offers endpoint after commit beyond search smoke.
2026-05-12 19:05:29 +09:00
Jeffrey (Dongkyu) Kim
62b6bfd8cc Clarify bus booking secret requirements
Keep the new bus booking docs aligned with repository credential semantics: checkout/payment remains manual, so the README login column should stay binary while setup documents that no user secrets are required.

Constraint: PR #225 scope is documentation and SKILL files only.

Rejected: editing package/test files to satisfy stale-branch failures | those files are outside this PR scope and already fixed on dev.

Confidence: high

Scope-risk: narrow

Directive: Keep bus booking payment steps as manual handoff unless a later PR adds explicit payment automation safeguards.

Tested: npm run lint; npm run typecheck; ./scripts/validate-skills.sh; node --test scripts/skill-docs.test.js (PR-head failures are stale dev drift in ktx/package files outside scope).

Not-tested: live KOBUS/Tmoney booking endpoints and payment pages.
2026-05-12 19:04:03 +09:00
Jeffrey (Dongkyu) Kim
78e505bfc0 Keep MyRealTrip MCP calls bounded and covered
Add a timeout guard around the remote Streamable HTTP MCP call and focused wrapper tests so the new skill fails predictably under upstream stalls without touching unrelated PR scope.

Constraint: Task scope allowed fixes only inside PR #229 files; package.json test wiring was left unchanged because it is outside this PR's original file set.

Rejected: Editing root package scripts to auto-run the new unittest | outside assigned PR file scope without leader approval.

Confidence: high

Scope-risk: narrow

Directive: Keep MyRealTrip live calls bounded; do not remove the timeout without replacing it with an equivalent cancellation mechanism.

Tested: python3 -m py_compile myrealtrip-search/scripts/myrealtrip_mcp.py myrealtrip-search/scripts/test_myrealtrip_mcp.py; PYTHONPATH=myrealtrip-search/scripts python3 -m unittest myrealtrip-search/scripts/test_myrealtrip_mcp.py; node --test scripts/skill-docs.test.js; ./scripts/validate-skills.sh; npm run typecheck; git diff --check; npm test

Not-tested: Live MyRealTrip MCP tools smoke because local environment lacks the optional mcp Python package and live network dependency is upstream-owned.
2026-05-12 19:03:34 +09:00
Jeffrey (Dongkyu) Kim
7e89bc3647 Avoid false session-expiry labels for validation errors
The toss wrapper now treats bare validation_error text as an upstream command failure instead of a session-expired signal. Structured auth doctor JSON remains the source of truth for empty portfolio/watchlist invalid-session promotion, while known stored-session-invalid stderr still maps to TossSessionExpiredError.\n\nConstraint: PR #192 follow-up must stay scoped to issue #126 toss-securities behavior.\nRejected: Keep validation_error in the global regex | it mislabels auth doctor transport failures and quote 403 validation errors as session expiry.\nConfidence: high\nScope-risk: narrow\nDirective: Do not broaden the free-text session classifier without regressions for auth doctor and quote upstream validation failures.\nTested: npm run lint --workspace toss-securities; npm run test --workspace toss-securities; npm run ci; manual mock tossctl validation_error checks; architect verification CLEAR\nNot-tested: Live tossctl network/auth session against real Toss upstream
2026-05-12 18:59:53 +09:00
Jeffrey (Dongkyu) Kim
6467f8b2db Clarify toss empty-output session expiry
Portfolio and watchlist reads can exit successfully with empty payloads when the stored Toss session has expired. The empty-output path now verifies the session before JSON parsing and only promotes confirmed invalid auth doctor data into TossSessionExpiredError.

Constraint: Scope is limited to toss-securities issue #126 follow-up on PR #192

Rejected: Treat auth doctor execution failures as expired sessions | unsupported or failing doctor output is inconclusive without parsed session.valid=false

Confidence: high

Scope-risk: narrow

Directive: Keep empty-result session expiry classification tied to explicit auth doctor confirmation

Tested: npm run test --workspace toss-securities; npm run lint --workspace toss-securities; npm run ci; manual mock tossctl blank stdout invalid/inconclusive doctor checks
2026-05-12 18:59:50 +09:00
galvaomica
ad7a9d2aee fix(toss-securities): clarify session expiry and quote 403 handling 2026-05-12 18:59:48 +09:00
Jeffrey (Dongkyu) Kim
01de419f73 Feature/#126 (#193)
* fix(toss-securities): clarify session expiry and quote 403 handling

* Clarify toss empty-output session expiry

Portfolio and watchlist reads can exit successfully with empty payloads when the stored Toss session has expired. The empty-output path now verifies the session before JSON parsing and only promotes confirmed invalid auth doctor data into TossSessionExpiredError.

Constraint: Scope is limited to toss-securities issue #126 follow-up on PR #192

Rejected: Treat auth doctor execution failures as expired sessions | unsupported or failing doctor output is inconclusive without parsed session.valid=false

Confidence: high

Scope-risk: narrow

Directive: Keep empty-result session expiry classification tied to explicit auth doctor confirmation

Tested: npm run test --workspace toss-securities; npm run lint --workspace toss-securities; npm run ci; manual mock tossctl blank stdout invalid/inconclusive doctor checks

* Avoid false session-expiry labels for validation errors

The toss wrapper now treats bare validation_error text as an upstream command failure instead of a session-expired signal. Structured auth doctor JSON remains the source of truth for empty portfolio/watchlist invalid-session promotion, while known stored-session-invalid stderr still maps to TossSessionExpiredError.\n\nConstraint: PR #192 follow-up must stay scoped to issue #126 toss-securities behavior.\nRejected: Keep validation_error in the global regex | it mislabels auth doctor transport failures and quote 403 validation errors as session expiry.\nConfidence: high\nScope-risk: narrow\nDirective: Do not broaden the free-text session classifier without regressions for auth doctor and quote upstream validation failures.\nTested: npm run lint --workspace toss-securities; npm run test --workspace toss-securities; npm run ci; manual mock tossctl validation_error checks; architect verification CLEAR\nNot-tested: Live tossctl network/auth session against real Toss upstream

* Preserve toss empty-response auth-doctor contract

The prior review identified the empty portfolio/watchlist promotion rule as an upstream-contract dependency worth making explicit. Add regression coverage for the non-invalid auth doctor path and document that only parsed JSON with session.valid false promotes empty results to TossSessionExpiredError.

Constraint: Scope is issue #126 / toss-securities only; public-restroom-nearby changes are excluded.
Rejected: Treat any auth doctor output as session-expiry evidence | false positives would relabel valid empty portfolio/watchlist responses.
Confidence: high
Scope-risk: narrow
Directive: Do not broaden empty-response promotion unless tossctl provides a stronger authenticated-empty-result contract.
Tested: npm run lint --workspace toss-securities
Tested: npm run test --workspace toss-securities (15/15)
Tested: npm run ci
Tested: Manual mock tossctl empty portfolio with session.valid true preserved []
Tested: Architect verification CLEAR
Not-tested: Live Toss Securities account session behavior.

---------

Co-authored-by: galvaomica <galvaomica@galvaomicaui-MacBookAir.local>
2026-05-12 18:59:45 +09:00
Jeffrey (Dongkyu) Kim
116bb5f58a omx(team): auto-checkpoint worker-5 [5] 2026-05-12 18:59:43 +09:00
Jeffrey (Dongkyu) Kim
300f1c9c93 omx(team): auto-checkpoint worker-5 [5] 2026-05-12 18:58:59 +09:00
Jeffrey (Dongkyu) Kim
043efe7f79 omx(team): auto-checkpoint worker-5 [5] 2026-05-12 18:57:33 +09:00
Jeffrey (Dongkyu) Kim
667e2e1347
Feature/#211 (#222)
* Add public marathon schedule lookup

Implement a read-only Korean marathon schedule skill so agents can report event dates, venues, registration deadlines, and categories from public race pages, with best-effort triathlon coverage.

Constraint: Issue #211 requires 장소, 신청 마감일, 종목, and possible triathlon inclusion without interactive clarification.

Constraint: Public unauthenticated GoRunning and triathlon.or.kr surfaces do not require k-skill-proxy.

Rejected: Proxy route | upstream pages are public and need no API key, so proxying would violate the free API proxy inclusion rule.

Confidence: high

Scope-risk: moderate

Directive: Keep source parsing fail-soft with explicit warnings when one public source changes or is temporarily unavailable.

Tested: npm test --workspace korean-marathon-schedule; live CLI smoke for 고령 2026 triathlon category; npm run ci; architect verification approved.

Not-tested: Real-time coverage of every future race page variant across both upstream sites.

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

* Keep marathon locations authoritative

Fix the reviewed GoRunning region inference bug by ranking event location fields ahead of full-page text, and remove the unrelated public SH notice proxy/skill surface so the PR remains inside the approved marathon scope and proxy policy.

Constraint: PR #222 review required TDD, full verification, and removal of public unauthenticated SH proxy routes before merge-readiness.
Rejected: Keeping /v1/sh-notice as a proxy route | violates the repository free-API proxy inclusion rule for public unauthenticated HTML.
Confidence: high
Scope-risk: narrow
Directive: Do not reintroduce public unauthenticated SH scraping through k-skill-proxy without an explicit documented policy exception.
Tested: npm test --workspace korean-marathon-schedule; node packages/korean-marathon-schedule/src/cli.js 용인 --from 2026-05-01 --to 2026-06-30 --limit 3; node packages/korean-marathon-schedule/src/cli.js 고령 --from 2026-01-01 --to 2026-12-31 --include-triathlon --limit 5; npm run lint --workspace k-skill-proxy; npm test --workspace k-skill-proxy; grep -RIn 'sh-notice\|i-sh.co.kr' README.md docs packages package.json package-lock.json .changeset; npm run ci; git diff --check; architect verification CLEAR.
Not-tested: None.

* Bound marathon schedule crawling to trusted sources

Fix review-round false negatives by continuing beyond the old pre-filter windows while adding an explicit per-source detail budget and warnings for partial crawls. Keep race detail traversal constrained to documented hosts and filter triathlon non-race rows before fetching details.\n\nConstraint: Review round required TDD, live verification, full CI, and preserving the public no-proxy source boundary.\nRejected: Exhaustive unbounded detail traversal | it maximizes recall but can over-crawl public list pages.\nConfidence: high\nScope-risk: narrow\nDirective: Keep future crawling changes host-allowlisted, budgeted, and warning-producing when partial.\nTested: npm test --workspace korean-marathon-schedule; npm run lint --workspace korean-marathon-schedule; node packages/korean-marathon-schedule/src/cli.js 고령 --from 2026-01-01 --to 2026-12-31 --include-triathlon --limit 5; node packages/korean-marathon-schedule/src/cli.js 용인 --from 2026-05-01 --to 2026-06-30 --limit 3; npm run ci; architect verification CLEAR.\nNot-tested: Live off-origin or malformed upstream HTML beyond mocked regressions.

* Honor explicit public crawl budgets

Keep broad triathlon searches bounded by applying one detail budget across selected year lists and exposing the same budget control in the CLI.

Constraint: PR #222 review requested shared triathlon crawl budget and CLI access to maxDetailsPerSource.

Rejected: Per-year triathlon budget counters | they can exceed the documented per-source crawl cap on multi-year ranges.

Confidence: high

Scope-risk: narrow

Directive: Keep public-source crawl caps source-scoped and documented when adding more list partitions.

Tested: npm test --workspace korean-marathon-schedule; npm run lint --workspace korean-marathon-schedule; live CLI 고령 smoke; CLI help grep; npm run ci; git diff --check; architect verification CLEAR

Not-tested: Live multi-year low-budget triathlon crawl against upstream beyond mocked regression.

---------

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-12 18:49:06 +09:00
cedric
83a1dd1409 feat: N카드 할인 예매 지원 추가 (ktx-booking)
- ncard-list: 보유 N카드 목록 조회 (owned_ncards)
- ncard-search: N카드 할인 열차 조회 (search_owned_ncard_trains)
- reserve --ncard-no: N카드 번호로 할인 승객 예약
- NCardPassenger는 별도 try/except ImportError 블록으로 분리해
  표준 korail2 환경에서도 모듈이 정상 로드되도록 처리
- korail2-ncard 미설치 시 N카드 커맨드에서 설치 안내 출력
- 관련 테스트 7개 추가 (총 18개)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:24:02 +09:00
TaeyoungPark
568b9256be Add MyRealTrip MCP search skill 2026-05-11 14:43:55 +09:00
Jeffrey (Dongkyu) Kim
f348cb4f85
feat: Manus.ai 호환 import 경로 추가 (GitHub URL + rolling .skill 번들) (#227)
* docs: add Manus.ai GitHub skill import guide

Manus.ai의 'GitHub에서 프로젝트 스킬 가져오기' 기능은 폴더 루트에 SKILL.md(YAML frontmatter name/description 필수)가 있는 디렉토리 URL을 받는다. k-skill의 모든 스킬은 이미 이 포맷을 만족하므로 코드 변경 없이 문서만 추가한다.

- 사용자는 저장소 루트 URL(https://github.com/NomaDamas/k-skill) 대신 개별 스킬 폴더 URL(https://github.com/NomaDamas/k-skill/tree/main/<skill-name>)을 붙여 넣어야 한다.

- 기존 frontmatter(license, metadata.*)는 Manus가 무시하지만 다른 코딩 에이전트와의 호환을 위해 그대로 유지한다.

* feat: add build:manus-bundle for batch .skill upload to Manus.ai

Per-folder GitHub URL import is tedious for 61 skills, so add 'npm run build:manus-bundle' which emits one .skill (ZIP) per skill into dist/manus/, plus a single k-skill-manus-all.zip convenience bundle and an INDEX.md listing. Each archive nests its content under <skill-name>/ to match the public Anthropic skill-creator packager layout.

Manus does NOT support multi-skill bulk import in a single archive (verified against help.manus.im, manus.im/docs, and open.manus.ai API docs). The combined zip is purely a download convenience: users still drag-drop individual .skill files into Manus, but the file picker accepts multiple selections so it's still much faster than pasting 61 GitHub URLs.

- scripts/build-manus-bundle.js: discovers root-level skills (mirrors validate-skills.sh exclusions), shells out to system zip with -X for reproducible archives, excludes node_modules/__pycache__/.DS_Store.

- scripts/test_build_manus_bundle.js: validates discovery, frontmatter parsing, lockstep with validate-skills.sh, and docs coverage.

- scripts/validate-skills.sh: also skip dist/ and .sisyphus/ so the validator stays clean after a build.

- .gitignore: ignore dist/ and .sisyphus/.

- docs/install-manus.md: document both Method A (GitHub URL) and Method B (.skill bundle).

* ci: auto-publish Manus .skill bundle as rolling release on main push

Every push to main that touches a skill folder or the bundler now builds the .skill bundle and publishes it to the GitHub Releases tag 'manus-bundle-latest' (marked prerelease so it does not pollute the Latest release pointer used by the npm release flow).

Users get stable download URLs that always point to the latest build:

  - https://github.com/NomaDamas/k-skill/releases/download/manus-bundle-latest/k-skill-manus-all.zip

  - https://github.com/NomaDamas/k-skill/releases/download/manus-bundle-latest/INDEX.md

This removes the 'clone the repo and run npm' step for non-developers. The direct-build path remains documented as the developer fallback.

- .github/workflows/manus-bundle.yml: workflow_dispatch + push-to-main with paths filter, uses preinstalled gh CLI (no third-party release action), concurrency-grouped so overlapping pushes do not race on the same tag, --clobber upload to keep asset URLs stable.

- docs/install-manus.md: new 'quick path' section with the rolling-release URLs; existing local-build section reframed as a developer fallback.

- scripts/test_build_manus_bundle.js: 2 new tests pinning the doc URLs and key workflow invariants (trigger branch, build invocation, tag, asset name, prerelease flag, write permission).
2026-05-11 12:12:44 +09:00
TaeyoungPark
9f042642a5 chore: merge dev into danawa skill PR 2026-05-10 17:42:10 +09:00
TaeyoungPark
8f1044046f feat: add flight ticket search skill 2026-05-10 17:40:39 +09:00
TaeyoungPark
7f66f04c46 feat: add Danawa price comparison skill 2026-05-10 02:39:32 +09:00
TaeyoungPark
37cbcdb6dd Add Korean bus booking skills 2026-05-10 02:36:57 +09:00
TaeyoungPark
91eeaf607a
feat: add SH notice search skill (#218)
* feat: add SH notice search skill

* fix(sh-notice): require srchTp for keyword search, parse real attachments, cap pageSize

- Default srchTp to title ("1") when srchWord is provided without an explicit
  type. SH 게시판 ignores srchWord without srchTp and silently returns the full
  list, so /v1/sh-notice/search?q=행복주택 was returning all 1608 notices.
- Rewrite parseAttachments to ignore icon-template anchors (.pdf, .hwp, ...)
  and require existFile() onclick for real file rows. Multi-attachment notices
  now expose every real attachment with the correct filename.
- Drop unverified download_hint field from attachment objects; preview_url
  remains the only documented stable path.
- Cap pageSize at 10 to match the SH board's fixed page size and update docs
  to direct callers to use the page parameter for more results.
- Add multiItmSeq digits-only validation and a 100-char keyword length cap to
  bound cache cardinality.
- Add README, docs/install.md, packages/k-skill-proxy/README.md, and
  docs/features/sh-notice-search.md entries to register the skill in the
  repo's public surface.

Verified live against www.i-sh.co.kr:
- q=행복주택 → 96 hits (was 1608, unfiltered)
- seq=303994 → 11 real attachments with correct filenames (was 1 with '.pdf')
- pageSize=50 → caps at 10 with correct summary.page_size
- Validation errors return 400 with clear messages.

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>
2026-05-09 00:09:45 +09:00
설코딩 -SeolCoding
4ba9876a57
feat: 국가데이터처 KOSIS 통계 조회(kosis-stats) 스킬 추가 (#216)
* feat: 국가데이터처 KOSIS 통계 조회(kosis-stats) 스킬 추가

KOSIS Open API 4개 endpoint(statisticsSearch / statisticsData getMeta /
statisticsParameterData / statisticsBigData) read-only 호출을 단일 Python
helper로 묶었다. 인증키는 KSKILL_KOSIS_API_KEY 환경변수(또는 기본
secrets.env)로 사용자별 발급한다 — proxy 미사용.

- kosis-stats/SKILL.md, scripts/run_kosis_stats.py: stdlib only,
  search/meta/data/bigdata 서브커맨드, --json/--text/--dry-run
- kosis-stats/references/kosis-openapi-guide.md: 인증키 발급, 호출 한도
  (분당 1000건/40k cells), 에러 코드, HTTPS 전용 정책 정리
- kosis-stats/tests/: stdlib unittest 36개, mock 기반 (네트워크 X) +
  KSKILL_KOSIS_API_KEY 가 있을 때만 도는 라이브 smoke 1개
- docs/features/kosis-stats.md, README, install/setup/security-and-secrets/
  sources, examples/secrets.env.example, package.json lint/test 등록

* fix(kosis-stats): 사용자 시나리오 e2e 검증 기반 UX 보강

4개 sonnet 서브에이전트 병렬 시나리오(단일수치/시계열/지역비교/실패회복)
검증에서 발견된 P1/P2 UX 부족함 보강. 4개 회복 시나리오 친절도 평균 2.75
→ 4.5 (S4c 코드 20 막힘 P1 해결).

- ERROR_CODE_HINTS: 코드 20/21/30/31 모두 next-step 명령 예시 포함
  (코드 20은 ITM 메타 우선 안내 — 실제 표 다수에서 OBJ 비어 있음)
- render_search_text: Next 액션 흐름 안내 추가
- render_meta_text: 빈 결과 시 다른 --meta-type 시도 안내
- render_data_text: 빈 결과 시 필터/meta 재확인 안내,
  새 [summary] 라인(rows/period/unit, UNIT_NM 누락 명시)
- SKILL.md Workflow: 코드 20 회복 절차, 행정구역 코드(시도 2자리/시군구
  5자리) 관례 명시
- SKILL.md Failure modes: 코드 20 추가, meta 30 분기, UNIT_NM 누락 처리,
  코드 20/31 회복 시나리오 예시
- docs/features/kosis-stats.md "흔한 문제 해결"에 코드 20 회복 절차 추가
- tests: 8개 회귀 테스트 추가 (hint 키워드/render 메시지/[summary] 라인)

* fix(kosis-stats): drop xls bigdata format and detect json error envelope in non-json formats

Reviewer follow-up on PR #216:

- Removes `xls` from bigdata --format choices. KOSIS returns xls as a
  binary Excel payload, but the helper streams text-only output, which
  would corrupt the file. json/sdmx/csv (text) remain supported.
- Detects KOSIS `{err, errMsg}` envelopes even when --format is csv/sdmx,
  so non-json bigdata responses surface auth/limit errors instead of
  printing a misleading error envelope as raw success output.
- Updates SKILL.md, references/kosis-openapi-guide.md, and
  docs/features/kosis-stats.md so the advertised contract matches the
  helper's actual capabilities.
- Adds 3 unit tests: xls rejection, json error envelope detection in csv
  mode, and clean csv passthrough when no error envelope is present.

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>
2026-05-08 23:06:19 +09:00
Jeffrey (Dongkyu) Kim
4e5abf0861
Feature/#212 (#214)
* Help donors choose verified recipients by place and cause

Add a read-only donation-place search skill and npm helper that ranks Korean donation recipients by user-provided location/category while keeping final verification on official 1365 and recipient pages. The implementation avoids proxy routes because the chosen verification surface is public and does not require an API key.

Constraint: Issue #212 requested 기부처 조회 recommendations by place and category under TDD with a PR to dev.
Constraint: k-skill free API proxy policy allows proxying only when upstream requires API keys; 1365 verification links are public.
Rejected: Screen-scraping 1365 result pages | headless requests were slow/unstable and would be brittle for a recommendation helper.
Rejected: Treating general-purpose charities as matches for every requested category | architect review found it could return off-category results, so matching now requires explicit category tags.
Confidence: high
Scope-risk: narrow
Directive: Do not add automatic donation/payment submission; keep this skill read-only and require official-page verification before final donation decisions.
Tested: npm test --workspace donation-place-search
Tested: node smoke invocation of recommendDonationPlaces + formatDonationRecommendationReport for 서울 마포구/동물
Tested: npm run lint --workspace donation-place-search
Tested: npm run typecheck
Tested: npm run ci
Tested: architect verification approved after off-category regression fix
Not-tested: Live 1365 search result scraping; intentionally not used because the skill returns official verification links instead.
Co-authored-by: OmX <omx@oh-my-codex.dev>

* Keep donation recommendations on requested intent

Prioritize specific donation category keywords before broad general donation terms, and make item-level 1365 links candidate-specific while preserving the broad result search link.

Constraint: PR #214 review required TDD fixes for category normalization and per-candidate 1365 link semantics.

Rejected: Rewording item URLs as broad portal searches | the issue explicitly asks for candidate-specific verification links.

Confidence: high

Scope-risk: narrow

Directive: Keep item officialSearchUrl candidate-specific; use result officialSearchUrl for broad latest portal searches.

Tested: npm test --workspace donation-place-search; node smoke invocation; npm run lint --workspace donation-place-search; npm run typecheck; npm run ci; code-reviewer APPROVE; architect CLEAR.

Not-tested: Live 1365 HTTP availability, because the workflow only builds official read-only search links and prior review documented headless 1365 timeouts.

* Harden donation skill follow-up guarantees

Constraint: PR #214 review follow-up required TDD, empty category defaults, README discoverability, and release-pack coverage without pinning package versions.\nRejected: Static pack dry-run allowlist | it already missed a publishable workspace and would drift again.\nConfidence: high\nScope-risk: narrow\nDirective: Keep pack dry-run coverage dynamic over publishable workspaces; do not assert workspace package versions in tests.\nTested: npm test --workspace donation-place-search; node smoke for empty category URL/recommend/report; npm run lint --workspace donation-place-search; npm run typecheck; npm run ci; git diff --check; code-reviewer APPROVE; architect CLEAR.\nNot-tested: Live 1365 portal filtering semantics, by design; links remain read-only verification entry points.

* Clarify donation verification links

Reject misleading 1365 URL contracts and keep item search categories aligned with the candidate that is being recommended.

Constraint: PR #214 round-3 review required TDD fixes for multi-category candidate links, clean install docs, and evidence-safe 1365 wording.

Rejected: Keep broad first-request category on every item URL | It mislabels later-category candidates in multi-category requests.

Rejected: Preserve public baseUrl override | It conflicts with the official 1365 helper contract.

Confidence: high

Scope-risk: narrow

Directive: Keep 1365 URLs framed as best-effort verification assists unless browser-observed 1365 search parameters are documented.

Tested: npm test --workspace donation-place-search; node --test --test-name-pattern 'donation-place-search' scripts/skill-docs.test.js; npm run lint --workspace donation-place-search; npm run typecheck; npm run ci; node smoke for multi-category URLs, malformed limits, baseUrl rejection, and empty category.

Not-tested: Live 1365 parameter behavior; headless HTTP remains documented as unreliable.

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

---------

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-08 15:41:21 +09:00
Jeffrey (Dongkyu) Kim
af55f58cb4
Feature/#207: Restore actionable Daiso pickup answer via selPkupStr fallback (#215)
* Restore actionable Daiso pickup answer when store pickup stock is blocked

Adds a public selPkupStr-backed getStorePickupEligibility() helper plus a
new pickupEligibility field on lookupStoreProductAvailability(). When
selStrPkupStck still returns 401/403 Unauthorized as in #207, the package
now reports whether the selected store is registered as a pickup-capable
store for the product (pickupEligible: true|false|null), instead of only
returning blocked/unknown.

Closes #207

* Make scope limits explicit in skill description and feature doc

Clarify across three high-traffic surfaces that this skill no longer
returns exact per-store stock quantities while the official Daiso
selStrPkupStck endpoint stays Unauthorized: only pickup eligibility
(yes/no) is reported in that state.

- daiso-product-search/SKILL.md frontmatter description rewritten
  so coding agents see the limit before triggering the skill
- daiso-product-search/SKILL.md adds explicit Scope and limits
  section plus reworked When to use / When not to use examples
- docs/features/daiso-product-search.md adds a new
  "이 기능으로 할 수 없는 일" section listing the quantity gap
- root README.md row clarifies the skill answers pickup eligibility,
  not exact per-store quantities, while the upstream block holds

* Prevent under-scoped Daiso pickup negatives

Return an explicit insufficient-coverage eligibility state when selPkupStr search input cannot prove absence, and require pkupYn=Y for positive eligibility. This preserves the actionable fallback while avoiding false negatives from broad or missing store keywords.

Constraint: Existing PR #215 already added selPkupStr fallback; this follow-up is limited to review-requested correctness fixes.

Rejected: Treating a missing first-page match as definitive false | broad or unkeyed selPkupStr searches can miss the target store.

Confidence: high

Scope-risk: narrow

Directive: Do not claim pickup ineligibility unless the searched selPkupStr coverage is sufficient to prove absence.

Tested: npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live Daiso smoke for 10224, missing keyword, and negative 99999.

Not-tested: Exhaustive multi-page live pagination across all Daiso store keywords.

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

* Keep Daiso pickup fallback shape actionable

Stabilize blocked pickupEligibility responses with matchedStore:null and keep optional online-stock failures from preventing the selPkupStr pickup-eligibility fallback. This preserves the core store/product/pickup answer even when reference-only online stock is unavailable.

Constraint: Issue #207 requires an actionable pickup answer when the pickup-stock endpoint is blocked, and PR review required stable public response shape.

Rejected: Letting optional online stock reject the end-to-end helper | it can defeat the new actionable fallback even though online stock is reference-only.

Confidence: high

Scope-risk: narrow

Directive: Keep quantity-bearing pickupStock separate from quantity-free pickupEligibility, and do not let optional enrichments block core pickup fallback results.

Tested: npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live Daiso smoke for 10224, missing keyword, negative 99999, and end-to-end lookup.

Not-tested: Exhaustive live multi-page selPkupStr pagination across every store keyword.

---------

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-08 15:41:08 +09:00
Jeffrey (Dongkyu) Kim
f527515932
Enable property search by free auction conditions (#213)
Add Workflow C for court-auction-notice-search with direct PGJ151 property search payload mapping, representative frozen code tables, CLI/docs coverage, and normalized item rows.

Constraint: Issue #184 requires Workflow C region/usage/price/date/area/flbd filters and release automation requires a Changeset.

Rejected: Proxy route | courtauction.go.kr property search is a public site endpoint and does not require an API key.

Confidence: high

Scope-risk: moderate

Directive: Keep code-table lookups fail-open and avoid tests that pin package versions or changeset file presence.

Tested: npm test --workspace court-auction-notice-search; npm run lint --workspace court-auction-notice-search; npm run ci

Not-tested: Live courtauction.go.kr property search, to avoid unnecessary upstream calls and potential anti-bot blocking.
2026-05-08 10:14:33 +09:00
Jeffrey (Dongkyu) Kim
dc9a765e2a
Feature/#205 (#210)
* Align proxy defaults for hosted Korean routes

Constraint: Issue #205 requires unset or empty KSKILL_PROXY_BASE_URL to use the hosted proxy consistently while preserving explicit proxy overrides and server-side upstream keys.\nRejected: Keeping Seoul subway and Korea weather as self-host-only routes | it preserves the documented inconsistency and blocks zero-config usage.\nConfidence: high\nScope-risk: narrow\nDirective: Keep client docs pointing to hosted proxy defaults unless a route is intentionally removed from hosted service.\nTested: node --test scripts/skill-docs.test.js; npm run ci; hosted smoke curls for /v1/seoul-subway/arrival and /v1/korea-weather/forecast; architect verification approved.\nNot-tested: Private self-host proxy deployment.

* Preserve hosted proxy fallback in setup guidance

Make self-host proxy examples inactive by default so client setup no longer blocks the hosted proxy resolver contract for Seoul subway and Korea weather skills.

Constraint: PR #210 review required unset and empty KSKILL_PROXY_BASE_URL to fall back to https://k-skill-proxy.nomadamas.org while preserving explicit self-host overrides.\nRejected: Keep active https://your-proxy.example.com placeholder | It creates a non-empty override and prevents hosted fallback for users copying the default secrets file.\nConfidence: high\nScope-risk: narrow\nDirective: Keep upstream API keys documented as proxy-operator/server-side only; do not reintroduce active client-side proxy placeholders for hosted-default flows.\nTested: node --test scripts/skill-docs.test.js; npm run ci; hosted smoke checks for /v1/seoul-subway/arrival?stationName=강남 and /v1/korea-weather/forecast?lat=37.5665&lon=126.9780; resolver smoke for unset, empty, and custom KSKILL_PROXY_BASE_URL; git diff --check; Ralph architect verification CLEAR.\nNot-tested: none

* Clarify proxy guide override boundary

Document the hosted-client default in the proxy guide while keeping the self-host placeholder as an explicitly scoped override, so users do not mistake it for required setup.

Constraint: Issue #205 review round 2 left a WATCH concern on docs/features/k-skill-proxy.md client env-var wording.

Rejected: Leave the proxy guide unchanged | It preserved ambiguity between hosted-client defaults and self-host/operator overrides.

Rejected: Sentence-exact regression assertions | They were too brittle after code review; semantic assertions preserve wording flexibility while locking the policy.

Confidence: high

Scope-risk: narrow

Directive: Keep KSKILL_PROXY_BASE_URL examples inactive or clearly scoped unless documenting a self-host/alternate-proxy override.

Tested: node --test scripts/skill-docs.test.js; npm run ci; resolver smoke for unset empty and custom KSKILL_PROXY_BASE_URL; hosted Seoul subway and Korea weather smokes; git diff --check; code-reviewer APPROVE; architect CLEAR

Not-tested: none

* Keep proxy guide examples on the hosted-default path

Constraint: PR #210 issue #205 follow-up requires KSKILL_PROXY_BASE_URL unset/empty to resolve to hosted while preserving explicit self-host overrides.\nRejected: Labeling the existing 127.0.0.1 examples as operator-only | it would leave the general usage section less aligned with the hosted-client default.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Seoul subway and Korea weather user-facing examples on the resolver pattern unless the section is explicitly scoped to local operator smoke tests.\nTested: node --test scripts/skill-docs.test.js; npm run ci; resolver smoke for unset empty custom KSKILL_PROXY_BASE_URL; hosted Seoul subway and Korea weather smoke; architect verification CLEAR.\nNot-tested: None.
2026-05-06 16:56:40 +09:00
Jeffrey (Dongkyu) Kim
95f0f042a9
Feature/#202 (#208) 2026-05-06 12:39:49 +09:00
Jeffrey (Dongkyu) Kim
e87330874b
Feature/#207 (#209) 2026-05-06 12:38:52 +09:00
Taekyun Kim
6b78624920
Correct limit description for free usage (#206)
오타 수정
2026-05-06 01:40:50 +09:00
Inho Jeong
2ff51db5d2
feat: 개별공시지가(gongsijiga-search) 스킬 추가 (#200)
* chore: version packages

* Merge dev into main (#197)

* fix(toss-securities): clarify session expiry and quote 403 handling

* Clarify toss empty-output session expiry

Portfolio and watchlist reads can exit successfully with empty payloads when the stored Toss session has expired. The empty-output path now verifies the session before JSON parsing and only promotes confirmed invalid auth doctor data into TossSessionExpiredError.

Constraint: Scope is limited to toss-securities issue #126 follow-up on PR #192

Rejected: Treat auth doctor execution failures as expired sessions | unsupported or failing doctor output is inconclusive without parsed session.valid=false

Confidence: high

Scope-risk: narrow

Directive: Keep empty-result session expiry classification tied to explicit auth doctor confirmation

Tested: npm run test --workspace toss-securities; npm run lint --workspace toss-securities; npm run ci; manual mock tossctl blank stdout invalid/inconclusive doctor checks

* Avoid false session-expiry labels for validation errors

The toss wrapper now treats bare validation_error text as an upstream command failure instead of a session-expired signal. Structured auth doctor JSON remains the source of truth for empty portfolio/watchlist invalid-session promotion, while known stored-session-invalid stderr still maps to TossSessionExpiredError.\n\nConstraint: PR #192 follow-up must stay scoped to issue #126 toss-securities behavior.\nRejected: Keep validation_error in the global regex | it mislabels auth doctor transport failures and quote 403 validation errors as session expiry.\nConfidence: high\nScope-risk: narrow\nDirective: Do not broaden the free-text session classifier without regressions for auth doctor and quote upstream validation failures.\nTested: npm run lint --workspace toss-securities; npm run test --workspace toss-securities; npm run ci; manual mock tossctl validation_error checks; architect verification CLEAR\nNot-tested: Live tossctl network/auth session against real Toss upstream

* Align court auction lookup with monthly site search (#196)

The court auction notice page posts a YYYYMM search key from its 조회 button and returns a month of rows. Keep day inputs as a compatibility filter over the monthly response and normalize the current nested detail payload shape.

Constraint: courtauction.go.kr has no public API and blocks bursty automated calls.

Rejected: querying every day independently | the upstream search surface is month-based and day calls return false empty results.

Confidence: high

Scope-risk: narrow

Directive: Preserve the site-observed YYYYMM notice search contract unless the PGJ143M01 XHR changes again.

Tested: npm --workspace packages/court-auction-notice-search test; npm run ci; live 서울중앙지방법원 2026-05 notice/detail smoke lookup.

Not-tested: PR CI after push.

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

* Guide crawler skills toward reusable discovery (#195)

* chore: version packages

* Guide crawler skills toward reusable discovery

Constraint: User requested insane-search-style guidance for future crawling k-skills without unrelated implementation changes.
Rejected: Adding crawler code or a standalone template | too broad for a docs guidance change and risks dependency creep.
Confidence: high
Scope-risk: narrow
Directive: Keep site-specific access details inside individual skills after a site-agnostic discovery pass.
Tested: npm run ci
Not-tested: Live crawler behavior; documentation-only change.

* Clarify crawler skill discovery guidance

Constraint: Crawling k-skills need site-dependent recipes, but should derive them through a reusable discovery pass.
Rejected: Leaving guidance only in docs/adding-a-skill.md | AGENTS.md and CLAUDE.md also guide future agents.
Confidence: high
Scope-risk: narrow
Directive: Use site-agnostic discovery to find, then explicitly package, the target site's stable access path.
Tested: npm run ci
Not-tested: Live crawler behavior; documentation-only change.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Ground corporate registration guidance in official form sources

Keep the consulting skill focused on draft/checklist support while pointing users to current IROS and law.go.kr form sources for submission-ready artifacts.

Constraint: official registry forms can change outside the repository and must be re-downloaded at use time

Rejected: committing copied official HWP/HWPX/PDF forms | they would become stale and risk misleading users

Confidence: high

Scope-risk: narrow

Directive: do not treat Markdown templates as substitutes for official registry submission forms

Tested: npm test

* Ground incorporation drafting in real HWP forms

Bundle official court incorporation forms plus public startup incorporation attachments, and make rhwp-filled HWP outputs the default drafting path for the corporate-registration skill. Replace the listed-company articles reference with a startup-suitable Ministry of Justice stock-company form and record source manifests for bundled binaries.

Constraint: user requires actual sourced HWP templates, not generated placeholder binaries.
Rejected: markdown-only drafting | it cannot produce submission-shaped Korean registry forms.
Rejected: listed-company standard articles as the default reference | it is mismatched for typical startup incorporation.
Confidence: high
Scope-risk: moderate
Directive: keep bundled HWP forms source-backed, sanitized, and edited only through copied working files.
Tested: node --test scripts/skill-docs.test.js; npm run lint; k-skill-rhwp info on bundled HWP files; kordoc conversion spot checks.
Not-tested: manual opening every HWP in Hancom Office and live registry submission.
Co-authored-by: OmX <omx@oh-my-codex.dev>

* Streamline corporate registration forms workflow

Prioritize saved HWP forms for ordinary stock-company promoter incorporations, make required court-registry receipts and director identity certificates explicit, and remove the redundant markdown articles template so the skill stays HWP-first.

Constraint: 법원등기소 기준 체크리스트 must include fee receipts, director seal/signature certificates, and resident-record documents.

Rejected: Keeping a separate markdown articles template | duplicated the stored HWP articles workflow and encouraged non-HWP drafting.

Confidence: high

Scope-risk: narrow

Directive: Keep corporate-registration-consulting focused on stored HWP form copies and explicit issued-document checklists.

Tested: node --test --test-name-pattern 'corporate-registration-consulting' scripts/skill-docs.test.js; node --check scripts/skill-docs.test.js; ./scripts/validate-skills.sh; git diff --check

Not-tested: Full npm run ci was not run because this is a skill documentation/template refactor, not release or package automation.

---------

Co-authored-by: galvaomica <galvaomica@galvaomicaui-MacBookAir.local>
Co-authored-by: OmX <omx@oh-my-codex.dev>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* chore: version packages (#198)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* feat(realtyprice): add address parsing and sido code mapping

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

* fix(realtyprice): use string sido codes for consistency with upstream API

* feat(realtyprice): add response normalization and buildResponse

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

* feat(realtyprice): add upstream cascade fetch functions with timeout

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

* feat(realtyprice): add lookupGongsijiga orchestrator with region matching

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

* feat(realtyprice): add simple in-memory cache with TTL

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

* feat(proxy): register GET /realtyprice route with caching

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

* feat: add gongsijiga-search SKILL.md

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

* chore: add changeset for gongsijiga-search

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

* fix(realtyprice): align with actual realtyprice.kr API response format

- Response wraps data in model.list (not bjdList/gsiList)
- Field names are code/name (not bjd_cd/bjd_nm)
- bun2 empty → send "0000" (not empty string)
- eupmyeondong matching: try full string match first (API returns
  combined "면 리" names like "청계면 청천리")

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

* fix(gongsijiga-search): align /realtyprice route with v1 API convention

- Change route from /realtyprice to /v1/realtyprice for consistency with other proxy endpoints.
- Add realtypriceConfigured flag to /health upstreams.
- Normalize address cache key by collapsing multiple whitespaces.
- Update SKILL.md and README.md to reflect the new v1 path.

* feat(gongsijiga-search): add Sejong special-city support

- parseAddress: allow 3-token minimum for Sejong (no sigungu) and set sigungu to empty string.
- lookupGongsijiga: skip sigungu lookup for Sejong (sidoCode 29), use fixed sggCode 36110.
- Add Sejong parseAddress and lookupGongsijiga test cases.
- Update SKILL.md with Sejong address format examples.

* refactor(gongsijiga-search): split realtyprice.kr lookup into standalone package

realtyprice.kr is a fully public endpoint that needs no API key, so per
the new k-skill-proxy inclusion rule (proxy is for keyed upstreams only)
the helper now ships as `gongsijiga-search` and is invoked directly from
the user's machine.

- new workspace package packages/gongsijiga-search/ following the
  blue-ribbon-nearby/coupang-product-search convention (publishConfig,
  files, repository, keywords)
- remove /v1/realtyprice route, realtyprice.js, realtyprice.test.js, and
  the realtypriceConfigured health flag from k-skill-proxy
- document the inclusion rule in AGENTS.md and CLAUDE.md so future skills
  default to direct calls when no key is required
- advertise the new skill in README.md, docs/install.md, and add
  docs/features/gongsijiga-search.md
- drop the hardcoded toss-securities lockfile version assertion that
  pinned a workspace version (would block changesets version-packages)
  and document the anti-pattern in AGENTS.md / CLAUDE.md
- changesets: refresh the proxy refactor message and add a patch
  changeset so the new gongsijiga-search package gets published

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: galvaomica <galvaomica@galvaomicaui-MacBookAir.local>
Co-authored-by: OmX <omx@oh-my-codex.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 00:27:31 +09:00
SEONGYEON KIM
32162e4cd7
Add korean-transit-route skill (#201)
* Add korean-transit-route skill

* Apply review fixes to korean-transit-route skill

- Add Done when and Failure modes sections to align with SKILL.md template
- Fix Python geocode example to use with-statement for urlopen
- Add korean-transit-route to README.md skill table and feature list
- Add docs/features/korean-transit-route.md guide

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>
2026-05-04 14:11:40 +09:00
Jeffrey (Dongkyu) Kim
8c2f31ad59 Streamline corporate registration forms workflow
Prioritize saved HWP forms for ordinary stock-company promoter incorporations, make required court-registry receipts and director identity certificates explicit, and remove the redundant markdown articles template so the skill stays HWP-first.

Constraint: 법원등기소 기준 체크리스트 must include fee receipts, director seal/signature certificates, and resident-record documents.

Rejected: Keeping a separate markdown articles template | duplicated the stored HWP articles workflow and encouraged non-HWP drafting.

Confidence: high

Scope-risk: narrow

Directive: Keep corporate-registration-consulting focused on stored HWP form copies and explicit issued-document checklists.

Tested: node --test --test-name-pattern 'corporate-registration-consulting' scripts/skill-docs.test.js; node --check scripts/skill-docs.test.js; ./scripts/validate-skills.sh; git diff --check

Not-tested: Full npm run ci was not run because this is a skill documentation/template refactor, not release or package automation.
2026-05-02 23:27:52 +09:00
Jeffrey (Dongkyu) Kim
ff92e77fa5 Ground incorporation drafting in real HWP forms
Bundle official court incorporation forms plus public startup incorporation attachments, and make rhwp-filled HWP outputs the default drafting path for the corporate-registration skill. Replace the listed-company articles reference with a startup-suitable Ministry of Justice stock-company form and record source manifests for bundled binaries.

Constraint: user requires actual sourced HWP templates, not generated placeholder binaries.
Rejected: markdown-only drafting | it cannot produce submission-shaped Korean registry forms.
Rejected: listed-company standard articles as the default reference | it is mismatched for typical startup incorporation.
Confidence: high
Scope-risk: moderate
Directive: keep bundled HWP forms source-backed, sanitized, and edited only through copied working files.
Tested: node --test scripts/skill-docs.test.js; npm run lint; k-skill-rhwp info on bundled HWP files; kordoc conversion spot checks.
Not-tested: manual opening every HWP in Hancom Office and live registry submission.
Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-02 18:30:53 +09:00
Jeffrey (Dongkyu) Kim
b8849953d8 Ground corporate registration guidance in official form sources
Keep the consulting skill focused on draft/checklist support while pointing users to current IROS and law.go.kr form sources for submission-ready artifacts.

Constraint: official registry forms can change outside the repository and must be re-downloaded at use time

Rejected: committing copied official HWP/HWPX/PDF forms | they would become stale and risk misleading users

Confidence: high

Scope-risk: narrow

Directive: do not treat Markdown templates as substitutes for official registry submission forms

Tested: npm test
2026-05-02 12:31:07 +09:00
Jeffrey (Dongkyu) Kim
12289bd9a2
Guide crawler skills toward reusable discovery (#195)
* chore: version packages

* Guide crawler skills toward reusable discovery

Constraint: User requested insane-search-style guidance for future crawling k-skills without unrelated implementation changes.
Rejected: Adding crawler code or a standalone template | too broad for a docs guidance change and risks dependency creep.
Confidence: high
Scope-risk: narrow
Directive: Keep site-specific access details inside individual skills after a site-agnostic discovery pass.
Tested: npm run ci
Not-tested: Live crawler behavior; documentation-only change.

* Clarify crawler skill discovery guidance

Constraint: Crawling k-skills need site-dependent recipes, but should derive them through a reusable discovery pass.
Rejected: Leaving guidance only in docs/adding-a-skill.md | AGENTS.md and CLAUDE.md also guide future agents.
Confidence: high
Scope-risk: narrow
Directive: Use site-agnostic discovery to find, then explicitly package, the target site's stable access path.
Tested: npm run ci
Not-tested: Live crawler behavior; documentation-only change.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 22:11:04 +09:00
Jeffrey (Dongkyu) Kim
593865f2e5
Merge pull request #192 from galvaomica396/fix/toss-session-126
fix(toss-securities): clarify session expiry handling (#126)
2026-05-01 22:08:39 +09:00
Jeffrey (Dongkyu) Kim
7f9835e8ec Unblock PR 192 by reconciling dev conflict context
Kept PR session-expiry behavior while accepting dev's explicit inconclusive-auth documentation and regression coverage.

Constraint: PR #192 targets dev and was DIRTY due to README/test conflicts after origin/dev advanced.

Rejected: Dropping dev regression cases | would reintroduce false session-expiry risk documented on dev.

Confidence: high

Scope-risk: narrow

Directive: Preserve empty-output session checks as inconclusive unless auth doctor confirms session.valid === false.

Tested: npm test --workspace packages/toss-securities; git diff --check

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-01 22:08:11 +09:00
Jeffrey (Dongkyu) Kim
a25d641d00
Align court auction lookup with monthly site search (#196)
The court auction notice page posts a YYYYMM search key from its 조회 button and returns a month of rows. Keep day inputs as a compatibility filter over the monthly response and normalize the current nested detail payload shape.

Constraint: courtauction.go.kr has no public API and blocks bursty automated calls.

Rejected: querying every day independently | the upstream search surface is month-based and day calls return false empty results.

Confidence: high

Scope-risk: narrow

Directive: Preserve the site-observed YYYYMM notice search contract unless the PGJ143M01 XHR changes again.

Tested: npm --workspace packages/court-auction-notice-search test; npm run ci; live 서울중앙지방법원 2026-05 notice/detail smoke lookup.

Not-tested: PR CI after push.

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-01 22:05:54 +09:00
Jeffrey (Dongkyu) Kim
8243e231db Avoid false session-expiry labels for validation errors
The toss wrapper now treats bare validation_error text as an upstream command failure instead of a session-expired signal. Structured auth doctor JSON remains the source of truth for empty portfolio/watchlist invalid-session promotion, while known stored-session-invalid stderr still maps to TossSessionExpiredError.\n\nConstraint: PR #192 follow-up must stay scoped to issue #126 toss-securities behavior.\nRejected: Keep validation_error in the global regex | it mislabels auth doctor transport failures and quote 403 validation errors as session expiry.\nConfidence: high\nScope-risk: narrow\nDirective: Do not broaden the free-text session classifier without regressions for auth doctor and quote upstream validation failures.\nTested: npm run lint --workspace toss-securities; npm run test --workspace toss-securities; npm run ci; manual mock tossctl validation_error checks; architect verification CLEAR\nNot-tested: Live tossctl network/auth session against real Toss upstream
2026-04-30 02:30:18 +09:00
Jeffrey (Dongkyu) Kim
4e8f5865f8 Clarify toss empty-output session expiry
Portfolio and watchlist reads can exit successfully with empty payloads when the stored Toss session has expired. The empty-output path now verifies the session before JSON parsing and only promotes confirmed invalid auth doctor data into TossSessionExpiredError.

Constraint: Scope is limited to toss-securities issue #126 follow-up on PR #192

Rejected: Treat auth doctor execution failures as expired sessions | unsupported or failing doctor output is inconclusive without parsed session.valid=false

Confidence: high

Scope-risk: narrow

Directive: Keep empty-result session expiry classification tied to explicit auth doctor confirmation

Tested: npm run test --workspace toss-securities; npm run lint --workspace toss-securities; npm run ci; manual mock tossctl blank stdout invalid/inconclusive doctor checks
2026-04-30 02:10:51 +09:00
galvaomica
9967caf6b8 fix(toss-securities): clarify session expiry and quote 403 handling 2026-04-30 01:46:43 +09:00
354 changed files with 46896 additions and 4745 deletions

View file

@ -0,0 +1,13 @@
{
"name": "k-skill",
"owner": {
"name": "NomaDamas"
},
"plugins": [
{
"name": "k-skill",
"source": "./",
"description": "한국인을 위한 90+ Agent Skill 번들 — SRT/KTX/당근/쿠팡/카톡/정부24 등 한국 일상·업무 자동화"
}
]
}

110
.claude-plugin/plugin.json Normal file
View file

@ -0,0 +1,110 @@
{
"name": "k-skill",
"description": "한국인을 위한 90+ Agent Skill 모음 — SRT/KTX/당근/쿠팡/카톡/정부24 등 한국 일상·업무 자동화",
"version": "1.0.0",
"author": {
"name": "NomaDamas"
},
"homepage": "https://github.com/NomaDamas/k-skill",
"repository": "https://github.com/NomaDamas/k-skill",
"license": "MIT",
"skills": [
"./biz-health-check",
"./bunjang-search",
"./catchtable-sniper",
"./cheap-gas-nearby",
"./corporate-registration-consulting",
"./coupang-product-search",
"./court-auction-notice-search",
"./daangn-cars-search",
"./daangn-jobs-search",
"./daangn-realty-search",
"./daangn-used-goods-search",
"./daishin-report-search",
"./daiso-product-search",
"./danawa-price-search",
"./delivery-tracking",
"./donation-place-search",
"./emergency-room-beds",
"./express-bus-booking",
"./fine-dust-location",
"./flight-ticket-search",
"./foresttrip-vacancy",
"./fsc-corporate-info",
"./g2b-sanctioned-supplier",
"./gangnamunni-clinic-search",
"./geeknews-search",
"./gongsijiga-search",
"./han-river-water-level",
"./hipass-receipt",
"./hola-poke-yeoksam",
"./household-waste-info",
"./hwp",
"./intercity-bus-booking",
"./iros-registry-automation",
"./jobkorea-talent-search",
"./joseon-sillok-search",
"./k-dart",
"./k-schoollunch-menu",
"./k-skill-cleaner",
"./k-skill-setup",
"./kakao-bar-nearby",
"./kakao-map",
"./kakaotalk-mac",
"./kbl-results",
"./kbo-results",
"./kleague-results",
"./korea-weather",
"./korean-character-count",
"./korean-cinema-search",
"./korean-humanizer",
"./korean-jangbu-for",
"./korean-law-search",
"./korean-marathon-schedule",
"./korean-middle-korean",
"./korean-patent-search",
"./korean-privacy-terms",
"./korean-scholarship-search",
"./korean-slang-writing",
"./korean-spell-check",
"./korean-stock-search",
"./korean-transit-route",
"./kosis-stats",
"./kstartup-search",
"./ktx-booking",
"./lck-analytics",
"./lh-notice-search",
"./library-book-search",
"./local-election-candidate-search",
"./localdata-business-status",
"./lotto-results",
"./market-kurly-search",
"./mfds-drug-safety",
"./mfds-food-safety",
"./myrealtrip-search",
"./national-pension-workplace",
"./naver-blog-research",
"./naver-news-search",
"./naver-shopping-search",
"./nts-business-registration",
"./nts-tax-delinquency",
"./ohou-today-deal",
"./olive-young-search",
"./parking-lot-search",
"./public-restroom-nearby",
"./real-estate-search",
"./rhwp-advanced",
"./rhwp-edit",
"./saramin-talent-search",
"./seoul-bike",
"./seoul-density",
"./seoul-subway-arrival",
"./sh-notice-search",
"./srt-booking",
"./subway-lost-property",
"./ticket-availability",
"./toss-securities",
"./used-car-price-search",
"./zipcode-search"
]
}

38
.dockerignore Normal file
View file

@ -0,0 +1,38 @@
.git
.github
.gitignore
.DS_Store
.omx
.sisyphus
.venv
.env
.env.*
*.dec
*.plaintext
__pycache__
**/__pycache__
**/node_modules
**/dist
**/.next
**/.cache
docs
tests
test
**/test
**/tests
**/*.test.js
**/*.test.py
**/*.spec.js
scripts/build-manus-bundle.js
*.md
LICENSE
CONTRIBUTING.md
CHANGELOG.md
**/CHANGELOG.md
**/README.md
**/*.test.js
.changeset
.claude
.agents
.cursor
.kiro

View file

@ -10,9 +10,9 @@ jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 20
cache: npm

View file

@ -0,0 +1,147 @@
name: Deploy k-skill-proxy to Cloud Run
# Live: https://k-skill-proxy.nomadamas.org
# GCP project: k-skill-proxy, region: asia-northeast1
# Auth: Workload Identity Federation. Setup: docs/deploy-k-skill-proxy.md
on:
push:
branches: [main]
workflow_dispatch: {}
permissions:
contents: read
id-token: write
concurrency:
group: deploy-k-skill-proxy
cancel-in-progress: false
env:
GCP_PROJECT_ID: k-skill-proxy
GCP_REGION: asia-northeast1
AR_REPO: k-skill
SERVICE_NAME: k-skill-proxy
IMAGE_NAME: k-skill-proxy
jobs:
deploy:
name: Build and deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Authenticate to Google Cloud (Workload Identity Federation)
id: auth
uses: google-github-actions/auth@v3
with:
project_id: ${{ env.GCP_PROJECT_ID }}
workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}
service_account: ${{ secrets.GCP_DEPLOY_SERVICE_ACCOUNT }}
token_format: access_token
- name: Set up gcloud CLI
uses: google-github-actions/setup-gcloud@v3
with:
project_id: ${{ env.GCP_PROJECT_ID }}
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker ${{ env.GCP_REGION }}-docker.pkg.dev --quiet
- name: Resolve image URI
id: image
run: |
IMAGE_URI="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${AR_REPO}/${IMAGE_NAME}:${GITHUB_SHA}"
echo "uri=${IMAGE_URI}" >> "$GITHUB_OUTPUT"
echo "Image: ${IMAGE_URI}"
- name: Build container image
run: |
docker build \
--tag "${{ steps.image.outputs.uri }}" \
--file packages/k-skill-proxy/Dockerfile \
.
- name: Push image to Artifact Registry
run: docker push "${{ steps.image.outputs.uri }}"
- name: Deploy to Cloud Run
id: deploy
uses: google-github-actions/deploy-cloudrun@v3
with:
service: ${{ env.SERVICE_NAME }}
region: ${{ env.GCP_REGION }}
image: ${{ steps.image.outputs.uri }}
secrets: |-
AIR_KOREA_OPEN_API_KEY=AIR_KOREA_OPEN_API_KEY:latest
KMA_OPEN_API_KEY=KMA_OPEN_API_KEY:latest
SEOUL_OPEN_API_KEY=SEOUL_OPEN_API_KEY:latest
HRFCO_OPEN_API_KEY=HRFCO_OPEN_API_KEY:latest
OPINET_API_KEY=OPINET_API_KEY:latest
DATA_GO_KR_API_KEY=DATA_GO_KR_API_KEY:latest
KEDU_INFO_KEY=KEDU_INFO_KEY:latest
DATA4LIBRARY_AUTH_KEY=DATA4LIBRARY_AUTH_KEY:latest
FOODSAFETYKOREA_API_KEY=FOODSAFETYKOREA_API_KEY:latest
KAKAO_REST_API_KEY=KAKAO_REST_API_KEY:latest
KRX_API_KEY=KRX_API_KEY:latest
KOSIS_API_KEY=KOSIS_API_KEY:latest
NAVER_SEARCH_CLIENT_ID=NAVER_SEARCH_CLIENT_ID:latest
NAVER_SEARCH_CLIENT_SECRET=NAVER_SEARCH_CLIENT_SECRET:latest
LAW_OC=LAW_OC:latest
env_vars: |-
KSKILL_PROXY_HOST=0.0.0.0
KSKILL_PROXY_NAME=k-skill-proxy
KSKILL_PROXY_CACHE_TTL_MS=300000
KSKILL_PROXY_RATE_LIMIT_WINDOW_MS=60000
KSKILL_PROXY_RATE_LIMIT_MAX=60
flags: >-
--platform=managed
--allow-unauthenticated
--cpu=1
--memory=512Mi
--min-instances=0
--max-instances=3
--concurrency=80
--timeout=60
--execution-environment=gen2
--cpu-boost
- name: Smoke test /health on the new revision
env:
SERVICE_URL: ${{ steps.deploy.outputs.url }}
run: |
set -euo pipefail
echo "Service URL: ${SERVICE_URL}"
for attempt in 1 2 3 4 5; do
if curl -fsS --max-time 15 "${SERVICE_URL}/health" >/tmp/health.json; then
break
fi
echo "Health probe attempt ${attempt} failed, retrying in 5s..."
sleep 5
done
python3 -c "
import json, sys
data = json.load(open('/tmp/health.json'))
if not data.get('ok'):
print('Health response is not ok:', data)
sys.exit(1)
missing = [k for k, v in data.get('upstreams', {}).items() if k.endswith('Configured') and v is not True]
if missing:
print('Upstreams not configured:', missing)
sys.exit(1)
print('Health OK. All upstreams configured.')
"
- name: Smoke test custom domain (k-skill-proxy.nomadamas.org)
run: |
set -euo pipefail
if curl -fsS --max-time 15 https://k-skill-proxy.nomadamas.org/health >/tmp/prod-health.json; then
python3 -c "
import json
data = json.load(open('/tmp/prod-health.json'))
print('Prod /health ok:', data.get('ok'))
"
else
echo "::warning::Custom domain /health probe failed; revision may need traffic split or DNS warm-up."
fi

View file

@ -28,9 +28,9 @@ jobs:
RELEASE_TAG: manus-bundle-latest
RELEASE_TITLE: "Manus bundle (rolling)"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 20
cache: npm

View file

@ -24,11 +24,11 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
@ -37,6 +37,41 @@ jobs:
- run: npm ci
- run: npm run ci
- name: Preflight verify npm auth
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
echo "::group::npm whoami"
NPM_USER=$(npm whoami 2>&1) || {
echo "::error::npm whoami failed NPM_TOKEN is invalid or expired. Rotate the token and update the repository secret."
exit 1
}
echo "Authenticated as: ${NPM_USER}"
echo "::endgroup::"
- name: Preflight list unpublished packages (diagnostic)
run: |
set -euo pipefail
echo "Packages that will be published:"
FOUND=0
for pkg_json in packages/*/package.json; do
PRIVATE=$(node -e "console.log(require('./${pkg_json}').private || false)")
[ "$PRIVATE" = "true" ] && continue
PKG=$(node -e "console.log(require('./${pkg_json}').name)")
LOCAL_VER=$(node -e "console.log(require('./${pkg_json}').version)")
REMOTE_VER=$(npm view "${PKG}" version 2>/dev/null || echo "")
if [ "${LOCAL_VER}" != "${REMOTE_VER}" ]; then
echo " → ${PKG}@${LOCAL_VER} (npm: ${REMOTE_VER:-not yet published})"
FOUND=1
fi
done
if [ "$FOUND" -eq 0 ]; then
echo " (none all versions are already on npm)"
fi
- name: Create npm release PR or publish changed packages
uses: changesets/action@v1
with:

View file

@ -16,20 +16,37 @@ permissions:
id-token: write
jobs:
detect_python_packages:
runs-on: ubuntu-latest
outputs:
has_python_packages: ${{ steps.detect.outputs.has_python_packages }}
steps:
- uses: actions/checkout@v5
- id: detect
shell: bash
run: |
if find python-packages -mindepth 2 -maxdepth 2 -name pyproject.toml -print -quit | grep -q .; then
echo "has_python_packages=true" >> "$GITHUB_OUTPUT"
else
echo "has_python_packages=false" >> "$GITHUB_OUTPUT"
fi
scaffold-only:
if: ${{ hashFiles('python-packages/**/pyproject.toml') == '' }}
needs: detect_python_packages
if: ${{ needs.detect_python_packages.outputs.has_python_packages != 'true' }}
runs-on: ubuntu-latest
steps:
- run: echo "No Python package exists yet. release-please remains scaffold-only."
release:
if: ${{ hashFiles('python-packages/**/pyproject.toml') != '' }}
needs: detect_python_packages
if: ${{ needs.detect_python_packages.outputs.has_python_packages == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- id: release
uses: googleapis/release-please-action@v4
uses: googleapis/release-please-action@v5
with:
config-file: .github/release-please/python-config.json
manifest-file: .github/release-please/python-manifest.json

4
.gitignore vendored
View file

@ -9,3 +9,7 @@ node_modules/
__pycache__/
dist/
.sisyphus/
.omo/
.gjc/
.agents/

View file

@ -20,6 +20,7 @@ These rules are repo-specific and apply to everything under this directory.
## Testing anti-patterns
- **Never write tests that assert `.changeset/*.md` files exist.** Changesets are consumed (deleted) by `changeset version` during the release flow. Any test guarding changeset file presence will break CI on the version-bump commit and block the release pipeline.
- **Never write tests that pin a workspace package's `version` field** (in `package.json` or `package-lock.json`). `changeset version` bumps these on every release, so any hardcoded version assertion will fail the next release commit and block the npm publish pipeline. Stable invariants like `name`, `license`, `engines.node`, or workspace link metadata are fine to assert; the `version` is not.
## Development skill install rules
@ -38,6 +39,7 @@ These rules are repo-specific and apply to everything under this directory.
## Free API proxy policy
- The built-in `k-skill-proxy` is for **free APIs only**.
- **k-skill-proxy inclusion rule**: A skill should be served through `k-skill-proxy` **only when the upstream requires an API key** (e.g., data.go.kr, KRX, Naver Search Open API, NEIS, Data4Library). Fully public endpoints that work without any authentication (e.g., realtyprice.kr) should be called directly from the user's machine, not routed through the proxy.
- Default posture: public read-only endpoint, **no proxy auth by default**.
- Keep free-API proxy surfaces narrow, allowlisted, cache-backed, and rate-limited.
- If abuse or operational issues appear later, add stricter controls then instead of preemptively requiring auth.
@ -45,10 +47,12 @@ These rules are repo-specific and apply to everything under this directory.
## Proxy server development
- 개발 repo (`dev` 브랜치)에서 proxy 코드를 수정하고, main에 merge하면 프로덕션에 반영된다.
- 프로덕션 배포본은 `~/.local/share/k-skill-proxy`에 main 브랜치 단독 clone으로 존재한다.
- cron job (`0 * * * *`)이 매시 정각에 `~/.local/share/k-skill-proxy/scripts/auto-update-proxy.sh`를 실행해 origin/main fetch → fast-forward pull → package-lock 변경 시 npm ci → pm2 restart 순서로 자동 배포한다.
- 로그: `/tmp/k-skill-proxy-update.log`
- 프로덕션 배포 대상은 **Google Cloud Run** (`asia-northeast1`, GCP project `k-skill-proxy`)이며, 커스텀 도메인 `k-skill-proxy.nomadamas.org`로 노출된다.
- `main` 브랜치에 merge되면 `.github/workflows/deploy-k-skill-proxy.yml`이 Workload Identity Federation으로 GCP 인증 → Artifact Registry로 image build/push → Cloud Run 재배포 → `/health` smoke test까지 자동으로 수행한다.
- 따라서 **dev에서 route를 추가/수정한 뒤 main에 merge되기 전까지는 프로덕션 proxy에 반영되지 않는다.**
- proxy 서버 코드: `packages/k-skill-proxy/src/server.js`
- 컨테이너 이미지 빌드 정의: `packages/k-skill-proxy/Dockerfile`
- proxy 서버 테스트: `packages/k-skill-proxy/test/server.test.js`
- proxy 환경변수(API key 등)는 `~/.config/k-skill/secrets.env`에 넣고, `scripts/run-k-skill-proxy.sh`가 source한다.
- **dev에서 route를 추가/수정한 뒤 main에 merge되기 전까지는 프로덕션 proxy에 반영되지 않는다.** 로컬 테스트는 `node packages/k-skill-proxy/src/server.js`로 직접 실행한다.
- 로컬 테스트: `node packages/k-skill-proxy/src/server.js` (환경변수는 `~/.config/k-skill/secrets.env` 등에서 직접 export해서 띄운다)
- 프로덕션 시크릿은 GCP Secret Manager에 보관되고 Cloud Run runtime에 주입된다.
- **운영 관련 모든 절차는 [`docs/deploy-k-skill-proxy.md`](docs/deploy-k-skill-proxy.md)에 정리되어 있다.** 새 maintainer 인계를 위한 1회성 GCP/WIF 셋업, GitHub repository secrets 등록, upstream API 키 회전(rotation), 자동 배포 상태/로그/이미지 태그 확인, Cloud Run 트래픽 롤백, GitHub Actions 장애 시 로컬에서 동일한 배포를 수동으로 돌리는 비상 명령까지 전부 거기서 본다. proxy 운영 관련 어떤 질문이 들어와도 먼저 그 문서를 확인한다.

View file

@ -3,6 +3,7 @@
## Testing anti-patterns
- **Never write tests that assert `.changeset/*.md` files exist.** Changesets are consumed (deleted) by `changeset version` during the release flow. Any test guarding changeset file presence will break CI on the version-bump commit and block the release pipeline.
- **Never write tests that pin a workspace package's `version` field** (in `package.json` or `package-lock.json`). `changeset version` bumps these on every release, so any hardcoded version assertion will fail the next release commit and block the npm publish pipeline. Stable invariants like `name`, `license`, `engines.node`, or workspace link metadata are fine to assert; the `version` is not.
## Crawling/search skill authoring
@ -13,8 +14,9 @@
## Proxy server development
- 개발 repo: `/Users/jeffrey/Projects/k-skill` (이 디렉토리, `dev` 브랜치)
- 프로덕션 배포본: `~/.local/share/k-skill-proxy` (main 브랜치 단독 clone)
- **cron job** 이 매시 정각에 `origin/main` fetch → fast-forward pull → pm2 restart 실행
- 개발 repo: 이 디렉토리, `dev` 브랜치
- 프로덕션 배포 대상: **Google Cloud Run** (project `k-skill-proxy`, region `asia-northeast1`, custom domain `k-skill-proxy.nomadamas.org`)
- `main` 브랜치에 merge되면 `.github/workflows/deploy-k-skill-proxy.yml`이 자동으로 Cloud Run 재배포를 수행한다. 인증은 Workload Identity Federation, 이미지 빌드 정의는 `packages/k-skill-proxy/Dockerfile`, 시크릿은 GCP Secret Manager에서 주입된다. WIF/Secret Manager 셋업은 `docs/deploy-k-skill-proxy.md` 참고.
- 따라서 proxy route 변경은 **main에 merge되어야 프로덕션에 반영**된다. dev에서 코드를 바꿔도 프로덕션 proxy에는 영향 없음.
- 로컬 테스트는 `node packages/k-skill-proxy/src/server.js` 로 직접 실행하거나 `node --test packages/k-skill-proxy/test/server.test.js` 로 확인.
- **Proxy 편입 규칙**: k-skill-proxy에 route를 추가하려면 upstream이 API 키를 필요로 해야 한다. 공개 엔드포인트(키 불필요)는 skill 코드에서 직접 호출하고 프록시를 거치지 않는다.

76
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,76 @@
# 기여 가이드
외부 기여자는 이 문서를 기준으로 이슈, PR, 스킬, 패키지, 프록시 변경을 준비해 주세요. 이 레포의 세부 운영 규칙은 `AGENTS.md``CLAUDE.md`에도 있으며, 충돌할 때는 더 구체적인 최신 지침을 우선합니다.
## 소통 언어
- PR 코멘트, 이슈, 리뷰 등 모든 소통은 한국어로 진행합니다.
- 외부 문서나 로그를 인용해야 할 때는 원문을 함께 둘 수 있지만, 결정 사항과 요청 사항은 한국어로 요약해 주세요.
## 브랜치와 PR 대상
- 기능/수정 브랜치는 가능한 한 `feature/<issue-number>` 또는 `feature/#<issue-number>`처럼 추적 가능한 이름을 사용합니다.
- PR의 대상 브랜치는 반드시 `dev` 브랜치여야 합니다.
- `main` 브랜치로 PR을 만들 수 있는 사람은 `@vkehfdl1`뿐입니다. 그 외 기여자는 `main` 대상 PR을 만들지 않습니다.
- 프록시 서버 변경도 개발 레포의 `dev` 브랜치에서 작업하고, `main`에 머지된 뒤에만 프로덕션에 반영됩니다.
## 스킬 추가 또는 변경
스킬을 추가하거나 변경할 때는 관련 기능 문서와 `README.md`의 표를 포함해 코드와 문서를 함께 갱신합니다.
- 관련 기능 문서(`docs/features/<skill-name>.md`)를 추가하거나 업데이트합니다.
- `README.md`의 "어떤 걸 할 수 있나" 표에 스킬 이름, 설명, 사용자 로그인 필요 여부, 문서 링크를 업데이트합니다.
- 설치 흐름이 바뀌면 `docs/install.md`, `docs/setup.md`, `docs/security-and-secrets.md` 등 관련 문서도 함께 맞춥니다.
- 출처나 공식 표면이 바뀌면 `docs/sources.md`에 반영합니다.
- 스킬 개발/테스트 시에는 현재 스킬 디렉터리를 먼저 홈 디렉터리 전역 스킬 위치에 동기화합니다.
- Claude Code: `~/.claude/skills/<skill-name>`
- agents 호환 런타임: `~/.agents/skills/<skill-name>`
- `~/.agents/skills`가 symlink 등으로 우회되어 있으면 기존 indirection을 존중합니다.
- 사용자가 명시적으로 요청하지 않는 한 레포 내부에 `.claude` 또는 `.agents` 설치 테스트 디렉터리를 만들지 않습니다.
## npm 패키지와 릴리스
- Node 패키지는 `packages/*` 아래 npm workspaces로 관리합니다.
- npm 패키지를 수정할 때는 Changesets를 조사하고, 자동 CD가 올바르게 트리거되도록 `.changeset/*.md` 변경이 필요한지 신중히 판단합니다.
- 패키지 릴리스 목적의 버전 변경은 `package.json`만 직접 수정하지 말고 Changesets 흐름을 사용합니다.
- npm publish는 GitHub Actions가 생성하는 **Version Packages** PR이 `main`에 머지된 뒤 자동으로 수행되는 것을 전제로 합니다.
- Changeset 파일의 존재 여부를 테스트로 검증하지 않는다. Changesets는 `changeset version` 단계에서 소비되어 삭제될 수 있으므로, 그런 테스트는 버전 bump 커밋의 CI를 막습니다.
- `package.json``package-lock.json``version` 필드를 테스트에서 고정하지 않는다. Changesets 릴리스 흐름에서 매번 바뀔 수 있으므로, 테스트는 `name`, `license`, `engines.node`, workspace link metadata처럼 안정적인 invariant를 검증합니다.
- 현재 구현이 registry token 기반인 경우에도 신규 또는 재설계 흐름은 trusted publishing/OIDC를 우선합니다. 기존 token 기반 경로를 고칠 때는 현재 구현 예외와 목표 원칙을 PR 설명에 분리해 적습니다.
## Python 패키지와 PyPI
- Python 패키지는 `python-packages/*` 아래에 둡니다.
- Python 릴리스는 release-please 기반입니다.
- 실제 Python 패키지가 생기기 전까지 Python release workflow는 scaffold-only로 유지합니다.
- PyPI publish는 release-please가 구체적인 패키지 경로에 대해 `release_created=true`를 보고할 때만 실행되도록 설계합니다.
- PyPI도 가능하면 trusted publishing/OIDC를 우선합니다.
## API와 k-skill-proxy 정책
- `k-skill-proxy`는 무료 API 전용입니다.
- 신규 proxy route는 upstream이 API key를 요구하는 무료 API인 경우에만 `k-skill-proxy` 경유를 검토합니다. 기존 승인 예외를 넓히려면 근거와 운영 경계를 문서화합니다.
- 인증 없이 동작하는 공개 read-only endpoint는 기본적으로 사용자 머신에서 직접 호출하고, 불필요하게 프록시 운영 표면을 넓히지 않습니다.
- 유료 API, 사용자별 과금 API, 개인 계정 권한이 필요한 API는 `k-skill-proxy`를 타지 않도록 설계합니다.
- 기본 자세는 공개 read-only endpoint, proxy auth 없음입니다.
- 프록시 표면은 좁게 유지하고 allowlist, cache, rate limit를 적용합니다.
- 남용이나 운영 문제가 실제로 나타나면 그때 더 강한 제어를 추가합니다.
## 프록시 서버 개발과 배포
- 프록시 서버 코드: `packages/k-skill-proxy/src/server.js`
- 프록시 서버 테스트: `packages/k-skill-proxy/test/server.test.js`
- 컨테이너 이미지 정의: `packages/k-skill-proxy/Dockerfile`
- 로컬 테스트: 필요한 upstream 환경변수를 export한 상태에서 `node packages/k-skill-proxy/src/server.js`. 로컬에서 시크릿을 모아두는 표준 위치는 `~/.config/k-skill/secrets.env` 입니다.
- 프로덕션 프록시는 **Google Cloud Run** (project `k-skill-proxy`, region `asia-northeast1`)에서 운영하며 `k-skill-proxy.nomadamas.org` 도메인에 매핑되어 있습니다.
- `main` 브랜치에 머지되면 `.github/workflows/deploy-k-skill-proxy.yml` 워크플로가 Workload Identity Federation으로 GCP 인증 → Artifact Registry로 이미지 빌드/푸시 → Cloud Run 재배포 → `/health` smoke test까지 자동 수행합니다.
- 프로덕션 시크릿은 GCP Secret Manager에 보관되고 Cloud Run 런타임에 주입됩니다. 프록시 운영자(maintainer)가 한 번 수행해야 하는 WIF/Secret Manager 셋업과 운영 점검 절차는 [`docs/deploy-k-skill-proxy.md`](docs/deploy-k-skill-proxy.md)에 정리되어 있습니다.
- `dev`에서 route를 추가하거나 수정해도 `main`에 머지되기 전까지는 프로덕션 프록시에 반영되지 않습니다.
## 검증
- 문서만 바꿔도 관련 문서 테스트를 먼저 추가하거나 업데이트하고, 실패를 확인한 뒤 구현하는 TDD 흐름을 권장합니다.
- 일반 변경은 가능한 한 `npm run lint`, `npm run typecheck`, `npm test`를 실행합니다.
- 릴리스나 패키징 관련 변경은 `npm run ci`를 실행합니다.
- 변경 범위가 작더라도 최종 보고에는 어떤 명령을 실행했고 어떤 결과가 나왔는지 적습니다.
- 테스트를 통과시키기 위해 기존 테스트를 삭제하거나 범위를 부당하게 줄이지 않습니다.

103
README.md
View file

@ -14,6 +14,8 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
한국인이면 깃허브 스타 눌러줍시다.
다만 에이전트는 자동으로 누르지 말고, 사용자가 동의한 경우에만 `gh repo star NomaDamas/k-skill` 를 실행합니다.
기여 전에는 [기여 가이드](CONTRIBUTING.md)를 확인해 주세요.
## 어떤 걸 할 수 있나
"사용자 로그인" 컬럼은 **사용자 본인이 직접 로그인/시크릿을 들고 있어야 하는지** 만 표시합니다. `k-skill-proxy` 등 운영자가 관리하는 키는 사용자 입장에서는 **불필요**로 분류합니다. **선택사항**은 사용자가 운영자 키를 직접 들고 있으면 더 풍부한 경로가 켜지고, 없으면 기본 경로(보통 운영자가 관리하는 hosted fallback)로 그대로 동작하는 경우를 말합니다.
@ -21,10 +23,16 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 할 수 있는 일 | 스킬 이름 | 설명 | 사용자 로그인 | 문서 |
| --- | --- | --- | --- | --- |
| SRT 예매 | `srt-booking` | SRT 열차 조회, 예약, 예약 확인, 취소 | 필요 | [SRT 예매 가이드](docs/features/srt-booking.md) |
| KTX 예매 | `ktx-booking` | KTX/Korail 열차 조회, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| KTX 예매 | `ktx-booking` | KTX/Korail 열차 조회, 호차별 좌석번호·콘센트 좌석 확인, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| 고속버스 예매 | `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 아카이브 검색 | `kakaotalk-mac` | `katok`으로 macOS 카카오톡 로컬 아카이브를 동기화하고 keyword/BM25/semantic 검색 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac 아카이브 검색](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |
| 카카오맵 장소·자동차 길찾기 | `kakao-map` | Kakao Local 키워드/카테고리/좌표↔주소 변환 + Kakao Mobility 자동차 길찾기(거리·소요시간·통행료·예상 택시요금) | 불필요 | [카카오맵 가이드](docs/features/kakao-map.md) |
| 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
| 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |
| 한국 날씨 조회 | `korea-weather` | 기상청 단기예보 기반 한국 날씨 조회 | 불필요 | [한국 날씨 조회 가이드](docs/features/korea-weather.md) |
@ -33,11 +41,23 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 법령 검색 | `korean-law-search` | 한국 법령/조문/판례/유권해석 검색 | 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 등기부등본 자동화 | `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) |
| 사업자 실사 종합 | `biz-health-check` | 사업자등록번호를 중심으로, 상호·지역을 함께 주면 국세청 상태·국민연금·체납 명단·금융위 법인개요·부정당제재·인허가 영업상태를 교차 조회한 실사 리포트(점수·판정 없이 사실만) | 불필요 | [사업자 실사 종합 가이드](docs/features/biz-health-check.md) |
| 국민연금 가입 사업장 조회 | `national-pension-workplace` | 사업장명으로 국민연금 가입자수·당월 고지금액·월별 추이 조회(공공데이터포털 API, 프록시 경유) | 불필요 | [국민연금 가입 사업장 조회 가이드](docs/features/national-pension-workplace.md) |
| 국세 체납 명단공개 검색 | `nts-tax-delinquency` | 상호·법인명으로 국세청 고액·상습체납자 명단공개 대조(무인증 공개 검색) | 불필요 | [국세 체납 명단공개 검색 가이드](docs/features/nts-tax-delinquency.md) |
| 금융위 기업기본정보 조회 | `fsc-corporate-info` | 법인명으로 대표자·설립일·업종 등 법인 개요 조회와 사업자번호 교차검증(공공데이터포털 API, 프록시 경유) | 불필요 | [금융위 기업기본정보 조회 가이드](docs/features/fsc-corporate-info.md) |
| 부정당제재업체 조회 | `g2b-sanctioned-supplier` | 사업자번호로 나라장터 부정당제재(조회시점 유효 제재) 조회(공공데이터포털 API, 프록시 경유) | 불필요 | [부정당제재업체 조회 가이드](docs/features/g2b-sanctioned-supplier.md) |
| 인허가 영업상태 조회 | `localdata-business-status` | 상호+시군구로 동네 사업장(208업종)의 영업/휴업/폐업·업력·주소 조회(LOCALDATA 무인증) | 불필요 | [인허가 영업상태 조회 가이드](docs/features/localdata-business-status.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) |
| 장학금 검색 및 조회 | `korean-scholarship-search` | 한국장학재단·전국 대학교·재단·기업 장학 공고를 검색해 금액·자격·지원구간·링크를 정리하고 KST 기준 현재 날짜 마감 상태와 조건별 필터링까지 제공 | 불필요 | [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md) |
| 생활쓰레기 배출정보 조회 | `household-waste-info` | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
| 학교 급식 식단 조회 | `k-schoollunch-menu` | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
@ -46,48 +66,69 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 식품 안전 체크 | `mfds-food-safety` | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 한국 주식 정보 조회 | `korean-stock-search` | KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 금감원 DART 전자공시 조회 | `k-dart` | 공시검색, 기업개황, 재무제표, 배당, 증자/감자, 감사의견, 주요사항보고서 등 14개 endpoint | 필요 | [금감원 DART 전자공시 조회 가이드](docs/features/k-dart.md) |
| 잡코리아 인재검색 | `jobkorea-talent-search` | 잡코리아 기업회원 로그인 세션에서 마스킹 후보 정보를 읽고 유료 열람 전 shortlist 작성 | 필요 | [잡코리아 인재검색 가이드](docs/features/jobkorea-talent-search.md) |
| 사람인 인재풀 검색 | `saramin-talent-search` | 사람인 기업회원 인재풀에서 마스킹 후보 정보를 읽고 유료 열람 전 shortlist 작성 | 필요 | [사람인 인재풀 검색 가이드](docs/features/saramin-talent-search.md) |
| 대신증권 리포트 조회 | `daishin-report-search` | GitHub Pages에 공개된 대신증권 리포트 HTML 미러에서 최신 리포트 목록, 원문, 설명 페이지, Rating/Target 표를 조회 | 불필요 | [대신증권 리포트 조회 가이드](docs/features/daishin-report-search.md) |
| 국가데이터처 KOSIS 통계 조회 | `kosis-stats` | 국가데이터처가 운영하는 KOSIS(국가통계포털) Open API로 통계표 검색·메타·데이터·대용량 자료 조회 (조회 전용) | 일반 조회 불필요 (`bigdata`/`--direct` 필요) | [국가데이터처 KOSIS 통계 조회 가이드](docs/features/kosis-stats.md) |
| 조선왕조실록 검색 | `joseon-sillok-search` | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 한국 특허 정보 검색 | `korean-patent-search` | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
| 근처 가장 싼 주유소 찾기 | `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) |
| K리그 경기 결과 조회 | `kleague-results` | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
| LCK 경기 분석 | `lck-analytics` | LCK 경기 결과, 현재 순위, live turning point, 밴픽, 패치 메타, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
| 토스증권 조회 | `toss-securities` | 토스증권 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 토스증권 조회 | `toss-securities` | 토스증권 공식 Open API(OAuth2) 우선, tossctl fallback으로 계좌·보유주식·시세·주문조회 등 조회 전용 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 하이패스 영수증 발급 | `hipass-receipt` | 하이패스 사용내역 조회 및 영수증 출력 payload 준비 | 필요 | [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md) |
| 캐치테이블 예약 스나이핑 | `catchtable-sniper` | 로그인된 캐치테이블 Chrome 세션으로 빈자리 감시, 오픈런, 자동 예약 시도 | 필요 | [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md) |
| 공연 일정·잔여석 조회 | `ticket-availability` | YES24·인터파크 공연의 회차별 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회 (조회 전용, 예매·결제 없음) | 불필요 | [공연 일정·잔여석 조회 가이드](docs/features/ticket-availability.md) |
| 로또 당첨 확인 | `lotto-results` | 로또 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 조회/변환 | `hwp` | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 (kordoc 기반 read-only) | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| HWP 문서 편집 | `rhwp-edit` | `.hwp` 본문 텍스트 삽입/삭제, 표 생성, 셀 수정, replace-all (`k-skill-rhwp` CLI + `@rhwp/core` WASM, HWP 5.x round-trip) | 불필요 | [HWP 문서 편집 가이드](docs/features/rhwp-edit.md) |
| HWP 레이아웃·IR 디버깅 | `rhwp-advanced` | 업스트림 `rhwp` Rust CLI(`export-svg --debug-overlay`, `dump`, `dump-pages`, `ir-diff`, `thumbnail`, `convert`)로 HWP 레이아웃 진단·IR 덤프·버전 비교·썸네일 추출·배포용 문서 잠금 해제 | 불필요 | [HWP 레이아웃·IR 디버깅 가이드](docs/features/rhwp-advanced.md) |
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~`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` | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 픽업 가능 여부 확인 (정확한 매장별 재고 수량은 다이소몰 보안 정책으로 2026-05-05 부터 차단됨) | 불필요 | [다이소 상품 조회 가이드](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) |
| 당근알바 검색 | `daangn-jobs-search` | 당근알바 공개 웹 데이터 표면으로 키워드·지역 기반 알바 공고 검색과 상세 조회 | 불필요 | [당근알바 검색 가이드](docs/features/daangn-jobs-search.md) |
| 당근중고차 검색 | `daangn-cars-search` | 당근중고차 공개 웹 데이터 표면으로 지역·가격 조건 기반 차량 검색과 상세 조회 | 불필요 | [당근중고차 검색 가이드](docs/features/daangn-cars-search.md) |
| 중고차 가격 조회 | `used-car-price-search` | 중고차 인수가/월 렌트료 비교 조회 | 불필요 | [중고차 가격 조회 가이드](docs/features/used-car-price-search.md) |
| 한국어 맞춤법 검사 | `korean-spell-check` | 한국어 텍스트 맞춤법/문법 검사 및 교정안 정리 | 불필요 | [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md) |
| 네이버 블로그 리서치 | `naver-blog-research` | 네이버 블로그 검색, 원문 읽기, 이미지 다운로드, 한국어 콘텐츠 교차 검증 | 불필요 | [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md) |
| 네이버 쇼핑 가격비교 | `naver-shopping-search` | 네이버 검색 Open API 우선, 공개 BFF JSON fallback으로 상품 후보·현재 노출가·판매처 링크 비교 | 불필요 | [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md) |
| 다나와 최저가 비교 | `danawa-price-search` | 다나와 공개 검색/가격비교 표면으로 상품 후보·쇼핑몰별 가격·배송비 포함 실구매가·카드 할인가·무이자 할부 비교 | 불필요 | [다나와 최저가 비교 가이드](docs/features/danawa-price-search.md) |
| 네이버 뉴스 검색 | `naver-news-search` | 네이버 검색 Open API 뉴스 검색으로 기사 제목·요약·발행시각·원문/네이버 링크를 정리 | 불필요 | [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md) |
| 한국어 글자 수 세기 | `korean-character-count` | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
| 한국어 유행어 글쓰기 | `korean-slang-writing` | 나무위키 유행어 기반 큐레이션 시드로 한국 유행어 후보 조회, 무드/문맥/safety 필터 및 나무위키 best-effort 요약으로 한국어 글을 유행어 느낌으로 작성 | 불필요 | [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md) |
| 한국어 AI 윤문 | `korean-humanizer` | AI가 쓴 티 나는 한국어 글을 번역체·AI 상투어·과장된 의의·줄표/이모지 등 흔적을 심각도(S1/S2/S3)로 분류해 의미는 보존하며 사람 글로 윤문, 목표 글자수도 맞춤 | 불필요 | [한국어 AI 윤문 가이드](docs/features/korean-humanizer.md) |
| 한국 중세 국어풍 변환 | `korean-middle-korean` | 한국어 입력문을 중세국어풍 조사·어미·Hanja 힌트·성조점이 섞인 창작용 문체로 결정론적 변환 | 불필요 | [한국 중세 국어풍 변환 가이드](docs/features/korean-middle-korean.md) |
| K-스킬 클리너 | `k-skill-cleaner` | 인터뷰와 코딩 에이전트별 트리거 횟수 통계를 합쳐 불필요한 K-스킬 삭제 후보를 추천 | 불필요 | [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md) |
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
>
> **블루리본 측이 `www.bluer.co.kr` 에 자동화 접근 전면 차단을 적용해 스킬이 더 이상 동작하지 않습니다.**
>
> - 브라우저·`curl`·Playwright·TLS impersonation 등 가능한 우회를 모두 검증했지만 nginx 단에서 403이 반환되며, 같은 가구 공인 IP로도 특정 장비만 차단되는 상황이 관측되었습니다.
> - 유료 회원권 보유자도 접근이 막히는 사례가 확인되었습니다. 복구 여부와 일정은 블루리본 측 정책에 전적으로 달려 있어 이 레포에서 대응할 수 있는 범위를 벗어났습니다.
> - 해당 스킬 디렉토리(`blue-ribbon-nearby/`)와 관련 프록시 라우트는 히스토리 보존을 위해 당분간 남겨두지만, **새 프로젝트에서는 해당 스킬을 사용하지 마세요.** 차단이 해제되는 날이 오면 이 안내를 제거하고 재검증하겠습니다.
## Claude Code 플러그인으로 설치
[Claude Code](https://claude.com/claude-code)에서는 마켓플레이스로 전체 스킬을 한 번에 설치할 수 있습니다.
```
/plugin marketplace add NomaDamas/k-skill
/plugin install k-skill@k-skill
```
설치하면 스킬이 `/k-skill:<스킬 이름>` 네임스페이스로 호출됩니다 (예: `/k-skill:lotto-results`). 개별 디렉토리를 직접 복사하는 수동 설치나 다른 에이전트 설치는 [설치 방법](docs/install.md)을 참고하세요.
## 처음 시작하는 순서
@ -102,6 +143,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 문서 | 설명 |
| --- | --- |
| [설치 방법](docs/install.md) | 패키지 설치, 선택 설치, 로컬 테스트 방법 |
| [기여 가이드](CONTRIBUTING.md) | 외부 기여자를 위한 소통, PR 대상 브랜치, 스킬 문서, Changesets, 프록시 정책 |
| [Manus.ai 에서 가져오기](docs/install-manus.md) | Manus.ai 에서 개별 스킬 폴더 URL 가져오기 또는 `npm run build:manus-bundle` 로 빌드한 `.skill` 파일을 드래그-드롭으로 업로드하는 방법 |
| [공통 설정 가이드](docs/setup.md) | credential resolution order, 기본 secrets 파일 준비 |
| [보안/시크릿 정책](docs/security-and-secrets.md) | 인증 정보 저장 원칙, 금지 패턴, 표준 환경변수 이름 |
@ -114,9 +156,14 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [SRT 예매](docs/features/srt-booking.md)
- [KTX 예매](docs/features/ktx-booking.md)
- [고속버스 예매](docs/features/express-bus-booking.md)
- [시외버스 예매](docs/features/intercity-bus-booking.md)
- [자연휴양림 빈 객실 조회](docs/features/foresttrip-vacancy.md)
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [카카오톡 Mac 아카이브 검색](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
- [카카오맵 가이드](docs/features/kakao-map.md)
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
- [긱뉴스 조회 가이드](docs/features/geeknews-search.md)
- [한국 날씨 조회 가이드](docs/features/korea-weather.md)
@ -124,52 +171,80 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md)
- [사업자 실사 종합 가이드](docs/features/biz-health-check.md)
- [국민연금 가입 사업장 조회 가이드](docs/features/national-pension-workplace.md)
- [국세 체납 명단공개 검색 가이드](docs/features/nts-tax-delinquency.md)
- [금융위 기업기본정보 조회 가이드](docs/features/fsc-corporate-info.md)
- [부정당제재업체 조회 가이드](docs/features/g2b-sanctioned-supplier.md)
- [인허가 영업상태 조회 가이드](docs/features/localdata-business-status.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)
- [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md)
- [도서관 도서 조회 가이드](docs/features/library-book-search.md)
- [기부처 조회 가이드](docs/features/donation-place-search.md)
- [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md)
- [식품 안전 체크 가이드](docs/features/mfds-food-safety.md)
- [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md)
- [국가데이터처 KOSIS 통계 조회 가이드](docs/features/kosis-stats.md)
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
- [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md)
- [근처 가장 싼 주유소 찾기 가이드](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)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
- [토스증권 조회 가이드](docs/features/toss-securities.md)
- [대신증권 리포트 조회 가이드](docs/features/daishin-report-search.md)
- [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md)
- [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md)
- [공연 일정·잔여석 조회 가이드](docs/features/ticket-availability.md)
- [로또 당첨 확인](docs/features/lotto-results.md)
- [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md)
- [법인등기 신청 컨설팅](docs/features/corporate-registration-consulting.md)
- [HWP 문서 조회/변환](docs/features/hwp.md)
- [HWP 문서 편집](docs/features/rhwp-edit.md)
- [HWP 레이아웃·IR 디버깅](docs/features/rhwp-advanced.md)
- [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
- [우편번호 검색](docs/features/zipcode-search.md)
- [다이소 상품 조회](docs/features/daiso-product-search.md)
- [강남언니 병원 조회 가이드](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)
- [당근알바 검색 가이드](docs/features/daangn-jobs-search.md)
- [당근중고차 검색 가이드](docs/features/daangn-cars-search.md)
- [중고차 가격 조회 가이드](docs/features/used-car-price-search.md)
- [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md)
- [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md)
- [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md)
- [다나와 최저가 비교 가이드](docs/features/danawa-price-search.md)
- [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md)
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
- [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md)
- [한국어 AI 윤문 가이드](docs/features/korean-humanizer.md)
- [한국 중세 국어풍 변환 가이드](docs/features/korean-middle-korean.md)
- [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md)
- [릴리스/배포 가이드](docs/releasing.md)

79
biz-health-check/SKILL.md Normal file
View file

@ -0,0 +1,79 @@
---
name: biz-health-check
description: 사업자등록번호 하나로 "이 사업자, 실제 문제 없나"를 확인한다 — 국세청 사업자등록 상태·국민연금 가입 사업장·국세 체납 명단·금융위 법인개요·조달청 부정당제재·지방행정 인허가 영업상태를 무료 공공 데이터로 교차 조회해 사실만 병렬하는 실사 리포트(점수·등급·위험 판정 없음).
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 사업자 실사 복합 조회 (biz-health-check)
## What this skill does
사업자등록번호(+상호/지역)를 입력하면 무료 공공 데이터 6종을 한 번에 교차 조회해 실사 리포트 한 장을 만든다. 같은 레포의 단품 스킬 helper를 그대로 재사용한다(단일 진실원천).
| 섹션 | 데이터 | 단품 스킬 | 경로 |
|---|---|---|---|
| 국세청 상태 | 계속/휴업/폐업·과세유형 | `nts-business-registration` | proxy |
| 국민연금 | 가입자수·당월 고지금액·월별 | `national-pension-workplace` | proxy |
| 체납 명단 | 고액·상습체납자 명단공개 대조 | `nts-tax-delinquency` | 직접(무인증) |
| 금융위 | 대표자·설립일·업종 법인개요 | `fsc-corporate-info` | proxy |
| 부정당제재 | 조회시점 유효 제재 | `g2b-sanctioned-supplier` | proxy |
| 인허가 영업상태 | 동네 사업장(208업종) 영업/폐업·업력 | `localdata-business-status` | 직접(무인증) |
공시 유무는 기존 `k-dart` 스킬을 함께 쓰면 된다.
## Design principles
- **점수·등급·"위험" 같은 해석 라벨을 산출하지 않는다.** 각 항목의 사실 + 출처 + 조회시각만 병렬한다. 판단은 사용자 몫이다.
- 한 항목 조회가 실패해도 전체를 막지 않고 그 항목만 정직하게 강등한다(`unavailable` + 사유).
- 단품 helper를 찾지 못하면(개별 설치 등) 해당 섹션만 건너뛰고 나머지를 진행한다.
## When to use
- "이 사업자(거래처/의뢰인) 실제 문제 없는지 한 번에 확인해줘"
- "○○○-○○-○○○○○ 살아있는 회사야? 직원은 좀 있고, 체납·입찰 제재 이력은 없어?"
## Prerequisites
- 인터넷 연결, `python3`
- 같은 레포의 단품 스킬 6종(이 복합이 helper를 재사용)
- proxy 섹션을 켜려면 hosted/self-host `k-skill-proxy` 접근 가능
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- proxy 섹션(국세청 상태·국민연금·금융위·부정당)은 운영 서버의 `DATA_GO_KR_API_KEY`로 동작한다. 활용신청 항목은 각 단품 스킬 문서를 따른다.
- 무인증 섹션(체납·인허가)은 키 없이 사용자 머신에서 직접 동작한다.
## Inputs
- `b_no`: 사업자등록번호 10자리(하이픈 허용) — 상태조회·부정당제재에 필요
- `--name`: 상호·법인명 — 국민연금·금융위·체납·인허가에 필요
- `--region`: 시군구 — 인허가(동네 사업장) 조회에 필요 (예: `제주제주시`)
- `--industry`: 인허가 업종(여러 번 지정 가능). 생략 시 음식점·카페·숙박
## CLI examples
```bash
python3 biz-health-check/scripts/biz_health_check.py 124-81-00998 --name "삼성전자"
# 동네 사업장까지 포함
python3 biz-health-check/scripts/biz_health_check.py --name "호텔샬롬" --region 제주제주시 --industry 숙박업
```
## Output
- `sections`: 6개 섹션 각각의 `data`(단품 응답 원문) 또는 `status: unavailable` + `note`
- 입력에 따라 일부 섹션은 생략된다(예: `--name` 없으면 국민연금/금융위/체납 생략).
## Failure modes
- 섹션별 강등은 리포트에 그대로 남는다(전체 실패가 아니다).
- proxy 섹션이 `503/502`면 운영 서버 키·활용신청 문제 — 각 단품 스킬 문서 참고.
## Official surfaces
- 각 단품 스킬 문서(`docs/features/<skill>.md`)의 공식 출처를 따른다.

View file

@ -0,0 +1,161 @@
"""Business due-diligence composite — runs the sibling k-skill providers at once.
사업자등록번호(+상호/지역) 하나로 "이 사업자, 실제 문제 없나" 무료 공공 데이터로
교차 조회해 실사 리포트 장을 만든다. 점수·등급·"위험" 라벨을 만들지 않고,
항목의 사실 + 출처 + 조회시각만 병렬한다. 판단은 사용자 몫이다.
복합 스킬은 같은 레포의 단품 스킬 helper들을 그대로 재사용한다(단일 진실원천):
- nts-business-registration 상태조회 (k-skill-proxy)
- national-pension-workplace 국민연금 사업장 (k-skill-proxy)
- fsc-corporate-info 금융위 법인개요 (k-skill-proxy)
- g2b-sanctioned-supplier 부정당제재 (k-skill-proxy)
- nts-tax-delinquency 체납 명단 (무인증 직접)
- localdata-business-status 인허가 영업상태 (무인증 직접, --region 필요)
단품 helper를 찾지 못하면 해당 항목만 정직하게 강등하고 나머지는 계속 진행한다.
"""
from __future__ import annotations
import argparse
import datetime as dt
import importlib.util
import json
import pathlib
import re
import sys
from typing import Any, Callable
KST = dt.timezone(dt.timedelta(hours=9))
_REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
# (섹션 키, 사람이 읽는 라벨, 단품 스킬 디렉토리, helper 파일명)
_SIBLINGS = {
"nts_status": ("국세청 사업자등록 상태", "nts-business-registration", "nts_business_registration.py"),
"national_pension": ("국민연금 가입 사업장", "national-pension-workplace", "national_pension_workplace.py"),
"fsc_corp": ("금융위 기업기본정보", "fsc-corporate-info", "fsc_corporate_info.py"),
"g2b_sanction": ("조달청 부정당제재", "g2b-sanctioned-supplier", "g2b_sanctioned_supplier.py"),
"tax_delinquency": ("국세 체납 명단공개", "nts-tax-delinquency", "nts_tax_delinquency.py"),
"localdata": ("지방행정 인허가 영업상태", "localdata-business-status", "localdata_business_status.py"),
}
def _now_iso() -> str:
return dt.datetime.now(KST).isoformat(timespec="seconds")
def _normalize_b_no(value: Any) -> str:
normalized = re.sub(r"\D", "", str(value or ""))
if not re.fullmatch(r"\d{10}", normalized):
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다 (하이픈 허용).")
return normalized
def _unavailable(module_key: str, note: str) -> dict:
label, skill_dir, _ = _SIBLINGS[module_key]
return {"provider": label, "skill": skill_dir, "status": "unavailable",
"looked_up_at": _now_iso(), "data": None, "note": note}
def _load(module_key: str) -> Any | None:
"""단품 스킬 helper를 레포 레이아웃 기준 파일 경로로 로드. 없으면 None."""
_, skill_dir, filename = _SIBLINGS[module_key]
path = _REPO_ROOT / skill_dir / "scripts" / filename
if not path.exists():
return None
spec = importlib.util.spec_from_file_location(f"_bhc_{module_key}", path)
if spec is None or spec.loader is None:
return None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _section(module_key: str, caller: Callable[[Any], dict]) -> dict:
"""단품 helper 하나를 호출해 섹션 결과로 감싼다. 어떤 오류든 강등."""
label, skill_dir, _ = _SIBLINGS[module_key]
base = {"provider": label, "skill": skill_dir, "looked_up_at": _now_iso()}
try:
module = _load(module_key)
except Exception as err:
return {**base, "status": "unavailable", "data": None,
"note": f"단품 스킬 '{skill_dir}' helper import 실패({type(err).__name__}: {err})."}
if module is None:
return {**base, "status": "unavailable", "data": None,
"note": f"단품 스킬 '{skill_dir}' helper를 찾지 못해 건너뜀 (개별 설치 시 함께 두세요)."}
try:
data = caller(module)
status = "unavailable" if isinstance(data, dict) and (data.get("status") == "unavailable" or data.get("error")) else "ok"
return {**base, "status": status, "data": data}
except Exception as err: # 경계 계약: 한 항목 실패가 전체를 막지 않는다
return {**base, "status": "unavailable", "data": None, "note": f"조회 실패({type(err).__name__}: {err})."}
def run(b_no: str | None, name: str | None = None, region: str | None = None,
industries: list[str] | None = None, *, base_url: str | None = None) -> dict:
no = _normalize_b_no(b_no) if b_no else None
name = (name or "").strip() or None
sections: dict[str, dict] = {}
if no:
sections["nts_status"] = _section(
"nts_status", lambda m: m.query_status([no], base_url=base_url))
else:
sections["nts_status"] = _unavailable("nts_status", "사업자등록번호가 없어 상태조회 생략.")
sections["national_pension"] = _section(
"national_pension",
lambda m: m.query_workplace(name, no, base_url=base_url)) if name else \
_unavailable("national_pension", "상호(--name)가 없어 국민연금 조회 생략.")
sections["fsc_corp"] = _section(
"fsc_corp",
lambda m: m.query_corp_outline(name, no, base_url=base_url)) if name else \
_unavailable("fsc_corp", "법인명(--name)이 없어 금융위 조회 생략.")
sections["g2b_sanction"] = _section(
"g2b_sanction", lambda m: m.query_sanctions(no, base_url=base_url)) if no else \
_unavailable("g2b_sanction", "사업자등록번호가 없어 부정당제재 조회 생략.")
sections["tax_delinquency"] = _section(
"tax_delinquency", lambda m: m.lookup(name)) if name else \
_unavailable("tax_delinquency", "상호(--name)가 없어 체납 명단 조회 생략.")
if name and region:
sections["localdata"] = _section(
"localdata", lambda m: m.lookup(name, region, industries))
else:
sections["localdata"] = _unavailable("localdata", "동네 사업장 인허가 조회는 상호(--name)와 지역(--region)이 함께 필요.")
return {
"query": {"b_no": no, "name": name, "region": region, "industries": industries},
"generated_at": _now_iso(),
"disclaimer": ("무료 공공 데이터의 사실만 병렬한 실사 리포트다. 점수·등급·위험 판정은 "
"하지 않으며, 동일성·해석은 사용자가 판단한다."),
"sections": sections,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="사업자 실사 복합 조회 (단품 k-skill 6종 묶음)")
parser.add_argument("b_no", nargs="?", default=None, help="사업자등록번호 10자리(하이픈 허용)")
parser.add_argument("--name", help="상호·법인명 — 국민연금/금융위/체납/인허가 조회에 필요")
parser.add_argument("--region", help="시군구 (동네 사업장 인허가 조회용 — 예: 제주제주시)")
parser.add_argument("--industry", action="append", dest="industries", help="인허가 업종(여러 번 지정 가능)")
parser.add_argument("--proxy-base-url")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
report = run(args.b_no, args.name, args.region, args.industries, base_url=args.proxy_base_url)
except ValueError as err:
print(json.dumps({"error": str(err)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,6 +1,6 @@
---
name: court-auction-notice-search
description: Browse 대법원경매정보(courtauction.go.kr) 부동산 매각공고 by 매각기일·법원·기일/기간 입찰, expand each notice into 사건번호·용도·주소·감정평가액·최저매각가, and look up a case directly by 법원+사건번호. Read-only, slow-by-design (~2s/call) to avoid IP blocks. Use when the user asks "오늘 어디서 부동산 경매가 열려?" "이 사건번호 정보 알려줘" or wants 매각공고 데이터를 에이전트가 다룰 수 있는 JSON으로.
description: Browse 대법원경매정보(courtauction.go.kr) 부동산 매각공고 by 매각기일·법원·기일/기간 입찰, expand each notice into 사건번호·용도·주소·감정평가액·최저매각가, search property items by free conditions(지역·용도·가격·면적·유찰횟수), and look up a case directly by 법원+사건번호. Read-only, slow-by-design (~2s/call) to avoid IP blocks.
license: MIT
metadata:
category: real-estate
@ -15,7 +15,7 @@ metadata:
대한민국 법원이 운영하는 공식 **법원경매정보** 사이트(`courtauction.go.kr`) 의 매각공고와 사건정보를 에이전트가 활용할 수 있는 JSON 형태로 변환해서 돌려준다.
- 공식 OPEN API가 없어 사이트 내부의 WebSquare JSON XHR endpoint를 그대로 호출한다.
- 1차 transport 는 직접 HTTP, 차단되거나 5xx 가 떨어질 때만 Playwright fallback 으로 전환한다 (`rebrowser-playwright` 또는 `playwright-core` 가 있을 때만).
- 1차 transport 는 직접 HTTP다. Workflow C 자유검색에서 raw-HTTP WAF성 HTTP 400이 날 때만 Playwright fallback 으로 전환하며, 명시적 차단(`BLOCKED`/`ipcheck=false`)은 기본적으로 중단한다 (`rebrowser-playwright` 또는 `playwright-core` 가 있을 때만).
- 사이트는 **IP 단위 봇 차단** 이 매우 공격적이다 (16회/30초 정도면 1시간 차단). 이 패키지는 호출 간 최소 2초 jitter, 세션당 호출 budget(기본 10회), `data.ipcheck === false` 즉시 throw 로 보수적으로 동작한다.
- **참고용 도구**다. 실제 입찰 전에는 반드시 법원 원문 매각공고를 다시 확인해야 한다.
@ -26,12 +26,12 @@ metadata:
- "기일입찰 vs 기간입찰만 나눠서 보여줘"
- "이 매각공고 안의 사건번호/용도/주소/감정평가액 다 보여줘"
- "사건번호 2024타경100001 진행 상황 알려줘"
- "서울 강남구 아파트 최저가 5억 이하 유찰 1회 이상 물건 찾아줘"
- "법원사무소 코드 표 줘"
## When not to use
- 동산(자동차·중기) 경매 (이번 v1 범위 밖)
- 자유 조건검색(지역·용도·가격대·면적·유찰횟수) — Workflow C 별도 follow-up 이슈에서 다룬다
- 특정 매각기일 날짜의 모든 법원 일정을 한 번에 (Workflow D 별도 follow-up 이슈)
- 매각물건 사진(전경/개황/내부) URL 노출 (별도 follow-up 이슈)
- 매각물건명세서 / 현황조사서 / 감정평가서 PDF 다운로드 (별도 follow-up 이슈)
@ -62,6 +62,7 @@ metadata:
- `POST /pgj/pgj143/selectRletDspslPbanc.on` — 매각공고 목록
- `POST /pgj/pgj143/selectRletDspslPbancDtl.on` — 매각공고 상세 (사건/물건 펼치기)
- `POST /pgj/pgj15A/selectAuctnCsSrchRslt.on` — 사건 단건 조회
- `POST /pgj/pgjsearch/searchControllerMain.on` — 물건 자유 조건검색 (PGJ151F00 → PGJ151M01)
- `POST /pgj/pgjComm/selectCortOfcCdLst.on` — 법원사무소코드 전체
## Workflow A — 매각공고 → 사건/물건 펼치기
@ -79,12 +80,42 @@ metadata:
3. `found:false / status:204` 면 사건이 존재하지 않거나 비공개. 사건번호 형식·법원이 맞는지 사용자에게 다시 확인한다.
4. `found:true``caseInfo`(사건명·접수일·청구액·재판부·진행상태), `items[]`(매각목적물 — 주소/배당요구종기), `schedule[]`(매각기일별 최저가/감정가/결과), `claimDeadline`, `relatedCases`, `stakeholders` 가 채워진다.
## Workflow C — 부동산 물건 자유 조건검색
1. 사용자의 조건을 `searchProperties()` 입력으로 매핑한다.
- `region: { sido, sigungu, dong }` — 코드 또는 대표 정적 sido 코드테이블의 한국어명. 지역을 주면 지번주소 검색(`cortStDvs:"2"`)으로, 지역이 없으면 매각공고 모드(`cortStDvs:"1"`)로 조회한다. 시군구/읍면동은 정적 표가 없으므로 코드로 직접 전달(예: `{ sido:"11", sigungu:"11680", dong:"11680101" }`)한다.
- `usage: { large, medium, small }` — 용도 대/중/소분류 코드(5자리, 예: 건물=`20000`) 또는 대분류 한국어명(`토지`/`건물`/`차량및운송장비`/`기타`).
- `priceRange` — 최저매각가격 원 단위 `{ min, max }` (실수 허용)
- `appraisedPriceRange` — 감정평가액 원 단위 `{ min, max }` (실수 허용)
- `saleDate``{ from, to }`
- `flbdCount` — 유찰횟수 `{ min, max }` **정수만**
- `area` — 면적(㎡) `{ min, max }` (실수 허용)
- `pageSize` — 페이지당 결과 수, upstream PGJ151 드롭다운에서 확인된 `10`/`20`/`50`/`100` 중 하나(기본 10). `1` 등 임의 값은 live endpoint 가 HTTP 400을 반환하므로 로컬에서 거부한다.
2. `searchProperties({ ... })` 호출 → `POST /pgj/pgjsearch/searchControllerMain.on`.
- 1차로 direct HTTP 시도. Workflow C raw-HTTP WAF의 HTTP 400을 만나면 자동으로 Playwright fallback 으로 재시도한다. fallback 을 끄려면 `{ fallback: false }`. `BLOCKED`(`ipcheck=false`)는 사이트의 명시적 차단 신호이므로 기본적으로 즉시 중단하며, 사용자가 위험을 이해하고 명시적으로 `{ fallbackOnBlocked: true }` 를 준 경우에만 재시도한다.
3. 응답의 `items[]` 는 핵심 raw 컬럼을 영문 키로 정규화한다:
- `saNo``caseNumber`, `srnSaNo`/`printCsNo``displayCaseNumber`
- `mokmulSer`/`maemulSer``itemNumber`
- `hjguSido + hjguSigu + hjguDong + daepyoLotno + buldNm``address`
- `gamevalAmt``appraisedPrice`, `minmaePrice``minimumSalePrice`
- `yuchalCnt``flbdCount`, `mulStatcd``statusCode`, `jinstatCd``progressStatusCode`
- `boCd``courtCode`, `jiwonNm``courtName`, `jpDeptNm``judgeDeptName`
- `lclsUtilCd/mclsUtilCd/sclsUtilCd``usageCodes.{large,medium,small}`
- `srchHjguSidoCd/SiguCd/DongCd``regionCodes.{sido,sigungu,dong}`
- `xCordi/yCordi``coordinates`, `wgs84Xcordi/Ycordi``coordinatesWgs84`
- `buldList/areaList/jimokList``buildingList/areaList/landCategoryList`
- `pjbBuldList``propertyDescription`, `mulBigo``remarks`
4. `getUsageCodes()` 는 4개 대분류(`10000=토지`, `20000=건물`, `30000=차량및운송장비`, `40000=기타`)와 일부 대표 중/소분류를 정적으로 반환한다. `getRegionCodes()` 는 19개 시도 + 코드만 반환한다. 시군구/읍면동은 upstream cascade XHR이 안정적이지 않아 정적 표에 포함하지 않으며 raw 코드를 그대로 전달하면 된다. 알 수 없는 값은 fail-open으로 통과한다.
5. **Same-name usage codes 보호**: `resolveUsageCode("아파트", "large")` 처럼 입력 이름이 다른 level 에만 존재하면, 같은 이름의 medium/small 코드를 잘못 리턴하지 않고 fail-open(원문 통과)한다.
## Throttling and call-budget rules
- 호출 간 최소 2초 (기본). 더 늘리려면 `--min-delay-ms 3000`.
- 기본 세션 budget 은 **10회**. 더 많은 조회가 필요하면 새 세션을 열거나 (`new CourtAuctionHttpClient`) `maxCallsPerSession` 을 명시적으로 늘린다.
- 차단(`data.ipcheck === false`)을 만나면 `BLOCKED` 에러를 즉시 throw 하고 멈춘다. 자동 retry 하지 않는다 (차단 연장 위험).
- 차단된 IP는 **약 1시간** 후 자연 복구된다. 그 사이에는 다른 IP/네트워크에서 작업하거나 사람이 브라우저로 사이트에 접속해서 차단 해제 화면을 거친다.
- **Workflow C 자유검색은 사이트 WAF 가 raw HTTP 호출을 더 엄격하게 차단**한다. `searchProperties()` 는 1차 direct HTTP에서 WAF성 HTTP 400을 만났을 때만 Playwright fallback 으로 재시도한다. 명시적 차단(`BLOCKED`/`ipcheck=false`)은 기본적으로 즉시 중단하며, 사용자가 위험을 이해하고 `fallbackOnBlocked:true` 를 준 경우에만 재시도한다. Playwright fallback 모듈(`rebrowser-playwright` 또는 `playwright-core`)이 없으면 첫 HTTP 400 실패가 그대로 throw 된다.
- searchProperties 는 같은 Playwright 클라이언트로 연속 호출하면 **10~15회 간격 호출에서 안정**하다. 그 이상 burst 호출이 필요하면 호출 사이 3~5초 sleep 을 두고 새 클라이언트를 열어라.
## Node.js example
@ -145,6 +176,8 @@ court-auction-notice-search codes courts --pretty | head -40
# 2. 입찰구분 (정적 코드)
court-auction-notice-search codes bid-types --pretty
court-auction-notice-search codes usages --pretty
court-auction-notice-search codes regions --pretty
# 3. 매각공고 목록
court-auction-notice-search notices --date 2026-04 --court-code B000210 --bid-type date --pretty
@ -154,6 +187,10 @@ court-auction-notice-search notices --date 2026-04 --court-code B000210 --bid-ty
# 5. 사건번호 직접 조회
court-auction-notice-search case --court-code B000210 --case-number "2024타경100001" --pretty
# 6. 자유 조건검색
court-auction-notice-search search --sido 서울특별시 --sigungu 11680 --usage-large 건물 --usage-medium 21200 \
--price-min 100000000 --price-max 500000000 --sale-from 2026-05-01 --sale-to 2026-05-20 --pretty
```
## Block / Error handling

101
daangn-cars-search/SKILL.md Normal file
View file

@ -0,0 +1,101 @@
---
name: daangn-cars-search
description: 당근중고차 공개 웹 데이터 표면으로 지역·가격 조건 기반 차량 검색과 상세 조회를 수행한다. 문의/구매 자동화는 제외한다.
license: MIT
metadata:
category: automotive
locale: ko-KR
phase: v1
---
# Daangn Cars Search
## What this skill does
당근중고차 공개 Remix `_data` JSON route를 사용해 차량 목록과 상세 정보를 읽기 전용으로 조회한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근중고차 합정동 레이 찾아봐"
- "당근에서 천만원 이하 중고차 검색"
- "이 당근 중고차 URL 상세 봐줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/cars/?in=<지역명>-<id>&onlyOnSale=1&_data=routes/kr.cars._index`
- Detail `_data`: `<car-url>?_data=routes%2Fkr.cars.%24car_post_id`
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-cars-search/scripts/daangn_cars.py search "레이" --region "합정동" --limit 5
python3 daangn-cars-search/scripts/daangn_cars.py search --region "합정동" --price-max 10000000 --limit 5
python3 daangn-cars-search/scripts/daangn_cars.py detail "https://www.daangn.com/kr/cars/.../"
```
## Output fields
- title, price, price_text, region, status, driveDistance, carData, chatRoomCount, url
- detail: carPost 원문
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return json.load(r)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def cmd_search(args):
sel=resolve_region(args.region) if args.region else None
params=[]
if sel: params.append(('in', f"{sel['name']}-{sel['id']}"))
if args.only_on_sale: params.append(('onlyOnSale','1'))
if args.price_max: params.append(('priceMax', str(args.price_max)))
if args.price_min: params.append(('priceMin', str(args.price_min)))
params.append(('_data','routes/kr.cars._index'))
url='https://www.daangn.com/kr/cars/?'+urllib.parse.urlencode(params)
data=fetch_json(url); arr=((data.get('carAllPage') or {}).get('carPosts') or [])
if args.keyword:
arr=[a for a in arr if args.keyword.lower() in (a.get('title') or '').lower()]
arr=arr[:args.limit]
items=[{'title':a.get('title'),'price':a.get('price'),'price_text':won(a.get('price')),'region':(a.get('region') or {}).get('name'),
'status':a.get('status'),'driveDistance':a.get('driveDistance'),'carData':a.get('carData'),
'chatRoomCount':a.get('chatRoomCount'),'url':absolute(a.get('href'))} for a in arr]
print_json({'source':url,'effective_region':data.get('searchRegion') or sel,'count':len(items),'items':items})
def cmd_detail(args):
u=args.url.rstrip('/')+'/?_data=routes%2Fkr.cars.%24car_post_id'
data=fetch_json(u); print_json({'source':u,'carPost':data.get('carPost') or data})
p=argparse.ArgumentParser(description='Daangn cars read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('keyword', nargs='?'); s.add_argument('--region'); s.add_argument('--price-min',type=int); s.add_argument('--price-max',type=int); s.add_argument('--only-on-sale',action='store_true',default=True); s.add_argument('--limit',type=int,default=10); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

100
daangn-jobs-search/SKILL.md Normal file
View file

@ -0,0 +1,100 @@
---
name: daangn-jobs-search
description: 당근알바 공개 웹 데이터 표면으로 키워드·지역 기반 알바 공고 검색과 상세 조회를 수행한다. 지원/채팅 자동화는 제외한다.
license: MIT
metadata:
category: jobs
locale: ko-KR
phase: v1
---
# Daangn Jobs Search
## What this skill does
당근알바 공개 Remix `_data` JSON route로 채용/알바 공고 목록과 상세 정보를 읽기 전용으로 조회한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근알바 합정동 카페 찾아봐"
- "홍대 근처 주말 알바 검색"
- "이 당근알바 공고 상세 봐줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/jobs/?in=<지역명>-<id>&search=<keyword>&_data=routes/kr.jobs._index`
- Detail fallback: `<job-url>` redirects to `jobs.daangn.com/job-posts/<id>` and exposes public HTML title/meta/JSON-LD. The helper first tries the legacy `_data` route and falls back to HTML meta when that route returns an empty response.
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-jobs-search/scripts/daangn_jobs.py search "카페" --region "합정동" --limit 5
python3 daangn-jobs-search/scripts/daangn_jobs.py detail "https://www.daangn.com/kr/jobs/.../"
```
## Output fields
- title, company, region, address, salary, salaryType, workDays, workTimeStart, workTimeEnd, closed, url
- detail: `jobPost` 원문 if the `_data` route is available; otherwise public page `title`, `meta`, and `json_ld`
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,98 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
body = r.read()
if not body:
raise ValueError(f'빈 JSON 응답: {url}')
return json.loads(body)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def parse_html_detail(url):
html = fetch_text(url)
title = re.search(r'<title>(.*?)</title>', html, re.S)
meta = {}
for m in re.finditer(r'<meta[^>]+(?:property|name)=["\']([^"\']+)["\'][^>]+content=["\']([^"\']*)["\']', html):
key, value = m.group(1), unescape(m.group(2)).strip()
if key in ('description', 'og:title', 'og:description', 'og:image'):
meta[key] = value
json_ld = []
for m in re.finditer(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.S):
try:
json_ld.append(json.loads(unescape(m.group(1))))
except Exception:
pass
return {
'source': url,
'title': unescape(title.group(1)).strip() if title else meta.get('og:title'),
'meta': meta,
'json_ld': json_ld[:3],
}
def cmd_search(args):
sel=resolve_region(args.region) if args.region else None
params=[]
if sel: params.append(('in', f"{sel['name']}-{sel['id']}"))
if args.keyword: params.append(('search', args.keyword))
params.append(('_data','routes/kr.jobs._index'))
url='https://www.daangn.com/kr/jobs/?'+urllib.parse.urlencode(params)
data=fetch_json(url); arr=((data.get('jobsAllPage') or {}).get('jobPosts') or [])[:args.limit]
items=[{'title':a.get('title'),'company':a.get('workplaceCompanyName'),'region':a.get('workplaceRegion'),
'address':a.get('workplaceRoadNameAddress'),'salary':a.get('salary'),'salaryType':a.get('salaryType'),
'workDays':a.get('workDays'),'workTimeStart':a.get('workTimeStart'),'workTimeEnd':a.get('workTimeEnd'),
'closed':a.get('closed'),'url':absolute(a.get('href') or a.get('jobsWebDetailUrl'))} for a in arr]
print_json({'source':url,'effective_region':data.get('searchRegion') or sel,'count':len(items),'items':items})
def cmd_detail(args):
u=args.url.rstrip('/')+'/?_data=routes%2Fkr.jobs.%24job_post_id'
try:
data=fetch_json(u)
print_json({'source':u,'jobPost':data.get('jobPost') or data})
except Exception:
detail = parse_html_detail(args.url)
detail['data_source_attempted'] = u
print_json(detail)
p=argparse.ArgumentParser(description='Daangn jobs read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('keyword', nargs='?'); s.add_argument('--region'); s.add_argument('--limit',type=int,default=10); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

View file

@ -0,0 +1,101 @@
---
name: daangn-realty-search
description: 당근부동산 공개 웹 데이터 표면으로 지역 기반 부동산 매물 검색과 상세 확인을 수행한다. 문의/예약/계약 자동화는 제외한다.
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1
---
# Daangn Realty Search
## What this skill does
당근부동산 목록의 공개 Remix `_data` JSON과 상세 페이지의 JSON-LD/HTML 메타를 읽어 매물 후보를 정리한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근부동산 합정동 전세 찾아봐"
- "마포구 월세 매물 봐줘"
- "이 당근부동산 URL 상세 요약해줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/realty/?in=<지역명>-<id>&_data=routes/kr.realty._index`
- Detail: `https://realty.daangn.com/articles/<id>``application/ld+json``<title>`
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-realty-search/scripts/daangn_realty.py search --region "합정동" --limit 5
python3 daangn-realty-search/scripts/daangn_realty.py search --region "합정동" --sales-type "APARTMENT" --trade-type "MONTHLY_RENT"
python3 daangn-realty-search/scripts/daangn_realty.py detail "https://realty.daangn.com/articles/..."
```
## Output fields
- title, salesType, trade, area, areaPyeong, totalManageCost, url
- detail: JSON-LD, page title
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return json.load(r)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def norm_trade(t):
if not t: return None
return t
def cmd_search(args):
sel = resolve_region(args.region) if args.region else None
params=[]
if sel: params.append(('in', f"{sel['name']}-{sel['id']}"))
if args.sales_type: params.append(('salesType', args.sales_type))
if args.trade_type: params.append(('tradeType', args.trade_type))
if args.only_verified: params.append(('onlyVerified','true'))
params.append(('_data','routes/kr.realty._index'))
url='https://www.daangn.com/kr/realty/?'+urllib.parse.urlencode(params)
data=fetch_json(url)
arr=((data.get('realtyPosts') or {}).get('realtyPosts') or [])
if args.keyword:
arr=[a for a in arr if args.keyword.lower() in json.dumps(a, ensure_ascii=False).lower()]
arr=arr[:args.limit]
items=[]
for a in arr:
tr=(a.get('trades') or [{}])[0]
items.append({'title':a.get('title'),'salesType':a.get('salesType') or a.get('salesTypeV2'),'trade':tr,
'area':a.get('area'),'areaPyeong':a.get('areaPyeong'),'totalManageCost':a.get('totalManageCost'),
'url':a.get('webUrl') or absolute(a.get('href'))})
print_json({'source':url,'effective_region':data.get('searchRegion') or sel,'count':len(items),'items':items})
def cmd_detail(args):
html=fetch_text(args.url)
lds=[]
for m in re.finditer(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.S):
try: lds.append(json.loads(unescape(m.group(1))))
except Exception: pass
title=re.search(r'<title>(.*?)</title>', html, re.S)
print_json({'source':args.url,'title':unescape(title.group(1)).strip() if title else None,'json_ld':lds[:3]})
p=argparse.ArgumentParser(description='Daangn realty read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('--region'); s.add_argument('--keyword'); s.add_argument('--sales-type'); s.add_argument('--trade-type'); s.add_argument('--only-verified',action='store_true'); s.add_argument('--limit',type=int,default=10); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

View file

@ -0,0 +1,100 @@
---
name: daangn-used-goods-search
description: 당근 중고거래 공개 웹 데이터 표면으로 키워드·지역 기반 매물 검색과 상세 조회를 수행한다. 로그인/채팅/찜/구매 자동화는 제외한다.
license: MIT
metadata:
category: marketplace
locale: ko-KR
phase: v1
---
# Daangn Used-Goods Search
## What this skill does
당근 중고거래 공개 Remix `_data` JSON route를 사용해 매물 목록과 상세 정보를 읽기 전용으로 조회한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근에서 맥북 찾아봐"
- "합정동 아이폰 매물 검색"
- "이 당근 중고거래 URL 상세 봐줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/buy-sell/all/?in=<지역명>-<id>&search=<keyword>&only_on_sale=true&_data=routes/kr.buy-sell._index`
- Detail `_data`: `<listing-url>?_data=routes%2Fkr.buy-sell.%24buy_sell_id`
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-used-goods-search/scripts/daangn_used_goods.py search "맥북" --region "합정동" --limit 5
python3 daangn-used-goods-search/scripts/daangn_used_goods.py detail "https://www.daangn.com/kr/buy-sell/.../"
```
## Output fields
- title, price, price_text, status, region, url
- detail: product 원문, view/chat/count류 필드가 있으면 함께 확인
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return json.load(r)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def cmd_search(args):
params = []
effective = None
path = '/kr/buy-sell/'
if args.region:
effective = resolve_region(args.region)
path = '/kr/buy-sell/all/'
params.append(('in', f"{effective['name']}-{effective['id']}"))
params.append(('search', args.keyword))
if args.only_on_sale: params.append(('only_on_sale','true'))
params.append(('_data','routes/kr.buy-sell._index'))
url = 'https://www.daangn.com' + path + '?' + urllib.parse.urlencode(params)
data = fetch_json(url)
arr = (((data.get('allPage') or {}).get('fleamarketArticles')) or [])[:args.limit]
print_json({
'source': url,
'effective_region': effective or data.get('region'),
'count': len(arr),
'items': [{
'title': a.get('title'), 'price': a.get('price'), 'price_text': won(a.get('price')),
'region': (a.get('region') or {}).get('name'), 'status': a.get('status'),
'url': absolute(a.get('href') or a.get('webUrl')),
} for a in arr]
})
def cmd_detail(args):
u = args.url.rstrip('/') + '/?_data=routes%2Fkr.buy-sell.%24buy_sell_id'
data = fetch_json(u); p = data.get('product') or data.get('article') or data
print_json({'source': u, 'product': p})
p=argparse.ArgumentParser(description='Daangn used-goods read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('keyword'); s.add_argument('--region'); s.add_argument('--limit',type=int,default=10); s.add_argument('--only-on-sale',action='store_true',default=True); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

View file

@ -0,0 +1,148 @@
---
name: daishin-report-search
description: 대신증권 리포트 GitHub Pages 미러에서 최신 HTML 리포트 목록과 원문/설명 페이지를 조회한다.
license: MIT
metadata:
category: finance
locale: ko-KR
phase: v1
---
# Daishin Report Search
## What this skill does
대신증권 리포트 HTML 미러(`jay-jo-0/github_pages_repo`)에서 최신 리포트 목록을 찾고, 특정 리포트의 원문 텍스트·제목·헤딩·Rating/Target 표·원문 링크를 에이전트가 재사용하기 쉬운 JSON으로 반환한다.
이 스킬은 투자 조언, 매매 자동화, 추천을 하지 않는다. 공개 HTML 리포트를 읽어 요약 가능한 자료로 정리하는 조회 전용 스킬이다.
## When to use
- "대신증권 최신 리포트 보여줘"
- "대신증권 반도체 리포트 찾아줘"
- "20260511082352 리포트 원문과 설명 페이지를 읽어줘"
- "대신증권 리포트 목록을 에이전트가 쓰기 좋은 JSON으로 줘"
## Prerequisites
- 인터넷 연결
- Node.js 18+
- 이 저장소의 `daishin-report-search` npm package 또는 동일 로직
## Public access path discovered
### Primary source: GitHub recursive tree API
- list endpoint: `https://api.github.com/repos/jay-jo-0/github_pages_repo/git/trees/main?recursive=1`
- selected paths: repository-root files matching `YYYYMMDDHHMMSS.html`
- optional companion paths: `YYYYMMDDHHMMSS_explain.html`
- detail raw HTML: `https://raw.githubusercontent.com/Jay-jo-0/github_pages_repo/main/<path>`
- browser detail URL: `https://jay-jo-0.github.io/github_pages_repo/<path>`
- reason selected: the sample GitHub Pages URL maps directly to a public GitHub repository. The recursive tree API exposes all timestamped HTML filenames without relying on a brittle directory listing screen scrape. Raw GitHub URLs provide stable unauthenticated detail fetches.
### Fallback source: GitHub contents API for an exact file
- exact-file endpoint: `https://api.github.com/repos/jay-jo-0/github_pages_repo/contents/<path>?ref=main`
- used automatically for a known timestamp when the raw detail URL is unavailable; it also provides GitHub content metadata for manual diagnostics.
No `k-skill-proxy` route is used because the upstream is public and does not require an API key.
## Workflow
### 1. List latest reports
```js
const { listReports } = require("daishin-report-search")
const result = await listReports({
limit: 10,
query: "반도체", // optional; matches title/headings/detail text
maxInspect: 100, // optional query crawl budget among newest pages
githubToken: process.env.GITHUB_TOKEN // optional; raises GitHub API limits when caller has one
})
console.log(result.items)
```
CLI:
```bash
node packages/daishin-report-search/src/cli.js --limit 10
node packages/daishin-report-search/src/cli.js 반도체 --limit 5 --max-inspect 100
```
Return each item with:
- `id` (`YYYYMMDDHHMMSS`)
- `date`, `time`, `timestamp` (filename-derived KST timestamp)
- `title`
- `headings`
- `excerpt`
- `ratingTargets` when a Rating/Target table is present
- `pageUrl`, `rawUrl`, `apiUrl`
- `hasExplain`, `explainUrl` when a companion explanation page exists
### 2. Fetch one report
```js
const { fetchReport } = require("daishin-report-search")
const report = await fetchReport("20260511082352", {
includeExplain: true
})
console.log(report.title)
console.log(report.text)
console.log(report.explain?.text)
```
CLI:
```bash
node packages/daishin-report-search/src/cli.js --id 20260511082352 --include-explain
```
### 3. Summarize conservatively
When answering a user, show:
```text
- 제목: ...
게시 추정 시각: 2026-05-11 08:23:52 KST (파일명 기준)
주요 헤딩: ...
Rating/Target: ... (있는 경우)
원문: https://jay-jo-0.github.io/github_pages_repo/...
설명 페이지: ... (있는 경우)
```
Always state that the timestamp is filename-derived and that report contents can change in the public mirror.
## Fallback order
1. GitHub recursive tree API → filter timestamped root HTML files → sort newest filename first → fetch raw detail HTML for selected/latest candidates.
2. If a query is present, inspect newer candidates up to `maxInspect` until enough matches are found or the budget is exhausted; return a warning if the budget is exhausted.
3. For a known id, fetch raw detail directly. If explanation is requested, fetch `<id>_explain.html`; if absent, return the original report plus a warning.
4. If the tree endpoint is truncated, blocked, rate-limited, or changed, report that as a source warning/failure instead of guessing hidden pages.
5. For a known id, if the raw detail URL fails, fall back to the GitHub contents API for that exact file path. Explanation pages use the same exact-file fallback but remain optional and return a warning if unavailable.
6. If the caller has authenticated GitHub access, pass `githubToken` / `githubHeaders` in library calls or set `DAISHIN_GITHUB_TOKEN` / `GITHUB_TOKEN` for the CLI; these credentials are scoped to `api.github.com` requests and are not sent to raw detail URLs. Do not require or proxy a token by default.
## Done when
- Latest report rows or a specific report are returned with direct source URLs.
- Query and limit were applied or explicitly left broad.
- Explanation pages were included only when requested or when listing metadata shows they exist.
- Empty results and upstream warnings are disclosed.
## Failure modes
- GitHub unauthenticated API rate limits can return 403/429; latest/search returns empty `items` plus `source.error.kind = "rate_limit"` and rate-limit reset metadata when GitHub exposes it. Retry later or use caller-supplied authenticated GitHub access if appropriate.
- The repository path or branch can change; then tree/raw URLs will fail.
- The tree response could become truncated; in that case the latest-list completeness is not guaranteed.
- HTML structure can change; title/headings/table extraction may be partial, but URLs and raw text fallback should still be returned when available.
- Some pages may not be authored by Daishin even though they are in the issue-scoped public mirror. Do not infer provenance beyond page title/content.
## Notes
- Read-only lookup only; no login, trading, order placement, recommendation, or investment advice.
- Do not scrape private Daishin services or bypass CAPTCHA/login walls.
- No secrets or API keys are required. Optional GitHub tokens are caller-owned, used only when explicitly supplied via options or environment, and scoped to GitHub API hosts.

View file

@ -62,7 +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`
- 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
@ -106,7 +108,17 @@ console.log(productResult.items)
### 3. Check the store pickup stock
공식 매장 픽업 재고 API로 해당 매장의 재고를 확인한다.
`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")
@ -157,9 +169,14 @@ console.log(result.pickupStock)
- 상품명이 너무 넓으면 다른 용량/호수 후보가 많이 섞일 수 있다.
- 공식 재고는 시점 차이로 실제 방문 시 수량이 달라질 수 있다.
- 현재 확인된 공식 표면은 **매장 내 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

@ -0,0 +1,193 @@
---
name: danawa-price-search
description: 다나와 공개 검색/가격비교 표면으로 상품 후보를 찾고, 쇼핑몰별 최저가·배송비 포함 실구매가·카드 할인가·무이자 할부 정보를 보수적으로 비교한다.
license: MIT
metadata:
category: retail
locale: ko-KR
phase: v1
---
# Danawa Price Search
## What this skill does
다나와의 로그인 없는 공개 검색/가격비교 표면을 읽기 전용으로 호출해 한국 쇼핑몰 가격을 비교한다.
- 상품명/검색어로 다나와 상품 후보와 `pcode`를 찾는다.
- 선택한 상품의 쇼핑몰별 오퍼를 조회한다.
- 상품가만이 아니라 배송비 포함 실구매가, 무료배송 여부, 카드 할인가, 무이자 할부 문구를 함께 정리한다.
- 구매, 로그인, 장바구니, 찜, 주문 액션은 하지 않는다.
## When to use
- "다나와에서 에어팟 최저가 찾아줘"
- "다나와 가격비교로 쇼핑몰별 가격 비교해줘"
- "무료배송인지, 카드 할인까지 보면 어디가 제일 싸?"
- "무이자 할부 붙은 최저가도 같이 봐줘"
## When not to use
- 실제 구매/주문/결제/로그인이 필요한 경우
- 회원 전용 쿠폰, 개인화 포인트, 앱 전용 혜택을 확정해야 하는 경우
- 대량 모니터링이나 고빈도 크롤링을 해야 하는 경우
- CAPTCHA, 접근 차단, fingerprint 우회를 해야 하는 경우
## Required inputs
상품명 또는 검색어가 필요하다. 검색어가 넓으면 브랜드, 모델명, 용량, 색상, 자급제/통신사 여부 등을 추가로 물어본다.
권장 질문:
> 찾을 다나와 상품명이나 모델명을 알려주세요. 예: 갤럭시 S25 울트라 256GB 자급제, 에어팟 프로 2세대 USB-C
## Public surfaces
현재 구현은 인증 없는 공개 표면만 사용한다.
- 검색 페이지: `https://search.danawa.com/dsearch.php?query=...`
- 상품 상세 페이지: `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`, 배송 문구, 결제조건 배지(`.ico.cash`/`.ico.point`/`.ico.coupon`/`.ico.discount`/`.ico.card`/`.ico.membership` 등), 카드 할인 라인, 무이자 할부 레이어, 다나와 bridge link를 파싱한다.
## Commands
스킬 디렉터리에서 실행한다.
```bash
python scripts/danawa_search.py search "에어팟 프로 2세대" --limit 8
python scripts/danawa_search.py offers 28208783 --limit 10
python scripts/danawa_search.py compare "에어팟 프로 2세대" --limit 5 --offers 5
```
helper는 JSON만 출력한다. 결과를 확인한 뒤 사용자에게는 한국어 표와 짧은 결론으로 정리한다.
## Output shape
### `search`
```json
{
"query": "...",
"source_url": "...",
"count": 0,
"items": []
}
```
`items[]` 주요 필드:
- `pcode`
- `title`
- `price`, `price_text`
- `mall_text`
- `url`
- `image_url`
- `spec`
### `offers`
```json
{
"pcode": "...",
"title": "...",
"source_url": "...",
"count": 0,
"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`
- `price`, `price_text`
- `shipping`
- `is_free_shipping`
- `shipping_fee`
- `total_price`, `total_price_text`
- `card_price`, `card_price_text`
- `card_name`
- `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`
`compare`는 검색 결과를 먼저 가져온 뒤 각 후보 상품에 대해 `offers[]`를 best-effort로 붙인다. 검색 결과가 애매하면 상위 후보의 제목과 `pcode`를 먼저 보여주고 선택을 요청한다.
## Response style
Discord/Telegram/chat 응답에서는 표 형식을 우선한다.
```md
| 순위 | 판매처 | 상품가 | 결제조건 | 배송 | 실구매가 | 카드할인가 | 무이자 | 링크 |
|---:|---|---:|---|---|---:|---:|---|---|
| 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` 오름차순 단일 기준.** 결제조건(현금/쿠폰/포인트/할인/특정카드/멤버십 한정)이 붙은 row도 같은 정렬에 그대로 참여한다 — 가장 싸면 1위로 올라온다. 결제조건은 분리 그룹화하지 않고 표의 "결제조건" 컬럼에 행별로 표시한다 (`payment_condition_label`이 있으면 그 값을 우선 표시, 없으면 "일반"; 세부 매핑은 `cash` → "현금 전용", `coupon` → "쿠폰 적용가", `point` → "포인트 적용가", `card` → 카드명/카드 조건, `discount` → "할인 조건", `membership` → "멤버십 조건"). 사용자는 자기 결제 수단에 따라 직접 판단한다.
2. `card_price`가 있고 카드 적용 시 승자가 바뀌면 표 아래에 "카드 기준 최저가"를 별도로 적는다.
3. 무이자 할부는 결제 조건이 달라질 수 있으므로 Danawa 노출 문구 기준이라고 밝힌다.
4. 1위가 조건부 가격이면 요약 문장에 결제수단 단서를 짧게 덧붙인다. 예: "**최저 실구매가: 킴스클럽 979,000원 / 현금 결제 한정**, 카드 결제 기준 최저가는 롯데ON 1,073,890원". 카드 결제 가능한 최저가도 같이 알려 사용자가 결제수단별 결과를 한 번에 비교할 수 있게 한다.
요약 예시:
```md
최저 실구매가: G마켓 217,950원 / 무료배송
카드 기준 최저가: 옥션 우리카드 303,720원
무이자: G마켓·옥션 최대 24개월 표기
```
카드 할인 markup이 없으면 "카드 할인가 표기 없음"이라고 쓰고, 체크아웃 할인 자체가 없다고 단정하지 않는다.
## Workflow
1. 검색어를 확인한다.
2. `python scripts/danawa_search.py search "<검색어>" --limit 5`로 후보를 확인한다.
3. 후보가 명확하면 해당 `pcode``offers`를 실행한다.
4. 후보가 애매하면 상위 3~5개 상품명/가격/`pcode`를 보여주고 선택을 요청한다.
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를 추가해야 한다.
- 접근 차단이나 CAPTCHA가 나오면 우회를 시도하지 말고 실패 모드로 보고한다.
## Done when
- 검색어 또는 모델명을 확인했다.
- 상품 후보를 최소 1개 이상 반환하거나, 반환 실패 이유를 설명했다.
- 쇼핑몰별 상품가, 배송비, 실구매가, 카드 할인가, 무이자 문구를 조회 시점 기준으로 정리했다.
- 사용자 응답은 표 형식으로 제공했다.
- 로그인/구매/차단 우회 범위를 벗어나지 않았다.

View file

@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""Read-only Danawa search/price comparison helper for Hermes.
Usage:
python scripts/danawa_search.py search "에어팟 프로 2세대" --limit 8
python scripts/danawa_search.py offers 28208783 --limit 10
python scripts/danawa_search.py compare "에어팟 프로 2세대" --limit 5 --offers 5
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
import urllib.parse
import urllib.request
from html import unescape
from typing import Any, Dict, List, Optional
try:
from bs4 import BeautifulSoup
except ImportError as exc: # pragma: no cover - environment guard
raise SystemExit("beautifulsoup4 is required: python -m pip install beautifulsoup4") from exc
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/121 Safari/537.36"
def fetch(url: str, *, method: str = "GET", data: Optional[dict] = None, referer: Optional[str] = None) -> str:
headers = {
"User-Agent": UA,
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
}
body = None
if data is not None:
body = urllib.parse.urlencode(data).encode("utf-8")
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
headers["X-Requested-With"] = "XMLHttpRequest"
if referer:
headers["Referer"] = referer
req = urllib.request.Request(url, data=body, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=25) as resp:
return resp.read().decode("utf-8", "replace")
def soup_for(html: str) -> BeautifulSoup:
return BeautifulSoup(html, "html.parser")
def clean_text(s: Optional[str]) -> Optional[str]:
if s is None:
return None
return " ".join(unescape(s).split())
def parse_int(s: Optional[str]) -> Optional[int]:
if not s:
return None
digits = re.sub(r"\D", "", s)
return int(digits) if digits else None
def abs_url(url: Optional[str]) -> Optional[str]:
if not url:
return None
if url.startswith("//"):
return "https:" + url
if url.startswith("/"):
return "https://prod.danawa.com" + url
return url
def search(query: str, limit: int = 10) -> Dict[str, Any]:
url = "https://search.danawa.com/dsearch.php?query=" + urllib.parse.quote(query)
html = fetch(url)
soup = soup_for(html)
items: List[Dict[str, Any]] = []
for li in soup.select("li.prod_item"):
pid = (li.get("id") or "").replace("productItem", "") or None
name_el = li.select_one(".prod_name a") or li.select_one("p.prod_name a") or li.select_one('a[name="productName"]')
if not name_el:
continue
name = clean_text(name_el.get_text(" ", strip=True))
link = abs_url(name_el.get("href"))
min_input = li.select_one(f"#min_price_{pid}") if pid else None
price = parse_int(min_input.get("value") if min_input else None)
if price is None:
price_el = li.select_one(".price_sect strong") or li.select_one(".prod_pricelist strong")
price = parse_int(price_el.get_text() if price_el else None)
img = li.select_one(".thumb_image img")
image = abs_url((img.get("data-original") or img.get("src")) if img else None)
mall_el = li.select_one(".prod_pricelist .memory_sect") or li.select_one(".meta_item")
spec = " / ".join(clean_text(e.get_text(" ", strip=True)) or "" for e in li.select(".spec_list a, .spec_list span")[:10])
items.append(
{
"pcode": pid,
"title": name,
"price": price,
"price_text": f"{price:,}" if price else None,
"mall_text": clean_text(mall_el.get_text(" ", strip=True)) if mall_el else None,
"url": link,
"image_url": image,
"spec": spec[:300] if spec else None,
}
)
if len(items) >= limit:
break
return {"query": query, "source_url": url, "count": len(items), "items": items, "meta": {"extraction": "danawa-search-html", "ts": int(time.time())}}
def js_value(html: str, key: str) -> str:
patterns = [
rf"{re.escape(key)}\s*:\s*\"([^\"]*)\"",
rf"{re.escape(key)}\s*:\s*'([^']*)'",
rf"{re.escape(key)}\s*:\s*([0-9]+)",
]
for pat in patterns:
m = re.search(pat, html)
if m:
raw = m.group(1)
if "\\u" in raw or "\\/" in raw:
try:
return json.loads('"' + raw.replace('"', '\\"') + '"')
except Exception:
return raw.replace("\\/", "/")
return raw
return ""
def product_meta(pcode: str) -> Dict[str, str]:
url = f"https://prod.danawa.com/info/?pcode={urllib.parse.quote(str(pcode))}"
html = fetch(url)
meta = {
"pcode": str(pcode),
"source_url": url,
"cate1": js_value(html, "nCategoryCode1"),
"cate2": js_value(html, "nCategoryCode2"),
"cate3": js_value(html, "nCategoryCode3"),
"cate4": js_value(html, "nCategoryCode4") or "0",
"UICategoryCode": js_value(html, "nCategoryCode"),
"powerLinkKeyword": js_value(html, "powerLinkKeyword"),
"minPrice": js_value(html, "nMinPrice"),
"keyword": js_value(html, "sKeyword"),
"NaPm": js_value(html, "sNaPm"),
"sProductFullName": js_value(html, "sProductName"),
"makerCode": js_value(html, "makerCode"),
"makerName": js_value(html, "makerName"),
}
title = soup_for(html).select_one(".prod_tit .title")
if title:
meta["sProductFullName"] = clean_text(title.get_text(" ", strip=True)) or meta["sProductFullName"]
return meta
def offers(pcode: str, limit: int = 20, include_shipping: bool = False) -> Dict[str, Any]:
meta = product_meta(pcode)
post_price = "Y" if include_shipping else "N"
data = {
"pcode": meta["pcode"],
"cate1": meta.get("cate1", ""),
"cate2": meta.get("cate2", ""),
"cate3": meta.get("cate3", ""),
"cate4": meta.get("cate4", "0"),
"UICategoryCode": meta.get("UICategoryCode", "0"),
"powerLinkKeyword": meta.get("powerLinkKeyword", ""),
"minPrice": meta.get("minPrice", ""),
"keyword": meta.get("keyword", ""),
"NaPm": meta.get("NaPm", ""),
"bDeliveryLeftRightYN": "N",
"bQuickPostSortYN": "N",
"sSortType": "minPrice",
"sProductFullName": meta.get("sProductFullName", ""),
"bPostPriceYN": post_price,
"bBadgeDefaultYN": "N",
"bWarrantyDefaultYN": "N",
"nOpenMarketMoreCount": "30",
"nAffiliateMoreCount": "30",
"nOverseasShoppingMoreCount": "30",
"nGeneralAffiliateMoreCount": "3",
"sRelationMenuType": "",
"sRelationType": "",
"bCoupangSortYN": "N",
"makerCode": meta.get("makerCode", ""),
"makerName": meta.get("makerName", ""),
}
html = fetch("https://prod.danawa.com/info/ajax/getAllPriceCompareMallList.ajax.php", method="POST", data=data, referer=meta["source_url"])
soup = soup_for(html)
rows: List[Dict[str, Any]] = []
for div in soup.select(".diff_item"):
mall_img = div.select_one(".d_mall img")
mall = mall_img.get("alt") if mall_img else None
price_el = div.select_one("em.prc_c") or div.select_one("em.prc_t")
price = parse_int(price_el.get_text() if price_el else None)
if not mall or price is None:
continue
ship_el = div.select_one(".ship") or div.select_one(".stxt")
shipping = clean_text(ship_el.get_text(" ", strip=True)) if ship_el else None
shipping_fee = 0 if shipping and "무료" in shipping else parse_int(shipping)
card_line = div.select_one(".card_line")
card_price_el = card_line.select_one(".card_prc") if card_line else None
card_name_el = card_line.select_one(".txt") if card_line else None
card_price = parse_int(card_price_el.get_text() if card_price_el else None)
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),
"price": price,
"price_text": f"{price:,}",
"shipping": shipping,
"is_free_shipping": bool(shipping and "무료" in shipping),
"shipping_fee": shipping_fee,
"total_price": price + (shipping_fee or 0),
"total_price_text": f"{price + (shipping_fee or 0):,}",
"card_price": card_price,
"card_price_text": f"{card_price:,}" if card_price else None,
"card_name": clean_text(card_name_el.get_text(" ", strip=True)) if card_name_el else None,
"card_discount": (price - card_price) if card_price else None,
"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),
}
)
# 정렬은 단순히 배송비 포함 실구매가 오름차순. 결제조건(현금/쿠폰/포인트/특정카드)은
# 분리 그룹으로 묶지 않고 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),
"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]:
result = search(query, limit=limit)
enriched = []
for item in result["items"]:
row = dict(item)
if item.get("pcode"):
try:
off = offers(item["pcode"], limit=offer_limit)
row["offers"] = off.get("offers", [])
except Exception as exc: # keep search result usable if a detail call fails
row["offers_error"] = f"{type(exc).__name__}: {exc}"
enriched.append(row)
result["items"] = enriched
result["meta"]["detail_extraction"] = "best-effort"
return result
def positive_int(raw: str) -> int:
value = int(raw)
if value < 1:
raise argparse.ArgumentTypeError("must be >= 1")
return value
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("search")
s.add_argument("query")
s.add_argument("--limit", type=positive_int, default=10)
o = sub.add_parser("offers")
o.add_argument("pcode")
o.add_argument("--limit", type=positive_int, default=20)
o.add_argument("--include-shipping", action="store_true")
c = sub.add_parser("compare")
c.add_argument("query")
c.add_argument("--limit", type=positive_int, default=5)
c.add_argument("--offers", type=positive_int, default=5)
args = ap.parse_args()
try:
if args.cmd == "search":
out = search(args.query, args.limit)
elif args.cmd == "offers":
out = offers(args.pcode, args.limit, args.include_shipping)
else:
out = compare(args.query, args.limit, args.offers)
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
except Exception as exc:
print(json.dumps({"error": f"{type(exc).__name__}: {exc}"}, ensure_ascii=False), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -13,7 +13,7 @@
| 유형 | 설명 | 예시 |
|------|------|------|
| **SKILL.md 전용** | 문서만으로 동작 (에이전트가 bash/python 직접 실행) | `kakaotalk-mac`, `srt-booking` |
| **npm 패키지** | `packages/` 아래 Node.js 라이브러리로 구현 | `k-lotto`, `blue-ribbon-nearby` |
| **npm 패키지** | `packages/` 아래 Node.js 라이브러리로 구현 | `k-lotto`, `daiso-product-search` |
| **프록시 경유** | `k-skill-proxy`가 upstream API 키를 보관하고 HTTP로 중계 | `seoul-subway-arrival`, `fine-dust-location` |
| **Python 스크립트** | `scripts/`의 Python 파일 직접 실행 | `korean-spell-check`, `sillok-search` |

View file

@ -0,0 +1,211 @@
# k-skill-proxy 배포 가이드 (Cloud Run + GitHub Actions)
`k-skill-proxy`는 Google Cloud Run에서 운영되고, `main` 브랜치에 머지되면 GitHub Actions가 자동으로 재배포합니다.
이 문서는 그 자동 배포 파이프라인의 **1회성 셋업 절차**와 **운영 점검 절차**를 정리합니다. 일반 contributor는 읽지 않아도 되며, 프록시 운영을 담당하는 maintainer(현재 `jeffrey@markr.ai`)가 인프라를 처음 만들거나 수리할 때 참고합니다.
## 운영 사실
| 항목 | 값 |
| --- | --- |
| GCP project ID | `k-skill-proxy` |
| Region | `asia-northeast1` (도쿄) |
| Cloud Run service | `k-skill-proxy` |
| Artifact Registry repo | `asia-northeast1-docker.pkg.dev/k-skill-proxy/k-skill` |
| 공개 도메인 | `https://k-skill-proxy.nomadamas.org` (Cloud Run domain mapping) |
| 컨테이너 이미지 정의 | `packages/k-skill-proxy/Dockerfile` |
| 워크플로 | `.github/workflows/deploy-k-skill-proxy.yml` |
| 인증 | Workload Identity Federation (long-lived JSON key 없음) |
| 시크릿 저장소 | GCP Secret Manager (이름 = 환경변수 이름) |
## 배포 흐름
1. `dev` 브랜치에서 작업, PR을 `dev`에 보낸다.
2. `dev``main` 머지 PR이 `@vkehfdl1`에 의해 머지된다.
3. `main` push가 `.github/workflows/deploy-k-skill-proxy.yml`을 트리거한다.
4. 워크플로가:
- WIF로 `${GCP_DEPLOY_SERVICE_ACCOUNT}`로 impersonate
- `packages/k-skill-proxy/Dockerfile`로 컨테이너 빌드
- Artifact Registry에 `:${GITHUB_SHA}` 태그로 push
- Cloud Run `k-skill-proxy` 서비스를 새 이미지로 재배포 (Secret Manager 시크릿 + 런타임 env 주입)
- 새 revision의 `*.run.app` URL과 `https://k-skill-proxy.nomadamas.org/health`에 smoke test
5. 실패 시 GitHub Actions 페이지에서 로그 확인. Cloud Run 자체는 마지막 healthy revision에 트래픽을 유지한다.
## 1회성 GCP 셋업
> 이미 한 번 셋업되어 있다면 다시 실행할 필요 없음. 새 maintainer가 인계받거나 SA를 새로 만들 때만 사용.
```bash
export PROJECT_ID="k-skill-proxy"
export PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')"
export GH_REPO="NomaDamas/k-skill" # owner/repo
export POOL_ID="github-actions-pool"
export PROVIDER_ID="github-actions-provider"
export DEPLOY_SA="k-skill-proxy-deploy"
export DEPLOY_SA_EMAIL="${DEPLOY_SA}@${PROJECT_ID}.iam.gserviceaccount.com"
```
### 1) 필요한 API 활성화
```bash
gcloud services enable \
iamcredentials.googleapis.com \
run.googleapis.com \
artifactregistry.googleapis.com \
secretmanager.googleapis.com \
--project="$PROJECT_ID"
```
### 2) Workload Identity Pool + GitHub OIDC provider
```bash
gcloud iam workload-identity-pools create "$POOL_ID" \
--project="$PROJECT_ID" \
--location=global \
--display-name="GitHub Actions"
gcloud iam workload-identity-pools providers create-oidc "$PROVIDER_ID" \
--project="$PROJECT_ID" \
--location=global \
--workload-identity-pool="$POOL_ID" \
--display-name="GitHub OIDC" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
--attribute-condition="assertion.repository == '${GH_REPO}'"
```
> `attribute-condition`은 토큰 발급 단계에서 우리 저장소만 허용해 풀 자체를 좁힙니다. 임의의 다른 repo가 같은 풀을 통해 SA를 impersonate하지 못하게 막는 핵심 가드입니다.
### 3) Deploy service account 생성
```bash
gcloud iam service-accounts create "$DEPLOY_SA" \
--project="$PROJECT_ID" \
--display-name="GitHub Actions k-skill-proxy deployer"
```
### 4) 풀 → service account impersonation 허용
```bash
gcloud iam service-accounts add-iam-policy-binding "$DEPLOY_SA_EMAIL" \
--project="$PROJECT_ID" \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/attribute.repository/${GH_REPO}"
```
### 5) deploy SA에 필요한 권한 부여
```bash
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${DEPLOY_SA_EMAIL}" \
--role=roles/run.admin
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${DEPLOY_SA_EMAIL}" \
--role=roles/artifactregistry.writer
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${DEPLOY_SA_EMAIL}" \
--role=roles/iam.serviceAccountUser
```
`iam.serviceAccountUser`는 Cloud Run의 런타임 service account(`${PROJECT_NUMBER}-compute@developer.gserviceaccount.com`)를 deploy SA가 대신 지정할 수 있게 하기 위함입니다.
### 6) Cloud Run 런타임 SA에 Secret Manager accessor 부여
```bash
RUNTIME_SA="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"
for s in \
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY \
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY \
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY \
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET LAW_OC; do
gcloud secrets add-iam-policy-binding "$s" \
--project="$PROJECT_ID" \
--member="serviceAccount:${RUNTIME_SA}" \
--role=roles/secretmanager.secretAccessor \
--condition=None >/dev/null
done
```
### 7) WIF provider 리소스 이름 확인
```bash
gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \
--project="$PROJECT_ID" \
--location=global \
--workload-identity-pool="$POOL_ID" \
--format='value(name)'
# 예: projects/123456789/locations/global/workloadIdentityPools/github-actions-pool/providers/github-actions-provider
```
이 값과 `${DEPLOY_SA_EMAIL}`을 GitHub에 등록합니다.
## GitHub repository secrets
다음 두 개의 **secret**을 `Settings → Secrets and variables → Actions → Repository secrets`에 등록합니다.
| Name | Value |
| --- | --- |
| `GCP_WIF_PROVIDER` | 위 7번에서 얻은 provider 리소스 전체 이름 |
| `GCP_DEPLOY_SERVICE_ACCOUNT` | `k-skill-proxy-deploy@k-skill-proxy.iam.gserviceaccount.com` |
> 값 자체가 민감하진 않지만, 외부에 노출되면 reconnaissance에 도움이 될 수 있으므로 secret으로 둡니다. variable로 옮겨도 동작은 동일합니다.
## Secret Manager에 upstream key 업로드
```bash
KEYS=(
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET LAW_OC
)
set -a; source ~/.config/k-skill/secrets.env; set +a
for k in "${KEYS[@]}"; do
value="${!k:-}"
[[ -z "$value" ]] && { echo "skip $k (empty)"; continue; }
if gcloud secrets describe "$k" --project="$PROJECT_ID" >/dev/null 2>&1; then
printf '%s' "$value" | gcloud secrets versions add "$k" --data-file=- --project="$PROJECT_ID"
else
printf '%s' "$value" | gcloud secrets create "$k" --data-file=- --replication-policy=automatic --project="$PROJECT_ID"
fi
done
```
키 값을 회전(rotate)할 때도 같은 명령을 다시 실행하면 새 version이 추가됩니다. Cloud Run은 `:latest`로 바인딩되어 있어 다음 배포부터 자동 반영됩니다(즉시 적용이 필요하면 새 revision을 한 번 더 deploy).
## 운영 점검 절차
- 자동 배포 상태: GitHub `Actions` 탭의 "Deploy k-skill-proxy to Cloud Run" 워크플로
- 라이브 헬스체크: `curl -fsS https://k-skill-proxy.nomadamas.org/health`
- Cloud Run revision/로그: GCP Console → Cloud Run → `k-skill-proxy` (`asia-northeast1`)
- 이미지 태그: `asia-northeast1-docker.pkg.dev/k-skill-proxy/k-skill/k-skill-proxy:<commit-sha>`
- 트래픽 롤백: 이전 revision으로 traffic split을 100% 되돌리거나, 직전 commit을 revert해서 main에 머지 → 워크플로가 다시 돈다.
## 로컬에서 동일한 배포를 수동으로 돌리고 싶을 때
`gcloud auth login`으로 maintainer 계정에 로그인된 상태에서:
```bash
SHA="$(git rev-parse HEAD)"
IMAGE_URI="asia-northeast1-docker.pkg.dev/k-skill-proxy/k-skill/k-skill-proxy:${SHA}"
gcloud auth configure-docker asia-northeast1-docker.pkg.dev --quiet
docker build -t "$IMAGE_URI" -f packages/k-skill-proxy/Dockerfile .
docker push "$IMAGE_URI"
gcloud run deploy k-skill-proxy \
--image="$IMAGE_URI" \
--region=asia-northeast1 \
--platform=managed \
--allow-unauthenticated \
--execution-environment=gen2 \
--cpu=1 --memory=512Mi --min-instances=0 --max-instances=3 \
--concurrency=80 --timeout=60 --cpu-boost \
--project=k-skill-proxy
```
이 명령은 평상시에는 필요 없습니다. GitHub Actions가 같은 일을 하기 때문입니다.

View file

@ -0,0 +1,45 @@
# 사업자 실사 종합 (biz-health-check)
`biz-health-check` 스킬은 사업자등록번호(+상호/지역) 하나로 무료 공공 데이터 6종을 한 번에 교차 조회해 실사 리포트 한 장을 만든다. 같은 레포의 단품 스킬 helper를 그대로 재사용한다(단일 진실원천).
## 묶는 단품 스킬
| 섹션 | 단품 스킬 | 경로 |
| --- | --- | --- |
| 국세청 사업자등록 상태 | `nts-business-registration` | proxy |
| 국민연금 가입 사업장 | `national-pension-workplace` | proxy |
| 국세 체납 명단공개 | `nts-tax-delinquency` | 직접(무인증) |
| 금융위 기업기본정보 | `fsc-corporate-info` | proxy |
| 조달청 부정당제재 | `g2b-sanctioned-supplier` | proxy |
| 지방행정 인허가 영업상태 | `localdata-business-status` | 직접(무인증) |
## 설계 원칙
- 점수·등급·"위험" 같은 해석 라벨을 산출하지 않는다. 각 항목의 사실 + 출처 + 조회시각만 병렬한다.
- 한 항목 조회가 실패해도 전체를 막지 않고 그 항목만 `unavailable` + 사유로 강등한다.
- 단품 helper를 찾지 못하면 해당 섹션만 건너뛰고 나머지를 진행한다.
## 인증/시크릿
- 사용자 측 필수 시크릿 없음.
- proxy 섹션(국세청 상태·국민연금·금융위·부정당)은 운영 서버의 `DATA_GO_KR_API_KEY`로 동작한다.
- 무인증 섹션(체납·인허가)은 키 없이 사용자 머신에서 직접 동작한다.
## 예시
```bash
python3 biz-health-check/scripts/biz_health_check.py 124-81-00998 --name "삼성전자"
python3 biz-health-check/scripts/biz_health_check.py --name "호텔샬롬" --region 제주제주시 --industry 숙박업
```
## 입력
- `b_no`: 사업자등록번호 10자리(하이픈 허용) — 상태조회·부정당제재에 필요
- `--name`: 상호·법인명 — 국민연금·금융위·체납·인허가에 필요
- `--region`: 시군구 — 인허가(동네 사업장) 조회에 필요
- `--industry`: 인허가 업종(여러 번 지정 가능)
## 공식 출처
- 각 단품 스킬 문서의 공식 출처를 따른다. 통합 목록은 [sources](../sources.md)의 "사업자 실사" 항목 참조.

View file

@ -8,13 +8,13 @@
- ✅ Workflow A — **매각공고 브라우징**: 매각기일·법원·기일/기간 입찰을 조건으로 매각공고 목록 → 그 공고 안의 사건번호·용도·주소·감정평가액·최저매각가격 펼치기
- ✅ Workflow B — **사건번호 직접 조회**: 법원사무소코드 + 사건번호(`2024타경100001`) → 사건정보·물건내역·매각기일별 이력·배당요구종기
- ✅ 법원사무소 코드(60+개) + 입찰구분 코드(기일입찰=`000331`, 기간입찰=`000332`) 변환
- ✅ Workflow C — **부동산 물건 자유 조건검색**: 지역·용도·가격대·면적·유찰횟수·매각기일 조건 → 물건 목록 JSON
- ✅ 법원사무소 코드(60+개) + 입찰구분 코드(기일입찰=`000331`, 기간입찰=`000332`) + Workflow C용 대표 용도/지역 코드 변환
- ✅ 2-tier transport — direct HTTP 1차, Playwright fallback 옵션
- ✅ 안티봇 가드 — 호출 간 ≥2초 jitter, 세션당 호출 budget, `data.ipcheck === false` 즉시 `BLOCKED` throw
## 무엇을 할 수 없나 (별도 follow-up 이슈)
- ❌ Workflow C 자유 조건검색 (지역·용도·가격대·면적·유찰횟수)
- ❌ Workflow D 일별/월별 캘린더
- ❌ 매각물건 사진(전경/개황/내부) URL 노출
- ❌ 매각물건명세서·현황조사서·감정평가서 PDF 다운로드
@ -36,7 +36,11 @@
court-auction-notice-search -h
court-auction-notice-search codes courts --pretty | head -40
court-auction-notice-search codes bid-types --pretty
court-auction-notice-search codes usages --pretty
court-auction-notice-search codes regions --pretty
court-auction-notice-search notices --date 2026-04 --court-code B000210 --bid-type date --pretty
court-auction-notice-search search --sido 서울특별시 --sigungu 11680 --usage-large 건물 --usage-medium 21200 \
--price-min 100000000 --price-max 500000000 --sale-from 2026-05-01 --sale-to 2026-05-20 --pretty
court-auction-notice-search case --court-code B000210 --case-number "2024타경100001" --pretty
```
@ -46,7 +50,8 @@ court-auction-notice-search case --court-code B000210 --case-number "2024타경1
const {
searchSaleNotices,
getSaleNoticeDetail,
getCaseByCaseNumber
getCaseByCaseNumber,
searchProperties
} = require("court-auction-notice-search");
const notices = await searchSaleNotices({
@ -67,6 +72,16 @@ const caseInfo = await getCaseByCaseNumber({
courtCode: "B000210",
caseNumber: "2024타경100001"
});
const properties = await searchProperties({
region: { sido: "서울특별시", sigungu: "11680", dong: "11680101" },
usage: { large: "건물" },
priceRange: { min: 100000000, max: 500000000 },
saleDate: { from: "2026-05-01", to: "2026-05-20" },
flbdCount: { min: 1 },
page: 1,
pageSize: 20
});
```
## 사이트 내부 endpoint (직접 캡처한 것)
@ -76,9 +91,10 @@ const caseInfo = await getCaseByCaseNumber({
| 매각공고 목록 | `POST /pgj/pgj143/selectRletDspslPbanc.on` | `{"dma_srchDspslPbanc":{"srchYmd","cortOfcCd","bidDvsCd","srchBtnYn":"Y"}}` (`srchYmd`는 사이트 검색 버튼과 동일하게 `YYYYMM`) |
| 매각공고 상세 | `POST /pgj/pgj143/selectRletDspslPbancDtl.on` | `{"dma_srchGnrlPbanc":{"cortOfcCd","dspslDxdyYmd","jdbnCd",...}}` |
| 사건 단건 | `POST /pgj/pgj15A/selectAuctnCsSrchRslt.on` | `{"dma_srchCsDtlInf":{"cortOfcCd","csNo"}}` |
| 물건 자유 조건검색 | `POST /pgj/pgjsearch/searchControllerMain.on` | canonical body captured via Playwright (`scripts/capture-pgj151-submit.cjs`); fixture at `packages/court-auction-notice-search/test/fixtures/canonical-search-body.json`. `pageNo/pageSize/statNum` 은 number, `pageSize` 는 upstream 드롭다운 값 `10`/`20`/`50`/`100`만 허용, `notifyLoc` 기본 `"off"`. |
| 법원사무소 코드 | `POST /pgj/pgjComm/selectCortOfcCdLst.on` | `{}` |
세션 cookie(`JSESSIONID`, `WMONID`)는 `GET /pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01` 으로 사전에 한 번 받아둡니다.
세션 cookie(`JSESSIONID`, `WMONID`)는 endpoint별 진입 화면을 먼저 열어 받아둡니다. 매각공고/상세는 `GET /pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ143M01.xml&pgjId=143M01`, 물건 자유 조건검색(Workflow C)은 `GET /pgj/index.on?w2xPath=/pgj/ui/pgj100/PGJ151F00.xml&pgjId=151F00` 으로 warmup 합니다.
## 설치
@ -92,5 +108,5 @@ npm install playwright-core
## 관련 이슈
- 이 패키지는 [Issue #167](https://github.com/NomaDamas/k-skill/issues/167) 에서 출발했고, A/B 워크플로 + 코드테이블 MVP만 포함합니다.
- 자유 조건검색·캘린더·물건 사진·PDF·동산 경매는 별도 follow-up 이슈로 분리되어 추적됩니다.
- 이 패키지는 [Issue #167](https://github.com/NomaDamas/k-skill/issues/167) 에서 출발했고, #184에서 Workflow C 자유 조건검색을 추가했습니다.
- 캘린더·물건 사진·PDF·동산 경매는 별도 follow-up 이슈로 분리되어 추적됩니다.

View file

@ -0,0 +1,43 @@
# 당근중고차 검색 가이드 (`daangn-cars-search`)
당근중고차 공개 웹 데이터 표면을 사용해 지역·키워드·가격 조건 기반 차량을 검색하고, 개별 차량 상세를 읽기 전용으로 확인하는 스킬입니다.
## 사용 시나리오
- "당근중고차 합정동 레이 찾아봐"
- "당근에서 천만원 이하 중고차 검색해줘"
- "이 당근 중고차 URL 상세 요약해줘"
## 구현 표면
브라우저 자동화, 로그인, 채팅, 문의, 구매 자동화를 사용하지 않습니다.
1. 지역 해석: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
2. 검색: `https://www.daangn.com/kr/cars/?in=<지역명>-<id>&onlyOnSale=1&_data=routes/kr.cars._index`
3. 상세: `<차량 URL>?_data=routes%2Fkr.cars.%24car_post_id`
## 로컬 실행
```bash
python3 daangn-cars-search/scripts/daangn_cars.py search "레이" --region "합정동" --limit 5
python3 daangn-cars-search/scripts/daangn_cars.py search --region "합정동" --price-max 10000000 --limit 5
python3 daangn-cars-search/scripts/daangn_cars.py detail "https://www.daangn.com/kr/cars/.../"
```
## 지역 필터
지역명은 당근 region API로 내부 id를 해석한 뒤 `in=<지역명>-<id>` 형태로 검색 URL에 넣습니다.
```text
합정동 → 서울특별시 마포구 합정동, id=231 → in=합정동-231
```
## 출력 해석
검색 결과는 `title`, `price`, `price_text`, `region`, `status`, `driveDistance`, `carData`, `chatRoomCount`, `url`을 우선 확인합니다. 차량 연식, 주행거리, 사고/정비 이력처럼 원문 의존도가 높은 정보는 상세 조회의 `carPost` 원문을 함께 확인합니다.
## 제한사항
- 공개 Remix `_data` route 이름이나 JSON shape가 바뀌면 실패할 수 있습니다.
- 문의, 시승 예약, 구매, 결제, 채팅 자동화는 실행하지 않습니다.
- 가격·판매 상태는 실시간으로 바뀔 수 있어 원문 URL을 함께 제시합니다.

View file

@ -0,0 +1,42 @@
# 당근알바 검색 가이드 (`daangn-jobs-search`)
당근알바 공개 웹 데이터 표면을 사용해 키워드·지역 기반 알바 공고를 검색하고, 개별 공고 상세를 읽기 전용으로 확인하는 스킬입니다.
## 사용 시나리오
- "당근알바 합정동 카페 알바 찾아봐"
- "홍대 근처 주말 알바 검색해줘"
- "이 당근알바 공고 상세 요약해줘"
## 구현 표면
브라우저 자동화, 로그인, 채팅, 지원, 문의 자동화를 사용하지 않습니다.
1. 지역 해석: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
2. 검색: `https://www.daangn.com/kr/jobs/?in=<지역명>-<id>&search=<키워드>&_data=routes/kr.jobs._index`
3. 상세: `<공고 URL>``jobs.daangn.com/job-posts/<id>` 공개 HTML의 title/meta/JSON-LD(헬퍼는 legacy `_data`를 먼저 시도 후 빈 응답이면 HTML 메타로 fallback)
## 로컬 실행
```bash
python3 daangn-jobs-search/scripts/daangn_jobs.py search "카페" --region "합정동" --limit 5
python3 daangn-jobs-search/scripts/daangn_jobs.py detail "https://www.daangn.com/kr/jobs/.../"
```
## 지역 필터
지역명은 당근 region API로 내부 id를 해석한 뒤 `in=<지역명>-<id>` 형태로 검색 URL에 넣습니다.
```text
합정동 → 서울특별시 마포구 합정동, id=231 → in=합정동-231
```
## 출력 해석
검색 결과는 `title`, `company`, `region`, `address`, `salary`, `salaryType`, `workDays`, `workTimeStart`, `workTimeEnd`, `closed`, `url`을 우선 확인합니다. 상세 조회는 가능하면 `jobPost` 원문을 사용하고, 공개 `_data`가 빈 응답이면 HTML title/meta/JSON-LD를 근거로 정리합니다.
## 제한사항
- 공개 Remix `_data` route 이름이나 JSON shape가 바뀌면 실패할 수 있습니다.
- 마감·삭제·비공개 전환된 공고는 상세 조회가 실패할 수 있습니다.
- 지원, 채팅, 문의, 개인정보 제출 자동화는 범위 밖입니다.

View file

@ -0,0 +1,43 @@
# 당근부동산 검색 가이드 (`daangn-realty-search`)
당근부동산 공개 웹 데이터 표면을 사용해 지역 기반 부동산 매물 후보를 검색하고, 상세 페이지의 공개 메타를 읽기 전용으로 확인하는 스킬입니다.
## 사용 시나리오
- "당근부동산 합정동 월세 매물 찾아봐"
- "마포구 전세 후보 당근에서 봐줘"
- "이 당근부동산 URL 상세 요약해줘"
## 구현 표면
브라우저 자동화, 로그인, 채팅, 문의, 예약, 계약 자동화를 사용하지 않습니다.
1. 지역 해석: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
2. 검색: `https://www.daangn.com/kr/realty/?in=<지역명>-<id>&_data=routes/kr.realty._index`
3. 상세: `https://realty.daangn.com/articles/<id>``application/ld+json``<title>`
## 로컬 실행
```bash
python3 daangn-realty-search/scripts/daangn_realty.py search --region "합정동" --limit 5
python3 daangn-realty-search/scripts/daangn_realty.py search --region "합정동" --sales-type "APARTMENT" --trade-type "MONTHLY_RENT"
python3 daangn-realty-search/scripts/daangn_realty.py detail "https://realty.daangn.com/articles/..."
```
## 지역 필터
지역명은 당근 region API로 내부 id를 해석한 뒤 `in=<지역명>-<id>` 형태로 검색 URL에 넣습니다.
```text
합정동 → 서울특별시 마포구 합정동, id=231 → in=합정동-231
```
## 출력 해석
검색 결과는 `title`, `salesType`, `trade`, `area`, `areaPyeong`, `totalManageCost`, `url`을 우선 확인합니다. 부동산 판단에는 실시간 상태, 보증금/월세, 관리비, 면적, 중개/직거래 여부가 중요하므로 원본 URL을 함께 제시합니다.
## 제한사항
- 당근부동산 목록 JSON과 `realty.daangn.com` 상세 HTML 구조 변경에 영향을 받습니다.
- 문의, 방문 예약, 계약, 결제, 채팅은 실행하지 않습니다.
- 공고 내용은 실시간 상태와 달라질 수 있어 최종 판단 전 원문 확인이 필요합니다.

View file

@ -0,0 +1,45 @@
# 당근 중고거래 검색 가이드 (`daangn-used-goods-search`)
당근 중고거래 공개 웹 데이터 표면을 사용해 키워드·지역 기반 매물을 검색하고, 개별 매물 상세를 읽기 전용으로 확인하는 스킬입니다.
## 사용 시나리오
- "당근에서 합정동 맥북 매물 찾아봐"
- "이 당근 중고거래 URL 상세 요약해줘"
- "아이폰 15 Pro 중고 매물 중 판매중인 것만 봐줘"
## 구현 표면
브라우저 자동화, 로그인, 채팅, 찜, 거래 제안, 구매 자동화를 사용하지 않습니다.
1. 지역 해석: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
2. 검색: `https://www.daangn.com/kr/buy-sell/all/?in=<지역명>-<id>&search=<키워드>&only_on_sale=true&_data=routes/kr.buy-sell._index`
3. 상세: `<매물 URL>?_data=routes%2Fkr.buy-sell.%24buy_sell_id`
## 로컬 실행
```bash
python3 daangn-used-goods-search/scripts/daangn_used_goods.py search "맥북" --region "합정동" --limit 5
python3 daangn-used-goods-search/scripts/daangn_used_goods.py detail "https://www.daangn.com/kr/buy-sell/.../"
```
## 지역 필터
지역명은 바로 URL에 넣지 않고 당근 region API로 내부 id를 먼저 조회합니다.
```text
합정동 → 서울특별시 마포구 합정동, id=231 → in=합정동-231
```
동일 지명이 여러 곳에 있으면 정확 일치 후보, 서울 동 단위 후보, 첫 번째 후보 순으로 선택합니다. 결과에는 적용 지역(`effective_region`)과 원본 URL을 함께 남깁니다.
## 출력 해석
검색 결과는 `title`, `price`, `price_text`, `status`, `region`, `url` 중심으로 1차 후보를 고릅니다. 조회수, 채팅수, 설명 같은 상세 판단은 상세 조회 결과의 `product` 원문을 확인한 뒤 정리합니다.
## 제한사항
- 공개 Remix `_data` route 이름이나 JSON shape가 바뀌면 실패할 수 있습니다.
- 삭제·판매완료·비공개 전환된 글은 상세 조회가 실패할 수 있습니다.
- CAPTCHA, 로그인벽, 봇 차단이 나오면 실패 모드로 보고하고 우회하지 않습니다.
- 상대방에게 영향을 주는 채팅, 찜, 거래 제안, 구매 자동화는 범위 밖입니다.

View file

@ -0,0 +1,45 @@
# 대신증권 리포트 조회 가이드
`daishin-report-search``jay-jo-0/github_pages_repo` GitHub Pages 미러에 올라오는 대신증권 리포트 HTML을 최신순으로 찾고 원문/설명 페이지를 JSON으로 정리하는 조회 전용 스킬이다.
## 공개 접근 경로
- 목록: `https://api.github.com/repos/jay-jo-0/github_pages_repo/git/trees/main?recursive=1`
- 원문 HTML: `https://raw.githubusercontent.com/Jay-jo-0/github_pages_repo/main/<YYYYMMDDHHMMSS.html>`
- exact-file fallback: `https://api.github.com/repos/jay-jo-0/github_pages_repo/contents/<YYYYMMDDHHMMSS.html>?ref=main`
- 브라우저 URL: `https://jay-jo-0.github.io/github_pages_repo/<YYYYMMDDHHMMSS.html>`
- 설명 페이지: `<YYYYMMDDHHMMSS_explain.html>`이 있을 때만 제공
파일명 timestamp를 KST 게시 추정 시각으로 표시한다. GitHub API와 raw 파일은 공개 unauthenticated endpoint라서 proxy를 쓰지 않는다.
## 사용 예시
```bash
node packages/daishin-report-search/src/cli.js --limit 10
GITHUB_TOKEN=... node packages/daishin-report-search/src/cli.js --limit 10
node packages/daishin-report-search/src/cli.js 반도체 --limit 5 --max-inspect 100
node packages/daishin-report-search/src/cli.js --id 20260511082352 --include-explain
```
```js
const { listReports, fetchReport } = require("daishin-report-search")
const latest = await listReports({ limit: 10 })
const semis = await listReports({ query: "반도체", limit: 5, maxInspect: 100 })
const withToken = await listReports({ githubToken: process.env.GITHUB_TOKEN })
const detail = await fetchReport("20260511082352", { includeExplain: true })
```
## 출력 필드
목록 항목은 `id`, `date`, `time`, `timestamp`, `title`, `headings`, `excerpt`, `ratingTargets`, `pageUrl`, `rawUrl`, `apiUrl`, `hasExplain`, `explainUrl`을 포함한다.
상세 조회는 원문 `text`를 추가하고, `includeExplain`이 켜져 있으면 `explain` 객체에 설명 페이지의 `title`, `headings`, `text`, `excerpt`, `pageUrl`을 포함한다.
## 주의 사항
- 투자 판단이나 매매 추천이 아니라 공개 리포트 조회 보조 기능이다.
- GitHub unauthenticated API rate limit, upstream repository 변경, HTML 구조 변경 시 경고나 오류가 반환될 수 있다. 목록 조회의 GitHub tree API가 403/429로 막히면 예외 대신 빈 `items``source.error`/rate-limit metadata를 반환한다.
- API limit을 높여야 할 때는 caller-owned `githubToken`/`githubHeaders` 옵션 또는 CLI 환경변수 `DAISHIN_GITHUB_TOKEN`/`GITHUB_TOKEN`을 사용할 수 있다. 이 값은 GitHub API host(tree discovery와 exact-file fallback)에만 전송되고 raw 원문 URL에는 전송되지 않는다. 기본 동작에는 토큰이나 proxy가 필요 없다.
- 상세 조회는 raw 원문 URL을 먼저 읽고, 실패하면 알려진 timestamp 경로의 GitHub contents API로 fallback한다.
- 검색어가 있으면 최신 파일부터 `maxInspect`개까지 원문을 읽어 매칭하므로 너무 낮게 잡으면 결과가 누락될 수 있다.

View file

@ -4,8 +4,14 @@
- 다이소 매장명으로 공식 매장 후보 찾기
- 상품명/검색어로 공식 상품 후보 찾기
- 특정 매장의 **매장 픽업 재고** 확인
- 필요하면 온라인 재고 참고값 함께 확인
- 특정 매장의 **매장 픽업 재고 수량** 확인 (Bearer 토큰 인증 기반 공식 `selStrPkupStck` 표면)
- 필요하면 `referenceOnly: true` 온라인 재고 참고값 함께 확인
## 이 기능으로 할 수 없는 일 (스킬 범위 한계)
- 매장 내 진열 위치(aisle/매대)는 공식 표면이 제공하지 않으므로 답하지 않습니다.
- 결제·주문·픽업 예약 자동화는 범위가 아닙니다.
- 비공식 크롤링·헤드리스 브라우저 우회·계정 세션 재사용은 범위가 아닙니다.
## 먼저 필요한 것
@ -27,7 +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`
- 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`
## 기본 흐름
@ -36,9 +44,12 @@
2. 상품명이 없으면 상품명/검색어를 한 번 더 물어봅니다.
3. `selStr` 로 매장 후보를 찾고, 필요하면 `selStrInfo` 로 매장 상세를 확인합니다.
4. `SearchGoods` 로 상품 후보를 찾습니다.
5. `selStrPkupStck` 로 해당 매장의 상품 재고를 확인합니다.
6. 필요하면 `SearchGoods` 응답의 `onldPdNo` 를 함께 보존해 `selOnlStck` 온라인 재고 교차 확인에 사용합니다.
7. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
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. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
## 예시
@ -71,13 +82,20 @@ main().catch((error) => {
- 상품 후보가 여러 개면 브랜드, 용량, 호수까지 같이 보여 주는 편이 덜 헷갈립니다.
- 재고 수량은 실시간 100% 보장값이 아니므로, 필요하면 `방문 직전 다시 확인` 문구를 같이 줍니다.
- 공식 표면이 매장 내 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 답합니다.
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 상품 재고 여부는 `inStock` 또는 `inventoryStatus` 로 설명하고, `status: "available"` 만으로 재고가 있다고 말하지 않습니다.
- 인증 키(`PRE_AUTH_ENC_KEY`)는 JS 번들에 하드코딩되어 있으며 변경될 수 있습니다. 403이 지속되면 키가 교체된 것일 수 있습니다.
## 라이브 확인 메모
2026-03-27 기준으로 다음 공식 호출이 실제 응답을 반환했습니다.
2026-03-27 기준으로 `selStrPkupStck` 는 실제 매장 픽업 재고를 반환했습니다.
2026-05-15 기준 Bearer 토큰 인증(`/api/auth/request` + AES-128-CBC)으로 정상 접근 가능합니다.
- `POST /api/ms/msg/selStr``강남역2호점` 매장 후보
- `GET /ssn/search/SearchGoods?searchTerm=리들샷...``1049275` 포함 상품 후보
- `POST /api/pd/pdh/selStrPkupStck``strCd=10224`, `pdNo=1049275` 조합의 매장 픽업 재고
현재 운영 원칙은 다음과 같습니다.
같은 날짜 smoke test 에서 `강남역2호점 + VT 리들샷 100` 조합은 재고 수량 `0` 으로 응답했습니다. 즉, **공식 경로가 실제로 동작함은 확인했지만 당시 해당 매장 재고는 없었습니다.**
- `POST /api/ms/msg/selStr` → 매장 후보 확인
- `GET /ssn/search/SearchGoods?searchTerm=...` → 상품 후보 및 `onldPdNo` 확인
- `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

@ -0,0 +1,55 @@
# 다나와 최저가 비교 (`danawa-price-search`)
다나와 공개 검색/가격비교 표면을 사용해 상품 후보를 찾고, 쇼핑몰별 가격을 배송비 포함 실구매가 기준으로 비교하는 스킬입니다.
## 사용 시나리오
- "다나와에서 맥북 에어 M4 최저가 비교해줘"
- "이 다나와 pcode 쇼핑몰별 가격 표로 보여줘"
- "배송비랑 카드할인까지 포함해서 어디가 제일 싼지 봐줘"
## 구현 표면
브라우저 자동화나 로그인을 사용하지 않습니다.
1. 검색: `https://search.danawa.com/dsearch.php?query=...`
2. 상품 상세 확인: `https://prod.danawa.com/info/?pcode=...`
3. 쇼핑몰별 가격비교 AJAX: `https://prod.danawa.com/info/ajax/getAllPriceCompareMallList.ajax.php`
## 로컬 실행
```bash
python3 danawa-price-search/scripts/danawa_search.py search "맥북 에어 M4" --limit 5
python3 danawa-price-search/scripts/danawa_search.py offers 28208783 --limit 10
python3 danawa-price-search/scripts/danawa_search.py compare "갤럭시 S25" --limit 3 --offers 5
```
## 출력 해석
`offers``compare` 결과에는 다음 필드가 포함됩니다.
- `mall`: 쇼핑몰명
- `price`: 표시 가격
- `shipping_fee`: 배송비 숫자. 무료배송이면 `0`, 파싱 불가면 `null`
- `is_free_shipping`: 무료배송 여부
- `total_price`: 가격 + 배송비 기준 실구매가 후보
- `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 표를 먼저 보여주고, 카드가는 별도 열에 표시합니다.
## 주의사항
- 다나와의 공개 HTML/AJAX 구조가 바뀌면 selector와 파싱 규칙을 갱신해야 합니다.
- 자동 구매, 로그인, CAPTCHA 우회, 결제 단계 자동화는 이 스킬의 범위가 아닙니다.
- 동일 상품명이라도 옵션/용량/모델명이 섞일 수 있으므로 검색 후보를 먼저 확인한 뒤 가격비교를 진행합니다.
- 결제조건 배지(현금/쿠폰/포인트/할인/특정 카드/멤버십 한정)는 사용자 응답 표에 반드시 `payment_condition_label` 기반 라벨로 표시해야 합니다. 정렬은 `total_price` 단일 기준이라 조건부 가격이 1위로 올라올 수 있고, 라벨이 없으면 카드 결제 사용자에게 적용 불가능한 가격을 일반 최저가로 안내하게 됩니다.

View file

@ -0,0 +1,34 @@
# 기부처 조회 가이드
`donation-place-search`는 사용자가 제공한 지역과 관심 분야를 기준으로 한국 기부처 후보를 추천하는 조회형 스킬이다.
- 자동 후원 신청, 결제, 개인정보 입력은 하지 않는다.
- 1365 기부포털 공식 진입점(`https://www.1365.go.kr/dntn/main.do`)과 각 단체 공식 홈페이지에서 최신 등록 상태, 모금 기간, 기부금영수증 가능 여부를 확인하도록 안내한다.
- 공개 페이지와 로컬 후보 랭킹만 사용하므로 `k-skill-proxy`나 API key가 필요 없다.
## 사용 예
```js
const {
recommendDonationPlaces,
formatDonationRecommendationReport
} = require("donation-place-search");
const result = recommendDonationPlaces({
location: "서울 마포구",
category: "동물",
limit: 3
});
console.log(formatDonationRecommendationReport(result));
```
## 입력
- `location`: `서울 마포구`, `부산 해운대구`, `제주`, `온라인` 같은 위치 힌트
- `category`: `아동`, `동물보호`, `환경`, `재난`, `장애`, `노인`, `의료`, `생계`, `해외구호`
- `limit`: 기본 5, 최대 20
## 검증 표면
`nanumkorea.go.kr`는 1365 자원봉사/기부 통합 안내를 반환하므로, 스킬은 `www.1365.go.kr/dntn/main.do`를 최신 공식 확인 진입점의 기준으로 사용한다. 1365 페이지가 headless HTTP에서 느리거나 빈 응답을 줄 수 있어 화면 스크래핑 대신 best-effort 확인 보조 링크와 후보 공식 홈페이지를 함께 제시하며, 후보별 등록 검증이 이미 완료됐다고 표현하지 않는다.

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,40 @@
# 고속버스 예매 가이드
## 이 기능으로 할 수 있는 일
- KOBUS 고속버스 터미널/노선 후보 확인
- 배차 시간표, 버스 등급, 잔여석, 요금 확인
- 좌석 선택 단계 진입 가능 여부 확인
- 필요한 경우 임시 좌석 선점 후 공식 결제정보 입력 페이지로 handoff
- 진행하지 않을 때 임시 선점 해제
## 먼저 필요한 것
- 별도 사용자 계정/비밀번호는 기본 조회·좌석 단계에서 필요하지 않음
- 결제는 공식 KOBUS 페이지에서 사용자가 직접 진행
- 브라우저 자동화보다 `https://www.kobus.co.kr` 공식 HTTP 흐름을 우선 사용
## 입력값
- 출발 터미널
- 도착 터미널
- 날짜: `YYYYMMDD`
- 희망 시간대
- 인원 수와 좌석 선호
## 기본 흐름
1. 쿠키 jar를 만들고 KOBUS 메인/예매 페이지를 열어 세션을 시작한다.
2. `POST /mrs/readRotLinInf.ajax` 로 터미널/노선 코드를 확인한다.
3. `POST /mrs/alcnSrch.do` 로 배차를 조회한다.
4. 결과 HTML의 `fnSatsChc(...)` 인자를 파싱해 후보를 정리한다.
5. 선택 후보는 `POST /mrs/satschc.do` 로 좌석/요금 단계 진입을 확인한다.
6. 사용자가 원하면 `POST /mrs/setPcpy.ajax` 로 임시 선점 후 공식 결제정보 입력 페이지 링크를 제공한다.
7. 사용자가 진행하지 않으면 `POST /mrs/cancPcpy.ajax` 로 선점을 해제한다.
## 주의할 점
- 결제 자동화는 포함하지 않는다. 공식 페이지의 결제 직전 단계까지 보조하는 assisted checkout 흐름이다.
- KOBUS 모바일 페이지는 좁은 화면에서 `/mblIdx.do` 로 리다이렉트할 수 있어 helper 링크 caveat를 확인한다.
- KOBUS 터미널 코드는 티머니 시외버스 코드와 다르므로 혼용하지 않는다.
- stateless POST보다 쿠키와 referer를 유지하는 흐름이 안정적이다.

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

@ -80,6 +80,7 @@ python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --forest-name 유
3. helper로 read-only 월별예약조회 endpoint를 실행한다.
4. helper가 로그인 세션, CSRF, 공식 휴양림 ID 목록을 확보한다.
5. 날짜, 휴양림명, 객실/시설명, 숙박/야영 구분, 정원 중심으로 요약한다.
6. 응답 정제: API가 `srchDate` 기준 최대 5일 윈도우를 반환할 수 있어 helper가 요청 범위 밖 `useDt`, 운영자 보유분("예비" 포함 객실), 같은 객실 중복 행을 자동 제거한다.
2026-04-29 확인 기준, 로그인 없이 월별예약조회 화면에 접근하면 `401 Unauthorized`가 반환되고, 조회 endpoint는 JSON 대신 안내 HTML을 반환한다. 따라서 현재 구현은 로그인 세션/CSRF 확보를 필수 전제로 둔다.
@ -136,6 +137,8 @@ python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --all --text --date
- aggressive polling은 피한다.
- 조회 결과는 시점 차이로 숲나들e 화면과 달라질 수 있다.
- 로그인 실패 시 계정 정보 또는 숲나들e 정책 변경을 먼저 확인한다.
- API가 요청 날짜보다 넓은 5일 윈도우를 반환해도 출력에는 요청 범위(`today``last_day`) 안의 행만 포함된다.
- "예비" 표기가 있는 객실은 사용자 예약 화면에 노출되지 않는 운영자 보유분이라 결과에서 자동 제외된다.
## 흔한 문제 해결

View file

@ -0,0 +1,35 @@
# 금융위 기업기본정보 조회 (fsc-corporate-info)
`fsc-corporate-info` 스킬은 공공데이터포털의 **금융위원회_기업기본정보 서비스**(15043184, `getCorpOutline_V2`)를 `k-skill-proxy` 경유로 호출한다.
## 제공 기능
- 법인명(`corpNm`) 기준 후보: 대표자·설립일·업종 등 upstream 필드 원문
- 사업자번호 교차검증: 응답에 `bzno`가 있으면 입력 번호와 정확 일치하는 후보를 분리(없으면 교차검증 불가 표기)
## 인증/시크릿
사용자 로컬 시크릿은 필요 없다. upstream `DATA_GO_KR_API_KEY`는 프록시 서버에만 둔다(15043184 활용신청 필요). self-host 프록시는 `KSKILL_PROXY_BASE_URL`로 지정한다.
## 입력 제한
검색 파라미터가 `crno`(법인등록번호 13자리)/`corpNm`(법인명)뿐이라 **사업자번호 단독 조회가 불가**하다. 법인명으로 조회한다. `crno`는 사업자등록번호와 별개 번호다.
## 예시
```bash
python3 fsc-corporate-info/scripts/fsc_corporate_info.py --name "삼성전자" --b-no 124-81-00998
```
## 실패 모드
- `400 bad_request`: 법인명 미입력
- `503 upstream_not_configured`: 프록시에 `DATA_GO_KR_API_KEY` 없음
- `502 upstream_forbidden`: 프록시 키가 15043184에 미신청
- 빈 결과: 법인명 불일치 — 표기를 바꿔 재시도
## 공식 출처
- 공공데이터포털: <https://www.data.go.kr/data/15043184/openapi.do>
- upstream: `https://apis.data.go.kr/1160100/service/GetCorpBasicInfoService_V2/getCorpOutline_V2`
- 프록시 route: `GET /v1/fsc/corp-outline`

View file

@ -0,0 +1,41 @@
# 부정당제재업체 조회 (g2b-sanctioned-supplier)
`g2b-sanctioned-supplier` 스킬은 공공데이터포털의 **조달청 나라장터 사용자정보 서비스**(15129466, `getUnptRsttCorpInfo02`)를 `k-skill-proxy` 경유로 호출한다.
## 제공 기능
- 사업자등록번호 정확 일치(`inqryDiv=1`)로 **조회시점 현재 유효한** 부정당제재 조회
- 반환: 제재 시작/종료일자, 제재기관명, 계약법구분, 제재근거법률 등 upstream 필드 원문
## 적용 범위 한계
upstream 명세상 다음은 제공되지 않는다(과거 이력 조회가 아니다).
- 조회시점에 제재만료·해제된 건
- 나라장터 미등록업체·개인에 대한 제재
만료 이력까지 보려면 나라장터(<https://www.g2b.go.kr>)에서 수동 확인이 필요하다.
## 인증/시크릿
사용자 로컬 시크릿은 필요 없다. upstream `DATA_GO_KR_API_KEY`는 프록시 서버에만 둔다(15129466 활용신청 필요). self-host 프록시는 `KSKILL_PROXY_BASE_URL`로 지정한다.
## 예시
```bash
python3 g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py --bizno 124-81-00998
```
## 실패 모드
- `400 bad_request`: 사업자번호가 10자리가 아님
- `503 upstream_not_configured`: 프록시에 `DATA_GO_KR_API_KEY` 없음
- `502 upstream_forbidden`: 프록시 키가 15129466에 미신청
- `total_count: 0`: 조회시점 유효 제재 없음(만료·미등록업체는 미제공임에 유의)
## 공식 출처
- 공공데이터포털: <https://www.data.go.kr/data/15129466/openapi.do>
- upstream: `https://apis.data.go.kr/1230000/ao/UsrInfoService02/getUnptRsttCorpInfo02`
- 수동 대조: 나라장터 <https://www.g2b.go.kr>
- 프록시 route: `GET /v1/g2b/sanctioned-supplier`

View file

@ -0,0 +1,32 @@
# 강남언니 병원 조회 가이드
`gangnamunni-clinic-search`는 강남언니 공개 검색 페이지에서 병원 후보를 조회하는 read-only 스킬입니다.
## 공개 접근 경로
- 검색 URL: `https://www.gangnamunni.com/search?q=<keyword>`
- 데이터 위치: HTML 안의 `__NEXT_DATA__` JSON (`props.pageProps.hospitals`)
- 인증/시크릿: 불필요
- 프록시: 사용하지 않음
## 예시
```bash
npx gangnamunni-clinic-search "강남 성형외과" --limit 5
```
```js
const { searchClinics } = require("gangnamunni-clinic-search")
const result = await searchClinics({ query: "코성형", limit: 3 })
```
## 출력
각 후보는 공개 검색 페이지에 포함된 병원명, 평점, 리뷰 수, 지원 언어, 이미지 URL, 공개 병원 링크를 포함합니다.
## 제한사항
- 조회 시점 공개 검색 결과 기준입니다.
- 로그인, 상담, 예약, 결제, 찜, 리뷰 작성은 자동화하지 않습니다.
- CAPTCHA/차단/로그인벽/빈 shell 페이지는 실패 모드로 처리합니다.
- 의료 판단이나 병원 선택 보증을 대신하지 않습니다.

View file

@ -0,0 +1,93 @@
# 개별공시지가 조회 가이드
## 이 기능으로 할 수 있는 일
- 한국 국토교통부 부동산공시가격알리미(`realtyprice.kr`)에서 지번 단위 **개별공시지가**(원/㎡) 조회
- 다년도 추이(과거 수년치)와 전년 대비 변동률 정규화 JSON 출력
- 17개 광역자치단체(서울, 세종특별자치시 포함) 모든 시·군·구 지원
- 산 지번 / 본번-부번 모두 지원
## 가장 중요한 규칙
`realtyprice.kr`는 **API 키가 필요 없는 완전 공개 엔드포인트**이므로 이 스킬은 `k-skill-proxy`를 경유하지 않는다. 사용자 머신에서 직접 upstream을 호출한다. (저장소의 *k-skill-proxy inclusion rule* — 프록시는 API 키가 필요한 upstream만 다룬다.)
## 무엇을 가져오나
- 공시지가는 매년 1월 1일 기준, 4~5월에 공시된다.
- 재산세, 종합부동산세, 양도소득세 등 **세금 산정의 법적 기준 단가**다.
- 공시지가 ≠ 시세. 시세는 통상 공시지가의 1.5~3배.
> 시세, 실거래가, 매매가, 호가가 필요하면 [`real-estate-search`](real-estate-search.md) 또는 다른 스킬을 사용한다.
## 먼저 필요한 것
없음. 인터넷 연결과 Node.js 18+ 만 있으면 된다.
## 사용 방법
### 설치
```bash
npm install gongsijiga-search
```
### 기본 호출
```js
const { lookupGongsijiga } = require("gongsijiga-search");
const result = await lookupGongsijiga("서울특별시 강남구 역삼동 736");
console.log(result.latest.price_per_sqm); // 72340000
console.log(result.yoy_change_pct); // 5.45
```
### 입력 주소 형식
`<시도> <시군구> <읍면동…> [산] <본번[-부번]>`
| 형식 | 예시 |
| --- | --- |
| 일반 | `서울특별시 강남구 역삼동 736` |
| 약칭 시도 | `서울 강남구 역삼동 736` |
| 부번 있음 | `경기 성남시 분당구 정자동 178-3` |
| 산 지번 | `서울 서초구 서초동 산 1-2` |
| 다토큰 읍면동 | `전남 무안군 청계면 청천리 100-5` |
| 세종 (시군구 없음) | `세종 어진동 575` 또는 `세종특별자치시 어진동 575` |
### 응답 모양
```json
{
"address": "서울특별시 강남구 역삼동 736",
"jibun": "736번지",
"san": false,
"latest": {
"year": 2026,
"price_per_sqm": 72340000,
"notice_date": "2026-04-30",
"base_date": "2026-01-01"
},
"history": [
{ "year": 2026, "price_per_sqm": 72340000, "notice_date": "2026-04-30" },
{ "year": 2025, "price_per_sqm": 68600000, "notice_date": "2025-04-30" }
],
"yoy_change_pct": 5.45,
"source_url": "https://www.realtyprice.kr/notice/gsindividual/search.htm"
}
```
## 실패 모드
| `error.code` | 의미 | 처리 |
| --- | --- | --- |
| `ADDRESS_PARSE_FAILED` | 주소 파싱 실패 / 미인식 시도 | "행정구역 + 본번이 포함된 주소가 필요합니다" 안내 후 재요청 |
| `INVALID_BUNJI` | 본번 비숫자 또는 4자리 초과 | 본번 형식 재요청 |
| `REGION_NOT_FOUND` | 시군구/읍면동 매칭 실패 | `err.candidates` 후보(최대 3개) 제안 |
| `LAND_NOT_FOUND` | 해당 지번 미등재 | "본번/부번 오타이거나 도로/하천 등 미과세 토지" 설명 |
| `UPSTREAM_ERROR` | `realtyprice.kr` 비정상 응답 | "데이터 출처 일시 장애. 잠시 후 재시도" + `source_url` |
| `UPSTREAM_TIMEOUT` | 30초 타임아웃 | UPSTREAM_ERROR와 동일 처리 |
## 출처
- [부동산공시가격알리미](https://www.realtyprice.kr/notice/gsindividual/search.htm) — 국토교통부
- 패키지 소스: [`packages/gongsijiga-search/`](../../packages/gongsijiga-search)

View file

@ -0,0 +1,68 @@
# 시외버스 예매 가이드
## 이 기능으로 할 수 있는 일
- 티머니 시외버스 터미널/노선 후보 확인
- 배차 시간표, 운수사, 잔여석, 요금 확인
- 좌석/요금 단계 진입 가능 여부 확인
- 공식 카드정보 입력 페이지로 handoff
## 먼저 필요한 것
- 별도 사용자 계정/비밀번호는 기본 조회·좌석 단계에서 필요하지 않음
- 결제는 공식 티머니 시외버스 페이지에서 사용자가 직접 진행
- 브라우저 자동화보다 `https://intercitybus.tmoney.co.kr` 공식 HTTP 흐름을 우선 사용
## 입력값
- 출발 터미널
- 도착 터미널
- 날짜: `YYYYMMDD`
- 희망 시간대
- 인원 수와 좌석 선호
## 기본 흐름
1. 쿠키 jar를 만들고 티머니 시외버스 페이지를 열어 세션을 시작한다.
2. `POST /otck/readAlcnList.do` 로 배차를 조회한다. 이때 브라우저 JS가 붙이는 `bef_Aft_Dvs=D`, `req_Rec_Num=10`을 반드시 같이 보낸다.
3. 결과의 `readSasFeeInf(...)` 인자를 파싱해 후보를 정리한다.
4. 선택 후보는 `POST /otck/readSatsFee.do` 로 좌석/요금 단계 진입을 확인한다.
5. 사용자가 원하면 `POST /otck/readPcpySats.do` 로 공식 카드정보 입력 페이지에 진입하도록 handoff한다.
6. 뒤로가기/취소성 이동으로 좌석 선택 단계에 복귀해 임시 선점을 해제할 수 있는지 확인한다.
## read-only 조회 helper
```bash
python3 intercity-bus-booking/scripts/intercity_bus_search.py \
--depart-code 0511601 \
--arrive-code 2482701 \
--depart-name 동서울 \
--arrive-name 속초 \
--date 20260520
```
이 helper는 쿠키 세션을 시작하고 공식 배차 조회 POST를 수행한 뒤 출발시각, 운수사, 등급, 요금, 잔여/총 좌석을 JSON으로 출력한다. 기본은 read-only이며, `--hold-seat` 또는 `--hold-first-seat`를 주면 좌석/요금 단계에 진입해 `readPcpySats.do`로 임시 좌석 선점을 만들고 공식 카드정보 입력 HTML과 cancel/back 필드를 저장한다. 결제 정보 입력·제출은 수행하지 않는다.
### 임시 선점 예시
```bash
python3 intercity-bus-booking/scripts/intercity_bus_search.py \
--depart-code 0511601 \
--arrive-code 2482701 \
--depart-name 동서울 \
--arrive-name 속초 \
--date 20260520 \
--select-index 1 \
--hold-first-seat \
--output-dir /tmp/tmoney-hold
```
성공 조건은 JSON의 `hold.success=true`, `hold.hold_id` 존재, 저장된 HTML에 `카드정보 입력` 표시가 있는 것이다. 라이브 응답 페이지에는 정확한 만료 카운트다운 문구가 노출되지 않았으므로, 선점 후 결제는 즉시 진행하게 안내하고 방치된 선점은 저장된 cancel/back 필드로 해제한다.
## 주의할 점
- 결제 자동화는 포함하지 않는다. 공식 페이지의 결제 직전 단계까지 보조하는 assisted checkout 흐름이다.
- 티머니 시외버스 터미널 코드는 KOBUS 고속버스 코드와 다르므로 혼용하지 않는다.
- 일부 표면은 `txbus` 계열 URL과 연결될 수 있지만, 검증된 기본 URL은 `intercitybus.tmoney.co.kr` 이다.
- stateless POST보다 쿠키와 referer를 유지하는 흐름이 안정적이다.
- `bef_Aft_Dvs` 또는 `req_Rec_Num`을 누락하면 실제 배차가 있어도 `errorCont`가 포함된 일반 오류 페이지가 반환될 수 있다.

View file

@ -0,0 +1,67 @@
# 잡코리아 인재검색 가이드
## 이 기능으로 할 수 있는 일
- 잡코리아 기업회원 인재검색 화면에서 구인/채용 조건을 입력해 후보를 찾는다.
- 사용자가 직접 로그인한 브라우저 세션에서 현재 보이는 마스킹된 목록/이력서 정보를 읽는다.
- 유료 이력서 열람 전에 후보 적합도를 비교하고 shortlist를 만든다.
- 영업, 마케팅, 디자인, PM/PO, HR, 재무, 운영, 개발/데이터 등 전 직무에 사용할 수 있다.
## 먼저 알아둘 점
- 잡코리아 구인자/채용 담당자가 접근 가능한 기업회원 계정과 사용자 직접 로그인이 필요하다.
- 에이전트는 비밀번호, OTP, 세션 쿠키를 요청하거나 저장하지 않는다.
- 유료 열람, 마스킹 해제, 연락처 확인, 포지션 제안, 스크랩, 메모, 후보 상태 변경은 자동으로 하지 않는다.
- 비로그인 공개 목록 fallback은 가능하지만 정확도가 낮으므로 `목록 기반 1차 shortlist`로 표시한다.
## 공식 표면
- 잡코리아 기업 인재검색: https://www.jobkorea.co.kr/corp/person/find
## 입력값
- 채용 직무명
- 경력 범위
- 지역
- 필수 경험/스킬/업종
- 우대 경험/성과/툴
- 제외할 업무/업종/경력 패턴
- 유료 열람 추천 인원 수
## 기본 흐름
1. 잡코리아 기업 인재검색 페이지를 연다.
2. 로그인 상태를 확인한다. 로그인되지 않았으면 사용자가 열린 브라우저에서 직접 로그인한다.
3. 직무/키워드/경력/지역/제외 조건을 입력한다.
4. 결과 목록에서 후보 pool을 만든다.
5. 유료 열람이나 연락처 확인이 아닌 일반 상세/마스킹 이력서만 연다.
6. 현재 보이는 정보만 근거로 점수화한다.
7. URL과 검토 수준을 포함해 유료 열람 추천 후보를 정리한다.
## 결과 형식
```text
잡코리아 인재 shortlist
검색 조건
- 포지션: ...
- 필수 조건: ...
- 우대 조건: ...
- 제외 조건: ...
- 경력/지역: ...
- 모드: 로그인 마스킹 이력서 / 비로그인 목록 fallback
유료 열람 추천 Top N
1. 후보 A
- 점수: ...
- 근거: ...
- 리스크: ...
- 추천 액션: 채용 담당자가 유료 열람 검토
- URL: ...
```
## 제한사항
- 사이트 UI 변경 시 브라우저 추출 selector를 조정해야 할 수 있다.
- 계정 권한, 유료 상품 상태, 마스킹 정책에 따라 보이는 정보가 다르다.
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.

View file

@ -27,7 +27,7 @@ python3 scripts/k_skill_cleaner.py \
--skills-root . \
--scan-default-logs \
--days 90 \
--never-use blue-ribbon-nearby,lotto-results \
--never-use lotto-results \
--keep k-skill-setup,k-skill-cleaner
```

View file

@ -18,6 +18,10 @@ client/skill -> k-skill-proxy -> upstream public API
- `GET /v1/fine-dust/report`
- `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/seoul-density/citydata` (서울 실시간 도시데이터 핫스팟 혼잡도/추정 인구, `SEOUL_OPEN_API_KEY`)
- `GET /v1/seoul-bike/realtime` (서울 따릉이 실시간 대여정보 `bikeList`, `SEOUL_OPEN_API_KEY`)
- `GET /v1/seoul-bike/stations` (서울 따릉이 대여소 마스터 `tbCycleStationInfo`, `SEOUL_OPEN_API_KEY`)
- `GET /v1/seoul-bike/nearby` (좌표 주변 따릉이 실시간 대여소 필터링, `SEOUL_OPEN_API_KEY`)
- `GET /v1/han-river/water-level`
- `GET /v1/household-waste/info` (생활쓰레기 배출정보, `DATA_GO_KR_API_KEY`; 쿼리 `pageNo`·`numOfRows` 필수, 값 `1`·`100`)
- `GET /v1/mfds/drug-safety/lookup` (식약처 의약품개요정보 + 안전상비의약품 정보, `DATA_GO_KR_API_KEY`)
@ -35,13 +39,18 @@ 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)
## 권장 환경변수
클라이언트(스킬) 쪽:
- `KSKILL_PROXY_BASE_URL=https://your-proxy.example.com`
- 일반 hosted client는 `KSKILL_PROXY_BASE_URL`을 unset/empty로 비워 두면 hosted `https://k-skill-proxy.nomadamas.org`를 기본값으로 사용합니다.
- `KSKILL_PROXY_BASE_URL=https://your-proxy.example.com`은 self-host 또는 alternate proxy를 명시적으로 쓰는 경우에만 설정하는 override 예시입니다.
프록시 서버 쪽:
@ -60,38 +69,32 @@ client/skill -> k-skill-proxy -> upstream public API
## 프로덕션 배포 구조
프로덕션 proxy 서버는 개발 repo와 분리된 별도 clone으로 운영한다.
프로덕션 proxy 서버는 **Google Cloud Run**에서 운영한다.
- 배포 디렉토리: `~/.local/share/k-skill-proxy` (main 브랜치 단독 clone)
- PM2 프로세스: `k-skill-proxy`
- Cloudflare Tunnel ingress: `k-skill-proxy.nomadamas.org -> http://localhost:4020`
- GCP project: `k-skill-proxy`
- Region: `asia-northeast1` (도쿄)
- Cloud Run service: `k-skill-proxy`
- 공개 도메인: `k-skill-proxy.nomadamas.org` (Cloud Run domain mapping)
- 컨테이너 이미지 정의: `packages/k-skill-proxy/Dockerfile`
- 시크릿(upstream API key): GCP Secret Manager에 보관, Cloud Run runtime에 주입
### 자동 배포 (cron)
### 자동 배포 (GitHub Actions)
`~/.local/share/k-skill-proxy/scripts/auto-update-proxy.sh`가 매시 정각에 실행된다.
`main` 브랜치에 push/merge되면 `.github/workflows/deploy-k-skill-proxy.yml` 워크플로가 실행되어 다음 순서로 동작한다.
```
0 * * * * PATH=/usr/bin:/opt/homebrew/bin:/opt/homebrew/lib/node_modules/.bin:$PATH ~/.local/share/k-skill-proxy/scripts/auto-update-proxy.sh >> /tmp/k-skill-proxy-update.log 2>&1
```
동작 순서:
1. `git fetch origin main`
2. local SHA == remote SHA 이면 종료 (up-to-date)
3. `git pull --ff-only`
4. `package-lock.json` 변경 시 `npm ci`
5. `pm2 restart k-skill-proxy --update-env`
1. Workload Identity Federation으로 GCP 인증
2. `packages/k-skill-proxy/Dockerfile`로 이미지 빌드
3. Artifact Registry (`asia-northeast1-docker.pkg.dev/k-skill-proxy/k-skill/k-skill-proxy:<sha>`)에 push
4. Cloud Run service `k-skill-proxy` 재배포 (Secret Manager 시크릿 + 런타임 환경변수 주입)
5. 직접 Cloud Run URL과 `https://k-skill-proxy.nomadamas.org/health` smoke test
따라서 **main에 merge되어야 프로덕션에 반영**된다. dev 브랜치 변경은 프로덕션에 영향 없음.
로그: `/tmp/k-skill-proxy-update.log`
배포 상태와 로그는 GitHub Actions의 "Deploy k-skill-proxy to Cloud Run" 워크플로 실행 페이지와 GCP Console의 Cloud Run revision/log에서 확인한다.
### 초기 설정 (PM2 + cloudflared)
### 초기 셋업 (운영자 1회 수행)
1. `pm2 start ecosystem.config.cjs`
2. `pm2 save`
3. `pm2 startup` 출력대로 launchd 등록
4. Cloudflare Tunnel ingress 에 `k-skill-proxy.nomadamas.org -> http://localhost:4020` 추가
WIF pool/provider, deploy service account, Secret Manager 시크릿 생성 등 1회성 GCP 셋업 절차와 GitHub repository secrets/variables 등록 방법은 [`docs/deploy-k-skill-proxy.md`](../deploy-k-skill-proxy.md)에 정리되어 있다.
## 기본 공개 정책
@ -114,14 +117,30 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
서울 지하철 도착정보 endpoint:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/seoul-subway/arrival" \
--data-urlencode 'stationName=강남'
```
서울 실시간 혼잡도 endpoint:
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/seoul-density/citydata" \
--data-urlencode 'area=강남역'
# 서울 따릉이 주변 대여소
curl -fsS --get "${BASE}/v1/seoul-bike/nearby" \
--data-urlencode 'lat=37.5717' \
--data-urlencode 'lon=126.9763' \
--data-urlencode 'radius_m=500'
```
한국 날씨 endpoint:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/korea-weather/forecast' \
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/korea-weather/forecast" \
--data-urlencode 'lat=37.5665' \
--data-urlencode 'lon=126.9780'
```
@ -193,6 +212,33 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/mfds/food-safety/search'
--data-urlencode 'limit=5'
```
KOSIS 통계 조회 endpoint (`KOSIS_API_KEY` 필요, caller `apiKey`는 무시하고 서버 쪽 키를 주입):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kosis/search' \
--data-urlencode 'q=1인 가구' \
--data-urlencode 'limit=3'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kosis/meta' \
--data-urlencode 'tableId=DT_1JC1501' \
--data-urlencode 'metaType=ITM'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kosis/data' \
--data-urlencode 'tableId=DT_1JC1501' \
--data-urlencode 'prdSe=Y' \
--data-urlencode 'start=2020' \
--data-urlencode 'end=2023' \
--data-urlencode 'objL1=ALL'
```
Kakao Local geocoding endpoint (`KAKAO_REST_API_KEY` 필요, caller `apiKey`는 무시하고 서버 쪽 키를 주입):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kakao-local/geocode' \
--data-urlencode 'q=서울역' \
--data-urlencode 'limit=1'
```
도서관 정보나루 도서 검색 endpoint (`DATA4LIBRARY_AUTH_KEY` 필요):
@ -272,5 +318,4 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSv
- 한국 주식 route도 사용자에게 `KRX_API_KEY` 를 배포하지 않습니다.
- client 쪽에는 upstream API key를 배포하지 않습니다.
- 도서관 정보나루 route도 사용자에게 `DATA4LIBRARY_AUTH_KEY` 를 배포하지 않습니다.
- public hosted route rollout 이 끝나기 전에는 서울 지하철/한국 날씨 예시를 local/self-host URL 로 검증합니다.
- public hosted route rollout 이 끝나기 전에는 한강 수위 route도 local/self-host 또는 배포 확인이 끝난 proxy URL 로 검증합니다.
- self-host proxy 운영자는 동일 route를 local/self-host URL 로도 검증합니다.

104
docs/features/kakao-map.md Normal file
View file

@ -0,0 +1,104 @@
# 카카오맵 가이드
## 이 기능으로 할 수 있는 일
- **장소 검색**: 키워드(`스타벅스`)·카테고리(`FD6`=음식점)·좌표 중심으로 가게·시설 검색 (Kakao Local API)
- **좌표 ↔ 주소 변환**: 좌표 → 도로명/지번 주소, 좌표 → 행정구역(법정동/행정동)
- **자동차 길찾기**: 출발지·목적지 좌표 기준 거리·소요시간·통행료·예상 택시 요금 (Kakao Mobility Directions)
- 모두 `k-skill-proxy` 경유. 사용자 키 발급 불필요.
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
- 사용자는 별도 Kakao Developers 앱 생성/키 발급 필요 없음
- 운영자(proxy 서버)는 `KAKAO_REST_API_KEY` 보유
## 기본 경로
기본 hosted path: `https://k-skill-proxy.nomadamas.org/v1/kakao-map/*`, `https://k-skill-proxy.nomadamas.org/v1/kakao-mobility/*`
`KSKILL_PROXY_BASE_URL` 환경변수로 override 가능.
## Proxy routes
| endpoint | upstream | 주요 입력 |
|---|---|---|
| `GET /v1/kakao-map/search/keyword` | `https://dapi.kakao.com/v2/local/search/keyword.json` | `q`, `x`, `y`, `radius`, `category_group_code`, `sort`, `page`, `size` |
| `GET /v1/kakao-map/search/category` | `https://dapi.kakao.com/v2/local/search/category.json` | `category_group_code`, `x`, `y`, `radius`, `sort`, `page`, `size` |
| `GET /v1/kakao-map/coord2address` | `https://dapi.kakao.com/v2/local/geo/coord2address.json` | `x`, `y`, `input_coord` |
| `GET /v1/kakao-map/coord2region` | `https://dapi.kakao.com/v2/local/geo/coord2regioncode.json` | `x`, `y`, `input_coord` |
| `GET /v1/kakao-mobility/directions` | `https://apis-navi.kakaomobility.com/v1/directions` | `origin=x,y`, `destination=x,y`, `waypoints`, `priority`(RECOMMEND\|TIME\|DISTANCE), `car_fuel`, `car_hipass`, `alternatives`, `avoid`(ferries\|toll\|motorway\|schoolzone\|uturn; `\|` 구분) |
## 기본 흐름
1. 사용자가 장소 키워드/카테고리/좌표/길찾기 질문을 한다.
2. 적합한 endpoint를 골라 proxy 로 호출한다 (위 표 참고).
3. proxy는 `KAKAO_REST_API_KEY` 를 서버측에서만 `Authorization: KakaoAK ...` 헤더로 주입한다.
4. 응답에서 핵심 필드만 추려 사용자에게 정리해 전달한다.
5. 성공 응답은 proxy cache(기본 TTL 5분)로 보관해 다음 동일 쿼리를 빠르게 돌려준다.
## 예시
키워드 검색:
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/kakao-map/search/keyword" \
--data-urlencode 'q=스타벅스' \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979' \
--data-urlencode 'radius=500' \
--data-urlencode 'sort=distance'
```
좌표 → 주소:
```bash
curl -fsS --get "${BASE}/v1/kakao-map/coord2address" \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979'
```
자동차 길찾기:
```bash
curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
--data-urlencode 'origin=126.9706,37.5559' \
--data-urlencode 'destination=127.0276,37.4979' \
--data-urlencode 'priority=RECOMMEND' \
--data-urlencode 'avoid=toll'
```
응답 요약(예):
```text
자동차 경로: (126.9706,37.5559) → (127.0276,37.4979)
- 거리: 12.3km / 예상 소요시간: 25분
- 통행료: 1,200원 / 예상 택시요금: 18,500원
- 옵션: RECOMMEND, avoid=toll
```
## fallback / 대체 흐름
- 키 누락(`503 upstream_not_configured`) → 사용자에게 운영자 설정 필요 안내
- 인증 실패(401/403) → `503` 으로 변환 (key revoke / 쿼터 초과)
- 좌표 형식 오류 / 미존재 카테고리 코드 → `400 bad_request`
- 경로 미발견·출발지=도착지 근접 등 semantic 실패 → `502 upstream_semantic_error` + `result_msg`
- 네트워크 실패 → `502 upstream_error`
## 주의할 점
- Kakao Mobility는 **자동차 전용**이다. 대중교통 길찾기는 [한국 대중교통 길찾기 가이드](korean-transit-route.md) 를 쓴다.
- 카테고리 검색은 좌표 중심(`x`, `y`)이 필수다.
- waypoints 는 최대 5개 (Kakao Mobility 정책).
- 통행료 회피는 `avoid=toll`을 사용한다. `priority=DISTANCE`는 최단거리 우선순위일 뿐 통행료 회피와 동의어가 아니다.
- Kakao Mobility 무료 일일 쿼터는 1,000건 수준이다. proxy cache + rate-limit이 보호 역할을 하지만, 대량 호출은 자제한다.
- 본 스킬은 데이터 조회 전용이다. 예약·결제·자동 운전은 하지 않는다.
- secret/token/.env 원문은 응답에 노출되지 않는다 (proxy가 키를 서버측에서만 주입).
## 참고 표면
- Kakao Developers Console: `https://developers.kakao.com`
- Kakao Local API 문서: `https://developers.kakao.com/docs/latest/ko/local/dev-guide`
- Kakao Mobility 안내: `https://developers.kakao.com/docs/latest/ko/kakaonavi/common`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -1,84 +1,113 @@
# 카카오톡 Mac CLI 가이드
# 카카오톡 Mac 아카이브 검색 가이드
## 이 기능으로 할 수 있는 일
- macOS에서 카카오톡 최근 대화 목록 확인
- 특정 채팅방 최근 메시지 읽기
- 키워드로 전체 대화 검색
- 나와의 채팅으로 안전하게 테스트 전송
- 사용자 확인 후 특정 채팅방으로 메시지 전송
- Apple Silicon macOS에서 `katok`으로 카카오톡 로컬 대화 아카이브 생성
- keyword, BM25, semantic 검색
- 검색 결과의 chunk id로 원문, 주변 맥락, parent window 조회
- 검색 전 freshness 확인과 sync/index 필요 여부 판단
이 가이드는 기존 `kakaotalk-mac` 스킬 경로를 유지하지만 실행 표면은 `katok` CLI다. 메시지 전송, 삭제, UI 자동화, 직접 DB 읽기, 인증 캐시 처리, 복호화 material 처리는 포함하지 않는다.
## 먼저 필요한 것
- macOS
- Apple Silicon macOS
- KakaoTalk for Mac 설치
- Homebrew
- `brew install silver-flight-group/tap/kakaocli`
- `python3` 3.10+
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
- 터미널 앱에 **Full Disk Access****Accessibility** 권한 부여
- Homebrew 또는 Cargo
- `katok` CLI
- 현재 터미널 앱의 Full Disk Access 권한
카카오톡 앱이 없으면 `mas` 로 먼저 설치할 수 있다.
## 설치
Homebrew:
```bash
brew install mas
mas account
mas install 869223134
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
brew install katok
```
## 입력값
Cargo:
- 채팅방 이름
- 검색 키워드
- 최근 범위(`--since 1h`, `--since 7d` 등)
- 전송 메시지 본문
- 테스트 여부(`--me`, `--dry-run`)
```bash
cargo install katok
export PATH="$HOME/.cargo/bin:$PATH"
```
Cargo 설치 후 `katok`이 보이지 않으면 `$HOME/.cargo/bin`을 shell PATH에 추가한다.
## 개인 정보와 안전 규칙
- Do not inspect local database internals from this skill.
- Do not directly read KakaoTalk DB files.
- Do not handle auth caches or decryption material.
- live macOS 카카오톡 ingestion은 `katok sync --source macos --json`으로만 수행한다.
- 검색 결과는 snippet과 chunk id 중심으로 먼저 다룬다.
- 사용자가 특정 결과를 열어 달라고 하거나 chunk id를 제공했을 때만 chunk 원문을 조회한다.
## 기본 흐름
1. KakaoTalk for Mac 과 `kakaocli` 가 설치되어 있는지 확인한다.
2. `kakaocli status`, `kakaocli auth` 로 권한과 DB 접근이 되는지 먼저 확인한다.
3. `user_id` 자동 감지가 실패하면 helper `python3 scripts/kakaotalk_mac.py auth --refresh` 로 복구한다.
4. 읽기/검색은 JSON 모드로 실행한 뒤 사람이 읽기 쉽게 요약한다.
5. 전송은 먼저 `--me` 또는 `--dry-run` 으로 테스트한다.
6. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
1. `katok doctor --json`으로 freshness와 준비 상태를 확인한다.
2. Full Disk Access 설정이 필요하면 `katok permissions macos`로 시스템 설정 화면을 연다.
3. 앱 설치, container, DB 파일 접근 진단이 필요할 때만 `katok doctor --macos-probe --json`을 실행한다.
4. 최신성이 중요하거나 sync 권장이 있으면 `katok sync --source macos --json`을 실행한다.
5. semantic search 전에 index 권장이 있으면 `katok index --json`을 실행한다.
6. 질의 성격에 따라 `katok search keyword`, `katok search bm25`, `katok search semantic`을 선택한다.
7. 사용자가 지정한 결과만 `katok chunk get`, `katok chunk context`, `katok chunk parent`로 연다.
## 예시
```bash
kakaocli status
kakaocli auth
python3 scripts/kakaotalk_mac.py auth --refresh
python3 scripts/kakaotalk_mac.py chats --limit 10 --json
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1d --json
python3 scripts/kakaotalk_mac.py search "회의" --json
kakaocli chats --limit 10 --json
kakaocli messages --chat "지수" --since 1d --json
kakaocli search "회의" --json
kakaocli send --me _ "테스트 메시지"
kakaocli send --dry-run "팀 공지방" "오늘 3시에 만나요"
katok doctor --json
katok permissions macos
katok doctor --macos-probe --json
katok sync --source macos --json
katok index --json
katok search keyword "계약서" --json
katok search bm25 "지난주 미팅 자료" --json
katok search semantic "최근에 논의한 세금 신고 일정" --json
katok chunk get <chunk-id> --json
katok chunk context <chunk-id> --json
katok chunk parent <chunk-id> --json
```
## helper 가 해결하는 문제
## 검색 방식 선택
`kakaocli auth` 실패가 항상 “DB 파일이 없음”을 의미하지는 않는다. 실제 Mac 환경에서는:
`katok search keyword`는 정확한 문자열, 이름, 계좌번호, 고유명사처럼 그대로 기억나는 값을 찾을 때 쓴다.
- container 안에 `KakaoTalk.db` 라는 이름 대신 **78자 hex 파일**이 DB 로 존재할 수 있다.
- `kakaocli status` 는 정상이어도 `auth``user_id 자동 감지 실패` 로 끝날 수 있다.
- 이 경우 plist 의 `AlertKakaoIDsList` 후보만으로는 부족하고, `DESIGNATEDFRIENDSREVISION:<SHA-512(user_id)>` 에서 실제 `user_id` 를 더 오래 찾아야 할 수 있다.
`katok search bm25`는 여러 단어가 섞인 일반 질의에 쓴다.
helper `scripts/kakaotalk_mac.py` 는 그 얇은 read-only 어댑터 역할을 한다.
`katok search semantic`은 표현이 정확히 기억나지 않지만 의미가 비슷한 대화를 찾을 때 쓴다. `katok doctor --json`에서 semantic index 갱신이 필요하다고 나오면 먼저 `katok index --json`을 실행한다.
- plist 에서 후보 `user_id` 와 active hash 를 읽는다.
- hash recovery 가 필요하면 더 긴 검색으로 실제 `user_id` 를 찾는다.
- 검증된 DB 경로와 SQLCipher key 를 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
- 이후 read-only helper 명령 `chats`, `messages`, `search`, `schema` 를 cached `--db` / `--key` 와 함께 다시 실행한다.
## chunk 조회
검색 결과에서 더 넓은 맥락이 필요할 때만 chunk 명령을 사용한다.
```bash
katok chunk get <chunk-id> --json
katok chunk context <chunk-id> --json
katok chunk parent <chunk-id> --json
```
- `chunk get`: 해당 chunk 원문 조회
- `chunk context`: 같은 채팅방의 바로 앞뒤 micro chunk 조회
- `chunk parent`: semantic search가 사용한 더 큰 parent window 조회
## Synthetic QA
실제 카카오톡 설치 없이 upstream fixture로 테스트할 때만 아래 경로를 쓴다.
```bash
katok sync --source fixture tests/fixtures/kakao/replies.jsonl --json
KATOK_EMBEDDER=local-test katok index --json
KATOK_EMBEDDER=mock katok index --json
```
실사용 경로에서는 fixture, mock embedder, 원격 embedding endpoint를 사용하지 않는다.
## 주의할 점
- **Full Disk Access** 가 없으면 읽기 명령도 실패할 수 있다.
- **Accessibility** 가 없으면 전송과 harvest 계열 자동화가 실패한다.
- macOS 전용이므로 Windows/Linux 대체 구현으로 넘어가지 않는다.
- 다른 사람에게 보내는 메시지는 자동 전송하지 말고 확인을 먼저 받는다.
- helper cache 는 로컬 auth material 을 담으므로 본인 장비에서만 보관한다.
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
- Apple Silicon macOS 전용이다.
- Intel macOS는 packaged local EmbeddingGemma 경로의 지원 대상이 아니다.
- Full Disk Access는 사용자가 System Settings에서 직접 허용해야 한다.
- `katok doctor --macos-probe --json`은 macOS app-data 접근 prompt를 띄울 수 있으므로 setup 진단이 필요할 때만 실행한다.
- 이 스킬은 read/search/retrieve 전용이며 메시지 전송과 삭제를 지원하지 않는다.

View file

@ -10,13 +10,13 @@
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
- self-host 또는 배포 확인이 끝난 proxy base URL: `KSKILL_PROXY_BASE_URL`
- optional: `KSKILL_PROXY_BASE_URL` (self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용)
## 필요한 환경변수
- `KSKILL_PROXY_BASE_URL` (필수: self-host 또는 배포 확인이 끝난 proxy base URL)
- 없음. `KSKILL_PROXY_BASE_URL` 은 선택 사항이며, 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
사용자가 공공데이터포털 기상청 단기예보 API key를 직접 발급할 필요는 없다. 대신 `KSKILL_PROXY_BASE_URL``/v1/korea-weather/forecast` route가 실제로 배포된 proxy 를 가리켜야 한다. upstream `KMA_OPEN_API_KEY` 는 proxy 서버에서만 관리한다.
사용자가 공공데이터포털 기상청 단기예보 API key를 직접 발급할 필요는 없다. `/v1/korea-weather/forecast` route는 기본 hosted proxy에서 호출하고, upstream `KMA_OPEN_API_KEY` 는 proxy 서버에서만 관리한다. 별도 proxy를 쓰는 경우에만 `KSKILL_PROXY_BASE_URL` 을 설정한다.
## 입력값
@ -26,7 +26,7 @@
## 기본 흐름
1. `KSKILL_PROXY_BASE_URL` 로 self-host 또는 배포 확인이 끝난 proxy base URL 을 확인한다.
1. `KSKILL_PROXY_BASE_URL` 이 있으면 그 값을 사용하고, 없거나 비어 있으면 기본 hosted proxy `https://k-skill-proxy.nomadamas.org` 를 사용한다.
2. `/v1/korea-weather/forecast` 로 한국 기상청 단기예보를 조회한다.
3. `baseDate` / `baseTime` 을 생략하면 proxy 가 KST 기준 최신 발표 시각을 자동으로 선택한다.
4. 응답의 `item[]` 에서 `TMP`, `SKY`, `PTY`, `POP`, `PCP`, `SNO`, `REH`, `WSD` 를 우선 요약한다.
@ -36,7 +36,8 @@
위도/경도 기준:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/korea-weather/forecast" \
--data-urlencode 'lat=37.5665' \
--data-urlencode 'lon=126.9780'
```
@ -44,7 +45,8 @@ curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
격자 좌표 기준:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/korea-weather/forecast" \
--data-urlencode 'nx=60' \
--data-urlencode 'ny=127' \
--data-urlencode 'baseDate=20260405' \
@ -66,5 +68,4 @@ curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
- 단기예보는 5km 격자 기반이라 행정구역 경계와 완전히 일치하지 않을 수 있다.
- 발표 시각 직후에는 최신 `baseTime` 이 아직 준비되지 않았을 수 있다. proxy 는 보수적으로 직전 발표 시각을 선택한다.
- public hosted route rollout 이 끝나기 전까지는 `KSKILL_PROXY_BASE_URL` 을 반드시 명시한다.
- self-host proxy 설정은 [k-skill 프록시 서버 가이드](k-skill-proxy.md)를 본다.

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 @@
# 한국어 AI 윤문 (korean-humanizer) 가이드
## 이 기능으로 할 수 있는 일
- ChatGPT·Claude·Gemini 등이 쓴 "AI 티 나는" 한국어 글을 자연스러운 사람 글로 윤문
- 번역체, AI 상투어, 과도한 명사화·피동, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재, 줄표·이모지·곡선따옴표 같은 흔적을 **심각도(S1/S2/S3)** 로 분류해 탐지
- "이 글에서 AI 흔적 찾아줘"처럼 고치지 않고 진단만 (탐지 리포트 + 심각도)
- 목표 글자수 지정 시(`length=1000`, "1000자로") ±5% 안으로 분량 조정, 공백 포함/제외 글자수 보고
- 사용자 글 샘플을 주면 그 말투(voice)로 재작성
## 왜 별도 스킬이 필요한가
- 영어권 humanizer(QuillBot·Undetectable AI 등)는 한국어에 약하다. 한국어 AI 글의 티는 대부분 **영어 번역투**와 격식을 가장한 **상투어**에서 나온다.
- 단순 맞춤법 교정(`korean-spell-check`)이나 유행어 입히기(`korean-slang-writing`)와 달리, 이 스킬은 의미를 보존하면서 **문체·리듬·표현**만 사람답게 되돌린다.
- 과교정을 막기 위해 4대 철칙(의미 불변 · 근거 기반 · 장르 유지 · 과윤문 금지)과 변경률 가드(30% 경고, 50% 중단)를 둔다.
## 먼저 필요한 것
- 추가 설치·API 키 없음. 이 스킬은 프롬프트/지식 기반이며 외부 호출이나 스크립트가 없다.
- (선택) 정확한 글자수 카운팅이 필요하면 `korean-character-count` 스킬과 연동된다.
## 기본 흐름 (탐지 → 윤문 → 감사 → 등급)
1. **트리아지** — 흔적이 무더기인지, 서식만 문제인지, 산문까지 다시 써야 하는지 먼저 정한다.
2. **탐지** — A~J 분류 카탈로그로 흔적을 span·심각도로 표시한다. S1부터 본다.
3. **윤문** — 흔적을 자연스러운 표현으로 교체한다. 의미·사실·고유명사·수치는 100% 보존한다.
4. **감사** — "왜 아직 AI 같은가?"를 다시 묻고, 자가검증 6항과 변경률을 점검한다. 위반이면 롤백 후 재윤문.
5. **등급** — A~D로 자가 채점한다. C·D면 추가 윤문이나 사람 검토를 권한다.
전체 패턴 표(A~J, 60+ 서브 패턴)는 스킬 디렉터리의 [`references/ai-tell-taxonomy.md`](../../korean-humanizer/references/ai-tell-taxonomy.md)에 있다.
## 사용 예시
```text
이 글 AI 티 안 나게 자연스럽게 다듬어줘:
[ChatGPT/Claude 초안 붙여넣기]
```
```text
이 글에서 AI 흔적만 찾아줘 (고치지 말고 심각도까지)
```
```text
1000자로 맞춰서 번역체 고쳐줘
```
## 제한사항
- 문체만 고친다. 사실관계 확인·출처 보강은 하지 않는다(필요하면 별도 리서치).
- 원문에 없는 내용을 창작해 채우지 않는다(의미 보존이 원칙).
- 변경률이 50%를 넘으면 작업을 중단하고 사람 검토를 권한다.
## 감사의 말 (Acknowledgments)
이 스킬은 두 기여 위에 만들어졌다.
- **[happy-nut](https://github.com/happy-nut) (Hyungsun Song)** 님이 PR [#311](https://github.com/NomaDamas/k-skill/pull/311)로 최초 `korean-humanizer` 스킬과 33개 한국어 패턴 카탈로그·예문, triage/length-control 설계를 기여했다. 이 가이드와 v2 스킬의 토대다.
- **[epoko77-ai/im-not-ai](https://github.com/epoko77-ai/im-not-ai)** (Humanize KR, MIT)의 방법론을 중심으로 v2를 재구성했다. A~J 분류 체계, S1/S2/S3 심각도, 4대 철칙, 변경률 30%/50% 가드, 품질 등급(A~D), 그리고 A-16(그/그녀 강박)·A-18(관계절 좌향 수식)·A-19(이중 조사)·C-11(연결어미 뒤 쉼표)·E-7(경어법 일관성) 같은 한국어 고유 패턴이 여기서 왔다.
원형은 영어권 [blader/humanizer](https://github.com/blader/humanizer)와 [Wikipedia: Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing)이다. 두 프로젝트와 happy-nut 님의 기여에 감사한다.

View file

@ -2,126 +2,101 @@
## 이 기능으로 할 수 있는 일
- `korean-law-mcp` 로 법령명 검색
- 특정 법령의 조문 본문 조회
- 판례 / 유권해석 / 자치법규 검색
- MCP 또는 CLI 경로 중 현재 환경에 맞는 방식 선택
- 기존 경로 장애 시 `법망` fallback으로 이어가기
- `k-skill-proxy` 로 법령명/조문/판례/유권해석/자치법규 검색
- 검색 결과 식별자로 조문·판례 본문(상세) 조회
- 별도 API key나 로컬 설치 없이 hosted proxy로 바로 사용
## 가장 중요한 규칙
한국 법령 관련 검색/조회가 필요할 때는 **`korean-law-mcp`를 먼저 사용**합니다.
기존 서비스가 동작하지 않을 때만 승인된 fallback 표면인 **`법망`(`https://api.beopmang.org`)** 으로 전환합니다.
별도 repo package, 별도 python package, 임의 크롤러를 새로 만들지 않습니다.
한국 법령 관련 검색/조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` endpoint로 처리합니다. 사용자 쪽 `LAW_OC` 가 불필요합니다. 별도 repo package, 별도 python package, 임의 크롤러를 새로 만들지 않습니다.
이 endpoint는 법제처(국가법령정보센터) 공식 Open API(`open.law.go.kr` 의 DRF `lawSearch.do`/`lawService.do`)를 감싼 것이고, read-only 도구 표면 설계는 `chrisryugj/korean-law-mcp` 를 참고했습니다.
## 먼저 필요한 것
- 인터넷 연결
- `node` 18+
- `npm install -g korean-law-mcp` (로컬 CLI/로컬 MCP server 경로일 때)
- remote MCP endpoint를 쓸 MCP 클라이언트
- `법망` fallback (`https://api.beopmang.org`) 에 접근할 수 있는 네트워크
- (선택) `KSKILL_PROXY_BASE_URL` — self-host proxy를 쓸 때만
무료 API key 발급처: `https://open.law.go.kr`
사용자는 별도 API key를 준비할 필요가 없습니다. upstream `LAW_OC` 는 proxy 서버에서만 주입합니다. 무료 발급처(운영자용): `https://open.law.go.kr`
로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC` 가 필요하다.
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
## 기본 경로
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용합니다.
## 지원 endpoint
### 검색/목록 조회
```
GET /v1/korean-law/search?target={target}&query={검색어}
```
| target | 설명 |
|---|---|
| `law` | 현행법령 |
| `eflaw` | 시행일 법령 |
| `prec` | 판례 |
| `detc` | 헌재결정례 |
| `expc` | 법령해석례(유권해석) |
| `admrul` | 행정규칙 |
| `ordin` | 자치법규 |
| `trty` | 조약 |
지원 필터: `query`(검색어), `display`, `page`, `sort`, `date`, `prncYd`(선고일자), `nb`(사건번호), `datSrcNm`(데이터출처명), `curt`(법원) 등. 활성 필터만 넘기고, 요약 전에 반환 메타데이터를 확인합니다.
### 본문/상세 조회
```
GET /v1/korean-law/detail?target={target}&ID={일련번호}
```
검색 결과의 식별자(`ID` 또는 `MST`/`LID`)를 넘겨 상세 본문을 가져옵니다. 조문 지정은 `JO`(예: `000200` = 제2조)로 넘깁니다.
## 예시
```bash
npm install -g korean-law-mcp
export LAW_OC=your-api-key
# 법령명 검색
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
--data-urlencode 'target=law' \
--data-urlencode 'query=관세법'
korean-law list
korean-law help search_law
```
# 판례 검색
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
--data-urlencode 'target=prec' \
--data-urlencode 'query=부당해고'
로컬 설치가 막히면 먼저 `https://korean-law-mcp.fly.dev/mcp` remote endpoint를 사용한다. 그 경로도 응답하지 않거나 서비스 장애가 나면 `법망`(`https://api.beopmang.org`) MCP/REST를 fallback으로 사용한다.
## MCP 연결 예시
```json
{
"mcpServers": {
"korean-law": {
"command": "korean-law-mcp",
"env": {
"LAW_OC": "your-api-key"
}
}
}
}
```
remote endpoint 예시:
```json
{
"mcpServers": {
"korean-law": {
"url": "https://korean-law-mcp.fly.dev/mcp"
}
}
}
```
위 remote 예시는 upstream 문서 기준으로 사용자 `LAW_OC` 를 따로 넣지 않는다. 사용자 쪽에서 준비할 것은 `url` 등록뿐이다.
## fallback: 법망
기존 `korean-law-mcp` 경로가 동작하지 않을 때만 `법망`을 사용한다.
### MCP fallback
```json
{
"mcpServers": {
"beopmang": {
"url": "https://api.beopmang.org/mcp"
}
}
}
```
### REST fallback 예시
```bash
curl "https://api.beopmang.org/api/v4/law?action=search&q=관세법"
curl "https://api.beopmang.org/api/v4/tools?action=overview&law_id=001706"
curl "https://api.beopmang.org/api/v4/law?action=get&law_id=001706&article=제750조"
# 판례 본문 조회
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/detail' \
--data-urlencode 'target=prec' \
--data-urlencode 'ID=228541'
```
## 기본 흐름
1. 질의가 법령/판례/행정해석/자치법규 중 어디에 가까운지 분류한다.
2. 법령명만 찾으면 `search_law` 를 먼저 쓴다.
3. 특정 조문이 필요하면 `search_law` 또는 `search_all` 로 식별자(`mst`)를 확인한 뒤 `get_law_text` 를 호출한다.
4. 판례는 `search_precedents`, 유권해석은 `search_interpretations`, 자치법규는 `search_ordinance` 를 우선 사용한다.
5. 범주가 애매하면 `search_all` 로 시작한다.
6. `korean-law-mcp` 경로가 설치/네트워크/서비스 장애로 막히면 `법망` fallback으로 전환한다.
7. fallback 검색 결과가 0건이어도 바로 "관련 규범이 없다"고 단정하지 말고 검색어와 범주를 다시 확인한다.
2. 법령명만 찾으면 `target=law``search` 한다.
3. 특정 조문이 필요하면 `search` 로 식별자(`MST`/`ID`)를 확인한 뒤 `detail` 을 호출한다.
4. 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 조회한다.
5. 범주가 애매하면 `target=law` 부터 시작한다.
6. 검색 결과가 0건이어도 바로 "관련 규범이 없다"고 단정하지 말고 검색어와 범주를 다시 확인한다.
## CLI 예시
## 실패 모드
```bash
korean-law search_law --query "관세법"
korean-law get_law_text --mst 160001 --jo "제38조"
korean-law search_precedents --query "부당해고"
```
- `target` 이 없거나 허용되지 않은 값이면 400 응답
- 검색어/식별자가 없으면 400 응답
- 프록시 서버에 `LAW_OC` 가 없으면 503 응답
- 법제처 API가 사용자 검증 실패를 반환하면 502 + `law_user_verification_failed` (운영자가 서버 OC/UA/Referer 점검)
- 법제처 API가 일시적으로 빈/HTML 응답이면 proxy가 재시도 후 502 + `upstream_unstable`
- 일부 출처는 본문을 제공하지 않을 수 있다. 본문을 못 가져오면 목록 메타데이터(사건번호·법원·선고일자·출처·요지)까지만 제공하고 본문이 없다는 점을 명시한다.
## 운영 팁
- `화관법` 같은 약칭은 `search_law` / `search_all` 로 정식 법령명을 먼저 확인한다.
- 조문 번호가 헷갈리면 `get_law_text` 전에 법령 식별자부터 다시 확인한다.
- 로컬 CLI/MCP 경로를 쓰는데 `LAW_OC` 가 없으면 credential resolution order에 따라 확보를 안내한다.
- remote MCP endpoint를 쓰면 사용자 `LAW_OC` 없이 `url` 등록 상태만 확인한다.
- 기존 `korean-law-mcp` 경로가 실패하면 `https://api.beopmang.org/mcp` 또는 `/api/v4/law?action=search` 경로를 fallback으로 쓴다.
- `화관법` 같은 약칭은 `target=law` 로 정식 법령명을 먼저 확인한다.
- 조문 번호가 헷갈리면 `detail` 전에 법령 식별자부터 다시 확인한다.
- 요약은 할 수 있지만 법률 자문처럼 단정적으로 결론을 내리지는 않는다.
## 라이브 확인 메모
## 출처
2026-04-01 기준 smoke test 에서 아래 명령은 실제로 정상 동작했다.
- `korean-law list`
- `korean-law help search_law`
즉, `korean-law-mcp` CLI 설치와 기본 명령 진입은 검증했다. 실제 법령 검색은 로컬 CLI/MCP 경로라면 `LAW_OC` 가 준비된 환경에서 바로 이어서 사용할 수 있고, remote MCP endpoint는 사용자 `LAW_OC` 없이 URL 등록만으로 붙일 수 있다. 기존 경로 장애 시에는 `법망` fallback을 사용할 수 있다.
- 설계 참고(upstream): `https://github.com/chrisryugj/korean-law-mcp`
- 공식 데이터 출처: 법제처 국가법령정보 공동활용 (`https://open.law.go.kr`, DRF `lawSearch.do`/`lawService.do`)
- 운영자(proxy) 전용 시크릿: `LAW_OC` (사용자는 불필요)

View file

@ -0,0 +1,66 @@
# 한국 마라톤 일정 조회 가이드
`korean-marathon-schedule` 스킬은 공개 웹 표면을 읽어 한국 마라톤/러닝 대회 일정을 조회하고, 요청 시 철인3종 대회도 함께 확인합니다.
## 제공 정보
각 결과는 가능한 범위에서 아래 정보를 반환합니다.
- 대회명
- 개최일
- 지역과 장소
- 신청 마감일 및 접수 기간
- 종목/코스
- 주최자
- 공식 웹사이트 또는 공개 상세 링크
## 공개 접근 경로
| 구분 | 공개 표면 | 사용 정보 | 인증 |
| --- | --- | --- | --- |
| 마라톤/러닝 | `https://gorunning.kr/races/``/races/<id>/<slug>/` 상세 페이지 | 일정, 장소, 접수 기간, 종목, 주최자, 웹사이트 | 불필요 |
| 철인3종 | `https://triathlon.or.kr/events/tour/?sYear=<YYYY>&vType=list` 및 상세 페이지 | 일정, 장소, 접수 기간, 코스, 주최자 | 불필요 |
두 표면 모두 API 키가 필요 없는 공개 읽기 경로이므로 `k-skill-proxy`를 사용하지 않습니다.
## 사용 예시
```js
const { searchEvents } = require("korean-marathon-schedule")
const result = await searchEvents({
query: "서울",
from: "2026-05-01",
to: "2026-12-31",
includeTriathlon: true,
limit: 10
})
console.log(result.items)
```
CLI:
```bash
node packages/korean-marathon-schedule/src/cli.js 서울 --from 2026-05-01 --to 2026-12-31 --include-triathlon --limit 10
```
## 응답 작성 원칙
```text
- 대회명: 소아암환우돕기 제23회 서울시민마라톤
일정: 2026-05-10
장소: 서울 여의도 한강 물빛광장
신청 마감: 2026-02-28 (접수기간 2026-01-12 ~ 2026-02-28)
종목: Half, 10km, 5km, 3km 걷기
링크: https://gorunning.kr/races/...
```
신청 마감일이 공개 페이지에서 확인되지 않으면 추정하지 말고 `신청 마감일 미확인`으로 표시합니다.
## 실패/주의 사항
- 일정과 접수 상태는 수시로 바뀌므로 조회 시각 기준 참고값으로 안내합니다.
- 공개 HTML 구조가 바뀌면 일부 필드가 비거나 파싱이 실패할 수 있습니다.
- 접수/결제/로그인/CAPTCHA가 필요한 경로는 자동화하지 않습니다.
- 행사별 공식 사이트가 없으면 GoRunning 또는 대한철인3종협회 상세 링크를 대신 제공합니다.

View file

@ -0,0 +1,86 @@
# 한국 중세 국어풍 변환 가이드
## 이 기능으로 할 수 있는 일
- 한국어 입력문을 창작용 **중세국어풍 문체**로 변환
- `은/는`, `을/를`, `에서` 같은 일부 조사를 `ᄋᆞᆫ`, `ᄋᆞᆯ`, `애`처럼 변환
- `했다`, `하는`, `말하는` 같은 일부 어미를 `ᄒᆞ엿다〮`, `ᄒᆞᄂᆞᆫ`, `ᄆᆞᆯᄒᆞᄂᆞᆫ`처럼 변환
- 날짜 단위를 `年`, `月`, `日`로 변환
- 일부 한자어를 `熱愛說`, `俳優`, `學校`처럼 Hanja 힌트로 변환
- URL, 이메일, Markdown 링크, inline/fenced code span은 구조 토큰으로 보고 변환하지 않음
- 인명·숫자·고유명사는 완전 보존이 아니라, 규칙이 맞지 않을 때 원문을 남기는 best-effort 방식으로 처리
## 왜 별도 스킬이 필요한가
LLM에게 "중세 국어처럼"이라고만 요청하면 변환 강도와 표기가 매번 달라진다. 이 스킬은 밈/창작용 변환에서 필요한 최소 계약을 고정한다.
- 동일 입력은 동일 출력으로 변환한다.
- 어떤 규칙이 적용됐는지 `replacements` 배열로 확인할 수 있다.
- 학술적 복원이 아니라 스타일 변환임을 문서화한다.
## 기본 계약
프로필은 `middle-korean-style-v1`이다.
- 날짜 단위 정규화를 먼저 적용한다. `2015년 7월 21일``2015年 7月 21日`처럼 바뀐다.
- 그다음 결정론적 lexicon 치환을 적용한다.
- 일부 현대 조사를 중세국어풍 조사로 바꾼다.
- 일부 현대 어미를 `ᄒᆞ-` 계열 중세국어풍 어미로 바꾼다.
- URL, 이메일, Markdown 링크, inline/fenced code span은 먼저 보호한 뒤 마지막에 원문 그대로 복원한다.
- 한자어 힌트는 넓은 전역 치환으로 적용되므로 합성어·고유명사처럼 보이는 문자열 안에서도 바뀔 수 있다.
- 변환하지 못한 내용은 원문 의미 보존을 위해 그대로 둔다.
`middle-korean-style-v1`의 출력 변경은 호환성에 영향을 주는 계약 변경으로 본다. 새 규칙을 추가하거나 순서를 바꿀 때는 회귀 테스트와 문서 예시를 함께 갱신한다.
## CLI 사용 예시
### 기본 JSON 출력
```bash
node scripts/korean_middle_korean.js --text "민수는 3월 5일 학교에서 공부했다."
```
예상 출력 일부:
```json
{
"profile": "middle-korean-style-v1",
"input": "민수는 3월 5일 학교에서 공부했다.",
"output": "민수ᄋᆞᆫ 3月 5日 學校애 공부ᄒᆞ엿다〮.",
"replacements": [
{ "kind": "date", "from": "월→月", "to": "$1月", "count": 1 }
]
}
```
### 변환문만 출력
```bash
node scripts/korean_middle_korean.js --text "열애설을 인정했다." --format text
```
예상 출력:
```text
熱愛說ᄋᆞᆯ 인졍ᄒᆞ엿다〮.
```
### 파일/stdin 입력
```bash
node scripts/korean_middle_korean.js --file ./input.txt --format text
cat input.txt | node scripts/korean_middle_korean.js --stdin --format json
```
## 응답 원칙
- 결과는 `output` 필드를 중심으로 전달한다.
- "정확한 중세국어 번역"이 아니라 "중세국어풍/창작용 변환"이라고 설명한다.
- 사용자가 학술적 정확성을 요구하면 이 스킬의 한계를 먼저 알리고, 전문 고문헌 검토가 필요하다고 안내한다.
## 검증
```bash
node --test scripts/test_korean_middle_korean.js
node scripts/korean_middle_korean.js --text "민수는 3월 5일 학교에서 공부했다." --format text
```

View file

@ -0,0 +1,72 @@
# 한국 대중교통 길찾기 가이드
## 이 기능으로 할 수 있는 일
- 출발지→도착지 도어투도어 대중교통 경로 조회 (지하철 + 버스 + 도보)
- ODsay LIVE API 기반 환승 정보, 소요시간, 요금 확인
- Kakao Local geocoding으로 주소·장소명→좌표 변환
- 추천순 / 최소시간 / 최소환승 옵션 선택
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
- ODsay Server API Key 발급 및 호출 IP 화이트리스트 등록: https://lab.odsay.com
- Kakao Local geocoding은 기본 hosted `k-skill-proxy` 경유. 사용자 쪽 Kakao 키는 불필요하며, self-host proxy 운영자만 Kakao REST API Key를 발급해 서버에 설정한다: https://developers.kakao.com
## 필요한 환경변수
- `ODSAY_API_KEY` — ODsay LIVE API Server 키
`ODSAY_API_KEY``~/.config/k-skill/secrets.env` 에 저장하거나 환경변수로 주입한다. 별도 self-host proxy를 쓰는 경우에만 `KSKILL_PROXY_BASE_URL` 을 설정한다.
## 입력값
- 출발지 (주소, 장소명, 또는 좌표)
- 도착지 (주소, 장소명, 또는 좌표)
- 선택 사항: 경로 옵션 (`OPT=0` 추천순, `4` 최소시간, `5` 최소환승), `SearchPathType` (`0` 지하철+버스, `1` 지하철만, `2` 버스만)
## 기본 흐름
1. 출발지/도착지를 `k-skill-proxy``/v1/kakao-local/geocode`로 geocoding하여 좌표를 확보한다. Proxy 내부에서 Kakao Local `address.json``keyword.json` 순서로 시도한다.
2. ODsay `searchPubTransPathT`에 출발/도착 좌표와 옵션을 전달한다.
3. 응답의 `result.path[]`를 3개 이내로 정리한다.
4. 각 경로의 `subPath[]``trafficType`별로 표시하며, 첫/끝 도보 구간을 반드시 포함한다.
## 예시
### 좌표 직접 입력
```bash
set -a; . ~/.config/k-skill/secrets.env; set +a
KEY=$(python3 -c "import os,urllib.parse;print(urllib.parse.quote(os.environ['ODSAY_API_KEY'],safe=''))")
curl -s "https://api.odsay.com/v1/api/searchPubTransPathT?apiKey=${KEY}&SX=126.9706&SY=37.5559&EX=127.0276&EY=37.4979&OPT=0&SearchPathType=0"
```
### 주소→좌표→경로 (Python)
```python
import os, urllib.parse, urllib.request, json
PROXY = os.environ.get('KSKILL_PROXY_BASE_URL', 'https://k-skill-proxy.nomadamas.org').rstrip('/')
def geocode(q):
url = PROXY + '/v1/kakao-local/geocode?q=' + urllib.parse.quote(q)
with urllib.request.urlopen(url, timeout=10) as resp:
d = json.loads(resp.read())
if d.get('documents'):
doc = d['documents'][0]
return float(doc['x']), float(doc['y']), doc.get('place_name') or doc.get('address_name')
return None
sx, sy, s_name = geocode('서울역')
ex, ey, e_name = geocode('강남역')
# 이후 ODsay searchPubTransPathT 호출
```
## 주의할 점
- ODsay Server 키는 **호출 IP 화이트리스트 등록이 필수**이다. 등록되지 않은 IP에서는 `error` 응답이 반환된다.
- 현재 ODsay 공식 Basic 상품 기준 무료 체험은 일 1,000건(6개월)이다. `searchPubTransPathT``searchStation` 호출이 합산된다.
- 한국 외 좌표는 지원하지 않는다.
- 카카오맵/네이버지도 directions API는 대중교통 라우팅을 공개하지 않으므로 사용하지 말 것.

View file

@ -0,0 +1,178 @@
# 국가데이터처 KOSIS 통계 조회 가이드
대상 사이트는 **국가데이터처**(구 통계청)가 운영하는 **KOSIS(국가통계포털)** 공식 Open API `https://kosis.kr/openapi/` 이다. 이 기능은 한국 공식 통계 자료의 **조회 자동화**만 수행한다.
## 이 기능으로 할 수 있는 일
- 키워드로 KOSIS 통계표 검색 (`statisticsSearch.do`)
- 통계표 메타데이터(분류·항목·단위) 조회 (`statisticsData.do?method=getMeta`)
- 통계표 데이터 셀 조회 (`statisticsParameterData.do`)
- 사용자별로 KOSIS에 등록한 대용량 자료 조회 (`statisticsBigData.do`)
- JSON 또는 사람이 읽기 좋은 텍스트 출력
이 기능은 **조회 전용 자동화**이다. 통계 작성, 데이터 변경, 대시보드 등록, 사용자별 통계자료(`userStatsId`) 신규 등록은 하지 않는다.
## 먼저 필요한 것
- Python 3.9+ (stdlib only, 외부 패키지 없음)
- 일반 `search`/`meta`/`data`: 기본 hosted `k-skill-proxy` 접근
- `bigdata` 또는 `--direct`: KOSIS Open API 인증키 (무료, https://kosis.kr/openapi/ 에서 회원가입 후 활용신청)
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
```bash
python3 kosis-stats/scripts/run_kosis_stats.py --help
```
## 필요한 환경변수
- 일반 `search`/`meta`/`data`: 없음
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 사용
- `KSKILL_KOSIS_API_KEY``bigdata` 또는 `--direct` 전용
### Credential resolution order (`bigdata` 또는 `--direct` 전용)
1. **이미 환경변수에 있으면** 그대로 사용한다.
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
3. **`~/.config/k-skill/secrets.env`** (기본 fallback) — plain dotenv 파일, 퍼미션 `0600`.
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
일반 조회 helper는 proxy URL만 읽고, KOSIS 인증키는 proxy 서버에서만 주입한다. `bigdata`/`--direct` 호출만 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일을 읽는다.
## 처음 실행 순서
처음 쓰는 사용자는 proxy 기반 검색 → 메타 → 작은 슬라이스 순으로 점검한다. 사용자 KOSIS 키는 일반 조회에 필요 없다.
```bash
python3 kosis-stats/scripts/run_kosis_stats.py search --query "1인 가구" --text
python3 kosis-stats/scripts/run_kosis_stats.py meta --table-id DT_1JC1501 --text
python3 kosis-stats/scripts/run_kosis_stats.py data \
--table-id DT_1ES4I001S --prd-se Y --start 2020 --end 2023 --text
```
발급 절차와 호출 한도, 에러 코드는 [`kosis-stats/references/kosis-openapi-guide.md`](../../kosis-stats/references/kosis-openapi-guide.md) 에 정리되어 있다.
## 입력값
서브커맨드: `search`, `meta`, `data`, `bigdata`. 공통 출력 옵션은 **서브커맨드 뒤에** 둔다.
- `search`
- `--query "키워드"`
- `--result-count N` (1-5000, 기본 20)
- `--start-count N` (페이징, 기본 1)
- `meta`
- `--org-id 101` (기본 101=통계청)
- `--table-id DT_1IN0001`
- `--meta-type TBL|ITM|OBJ` (기본 TBL)
- `data`
- `--org-id 101`
- `--table-id DT_1IN0001`
- `--prd-se M|Q|S|Y|F|IR`
- `--start YYYY[MM|QQ|HH]`, `--end YYYY[MM|QQ|HH]`
- `--itm-id ALL`
- `--obj-l 1=ALL --obj-l 2=00` (반복)
- `bigdata`
- `--user-stats-id <KOSIS 등록 ID>`
- `--format json|sdmx|csv` (xls는 바이너리라 helper 미지원 — 필요 시 KOSIS 웹에서 직접 다운로드)
- `--prd-se`, `--new-est-prd-cnt`
공통 옵션:
- `--text` / `--json` (기본 JSON)
- `--dry-run` (인증키 없이 URL/파라미터만 출력)
- `--timeout N` (기본 30)
- `--proxy-base-url URL` (기본 hosted proxy 대신 self-host/alternate proxy 사용)
- `--direct` (proxy를 우회하고 `KSKILL_KOSIS_API_KEY` 로 KOSIS 직접 호출)
## 기본 흐름
1. 일반 조회는 기본 hosted proxy를 사용한다. self-host를 쓰면 `KSKILL_PROXY_BASE_URL`을 설정한다.
2. `search` 로 후보 통계표를 본다.
3. `meta` 로 분류·단위·주기를 확인한다.
4. `data` 로 작은 슬라이스를 먼저 받는다.
5. 한도 초과(코드 `31`/`41`)면 기간/분류를 분할하거나 `bigdata` 로 전환한다.
6. 결과 요약 시 `org_id`, `tbl_id`, 기간, 단위, endpoint URL을 함께 적는다.
## 검증 방식
메인테이너가 일반 조회를 검토하기 위해 별도 KOSIS 인증키를 새로 발급받을 필요는 없다.
- CI/리뷰 검증: `./scripts/validate-skills.sh`, `python3 -m py_compile ...`, `--help`, `--dry-run`, 단위 테스트(`python3 -m unittest discover -s kosis-stats/tests`).
- 실제 direct 조회 검증: 기여자 또는 이미 KOSIS 키를 가진 사용자가 `--direct`로 선택 실행한다. Proxy live smoke는 배포 proxy에 `KOSIS_API_KEY`가 설정된 뒤 수행한다.
- PR에는 호출 endpoint, 파라미터, 응답 행 수 같은 비민감 요약만 남긴다. 인증키와 개인 조회 세부 내역은 공유하지 않는다.
## 예시
키워드 검색:
```bash
python3 kosis-stats/scripts/run_kosis_stats.py search --query "1인 가구" --text
```
JSON 검색:
```bash
python3 kosis-stats/scripts/run_kosis_stats.py search --query "고령" --result-count 50 --json
```
테이블 메타데이터:
```bash
python3 kosis-stats/scripts/run_kosis_stats.py meta --table-id DT_1IN0001 --text
```
연간 데이터 작은 슬라이스:
```bash
python3 kosis-stats/scripts/run_kosis_stats.py data \
--table-id DT_1ES4I001S --prd-se Y --start 2020 --end 2023 --json
```
월간 데이터:
```bash
python3 kosis-stats/scripts/run_kosis_stats.py data \
--table-id DT_1J22001 --prd-se M --start 202401 --end 202412 --json
```
분류 필터:
```bash
python3 kosis-stats/scripts/run_kosis_stats.py data \
--table-id DT_1B040A3 --prd-se Y --start 2024 --end 2024 \
--obj-l 1=ALL --json
```
대용량 자료 (사전 등록한 `userStatsId` 필요):
```bash
python3 kosis-stats/scripts/run_kosis_stats.py bigdata \
--user-stats-id "<KOSIS에서 등록한 ID>" \
--format json --new-est-prd-cnt 5
```
URL/파라미터만 확인 (인증키 없이):
```bash
python3 kosis-stats/scripts/run_kosis_stats.py search --query "인구" --dry-run --text
```
## 주의할 점
- 분당 1,000건 호출 한도. 반복 호출 시 호출 간 sleep을 둔다.
- 1회 응답 40,000셀 한도. 초과하면 코드 `31`/`41` 반환 → 쿼리 분할 또는 `bigdata` 사용.
- `bigdata``userStatsId` 는 KOSIS 웹에서 사용자가 직접 등록해야 하며, 이 helper로 자동 등록하지 않는다. `openapisample/...` 같은 타인 등록 ID는 인증되지 않아 코드 `11` 을 반환한다.
- 2026-03-05 이후 HTTPS 전용. 모든 URL은 `https://`.
- KOSIS는 가끔 따옴표 없는 키의 비표준 JSON을 반환한다. helper가 자동 보정한다.
## 흔한 문제 해결
- `missing required environment variable: KSKILL_KOSIS_API_KEY`: `bigdata` 또는 `--direct` 호출에서만 발생한다. 환경변수가 현재 shell에 주입됐는지 확인한다. 없다면 https://kosis.kr/openapi/ 에서 발급한다.
- `KOSIS error 10` (인증키 누락) / `11` (만료): 키를 재확인하거나 갱신한다. `bigdata` 호출에서 `11` 이 뜨면 해당 `userStatsId` 가 본인 KOSIS 계정에 등록되어 있지 않을 가능성이 높다.
- `KOSIS error 20` (필수 분류 누락): 표마다 필수 차원 수가 다르다. `meta --table-id <ID> --meta-type OBJ` 로 차원 수를 확인하고(OBJ가 비어 있으면 `--meta-type ITM`), `--obj-l 1=<코드> --obj-l 2=<코드>` 형태로 모두 지정한 뒤 재호출한다. 예: `data --table-id DT_1J22001 --prd-se M --start 202401 --end 202401 --obj-l 1=ALL` → 코드 20 → meta 확인 → `--obj-l 1=T10 --obj-l 2=0` 추가 → 성공.
- `KOSIS error 21` (잘못된 요청 변수): `org_id`/`tbl_id`/`prdSe`/`startPrdDe` 형식과 분류 인덱스를 재확인한다. 표에 존재하지 않는 `objL3=ALL` 같은 인덱스는 거부된다. tblId 의심 시 `search --query <키워드>` 로 정확한 ID를 다시 찾는다.
- `KOSIS error 30` (결과 없음): 키워드 또는 기간 필터를 완화한다.
- `KOSIS error 31` / `41` (한도 초과): 기간을 좁히거나(예: 5년 → 1년) 분류 ALL 을 특정 코드로 바꾼다(예: `--obj-l 1=ALL``--obj-l 1=11` 서울만). 그래도 부족하면 `bigdata` 로 전환한다. 행정구역 코드는 시도 2자리, 시군구 5자리 관례.
- `KOSIS error 40` (호출 한도): 분당 1,000건 한도 도달. 잠시 대기.
- `KOSIS error 50` (서버 오류): 1~2초 대기 후 재시도.
- `argparse: unrecognized arguments: --text`: `--text`/`--json`/`--dry-run`/`--timeout` 은 서브커맨드(`search`/`meta`/`data`/`bigdata`) **뒤에** 둔다.

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

@ -4,6 +4,8 @@
- KTX/Korail 열차 조회
- 좌석 가능 여부 확인
- 호차별 남은 좌석번호 확인
- 콘센트 꿀팁 좌석 필터링
- 예약 진행
- 예약 내역 확인
- 예약 취소
@ -35,6 +37,7 @@
- 희망 시작 시각: `HHMMSS`
- 인원 수와 승객 유형
- 좌석 선호
- 좌석 상세 조건: 객실 등급, 호차 번호, 남은 좌석만 보기, 콘센트 좌석 우선
- 조회 결과에서 복사한 `train_id`
## 왜 helper 를 쓰는가
@ -54,8 +57,9 @@
2. `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` 가 없으면 credential resolution order에 따라 확보한다.
3. helper 로 먼저 열차를 조회한다.
4. 후보 열차의 `index`, `train_id`, 출발/도착 시각, KTX 여부, 좌석 여부를 보여준다.
5. 대상 열차가 명확할 때만 예약한다.
6. 예약 확인/취소는 대상 예약을 다시 식별한 뒤 진행한다.
5. 사용자가 좌석번호, 호차별 잔여석, 콘센트 꿀팁 좌석을 물으면 `seats` 로 상세 좌석을 먼저 확인한다.
6. 대상 열차가 명확할 때만 예약한다.
7. 예약 확인/취소는 대상 예약을 다시 식별한 뒤 진행한다.
## 예시
@ -69,6 +73,43 @@ python3 scripts/ktx_booking.py search 서울 부산 20260328 090000 --limit 5
응답 JSON 의 `train_id` 는 검색 시점의 정확한 열차를 가리키는 stable selector 다. 예약할 때는 이 값을 그대로 복사해서 쓴다. 같은 열차가 더 이상 조회되지 않으면 helper 가 실패하고 새로 조회하게 만든다.
상세 좌석 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id>
```
남은 좌석번호만 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only
```
특정 호차를 지정하지 않으면 `seats` 는 승강장 이동 거리가 짧은 가운데 호차부터 탐색한다. 각 호차 안에서는 콘센트 힌트가 있는 좌석을 먼저, 같은 조건에서는 순방향 좌석을 먼저 반환한다.
특정 호차의 남은 좌석만 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --car-no 5 --available-only
```
콘센트 꿀팁 좌석부터 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only --power-only
```
특실 좌석을 확인하려면 `--room special`, KTX 외 열차를 조회했다면 `search` 와 같은 `--train-type` 을 함께 넘긴다.
```bash
python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \
--train-id <train_id> \
--train-type itx-cheongchun \
--available-only
```
`seats` 응답은 호차별 `remaining_seats`, `available_seats`, 좌석별 순방향/역방향, 창측/내측, 좌석 종류, 문 근처 여부, 콘센트 힌트를 JSON 으로 반환한다. 이 단계는 좌석을 선택하거나 선점하지 않고, 예약 전 확인만 한다.
예약:
```bash

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,44 @@
# 인허가 영업상태 조회 (localdata-business-status)
`localdata-business-status` 스킬은 행정안전부 **지방행정 인허가데이터(LOCALDATA)**의 지역별 CSV를 `file.localdata.go.kr`에서 직접 받아 동네 사업장의 영업상태를 조회한다.
## 제공 기능
- 영업상태(영업/휴업/폐업)·상세영업상태·인허가일자(업력)·폐업일자·업태구분·도로명/지번 주소·데이터갱신시점
- 인허가 업종 **208종 전체** 지원 — 한글명("약국", "숙박업", "일반음식점")으로 지정 가능
## 인증/시크릿
없다. 무인증 공개 파일 서버이므로 프록시를 거치지 않고 사용자 머신에서 직접 호출한다. helper는 stdlib만 쓴다(추가 의존성 없음). 받은 파일은 1일 로컬 캐시한다.
## 입력/동일성 경계
- 전국 통파일이 업종당 수백 MB라 시군구 단위 지역 지정(`--region`)이 필요하다.
- 자료에 **사업자등록번호가 수록되지 않아** 상호(사업장명) 문자열 매칭만 가능하다. 동명 상호 가능성은 사용자가 판단한다.
- 자료는 매일 갱신되며 2일 전 기준으로 현행화된다.
## 예시
```bash
python3 localdata-business-status/scripts/localdata_business_status.py \
--name "호텔샬롬" --region 제주제주시 --industry 숙박업
python3 localdata-business-status/scripts/localdata_business_status.py \
--name "○○약국" --region 서울종로구 --industry 약국
```
## 입력
- `--name`: 상호(사업장명) — 필수
- `--region`: 시군구 — 필수 (예: `제주제주시`, `서울종로구`)
- `--industry`: 업종 slug 또는 한글명(여러 번 지정 가능). 생략 시 일반음식점·휴게음식점·숙박업
## 실패 모드
- `unavailable` + 안내: 상호/지역 미입력, 지역·업종 특정 실패(후보 나열), 다운로드 실패 — 수동 확인 URL 제공
- 0건: 매치 없음
## 공식 출처
- 인허가 영업상태: `https://file.localdata.go.kr/file/download/<업종slug>/info?orgCode=<지자체코드>` (무인증, Referer 필요, CP949 CSV)
- 본체: <https://www.localdata.go.kr>

View file

@ -0,0 +1,84 @@
# 마이리얼트립 MCP 검색 가이드
`myrealtrip-search`는 마이리얼트립 공식 개발자센터가 공개한 Streamable HTTP MCP 서버를 직접 호출해 항공권, 숙소, 투어·티켓·액티비티를 검색하는 스킬이다.
- 공식 문서: <https://docs.myrealtrip.com/#/api/mcp/overview>
- 설정 문서: <https://docs.myrealtrip.com/#/api/mcp/setup>
- 기본 endpoint: `https://mcp-servers.myrealtrip.com/mcp`
- 인증: 문서 기준 불필요
## 설치
Python MCP SDK가 필요하다.
```bash
python3 -m pip install mcp
```
Hermes Agent에 MCP 서버로 직접 붙이고 싶다면 다음처럼 등록한다.
```yaml
mcp_servers:
myrealtrip:
url: "https://mcp-servers.myrealtrip.com/mcp"
```
직접 MCP 설정을 하지 않아도 스킬에 포함된 래퍼를 사용할 수 있다.
```bash
python3 myrealtrip-search/scripts/myrealtrip_mcp.py tools
```
## 주요 도구
| 도구 | 용도 |
| --- | --- |
| `searchDomesticFlights` | 김포↔제주 등 국내선 항공권 실시간 검색 |
| `searchInternationalFlights` | 인천↔해외 공항 국제선 항공권 실시간 검색 |
| `flightsFareCalendar` | 날짜별 최저가 캘린더 조회. 캐시/추정값이므로 실제 검색으로 재확인 필요 |
| `getPromotionAirlines` | 항공권 특가·프로모션 항공사 확인 |
| `searchStays` | 호텔·펜션·리조트 등 숙소 검색 |
| `getStayDetail` | 숙소 객실, 가격, 취소정책, 시설, 리뷰 상세 확인 |
| `getCategoryList` | 도시별 투어/액티비티 카테고리 값 확인 |
| `searchTnas` | 투어·티켓·액티비티 검색 |
| `getTnaDetail` | 투어/티켓 상세 정보 확인 |
| `getTnaOptions` | 특정 날짜의 옵션, 가격, 예약 가능 여부 확인 |
## 예시
### 국제선 검색
```bash
python3 myrealtrip-search/scripts/myrealtrip_mcp.py call searchInternationalFlights \
--json '{"tripType":"ROUND_TRIP","origin":"ICN","destination":"KIX","departDate":"2026-06-10","returnDate":"2026-06-14","passengers":{"adults":1,"children":0,"infants":0},"maxResults":5}'
```
### 숙소 검색
```bash
python3 myrealtrip-search/scripts/myrealtrip_mcp.py call searchStays \
--json '{"keyword":"부산 해운대","checkIn":"2026-06-10","checkOut":"2026-06-12","adultCount":2,"childCount":0,"isDomestic":true,"order":"recommended"}'
```
### 투어/티켓 검색
```bash
python3 myrealtrip-search/scripts/myrealtrip_mcp.py call searchTnas \
--arg query="오사카 유니버설 스튜디오" \
--arg perPage=5
```
날짜별 실제 옵션과 가격은 검색 결과의 `gid`, `url`로 다시 확인한다.
```bash
python3 myrealtrip-search/scripts/myrealtrip_mcp.py call getTnaOptions \
--json '{"gid":"123456","url":"https://www.myrealtrip.com/offers/123456","selectedDate":"2026-06-10"}'
```
## 운영 원칙
- 예약/결제/로그인은 자동화하지 않는다. 결과와 예약 URL만 제공한다.
- 가격, 좌석, 재고, 예약 가능 여부는 변동 가능하므로 답변에 명시한다.
- 항공 최저가 캘린더는 캐시/추정값이므로 실시간 검색 결과를 우선한다.
- 숙소 상세 질문은 목록 결과의 `gid``getStayDetail`을 이어서 호출한다.
- 도시별 투어 카테고리 값은 추측하지 말고 `getCategoryList` 결과를 사용한다.

View file

@ -0,0 +1,38 @@
# 국민연금 가입 사업장 조회 (national-pension-workplace)
`national-pension-workplace` 스킬은 공공데이터포털의 **국민연금공단_국민연금 가입 사업장 내역 서비스**(3046071, V2)를 `k-skill-proxy` 경유로 호출한다.
## 제공 기능
- 가입 사업장 후보: 사업장명 + 사업자번호 앞 6자리로 매칭, 자료생성년월별 중복은 사업장당 최신 월로 정리
- 단일 사업장 특정 시 상세: 가입자수(`jnngpCnt`), 당월 고지금액(`crrmmNtcAmt`), 신규취득/상실 인원
- 월별 가입 현황 시계열
## 인증/시크릿
사용자 로컬 시크릿은 필요 없다. upstream `DATA_GO_KR_API_KEY`는 프록시 서버에만 둔다(3046071 활용신청 필요). self-host 프록시는 `KSKILL_PROXY_BASE_URL`로 지정한다.
## 공개 범위
- 사업자번호는 앞 6자리만 공개(뒷자리 마스킹)되어 사업장명이 필수다. 후보가 여럿이면 동일성을 단정하지 않고 목록을 그대로 돌려준다.
- 법인·근로자 일정 규모 이상 사업장 위주로 공개되며, 소규모/개인 사업장은 미공개일 수 있다.
## 예시
```bash
python3 national-pension-workplace/scripts/national_pension_workplace.py \
--name "삼성전자(주)" --b-no 124-81-00998
```
## 실패 모드
- `400 bad_request`: 사업장명 미입력
- `503 upstream_not_configured`: 프록시에 `DATA_GO_KR_API_KEY` 없음
- `502 upstream_forbidden`: 프록시 키가 3046071에 미신청
- `selected_candidate: null`: 후보 다수 — 사용자가 특정
## 공식 출처
- 공공데이터포털: <https://www.data.go.kr/data/3046071/openapi.do>
- upstream: `https://apis.data.go.kr/B552015/NpsBplcInfoInqireServiceV2`
- 프록시 route: `GET /v1/national-pension/workplace`

View file

@ -0,0 +1,50 @@
# 국세청 사업자등록정보 진위확인 및 상태조회
`nts-business-registration` 스킬은 공공데이터포털의 **국세청_사업자등록정보 진위확인 및 상태조회 서비스**를 `k-skill-proxy` 경유로 호출한다.
## 제공 기능
- 사업자등록번호 상태조회: `POST /v1/nts-business/status`
- 사업자등록정보 진위확인: `POST /v1/nts-business/validate`
## 인증/시크릿
사용자 로컬 시크릿은 필요 없다. upstream `DATA_GO_KR_API_KEY`는 프록시 서버에만 둔다.
self-host 프록시를 쓰는 경우에만 `KSKILL_PROXY_BASE_URL`을 설정한다. 비우면 hosted proxy(`https://k-skill-proxy.nomadamas.org`)를 사용한다.
## 진위확인 개인정보 경로
`/v1/nts-business/validate`는 대표자명(`p_nm`), 개업일자(`start_dt`), 선택 주소/상호 메타데이터를 hosted proxy와 공공데이터포털 upstream으로 전송한다. proxy는 validate 성공 응답을 캐시하지 않고(`status` 조회만 성공 캐시), 응답에 normalized `query`를 echo하지 않으며, upstream 응답이 요청값을 되돌려도 민감 필드를 제거한다.
기본 proxy 서버는 Fastify request logging을 켜지 않는다. self-host 운영자가 별도 요청 로깅을 활성화했다면 validate 요청 본문이 저장되지 않도록 로그 정책을 확인해야 한다. hosted proxy 대신 자체 운영 경로가 필요하면 `KSKILL_PROXY_BASE_URL`로 self-host proxy를 지정한다.
## 예시
```bash
python3 nts-business-registration/scripts/nts_business_registration.py status \
--b-no 123-45-67890
```
```bash
python3 nts-business-registration/scripts/nts_business_registration.py validate \
--business-json '{"b_no":"123-45-67890","start_dt":"2020-01-31","p_nm":"홍길동","b_nm":"테스트상사"}'
```
## 입력 제한
- 사업자등록번호는 숫자 10자리여야 한다. 하이픈은 자동 제거한다.
- 상태조회/진위확인은 한 번에 최대 100건까지 보낸다.
- 진위확인은 `b_no`, `start_dt`, `p_nm`이 필수다.
- 선택 필드: `p_nm2`, `b_nm`, `corp_no`, `b_sector`, `b_type`, `b_adr`
- 길이 제한: `p_nm`/`p_nm2` 30자, `b_nm` 200자, `b_sector`/`b_type` 100자, `b_adr` 500자. `corp_no`는 제공 시 숫자 13자리여야 한다.
## 실패 모드
- `400 bad_request`: 입력 형식 오류 또는 필수 필드 누락
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 없음
- upstream 인증/활용신청 오류: 공공데이터포털 키가 해당 서비스에 승인되지 않았거나 오류 상태
## 공식 출처
- 공공데이터포털: <https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15081808>

View file

@ -0,0 +1,31 @@
# 국세 체납 명단공개 검색 (nts-tax-delinquency)
`nts-tax-delinquency` 스킬은 국세청 누리집의 **고액·상습체납자 명단공개**(국세기본법 제85조의5)를 무인증 공개 검색으로 직접 조회한다.
## 제공 기능
- 법인 명단: 법인명으로 검색 — 공개년도·법인명·대표자·업종·소재지·총 체납액·세목·체납건수·체납요지
- 개인 명단: 상호로 검색 — 공개년도·성명·연령·상호·직업·주소·총 체납액·세목·체납요지
## 인증/시크릿
없다. 인증 없이 동작하는 공개 read-only 검색이므로 프록시를 거치지 않고 사용자 머신에서 직접 호출한다. helper는 stdlib만 쓴다(추가 의존성 없음).
## 동일성 경계
명단공개 자료에는 **사업자등록번호가 수록되지 않는다.** 상호·법인명 문자열 일치 후보의 공개 사실만 나열하며, 동명 상호 가능성은 사용자가 판단한다.
## 예시
```bash
python3 nts-tax-delinquency/scripts/nts_tax_delinquency.py --name "○○건설"
```
## 실패 모드
- `unavailable` + 안내: 상호 미입력, 네트워크 오류, 페이지 구조 변경 추정 — 수동 확인 URL 제공. HTML 스크래핑이라 마커가 어긋나면 즉시 강등한다.
- 0건: 두 명단 모두 매치 없음.
## 공식 출처
- 명단공개 검색: <https://www.nts.go.kr/nts/ad/openInfo/selectList.do>

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,67 @@
# 사람인 인재풀 검색 가이드
## 이 기능으로 할 수 있는 일
- 사람인 기업회원 인재풀에서 구인/채용 조건에 맞는 후보를 검색한다.
- 사용자가 직접 로그인/2차 인증을 완료한 브라우저 세션에서 현재 보이는 마스킹 후보 정보를 읽는다.
- 유료 열람/연락처 확인/제안 발송 전에 후보 적합도를 비교하고 shortlist를 만든다.
- 영업, 마케팅, 디자인, PM/PO, HR, 재무, 운영, 제조, 법무/총무, 개발/데이터 등 전 직무에 사용할 수 있다.
## 먼저 알아둘 점
- 사람인 구인자/채용 담당자가 접근 가능한 기업회원 로그인과 첫 기기 2차 인증이 필요할 수 있다.
- 에이전트는 비밀번호, OTP, 인증번호, 세션 쿠키를 요청하거나 저장하지 않는다.
- 유료 이력서 열람, 연락처 확인, 포지션 제안, 스크랩/관심후보/메모/상태 변경, 결제는 자동으로 하지 않는다.
- 일반 후보 상세/프로필 링크를 열어 현재 보이는 마스킹 정보만 읽는다.
## 공식 표면
- 사람인 인재풀 검색: https://www.saramin.co.kr/zf_user/memcom/talent-pool/main/search
## 입력값
- 채용 직무명
- 경력 범위
- 지역
- 필수 경험/스킬/업종
- 우대 경험/성과/툴
- 제외할 업무/업종/경력 패턴
- 유료 열람 추천 인원 수
## 기본 흐름
1. 사람인 인재풀 검색 페이지를 연다.
2. 로그인/2차 인증이 필요하면 사용자가 열린 브라우저에서 직접 완료한다.
3. 검색어, 직무/직종, 경력, 지역, 최근 업데이트/정렬, 제외 조건을 적용한다.
4. 결과 목록에서 후보 pool을 만든다.
5. 최종 추천 전에는 가능한 후보 상세/프로필 페이지를 열어 현재 보이는 마스킹 정보를 확인한다.
6. 유료 열람/연락처/제안/스크랩/메모/상태 변경 버튼은 누르지 않는다.
7. URL, 검토 수준, 점수, 근거, 리스크를 포함해 shortlist를 정리한다.
## 결과 형식
```text
사람인 인재풀 shortlist
검색 조건
- 포지션: ...
- 필수 조건: ...
- 우대 조건: ...
- 제외 조건: ...
- 경력/지역: ...
- 모드: 로그인/인증 완료 브라우저 세션의 마스킹 후보 정보
유료 열람 추천 Top N
1. 후보 A
- 점수: ...
- 근거: ...
- 검토 수준: 상세 이력 확인 기반 / 목록 기반 1차
- 추천 액션: 채용 담당자가 유료 열람 검토
- URL: ...
```
## 제한사항
- 사람인 UI, 계정 권한, 유료 상품 상태에 따라 보이는 정보가 다르다.
- 상세 접근이 전부 유료 벽이면 `목록 기반 1차 shortlist`로 낮은 신뢰도를 표시한다.
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.

View file

@ -0,0 +1,82 @@
# 서울 따릉이 실시간 대여소 조회 가이드
## 이 기능으로 할 수 있는 일
- 현재 좌표 주변 따릉이 대여소의 대여 가능 자전거 수 확인
- 빈 거치대 수(`rackTotCnt - parkingBikeTotCnt`) 확인
- 대여소 이름 키워드로 실시간 상태 검색
- 별도 사용자 `SEOUL_OPEN_API_KEY` 없이 `k-skill-proxy` 로 조회
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
## 기본 경로
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/seoul-bike/*` 로 요청한다.
사용자는 별도 서울 열린데이터 광장 OpenAPI key 를 직접 발급받을 필요가 없다. upstream key 는 proxy 서버에서만 `SEOUL_OPEN_API_KEY` 로 관리한다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 쓴다.
## Proxy routes
| endpoint | upstream / 동작 | 주요 입력 |
|---|---|---|
| `GET /v1/seoul-bike/realtime` | 서울 열린데이터 광장 `bikeList` 실시간 대여정보 페이지 | `startIndex`, `endIndex` |
| `GET /v1/seoul-bike/stations` | 서울 열린데이터 광장 `tbCycleStationInfo` 대여소 마스터 페이지 | `startIndex`, `endIndex` |
| `GET /v1/seoul-bike/nearby` | proxy 가 realtime 행을 좌표 반경으로 필터링 | `lat`, `lon`, `radius_m`, `limit` |
## 기본 흐름
1. client/skill 은 기본 hosted path 또는 `KSKILL_PROXY_BASE_URL` 아래 `/v1/seoul-bike/nearby` endpoint 를 호출한다.
2. proxy 는 서울 열린데이터 광장 `bikeList``SEOUL_OPEN_API_KEY` 와 함께 호출한다.
3. proxy 는 좌표와 반경을 기준으로 대여소를 정렬하고 `available_bikes`, `empty_docks`, `distance_m` 을 반환한다.
4. 응답에는 `proxy.cache.hit`, `proxy.requested_at` 메타데이터가 붙는다.
## 예시
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/seoul-bike/nearby" \
--data-urlencode 'lat=37.5717' \
--data-urlencode 'lon=126.9763' \
--data-urlencode 'radius_m=500' \
--data-urlencode 'limit=5'
```
스킬 CLI 사용 예시:
```bash
python3 seoul-bike/scripts/seoul_bike.py nearby --lat 37.5717 --lon 126.9763 --radius-m 500
python3 seoul-bike/scripts/seoul_bike.py search "광화문" --limit 5
```
예상 응답 요약:
```text
따릉이 주변 대여소 2곳
기준 좌표: 37.5717, 126.9763 / 반경 500m
- 101. 광화문역 1번출구 앞: 대여 가능 4대, 빈 거치대 11개, 거리 0m
조회 시각: 2026-05-21T06:10:00.000Z
```
## fallback / 대체 흐름
- `KSKILL_PROXY_BASE_URL` 을 별도로 넣으면 해당 proxy 를 우선 사용한다.
- 기본 hosted path 는 `https://k-skill-proxy.nomadamas.org/v1/seoul-bike/*` 이다.
- self-host 운영자는 서버 쪽에만 `SEOUL_OPEN_API_KEY` 를 넣는다. 사용자 쪽에는 키가 필요 없다.
## 주의할 점
- 실시간 데이터는 계속 변하므로 답변에는 조회 시각을 함께 적는다.
- 예약/대여 자동화는 하지 않는다. 조회 전용 스킬이다.
- 서울 열린데이터 광장 quota 초과나 일시 장애가 있을 수 있다.
- 반경 안에 대여소가 없으면 `items: []` 가 정상적으로 반환될 수 있다.
## 참고 표면
- 서울 열린데이터 광장: `https://data.seoul.go.kr`
- 따릉이 실시간 대여정보: `bikeList`
- 따릉이 대여소 정보: `tbCycleStationInfo`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -0,0 +1,88 @@
# 서울 실시간 혼잡도 조회 가이드
## 이 기능으로 할 수 있는 일
- 서울 주요 121개 핫스팟의 실시간 혼잡도 단계(여유 / 보통 / 약간 붐빔 / 붐빔) 확인
- KT·SKT 통신 신호 기반 추정 인구 범위(`AREA_PPLTN_MIN ~ AREA_PPLTN_MAX`) 확인
- 기준 시각(`PPLTN_TIME`)과 혼잡도 메시지(`AREA_CONGEST_MSG`) 같이 확인
- 별도 사용자 `SEOUL_OPEN_API_KEY` 없이 `k-skill-proxy` 로 조회
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
## 기본 경로
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/seoul-density/citydata` 로 요청한다.
사용자는 별도 서울 열린데이터 광장 OpenAPI key 를 직접 발급받을 필요는 없다. upstream key 는 proxy 서버에서만 `SEOUL_OPEN_API_KEY` 로 관리한다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 쓴다.
## 입력값
- `area` — 지원 장소명 (예: `강남역`, `홍대 관광특구`, `여의도한강공원`)
지원 장소 전체 목록은 `seoul-density/SKILL.md``AREAS` 카테고리 또는 다음 명령으로 확인한다:
```bash
python3 seoul-density/scripts/seoul_density.py list
```
## 기본 흐름
1. client/skill 은 기본 hosted path 또는 `KSKILL_PROXY_BASE_URL` 아래 `/v1/seoul-density/citydata` endpoint 를 호출한다.
2. proxy 는 서울 열린데이터 광장 `citydata_ppltn/1/1/{area}``SEOUL_OPEN_API_KEY` 와 함께 호출한다.
3. 응답을 그대로 돌려주며, `proxy.cache.hit` 메타데이터를 추가한다.
## 예시
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/seoul-density/citydata" \
--data-urlencode 'area=강남역'
```
스킬 CLI 사용 예시:
```bash
python3 seoul-density/scripts/seoul_density.py query "강남역"
```
예상 응답 (요약):
```json
{
"SeoulRtd.citydata_ppltn": [
{
"AREA_NM": "강남역",
"AREA_CONGEST_LVL": "약간 붐빔",
"AREA_PPLTN_MIN": "24000",
"AREA_PPLTN_MAX": "26000",
"PPLTN_TIME": "2026-05-14 09:30",
"AREA_CONGEST_MSG": "사람이 몰려있을 수 있어요"
}
],
"RESULT": { "RESULT.CODE": "INFO-000" }
}
```
## fallback / 대체 흐름
- `KSKILL_PROXY_BASE_URL` 을 별도로 넣으면 해당 proxy 를 우선 사용한다.
- 기본 hosted path 는 `https://k-skill-proxy.nomadamas.org/v1/seoul-density/citydata` 이다.
- self-host 운영자는 서버 쪽에만 `SEOUL_OPEN_API_KEY` 를 넣는다 (사용자 쪽에는 키가 필요 없다).
## 주의할 점
- 인구 수치는 실제값이 아닌 **추계치** (KT·SKT 통신 신호 데이터 기반).
- 데이터는 호출 시점 기준 **약 15분 전** 값이며 5분 주기로 갱신된다.
- 새벽 01~05시는 실시간 데이터가 제공되지 않을 수 있다.
- 일일 호출 할당량 초과 시 다음 날 재시도해야 한다.
- 지원하지 않는 장소명을 넣으면 빈 응답이 돌아오므로 스킬의 `match` 서브커맨드로 후보를 먼저 확인한다.
## 참고 표면
- 공식 API 안내: `https://data.seoul.go.kr/dataList/OA-21778/A/1/datasetView.do`
- 서울 열린데이터 광장: `https://data.seoul.go.kr`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -11,18 +11,18 @@
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
- self-host 또는 배포 확인이 끝난 proxy base URL: `KSKILL_PROXY_BASE_URL`
- optional: `KSKILL_PROXY_BASE_URL` (self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용)
## 필요한 환경변수
- `KSKILL_PROXY_BASE_URL` (필수: self-host 또는 배포 확인이 끝난 proxy base URL)
- 없음. `KSKILL_PROXY_BASE_URL` 은 선택 사항이며, 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
사용자가 서울 열린데이터 광장 OpenAPI key를 직접 발급할 필요는 없다. 대신 `KSKILL_PROXY_BASE_URL``/v1/seoul-subway/arrival` route가 실제로 배포된 proxy를 가리켜야 한다. upstream key는 proxy 서버에서만 관리한다.
사용자가 서울 열린데이터 광장 OpenAPI key를 직접 발급할 필요는 없다. `/v1/seoul-subway/arrival` route는 기본 hosted proxy에서 호출하고, upstream key는 proxy 서버에서만 관리한다. 별도 proxy를 쓰는 경우에만 `KSKILL_PROXY_BASE_URL` 을 설정한다.
### Proxy resolution order
1. **`KSKILL_PROXY_BASE_URL` 이 있으면** 그 값을 사용합니다.
2. **없으면** 사용자/운영자에게 self-host 또는 배포 확인이 끝난 proxy URL 을 먼저 확보합니다.
2. **없거나 빈 값이면** 기본 hosted proxy `https://k-skill-proxy.nomadamas.org` 를 사용합니다.
3. **직접 proxy를 운영하는 경우에만** proxy 서버 upstream key를 서버 쪽에만 설정합니다.
## 입력값
@ -32,21 +32,23 @@
## 기본 흐름
1. `KSKILL_PROXY_BASE_URL` 로 self-host 또는 배포 확인이 끝난 proxy base URL 을 확인합니다.
1. `KSKILL_PROXY_BASE_URL` 이 있으면 그 값을 사용하고, 없거나 비어 있으면 기본 hosted proxy `https://k-skill-proxy.nomadamas.org` 를 사용합니다.
2. `/v1/seoul-subway/arrival?stationName=...` 로 역명 기준 실시간 도착정보를 조회합니다.
3. 호선, 진행 방향, 도착 메시지, 조회 시점을 함께 요약합니다.
## 예시
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/seoul-subway/arrival' \
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/seoul-subway/arrival" \
--data-urlencode 'stationName=강남'
```
범위를 줄이거나 늘리고 싶으면:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/seoul-subway/arrival' \
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/seoul-subway/arrival" \
--data-urlencode 'stationName=서울역' \
--data-urlencode 'startIndex=0' \
--data-urlencode 'endIndex=4'
@ -57,5 +59,4 @@ curl -fsS --get 'https://your-proxy.example.com/v1/seoul-subway/arrival' \
- 실시간 데이터라 몇 초 단위로 바뀔 수 있습니다.
- 역명 표기가 다르면 결과가 비어 있을 수 있습니다.
- 일일 호출 제한이나 quota 초과 가능성이 있습니다.
- public hosted route rollout 이 끝나기 전까지는 `KSKILL_PROXY_BASE_URL` 을 반드시 명시합니다.
- self-host proxy 설정은 [k-skill 프록시 서버 가이드](k-skill-proxy.md)를 봅니다.

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

@ -4,6 +4,8 @@
- 수서 출발 SRT 열차 조회
- 좌석 가능 여부 확인
- 호차별 남은 좌석번호 확인
- 특정 좌석 공석 여부 확인
- 예약 진행
- 예약 내역 확인
- 예약 취소
@ -35,33 +37,57 @@
- 희망 시작 시각: `HHMMSS`
- 인원 수
- 좌석 선호
- 좌석 상세 조건: 객실 등급, 호차 번호, 좌석 번호, 빈 좌석만 보기, 탐색 우선순위
## 기본 흐름
1. `SRTrain` 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치합니다.
2. `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` 가 없으면 credential resolution order에 따라 확보합니다.
3. 먼저 열차를 조회합니다.
3. 먼저 helper 로 열차를 조회합니다.
4. 후보 열차의 출발/도착 시각, 좌석 여부, 운임을 보여줍니다.
5. 대상 열차가 명확할 때만 예약합니다.
6. 예약 확인/취소는 예약을 다시 식별한 뒤 진행합니다.
5. 사용자가 좌석번호, 호차별 잔여석, 특정 좌석 공석 여부를 물으면 `seats` 로 상세 좌석을 먼저 확인합니다.
6. 대상 열차가 명확할 때만 예약합니다.
7. 예약 확인/취소는 예약을 다시 식별한 뒤 진행합니다.
## 예시
```bash
python3 - <<'PY'
import os
from SRT import SRT
srt = SRT(os.environ["KSKILL_SRT_ID"], os.environ["KSKILL_SRT_PASSWORD"])
trains = srt.search_train("수서", "부산", "20260328", "080000", time_limit="120000")
for idx, train in enumerate(trains[:5], start=1):
print(idx, train)
PY
python3 scripts/srt_booking.py search 수서 부산 20260328 080000 --time-limit 120000 --limit 5
```
상세 좌석 확인:
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 --train-id <train_id>
```
특정 호차의 빈 좌석만 확인:
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 --train-id <train_id> --car-no 5 --available-only
```
특정 좌석이 비었는지 확인:
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 --train-id <train_id> --car-no 5 --seat 11A
```
탐색 순서 조정:
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 \
--train-id <train_id> \
--car-priority center \
--seat-priority window-forward \
--available-only
```
`seats` 응답은 호차별 `available_seat_count`, `available_seats`, 좌석별 순방향/역방향, 창측/내측, 특정 좌석 요청 시 `requested_seat_available` 을 JSON 으로 반환합니다. 이 단계는 좌석을 선택하거나 선점하지 않고, 예약 전 확인만 합니다.
## 주의할 점
- credential은 환경변수로 주입합니다.
- 상세 좌석 확인은 SRT 웹 좌석선택 페이지의 공개 HTML을 조회 전용으로 파싱합니다.
- 결제 완료까지 자동화하는 문서는 아닙니다.
- 매진 시 공격적인 재시도 루프는 피합니다.

View file

@ -0,0 +1,145 @@
# 공연 일정·잔여석 조회 가이드
## 이 기능으로 할 수 있는 일
- YES24 (`ticket.yes24.com`) 공연의 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회
- 인터파크 (`tickets.interpark.com`) 공연의 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회
- 공연 URL 또는 `platform:id` 표기 (`yes24:58026`, `interpark:26000541`) 로 입력
- 회차별 등급명·잔여수 (YES24 는 노출가 포함) 를 JSON 으로 정리
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 완료
- `python3` (3.9 이상) 와 `httpx` 패키지
- 인터넷 연결
`httpx` 설치:
```bash
pip install httpx
```
## v1 범위
이 기능은 **공개 endpoint / 조회 전용** 범위로 제공된다.
- YES24 의 `axPerfDay.aspx`, `axPerfPlayTime.aspx`, `axPerfRemainSeat.aspx` 와 인터파크의 `api-ticketfront.interpark.com/v1/goods/<id>/playSeq` 만 호출한다.
- 회차 단위 일정·등급별 잔여석 ** 만 정규화한다.
- 예매·결제·취소·환불·좌석 선택·로그인 자동화는 **의도적으로 포함하지 않는다**. 매크로를 이용한 입장권 부정구매·판매는 공연법 §4조의2 (2023.9.22 시행) 에 따라 형사처벌 대상이다.
- 차단 우회, CAPTCHA 우회, fingerprint spoofing, headless 감지 우회는 사용하지 않는다.
## 기본 흐름
1. 공연 URL 또는 `platform:id` 를 받아온다.
2. 일정만 필요하면 `schedule`, 등급별 잔여석까지 필요하면 `seats` 를 호출한다.
3. 결과 JSON 에서 회차별 날짜·시각·등급·잔여수를 정리하고 "조회 시각 기준" 임을 함께 안내한다.
4. 사용자가 페이지에서 직접 결제하도록 안내한다 — 스킬이 결제·예매 흐름을 대신하지 않는다.
## 예시
### 일정 조회 (인터파크)
```bash
python3 scripts/ticket_availability.py schedule "https://tickets.interpark.com/goods/26000541"
```
응답 (요약):
```json
{
"platform": "interpark",
"id": "26000541",
"schedule": [
{"date": "2026-05-13", "time": "14:30", "play_seq": "055"},
{"date": "2026-05-14", "time": "19:30", "play_seq": "057"}
]
}
```
### 일정 조회 (YES24, 기본 3주 윈도우)
```bash
python3 scripts/ticket_availability.py schedule "https://ticket.yes24.com/Perf/58026"
```
6개월 전체:
```bash
python3 scripts/ticket_availability.py schedule "yes24:58026" --all-dates
```
### 등급별 잔여석 조회
```bash
python3 scripts/ticket_availability.py seats "interpark:26000541"
```
응답 (요약, 회차당 1개 키):
```json
{
"platform": "interpark",
"id": "26000541",
"seats": {
"2026-05-13|14:30|055": {
"date": "2026-05-13", "time": "14:30", "play_seq": "055",
"seats": [
{"grade": "VIP석", "remain": 150},
{"grade": "R석", "remain": 36},
{"grade": "S석", "remain": 82},
{"grade": "A석", "remain": 71}
]
}
}
}
```
YES24 응답은 회차별 `time_label` (예: `1회`, `2회`) 와 등급별 `price` (노출가, 예: `110,000원`) 가 함께 들어온다.
### 헬스체크
```bash
python3 scripts/ticket_availability.py health
```
응답:
```json
{
"yes24": {"status": 200, "ok": true},
"interpark": {"status": 200, "ok": true}
}
```
### 한 줄 JSON (파이프용)
```bash
python3 scripts/ticket_availability.py seats "interpark:26000541" --compact
```
## 출력에서 확인할 점
- `platform``yes24` 또는 `interpark` 인지
- `schedule[].date`, `time` 또는 `time_label` 이 채워졌는지
- `seats[<key>].seats[].grade``remain` 이 채워졌는지
- 잔여 0 인 등급이 매진된 등급인지 (조회 시각 기준이라 실시간 변동 가능)
## 실패 모드
- **빈 `schedule`**: 공연 ID 가 유효하지만 향후 3주 (또는 6개월) 내 일정이 없을 때. `--all-dates` 또는 다른 ID 확인을 안내한다.
- **인터파크 `data: []`**: goods_code 가 지나간 공연이거나 오픈 전 / 비공개. 다른 ID 확인을 안내한다.
- **HTTP 4xx/5xx**: 차단·일시 장애. 우회 시도하지 않고 `http error` 메시지를 그대로 반환한다.
- **HTML 응답 스키마 변경**: YES24 `axPerfRemainSeat.aspx` 는 HTML 정규식 파싱이라 사이트 갱신 시 영향 가능. 잔여 0 으로 잘못 보고될 가능성이 있어 "조회 시각 기준" 임을 명시한다.
- **rate-limit**: `seats` 명령은 회차별로 순차 호출한다 (Interpark 0.3s, YES24 0.4s 간격). 100 회차 짜리 공연이면 30 ~ 40 초 소요. 짧은 모니터링 루프에 넣지 말 것.
## 보안·법적 주의
- 본 스킬은 **조회 전용** 이다. 시크릿·로그인 세션·자동 예매·자동 결제·좌석 선택을 일체 포함하지 않는다.
- 공연법 §4조의2 (2023.9.22 시행): 매크로 프로그램을 이용한 입장권 부정구매·판매는 형사처벌 대상. 이 스킬은 의도적으로 그 경로를 막아두었다.
- 등급별 잔여 *수치* 만 인용하고, 좌석 번호·좌석 위치는 노출하지 않는다.
## 참고
- v1 은 비로그인 / 공개 endpoint / 단일 HTTP 호출 범위다.
- 헤더는 `User-Agent` + `Referer` + JSON `Accept` 만 사용한다 (`Cookie`, `Authorization` 없음).
- `httpx` 외 외부 의존성은 없다.

View file

@ -1,23 +1,68 @@
# 토스증권 조회 가이드
토스증권 조회는 두 경로를 제공한다. **공식 Open API(OAuth2)를 우선** 사용하고, 공식 credentials가 없으면 비공식 `tossctl` 을 fallback으로 쓴다. 두 경로 모두 read-only(조회 전용)이며 실거래 mutation은 포함하지 않는다.
## 이 기능으로 할 수 있는 일
- `tossctl` 기반 토스증권 계좌 목록 / 계좌 요약 조회
- 포트폴리오 보유 종목 / 자산 비중 조회
- 단일 종목 / 다중 종목 시세 조회
- 미체결 주문 / 월간 체결 내역 조회
- 관심종목 목록 조회
- 공식 API: 계좌 목록 / 보유 주식 조회
- 공식 API: 시세(현재가·호가·체결·상하한가·캔들) / 종목 정보 / 매수 유의사항
- 공식 API: 환율(KRW↔USD) / 장 운영 캘린더(KR·US)
- 공식 API: 대기중 주문 조회 / 주문 상세 / 매수가능금액 / 판매가능수량 / 수수료
- tossctl fallback: 계좌 요약, 포트폴리오 보유 종목 / 자산 비중, 관심종목, 월간 체결 내역
## 먼저 필요한 것
## 1. 공식 Open API (권장)
- macOS + Homebrew
- `tossctl` 설치
- `tossctl auth login` 으로 브라우저 세션 확보
- `node` 18+
### 먼저 필요한 것
## upstream 설치와 로그인
- 토스증권 OpenAPI 콘솔에서 발급한 `client_id` / `client_secret`
- `node` 18+ (global `fetch`)
이 기능은 `JungHoonGhae/tossinvest-cli``tossctl` 을 그대로 사용한다.
자격 증명은 사용자 환경변수로 두고 helper가 `https://openapi.tossinvest.com` 으로 직접 호출한다. 공유 프록시(k-skill-proxy)로 보내지 않는다.
| 환경변수 | 설명 |
|---|---|
| `TOSSINVEST_CLIENT_ID` | client id (필수) |
| `TOSSINVEST_CLIENT_SECRET` | client secret (필수) |
| `TOSSINVEST_ACCOUNT` | accountSeq. 계좌·자산·주문조회에 필요 (선택) |
| `TOSSINVEST_API_BASE_URL` | 기본 `https://openapi.tossinvest.com` (선택) |
### 동작 방식
helper는 `POST /oauth2/token` 으로 Client Credentials access token을 발급받아 `Authorization: Bearer` 로 호출한다. 계좌·자산·주문조회 API는 `X-Tossinvest-Account` 헤더가 추가로 필요하다. `429``Retry-After` 만큼 대기 후 백오프 재시도하고, `401` 은 토큰을 1회 재발급한다. `client_secret`/토큰은 에러에서 마스킹된다.
### Node.js 예시
```js
const {
getPrices,
listOfficialAccounts,
getHoldings,
getBuyingPower
} = require("toss-securities");
async function main() {
const prices = await getPrices(["005930", "AAPL"]);
const accounts = await listOfficialAccounts();
const accountSeq = accounts.data.result[0].accountSeq;
const holdings = await getHoldings({ account: accountSeq });
const buyingPower = await getBuyingPower({ account: accountSeq, currency: "KRW" });
console.log(prices.data);
console.log(holdings.data);
console.log(buyingPower.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 2. tossctl fallback
이 경로는 `JungHoonGhae/tossinvest-cli``tossctl` 을 그대로 사용한다. 공식 API credentials가 없을 때 쓴다.
```bash
brew tap JungHoonGhae/tossinvest-cli
@ -29,7 +74,7 @@ tossctl auth login
로그인이 끝나기 전에는 계좌/포트폴리오 조회를 시도하지 않는다.
## 지원하는 read-only 명령
지원하는 read-only 명령:
- `tossctl account list --output json`
- `tossctl account summary --output json`
@ -41,43 +86,17 @@ tossctl auth login
- `tossctl orders completed --market all --output json`
- `tossctl watchlist list --output json`
## Node.js 예시
```js
const {
getAccountSummary,
getPortfolioPositions,
getQuote,
listCompletedOrders
} = require("toss-securities");
async function main() {
const summary = await getAccountSummary();
const positions = await getPortfolioPositions();
const quote = await getQuote("TSLA");
const completed = await listCompletedOrders({ market: "all" });
console.log(summary.data);
console.log(positions.data);
console.log(quote.data);
console.log(completed.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
패키지 wrapper(`getAccountSummary`, `getPortfolioPositions`, `getQuote`, `listCompletedOrders`, `listWatchlist` 등)도 동일하게 동작한다.
## 운영 팁
- 계좌 요약과 포트폴리오는 로그인 세션이 있어야만 동작한다.
- `TSLA`, `VOO`, `005930` 같이 심볼을 그대로 넘기면 된다.
- 공식 API는 `TOSSINVEST_CLIENT_ID`/`TOSSINVEST_CLIENT_SECRET` 가 있어야 동작하고, 계좌·자산·주문조회는 `X-Tossinvest-Account`(=`TOSSINVEST_ACCOUNT` 또는 `account` 옵션)가 필요하다.
- `005930`, `AAPL`, `TSLA` 같이 심볼을 그대로 넘기면 된다. 공식 `getPrices`/`getStocks` 는 다건 심볼을 콤마로 연결한다.
- 주문 관련 답변은 **조회 결과만** 정리하고, 실거래로 이어지는 행동은 권하지 않는다.
- 민감한 계좌 정보는 꼭 필요한 값만 답한다.
## 주의할 점
- `tossctl` 은 비공식 CLI 이므로 웹 내부 API 변경에 영향을 받을 수 있다.
- 브라우저 세션이 만료되면 `tossctl auth login` 을 다시 해야 할 수 있다.
- 이 레포의 `toss-securities` 패키지는 read-only wrapper 이며, 거래 mutation 명령은 공개 API에 포함하지 않는다.
- 공식 credentials가 없으면 helper가 `TossCredentialsError` 로 명확히 실패한다.
- `tossctl` 은 비공식 CLI 이므로 웹 내부 API 변경에 영향을 받을 수 있다. 브라우저 세션이 만료되면 `tossctl auth login` 을 다시 해야 할 수 있다.
- 이 레포의 `toss-securities` 패키지는 공식/비공식 모두 read-only 이며, 거래 mutation 명령(주문 생성/정정/취소)은 공개 API에 포함하지 않는다.

View file

@ -47,6 +47,8 @@ npx --yes skills add <owner/repo> \
--skill hwp \
--skill rhwp-edit \
--skill rhwp-advanced \
--skill express-bus-booking \
--skill intercity-bus-booking \
--skill foresttrip-vacancy \
--skill kbo-results \
--skill kbl-results \
@ -64,6 +66,7 @@ npx --yes skills add <owner/repo> \
--skill real-estate-search \
--skill korean-scholarship-search \
--skill korean-stock-search \
--skill daishin-report-search \
--skill household-waste-info \
--skill mfds-drug-safety \
--skill mfds-food-safety \
@ -72,19 +75,22 @@ 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 \
--skill geeknews-search \
--skill daiso-product-search \
--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 \
@ -92,6 +98,7 @@ npx --yes skills add <owner/repo> \
--skill k-schoollunch-menu \
--skill korean-character-count \
--skill court-auction-notice-search \
--skill donation-place-search \
--skill k-skill-cleaner
```
@ -102,6 +109,8 @@ npx --yes skills add <owner/repo> \
--skill k-skill-setup \
--skill srt-booking \
--skill ktx-booking \
--skill express-bus-booking \
--skill intercity-bus-booking \
--skill foresttrip-vacancy \
--skill korean-law-search \
--skill real-estate-search \
@ -112,25 +121,15 @@ npx --yes skills add <owner/repo> \
--skill korean-patent-search \
--skill hipass-receipt \
--skill seoul-subway-arrival \
--skill seoul-density \
--skill seoul-bike \
--skill subway-lost-property \
--skill geeknews-search \
--skill korea-weather \
--skill fine-dust-location
```
`korean-law-search` 는 skill 설치 후 upstream CLI/MCP도 준비해야 한다.
- 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운다.
- remote endpoint는 `LAW_OC` 없이 `url`만 등록한다.
- 기존 `korean-law-mcp` 경로가 실패하면 `법망`(`https://api.beopmang.org`) fallback을 사용한다.
```bash
npm install -g korean-law-mcp
export LAW_OC=your-api-key
korean-law list
```
로컬 설치가 막히면 `https://korean-law-mcp.fly.dev/mcp` remote endpoint를 MCP 클라이언트에 등록한다. 그 경로도 응답하지 않거나 서비스 장애가 나면 `https://api.beopmang.org/mcp` 또는 `https://api.beopmang.org/api/v4/law?action=search` 를 fallback으로 사용한다.
`korean-law-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `LAW_OC` 가 불필요하다. proxy의 `/v1/korean-law/search` · `/v1/korean-law/detail` endpoint가 법제처(국가법령정보센터) 공식 Open API(`open.law.go.kr`)를 감싸며, 설계는 `https://github.com/chrisryugj/korean-law-mcp` 를 참고했다. 운영자만 proxy 서버에 `LAW_OC` 를 채운다(무료 발급: `https://open.law.go.kr`). 자세한 사용법은 [한국 법령 검색 가이드](features/korean-law-search.md)를 본다.
`real-estate-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`. 자세한 사용법은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
@ -196,6 +195,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) 를 그대로 사용한다.
@ -277,7 +310,7 @@ npm run ci
### Node 패키지
```bash
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search gongsijiga-search donation-place-search gangnamunni-clinic-search
export NODE_PATH="$(npm root -g)"
```
@ -286,14 +319,30 @@ HWP Node API 예시는 전역 `NODE_PATH` 대신 로컬 프로젝트에 `npm ins
### macOS 바이너리
카카오톡 Mac CLI는 npm 패키지가 아니라 Homebrew tap 설치를 사용한다.
카카오톡 Mac 아카이브 검색은 npm 패키지가 아니라 `katok` CLI 설치를 사용한다.
```bash
brew install silver-flight-group/tap/kakaocli
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
brew install katok
brew tap JungHoonGhae/tossinvest-cli
brew install tossctl
```
Cargo로 설치할 수도 있다.
```bash
cargo install katok
export PATH="$HOME/.cargo/bin:$PATH"
```
`toss-securities` 스킬은 공식 토스증권 Open API를 우선 사용한다. 공식 경로를 쓰려면 발급받은 자격증명을 사용자 환경변수로 둔다(공유 프록시로 보내지 않고 토스 서버로 직접 호출한다). `tossctl` 설치는 공식 credentials가 없을 때의 fallback 경로용이다.
```bash
export TOSSINVEST_CLIENT_ID=... # 필수
export TOSSINVEST_CLIENT_SECRET=... # 필수
export TOSSINVEST_ACCOUNT=... # 선택, 계좌·자산·주문조회 시 X-Tossinvest-Account
```
### Python 패키지
```bash
@ -319,6 +368,12 @@ python3 scripts/patent_search.py --query "배터리"
python3 scripts/scholarship_filter.py report --input scholarships.json --today 2026-04-14 --only-open-now
```
국가데이터처 KOSIS 통계 조회 helper는 설치된 `kosis-stats` skill 안의 `scripts/run_kosis_stats.py` 를 그대로 쓰면 되고, 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다. 일반 `search`/`meta`/`data`는 기본 hosted proxy를 쓰므로 사용자 KOSIS 키가 필요 없다.
```bash
python3 kosis-stats/scripts/run_kosis_stats.py search --query "1인 가구" --text
```
한국어 맞춤법 검사 helper는 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
```bash
@ -349,6 +404,8 @@ node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profi
- `srt-booking`
- `ktx-booking`
- `seoul-subway-arrival`
- `seoul-density`
- `seoul-bike`
- `korea-weather`
- `fine-dust-location`
- `korean-law-search`

View file

@ -32,12 +32,12 @@
- 근처 가장 싼 주유소 찾기 스킬 출시
- 근처 공중화장실 찾기 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시
- 근처 술집 조회 스킬 출시
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
- 다이소 상품 조회 스킬 출시
- 마켓컬리 상품 조회 스킬 출시
- 올리브영 검색 스킬 출시
- 영화관 검색 스킬 출시
- 올라포케 역삼 포케 스킬 출시
- 쿠팡 상품 검색 스킬 출시 (retention-corp/coupang_partners 로컬 MCP 호환 레이어 기반)
- 번개장터 검색 스킬 출시
@ -45,6 +45,7 @@
- 한국어 맞춤법 검사 스킬 출시
- 한국어 글자 수 세기 스킬 출시
- 긱뉴스 조회 스킬 출시
- 오늘의집 오늘의딜 조회 스킬 출시
## v1.5 candidates
@ -107,9 +108,9 @@
- 장점: 모바일 주민등록증·운전면허증 발급 흐름 정리에 특화할 수 있다
- 이유: 한국 특화성이 강하고 가이드형 스킬로 출발하기 좋다
#### 버스/지하철 도착정보 조회
#### 버스/지하철/따릉이 도착·가용정보 조회
- 장점: 주변 정류소, 지하철, 공항버스, 버스정보 조회까지 출퇴근 수요가 강하다
- 장점: 주변 정류소, 지하철, 공항버스, 버스정보, 따릉이 대여 가능 자전거/빈 거치대까지 출퇴근·라스트마일 수요가 강하다
- 이유: 이미 검증된 반복 조회 패턴이라 확장하기 쉽다
#### 네이버 생활 허브

View file

@ -26,13 +26,19 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
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
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
# Kakao Local geocoding은 hosted proxy 사용. self-host proxy 운영 때만 필요.
KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=
```
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보는 `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
@ -65,12 +71,15 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
- `KSKILL_KTX_PASSWORD`
- `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`
- `KAKAO_REST_API_KEY`
- `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 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 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` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철/한국 날씨 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, 의약품 안전 체크, 식품 안전 체크는 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
`LAW_OC`법제처 Open API(`open.law.go.kr`)를 호출할 때 쓰는 표준 식별자다. 한국 법령 검색은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` 라우트가 `LAW_OC` 와 브라우저 User-Agent/Referer 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `LAW_OC` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. `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`, `LAW_OC` 를 사용할 수 있다. 다만 일반 사용자/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

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 자연휴양림 빈 객실 조회, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소/식약처 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY`·`DATA4LIBRARY_AUTH_KEY`·`FOODSAFETYKOREA_API_KEY` 등은 서버에 설정되어 있어야 한다).
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 자연휴양림 빈 객실 조회, KOSIS `bigdata`/`--direct` 조회용 `KSKILL_KOSIS_API_KEY` (https://kosis.kr/openapi/ 에서 무료 발급), 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소/식약처/KOSIS/Kakao upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다. KOSIS 일반 조회와 Kakao Local geocoding도 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY`·`DATA4LIBRARY_AUTH_KEY`·`FOODSAFETYKOREA_API_KEY`·`KOSIS_API_KEY`·`KAKAO_REST_API_KEY` 등은 서버에 설정되어 있어야 한다).
## Credential resolution order
@ -26,21 +26,25 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
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
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
# Kakao Local geocoding은 hosted proxy 사용. self-host proxy 운영 때만 채운다.
KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=
EOF
chmod 0600 ~/.config/k-skill/secrets.env
```
실제 값을 채운다.
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. KOSIS 일반 조회와 Kakao Local geocoding도 같은 기본 hosted path를 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다.
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다. 다만 기존 `korean-law-mcp` 경로가 서비스 장애로 막히면 `법망`(`https://api.beopmang.org`) MCP/REST를 fallback으로 사용할 수 있다.
한국 법령 검색은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` endpoint를 경유하므로 사용자 쪽 `LAW_OC` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `LAW_OC` 를 채운다(무료 발급: `https://open.law.go.kr`).
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
@ -71,16 +75,19 @@ bash scripts/check-setup.sh
| --- | --- |
| SRT 예매 | `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` |
| KTX 예매 | `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` |
| 고속버스 예매 | 사용자 시크릿 불필요 (조회·좌석 단계는 공식 KOBUS HTTP 흐름 사용, 결제는 공식 페이지에서 수동 진행) |
| 시외버스 예매 | 사용자 시크릿 불필요 (조회·좌석 단계는 공식 티머니 HTTP 흐름 사용, 결제는 공식 페이지에서 수동 진행) |
| 자연휴양림 빈 객실 조회 | `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD` |
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
| 한국 법령 검색 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `LAW_OC`) |
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 한국 특허 정보 검색 | `KIPRIS_PLUS_API_KEY` |
| 하이패스 영수증 발급 | 사용자 시크릿 불필요 (로그인된 브라우저 세션 필요) |
| 한국 주식 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`) |
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 한국 날씨 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 서울 지하철 도착정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
| 서울 실시간 혼잡도 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
| 서울 따릉이 실시간 대여소 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
| 한국 날씨 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KMA_OPEN_API_KEY`) |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 생활쓰레기 배출정보 조회 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host; API 호출 시 `pageNo=1`, `numOfRows=100` 필수) |
@ -88,13 +95,17 @@ 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`) |
## 다음에 볼 문서
- [SRT 예매 가이드](features/srt-booking.md)
- [KTX 예매 가이드](features/ktx-booking.md)
- [고속버스 예매 가이드](features/express-bus-booking.md)
- [시외버스 예매 가이드](features/intercity-bus-booking.md)
- [자연휴양림 빈 객실 조회 가이드](features/foresttrip-vacancy.md)
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
- [서울 실시간 혼잡도 가이드](features/seoul-density.md)
- [한국 날씨 조회 가이드](features/korea-weather.md)
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
- [한강 수위 정보 가이드](features/han-river-water-level.md)
@ -110,6 +121,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

@ -6,6 +6,10 @@
- `SRTrain` / `ryanking13/SRT`: https://github.com/ryanking13/SRT
- `korail2` / `carpedm20/korail2`: https://github.com/carpedm20/korail2
- `korail2` anti-bot bypass PR #54: https://github.com/carpedm20/korail2/pull/54
- 국가데이터처(구 통계청) KOSIS Open API 공식 진입: https://kosis.kr/openapi/ (회원가입·활용신청·개발가이드는 사이트 내부 메뉴 — 직접 deep-link는 SSO/SPA 라우팅으로 빈 화면이 보일 수 있다)
- KOSIS Open API endpoint host: https://kosis.kr/openapi/ — 일반 helper 호출은 `k-skill-proxy``/v1/kosis/search`, `/v1/kosis/meta`, `/v1/kosis/data`가 이 host의 `/statisticsSearch.do`, `/statisticsData.do`, `/Param/statisticsParameterData.do` 로 중계한다. `bigdata`/`--direct``/statisticsBigData.do` 등을 직접 호출한다 (HTTPS 전용, 2026-03-05 시행)
- Kakao Local API endpoint host: https://dapi.kakao.com/v2/local/ — `k-skill-proxy``/v1/kakao-local/geocode``/search/address.json` → empty result 시 `/search/keyword.json` 순서로 중계한다. 같은 host의 `/search/keyword.json`, `/search/category.json`, `/geo/coord2address.json`, `/geo/coord2regioncode.json``kakao-map` 스킬용 `/v1/kakao-map/*` 라우트가 직접 중계한다.
- Kakao Mobility Directions endpoint: https://apis-navi.kakaomobility.com/v1/directions — `k-skill-proxy``/v1/kakao-mobility/directions`가 운영자 `KAKAO_REST_API_KEY``Authorization: KakaoAK ...` 헤더로 주입해 자동차 길찾기를 중계한다.
- 숲나들e 공식 사이트: https://foresttrip.go.kr/index.jsp
- 숲나들e 로그인: https://www.foresttrip.go.kr/com/login.do
- 숲나들e 월별예약조회 화면: https://www.foresttrip.go.kr/rep/or/sssn/monthRsrvtSmplStatus.do
@ -14,6 +18,9 @@
- KBL 일정/결과 API: https://api.kbl.or.kr/match/list
- KBL 팀 순위 API: https://api.kbl.or.kr/league/rank/team
- tossinvest-cli: https://github.com/JungHoonGhae/tossinvest-cli
- 토스증권 공식 Open API 문서: https://developers.tossinvest.com/docs
- 토스증권 공식 Open API OpenAPI JSON (source of truth): https://openapi.tossinvest.com/openapi-docs/latest/openapi.json
- 토스증권 공식 Open API 개요: https://openapi.tossinvest.com/openapi-docs/overview.md — 서버 host `https://openapi.tossinvest.com`. OAuth2 Client Credentials(`POST /oauth2/token`) 토큰으로 호출하며, 계좌·자산·주문 API는 `X-Tossinvest-Account` 헤더가 추가로 필요하다. 사용자별 민감 자격증명이므로 `k-skill-proxy` 가 아니라 사용자 환경에서 직접 호출한다.
- 하이패스 메인: https://www.hipass.co.kr/main.do
- 하이패스 로그인: https://www.hipass.co.kr/comm/lginpg.do
- 하이패스 사용내역 조회 진입: https://www.hipass.co.kr/usepculr/InitUsePculrTabSearch.do
@ -86,9 +93,9 @@
- LH 임대공고문 목록 endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/lhLeaseNoticeInfo1
- LH 임대공고문 상세(공급정보) endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1
- LH 청약 샘플 reference 구현(heereal/Bunyang_MoeumZip): https://github.com/heereal/Bunyang_MoeumZip
- beopmang: https://api.beopmang.org
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
- 법제처 국가법령정보 공동활용 Open API (k-skill-proxy `/v1/korean-law/*` upstream): https://open.law.go.kr — DRF `lawSearch.do` (검색) / `lawService.do` (본문)
- `NomaDamas/katok`: https://github.com/NomaDamas/katok
- `katok` macOS first-run docs: https://github.com/NomaDamas/katok/blob/main/docs/macos-first-run.md
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result
- 동행복권 지난 회차 JSON 표면: https://www.dhlottery.co.kr/lt645/selectPstLt645InfoNew.do
- 바른한글 메인: https://nara-speller.co.kr/speller/
@ -107,8 +114,12 @@
- 다이소몰 상품 검색 요약: 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
- 다이소몰 비로그인 인증: 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>
- 마켓컬리 검색 API(v4): https://api.kurly.com/search/v4/sites/market/normal-search
- 마켓컬리 검색 개수 API(v3): https://api.kurly.com/search/v3/sites/market/normal-search/count
- 마켓컬리 상품 상세 페이지 예시: https://www.kurly.com/goods/5063110
@ -118,17 +129,35 @@
- 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.bluer.co.kr/
- 블루리본 지역 검색: https://www.bluer.co.kr/search/zone
- 블루리본 주변 맛집 JSON: https://www.bluer.co.kr/restaurants/map
- 당근 메인: https://www.daangn.com/
- 당근 지역 검색 API: https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>
- 당근 중고거래 검색 Remix data route: https://www.daangn.com/kr/buy-sell/all/?_data=routes/kr.buy-sell._index
- 당근부동산 검색 Remix data route: https://www.daangn.com/kr/realty/?_data=routes/kr.realty._index
- 당근알바 검색 Remix data route: https://www.daangn.com/kr/jobs/?_data=routes/kr.jobs._index
- 당근중고차 검색 Remix data route: https://www.daangn.com/kr/cars/?_data=routes/kr.cars._index
- 당근부동산 상세 페이지: https://realty.daangn.com/articles/<id>
- 카카오맵 모바일 검색: https://m.map.kakao.com/actions/searchView
- 카카오맵 장소 패널 JSON: https://place-api.map.kakao.com/places/panel3/<confirmId>
- 조선왕조실록 메인: https://sillok.history.go.kr
@ -147,6 +176,8 @@
- 공중화장실정보 전국 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info
- 공중화장실정보 지역별 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 서울 실시간 도시데이터(`citydata_ppltn`): https://data.seoul.go.kr/dataList/OA-21778/A/1/datasetView.do
- 서울 공공자전거 따릉이 실시간 대여정보(`bikeList`) 및 대여소 정보(`tbCycleStationInfo`): https://data.seoul.go.kr
- 경찰청 LOST112 습득물 목록: https://www.lost112.go.kr/find/findList.do
- 서울교통공사 유실물센터: https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541
- GeekNews public RSS/Atom feed: https://feeds.feedburner.com/geeknews-feed
@ -177,3 +208,29 @@
- 도서관 정보나루 도서 상세 endpoint: https://data4library.kr/api/srchDtlList
- 도서관 정보나루 도서 소장 도서관 endpoint: https://data4library.kr/api/libSrchByBook
- 도서관 정보나루 도서관별 도서 소장여부 endpoint: https://data4library.kr/api/bookExist
### 지자체/유관기관 참고 사이트 (보조 소스)
- **서울시 창업플러스**: https://seoulstartup.go.kr
- **경기도 창업진흥원**: https://g-startup.kr
- **부산시 스타트업 허브**: https://busanstartup.kr
- **광주창업파크**: https://startup.gwangju.kr
- **대구창업진흥원**: https://daegu-startup.kr
- **중소기업진흥공단**: https://smbs.or.kr
- **기술보증기금**: https://koreatech.or.kr
- **KOTRA**: https://www.kotra.or.kr
- **중소벤처기업금융공단**: https://www.sbc.or.kr
### 사업자 실사 (biz-health-check 스킬군)
- 국세청 사업자등록정보 진위확인 및 상태조회: https://www.data.go.kr/data/15081808/openapi.do
- 국민연금공단 국민연금 가입 사업장 내역: https://www.data.go.kr/data/3046071/openapi.do
- 국민연금 endpoint(V2): https://apis.data.go.kr/B552015/NpsBplcInfoInqireServiceV2 (getBassInfoSearchV2 / getDetailInfoSearchV2 / getPdAcctoSttusInfoSearchV2, 요청 파라미터 camelCase)
- 금융위원회 기업기본정보: https://www.data.go.kr/data/15043184/openapi.do
- 금융위 기업개요 endpoint: https://apis.data.go.kr/1160100/service/GetCorpBasicInfoService_V2/getCorpOutline_V2
- 조달청 나라장터 사용자정보 서비스(부정당제재업체정보조회 포함): https://www.data.go.kr/data/15129466/openapi.do
- 부정당제재 endpoint: https://apis.data.go.kr/1230000/ao/UsrInfoService02/getUnptRsttCorpInfo02 (inqryDiv=1 사업자번호 정확일치, 조회시점 유효 제재만)
- 국세청 고액·상습체납자 명단공개(무인증): https://www.nts.go.kr/nts/ad/openInfo/selectList.do
- 지방행정 인허가데이터 LOCALDATA 파일 다운로드(무인증, CP949 CSV): https://file.localdata.go.kr/file/download/<업종slug>/info?orgCode=<지자체코드>
- LOCALDATA 본체: https://www.localdata.go.kr
- 잡코리아 기업 인재검색: https://www.jobkorea.co.kr/corp/person/find — 기업회원 로그인 세션에서 마스킹 이력서/목록을 읽는 브라우저 기반 경로. 유료 열람/마스킹 해제/포지션 제안은 수동 확인 대상.
- 사람인 기업회원 인재풀 검색: https://www.saramin.co.kr/zf_user/memcom/talent-pool/main/search — 기업회원 로그인 및 첫 기기 2차 인증 후 현재 보이는 마스킹 후보 정보를 읽는 브라우저 기반 경로. 유료 열람/연락처 확인/제안 발송은 수동 확인 대상.

View file

@ -0,0 +1,139 @@
---
name: donation-place-search
description: Use when the user asks where to donate, 기부처 조회, or donation place recommendations by Korean location and category. Recommend recipients with best-effort 1365 verification-assist links and never execute donations.
license: MIT
metadata:
category: utility
locale: ko-KR
phase: v1
---
# 기부처 조회 / Donation Place Search
## What this skill does
사용자가 “어디에 기부하면 좋을지”, “서울 아동 기부처”, “동물보호 기부처 추천”처럼 묻는 경우 **장소와 카테고리 기준으로 기부처 후보를 추천**한다.
- 기부를 대신 실행하지 않는다.
- 결제, 개인정보 입력, 자동 후원 신청은 하지 않는다.
- 추천은 의사결정 보조이며, 최종 기부 전 공식 페이지와 1365 기부포털에서 최신 등록·모금기간·기부금영수증 가능 여부를 확인한다.
- 위치는 사용자가 제공한 행정구역/동네/랜드마크 텍스트만 사용한다. 자동 위치 추적을 하지 않는다.
## When to use
- “기부처 조회해줘”
- “서울 마포구에서 동물보호 기부할 만한 곳 추천해줘”
- “부산 노인 복지 기부처 알려줘”
- “아동/재난 분야 기부처 비교해줘”
- “어디에 기부하는 곳이 좋을지 장소와 카테고리별로 추천해줘”
## Inputs
- `location`: 선택. 예: `서울 마포구`, `부산 해운대구`, `제주`, `온라인`
- `category`: 선택. 예: `아동`, `동물보호`, `환경`, `재난 구호`, `장애`, `노인`, `생계`, `의료`, `해외구호`
- `limit`: 선택. 기본 5개
위치나 카테고리가 없으면 보수적으로 `전국`·`일반/종합` 후보와 1365 공식 확인 보조 링크를 제공한다. 비대화형 자동화에서는 임의로 좁히지 말고 “입력 없음”을 명시한다.
## Public access path discovered
### Primary official verification surface
- Legacy `https://www.nanumkorea.go.kr/` currently returns a notice that 1365 기부포털 has moved/integrated into 1365 자원봉사.
- The notice links to `https://www.1365.go.kr/dntn/main.do`.
- The skill therefore uses `https://www.1365.go.kr/dntn/main.do` as the official public verification entry point.
### Search-link strategy
1365 pages can be slow or unavailable to headless HTTP clients, so the package does not depend on brittle screen scraping. It builds a best-effort official-entry/search-assist link with the users location/category keywords, then ranks a curated fallback list locally. The package does not assert that 1365 has already verified each returned candidate:
```js
const { recommendDonationPlaces } = require("donation-place-search");
const result = recommendDonationPlaces({
location: "서울 마포구",
category: "동물",
limit: 3
});
console.log(result.items);
console.log(result.officialSearchUrl);
```
The returned `officialSearchUrl` is a best-effort verification assist: open it as an official 1365 entry point, then confirm current registration and campaign status before giving the final answer.
## Workflow
1. Extract `location`, `category`, and optional `limit` from the user request.
2. Run the helper:
```bash
node - <<'NODE'
const {
recommendDonationPlaces,
formatDonationRecommendationReport
} = require("donation-place-search");
const result = recommendDonationPlaces({
location: "서울 마포구",
category: "동물",
limit: 3
});
console.log(formatDonationRecommendationReport(result));
NODE
```
3. Open or cite the returned best-effort 1365 verification-assist URL for latest verification when fresh browsing is available.
4. Summarize 35 candidates, including:
- 기부처명
- 분야/카테고리
- 지역 일치 여부 또는 전국 단위 여부
- 왜 맞는지 한 줄
- 공식 홈페이지
- 1365 확인 보조 링크
5. Add a caution that campaign status, donation receipt eligibility, and designated-use options must be checked on official pages before donating.
## Output fields
The npm helper returns:
```json
{
"location": { "raw": "서울 마포구", "province": "서울", "district": "마포구" },
"category": "animals",
"items": [
{
"name": "동물권행동 카라",
"categories": ["animals"],
"coverage": "local",
"homepageUrl": "https://www.ekara.org/",
"officialSearchUrl": "https://www.1365.go.kr/dntn/main.do?...",
"match": { "category": true, "local": true, "nationwide": false }
}
],
"officialSearchUrl": "https://www.1365.go.kr/dntn/main.do?...",
"meta": { "source": "curated-fallback-plus-1365-search-assist" }
}
```
## Done when
- 장소/카테고리 조건을 반영해 후보를 35개 이내로 정리했다.
- 각 후보마다 공식 홈페이지 또는 1365 확인 보조 링크를 제공했다.
- 최종 기부 전 등록 상태, 모금 기간, 기부금영수증 가능 여부를 확인하라고 안내했다.
- 자동 결제/후원 신청을 시도하지 않았다.
## Failure modes
- 1365 사이트가 느리거나 headless HTTP에서 timeout/empty page를 반환할 수 있다. 이 경우 확인 보조 URL과 후보 홈페이지를 제공하고 “최신 상태는 직접 확인 필요”라고 명시한다.
- 위치 문자열이 행정구역으로 파싱되지 않으면 전국 후보 위주로 제안한다.
- 지역·카테고리 모두 정확히 맞는 후보가 없으면 전국 단위 후보를 fallback으로 보여준다.
- 특정 단체의 모금 캠페인, 지정기부 가능 여부, 기부금영수증 처리는 수시로 바뀌므로 package 내 curated 설명만으로 확정하지 않는다.
- 로그인, 결제, CAPTCHA, 후원 신청서 제출은 자동화하지 않는다.
## Notes
- 이 스킬은 read-only 추천/조회 스킬이다.
- 기부는 금전 의사결정이므로 최신 공식 근거를 우선한다.
- 공개 표면만 사용하므로 `k-skill-proxy`와 API key가 필요 없다.

View file

@ -1,16 +0,0 @@
module.exports = {
apps: [
{
name: "k-skill-proxy",
cwd: __dirname,
script: "./scripts/run-k-skill-proxy.sh",
interpreter: "/bin/bash",
exec_mode: "fork",
autorestart: true,
watch: false,
env: {
NODE_ENV: "production"
}
}
]
};

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

@ -4,7 +4,12 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# 일반 KOSIS 조회는 hosted proxy 사용. bigdata/--direct 때만 채운다.
KSKILL_KOSIS_API_KEY=replace-me
# 한국 법령 검색은 hosted proxy 사용. self-host proxy 운영 때만 채운다.
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
# Kakao Local geocoding은 hosted proxy 사용. self-host proxy 운영 때만 채운다.
KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=

View file

@ -0,0 +1,217 @@
---
name: express-bus-booking
description: Search and assist Korean 고속버스/KOBUS bookings using official HTTP/API-first flows; use for 고속버스 예매, 시간표, 좌석 조회, 임시 선점, and official checkout-entry handoff.
license: MIT
metadata:
category: travel
locale: ko-KR
phase: v1
---
# Express Bus Booking (KOBUS)
## Overview
Use this skill for Korean 고속버스 / KOBUS timetable lookup and reservation assistance. The preferred workflow is HTTP/API-first: resolve routes, query timetables, inspect remaining seats and fares, and only use browser automation when endpoint discovery or an official web-only step blocks progress.
This skill is intentionally separate from 시외버스. KOBUS terminal codes, route structures, and checkout pages differ from Tmoney 시외버스. Use `intercity-bus-booking` for 시외버스/Tmoney flows.
## When to Use
- The user asks for `고속버스 예매`, `고속버스 시간표`, `고속버스 예약`, `프리미엄 고속버스`, or `우등 고속버스`.
- The route is clearly a KOBUS 고속버스 route or the user names KOBUS/코버스/고속버스통합예매.
- The user wants assisted reservation up to seat selection, temporary hold, or a payment-entry link.
Do **not** use this for:
- 시외버스/Tmoney routes — use `intercity-bus-booking`.
- Final card submission or payment without explicit, narrowly scoped confirmation.
- Blind browser automation before trying the official HTTP flow.
## Core Principles
- Use official KOBUS surfaces: `https://www.kobus.co.kr`.
- Keep a cookie jar and reuse referers. Stateless one-off POSTs are less reliable.
- Prefer desktop User-Agent and HTTP/1.1 during probing if the server behaves differently under HTTP/2.
- Default scope is assisted manual checkout: lookup, candidate presentation, seat-stage readiness, temporary seat hold, and official payment-entry page handoff.
- Do not submit card fields, agree to terms, or complete payment unless the user explicitly confirms that exact action.
## Known HTTP Flow
See `references/kobus-http-flow.md` for session-proven endpoint details and parameter examples.
### 1. Start a Session
Fetch either the main page or route/search page with cookies enabled:
```text
GET https://www.kobus.co.kr/main.do
GET https://www.kobus.co.kr/mrs/rotinf.do
```
Use a realistic desktop User-Agent and a cookie jar.
### 2. Resolve Route / Terminal Candidates
```text
POST /mrs/readRotLinInf.ajax
```
The response is JSON and can include `rotInfList`, `tfrInfList`, `len`, and `codeYn`. Prefer code-confirmed routes over display-name matching because terminal names are not always unique.
### 3. Query Timetable
```text
POST /mrs/alcnSrch.do
```
Typical fields include:
```text
deprCd=010
arvlCd=700
pathDvs=sngl
pathStep=1
deprDtm=YYYYMMDD
busClsCd=0
rtrpChc=1
timeLinkMin=00
timeLinkMax=23
```
Parse the returned HTML for schedule rows/cards and `fnSatsChc(...)` onclick arguments. Respect `mrsPsbYn=N` or any equivalent booking-disabled marker.
### 4. Enter Seat / Fare Stage
```text
POST /mrs/satschc.do
```
Send the original search form fields plus selected values from `fnSatsChc(...)`, commonly including:
```text
deprTime
alcnDeprTime
alcnDeprTrmlNo
alcnArvlTrmlNo
indVBusClsCd
cacmCd
dcDvsCd
prvtBbizEmpAcmtRt
chldSftySatsYn
dsprSatsYn
```
The response should include `form#satsChcFrm` and hidden fare/seat values such as `adltFee`, `rmnSatsNum`, `totSatsNum`, and terminal/time fields.
### 5. Temporary Seat Hold
```text
POST /mrs/setPcpy.ajax
```
Submit the `satsChcFrm` hidden fields plus selected seat/count fields. A successful response includes:
```text
MSG_CD=S0000
pcpyNoAll
satsNoAll
ESTM_AMT
DC_AMT
TISSU_AMT
```
Treat the hold as short-lived. If testing, if the user does not proceed, or if a new seat is chosen, release it explicitly.
### 6. Release Temporary Hold
```text
POST /mrs/cancPcpy.ajax
```
Use the same relevant form fields plus the returned `pcpyNoAll` and `satsNoAll`. A successful response returns `MSG_CD=S0000`.
## Helper Script
Use the bundled helper for KOBUS lookup and optional temporary holds:
```bash
python3 express-bus-booking/scripts/kobus_express_booking.py \
--depart-code 021 \
--arrive-code 500 \
--date 20260520 \
--select-index 1 \
--hold-first-seat \
--output-dir /tmp/kobus-hold
```
For Seoul to Gwangju, the verified KOBUS route is `센트럴시티(서울)` code `021` to `광주(유·스퀘어)` code `500`. A successful hold returns `MSG_CD=S0000`, `pcpyNoAll`, `satsNoAll`, fare amounts, and saves a local auto-submit helper for the official KOBUS payment-information page. Final card entry and payment remain manual. Cancel abandoned holds with `/mrs/cancPcpy.ajax` using the saved cancel fields.
## Checkout-Entry Link Helper
A plain official checkout URL is not enough because KOBUS expects a POST body containing the selected schedule, seat, fare, and hold identifiers. The practical user-facing pattern is:
1. Create the temporary hold server-side via `setPcpy.ajax`.
2. Generate a short helper page that auto-submits a POST form to the official KOBUS checkout endpoint.
3. Send the helper link to the user.
4. The user completes card/payment fields manually on the official KOBUS page.
Desktop action:
```text
https://www.kobus.co.kr/mrs/stplcfmpym.do
```
Mobile-friendly action:
```text
https://www.kobus.co.kr/mrs/stplcfmpym.do?keep=/mrs/pay
```
The POST body should include the original seat form fields plus returned hold values such as `pcpyNoAll`, `satsNoAll`, `estmAmt`, `dcAmt`, `tissuAmt`, and `nonMbrsYn=Y` when using a non-member checkout flow.
## Mobile Redirect Caveat
KOBUS common JavaScript can redirect narrow/mobile screens to `/mblIdx.do` unless `location.href` contains a mobile-allowed path fragment such as `/mrs/pay`. In testing, posting to:
```text
/mrs/stplcfmpym.do?keep=/mrs/pay
```
preserved the same checkout POST body while making the final browser URL contain `/mrs/pay`, avoiding the client-side mobile-main redirect condition. Use this variant for Discord/mobile users, but still verify with the user because in-app browsers may add their own quirks.
If mobile still lands on the homepage, recommend opening the helper link in a normal external browser or desktop browser. Do not keep retrying holds indefinitely; cancel stale holds.
## Suggested Output Format
Keep candidate lists concise and actionable:
```text
고속버스 서울경부 → 부산 / 2026-05-09
1. 00:30 심야우등 / 천일고속 / 잔여 10석 / 성인 47,600원
2. 13:50 우등 / ...
```
When a hold/checkout helper is created, state that the next step opens the official KOBUS payment page and that payment remains manual.
For Discord/mobile, provide links as normal text links, not fenced code blocks, so the user can tap them directly.
## Common Pitfalls
1. **Mixing KOBUS and Tmoney codes.** KOBUS terminal codes are not Tmoney 시외버스 terminal codes.
2. **Assuming a cart exists.** KOBUS does not expose a shopping-cart style hold list in the tested web flow. The realistic UX is temporary seat hold plus checkout-entry handoff.
3. **Ignoring hold cleanup.** Always cancel test holds or abandoned holds with `cancPcpy.ajax`.
4. **Treating lookup success as payment permission.** Lookup, seat-stage entry, and temporary hold are not authorization to submit payment.
5. **Mobile homepage redirects.** Use the `?keep=/mrs/pay` helper action for mobile; otherwise KOBUS JS may send the user to `/mblIdx.do`.
6. **Browser automation too early.** Try direct HTTP first. Use browser tooling only to discover changed endpoints or verify final user-facing behavior.
## Verification Checklist
- [ ] Route/terminal codes were resolved from KOBUS, not guessed.
- [ ] Timetable was queried with cookies and a realistic User-Agent.
- [ ] Candidate output includes date, departure/arrival terminals, time, class/operator when available, fare, and remaining seats.
- [ ] Seat-stage response contains expected hidden fields before attempting a hold.
- [ ] Temporary hold success was confirmed with `MSG_CD=S0000` before sending a checkout helper link.
- [ ] Stale/test holds were cancelled with `cancPcpy.ajax`.
- [ ] Payment/card fields were left for the user unless they explicitly confirmed otherwise.
- [ ] Mobile helper links use the `/mrs/pay` marker variant when sent to mobile-heavy platforms.

View file

@ -0,0 +1,159 @@
# KOBUS HTTP/API Probe Notes
Session-proven on 2026-05-08. Goal: avoid browser automation where possible.
## Base
```text
https://www.kobus.co.kr
```
Use a desktop User-Agent, HTTP/1.1 if needed, a cookie jar, and referers.
## Tested Flow
### Route / Terminal Candidates
```text
POST /mrs/readRotLinInf.ajax
```
Observed JSON keys:
```text
tfrLen
tfrInfList
len
codeYn
rotInfList
```
One probe returned about 1,208 route records in `rotInfList`.
### Timetable
```text
POST /mrs/alcnSrch.do
```
Example tested route/date:
```text
서울경부(010) -> 부산(700), 2026-05-09
```
Observed result:
```text
42 schedule links/cards
25 selectable seat snippets
first selectable: 00:30 / 천일고속 / 심야우등 / 10 seats
```
Typical POST fields:
```text
deprCd=010
arvlCd=700
pathDvs=sngl
pathStep=1
deprDtm=YYYYMMDD
busClsCd=0
rtrpChc=1
timeLinkMin=00
timeLinkMax=23
```
Parse `fnSatsChc(...)` onclick values for the next step. Example:
```text
fnSatsChc('20260509','003000','003000','010','700','3','07','0','Y','N','010','700','N','N','N','N')
```
### Seat / Fare Stage
```text
POST /mrs/satschc.do
```
Send the original `alcnSrchFrm` hidden fields plus selected values from `fnSatsChc(...)`, including values such as:
```text
deprTime=003000
alcnDeprTime=003000
alcnDeprTrmlNo=010
alcnArvlTrmlNo=700
indVBusClsCd=3
cacmCd=07
dcDvsCd=0
prvtBbizEmpAcmtRt=N
chldSftySatsYn=N
dsprSatsYn=N
```
Observed response contained `form#satsChcFrm` and hidden values:
```json
{
"deprTime": "003000",
"alcnDeprTrmlNo": "010",
"alcnArvlTrmlNo": "700",
"adltFee": "47600",
"rmnSatsNum": "10",
"totSatsNum": "28"
}
```
### Temporary Hold
```text
POST /mrs/setPcpy.ajax
```
Observed successful response markers:
```text
MSG_CD=S0000
pcpyNoAll
satsNoAll
ESTM_AMT
DC_AMT
TISSU_AMT
```
### Hold Cancellation
```text
POST /mrs/cancPcpy.ajax
```
Observed success marker:
```text
MSG_CD=S0000
```
2026-05-13 서울→광주 re-verification: `센트럴시티(서울)(021) -> 광주(유·스퀘어)(500)`, 2026-05-20 00:45 중앙고속 심야우등, seat 1. `/mrs/setPcpy.ajax` returned `MSG_CD=S0000`, `pcpyNoAll`, `satsNoAll=01`, `TISSU_AMT=36900`; `/mrs/stplcfmpym.do?keep=/mrs/pay` rendered the official payment-information page; `/mrs/cancPcpy.ajax` returned `MSG_CD=S0000`.
### Checkout Entry
```text
POST /mrs/stplcfmpym.do
```
The POST body must include the selected schedule/seat form values plus temporary hold identifiers and fare amounts. A helper page can auto-submit this form to the official KOBUS endpoint.
For mobile browsers, use:
```text
POST /mrs/stplcfmpym.do?keep=/mrs/pay
```
This preserves the POST body while placing `/mrs/pay` in `location.href`, which avoids a KOBUS client-side mobile redirect condition observed in the common JavaScript.
## Interpretation
- Login was not required for route lookup, timetable lookup, seat-selection-page entry, temporary hold, or checkout-entry page display in the tested flow.
- Page HTML can include login or `grecaptchaToken` forms, but these did not block the tested lookup/seat-stage path.
- Final payment should remain a manual, explicitly confirmed stage.
- KOBUS mobile behavior is less stable than desktop because common JavaScript can redirect narrow screens to the mobile main page.

View file

@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""KOBUS timetable lookup and temporary hold helper.
Default mode searches timetables. With --hold-first-seat or --hold-seat it creates
a temporary seat hold through /mrs/setPcpy.ajax and saves a local auto-submit
HTML helper for the official KOBUS payment-information page. It never submits
card fields or final payment.
"""
from __future__ import annotations
import argparse
import html
import http.cookiejar
import json
import re
import ssl
import sys
import tempfile
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
BASE_URL = "https://www.kobus.co.kr"
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/125 Safari/537.36"
FN_SATS_RE = re.compile(r"fnSatsChc\((.*?)\)", re.DOTALL)
ARG_RE = re.compile(r"'([^']*)'")
FORM_RE = re.compile(r"<form\b([^>]*)>(.*?)</form>", re.DOTALL | re.IGNORECASE)
INPUT_RE = re.compile(r"<input\b([^>]+)>", re.DOTALL | re.IGNORECASE)
ATTR_RE = re.compile(r"([\w:-]+)=[\"']([^\"']*)[\"']")
SEAT_RE = re.compile(r'<input\b([^>]*name=["\']seatBoxDtl["\'][^>]*)>', re.DOTALL | re.IGNORECASE)
TAG_RE = re.compile(r"<[^>]+>")
@dataclass
class Schedule:
index: int
departure_time: str | None
company: str | None
bus_class: str | None
remaining_text: str | None
raw_args: list[str]
@dataclass
class Hold:
success: bool
pcpy_no_all: str | None
sats_no_all: str | None
seat: str | None
estm_amt: str | None
dc_amt: str | None
tissu_amt: str | None
checkout_helper_path: str | None
checkout_response_path: str | None
cancel_fields_path: str | None
raw_response: dict[str, object]
def opener() -> urllib.request.OpenerDirector:
jar = http.cookiejar.CookieJar()
ctx = ssl._create_unverified_context()
try:
ctx.set_ciphers("DEFAULT@SECLEVEL=1")
except ssl.SSLError:
pass
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar),
urllib.request.HTTPSHandler(context=ctx),
)
def request(url: str, data: dict[str, str] | list[tuple[str, str]] | None = None, referer: str | None = None) -> urllib.request.Request:
headers = {"User-Agent": UA}
if referer:
headers["Referer"] = referer
if data is None:
return urllib.request.Request(url, headers=headers, method="GET")
headers["Content-Type"] = "application/x-www-form-urlencoded"
return urllib.request.Request(url, data=urllib.parse.urlencode(data).encode(), headers=headers, method="POST")
def open_text(op: urllib.request.OpenerDirector, req: urllib.request.Request, timeout: int) -> str:
with op.open(req, timeout=timeout) as resp:
return resp.read().decode(resp.headers.get_content_charset() or "utf-8", errors="replace")
def attrs(fragment: str) -> dict[str, str]:
return {k.lower(): html.unescape(v) for k, v in ATTR_RE.findall(fragment)}
def strip_tags(s: str) -> str:
return re.sub(r"\s+", " ", html.unescape(TAG_RE.sub(" ", s))).strip()
def parse_form(body: str, form_id: str) -> list[tuple[str, str]]:
for attr_text, form_body in FORM_RE.findall(body):
a = attrs(attr_text)
if a.get("id") == form_id or a.get("name") == form_id:
fields = []
for input_text in INPUT_RE.findall(form_body):
ia = attrs(input_text)
if ia.get("name"):
fields.append((ia["name"], ia.get("value", "")))
return fields
return []
def search(op: urllib.request.OpenerDirector, depart: str, arrive: str, date: str, timeout: int) -> tuple[str, list[Schedule]]:
open_text(op, request(f"{BASE_URL}/main.do"), timeout)
body = open_text(
op,
request(
f"{BASE_URL}/mrs/alcnSrch.do",
{
"deprCd": depart,
"arvlCd": arrive,
"pathDvs": "sngl",
"pathStep": "1",
"deprDtm": date,
"busClsCd": "0",
"rtrpChc": "1",
"timeLinkMin": "00",
"timeLinkMax": "23",
},
f"{BASE_URL}/main.do",
),
timeout,
)
schedules: list[Schedule] = []
for idx, m in enumerate(FN_SATS_RE.finditer(body), 1):
args = ARG_RE.findall(m.group(1))
context = strip_tags(body[max(0, m.start() - 900) : m.start() + 900])
departure = args[1][:2] + ":" + args[1][2:4] if len(args) > 1 and len(args[1]) >= 4 else None
schedules.append(
Schedule(
index=idx,
departure_time=departure,
company=(re.search(r"\((?:주|유)\)[^\s]+|[가-힣]+고속", context) or [None])[0],
bus_class=(re.search(r"심야우등|우등|프리미엄|고속", context) or [None])[0],
remaining_text=(re.search(r"잔여\s*\d+석|\d+\s*/\s*\d+", context) or [None])[0],
raw_args=args,
)
)
return body, schedules
def seat_stage_fields(search_form: list[tuple[str, str]], schedule: Schedule) -> list[tuple[str, str]]:
a = schedule.raw_args
values = dict(search_form)
updates = {
"deprTime": a[1],
"alcnDeprTime": a[2],
"alcnDeprTrmlNo": a[3],
"alcnArvlTrmlNo": a[4],
"indVBusClsCd": a[5],
"cacmCd": a[6],
"dcDvsCd": a[7],
"prvtBbizEmpAcmtRt": a[8],
"chldSftySatsYn": a[12],
"dsprSatsYn": a[13],
}
return [(k, updates.get(k, v)) for k, v in search_form]
def hold(op: urllib.request.OpenerDirector, alcn_body: str, schedule: Schedule, seat: str | None, out: Path, timeout: int) -> Hold:
search_form = parse_form(alcn_body, "alcnSrchFrm")
seat_body = open_text(op, request(f"{BASE_URL}/mrs/satschc.do", seat_stage_fields(search_form, schedule), f"{BASE_URL}/mrs/alcnSrch.do"), timeout)
fields = parse_form(seat_body, "satsChcFrm")
field_map = dict(fields)
seats = []
for input_text in SEAT_RE.findall(seat_body):
a = attrs(input_text)
if "disabled" not in input_text and a.get("value"):
seats.append(a["value"])
selected = seat or (seats[0] if seats else None)
if not selected:
raise RuntimeError("No selectable KOBUS seat found")
def set_field(items: list[tuple[str, str]], key: str, val: str) -> list[tuple[str, str]]:
return [(k, val if k == key else v) for k, v in items]
for key, val in {
"selSeatNum": selected,
"selSeatCnt": "1",
"selAdltCnt": "1",
"selAdltDcCnt": "0",
"prmmDcDvsCd": field_map.get("prmmDcDvsCd") or "0",
}.items():
fields = set_field(fields, key, val)
raw = json.loads(open_text(op, request(f"{BASE_URL}/mrs/setPcpy.ajax", fields, f"{BASE_URL}/mrs/satschc.do"), timeout))
success = raw.get("MSG_CD") == "S0000"
if not success:
return Hold(False, None, None, selected, None, None, None, None, None, None, raw)
for key, val in {
"satsNoAll": str(raw.get("satsNoAll", "")),
"pcpyNoAll": str(raw.get("pcpyNoAll", "")),
"estmAmt": str(raw.get("ESTM_AMT", "")),
"dcAmt": str(raw.get("DC_AMT", "")),
"tissuAmt": str(raw.get("TISSU_AMT", "")),
"nonMbrsYn": "Y",
}.items():
fields = set_field(fields, key, val)
out.mkdir(parents=True, exist_ok=True)
checkout = open_text(op, request(f"{BASE_URL}/mrs/stplcfmpym.do?keep=/mrs/pay", fields, f"{BASE_URL}/mrs/satschc.do"), timeout)
checkout_path = out / "kobus-checkout-response.html"
checkout_path.write_text(checkout)
helper_path = out / "kobus-payment-autosubmit.html"
inputs = "\n".join(f'<input type="hidden" name="{html.escape(k)}" value="{html.escape(v, quote=True)}">' for k, v in fields)
helper_path.write_text(f'<!doctype html><meta charset="utf-8"><p>공식 KOBUS 결제정보 입력 페이지로 이동합니다. 결제는 직접 진행하세요.</p><form id="f" method="post" action="{BASE_URL}/mrs/stplcfmpym.do?keep=/mrs/pay">{inputs}</form><script>document.getElementById("f").submit();</script>')
cancel_path = out / "kobus-cancel-fields.txt"
cancel_path.write_text("\n".join(f"{k}={v}" for k, v in fields))
return Hold(True, str(raw.get("pcpyNoAll")), str(raw.get("satsNoAll")), selected, str(raw.get("ESTM_AMT")), str(raw.get("DC_AMT")), str(raw.get("TISSU_AMT")), str(helper_path), str(checkout_path), str(cancel_path), raw)
def main(argv: Iterable[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--depart-code", required=True)
p.add_argument("--arrive-code", required=True)
p.add_argument("--date", required=True)
p.add_argument("--select-index", type=int, default=1)
p.add_argument("--hold-first-seat", action="store_true")
p.add_argument("--hold-seat")
p.add_argument("--output-dir")
p.add_argument("--limit", type=int, default=20)
p.add_argument("--timeout", type=int, default=20)
args = p.parse_args(argv)
op = opener()
body, schedules = search(op, args.depart_code, args.arrive_code, args.date, args.timeout)
result: dict[str, object] = {"route": {"depart_code": args.depart_code, "arrive_code": args.arrive_code, "date": args.date}, "count": len(schedules), "items": [asdict(s) for s in schedules[: args.limit]]}
if (args.hold_first_seat or args.hold_seat) and schedules:
out = Path(args.output_dir) if args.output_dir else Path(tempfile.mkdtemp(prefix="kobus-hold-"))
result["hold"] = asdict(hold(op, body, schedules[args.select_index - 1], args.hold_seat, out, args.timeout))
result["payment_note"] = "Opened/saved the official KOBUS payment-information page; final card entry/payment remains manual."
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,247 @@
---
name: flight-ticket-search
description: Google Flights 공개 검색 표면을 무료로 조회해 항공권 후보, 예약 검색 링크, 날짜/월/연도별 최저가·평균가 비교를 보수적으로 제공한다.
license: MIT
metadata:
category: travel
locale: ko-KR
phase: v1
---
# Flight Ticket Search
## What this skill does
`fast-flights` 기반으로 Google Flights의 공개 검색 결과를 조회해 항공권 후보를 정리한다. API key, 로그인, 결제, CAPTCHA 우회 없이 무료 공개 표면만 사용한다.
제공 기능:
- 편도/왕복 항공권 검색
- Google Flights 예약 검색 링크 생성
- 상위 후보 가격, 항공사, 출도착 시간, 소요시간, 경유 수 정리
- 날짜 범위, 월별, 연도별 샘플 비교
- 최저가, 평균가, 최고가 및 `low`/`typical`/`high` 가격 band 요약
예약 링크는 특정 판매자 결제 deep link가 아니라 **Google Flights 검색 결과 링크**다. 실제 구매·결제·좌석 선택은 사용자가 브라우저에서 직접 진행해야 한다.
## When to use
- "항공권 조회해줘"
- "인천에서 나리타 다음 달 최저가"
- "6월 ICN-NRT 월별 비교"
- "올해랑 내년 6월 1일 항공권 가격 비교"
- "서울에서 도쿄 왕복 예약 링크 줘"
- "ICN-LAX 비즈니스 가격 대략 비교해줘"
## When not to use
- 실제 예약/결제/취소/좌석지정 자동화
- 로그인 회원가, 카드 할인, 쿠폰, 마일리지 적용가 확정
- CAPTCHA, fingerprint, bot-block 우회
- 스카이스캐너 직접 조회. 현재 `skyscanner.net`은 기본 접속부터 CAPTCHA/403이 걸리므로 안정 skill provider로 쓰지 않는다.
## Required inputs
최소 입력:
- 출발 공항 IATA 코드: `ICN`, `GMP`, `PUS`
- 도착 공항 IATA 코드: `NRT`, `HND`, `LAX`
- 출발일 `YYYY-MM-DD` 또는 비교할 월/범위
선택 입력:
- 왕복 귀국일 `YYYY-MM-DD`
- 성인 수, 기본 1명
- 좌석 등급: `economy`, `premium-economy`, `business`, `first`
- 비교 샘플 방식: 월별 `weekly` 또는 `daily`
사용자가 도시명만 말하면 IATA 코드를 추론하되 애매하면 확인한다. 흔한 기본값은 다음처럼 처리한다.
- 서울/인천 국제선: `ICN`
- 서울 국내선/제주: `GMP` 우선, 사용자가 인천을 말하면 `ICN`
- 도쿄: 나리타 `NRT` 또는 하네다 `HND` 중 사용자가 지정하지 않으면 둘 중 하나를 확인한다.
- 제주: `CJU`
## Helper script
이 skill은 저장소 내 helper를 직접 실행한다.
```bash
python3 flight-ticket-search/scripts/flight_ticket_search.py --help
```
최초 실행 시 `~/.cache/k-skill/flight-ticket-search/venv``fast-flights==2.2`를 설치하고 그 venv로 재실행한다. 저장소에는 의존성 vendoring이나 API key를 넣지 않는다.
## Single search
편도:
```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
```
응답 주요 필드:
- `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`
## Monthly comparison
월별 비교는 지정 월의 날짜들을 실제 검색해 각 날짜의 최저가/평균가를 비교한다.
빠른 기본값은 주 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
```
월별 비교 응답:
- `stats.min_price` — 샘플 날짜 중 최저가
- `stats.avg_of_daily_min` — 날짜별 최저가의 평균
- `stats.max_of_daily_min` — 날짜별 최저가 중 최고값
- `cheapest_dates[]` — 가장 싼 날짜와 예약 검색 링크
- `rows[]` — 날짜별 성공/실패 및 요약
## Custom range comparison
사용자가 "다음주부터 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`은 주별 비교다.
## Year comparison
연도 비교는 같은 월일을 여러 연도에 대해 조회한다.
```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
```
주의: Google Flights가 너무 먼 미래 날짜를 표시하지 않으면 해당 연도는 실패로 기록한다. 실패한 날짜를 숨기지 말고 `failures`에 같이 보고한다.
## Reservation link policy
- `booking_search_url`은 Google Flights 검색 URL이다.
- 특정 항공사/OTA 결제 단계 deep link를 자동 추출하거나 클릭하지 않는다.
- 결제·예약 확정·로그인·여권 정보 입력은 skill 범위 밖이다.
- 사용자가 예약까지 원하면 링크를 열어 직접 확인하도록 안내한다.
## Response style
대표님에게는 짧게 핵심부터 보고한다.
좋은 형식:
```text
ICN → NRT / 2026-06-01 / 성인 1명 / economy
가격 band: typical
최저/평균/최고: ₩129,800 / ₩254,000 / ₩684,400
예약 검색 링크: <url>
1. Jeju Air — 09:45 → 12:15 / 2h30m / 직항 / ₩129,800
2. Air Seoul — 09:20 → 11:50 / 2h30m / 직항 / ₩143,500
3. Air Premia — 08:50 → 11:20 / 2h30m / 직항 / ₩160,800
```
월별 비교:
```text
ICN → NRT / 2026-06 weekly 샘플
최저: 6/1 ₩129,800
샘플 평균: ₩142,300
비싼 날: 6/22 ₩188,000
싼 날짜 TOP 3
1. 2026-06-01 — ₩129,800
2. 2026-06-08 — ₩135,000
3. 2026-06-15 — ₩144,000
```
파싱 누락 후보는 숨기지 말고 이렇게 표시한다.
```text
항공편 상세 확인 불가 — 시간 확인 불가 / 가격 ₩228,700
※ Google Flights 응답에서 항공사·시간 파싱이 일부 누락됐습니다.
```
## Failure modes
- Google Flights HTML/프론트엔드 구조 변경으로 항공사명·시간 파싱이 비거나 깨질 수 있다.
- 일부 노선은 가격만 나오고 항공편 상세가 `partial`로 떨어질 수 있다.
- 잘못된 IATA 코드, 동일 출도착 공항, 실제 항공편이 없는 구간은 실패한다.
- 너무 먼 미래 날짜는 upstream에서 결과가 없을 수 있다.
- 비교 기능은 날짜별 실시간 조회라 요청 수가 많다. daily 월별 비교는 30회 안팎의 요청이 발생한다.
- `fast-flights` fallback이 외부 fetch helper를 쓰는 경우 401 `no token provided`가 날 수 있다. 동일 입력의 실사용성이 낮은 케이스면 사전 validation으로 막고, 정상 노선이면 잠시 후 재시도한다.
## Verified discovery notes
2026-05-10 로컬 프로브 기준:
- Skyscanner home/API: CAPTCHA/403 blocked로 직접 provider 부적합.
- Kiwi Tequila API: 무료 계정 API key 필요. 기본 무료/no-key 경로는 아님.
- Google Flights + `fast-flights==2.2`: 국내선/일본/동남아/미국/유럽/호주/남미 일부 성공.
- 추가 테스트 성공: `ICN-CJU`, `ICN-NRT`, `ICN-PVG`, `ICN-SIN`, `ICN-BKK`, `ICN-DXB`, `ICN-LAX`, `ICN-JFK`, `ICN-LHR`, `ICN-CDG`, `ICN-FRA`, `ICN-HKG`, `ICN-TPE`, `ICN-SYD`, `ICN-GRU`, `ICN↔NRT`, `GMP↔CJU`, business, 성인 2명.
- 정상 실패/차단 대상: `GMP-ICN`, `ICN-ICN`, invalid airport code.
## Done when
- 출발/도착/날짜/좌석/인원 조건을 확인했다.
- 단일 검색이면 상위 후보와 예약 검색 링크를 제공했다.
- 비교 검색이면 샘플 방식과 최저/평균/최고, 싼 날짜 TOP을 제공했다.
- 가격은 조회 시점 기준이며 실제 결제가는 달라질 수 있음을 표시했다.
- 로그인/결제/CAPTCHA 우회는 하지 않았다.

View file

@ -0,0 +1,501 @@
#!/usr/bin/env python3
"""Free flight ticket search helper for the k-skill flight-ticket-search skill.
Uses fast-flights (Google Flights public surface scraper) in an isolated user cache venv.
No API key, login, CAPTCHA bypass, purchase, or booking automation.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import statistics
import shutil
import subprocess
import sys
import time
from dataclasses import asdict
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urlencode
PINNED_FAST_FLIGHTS = "fast-flights==2.2"
CACHE_ROOT = Path.home() / ".cache" / "k-skill" / "flight-ticket-search"
VENV_DIR = CACHE_ROOT / "venv"
def ensure_runtime() -> None:
"""Install fast-flights into a private cache venv, then re-exec there."""
if os.environ.get("FLIGHT_TICKET_SEARCH_BOOTSTRAPPED") == "1":
return
py = VENV_DIR / "bin" / "python"
def candidate_python_executables() -> list[str]:
candidates = [sys.executable]
candidates.extend(
found for name in ("python3.13", "python3.12", "python3.11", "python3")
if (found := shutil.which(name))
)
seen: set[str] = set()
unique: list[str] = []
for candidate in candidates:
resolved = str(Path(candidate).resolve())
if resolved not in seen:
seen.add(resolved)
unique.append(candidate)
return unique
def create_venv() -> None:
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
errors: list[str] = []
for python in candidate_python_executables():
shutil.rmtree(VENV_DIR, ignore_errors=True)
try:
subprocess.check_call([python, "-m", "venv", str(VENV_DIR)])
return
except (OSError, subprocess.CalledProcessError) as exc:
errors.append(f"{python}: {exc}")
raise RuntimeError(
"Unable to create flight-ticket-search venv with available Python interpreters: "
+ "; ".join(errors)
)
def venv_has_fast_flights() -> bool:
if not py.exists():
return False
return subprocess.run(
[str(py), "-c", "import fast_flights"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
if not py.exists():
create_venv()
if not venv_has_fast_flights():
try:
subprocess.check_call([str(py), "-m", "ensurepip", "--upgrade"])
subprocess.check_call([
str(py),
"-m",
"pip",
"install",
"--disable-pip-version-check",
"-q",
PINNED_FAST_FLIGHTS,
])
except (OSError, subprocess.CalledProcessError):
# Recover from interrupted or pip-less cache venvs before surfacing a hard failure.
shutil.rmtree(VENV_DIR, ignore_errors=True)
create_venv()
subprocess.check_call([str(py), "-m", "ensurepip", "--upgrade"])
subprocess.check_call([
str(py),
"-m",
"pip",
"install",
"--disable-pip-version-check",
"-q",
PINNED_FAST_FLIGHTS,
])
env = os.environ.copy()
env["FLIGHT_TICKET_SEARCH_BOOTSTRAPPED"] = "1"
os.execve(str(py), [str(py), __file__, *sys.argv[1:]], env)
def parse_date(s: str) -> date:
return datetime.strptime(s, "%Y-%m-%d").date()
def positive_int(value: str) -> int:
parsed = int(value)
if parsed < 1:
raise argparse.ArgumentTypeError("must be a positive integer")
return parsed
def nonnegative_int(value: str) -> int:
parsed = int(value)
if parsed < 0:
raise argparse.ArgumentTypeError("must be zero or a positive integer")
return parsed
def nonnegative_float(value: str) -> float:
parsed = float(value)
if parsed < 0:
raise argparse.ArgumentTypeError("must be zero or a positive number")
return parsed
def iter_dates(start: date, end: date, step_days: int) -> Iterable[date]:
d = start
while d <= end:
yield d
d += timedelta(days=step_days)
def parse_price(price_text: str | None) -> int | None:
if not price_text or "unavailable" in price_text.lower():
return None
digits = re.sub(r"[^0-9]", "", price_text)
return int(digits) if digits else None
def money_krw(value: int | float | None) -> str:
if value is None:
return "확인 불가"
return f"{int(round(value)):,}"
def validate_airport(code: str, field: str) -> str:
code = code.strip().upper()
if not re.fullmatch(r"[A-Z]{3}", code):
raise SystemExit(f"{field} must be a 3-letter IATA airport code, got: {code!r}")
return code
def build_query_url(flight_data: list[Any], trip: str, adults: int, seat: str) -> str:
from fast_flights.flights_impl import Passengers, TFSData
tfs = TFSData.from_interface(
flight_data=flight_data,
trip=trip,
passengers=Passengers(adults=adults),
seat=seat,
).as_b64().decode("utf-8")
params = {"tfs": tfs, "hl": "en", "tfu": "EgQIABABIgA", "curr": "KRW"}
return "https://www.google.com/travel/flights?" + urlencode(params)
def make_flight_data(from_airport: str, to_airport: str, outbound: str, return_date: str | None = None) -> tuple[list[Any], str]:
origin = validate_airport(from_airport, "from")
dest = validate_airport(to_airport, "to")
if origin == dest:
raise SystemExit("from and to airports must be different")
outbound_date = parse_date(outbound)
from fast_flights import FlightData
data = [FlightData(date=outbound_date.isoformat(), from_airport=origin, to_airport=dest)]
if return_date:
inbound_date = parse_date(return_date)
if inbound_date < outbound_date:
raise SystemExit("return-date must be on or after date")
data.append(FlightData(date=inbound_date.isoformat(), from_airport=dest, to_airport=origin))
return data, "round-trip"
return data, "one-way"
def fetch_flights(flight_data: list[Any], trip: str, adults: int, seat: str) -> Any:
from fast_flights import Passengers, get_flights
return get_flights(
flight_data=flight_data,
trip=trip,
passengers=Passengers(adults=adults),
seat=seat,
fetch_mode="fallback",
)
def normalize_flight(f: Any) -> dict[str, Any]:
raw = asdict(f)
price_value = parse_price(raw.get("price"))
raw["price_value"] = price_value
raw["price_text"] = money_krw(price_value) if price_value is not None else raw.get("price") or "확인 불가"
raw["quality"] = "complete" if raw.get("name") and raw.get("departure") and raw.get("arrival") else "partial"
return raw
def summarize_result(res: Any, query_url: str, limit: int) -> dict[str, Any]:
flights = [normalize_flight(f) for f in res.flights]
priced = [f for f in flights if f["price_value"] is not None]
complete = [f for f in priced if f["quality"] == "complete"]
best_pool = complete or priced
best_pool = sorted(best_pool, key=lambda x: x["price_value"] if x["price_value"] is not None else 10**18)
values = [f["price_value"] for f in priced if f["price_value"] is not None]
return {
"meta": {
"provider": "google-flights-fast-flights",
"source": "Google Flights public search surface via fast-flights",
"price_band": getattr(res, "current_price", ""),
"currency": "KRW",
"queried_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"booking_search_url": query_url,
"note": "예약 링크는 특정 판매자 결제 deep link가 아니라 Google Flights 검색 결과 링크입니다.",
},
"stats": {
"result_count": len(flights),
"priced_count": len(priced),
"complete_count": len(complete),
"min_price": min(values) if values else None,
"avg_price": statistics.mean(values) if values else None,
"max_price": max(values) if values else None,
},
"flights": best_pool[:limit],
}
def validate_date_text(value: str, field: str) -> date:
try:
return parse_date(value)
except ValueError as exc:
raise SystemExit(f"{field} must be YYYY-MM-DD, got: {value!r}") from exc
def validate_month_text(value: str) -> None:
try:
datetime.strptime(value + "-01", "%Y-%m-%d")
except ValueError as exc:
raise SystemExit(f"month must be YYYY-MM, got: {value!r}") from exc
def validate_month_day_text(value: str) -> None:
try:
datetime.strptime("2000-" + value, "%Y-%m-%d")
except ValueError as exc:
raise SystemExit(f"month-day must be MM-DD, got: {value!r}") from exc
def preflight_validate_args(args: argparse.Namespace) -> None:
validate_airport(args.from_airport, "from")
validate_airport(args.to_airport, "to")
if args.from_airport.strip().upper() == args.to_airport.strip().upper():
raise SystemExit("from and to airports must be different")
if args.command == "search":
outbound = validate_date_text(args.date, "date")
if args.return_date:
inbound = validate_date_text(args.return_date, "return-date")
if inbound < outbound:
raise SystemExit("return-date must be on or after date")
elif args.command == "compare-month":
validate_month_text(args.month)
elif args.command == "compare-range":
start = validate_date_text(args.start_date, "start-date")
end = validate_date_text(args.end_date, "end-date")
if end < start:
raise SystemExit("end-date must be on or after start-date")
elif args.command == "compare-years":
validate_month_day_text(args.month_day)
try:
years = [int(x) for x in re.split(r"[, ]+", args.years.strip()) if x]
except ValueError as exc:
raise SystemExit("years must be comma-separated numbers, e.g. 2026,2027") from exc
if not years:
raise SystemExit("years is required, e.g. 2026,2027")
def command_search(args: argparse.Namespace) -> dict[str, Any]:
data, trip = make_flight_data(args.from_airport, args.to_airport, args.date, args.return_date)
res = fetch_flights(data, trip, args.adults, args.seat)
url = build_query_url(data, trip, args.adults, args.seat)
out = summarize_result(res, url, args.limit)
out["query"] = {
"from": args.from_airport.upper(),
"to": args.to_airport.upper(),
"date": args.date,
"return_date": args.return_date,
"trip": trip,
"adults": args.adults,
"seat": args.seat,
}
return out
def scan_dates(args: argparse.Namespace, dates: list[date]) -> dict[str, Any]:
rows: list[dict[str, Any]] = []
for idx, d in enumerate(dates):
try:
data, trip = make_flight_data(args.from_airport, args.to_airport, d.isoformat(), None)
res = fetch_flights(data, trip, args.adults, args.seat)
url = build_query_url(data, trip, args.adults, args.seat)
summary = summarize_result(res, url, args.limit)
rows.append({
"date": d.isoformat(),
"ok": True,
"min_price": summary["stats"]["min_price"],
"avg_price": summary["stats"]["avg_price"],
"priced_count": summary["stats"]["priced_count"],
"price_band": summary["meta"]["price_band"],
"top": summary["flights"][: min(3, args.limit)],
"booking_search_url": url,
})
except Exception as e: # keep scans robust
rows.append({"date": d.isoformat(), "ok": False, "error": f"{type(e).__name__}: {str(e)[:300]}"})
if idx != len(dates) - 1 and args.sleep > 0:
time.sleep(args.sleep)
prices = [r["min_price"] for r in rows if r.get("ok") and r.get("min_price") is not None]
ok_rows = [r for r in rows if r.get("ok")]
cheapest = sorted((r for r in ok_rows if r.get("min_price") is not None), key=lambda r: r["min_price"])[: args.limit]
return {
"meta": {
"provider": "google-flights-fast-flights",
"currency": "KRW",
"queried_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"sampled_dates": len(rows),
"successful_dates": len(ok_rows),
"note": "월/연도 비교는 지정 날짜들을 실제 조회해 산출한 샘플 기반 비교입니다. Google Flights 가격은 수시 변동됩니다.",
},
"query": {
"from": args.from_airport.upper(),
"to": args.to_airport.upper(),
"adults": args.adults,
"seat": args.seat,
},
"stats": {
"min_price": min(prices) if prices else None,
"avg_of_daily_min": statistics.mean(prices) if prices else None,
"max_of_daily_min": max(prices) if prices else None,
},
"cheapest_dates": cheapest,
"rows": rows,
}
def command_compare_month(args: argparse.Namespace) -> dict[str, Any]:
month_start = datetime.strptime(args.month + "-01", "%Y-%m-%d").date()
if month_start.month == 12:
month_end = date(month_start.year + 1, 1, 1) - timedelta(days=1)
else:
month_end = date(month_start.year, month_start.month + 1, 1) - timedelta(days=1)
step = 1 if args.sample == "daily" else 7
dates = list(iter_dates(month_start, month_end, step))
if args.max_dates:
dates = dates[: args.max_dates]
out = scan_dates(args, dates)
out["query"]["month"] = args.month
out["query"]["sample"] = args.sample
return out
def command_compare_range(args: argparse.Namespace) -> dict[str, Any]:
start = parse_date(args.start_date)
end = parse_date(args.end_date)
if end < start:
raise SystemExit("end-date must be on or after start-date")
step = args.step_days
dates = list(iter_dates(start, end, step))
if args.max_dates:
dates = dates[: args.max_dates]
out = scan_dates(args, dates)
out["query"]["start_date"] = args.start_date
out["query"]["end_date"] = args.end_date
out["query"]["step_days"] = step
return out
def command_compare_years(args: argparse.Namespace) -> dict[str, Any]:
years = [int(x) for x in re.split(r"[, ]+", args.years.strip()) if x]
if not years:
raise SystemExit("years is required, e.g. 2026,2027")
mmdd = args.month_day
dates = [datetime.strptime(f"{year}-{mmdd}", "%Y-%m-%d").date() for year in years]
out = scan_dates(args, dates)
out["query"]["years"] = years
out["query"]["month_day"] = mmdd
return out
def print_markdown(payload: dict[str, Any]) -> None:
meta = payload.get("meta", {})
query = payload.get("query", {})
stats = payload.get("stats", {})
print(f"provider: {meta.get('provider')}")
print(f"queried_at: {meta.get('queried_at')}")
print(f"query: {json.dumps(query, ensure_ascii=False)}")
print()
if "flights" in payload:
print(f"price_band: {meta.get('price_band')}")
print(f"min/avg/max: {money_krw(stats.get('min_price'))} / {money_krw(stats.get('avg_price'))} / {money_krw(stats.get('max_price'))}")
print(f"booking_search_url: {meta.get('booking_search_url')}")
print("\nflights:")
for i, f in enumerate(payload.get("flights", []), 1):
print(f"{i}. {f.get('name') or '항공편 상세 확인 불가'} | {f.get('departure') or '시간 확인 불가'} -> {f.get('arrival') or '시간 확인 불가'} | {f.get('duration') or '소요시간 확인 불가'} | stops={f.get('stops')} | {f.get('price_text')}")
else:
print(f"sampled/success: {meta.get('sampled_dates')} / {meta.get('successful_dates')}")
print(f"min / avg(daily min) / max(daily min): {money_krw(stats.get('min_price'))} / {money_krw(stats.get('avg_of_daily_min'))} / {money_krw(stats.get('max_of_daily_min'))}")
print("\ncheapest_dates:")
for i, r in enumerate(payload.get("cheapest_dates", []), 1):
print(f"{i}. {r.get('date')} | min={money_krw(r.get('min_price'))} | avg={money_krw(r.get('avg_price'))} | band={r.get('price_band')} | {r.get('booking_search_url')}")
failures = [r for r in payload.get("rows", []) if not r.get("ok")]
if failures:
print("\nfailures:")
for r in failures[:5]:
print(f"- {r.get('date')}: {r.get('error')}")
if meta.get("note"):
print(f"\nnote: {meta.get('note')}")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Free Google Flights-based flight search and comparison helper")
sub = p.add_subparsers(dest="command", required=True)
def add_common(sp: argparse.ArgumentParser) -> None:
sp.add_argument("--from", dest="from_airport", required=True, help="IATA origin airport, e.g. ICN")
sp.add_argument("--to", dest="to_airport", required=True, help="IATA destination airport, e.g. NRT")
sp.add_argument("--adults", type=positive_int, default=1)
sp.add_argument("--seat", choices=["economy", "premium-economy", "business", "first"], default="economy")
sp.add_argument("--limit", type=positive_int, default=5)
sp.add_argument("--sleep", type=nonnegative_float, default=1.5, help="seconds between comparison queries")
sp.add_argument("--format", choices=["json", "markdown"], default="markdown")
s = sub.add_parser("search", help="single one-way or round-trip search")
add_common(s)
s.add_argument("--date", required=True, help="YYYY-MM-DD outbound date")
s.add_argument("--return-date", help="YYYY-MM-DD return date for round trip")
s.set_defaults(func=command_search)
m = sub.add_parser("compare-month", help="sample a whole month and rank cheapest dates")
add_common(m)
m.add_argument("--month", required=True, help="YYYY-MM")
m.add_argument("--sample", choices=["weekly", "daily"], default="weekly")
m.add_argument("--max-dates", type=nonnegative_int, default=0, help="cap dates for quick tests; 0 means no cap")
m.set_defaults(func=command_compare_month)
r = sub.add_parser("compare-range", help="compare a custom date range")
add_common(r)
r.add_argument("--start-date", required=True)
r.add_argument("--end-date", required=True)
r.add_argument("--step-days", type=positive_int, default=7)
r.add_argument("--max-dates", type=nonnegative_int, default=0)
r.set_defaults(func=command_compare_range)
y = sub.add_parser("compare-years", help="compare the same month-day across years")
add_common(y)
y.add_argument("--years", required=True, help="comma separated years, e.g. 2026,2027")
y.add_argument("--month-day", required=True, help="MM-DD, e.g. 06-01")
y.add_argument("--max-dates", type=nonnegative_int, default=0)
y.set_defaults(func=command_compare_years)
return p
def main() -> None:
parser = build_parser()
args = parser.parse_args()
preflight_validate_args(args)
ensure_runtime()
if getattr(args, "max_dates", 0) == 0:
args.max_dates = None
payload = args.func(args)
if args.format == "json":
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print_markdown(payload)
if __name__ == "__main__":
main()

View file

@ -100,6 +100,8 @@ python3 -m playwright install chromium
2026-04-29 확인 기준, 로그인 없이 월별예약조회 화면에 접근하면 `401 Unauthorized`가 반환되고, 조회 endpoint는 JSON 대신 안내 HTML을 반환한다. 따라서 현재 helper는 로그인 세션/CSRF 확보를 필수 전제로 둔다.
API는 `srchDate` 단일 일자만 요청해도 응답에 5일 윈도우를 포함할 수 있다. helper는 요청 범위(`today``last_day`) 밖 `useDt` 행을 자동 제거하므로 사용자에게는 요청한 날짜의 빈자리만 노출된다.
전체 자연휴양림에서 특정 날짜 조회:
```bash
@ -138,6 +140,8 @@ python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates
결과가 없으면 "조회 시점 기준 예약 가능 객실 없음"이라고 말한다. 실제 예약 가능 여부는 숲나들e 화면에서 재확인될 수 있음을 덧붙인다.
`goodsNm`에 "예비"가 포함된 객실은 운영자가 보유하는 내부용 자리로, 사용자 예약 화면에는 노출되지 않는다. helper는 이 객실들을 결과에서 자동 제외한다. 같은 `(휴양림, 날짜, 객실명)` 조합의 중복 행도 dedup된다.
## Done when
- 요청 날짜와 조회 범위가 명확하다.
@ -152,6 +156,8 @@ python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates
- Playwright browser 미설치: `python3 -m playwright install chromium`
- fetch failure 일부 발생: 결과와 실패 개수를 함께 보고하고, 필요하면 `--refresh-session` 으로 1회 재조회
- 숲나들e 표면 변경: helper의 login/session bootstrap 또는 parser 점검 필요
- "(예비)" 객실이 결과에 안 나옴: 정상 동작이다. 사용자 예약 화면에 노출되지 않는 운영자 보유분이라 의도적으로 제외된다.
- 사용자 화면 객실 수와 helper 결과가 다름: 같은 객실의 중복 행이 dedup되었거나, 요청 범위 밖 `useDt`가 제거됐을 가능성이 높다. raw API 응답을 확인하려면 helper 로직을 우회해서 직접 호출 필요.
## Maintainer review notes

View file

@ -30,6 +30,7 @@ DEFAULT_CONCURRENCY = 4
MAX_CONCURRENCY = 5
DEFAULT_WEEK_RANGE = 1
CATEGORY_CODES = {"01", "02"}
RESERVE_ROOM_MARKER = "예비"
@dataclass
@ -159,8 +160,8 @@ def check_dependencies(*, launch_browser: bool = True) -> None:
if sys.version_info < (3, 9):
raise SystemExit("python 3.9+ is required")
try:
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
from playwright.sync_api import Error as PlaywrightError # type: ignore[reportMissingImports]
from playwright.sync_api import sync_playwright # type: ignore[reportMissingImports]
except ImportError as exc:
raise SystemExit(
"playwright is required. Install with: python3 -m pip install playwright"
@ -209,8 +210,8 @@ def save_session_cache(path: Path, session: Session) -> None:
def bootstrap_session(*, forest_id: str, forest_pw: str, ttl_sec: int = 600) -> Session:
try:
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
from playwright.sync_api import Error as PlaywrightError # type: ignore[reportMissingImports]
from playwright.sync_api import sync_playwright # type: ignore[reportMissingImports]
except ImportError as exc:
raise SystemExit(
"playwright is required. Install with: python3 -m pip install playwright "
@ -379,7 +380,18 @@ def fetch_one(
def is_available(row: dict[str, Any]) -> bool:
return row.get("rsrvtAvail") == "Y" and row.get("rsrvtCnt") == 0
count_value = row.get("rsrvtCnt")
if count_value is None:
return False
try:
reserved_count = int(count_value)
except ValueError:
return False
return row.get("rsrvtAvail") == "Y" and reserved_count == 0
def is_reserve_room(row: dict[str, Any]) -> bool:
return RESERVE_ROOM_MARKER in (row.get("goodsNm") or "")
def normalize_row(row: dict[str, Any], forests: dict[str, str]) -> dict[str, Any]:
@ -443,11 +455,27 @@ def collect_results(
for row in data:
if not is_available(row):
continue
if is_reserve_room(row):
continue
use_dt = row.get("useDt") or ""
if use_dt < today or use_dt > last_day:
continue
normalized = normalize_row(row, session.forests)
normalized["source_category"] = category
if date_filter is not None and normalized["use_dt"] not in date_filter:
continue
rows.append(normalized)
seen: set[tuple[str, str, str, str]] = set()
deduped: list[dict[str, Any]] = []
for row in rows:
key = (row["forest_id"], row["use_dt"], row["source_category"], row["name"])
if key in seen:
continue
seen.add(key)
deduped.append(row)
rows = deduped
grouped: dict[str, dict[str, list[dict[str, Any]]]] = {}
for row in sorted(rows, key=lambda item: (item["forest"], item["use_dt"], item["name"])):
grouped.setdefault(row["forest"], {}).setdefault(row["use_dt"], []).append(row)

View file

View file

@ -0,0 +1,167 @@
[
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "동백1",
"goodsId": "GID-A1",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "동백1",
"goodsId": "GID-A2",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "해송2",
"goodsId": "GID-B",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "고로쇠1",
"goodsId": "GID-C",
"insttArea": "60㎡",
"mxmmAccptCnt": "10",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "(예비) 201호",
"goodsId": "GID-RES1",
"insttArea": "32㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260514",
"dywkDtTpcd": "목",
"goodsNm": "동백3",
"goodsId": "GID-D",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "동백1",
"goodsId": "GID-A1",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 1
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "해송2",
"goodsId": "GID-B",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 1
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "(예비) 201호",
"goodsId": "GID-RES1",
"insttArea": "32㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260517",
"dywkDtTpcd": "일",
"goodsNm": "중산막2",
"goodsId": "GID-E",
"insttArea": "32㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260517",
"dywkDtTpcd": "일",
"goodsNm": "동백3",
"goodsId": "GID-F",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
}
]

View file

@ -0,0 +1,62 @@
[
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "206호 쑥부쟁이방",
"goodsId": "GID-G1",
"insttArea": "37㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "201호 배꽃방(예비용)",
"goodsId": "GID-G2",
"insttArea": "30㎡",
"mxmmAccptCnt": "6",
"goodsClsscNm": "숲속휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "편백나무2호(예비용)",
"goodsId": "GID-G3",
"insttArea": "28㎡",
"mxmmAccptCnt": "6",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "은행나무방(예비용)",
"goodsId": "GID-G4",
"insttArea": "22㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "트리하우스",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
}
]

View file

@ -0,0 +1,348 @@
import importlib.util
import io
import json
import sys
import unittest
from contextlib import redirect_stdout
from datetime import datetime
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
HELPER_PATH = SCRIPT_DIR.parent / "scripts" / "run_foresttrip_vacancy.py"
FIXTURES_DIR = SCRIPT_DIR / "fixtures"
def load_helper():
spec = importlib.util.spec_from_file_location("run_foresttrip_vacancy", HELPER_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load helper from {HELPER_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules["run_foresttrip_vacancy"] = module
spec.loader.exec_module(module)
return module
helper = load_helper()
def load_fixture(name):
return json.loads((FIXTURES_DIR / name).read_text(encoding="utf-8"))
GEOJE_ROWS = load_fixture("geoje_window.json")
GUJAEBONG_ROWS = load_fixture("gujaebong_window.json")
GEOJE_FOREST_ID = "ID02030059"
GEOJE_FOREST_NAME = "[공립](거제시)거제자연휴양림"
GUJAEBONG_FOREST_ID = "ID02030072"
GUJAEBONG_FOREST_NAME = "[공립](하동군)구재봉자연휴양림"
FIXED_NOW = datetime(2026, 5, 12, 0, 0, 0)
def make_session(forests):
return helper.Session(
cookies={},
csrf="dummy-csrf",
user_agent="test-ua",
forests=forests,
expires_at=FIXED_NOW.timestamp() + 3600,
)
def stub_fetch(rows):
def _stub(*, forest_id, category, **_):
matched = [r for r in rows if r.get("insttId") == forest_id]
return forest_id, category, matched, None
return _stub
def run_collect(session, targets, rows, *, dates=None, week_range=None, categories=("01",)):
with mock.patch.object(helper, "fetch_one", side_effect=stub_fetch(rows)):
with mock.patch.object(helper, "datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = FIXED_NOW
return helper.collect_results(
session=session,
targets=targets,
categories=categories,
dates=tuple(dates) if dates else None,
week_range=week_range,
concurrency=1,
)
class IsReserveRoomTest(unittest.TestCase):
def test_parens_with_suffix(self):
self.assertTrue(helper.is_reserve_room({"goodsNm": "201호 배꽃방(예비용)"}))
def test_parens_prefix(self):
self.assertTrue(helper.is_reserve_room({"goodsNm": "(예비) 201호"}))
def test_predicate_with_simple_suffix(self):
self.assertTrue(helper.is_reserve_room({"goodsNm": "편백나무2호(예비용)"}))
def test_normal_room_passes(self):
self.assertFalse(helper.is_reserve_room({"goodsNm": "동백1"}))
def test_empty_name(self):
self.assertFalse(helper.is_reserve_room({"goodsNm": ""}))
def test_missing_name_key(self):
self.assertFalse(helper.is_reserve_room({}))
class IsAvailableTest(unittest.TestCase):
def test_y_and_zero_count(self):
self.assertTrue(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": 0}))
def test_y_and_string_zero_count(self):
self.assertTrue(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": "0"}))
def test_y_but_already_booked(self):
self.assertFalse(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": 1}))
def test_y_but_string_booked_count(self):
self.assertFalse(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": "1"}))
def test_not_available(self):
self.assertFalse(helper.is_available({"rsrvtAvail": "N", "rsrvtCnt": 0}))
class CollectResultsFilterTest(unittest.TestCase):
def setUp(self):
self.session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
self.targets = {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}
def test_geoje_5_13_three_unique_rooms_after_dedup_and_reserve_filter(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
self.assertEqual(payload["filter_hits"], 3)
names = {
room["name"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
}
self.assertEqual(names, {"동백1", "해송2", "고로쇠1"})
def test_geoje_5_16_returns_zero_when_only_reserved_or_booked(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260516"])
self.assertEqual(payload["filter_hits"], 0)
self.assertEqual(payload["results"], [])
def test_geoje_5_17_two_rooms(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260517"])
self.assertEqual(payload["filter_hits"], 2)
names = {
room["name"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
}
self.assertEqual(names, {"중산막2", "동백3"})
def test_dates_outside_request_filtered_out(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
observed_dates = {
room["use_dt"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
}
self.assertEqual(observed_dates, {"20260513"})
def test_reserve_rooms_excluded_across_all_dates(self):
payload = run_collect(
self.session, self.targets, GEOJE_ROWS,
dates=["20260513", "20260516", "20260517"],
)
for forest in payload["results"]:
for date in forest["dates"]:
for room in date["rooms"]:
self.assertNotIn("예비", room["name"])
def test_dedup_collapses_duplicate_room_with_different_goods_id(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
donbaek_count = sum(
1
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
if room["name"] == "동백1"
)
self.assertEqual(donbaek_count, 1)
def test_dedup_keeps_same_room_name_from_distinct_categories(self):
rows_by_category = {
"01": [
{
"insttId": GEOJE_FOREST_ID,
"insttNm": GEOJE_FOREST_NAME,
"useDt": "20260513",
"goodsNm": "같은이름",
"goodsClsscNm": "숙박",
"rsrvtAvail": "Y",
"rsrvtCnt": 0,
}
],
"02": [
{
"insttId": GEOJE_FOREST_ID,
"insttNm": GEOJE_FOREST_NAME,
"useDt": "20260513",
"goodsNm": "같은이름",
"goodsClsscNm": "야영",
"rsrvtAvail": "Y",
"rsrvtCnt": 0,
}
],
}
def fetch_category(*, forest_id, category, **_):
return forest_id, category, rows_by_category[category], None
with mock.patch.object(helper, "fetch_one", side_effect=fetch_category):
with mock.patch.object(helper, "datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = FIXED_NOW
payload = helper.collect_results(
session=self.session,
targets=self.targets,
categories=("01", "02"),
dates=("20260513",),
week_range=None,
concurrency=1,
)
self.assertEqual(payload["filter_hits"], 2)
observed = [
(room["name"], room["category"])
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
]
self.assertEqual(observed, [("같은이름", "숙박"), ("같은이름", "야영")])
class StrictUseDtGateTest(unittest.TestCase):
"""Bug 1 regression: API returns 5-day window even when single-day requested."""
def test_useDt_before_today_blocked_even_if_available(self):
past_row = dict(GEOJE_ROWS[0])
past_row["useDt"] = "20260101"
rows = [past_row]
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, rows,
week_range=1,
)
self.assertEqual(payload["filter_hits"], 0)
def test_useDt_after_last_day_blocked(self):
far_future = dict(GEOJE_ROWS[0])
far_future["useDt"] = "20300101"
rows = [far_future]
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, rows,
week_range=1,
)
self.assertEqual(payload["filter_hits"], 0)
class PrintTextTest(unittest.TestCase):
"""print_text is the user-facing output path — guard against format regressions."""
def test_renders_forest_and_rooms(self):
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, GEOJE_ROWS,
dates=["20260513"],
)
buffer = io.StringIO()
with redirect_stdout(buffer):
helper.print_text(payload)
output = buffer.getvalue()
self.assertIn(GEOJE_FOREST_NAME, output)
self.assertIn("20260513", output)
self.assertIn("slot(s)", output)
self.assertIn("동백1", output)
def test_empty_results_message(self):
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, GEOJE_ROWS,
dates=["20260516"],
)
buffer = io.StringIO()
with redirect_stdout(buffer):
helper.print_text(payload)
self.assertIn("(no available rooms at lookup time)", buffer.getvalue())
class MainOutputTest(unittest.TestCase):
def run_main(self, argv, payload):
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
buffer = io.StringIO()
with mock.patch.object(sys, "argv", ["run_foresttrip_vacancy.py", *argv]):
with mock.patch.object(helper, "get_session", return_value=session):
with mock.patch.object(helper, "resolve_targets", return_value={GEOJE_FOREST_ID: GEOJE_FOREST_NAME}):
with mock.patch.object(helper, "collect_results", return_value=payload):
with redirect_stdout(buffer):
exit_code = helper.main()
return exit_code, buffer.getvalue()
def test_main_text_output_returns_success_when_fetches_succeed(self):
payload = run_collect(
make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME}),
{GEOJE_FOREST_ID: GEOJE_FOREST_NAME},
GEOJE_ROWS,
dates=["20260513"],
)
exit_code, output = self.run_main(["--forest-id", GEOJE_FOREST_ID, "--text"], payload)
self.assertEqual(exit_code, 0)
self.assertIn("ForestTrip Vacancy Lookup", output)
self.assertIn("filter_hits: 3", output)
self.assertIn("동백1", output)
def test_main_json_output_reports_failure_and_returns_nonzero(self):
payload = {
"forests_scanned": 1,
"filter_hits": 0,
"fetch_failures": 1,
"failures": [{"forest_id": GEOJE_FOREST_ID, "category": "01", "error": "http_401"}],
"concurrency": 1,
"date_range": {"from": "20260512", "to": "20260513"},
"results": [],
}
exit_code, output = self.run_main(["--forest-id", GEOJE_FOREST_ID, "--json"], payload)
self.assertEqual(exit_code, 1)
rendered = json.loads(output)
self.assertEqual(rendered["fetch_failures"], 1)
self.assertEqual(rendered["failures"][0]["error"], "http_401")
class GroundTruthTest(unittest.TestCase):
"""Anchored to user-verified counts from foresttrip.go.kr on 2026-05-12.
Fixtures are simplified; tests assert the per-(forest, date) shape matches."""
def test_gujaebong_5_16_one_room_named_쑥부쟁이방(self):
session = make_session({GUJAEBONG_FOREST_ID: GUJAEBONG_FOREST_NAME})
payload = run_collect(
session, {GUJAEBONG_FOREST_ID: GUJAEBONG_FOREST_NAME}, GUJAEBONG_ROWS,
dates=["20260516"],
)
self.assertEqual(payload["filter_hits"], 1)
names = [
room["name"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
]
self.assertEqual(names, ["206호 쑥부쟁이방"])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,67 @@
---
name: fsc-corporate-info
description: 금융위원회 기업기본정보(법인 개요)를 공공데이터포털 API(k-skill-proxy 경유)로 조회한다. 법인명으로 대표자·설립일·업종 등 법인 개요를 확인하고, 응답에 사업자번호가 있으면 입력 번호와 교차검증한다.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 금융위 기업기본정보(법인 개요) 조회
## What this skill does
공공데이터포털의 **금융위원회_기업기본정보 서비스**(data.go.kr 15043184, `getCorpOutline_V2`)를 `k-skill-proxy` 경유로 호출해 법인 개요를 조회한다.
- 법인명(`corpNm`) 기준 후보 목록: 대표자·설립일·업종 등 upstream 필드 원문
- 사업자번호 교차검증: 응답 item에 `bzno`가 있으면 입력 사업자번호와 정확 일치하는 후보를 분리한다 (`bzno`가 없으면 교차검증 불가 사실을 그대로 표기)
이 API의 검색 파라미터는 `crno`(법인등록번호 13자리)/`corpNm`(법인명)뿐이라 **사업자번호 단독 조회가 불가**하다. 법인명으로 조회한다.
## Design principles
- 점수·등급·해석 라벨을 만들지 않는다. upstream 사실 + 출처만 담는다.
- `crno`(법인등록번호)는 사업자등록번호와 별개 번호임을 혼동하지 않는다.
## When to use
- "이 법인 대표자·설립일·업종 개요 확인해줘"
- "법인명으로 기업 기본정보 조회해줘"
## Prerequisites
- 인터넷 연결, `python3`
- `scripts/fsc_corporate_info.py` helper
- hosted/self-host `k-skill-proxy``/v1/fsc/corp-outline` route 접근 가능
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host 프록시를 쓸 때만 설정. 비우면 hosted `https://k-skill-proxy.nomadamas.org` 사용.
- `DATA_GO_KR_API_KEY` 는 프록시 운영 서버 환경에만 둔다. 공공데이터포털에서 `금융위원회_기업기본정보` 활용신청이 되어 있어야 한다.
## Inputs
- `--name`: 법인명(`corpNm`) — 필수
- `--b-no`: 사업자등록번호. 응답에 `bzno`가 있을 때 교차검증에만 쓰인다.
## CLI examples
```bash
python3 fsc-corporate-info/scripts/fsc_corporate_info.py \
--name "삼성전자" --b-no 124-81-00998
```
## Failure modes
- `400 bad_request`: 법인명을 주지 않음.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 없음.
- `502 upstream_forbidden`: 프록시 키가 15043184에 활용신청되지 않음.
- 빈 결과: 법인명 불일치 — 표기를 바꿔 재시도.
## Official surfaces
- 공공데이터포털: <https://www.data.go.kr/data/15043184/openapi.do>
- upstream: `https://apis.data.go.kr/1160100/service/GetCorpBasicInfoService_V2/getCorpOutline_V2`
- 프록시 route: `GET /v1/fsc/corp-outline`

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