Prepare k-skill for packaged releases and broader skill discovery

This snapshots the current repository updates as a coherent release-prep
baseline: workspace/package scaffolding, release automation docs and
workflows, refreshed skill/setup documentation, roadmap expansion, and
the README thumbnail polish.

Constraint: Node packages in this repo must use npm workspaces and Changesets for releases
Constraint: Python release automation stays scaffold-only until a real package exists
Rejected: Split the current work into multiple commits | user asked to commit the current changes together
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep release docs, workflows, and package metadata aligned when adding future packages
Tested: npm run ci
Not-tested: GitHub Actions execution on remote after push
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-03-25 23:57:53 +09:00
commit 720964cf49
40 changed files with 2456 additions and 68 deletions

View file

@ -0,0 +1,5 @@
---
"k-lotto": minor
---
Add the initial official dhlottery-backed k-lotto package.

11
.changeset/config.json Normal file
View file

@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": false,
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View file

@ -0,0 +1,3 @@
{
"packages": {}
}

View file

@ -0,0 +1 @@
{}

21
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run ci

45
.github/workflows/release-npm.yml vendored Normal file
View file

@ -0,0 +1,45 @@
name: Release npm packages
on:
push:
branches:
- main
paths:
- ".changeset/**"
- ".github/workflows/release-npm.yml"
- "package-lock.json"
- "package.json"
- "packages/**"
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm run ci
- name: Create npm release PR or publish changed packages
uses: changesets/action@v1
with:
version: npm run version-packages
publish: npm run release:npm
commit: "chore: version packages"
title: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: "true"

41
.github/workflows/release-python.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: Release Python packages
on:
push:
branches:
- main
paths:
- ".github/release-please/**"
- ".github/workflows/release-python.yml"
- "python-packages/**"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
scaffold-only:
if: ${{ hashFiles('python-packages/**/pyproject.toml') == '' }}
runs-on: ubuntu-latest
steps:
- run: echo "No Python package exists yet. release-please remains scaffold-only."
release:
if: ${{ hashFiles('python-packages/**/pyproject.toml') != '' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: release
uses: googleapis/release-please-action@v4
with:
config-file: .github/release-please/python-config.json
manifest-file: .github/release-please/python-manifest.json
- name: Reminder
if: ${{ steps.release.outputs.releases_created == 'true' }}
run: |
echo "Python package release metadata was created."
echo "Wire package-specific build/publish steps here when the first python package is added."

18
AGENTS.md Normal file
View file

@ -0,0 +1,18 @@
# k-skill repository instructions
This repository inherits the broader oh-my-codex guidance from the parent environment.
These rules are repo-specific and apply to everything under this directory.
## Release automation rules
- Node packages live under `packages/*` and use npm workspaces.
- Node package releases use **Changesets**. Do not hand-edit package versions only to cut a release; add a `.changeset/*.md` file instead.
- npm publish is automated from GitHub Actions and should happen only after the bot-generated **Version Packages** PR is merged into `main`.
- Python packages live under `python-packages/*` and use **release-please**. Until a real Python package exists, keep the Python release workflow as scaffold-only.
- PyPI publish should run only when release-please reports `release_created=true` for a concrete package path.
- Prefer trusted publishing via OIDC for npm and PyPI. Do not introduce long-lived registry tokens unless trusted publishing is unavailable.
## Verification rules
- For release or packaging changes, run `npm run ci`.
- Keep release docs, workflow files, and package metadata aligned in the same change.

View file

@ -1,5 +1,7 @@
# k-skill
![k-skill thumbnail](docs/assets/k-skill-thumbnail.png)
한국 서비스와 한국 생활 맥락에 맞춰 바로 쓸 수 있는 에이전트 스킬 모음집입니다.
## 어떤 걸 할 수 있나
@ -16,8 +18,9 @@
1. [설치 방법](docs/install.md)부터 보고 필요한 스킬만 설치합니다.
2. SRT/KTX/서울 지하철처럼 인증이 필요한 기능을 쓸 거라면 [공통 설정 가이드](docs/setup.md)를 먼저 따라갑니다.
3. 시크릿을 안전하게 관리하려면 [보안/시크릿 정책](docs/security-and-secrets.md)을 확인합니다.
4. 각 기능 문서를 열어 입력값, 예시, 제한사항을 확인합니다.
3. 시크릿이 비어 있으면 값을 채팅에 붙여 넣지 말고, [공통 설정 가이드](docs/setup.md)와 [보안/시크릿 정책](docs/security-and-secrets.md)에 따라 로컬에 안전하게 등록합니다.
4. Node/Python 패키지가 없으면 먼저 전역 설치를 기본으로 진행합니다.
5. 각 기능 문서를 열어 입력값, 예시, 제한사항을 확인합니다.
## 문서
@ -26,6 +29,7 @@
| [설치 방법](docs/install.md) | 패키지 설치, 선택 설치, 로컬 테스트 방법 |
| [공통 설정 가이드](docs/setup.md) | `sops + age` 설치, age key 생성, 공통 secrets 파일 준비 |
| [보안/시크릿 정책](docs/security-and-secrets.md) | 인증 정보 저장 원칙, 금지 패턴, 표준 환경변수 이름 |
| [릴리스/배포 가이드](docs/releasing.md) | npm Changesets, Python release-please, trusted publishing 운영 규칙 |
| [로드맵](docs/roadmap.md) | 현재 포함 기능과 다음 후보 |
| [출처/참고 표면](docs/sources.md) | 설계 시 참고한 공개 라이브러리와 공식 문서 |
@ -36,5 +40,6 @@
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [로또 당첨 확인](docs/features/lotto-results.md)
- [릴리스/배포 가이드](docs/releasing.md)
인증이 필요한 기능은 모두 [공통 설정 가이드](docs/setup.md)를 먼저 보는 것을 기준으로 합니다.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View file

@ -9,7 +9,7 @@
## 먼저 필요한 것
- Node.js 18+
- `npm install kbo-game`
- `npm install -g kbo-game`
## 입력값
@ -18,15 +18,22 @@
## 기본 흐름
1. 날짜 기준으로 경기 데이터를 조회합니다.
2. 홈팀/원정팀, 경기 상태, 스코어를 사람 읽기 좋은 형태로 정리합니다.
3. 팀 요청이 있으면 해당 팀 경기만 남깁니다.
1. 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치합니다.
2. 날짜 기준으로 경기 데이터를 조회합니다.
3. 홈팀/원정팀, 경기 상태, 스코어를 사람 읽기 좋은 형태로 정리합니다.
4. 팀 요청이 있으면 해당 팀 경기만 남깁니다.
## 예시
```bash
node --input-type=module - <<'JS'
import { getGame } from "kbo-game";
GLOBAL_NPM_ROOT="$(npm root -g)" node --input-type=module - <<'JS'
import path from "node:path";
import { pathToFileURL } from "node:url";
const entry = pathToFileURL(
path.join(process.env.GLOBAL_NPM_ROOT, "kbo-game", "dist", "index.js"),
).href;
const { getGame } = await import(entry);
const date = "2026-03-25";
const games = await getGame(new Date(`${date}T00:00:00+09:00`));

View file

@ -11,7 +11,7 @@
## 먼저 필요한 것
- Python 3.10+
- `python -m pip install korail2`
- `python3 -m pip install korail2`
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
@ -31,16 +31,18 @@
## 기본 흐름
1. 먼저 열차를 조회합니다.
2. 후보 열차의 출발/도착 시각, KTX 여부, 좌석 여부, 가격을 보여줍니다.
3. 대상 열차가 명확할 때만 예약합니다.
4. 예약 확인/취소는 대상 예약을 다시 식별한 뒤 진행합니다.
1. `korail2` 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치합니다.
2. `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` 가 없으면 채팅에 붙여 넣게 하지 말고 로컬 secrets 등록 절차를 안내합니다.
3. 먼저 열차를 조회합니다.
4. 후보 열차의 출발/도착 시각, KTX 여부, 좌석 여부, 가격을 보여줍니다.
5. 대상 열차가 명확할 때만 예약합니다.
6. 예약 확인/취소는 대상 예약을 다시 식별한 뒤 진행합니다.
## 예시
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python - <<'"'"'PY'"'"'
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python3 - <<'"'"'PY'"'"'
import os
from korail2 import Korail, TrainType

View file

@ -10,7 +10,9 @@
## 먼저 필요한 것
- Node.js 18+
- `npm install korean-lotto`
- 배포 후: `npm install -g k-lotto`
- 실행 전: `export NODE_PATH="$(npm root -g)"`
- 이 저장소에서 개발할 때: 루트에서 `npm install`
## 입력값
@ -19,20 +21,21 @@
## 기본 흐름
1. 최신 회차 또는 요청 회차를 확인합니다.
2. 당첨번호와 추첨일, 당첨금 분포를 요약합니다.
3. 사용자 번호가 있으면 일치 번호와 등수를 계산합니다.
1. 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치합니다.
2. 최신 회차 또는 요청 회차를 확인합니다.
3. 당첨번호와 추첨일, 당첨금 분포를 요약합니다.
4. 사용자 번호가 있으면 일치 번호와 등수를 계산합니다.
## 예시
```bash
node - <<'JS'
const lotto = require("korean-lotto");
lotto.getDetailResult(861).then((result) => console.log(JSON.stringify(result, null, 2)));
NODE_PATH="$(npm root -g)" node - <<'JS'
const lotto = require("k-lotto");
lotto.getDetailResult(1216).then((result) => console.log(JSON.stringify(result, null, 2)));
JS
```
## 주의할 점
- 사용자 번호는 영구 저장하지 않는 것을 기준으로 합니다.
- 최신 회차 반영은 upstream 사정에 따라 늦을 수 있습니다.
- 최신 회차는 결과 페이지 HTML에서 읽고, 상세 결과는 공식 JSON 응답을 사용합니다.

View file

@ -23,9 +23,10 @@
## 기본 흐름
1. API key가 안전하게 주입되는지 확인합니다.
2. 역명 기준으로 실시간 도착정보를 조회합니다.
3. 호선, 진행 방향, 도착 메시지, 조회 시점을 함께 요약합니다.
1. `SEOUL_OPEN_API_KEY` 가 없으면 채팅에 붙여 넣게 하지 말고 로컬 secrets 등록 절차를 안내합니다.
2. API key가 안전하게 주입되는지 확인합니다.
3. 역명 기준으로 실시간 도착정보를 조회합니다.
4. 호선, 진행 방향, 도착 메시지, 조회 시점을 함께 요약합니다.
## 예시

View file

@ -11,7 +11,7 @@
## 먼저 필요한 것
- Python 3.10+
- `python -m pip install SRTrain`
- `python3 -m pip install SRTrain`
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
@ -31,16 +31,18 @@
## 기본 흐름
1. 먼저 열차를 조회합니다.
2. 후보 열차의 출발/도착 시각, 좌석 여부, 운임을 보여줍니다.
3. 대상 열차가 명확할 때만 예약합니다.
4. 예약 확인/취소는 예약을 다시 식별한 뒤 진행합니다.
1. `SRTrain` 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치합니다.
2. `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` 가 없으면 채팅에 붙여 넣게 하지 말고 로컬 secrets 등록 절차를 안내합니다.
3. 먼저 열차를 조회합니다.
4. 후보 열차의 출발/도착 시각, 좌석 여부, 운임을 보여줍니다.
5. 대상 열차가 명확할 때만 예약합니다.
6. 예약 확인/취소는 예약을 다시 식별한 뒤 진행합니다.
## 예시
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python - <<'"'"'PY'"'"'
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python3 - <<'"'"'PY'"'"'
import os
from SRT import SRT

View file

@ -53,6 +53,32 @@ npx --yes skills add <owner/repo> \
npx --yes skills add . --list
```
유지보수자가 패키지/릴리스 설정까지 같이 검증하려면:
```bash
npm install
npm run ci
```
## 패키지가 없을 때의 기본 동작
스킬 실행에 필요한 Node/Python 패키지가 없으면 다른 방법으로 우회하지 말고 전역 설치를 먼저 시도하는 것을 기본으로 합니다.
### Node 패키지
```bash
npm install -g kbo-game k-lotto
export NODE_PATH="$(npm root -g)"
```
### Python 패키지
```bash
python3 -m pip install SRTrain korail2
```
운영체제 정책이나 권한 때문에 전역 설치가 막히면, 임의의 대체 구현으로 넘어가지 말고 그 차단 사유를 사용자에게 설명한 뒤 다음 설치 단계를 정합니다.
## npx도 없으면
`npx`, `pnpm dlx`, `bunx` 중 아무것도 없으면 먼저 Node.js 계열 런타임을 설치해야 한다.

41
docs/releasing.md Normal file
View file

@ -0,0 +1,41 @@
# 릴리스와 자동 배포
이 저장소는 **npm은 Changesets**, **Python은 release-please**로 관리한다.
## Node / npm 패키지
- 위치: `packages/*`
- 버전 관리: Changesets
- 배포 workflow 파일: `.github/workflows/release-npm.yml`
- 실제 publish 시점: **Version Packages PR을 merge한 뒤 `main`에 push가 발생했을 때**
- 기본 규칙: 패키지 버전을 직접 손으로 올리지 말고 `.changeset/*.md` 파일을 추가한다.
### 흐름
1. 기능 PR에서 `.changeset/*.md` 추가
2. PR merge
3. Changesets가 Version Packages PR 생성
4. Version Packages PR merge
5. GitHub Actions가 변경된 npm 패키지만 publish
## Python 패키지
- 위치: `python-packages/*`
- 버전 관리: release-please
- 배포 workflow 파일: `.github/workflows/release-python.yml`
- 실제 publish 시점: **release-please가 `release_created=true`를 만든 run**
- 현재 상태: 실제 Python 패키지가 없어 scaffold only
## Trusted publishing 원칙
- npm과 PyPI 모두 OIDC trusted publishing을 우선 사용한다.
- 장기 토큰(`NPM_TOKEN`, `PYPI_API_TOKEN`)은 fallback이 아니면 만들지 않는다.
- npm trusted publisher 등록 시 workflow filename은 `release-npm.yml`이다.
- PyPI trusted publisher 등록 시 workflow filename은 `release-python.yml`이다.
## Maintainer 확인 명령
```bash
npm install
npm run ci
```

View file

@ -12,27 +12,113 @@
## v1.5 candidates
### 네이버 스마트스토어
### 진짜 우선순위 높은 후보
#### 정부24 조회/발급/신청
- 장점: 등본·초본, 전입신고, 자동차등록원부, 건축물대장, 토지대장, 각종 사실확인, 보조금24까지 한 축으로 묶을 수 있다
- 이유: “한국 생활 운영체제”에 가장 가까운 범용 허브 후보다
#### 홈택스/손택스 도우미
- 장점: 세금 납부, 종합소득세·양도소득세 신고 보조, 연말정산 계산, 각종 세무 증명까지 커버할 수 있다
- 이유: 사용 빈도는 낮아도 필요할 때 고통이 커서 만족도가 높다
#### 토스 생활금융 스킬
- 장점: 계좌·카드 모아보기, 송금, 사기계좌 조회, 자동이체 예약, 신용점수 확인, 세금 납부, 등초본 발급까지 이어질 수 있다
- 이유: 금융 + 생활 민원을 한곳에서 묶는 허브 포지션이 좋다
#### 카카오페이 송금/결제/청구서
- 장점: 송금, 결제, 멤버십, 자산관리, 청구서 등 생활 결제 액션이 넓다
- 이유: 카톡과 연결되는 체감 가치가 커서 대중성이 높다
#### 카카오 T 이동 허브
- 장점: 택시, 대리, 주차, 기차, 시외버스, 퀵·택배까지 이동 관련 수요를 넓게 묶을 수 있다
- 이유: 이동/귀가/주차/예약을 하나의 생활 축으로 만들기 좋다
#### 네이버지도 / TMAP 길찾기·장소저장·교통
- 장점: 길찾기에서 출발해 장소 저장, 리뷰, 추천, 대중교통, 주차, 대리까지 확장 폭이 넓다
- 이유: 네이버 생활 서비스와 TMAP 이동 서비스 모두로 확장 가능한 교통 베이스다
#### 배달의민족 / 요기요 주문
- 장점: 음식배달뿐 아니라 장보기·쇼핑·선물까지 붙여서 즉시 생활구매 축으로 키울 수 있다
- 이유: “배달”보다 넓은 실사용 구매 액션으로 이어진다
#### 병원 접수/예약 스킬
- 장점: 사전 접수, 주변 병원 찾기, 지금 문 연 병원 찾기 흐름이 명확하다
- 이유: 실사용 가치가 높고 특히 부모층 체감이 크다
#### 택배 조회/예약
- 장점: 배송조회, 일반 택배예약, 국제특송조회까지 범용 작업 빈도가 높다
- 이유: 한국 생활에서 예상보다 자주 반복되는 작업이다
#### 미세먼지/황사/대기질 알림
- 장점: 오늘/내일/모레 대기정보와 예보, 하루 4회 수준의 예보 갱신 같은 한국형 수요에 잘 맞는다
- 이유: 한국 로컬 생활 스킬로 차별화가 쉽다
### 그다음으로 좋은 후보
#### 모바일 신분증 발급/재발급/분실 가이드
- 장점: 모바일 주민등록증·운전면허증 발급 흐름 정리에 특화할 수 있다
- 이유: 한국 특화성이 강하고 가이드형 스킬로 출발하기 좋다
#### 버스/지하철 도착정보 조회
- 장점: 주변 정류소, 지하철, 공항버스, 버스정보 조회까지 출퇴근 수요가 강하다
- 이유: 이미 검증된 반복 조회 패턴이라 확장하기 쉽다
#### 네이버 생활 허브
- 장점: 날씨, 뉴스, 스포츠, 네이버페이, 가격비교, 배송, 지도, QR, 전자증명서까지 한 축으로 확장 가능하다
- 이유: 네이버 한 축만 잘 잡아도 생활 플랫폼 허브가 된다
#### 공과금/청구서 납부 정리
- 장점: 카카오페이/토스와 연결해 전기·가스·통신·카드 청구서 조회/납부/알림으로 묶을 수 있다
- 이유: 생활 결제 자동화의 실용성이 높다
#### 네이버페이/포인트/가격비교
- 장점: 결제/포인트/배송/가격비교를 묶는 쇼핑형 허브 후보가 된다
- 이유: 스마트스토어와 별도의 큰 축으로 키울 수 있다
#### 한국 기상청 날씨/특보
- 장점: 국내 날씨, 특보, 단기 예보를 한국형 생활 정보로 직접 연결하기 좋다
- 이유: 미세먼지 스킬과 함께 묶었을 때 생활 밀착도가 더 올라간다
### 기존 탐색 후보
#### 네이버 스마트스토어
- 장점: 실제 수요가 크다
- 보류 이유: 공식 Commerce API auth/setup이 가볍지 않다
### 다나와 가격 비교
#### 다나와 가격 비교
- 장점: 검색 수요가 명확하다
- 보류 이유: 안정적인 공개 CLI를 아직 못 찾았다
### 카카오톡 조회/전송
#### 카카오톡 조회/전송
- 장점: 어그로가 매우 강하다
- 보류 이유: 계정/정책 리스크가 크다
### HWP 문서 편집
#### HWP 문서 편집
- 장점: 한국 로컬리티가 매우 강하다
- 보류 이유: 믿을 만한 자동화 표면이 아직 얇다
### 당근 자동 거래
#### 당근 자동 거래
- 장점: 바이럴 포텐셜이 높다
- 보류 이유: 계정 제재 리스크와 UI automation 의존도가 높다

View file

@ -2,6 +2,25 @@
`k-skill`은 인증이 필요한 스킬에서 비밀번호나 토큰을 채팅창에 직접 붙여 넣는 방식을 허용하지 않는다. 기본 원칙은 "비밀값은 암호화된 파일로 보관하고, 런타임에만 주입"이다.
## Missing secret handling policy
인증이 필요한 스킬에서 필요한 값이 없으면 우회하지 않는다.
- 어떤 값이 비어 있는지 정확한 환경변수 이름으로 사용자에게 알려준다
- 그 값을 채팅창에 붙여 넣으라고 하지 않는다
- 대체 사이트, 대체 API, 하드코딩, 임시 평문 `.env` 파일 같은 우회 경로를 찾지 않는다
- 사용자가 직접 로컬에 안전하게 등록하도록 안내한 뒤 다시 진행한다
안내 기본형:
1. 필요한 값 이름을 짚는다. 예: `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`
2. `~/.config/k-skill/secrets.env.plain` 에 값을 적고
3. `sops``~/.config/k-skill/secrets.env` 로 암호화한 뒤
4. plaintext 파일을 지우고
5. `bash scripts/check-setup.sh` 로 다시 확인하게 한다
즉, "시크릿이 없으면 사용자에게 필요한 정보를 요청하고, 안전한 등록 절차를 안내한 뒤 멈춘다"가 기본 동작이다.
## Required
- `sops`
@ -56,6 +75,7 @@ sops exec-env "$HOME/.config/k-skill/secrets.env" '<command>'
- 실제 비밀값이 들어있는 plaintext `.env` 파일을 git에 두기
- 셸 히스토리에 남는 `export PASSWORD=...`
- 스킬 문서 안에 예시용 실비밀번호를 쓰기
- 시크릿이 없다는 이유로 다른 서비스나 비공식 우회 수단을 자동 채택하기
## Threat model notes

View file

@ -42,6 +42,8 @@ sudo pacman -S sops age
winget install Mozilla.SOPS FiloSottile.age
```
도구가 없으면 다른 비밀 관리 방식으로 우회하지 말고, 이 도구들을 먼저 설치하는 것을 기본으로 합니다.
## 2) age key 만들기
```bash
@ -81,6 +83,23 @@ sops --encrypt --input-type dotenv --output-type dotenv \
rm secrets.env.plain
```
## 시크릿이 없을 때의 기본 응답
인증이 필요한 스킬에서 값이 비어 있으면 다음 식으로 안내하는 것을 기본으로 합니다.
- 어떤 값이 필요한지 정확한 변수 이름으로 알려주기
- 그 값을 채팅에 보내지 말라고 안내하기
- 아래 절차로 로컬에 직접 등록하게 하기
예:
```text
이 작업에는 KSKILL_SRT_ID, KSKILL_SRT_PASSWORD 가 필요합니다.
값을 채팅창에 붙여 넣지 말고, ~/.config/k-skill/secrets.env.plain 에 채운 뒤
sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요.
끝나면 plaintext 파일은 지우고 bash scripts/check-setup.sh 로 다시 확인해 주세요.
```
## 5) 런타임 주입 확인
```bash

View file

@ -6,7 +6,8 @@
- `SRTrain` / `ryanking13/SRT`: https://github.com/ryanking13/SRT
- `korail2` / `carpedm20/korail2`: https://github.com/carpedm20/korail2
- `kbo-game`: https://github.com/vkehfdl1/kbo-game
- `korean-lotto`: https://github.com/hs85jeong/korean-lotto
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result
- 동행복권 지난 회차 JSON 표면: https://www.dhlottery.co.kr/lt645/selectPstLt645InfoNew.do
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- SOPS docs: https://getsops.io/docs/
- age: https://github.com/FiloSottile/age

View file

@ -20,6 +20,13 @@ metadata:
- 암호화 확인
- 런타임 주입 확인
이 스킬의 기본 정책:
- 시크릿이 없으면 필요한 값 이름을 사용자에게 정확히 알려준다
- 값을 채팅창에 붙여 넣으라고 하지 않는다
- 로컬에 안전하게 등록하는 절차를 안내한 뒤 다시 진행한다
- 필요한 패키지가 없으면 대체 구현을 찾기보다 전역 설치를 먼저 시도한다
## Why this is the default setup path
- 계정 가입이 필요 없다
@ -118,6 +125,25 @@ sops --encrypt --input-type dotenv --output-type dotenv \
rm secrets.env.plain
```
### Missing secret response template
인증 스킬에서 값이 빠졌을 때는 다음 식으로 안내한다.
```text
이 작업에는 <REQUIRED_SECRET_NAMES> 이 필요합니다.
값을 채팅창에 보내지 말고 ~/.config/k-skill/secrets.env.plain 에 직접 채운 뒤
sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요.
암호화가 끝나면 plaintext 파일은 지우고 bash scripts/check-setup.sh 로 다시 확인해 주세요.
```
예를 들면:
- SRT: `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
- 서울 지하철: `SEOUL_OPEN_API_KEY`
시크릿이 비어 있다는 이유로 다른 서비스나 비공식 우회 경로를 자동 선택하지 않는다.
### 5. Verify runtime injection
```bash

View file

@ -23,7 +23,7 @@ metadata:
## Prerequisites
- Node.js 18+
- `npm install kbo-game`
- `npm install -g kbo-game`
## Inputs
@ -32,11 +32,27 @@ metadata:
## Workflow
### 0. Install the package globally when missing
`npm root -g` 아래에 `kbo-game` 이 없으면 다른 구현으로 우회하지 말고 전역 Node 패키지 설치를 먼저 시도한다.
```bash
npm install -g kbo-game
```
패키지가 없다는 이유로 다른 비공식 scoreboard 소스를 자동 채택하지 않는다.
### 1. Fetch the date
```bash
node --input-type=module - <<'JS'
import { getGame } from "kbo-game";
GLOBAL_NPM_ROOT="$(npm root -g)" node --input-type=module - <<'JS'
import path from "node:path";
import { pathToFileURL } from "node:url";
const entry = pathToFileURL(
path.join(process.env.GLOBAL_NPM_ROOT, "kbo-game", "dist", "index.js"),
).href;
const { getGame } = await import(entry);
const date = "2026-03-25";
const games = await getGame(new Date(`${date}T00:00:00+09:00`));
@ -44,7 +60,7 @@ console.log(JSON.stringify(games, null, 2));
JS
```
`kbo-game@0.0.2` 기준 실제 export는 `getGame` 하나이며, 문자열 날짜(`"2026-03-25"`)를 직접 넘기면 실패한다. 항상 `Date` 객체로 변환해서 호출한다.
`kbo-game@0.0.2` 기준 실제 export는 `getGame` 하나이며, 문자열 날짜(`"2026-03-25"`)를 직접 넘기면 실패한다. 항상 `Date` 객체로 변환해서 호출한다. 전역 설치를 기본으로 쓰므로 inline snippet에서는 전역 npm root 아래 entry file을 직접 import 한다.
### 2. Normalize for humans

View file

@ -30,7 +30,7 @@ metadata:
## Prerequisites
- Python 3.10+
- `python -m pip install korail2`
- `python3 -m pip install korail2`
- `sops` and `age` installed
- common setup reviewed in `../k-skill-setup/SKILL.md`
- secret policy reviewed in `../docs/security-and-secrets.md`
@ -51,11 +51,32 @@ metadata:
## Workflow
### 1. Search first
### 0. Install the package globally when missing
`python3 -c 'import korail2'` 가 실패하면 다른 구현으로 우회하지 말고 전역 Python 패키지 설치를 먼저 시도한다.
```bash
python3 -m pip install korail2
```
### 1. Stop for secure registration when secrets are missing
`KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`, `~/.config/k-skill/secrets.env`, `~/.config/k-skill/age/keys.txt` 중 하나라도 없으면 다음 식으로 안내하고 멈춘다.
```text
이 작업에는 KSKILL_KTX_ID, KSKILL_KTX_PASSWORD 가 필요합니다.
값을 채팅창에 붙여 넣지 말고 ~/.config/k-skill/secrets.env.plain 에 직접 채운 뒤
sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요.
암호화가 끝나면 plaintext 파일은 지우고 bash scripts/check-setup.sh 로 다시 확인해 주세요.
```
시크릿이 없다는 이유로 웹사이트를 직접 긁거나 다른 비공식 경로를 찾지 않는다.
### 2. Search first
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python - <<'"'"'PY'"'"'
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python3 - <<'"'"'PY'"'"'
import os
from korail2 import Korail, TrainType
@ -77,7 +98,7 @@ PY
'
```
### 2. Present the shortlist
### 3. Present the shortlist
예매 전에 항상 아래를 확인한다.
@ -86,11 +107,11 @@ PY
- 좌석 가능 여부
- 가격
### 3. Reserve only after the target train is unambiguous
### 4. Reserve only after the target train is unambiguous
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python - <<'"'"'PY'"'"'
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python3 - <<'"'"'PY'"'"'
import os
from korail2 import AdultPassenger, Korail, ReserveOption, TrainType
@ -115,13 +136,13 @@ PY
'
```
### 4. Inspect or cancel
### 5. Inspect or cancel
취소는 대상 예약을 다시 조회해 식별한 뒤에만 진행한다.
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python - <<'"'"'PY'"'"'
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python3 - <<'"'"'PY'"'"'
import os
from korail2 import Korail

View file

@ -1,6 +1,6 @@
---
name: lotto-results
description: Check Korean Lotto draw results, latest rounds, and ticket matches with the korean-lotto npm package. Use when the user asks for winning numbers, payout details, or whether their numbers matched.
description: Check Korean Lotto draw results, latest rounds, and ticket matches with the k-lotto npm package. Use when the user asks for winning numbers, payout details, or whether their numbers matched.
license: MIT
metadata:
category: utility
@ -12,7 +12,7 @@ metadata:
## What this skill does
`korean-lotto` 패키지로 동행복권 로또 최신 회차, 특정 회차, 상세 당첨 결과, 번호 대조를 처리한다.
`k-lotto` 패키지로 동행복권 로또 최신 회차, 특정 회차, 상세 당첨 결과, 번호 대조를 처리한다.
## When to use
@ -23,7 +23,9 @@ metadata:
## Prerequisites
- Node.js 18+
- `npm install korean-lotto`
- 배포 후: `npm install -g k-lotto`
- 실행 전: `export NODE_PATH="$(npm root -g)"`
- 이 저장소에서 개발할 때: 루트에서 `npm install`
## Inputs
@ -32,11 +34,22 @@ metadata:
## Workflow
### 0. Install the package globally when missing
`node -e 'require("k-lotto")'` 가 실패하면 다른 구현으로 우회하지 말고 전역 Node 패키지 설치를 먼저 시도한다.
```bash
npm install -g k-lotto
export NODE_PATH="$(npm root -g)"
```
패키지가 없다는 이유로 HTML 파서를 다시 짜거나 다른 비공식 소스를 찾지 않는다.
### 1. Get the latest round when needed
```bash
node - <<'JS'
const lotto = require("korean-lotto");
NODE_PATH="$(npm root -g)" node - <<'JS'
const lotto = require("k-lotto");
lotto.getLatestRound().then((round) => console.log(round));
JS
```
@ -44,18 +57,18 @@ JS
### 2. Fetch result or detailed payout data
```bash
node - <<'JS'
const lotto = require("korean-lotto");
lotto.getDetailResult(861).then((result) => console.log(JSON.stringify(result, null, 2)));
NODE_PATH="$(npm root -g)" node - <<'JS'
const lotto = require("k-lotto");
lotto.getDetailResult(1216).then((result) => console.log(JSON.stringify(result, null, 2)));
JS
```
### 3. Check user's numbers when provided
```bash
node - <<'JS'
const lotto = require("korean-lotto");
lotto.checkNumber(862, ["10", "32", "38", "40", "42", "43"])
NODE_PATH="$(npm root -g)" node - <<'JS'
const lotto = require("k-lotto");
lotto.checkNumber(1216, ["3", "10", "14", "15", "23", "24"])
.then((result) => console.log(JSON.stringify(result, null, 2)));
JS
```
@ -68,8 +81,8 @@ JS
## Failure modes
- 패키지가 오래되어 upstream HTML 변경에 취약할 수 있다
- 최신 회차 반영이 늦을 수 있다
- 최신 회차는 결과 페이지 HTML에서 읽기 때문에 upstream HTML 변경의 영향을 받을 수 있다
- 상세 회차 정보는 동행복권 JSON 응답 스키마 변경의 영향을 받을 수 있다
## Notes

1314
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "k-skill",
"private": true,
"engines": {
"node": ">=18"
},
"workspaces": [
"packages/*"
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"lint": "npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"
},
"devDependencies": {
"@types/node": "^22.14.1",
"@changesets/cli": "^2.29.5",
"typescript": "^5.8.2"
}
}

View file

@ -0,0 +1,44 @@
# k-lotto
동행복권 공식 결과 페이지와 JSON 응답을 이용해 한국 로또 6/45 당첨 결과를 조회하는 Node.js 패키지입니다.
## 설치
배포 후:
```bash
npm install k-lotto
```
이 저장소에서 개발할 때:
```bash
npm install
```
## 사용 예시
```js
const lotto = require("k-lotto");
async function main() {
const latestRound = await lotto.getLatestRound();
const detail = await lotto.getDetailResult(latestRound);
const checked = await lotto.checkNumber(latestRound, [3, 10, 14, 15, 23, 24]);
console.log({ latestRound, detail, checked });
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 공개 API
- `getLatestRound()`
- `getResult(round)`
- `getDetailResult(round)`
- `checkNumber(round, ticketNumbers)`
- `evaluateTicket(detailResult, ticketNumbers)`

View file

@ -0,0 +1,27 @@
{
"name": "k-lotto",
"version": "0.1.0",
"description": "Official dhlottery-backed client for Korean Lotto results",
"license": "MIT",
"main": "src/index.js",
"files": [
"src",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"k-skill",
"korea",
"lotto",
"dhlottery"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,107 @@
const {
evaluateTicket,
extractLatestRoundFromHtml,
normalizeRoundItem,
selectRoundItem
} = require("./parse");
const DEFAULT_HEADERS = {
accept: "application/json, text/html;q=0.9",
"user-agent": "k-skill/k-lotto"
};
const LATEST_RESULT_URL = "https://www.dhlottery.co.kr/lt645/result";
const ROUND_DETAIL_URL = "https://www.dhlottery.co.kr/lt645/selectPstLt645InfoNew.do";
/**
* @param {string} url
* @returns {Promise<string>}
*/
async function fetchText(url) {
const response = await fetch(url, { headers: DEFAULT_HEADERS });
if (!response.ok) {
throw new Error(`dhlottery request failed with ${response.status} for ${url}`);
}
return response.text();
}
/**
* @param {string} url
* @returns {Promise<unknown>}
*/
async function fetchJson(url) {
const response = await fetch(url, { headers: DEFAULT_HEADERS });
if (!response.ok) {
throw new Error(`dhlottery request failed with ${response.status} for ${url}`);
}
return response.json();
}
/**
* @returns {Promise<number>}
*/
async function getLatestRound() {
const html = await fetchText(LATEST_RESULT_URL);
return extractLatestRoundFromHtml(html);
}
/**
* @param {number} round
* @returns {Promise<ReturnType<typeof normalizeRoundItem>>}
*/
async function getDetailResult(round) {
assertValidRound(round);
const url = new URL(ROUND_DETAIL_URL);
url.searchParams.set("srchDir", "center");
url.searchParams.set("srchLtEpsd", String(round));
const payload = await fetchJson(url.toString());
const item = selectRoundItem(payload, round);
return normalizeRoundItem(item);
}
/**
* @param {number} round
*/
async function getResult(round) {
const detail = await getDetailResult(round);
return {
round: detail.round,
drawDate: detail.drawDate,
numbers: [...detail.numbers],
bonus: detail.bonus
};
}
/**
* @param {number} round
* @param {ReadonlyArray<number>} ticketNumbers
*/
async function checkNumber(round, ticketNumbers) {
const detail = await getDetailResult(round);
return evaluateTicket(detail, ticketNumbers);
}
/**
* @param {number} round
*/
function assertValidRound(round) {
if (!Number.isInteger(round) || round < 1) {
throw new Error("round must be a positive integer.");
}
}
module.exports = {
checkNumber,
evaluateTicket,
getDetailResult,
getLatestRound,
getResult
};

View file

@ -0,0 +1,209 @@
const LATEST_ROUND_PATTERN = /id="opt_val"[^>]*value="(\d+)"/i;
/**
* @param {string} html
* @returns {number}
*/
function extractLatestRoundFromHtml(html) {
const match = html.match(LATEST_ROUND_PATTERN);
if (!match) {
throw new Error("Unable to locate the latest round on the dhlottery result page.");
}
return Number.parseInt(match[1], 10);
}
/**
* @param {number} value
* @returns {string}
*/
function formatWon(value) {
return `${value.toLocaleString("ko-KR")}`;
}
/**
* @param {string} raw
* @returns {string}
*/
function formatYmd(raw) {
if (!/^\d{8}$/.test(raw)) {
throw new Error(`Unexpected date format: ${raw}`);
}
return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
}
/**
* @param {unknown} payload
* @param {number} round
* @returns {Record<string, any>}
*/
function selectRoundItem(payload, round) {
if (!payload || typeof payload !== "object") {
throw new Error("Expected a JSON object from dhlottery.");
}
const data = /** @type {{ data?: { list?: Array<Record<string, any>> } }} */ (payload).data;
const list = data?.list;
if (!Array.isArray(list) || list.length === 0) {
throw new Error(`No lotto result items were returned for round ${round}.`);
}
const item = list.find((entry) => Number(entry.ltEpsd) === round);
if (!item) {
throw new Error(`Round ${round} was not present in the dhlottery response.`);
}
return item;
}
/**
* @param {Record<string, any>} item
* @returns {{
* round: number,
* drawDate: string,
* numbers: number[],
* bonus: number,
* totalSalesAmount: number,
* carryoverSalesAmount: number,
* winnersByPurchaseType: null | { auto: number, manual: number, semiAuto: number },
* payouts: Array<{ rank: number, winners: number, prizeAmount: number, totalPrizeAmount: number, prizeAmountLabel: string, totalPrizeAmountLabel: string }>,
* raw: Record<string, any>
* }}
*/
function normalizeRoundItem(item) {
const numbers = [item.tm1WnNo, item.tm2WnNo, item.tm3WnNo, item.tm4WnNo, item.tm5WnNo, item.tm6WnNo]
.map((value) => Number(value));
const bonus = Number(item.bnsWnNo);
return {
round: Number(item.ltEpsd),
drawDate: formatYmd(String(item.ltRflYmd)),
numbers,
bonus,
totalSalesAmount: Number(item.wholEpsdSumNtslAmt),
carryoverSalesAmount: Number(item.rlvtEpsdSumNtslAmt),
winnersByPurchaseType: Number(item.winType0) === 0
? {
auto: Number(item.winType1),
manual: Number(item.winType2),
semiAuto: Number(item.winType3)
}
: null,
payouts: [
buildPayoutRow(1, item.rnk1WnNope, item.rnk1WnAmt, item.rnk1SumWnAmt),
buildPayoutRow(2, item.rnk2WnNope, item.rnk2WnAmt, item.rnk2SumWnAmt),
buildPayoutRow(3, item.rnk3WnNope, item.rnk3WnAmt, item.rnk3SumWnAmt),
buildPayoutRow(4, item.rnk4WnNope, item.rnk4WnAmt, item.rnk4SumWnAmt),
buildPayoutRow(5, item.rnk5WnNope, item.rnk5WnAmt, item.rnk5SumWnAmt)
],
raw: item
};
}
/**
* @param {number} rank
* @param {unknown} winners
* @param {unknown} prizeAmount
* @param {unknown} totalPrizeAmount
*/
function buildPayoutRow(rank, winners, prizeAmount, totalPrizeAmount) {
const normalizedPrize = Number(prizeAmount);
const normalizedTotal = Number(totalPrizeAmount);
return {
rank,
winners: Number(winners),
prizeAmount: normalizedPrize,
totalPrizeAmount: normalizedTotal,
prizeAmountLabel: formatWon(normalizedPrize),
totalPrizeAmountLabel: formatWon(normalizedTotal)
};
}
/**
* @param {ReadonlyArray<number>} ticketNumbers
* @returns {number[]}
*/
function normalizeTicket(ticketNumbers) {
if (!Array.isArray(ticketNumbers) || ticketNumbers.length !== 6) {
throw new Error("ticketNumbers must contain exactly 6 values.");
}
const normalized = ticketNumbers.map((value) => Number(value));
if (normalized.some((value) => !Number.isInteger(value) || value < 1 || value > 45)) {
throw new Error("ticketNumbers must be integers between 1 and 45.");
}
const uniqueCount = new Set(normalized).size;
if (uniqueCount !== 6) {
throw new Error("ticketNumbers must not contain duplicates.");
}
return normalized.sort((left, right) => left - right);
}
/**
* @param {ReturnType<typeof normalizeRoundItem>} detail
* @param {ReadonlyArray<number>} ticketNumbers
*/
function evaluateTicket(detail, ticketNumbers) {
const normalizedTicket = normalizeTicket(ticketNumbers);
const matchedNumbers = normalizedTicket.filter((number) => detail.numbers.includes(number));
const bonusMatched = normalizedTicket.includes(detail.bonus);
const rank = determineRank(matchedNumbers.length, bonusMatched);
return {
round: detail.round,
drawDate: detail.drawDate,
ticketNumbers: normalizedTicket,
winningNumbers: [...detail.numbers],
bonus: detail.bonus,
matchedNumbers,
bonusMatched,
matchCount: matchedNumbers.length,
rank,
outcome: rank === null ? "낙첨" : `${rank}`
};
}
/**
* @param {number} matchCount
* @param {boolean} bonusMatched
* @returns {number | null}
*/
function determineRank(matchCount, bonusMatched) {
if (matchCount === 6) {
return 1;
}
if (matchCount === 5 && bonusMatched) {
return 2;
}
if (matchCount === 5) {
return 3;
}
if (matchCount === 4) {
return 4;
}
if (matchCount === 3) {
return 5;
}
return null;
}
module.exports = {
evaluateTicket,
extractLatestRoundFromHtml,
normalizeRoundItem,
selectRoundItem
};

View file

@ -0,0 +1,6 @@
<!doctype html>
<html lang="ko">
<body>
<input type="hidden" class="opt_val" id="opt_val" value="1216">
</body>
</html>

View file

@ -0,0 +1,43 @@
{
"resultCode": null,
"resultMessage": null,
"data": {
"list": [
{
"winType0": 0,
"winType1": 8,
"winType2": 5,
"winType3": 1,
"gmSqNo": 5133,
"ltEpsd": 1216,
"tm1WnNo": 3,
"tm2WnNo": 10,
"tm3WnNo": 14,
"tm4WnNo": 15,
"tm5WnNo": 23,
"tm6WnNo": 24,
"bnsWnNo": 25,
"ltRflYmd": "20260321",
"rnk1WnNope": 14,
"rnk1WnAmt": 2148654000,
"rnk1SumWnAmt": 30081156000,
"rnk2WnNope": 87,
"rnk2WnAmt": 57626736,
"rnk2SumWnAmt": 5013526032,
"rnk3WnNope": 3181,
"rnk3WnAmt": 1576085,
"rnk3SumWnAmt": 5013526385,
"rnk4WnNope": 168089,
"rnk4WnAmt": 50000,
"rnk4SumWnAmt": 8404450000,
"rnk5WnNope": 2853121,
"rnk5WnAmt": 5000,
"rnk5SumWnAmt": 14265605000,
"sumWnNope": 3024492,
"rlvtEpsdSumNtslAmt": 62778263417,
"wholEpsdSumNtslAmt": 125556526000,
"excelRnk": "1등"
}
]
}
}

View file

@ -0,0 +1,95 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
evaluateTicket,
getDetailResult,
getLatestRound,
getResult
} = require("../src/index");
const {
extractLatestRoundFromHtml,
normalizeRoundItem,
selectRoundItem
} = require("../src/parse");
const fixturesDir = path.join(__dirname, "fixtures");
const latestResultHtml = fs.readFileSync(path.join(fixturesDir, "latest-result.html"), "utf8");
const round1216Payload = JSON.parse(
fs.readFileSync(path.join(fixturesDir, "round-1216.json"), "utf8")
);
test("extractLatestRoundFromHtml parses the latest round from the official result page", () => {
assert.equal(extractLatestRoundFromHtml(latestResultHtml), 1216);
});
test("normalizeRoundItem maps official JSON into the public detail shape", () => {
const detail = normalizeRoundItem(selectRoundItem(round1216Payload, 1216));
assert.equal(detail.round, 1216);
assert.equal(detail.drawDate, "2026-03-21");
assert.deepEqual(detail.numbers, [3, 10, 14, 15, 23, 24]);
assert.equal(detail.bonus, 25);
assert.equal(detail.payouts[0].rank, 1);
assert.equal(detail.payouts[0].winners, 14);
assert.equal(detail.winnersByPurchaseType?.auto, 8);
});
test("evaluateTicket returns the right rank for an exact match", () => {
const detail = normalizeRoundItem(selectRoundItem(round1216Payload, 1216));
const checked = evaluateTicket(detail, [3, 10, 14, 15, 23, 24]);
assert.equal(checked.rank, 1);
assert.equal(checked.outcome, "1등");
assert.deepEqual(checked.matchedNumbers, [3, 10, 14, 15, 23, 24]);
});
test("evaluateTicket rejects duplicate numbers", () => {
const detail = normalizeRoundItem(selectRoundItem(round1216Payload, 1216));
assert.throws(() => {
evaluateTicket(detail, [3, 3, 14, 15, 23, 24]);
}, /duplicates/);
});
test("public fetchers can consume injected fixtures via mocked fetch", async () => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
if (String(url).includes("/lt645/result")) {
return makeResponse(true, latestResultHtml);
}
return makeResponse(true, round1216Payload);
};
try {
assert.equal(await getLatestRound(), 1216);
assert.deepEqual(await getResult(1216), {
round: 1216,
drawDate: "2026-03-21",
numbers: [3, 10, 14, 15, 23, 24],
bonus: 25
});
const detail = await getDetailResult(1216);
assert.equal(detail.payouts[1].rank, 2);
} finally {
global.fetch = originalFetch;
}
});
/**
* @param {boolean} ok
* @param {string|object} body
*/
function makeResponse(ok, body) {
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
status: ok ? 200 : 500,
headers: {
"content-type": typeof body === "string" ? "text/html" : "application/json"
}
});
}

17
python-packages/README.md Normal file
View file

@ -0,0 +1,17 @@
# Python package release scaffold
이 저장소의 Python 패키지는 `python-packages/*` 아래에 둘 계획이다.
현재는 실제 패키지가 없어서 release-please workflow만 껍데기 상태로 남겨 둔다.
첫 Python 패키지를 추가할 때 해야 할 일:
1. `python-packages/<package-name>/pyproject.toml` 생성
2. `.github/release-please/python-config.json`에 해당 path와 `release-type: "python"` 추가
3. `.github/release-please/python-manifest.json`에 시작 버전 추가
4. `release-python.yml`에 build + `pypa/gh-action-pypi-publish` publish job 연결
주의:
- PyPI trusted publishing은 현재 reusable workflow 안에서 쓰지 않는 것이 안전하다.
- 실제 `pypi-publish` job은 지금처럼 top-level workflow에 두는 기준으로 유지한다.

View file

@ -28,6 +28,14 @@ if [[ ! -f "$secrets_file" ]]; then
fi
if [[ "$missing" -ne 0 ]]; then
cat <<EOF
next steps:
1. follow k-skill-setup / docs/setup.md
2. register required secrets in ~/.config/k-skill/secrets.env.plain
3. encrypt it to ~/.config/k-skill/secrets.env with sops
4. delete the plaintext file
5. run this check again
EOF
exit 1
fi

View file

@ -37,8 +37,13 @@ while IFS= read -r -d '' skill_dir; do
done < <(
find "$root" -mindepth 1 -maxdepth 1 -type d \
! -name .git \
! -name .github \
! -name .omx \
! -name .changeset \
! -name docs \
! -name node_modules \
! -name packages \
! -name python-packages \
! -name scripts \
! -name examples \
-print0

View file

@ -39,10 +39,21 @@ metadata:
## Workflow
### 1. Load the API key securely
### 1. Stop for secure registration when the API key is missing
평문 key를 붙여 넣지 않는다.
`SEOUL_OPEN_API_KEY`, `~/.config/k-skill/secrets.env`, `~/.config/k-skill/age/keys.txt` 중 하나라도 없으면 다음 식으로 안내하고 멈춘다.
```text
이 작업에는 SEOUL_OPEN_API_KEY 가 필요합니다.
값을 채팅창에 붙여 넣지 말고 ~/.config/k-skill/secrets.env.plain 에 직접 채운 뒤
sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요.
암호화가 끝나면 plaintext 파일은 지우고 bash scripts/check-setup.sh 로 다시 확인해 주세요.
```
시크릿이 없다는 이유로 비공식 미러 API나 다른 출처로 자동 우회하지 않는다.
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" 'test -n "$SEOUL_OPEN_API_KEY"'

View file

@ -30,7 +30,7 @@ metadata:
## Prerequisites
- Python 3.10+
- `python -m pip install SRTrain`
- `python3 -m pip install SRTrain`
- `sops` and `age` installed
- common setup reviewed in `../k-skill-setup/SKILL.md`
- secret policy reviewed in `../docs/security-and-secrets.md`
@ -53,17 +53,36 @@ metadata:
## Workflow
### 1. Validate secrets path
### 0. Install the package globally when missing
`python3 -c 'import SRT'` 가 실패하면 다른 구현으로 우회하지 말고 전역 Python 패키지 설치를 먼저 시도한다.
```bash
python3 -m pip install SRTrain
```
### 1. Validate secrets path and stop for secure registration when missing
비밀번호를 직접 받지 않는다. 필요한 경우 encrypted secrets file 경로와 변수 이름만 확인한다.
`KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`, `~/.config/k-skill/secrets.env`, `~/.config/k-skill/age/keys.txt` 중 하나라도 없으면 다음 식으로 안내하고 멈춘다.
```text
이 작업에는 KSKILL_SRT_ID, KSKILL_SRT_PASSWORD 가 필요합니다.
값을 채팅창에 붙여 넣지 말고 ~/.config/k-skill/secrets.env.plain 에 직접 채운 뒤
sops 로 ~/.config/k-skill/secrets.env 로 암호화해 주세요.
암호화가 끝나면 plaintext 파일은 지우고 bash scripts/check-setup.sh 로 다시 확인해 주세요.
```
시크릿이 없다는 이유로 웹사이트를 직접 긁거나 다른 비공식 경로를 찾지 않는다.
### 2. Search first
먼저 조회해서 후보를 요약한다.
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python - <<'"'"'PY'"'"'
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python3 - <<'"'"'PY'"'"'
import os
from SRT import SRT
@ -90,7 +109,7 @@ PY
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python - <<'"'"'PY'"'"'
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python3 - <<'"'"'PY'"'"'
import os
from SRT import Adult, SRT, SeatType
@ -112,7 +131,7 @@ PY
```bash
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python - <<'"'"'PY'"'"'
sops exec-env "$HOME/.config/k-skill/secrets.env" 'python3 - <<'"'"'PY'"'"'
import os
from SRT import SRT

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node10",
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": [
"node"
],
"lib": [
"ES2022",
"DOM"
]
},
"include": [
"packages/k-lotto/src/**/*.js",
"packages/k-lotto/test/**/*.js"
]
}