mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Compare commits
114 commits
feature/#2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08533bd9eb |
||
|
|
eacdfb882a |
||
|
|
b14f65361f | ||
|
|
caa1f0fd0d | ||
|
|
c619d3b7c7 | ||
|
|
e735abe8a4 |
||
|
|
c3f44eef14 | ||
|
|
1f186af480 |
||
|
|
5fd58facf4 |
||
|
|
e0d842435b | ||
|
|
ece355b807 | ||
|
|
a633b001be |
||
|
|
c8bb7f9f35 | ||
|
|
7586c0dea8 | ||
|
|
66f12cb43d |
||
|
|
f485591ac2 |
||
|
|
440cd697a7 | ||
|
|
b6200892e3 | ||
|
|
79f6038328 |
||
|
|
5faec8bb2a |
||
|
|
e25f8dd9ab | ||
|
|
a8a71eed11 | ||
|
|
52dbfee064 |
||
|
|
f5d37ddbee |
||
|
|
819be4897a | ||
|
|
1efef285ba |
||
|
|
acc66861ea | ||
|
|
bbba283151 | ||
|
|
46f44ed724 | ||
|
|
cc64d66d56 | ||
|
|
7c1a4530cf | ||
|
|
346ce7f516 | ||
|
|
e336f7898c | ||
|
|
352876f915 | ||
|
|
581b0344ce | ||
|
|
cc768f4031 | ||
|
|
51388a539e | ||
|
|
fe08b8e068 | ||
|
|
e76c125014 | ||
|
|
a490fcb0c0 | ||
|
|
9dc3577742 | ||
|
|
c27a3e9151 | ||
|
|
cff6b29ff9 | ||
|
|
b6b0c70091 | ||
|
|
edb2892dda | ||
|
|
b2404e99be | ||
|
|
00a6d9feae | ||
|
|
3d8008f2f2 | ||
|
|
f8401ef405 | ||
|
|
1d4b2eb723 | ||
|
|
002c6c13bc | ||
|
|
20e8f3f8f0 | ||
|
|
eb08ef6134 | ||
|
|
1d8fd333d8 | ||
|
|
95c6222a65 | ||
|
|
48a0420752 | ||
|
|
6a26a194c6 | ||
|
|
6aff1e7301 | ||
|
|
70b92d6b03 | ||
|
|
9b9f5bc7a2 | ||
|
|
ee51dbc2f1 | ||
|
|
24feb3edca | ||
|
|
8420792a82 | ||
|
|
3ba8899f63 | ||
|
|
9b2e0957f2 | ||
|
|
0e30b79e83 | ||
|
|
807fa0c900 | ||
|
|
d12bfa1fab | ||
|
|
0ea646a03d |
||
|
|
abe26e411d | ||
|
|
234790a7ea |
||
|
|
d2db629640 | ||
|
|
0300e9b91b |
||
|
|
19af47399d | ||
|
|
99340393de | ||
|
|
e90897a684 |
||
|
|
72a3fd7ca6 | ||
|
|
440f33b52f |
||
|
|
ed30f22f86 |
||
|
|
5e58d1fe86 | ||
|
|
51ea778a2d | ||
|
|
2dbad40078 | ||
|
|
68bd64ebd4 | ||
|
|
366d346f03 | ||
|
|
73c3611e8a | ||
|
|
45084293f0 |
||
|
|
6d49a28d87 | ||
|
|
ff2aa91f83 | ||
|
|
876077c7c9 |
||
|
|
e6d7072e93 |
||
|
|
6551004967 |
||
|
|
01cd887579 |
||
|
|
6dc9d9d9c6 | ||
|
|
1d6f97bb8a | ||
|
|
9164835e9e | ||
|
|
80e7805681 | ||
|
|
34a0928edd |
||
|
|
271ea185c4 |
||
|
|
2f68b1ab4b | ||
|
|
7c2dc59c6c | ||
|
|
68abad3de0 |
||
|
|
5b08b4c86e |
||
|
|
6831b3147e |
||
|
|
4a78169220 | ||
|
|
7e95ac69ab |
||
|
|
cf8e96acdc | ||
|
|
8d52850fec | ||
|
|
ca5aefd990 | ||
|
|
80303f55f4 |
||
|
|
94e4d81f0b | ||
|
|
9cb2ea037e |
||
|
|
0ddb23d2af |
||
|
|
20522ab43c | ||
|
|
3a4e409887 |
199 changed files with 17704 additions and 2658 deletions
|
|
@ -1,18 +0,0 @@
|
|||
---
|
||||
"court-auction-notice-search": minor
|
||||
---
|
||||
|
||||
Add Workflow C property free-condition search via `searchProperties()` (`POST /pgj/pgjsearch/searchControllerMain.on`).
|
||||
|
||||
The request body matches the canonical PGJ151M01 submission captured from a real browser session — numeric `pageNo`/`pageSize`/`statNum`, full `dma_pageInfo` shape, and the upstream-correct field names (`mvprpArtclKndCd`/`mvprpAtchmPlcTypCd`, not the previously-guessed `mvprpArtclKnd`/`mvrpDspslPlcTyp`).
|
||||
|
||||
The static usage/region codetables come from upstream discovery captures: 4 대분류 (`10000=토지`, `20000=건물`, `30000=차량및운송장비`, `40000=기타`) plus representative mid/small classes; 19 시도 with their official codes. Sigungu/dong cascade XHRs are not reliable so callers pass raw codes (e.g. `"11680"`) directly.
|
||||
|
||||
`searchProperties()` automatically falls back to the Playwright client only for WAF-style raw HTTP `UPSTREAM_ERROR` 400 responses. Confirmed `BLOCKED` / `ipcheck=false` responses stop by default to avoid extending an IP block; retrying that condition requires explicit `fallbackOnBlocked:true`. Disable fallback entirely with `{ fallback: false }`.
|
||||
|
||||
Other fixes:
|
||||
- `resolveUsageCode(name, level)` now refuses to silently return a wrong-level code for ambiguous names (e.g. `"아파트"` exists at multiple levels) — returns the input unchanged so the upstream rejects it instead of producing a wrong query.
|
||||
- `resolveRegionCodes({})` no longer accidentally maps "no region" to the first row's sido.
|
||||
- `flbdCount` is integer-only; `pageSize` is restricted to the observed PGJ151 dropdown values `10`/`20`/`50`/`100` to avoid unsupported upstream requests.
|
||||
- Endpoint-aware HTTP/Playwright warmup (`PGJ151F00` for property search instead of `PGJ143M01`).
|
||||
- CLI `search` accepts `--region 시도:시군구:읍면동` and `--usage 대:중:소` colon shorthand alongside the existing split flags.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"donation-place-search": minor
|
||||
---
|
||||
|
||||
Add a donation place recommendation skill and package for Korean location/category-based donation recipient lookup.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"court-auction-notice-search": patch
|
||||
---
|
||||
|
||||
Fix sale notice search to post the court site month key (`YYYYMM`) and filter exact-day requests locally; normalize the current nested notice-detail response shape and HTML-formatted prices.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"gangnamunni-clinic-search": minor
|
||||
---
|
||||
|
||||
Add Gangnam Unni public clinic search skill and package.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
---
|
||||
"gongsijiga-search": patch
|
||||
---
|
||||
|
||||
feat: extract realtyprice.kr lookup from k-skill-proxy into a standalone `gongsijiga-search` workspace package
|
||||
|
||||
The previous `/v1/realtyprice` proxy route called a fully public endpoint (realtyprice.kr) 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 its own package and is invoked directly from the user's machine.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"k-skill-proxy": patch
|
||||
---
|
||||
|
||||
refactor: remove realtyprice route (moved to standalone gongsijiga-search package)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"daiso-product-search": minor
|
||||
---
|
||||
|
||||
Restore Daiso store pickup stock quantities through the official non-login Bearer flow (`/api/auth/request` + AES-128-CBC token) while keeping the resilient `selPkupStr` fallback API. `getStorePickupStock()` now retries once with a fresh token on 401/403 and returns structured `retrievalStatus: "blocked"` after repeated auth blocks instead of throwing. `getStorePickupEligibility()` remains public, and `lookupStoreProductAvailability()` fills `pickupEligibility` when exact pickup stock remains unavailable.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"daiso-product-search": patch
|
||||
---
|
||||
|
||||
Handle Daiso Mall pickup-stock Unauthorized responses as structured unavailable results, include pickup-stock retrieval and inventory states, and mark online-stock fallback as reference-only.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"emergency-room-beds": minor
|
||||
---
|
||||
|
||||
Add an E-Gen based nearby emergency-room status skill and package.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"korean-marathon-schedule": minor
|
||||
---
|
||||
|
||||
Add a Korean marathon and triathlon schedule lookup skill backed by public event pages.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add `/v1/kstartup/{business-info,announcements,contents,statistics}` routes that wrap the data.go.kr `15125364` (창업진흥원_K-Startup) Open API. The routes inject `DATA_GO_KR_API_KEY` server-side, return 503 when the key (or the per-dataset 활용신청) is missing, and cache successful JSON responses while bypassing the cache for upstream error envelopes (`resultCode != "00"`).
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"k-skill-proxy": patch
|
||||
---
|
||||
|
||||
Add National Tax Service business registration status and authenticity proxy routes.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"daishin-report-search": minor
|
||||
---
|
||||
|
||||
Add a Daishin Securities report search skill backed by the public GitHub Pages report mirror.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add `/v1/seoul-density/citydata` route that proxies the Seoul Open Data realtime hotspot crowd-level API (`citydata_ppltn`) using the server-side `SEOUL_OPEN_API_KEY`.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"sh-notice-search": minor
|
||||
---
|
||||
|
||||
Add a policy-compliant SH public notice search skill and direct HTML lookup client.
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
---
|
||||
"toss-securities": minor
|
||||
---
|
||||
|
||||
Improve toss-securities session-expiry handling and diagnostics.
|
||||
|
||||
- Add `auth doctor` wiring and `checkSession()` helper.
|
||||
- Add `TossSessionExpiredError` for clearer invalid-session failures.
|
||||
- Promote silent empty-array responses from portfolio/watchlist into explicit session-expired errors when `auth doctor` says session is invalid.
|
||||
- Add `search/stocks 403` upstream hinting for quote failures.
|
||||
- Extend tests and README to document behavior and `tossctl >= 0.3.6` recommendation.
|
||||
13
.claude-plugin/marketplace.json
Normal file
13
.claude-plugin/marketplace.json
Normal 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
110
.claude-plugin/plugin.json
Normal 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
38
.dockerignore
Normal 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
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
147
.github/workflows/deploy-k-skill-proxy.yml
vendored
Normal file
147
.github/workflows/deploy-k-skill-proxy.yml
vendored
Normal 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
|
||||
4
.github/workflows/manus-bundle.yml
vendored
4
.github/workflows/manus-bundle.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
39
.github/workflows/release-npm.yml
vendored
39
.github/workflows/release-npm.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
6
.github/workflows/release-python.yml
vendored
6
.github/workflows/release-python.yml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
outputs:
|
||||
has_python_packages: ${{ steps.detect.outputs.has_python_packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
@ -43,10 +43,10 @@ jobs:
|
|||
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
4
.gitignore
vendored
|
|
@ -9,3 +9,7 @@ node_modules/
|
|||
__pycache__/
|
||||
dist/
|
||||
.sisyphus/
|
||||
.omo/
|
||||
.gjc/
|
||||
|
||||
.agents/
|
||||
|
|
|
|||
12
AGENTS.md
12
AGENTS.md
|
|
@ -47,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 운영 관련 어떤 질문이 들어와도 먼저 그 문서를 확인한다.
|
||||
|
|
|
|||
|
|
@ -14,9 +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 코드에서 직접 호출하고 프록시를 거치지 않는다.
|
||||
|
|
|
|||
|
|
@ -60,11 +60,11 @@
|
|||
|
||||
- 프록시 서버 코드: `packages/k-skill-proxy/src/server.js`
|
||||
- 프록시 서버 테스트: `packages/k-skill-proxy/test/server.test.js`
|
||||
- 로컬 테스트: `node packages/k-skill-proxy/src/server.js`
|
||||
- 프록시 환경변수와 API key는 `~/.config/k-skill/secrets.env`에 두고, `scripts/run-k-skill-proxy.sh`가 source합니다.
|
||||
- 프로덕션 프록시는 `~/.local/share/k-skill-proxy`에 있는 `main` 브랜치 단독 clone입니다.
|
||||
- cron job은 매시 정각 `scripts/auto-update-proxy.sh`를 실행해 `origin/main` fetch → fast-forward pull → `package-lock` 변경 시 `npm ci` → `pm2 restart` 순서로 배포합니다.
|
||||
- 배포 로그는 `/tmp/k-skill-proxy-update.log`에서 확인합니다.
|
||||
- 컨테이너 이미지 정의: `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`에 머지되기 전까지는 프로덕션 프록시에 반영되지 않습니다.
|
||||
|
||||
## 검증
|
||||
|
|
|
|||
54
README.md
54
README.md
|
|
@ -23,14 +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) |
|
||||
|
|
@ -40,7 +42,14 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 등기부등본 자동화 | `iros-registry-automation` | 인터넷등기소(IROS)에서 법인/부동산 등기부등본 장바구니, 수동 결제 후 열람·저장 흐름을 보조 | 필요(수동 로그인·결제/TouchEn) | [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md) |
|
||||
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.md) |
|
||||
| 사업자등록정보 확인 | `nts-business-registration` | 국세청 사업자등록번호 상태조회와 사업자등록정보 진위확인(공공데이터포털 API, 프록시 경유) | 불필요 | [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md) |
|
||||
| 사업자 실사 종합 | `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) |
|
||||
|
|
@ -57,6 +66,8 @@ 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) |
|
||||
|
|
@ -70,7 +81,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 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) |
|
||||
|
|
@ -78,10 +89,9 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 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) |
|
||||
|
|
@ -91,6 +101,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 항공권 가격 조회 | `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) |
|
||||
|
|
@ -104,15 +115,20 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 네이버 뉴스 검색 | `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)을 참고하세요.
|
||||
|
||||
## 처음 시작하는 순서
|
||||
|
||||
|
|
@ -143,10 +159,11 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [고속버스 예매](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)
|
||||
|
|
@ -155,7 +172,14 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [한국 법령 검색 가이드](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)
|
||||
|
|
@ -193,7 +217,6 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [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)
|
||||
|
|
@ -206,6 +229,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [항공권 가격 조회 가이드](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)
|
||||
|
|
@ -219,6 +243,8 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [네이버 뉴스 검색 가이드](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
79
biz-health-check/SKILL.md
Normal 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`)의 공식 출처를 따른다.
|
||||
161
biz-health-check/scripts/biz_health_check.py
Normal file
161
biz-health-check/scripts/biz_health_check.py
Normal 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())
|
||||
|
|
@ -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` |
|
||||
|
||||
|
|
|
|||
211
docs/deploy-k-skill-proxy.md
Normal file
211
docs/deploy-k-skill-proxy.md
Normal 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가 같은 일을 하기 때문입니다.
|
||||
45
docs/features/biz-health-check.md
Normal file
45
docs/features/biz-health-check.md
Normal 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)의 "사업자 실사" 항목 참조.
|
||||
|
|
@ -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`) 안의 행만 포함된다.
|
||||
- "예비" 표기가 있는 객실은 사용자 예약 화면에 노출되지 않는 운영자 보유분이라 결과에서 자동 제외된다.
|
||||
|
||||
## 흔한 문제 해결
|
||||
|
||||
|
|
|
|||
35
docs/features/fsc-corporate-info.md
Normal file
35
docs/features/fsc-corporate-info.md
Normal 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`
|
||||
41
docs/features/g2b-sanctioned-supplier.md
Normal file
41
docs/features/g2b-sanctioned-supplier.md
Normal 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`
|
||||
67
docs/features/jobkorea-talent-search.md
Normal file
67
docs/features/jobkorea-talent-search.md
Normal 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를 조정해야 할 수 있다.
|
||||
- 계정 권한, 유료 상품 상태, 마스킹 정책에 따라 보이는 정보가 다르다.
|
||||
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ client/skill -> k-skill-proxy -> upstream public API
|
|||
- `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`)
|
||||
|
|
@ -66,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)에 정리되어 있다.
|
||||
|
||||
## 기본 공개 정책
|
||||
|
||||
|
|
@ -131,6 +128,12 @@ curl -fsS --get "${BASE}/v1/seoul-subway/arrival" \
|
|||
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:
|
||||
|
|
|
|||
104
docs/features/kakao-map.md
Normal file
104
docs/features/kakao-map.md
Normal 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)
|
||||
|
|
@ -1,106 +1,113 @@
|
|||
# 카카오톡 Mac CLI 가이드
|
||||
# 카카오톡 Mac 아카이브 검색 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- macOS에서 카카오톡 최근 대화 목록 확인
|
||||
- 특정 채팅방 최근 메시지 읽기
|
||||
- 키워드로 전체 대화 검색
|
||||
- 나와의 채팅으로 안전하게 테스트 전송
|
||||
- 사용자 확인 후 특정 채팅방으로 메시지 전송
|
||||
- 로컬 메시지 ID로 후보를 고른 뒤 현재 보이는 정확히 하나의 UI bubble 삭제 자동화
|
||||
- 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` 등)
|
||||
- 전송 메시지 본문
|
||||
- 삭제할 메시지 ID 또는 최근 보낸 메시지 삭제 여부
|
||||
- 테스트 여부(`--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. 삭제는 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 을 먼저 실행한다.
|
||||
7. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
|
||||
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시에 만나요"
|
||||
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --dry-run
|
||||
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --everyone
|
||||
python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --dry-run
|
||||
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 조회
|
||||
|
||||
## 메시지 삭제
|
||||
|
||||
`delete` / `delete-last` 는 카카오톡 Mac UI의 삭제 메뉴를 Accessibility 로 자동화한다. 메시지 조회와 outbound 여부 검증은 로컬 DB에서 하고, 실제 삭제는 현재 보이는 UI bubble 에서 수행한다.
|
||||
검색 결과에서 더 넓은 맥락이 필요할 때만 chunk 명령을 사용한다.
|
||||
|
||||
```bash
|
||||
python3 scripts/kakaotalk_mac.py messages --chat "팀 공지방" --limit 20 --json
|
||||
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --dry-run
|
||||
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --everyone
|
||||
python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --everyone
|
||||
katok chunk get <chunk-id> --json
|
||||
katok chunk context <chunk-id> --json
|
||||
katok chunk parent <chunk-id> --json
|
||||
```
|
||||
|
||||
- `--everyone` 은 내가 보낸 메시지에만 허용된다.
|
||||
- UI 삭제 단계는 활성 채팅방을 확인하고, 선택된 outbound DB 메시지의 정규화된 텍스트가 대화 transcript 영역에서 정확히 하나의 visible targetable message bubble 과 일치할 때만 진행한다. 로컬 DB message id가 UI bubble identity를 직접 증명하는 것은 아니므로, 메시지 텍스트가 비어 있거나 첨부/비텍스트이거나 보이지 않거나 정규화 후 같은 텍스트가 여러 개이거나 최종 확인 버튼을 클릭할 수 없으면 실패한다.
|
||||
- `chats`, `messages`, `search`, `schema` 는 read-only 이지만 `delete` / `delete-last` 는 side effect 이다.
|
||||
- `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 대체 구현으로 넘어가지 않는다.
|
||||
- 다른 사람에게 보내는 메시지는 자동 전송하지 말고 확인을 먼저 받는다.
|
||||
- 삭제 자동화는 되돌리기 어렵기 때문에 `--dry-run` 으로 대상과 범위를 확인한다.
|
||||
- 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 전용이며 메시지 전송과 삭제를 지원하지 않는다.
|
||||
|
|
|
|||
60
docs/features/korean-humanizer.md
Normal file
60
docs/features/korean-humanizer.md
Normal 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 님의 기여에 감사한다.
|
||||
|
|
@ -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` (사용자는 불필요)
|
||||
|
|
|
|||
86
docs/features/korean-middle-korean.md
Normal file
86
docs/features/korean-middle-korean.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
|
|||
63
docs/features/local-election-candidate-search.md
Normal file
63
docs/features/local-election-candidate-search.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# 지방선거 후보자 조회 가이드
|
||||
|
||||
`local-election-candidate-search`는 중앙선거관리위원회 선거통계시스템(`info.nec.go.kr`)의 공개 **통합검색** HTML 표면을 직접 조회하는 read-only 스킬이다. upstream이 인증/키 없이 열려 있는 공개 표면이므로 `k-skill-proxy`를 사용하지 않는다.
|
||||
|
||||
## 공개 접근 경로
|
||||
|
||||
- 진입점: `https://info.nec.go.kr/search/searchCandidate.xhtml`
|
||||
- 방식: `POST searchKeyword=<정확한 후보자 성명>`
|
||||
- 기본 정책: 지방선거 관련 선거코드만 반환
|
||||
- `3` 시·도지사선거
|
||||
- `4` 구·시·군의 장선거
|
||||
- `5` 시·도의회의원선거
|
||||
- `6` 구·시·군의회의원선거
|
||||
- `8` 광역의원비례대표선거
|
||||
- `9` 기초의원비례대표선거
|
||||
- `11` 교육감선거
|
||||
|
||||
이 경로는 NEC 화면에 공개된 후보자 성명 기반 통합검색이며, 선거별 메뉴에서 모든 시도/구시군/선거구 조합을 먼저 선택하는 방식보다 조회 진입점이 좁고 안정적이다.
|
||||
|
||||
## CLI 사용
|
||||
|
||||
```bash
|
||||
node packages/local-election-candidate-search/src/cli.js 오세훈 --election 시도지사 --region 서울 --limit 5
|
||||
node packages/local-election-candidate-search/src/cli.js 김동연 --date 2014 --election 기초의원 --region 동작
|
||||
node packages/local-election-candidate-search/src/cli.js 이재명 --all --limit 20
|
||||
```
|
||||
|
||||
패키지 설치 후에는 bin 이름을 사용할 수 있다.
|
||||
|
||||
```bash
|
||||
local-election-candidate-search 오세훈 --election 시도지사 --region 서울
|
||||
```
|
||||
|
||||
## Node API
|
||||
|
||||
```js
|
||||
const { searchCandidates } = require("local-election-candidate-search")
|
||||
|
||||
const result = await searchCandidates({
|
||||
name: "오세훈",
|
||||
election: "시도지사",
|
||||
region: "서울",
|
||||
limit: 5
|
||||
})
|
||||
```
|
||||
|
||||
## 출력 필드
|
||||
|
||||
반환 JSON의 `items[]`에는 upstream HTML에 있는 범위에서 다음 필드가 포함된다.
|
||||
|
||||
- `name`, `hanja`, `birth_date`, `gender`
|
||||
- `election_date`, `election_name`, `election_code`, `election_type`
|
||||
- `party`, `district`, `votes`, `vote_share`, `elected`
|
||||
- `job`, `education`, `career[]`
|
||||
- `city_code`, `sgg_city_code`, `town_code`
|
||||
|
||||
## 실패 모드와 주의사항
|
||||
|
||||
- NEC 통합검색은 정확한 후보자명을 기준으로 동작하므로 동명이인이 나올 수 있다. 결과를 보여줄 때는 선거일·선거종류·지역을 함께 표시한다.
|
||||
- 사용자가 범위를 좁히면 `--election`, `--date`, `--region` 필터를 적용한다.
|
||||
- `--all`을 주지 않으면 지방선거 관련 선거코드만 반환한다.
|
||||
- 빈 결과, NetFunnel 대기열, 점검/로그인/차단 페이지, upstream HTML 변경은 `warnings[]`에 명시한다.
|
||||
- 로그인, CAPTCHA, 후보 등록/신고, 파일 다운로드, 정치 자금/선거 사무 자동화는 하지 않는다.
|
||||
44
docs/features/localdata-business-status.md
Normal file
44
docs/features/localdata-business-status.md
Normal 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>
|
||||
38
docs/features/national-pension-workplace.md
Normal file
38
docs/features/national-pension-workplace.md
Normal 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`
|
||||
31
docs/features/nts-tax-delinquency.md
Normal file
31
docs/features/nts-tax-delinquency.md
Normal 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>
|
||||
77
docs/features/ohou-today-deal.md
Normal file
77
docs/features/ohou-today-deal.md
Normal 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__` 스키마가 바뀌면 파서 수정이 필요하다.
|
||||
- 구매, 장바구니, 결제는 사용자가 직접 진행해야 한다.
|
||||
67
docs/features/saramin-talent-search.md
Normal file
67
docs/features/saramin-talent-search.md
Normal 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`로 낮은 신뢰도를 표시한다.
|
||||
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.
|
||||
82
docs/features/seoul-bike.md
Normal file
82
docs/features/seoul-bike.md
Normal 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)
|
||||
|
|
@ -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을 조회 전용으로 파싱합니다.
|
||||
- 결제 완료까지 자동화하는 문서는 아닙니다.
|
||||
- 매진 시 공격적인 재시도 루프는 피합니다.
|
||||
|
|
|
|||
|
|
@ -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에 포함하지 않는다.
|
||||
|
|
|
|||
|
|
@ -86,11 +86,11 @@ npx --yes skills add <owner/repo> \
|
|||
--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 \
|
||||
|
|
@ -122,25 +122,14 @@ npx --yes skills add <owner/repo> \
|
|||
--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)를 본다.
|
||||
|
||||
|
|
@ -330,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
|
||||
|
|
@ -400,6 +405,7 @@ node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profi
|
|||
- `ktx-booking`
|
||||
- `seoul-subway-arrival`
|
||||
- `seoul-density`
|
||||
- `seoul-bike`
|
||||
- `korea-weather`
|
||||
- `fine-dust-location`
|
||||
- `korean-law-search`
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@
|
|||
- 근처 가장 싼 주유소 찾기 스킬 출시
|
||||
- 근처 공중화장실 찾기 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
- 근처 술집 조회 스킬 출시
|
||||
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
|
||||
- 다이소 상품 조회 스킬 출시
|
||||
|
|
@ -46,6 +45,7 @@
|
|||
- 한국어 맞춤법 검사 스킬 출시
|
||||
- 한국어 글자 수 세기 스킬 출시
|
||||
- 긱뉴스 조회 스킬 출시
|
||||
- 오늘의집 오늘의딜 조회 스킬 출시
|
||||
|
||||
## v1.5 candidates
|
||||
|
||||
|
|
@ -108,9 +108,9 @@
|
|||
- 장점: 모바일 주민등록증·운전면허증 발급 흐름 정리에 특화할 수 있다
|
||||
- 이유: 한국 특화성이 강하고 가이드형 스킬로 출발하기 좋다
|
||||
|
||||
#### 버스/지하철 도착정보 조회
|
||||
#### 버스/지하철/따릉이 도착·가용정보 조회
|
||||
|
||||
- 장점: 주변 정류소, 지하철, 공항버스, 버스정보 조회까지 출퇴근 수요가 강하다
|
||||
- 장점: 주변 정류소, 지하철, 공항버스, 버스정보, 따릉이 대여 가능 자전거/빈 거치대까지 출퇴근·라스트마일 수요가 강하다
|
||||
- 이유: 이미 검증된 반복 조회 패턴이라 확장하기 쉽다
|
||||
|
||||
#### 네이버 생활 허브
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ KAKAO_REST_API_KEY=replace-me
|
|||
KSKILL_PROXY_BASE_URL=
|
||||
```
|
||||
|
||||
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크, 창업진흥원 K-Startup 조회도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
|
||||
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크, 창업진흥원 K-Startup 조회도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
|
||||
|
||||
## Missing secret handling policy
|
||||
|
||||
|
|
@ -80,6 +80,6 @@ KSKILL_PROXY_BASE_URL=
|
|||
- `KRX_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
`LAW_OC` 는 `korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy`의 `/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy`의 `/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 창업진흥원 K-Startup 조회도 `k-skill-proxy`의 `/v1/kstartup/*` 라우트를 거쳐 `ServiceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `KSKILL_KSTARTUP_API_KEY` 는 `--direct` 호출 문맥에서만 사용자 쪽에 둔다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
`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)를 본다.
|
||||
|
|
|
|||
|
|
@ -42,11 +42,9 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
실제 값을 채운다.
|
||||
|
||||
서울 지하철 도착정보, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. KOSIS 일반 조회와 Kakao Local geocoding도 같은 기본 hosted path를 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
|
||||
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `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` 가 불필요하다.
|
||||
|
||||
|
|
@ -80,8 +78,7 @@ bash scripts/check-setup.sh
|
|||
| 고속버스 예매 | 사용자 시크릿 불필요 (조회·좌석 단계는 공식 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` |
|
||||
| 하이패스 영수증 발급 | 사용자 시크릿 불필요 (로그인된 브라우저 세션 필요) |
|
||||
|
|
@ -89,6 +86,7 @@ bash scripts/check-setup.sh
|
|||
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 서울 지하철 도착정보 조회 | 사용자 시크릿 불필요 (기본 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 사용) |
|
||||
|
|
@ -124,6 +122,7 @@ bash scripts/check-setup.sh
|
|||
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)
|
||||
- [식품 안전 체크 가이드](features/mfds-food-safety.md)
|
||||
- [창업진흥원 K-Startup 조회 가이드](features/kstartup-search.md)
|
||||
- [지방선거 후보자 조회 가이드](features/local-election-candidate-search.md)
|
||||
- [보안/시크릿 정책](security-and-secrets.md)
|
||||
|
||||
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
- `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` 순서로 중계한다.
|
||||
- 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
|
||||
|
|
@ -17,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
|
||||
|
|
@ -89,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/
|
||||
|
|
@ -142,6 +146,9 @@
|
|||
- coupang_partners local MCP contract: local://coupang-mcp
|
||||
- coupang_partners hosted fallback (credentialless, allowlist-gated): https://a.retn.kr/v1/public/assist
|
||||
- coupang_partners hosted fallback PR (merged): https://github.com/retention-corp/coupang_partners/pull/1
|
||||
- 오늘의집 오늘의딜 공개 페이지: https://ohou.se/commerces/today_deals
|
||||
- 오늘의집 오늘의딜 canonical/OG URL: https://store.ohou.se/today_deals
|
||||
- 오늘의집 오늘의딜 데이터 표면: HTML `__NEXT_DATA__`의 `today-deal-feed`
|
||||
- bunjang-cli package: https://www.npmjs.com/package/bunjang-cli
|
||||
- bunjang-cli repo: https://github.com/pinion05/bunjangcli
|
||||
- 당근 메인: https://www.daangn.com/
|
||||
|
|
@ -151,9 +158,6 @@
|
|||
- 당근알바 검색 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://www.bluer.co.kr/
|
||||
- 블루리본 지역 검색: https://www.bluer.co.kr/search/zone
|
||||
- 블루리본 주변 맛집 JSON: https://www.bluer.co.kr/restaurants/map
|
||||
- 카카오맵 모바일 검색: https://m.map.kakao.com/actions/searchView
|
||||
- 카카오맵 장소 패널 JSON: https://place-api.map.kakao.com/places/panel3/<confirmId>
|
||||
- 조선왕조실록 메인: https://sillok.history.go.kr
|
||||
|
|
@ -173,6 +177,7 @@
|
|||
- 공중화장실정보 지역별 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
|
||||
|
|
@ -204,6 +209,28 @@
|
|||
- 도서관 정보나루 도서 소장 도서관 endpoint: https://data4library.kr/api/libSrchByBook
|
||||
- 도서관 정보나루 도서관별 도서 소장여부 endpoint: https://data4library.kr/api/bookExist
|
||||
|
||||
- 공공데이터포털 데이터셋(창업진흥원 K-Startup 조회서비스): https://www.data.go.kr/data/15125364/openapi.do
|
||||
- K-Startup Open API base URL: https://apis.data.go.kr/B552735/kisedKstartupService01 — `k-skill-proxy`의 `/v1/kstartup/business-info`, `/v1/kstartup/announcements`, `/v1/kstartup/contents`, `/v1/kstartup/statistics` 가 각각 `getBusinessInformation01`, `getAnnouncementInformation01`, `getContentInformation01`, `getStatisticalInformation01` 로 중계한다 (returnType=json 고정, ServiceKey 서버 측 주입)
|
||||
- K-Startup 공식 포털: https://www.k-startup.go.kr — 응답의 `detl_pg_url` 가 가리키는 사용자 진입점
|
||||
|
||||
### 지자체/유관기관 참고 사이트 (보조 소스)
|
||||
- **서울시 창업플러스**: 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차 인증 후 현재 보이는 마스킹 후보 정보를 읽는 브라우저 기반 경로. 유료 열람/연락처 확인/제안 발송은 수동 확인 대상.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
0
foresttrip-vacancy/tests/__init__.py
Normal file
0
foresttrip-vacancy/tests/__init__.py
Normal file
167
foresttrip-vacancy/tests/fixtures/geoje_window.json
vendored
Normal file
167
foresttrip-vacancy/tests/fixtures/geoje_window.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
62
foresttrip-vacancy/tests/fixtures/gujaebong_window.json
vendored
Normal file
62
foresttrip-vacancy/tests/fixtures/gujaebong_window.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
348
foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py
Normal file
348
foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py
Normal 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()
|
||||
67
fsc-corporate-info/SKILL.md
Normal file
67
fsc-corporate-info/SKILL.md
Normal 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`
|
||||
109
fsc-corporate-info/scripts/fsc_corporate_info.py
Normal file
109
fsc-corporate-info/scripts/fsc_corporate_info.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""FSC corporate-outline lookup via k-skill-proxy.
|
||||
|
||||
The proxy holds DATA_GO_KR_API_KEY server-side; this helper only builds the
|
||||
query and reads the structured response. No user secret is required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
|
||||
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
|
||||
ROUTE = "/v1/fsc/corp-outline"
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
def __init__(self, message: str, *, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _text_or_none(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def resolve_proxy_base_url(explicit: str | None = None, env: dict[str, str] | None = None) -> str:
|
||||
env = os.environ if env is None else env
|
||||
candidate = _text_or_none(explicit or env.get(PROXY_BASE_URL_ENV_VAR))
|
||||
if candidate and candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
|
||||
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
|
||||
if candidate and candidate != "replace-me":
|
||||
return candidate.rstrip("/")
|
||||
return DEFAULT_PROXY_BASE_URL
|
||||
|
||||
|
||||
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
try:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except json.JSONDecodeError as error:
|
||||
raise ApiError("fsc corp-outline proxy returned invalid JSON.") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise ApiError("fsc corp-outline proxy returned a non-object JSON payload.")
|
||||
return payload
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
payload = None
|
||||
if isinstance(payload, dict) and payload.get("message"):
|
||||
raise ApiError(str(payload["message"]), status_code=error.code) from error
|
||||
raise ApiError(f"fsc corp-outline proxy request failed with HTTP {error.code}", status_code=error.code) from error
|
||||
except urllib.error.URLError as error:
|
||||
raise ApiError(f"fsc corp-outline proxy request failed: {error.reason}") from error
|
||||
|
||||
|
||||
def query_corp_outline(name: str, b_no: str | None = None, *, base_url: str | None = None,
|
||||
read_json: Any = read_json_response) -> dict[str, Any]:
|
||||
name = _text_or_none(name)
|
||||
if not name:
|
||||
raise ValueError("법인명(corpNm)을 입력하세요. 이 API는 사업자번호 단독 조회가 불가합니다.")
|
||||
params = {"name": name}
|
||||
if _text_or_none(b_no):
|
||||
digits = re.sub(r"\D", "", str(b_no))
|
||||
if not re.fullmatch(r"\d{10}", digits):
|
||||
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다 (하이픈 허용).")
|
||||
params["b_no"] = digits
|
||||
url = f"{resolve_proxy_base_url(base_url)}{ROUTE}?{urllib.parse.urlencode(params)}"
|
||||
request = urllib.request.Request(url, headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "k-skill-fsc-corporate-info/1.0",
|
||||
}, method="GET")
|
||||
return read_json(request)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="금융위 기업기본정보(법인 개요) 조회 (k-skill-proxy 경유)")
|
||||
parser.add_argument("--name", required=True, help="법인명(corpNm) — 필수")
|
||||
parser.add_argument("--b-no", help="사업자등록번호 — 응답에 bzno가 있을 때 교차검증에만 사용")
|
||||
parser.add_argument("--proxy-base-url")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
result = query_corp_outline(args.name, args.b_no, base_url=args.proxy_base_url)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except (ValueError, ApiError) as error:
|
||||
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
71
g2b-sanctioned-supplier/SKILL.md
Normal file
71
g2b-sanctioned-supplier/SKILL.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
name: g2b-sanctioned-supplier
|
||||
description: 조달청 나라장터 부정당제재업체정보를 공공데이터포털 API(k-skill-proxy 경유)로 조회한다. 사업자등록번호 정확 일치로 조회시점 현재 유효한 입찰참가자격 제한(부정당제재)의 기간·제재기관·근거법률을 확인한다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: business
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# 나라장터 부정당제재업체정보 조회
|
||||
|
||||
## What this skill does
|
||||
|
||||
공공데이터포털의 **조달청 나라장터 사용자정보 서비스**(data.go.kr 15129466, `getUnptRsttCorpInfo02`)를 `k-skill-proxy` 경유로 호출해, 사업자등록번호 정확 일치(`inqryDiv=1`)로 **조회시점 현재 유효한** 부정당제재를 조회한다.
|
||||
|
||||
- 반환: 제재 시작/종료일자, 제재기관명, 계약법구분, 제재근거법률 등 upstream 필드 원문
|
||||
|
||||
## Coverage boundary
|
||||
|
||||
upstream 명세상 다음은 **제공되지 않는다** — 과거 이력 조회가 아니다.
|
||||
|
||||
- 조회시점에 제재만료·해제된 건
|
||||
- 나라장터 미등록업체·개인에 대한 제재
|
||||
|
||||
만료 이력까지 보려면 나라장터(<https://www.g2b.go.kr>)에서 수동 확인이 필요하다.
|
||||
|
||||
## Design principles
|
||||
|
||||
- 점수·등급·해석 라벨을 만들지 않는다. upstream 사실 + 출처 + 적용범위 한계만 담는다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "이 회사 입찰 제재(부정당제재) 이력 있어?"
|
||||
- "거래/계약 전에 부정당업자 제재 여부 확인해줘"
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 인터넷 연결, `python3`
|
||||
- `scripts/g2b_sanctioned_supplier.py` helper
|
||||
- hosted/self-host `k-skill-proxy`의 `/v1/g2b/sanctioned-supplier` route 접근 가능
|
||||
|
||||
## Credential requirements
|
||||
|
||||
- 사용자 측 필수 시크릿 없음.
|
||||
- `KSKILL_PROXY_BASE_URL` — self-host 프록시를 쓸 때만 설정. 비우면 hosted `https://k-skill-proxy.nomadamas.org` 사용.
|
||||
- `DATA_GO_KR_API_KEY` 는 프록시 운영 서버 환경에만 둔다. 공공데이터포털에서 `조달청_나라장터 사용자정보 서비스`(부정당제재업체정보조회 포함) 활용신청이 되어 있어야 한다.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `--bizno`: 사업자등록번호 10자리(하이픈 허용) — 필수
|
||||
|
||||
## CLI examples
|
||||
|
||||
```bash
|
||||
python3 g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py --bizno 124-81-00998
|
||||
```
|
||||
|
||||
## Failure modes
|
||||
|
||||
- `400 bad_request`: 사업자번호가 10자리가 아님.
|
||||
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 없음.
|
||||
- `502 upstream_forbidden`: 프록시 키가 15129466에 활용신청되지 않음.
|
||||
- `total_count = 0`: 조회시점 현재 유효한 제재 없음 (만료·미등록업체는 미제공임에 유의).
|
||||
|
||||
## Official surfaces
|
||||
|
||||
- 공공데이터포털: <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`
|
||||
110
g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py
Normal file
110
g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Procurement (나라장터) sanctioned-supplier lookup via k-skill-proxy.
|
||||
|
||||
The proxy holds DATA_GO_KR_API_KEY server-side; this helper only builds the
|
||||
query and reads the structured response. No user secret is required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
|
||||
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
|
||||
ROUTE = "/v1/g2b/sanctioned-supplier"
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
def __init__(self, message: str, *, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _text_or_none(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def resolve_proxy_base_url(explicit: str | None = None, env: dict[str, str] | None = None) -> str:
|
||||
env = os.environ if env is None else env
|
||||
candidate = _text_or_none(explicit or env.get(PROXY_BASE_URL_ENV_VAR))
|
||||
if candidate and candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
|
||||
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
|
||||
if candidate and candidate != "replace-me":
|
||||
return candidate.rstrip("/")
|
||||
return DEFAULT_PROXY_BASE_URL
|
||||
|
||||
|
||||
def normalize_bizno(value: Any) -> str:
|
||||
raw = _text_or_none(value)
|
||||
if not raw:
|
||||
raise ValueError("사업자등록번호(bizno)를 입력하세요.")
|
||||
normalized = re.sub(r"\D", "", raw)
|
||||
if not re.fullmatch(r"\d{10}", normalized):
|
||||
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다.")
|
||||
return normalized
|
||||
|
||||
|
||||
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
try:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except json.JSONDecodeError as error:
|
||||
raise ApiError("g2b sanction proxy returned invalid JSON.") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise ApiError("g2b sanction proxy returned a non-object JSON payload.")
|
||||
return payload
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
payload = None
|
||||
if isinstance(payload, dict) and payload.get("message"):
|
||||
raise ApiError(str(payload["message"]), status_code=error.code) from error
|
||||
raise ApiError(f"g2b sanction proxy request failed with HTTP {error.code}", status_code=error.code) from error
|
||||
except urllib.error.URLError as error:
|
||||
raise ApiError(f"g2b sanction proxy request failed: {error.reason}") from error
|
||||
|
||||
|
||||
def query_sanctions(bizno: str, *, base_url: str | None = None,
|
||||
read_json: Any = read_json_response) -> dict[str, Any]:
|
||||
normalized = normalize_bizno(bizno)
|
||||
url = f"{resolve_proxy_base_url(base_url)}{ROUTE}?{urllib.parse.urlencode({'bizno': normalized})}"
|
||||
request = urllib.request.Request(url, headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "k-skill-g2b-sanctioned-supplier/1.0",
|
||||
}, method="GET")
|
||||
return read_json(request)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="나라장터 부정당제재업체정보 조회 (k-skill-proxy 경유)")
|
||||
parser.add_argument("--bizno", required=True, help="사업자등록번호 10자리(하이픈 허용)")
|
||||
parser.add_argument("--proxy-base-url")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
result = query_sanctions(args.bizno, base_url=args.proxy_base_url)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except (ValueError, ApiError) as error:
|
||||
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
130
jobkorea-talent-search/SKILL.md
Normal file
130
jobkorea-talent-search/SKILL.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
name: jobkorea-talent-search
|
||||
description: 잡코리아 기업회원 로그인 세션으로 유료 열람 전 마스킹된 인재 이력서를 검색·비교해 채용 검토용 shortlist를 만듭니다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: recruiting
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# jobkorea-talent-search
|
||||
|
||||
잡코리아 기업 인재검색에서 유료 열람/포지션 제안 전에 현재 보이는 마스킹 이력서와 목록 정보를 비교해 “열람할 만한 후보”를 추천한다. 개발자 전용이 아니며 모든 직무에 role-adaptive하게 적용한다.
|
||||
|
||||
## Use when
|
||||
|
||||
- 사용자가 잡코리아에서 후보를 찾아달라고 요청한다.
|
||||
- 기업회원 로그인 세션에서 마스킹 이력서/목록을 비교해야 한다.
|
||||
- 유료 열람 전 shortlist, 점수, 근거, 리스크, 후보 URL이 필요하다.
|
||||
|
||||
## Hard boundaries
|
||||
|
||||
Allowed:
|
||||
- 잡코리아 기업회원 브라우저 세션 열기 및 검색 필터 입력
|
||||
- 현재 보이는 마스킹 목록/이력서/프로필 읽기
|
||||
- 후보 분석, 점수화, shortlist 작성, 유료 열람 추천
|
||||
|
||||
Never do without explicit user handoff/confirmation:
|
||||
- 유료 이력서 열람, 마스킹 해제, 연락처 확인
|
||||
- 포지션 제안 발송, 스크랩, 메모 저장, 후보 상태 변경
|
||||
- 결제/유료 크레딧 사용
|
||||
- 비밀번호, OTP, 인증번호, 세션 쿠키 요청/저장
|
||||
- 후보 개인정보 장기 저장 또는 대량 수집
|
||||
|
||||
If a control may spend credits, reveal contact/private info, send a proposal, or mutate account state, stop before clicking it.
|
||||
|
||||
## Primary access
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
https://www.jobkorea.co.kr/corp/person/find
|
||||
```
|
||||
|
||||
If not logged in, pause and show:
|
||||
|
||||
```text
|
||||
잡코리아 인재검색은 경력 상세/포트폴리오/마스킹 이력서 확인을 위해 기업회원 로그인이 필요합니다.
|
||||
제가 브라우저로 잡코리아 기업 인재검색 페이지를 열어둘게요.
|
||||
열린 브라우저에서 직접 로그인해 주세요. 비밀번호나 인증정보는 저에게 알려주지 마세요.
|
||||
로그인이 끝나면 “로그인했어”라고 알려주시면, 같은 브라우저 세션에서 검색을 이어가겠습니다.
|
||||
```
|
||||
|
||||
Resume only in the same browser session after the user confirms login.
|
||||
|
||||
## Input normalization
|
||||
|
||||
Extract or infer:
|
||||
|
||||
- role_title
|
||||
- must_have / nice_to_have
|
||||
- negative_keywords
|
||||
- career min/max
|
||||
- location/work_area
|
||||
- role-specific evaluation signals
|
||||
- limit / requested Top N
|
||||
|
||||
Do not block on missing details when a reasonable first search is possible.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Open the primary URL and verify corporate login.
|
||||
2. Ask the user to log in manually only when required; never handle credentials.
|
||||
3. Apply filters: keyword, 직무/스킬, 지역, 경력, recent activity/update, exclusions when supported.
|
||||
4. Build a candidate pool from visible rows.
|
||||
5. Before final Top N, open normal resume/detail links for promising candidates when this does not trigger paid unlock/contact/proposal actions.
|
||||
6. Read only visible free/masked details: career, responsibilities, project/achievement evidence, skills, education/certs/languages, desired location/salary, portfolio links if visible, recent activity.
|
||||
7. Score role-adaptively: must-have fit, career depth, concrete achievement/project evidence, location/activity fit, nice-to-have signals, and risk penalty.
|
||||
8. Return Korean shortlist with direct URL per recommended candidate.
|
||||
|
||||
If detail pages are inaccessible or paid-walled, label results as `목록 기반 1차 shortlist` and lower confidence. If detail text was inspected, label as `상세 이력 확인 기반 shortlist`.
|
||||
|
||||
## No-login fallback
|
||||
|
||||
Use only when the user cannot or will not log in. It is low-confidence because it cannot inspect resume details.
|
||||
|
||||
```bash
|
||||
python3 jobkorea-talent-search/scripts/jobkorea_talent_search.py --keyword "퍼포먼스 마케터 GA4" --work-area "서울" --career-min 3 --career-max 7 --limit 20
|
||||
```
|
||||
|
||||
## URL extraction guidance
|
||||
|
||||
Every recommended candidate needs a direct JobKorea resume/profile URL whenever available. If browser extraction fails, inspect anchors, onclick handlers, data attributes, card containers, and detail-page `location.href`. If still missing, write `URL: 추출 실패` and explain why.
|
||||
|
||||
## Output shape
|
||||
|
||||
```text
|
||||
잡코리아 인재 shortlist
|
||||
|
||||
검색 조건
|
||||
- 포지션: ...
|
||||
- 필수/우대/제외 조건: ...
|
||||
- 경력/지역: ...
|
||||
- 모드: 상세 이력 확인 기반 shortlist / 목록 기반 1차 shortlist
|
||||
|
||||
유료 열람 추천 Top N
|
||||
1. 후보 A
|
||||
- 점수: 88/100
|
||||
- 근거: ...
|
||||
- 보이는 경력/성과: ...
|
||||
- 리스크: ...
|
||||
- 추천 액션: 채용 담당자가 유료 열람 검토
|
||||
- URL: ...
|
||||
|
||||
보류 후보
|
||||
- ...
|
||||
|
||||
검색 한계
|
||||
- 마스킹/현재 표시 정보만 분석했음
|
||||
- 연락처/실명/비공개 정보는 열람하지 않음
|
||||
- 유료 액션은 실행하지 않음
|
||||
```
|
||||
|
||||
## Failure modes
|
||||
|
||||
- Login/2FA required: open the page and let the user complete it manually.
|
||||
- Browser/session unavailable: explain that the agent needs browser/computer-use access; do not silently switch to low-confidence fallback.
|
||||
- Paid wall/contact wall: stop and mark as manual paid review needed.
|
||||
- Empty results: adjust keywords, career, region, update/relevance filters.
|
||||
- UI changed: rediscover the visible form/data flow before updating scripts.
|
||||
27
jobkorea-talent-search/scripts/jobkorea_talent_models.py
Normal file
27
jobkorea-talent-search/scripts/jobkorea_talent_models.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
BASE_URL: Final = "https://www.jobkorea.co.kr"
|
||||
FIND_PATH: Final = "/corp/person/find"
|
||||
AJAX_PATH: Final = "/corp/person/detailsearchajax"
|
||||
DEFAULT_UA: Final = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Candidate:
|
||||
rno: str
|
||||
url: str
|
||||
name: str = ""
|
||||
meta: str = ""
|
||||
career: str = ""
|
||||
education: str = ""
|
||||
locations: str = ""
|
||||
salary: str = ""
|
||||
skills: str = ""
|
||||
badges: str = ""
|
||||
raw_summary: str = ""
|
||||
186
jobkorea-talent-search/scripts/jobkorea_talent_parse.py
Normal file
186
jobkorea-talent-search/scripts/jobkorea_talent_parse.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import urllib.parse
|
||||
|
||||
from jobkorea_talent_models import BASE_URL, Candidate
|
||||
|
||||
ACTION_CONTROL_RE = re.compile(
|
||||
r"^(?:스크랩\s*\d*|저장하기|닫기|포지션\s*제안|메모하기|프로필\s*확인|이력서\s*확인|펼쳐보기|접기|이전|다음)$"
|
||||
)
|
||||
ACTION_CONTROL_INLINE_RE = re.compile(
|
||||
r"(?:스크랩\s*\d+|저장하기|닫기|포지션\s*제안|메모하기|프로필\s*확인|이력서\s*확인|펼쳐보기|접기|이전|다음)"
|
||||
)
|
||||
RESUME_LINK_RE = re.compile(r'href="(?P<href>/corp/person/find/resume/view\?rNo=(?P<rno>\d+))"')
|
||||
|
||||
|
||||
def clean_text(value: str) -> str:
|
||||
value = html.unescape(value)
|
||||
value = re.sub(r"<script[\s\S]*?</script>", " ", value, flags=re.I)
|
||||
value = re.sub(r"<style[\s\S]*?</style>", " ", value, flags=re.I)
|
||||
value = re.sub(r"<[^>]+>", " ", value)
|
||||
value = re.sub(r"[ \t\r\f\v]+", " ", value)
|
||||
value = re.sub(r"\n\s*\n+", "\n", value)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def is_action_control_label(value: str) -> bool:
|
||||
label = re.sub(r"\s+", " ", html.unescape(value)).strip()
|
||||
return bool(label and ACTION_CONTROL_RE.match(label))
|
||||
|
||||
|
||||
def filter_action_control_text(value: str) -> str:
|
||||
lines = []
|
||||
for line in value.splitlines():
|
||||
label = line.strip()
|
||||
if not label or is_action_control_label(label):
|
||||
continue
|
||||
label = ACTION_CONTROL_INLINE_RE.sub(" ", label)
|
||||
label = re.sub(r"\s+", " ", label).strip()
|
||||
if label:
|
||||
lines.append(label)
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def row_contains_other_resume(candidate_markup: str, rno: str) -> bool:
|
||||
refs: list[str] = []
|
||||
for href_rno, data_rno in re.findall(r"rNo=(\d+)|data-rno=[\"'](\d+)[\"']", candidate_markup):
|
||||
refs.append(href_rno or data_rno)
|
||||
return any(ref != rno for ref in refs)
|
||||
|
||||
|
||||
def extract_regex_candidate_markup(markup: str, match: re.Match[str], rno: str) -> str:
|
||||
row_start = markup.rfind("<tr", 0, match.start())
|
||||
if row_start >= 0:
|
||||
row_open_end = markup.find(">", row_start, match.start())
|
||||
row_end = markup.find("</tr>", match.end())
|
||||
row_open = markup[row_start : row_open_end + 1] if row_open_end >= 0 else ""
|
||||
if row_end >= 0 and f'data-rno="{rno}"' in row_open:
|
||||
return markup[row_start : row_end + len("</tr>")]
|
||||
|
||||
booth_start = markup.rfind('<div class="booth"', 0, match.start())
|
||||
if booth_start >= 0:
|
||||
next_booth = markup.find('<div class="booth"', match.end())
|
||||
section_end = markup.find("</section>", match.end())
|
||||
end_candidates = [pos for pos in (next_booth, section_end) if pos >= 0]
|
||||
booth_end = min(end_candidates) if end_candidates else min(len(markup), match.end() + 2500)
|
||||
booth = markup[booth_start:booth_end]
|
||||
if not row_contains_other_resume(booth, rno):
|
||||
return booth
|
||||
|
||||
start = max(0, match.start() - 300)
|
||||
end = min(len(markup), match.end() + 1200)
|
||||
return markup[start:end]
|
||||
|
||||
|
||||
def parse_with_bs4(markup: str, limit: int) -> list[Candidate] | None:
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
soup = BeautifulSoup(markup, "html.parser")
|
||||
candidates: list[Candidate] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for link in soup.select('a[href*="/corp/person/find/resume/view?rNo="]'):
|
||||
raw_href = link.get("href", "")
|
||||
href = raw_href if isinstance(raw_href, str) else ""
|
||||
matched_rno = re.search(r"rNo=(\d+)", href)
|
||||
if not matched_rno:
|
||||
continue
|
||||
rno = matched_rno.group(1)
|
||||
if rno in seen:
|
||||
continue
|
||||
seen.add(rno)
|
||||
|
||||
container = (
|
||||
link.find_parent("tr", attrs={"data-rno": rno})
|
||||
or link.find_parent(class_=re.compile(r"(^|\s)booth(\s|$)", re.I))
|
||||
or link.parent
|
||||
)
|
||||
if container and row_contains_other_resume(str(container), rno):
|
||||
container = link.parent
|
||||
|
||||
raw = clean_text(str(container)) if container else clean_text(str(link))
|
||||
texts = []
|
||||
for node in container.find_all(["dt", "dd", "p", "span", "li"]) if container else []:
|
||||
label = node.get_text(" ", strip=True)
|
||||
if label and not is_action_control_label(label):
|
||||
texts.append(label)
|
||||
for btn in container.select(".keywordSkill button, .keywordBox button") if container else []:
|
||||
label = btn.get_text(" ", strip=True)
|
||||
if label and not is_action_control_label(label):
|
||||
texts.append(label)
|
||||
text_join = " | ".join(dict.fromkeys(texts))
|
||||
|
||||
name_scope = container.select_one(".nameAge") if container else None
|
||||
dt = (name_scope or container).find("dt") if container else None
|
||||
name = dt.get_text(" ", strip=True) if dt else ""
|
||||
dd = dt.find_next("dd") if dt else None
|
||||
meta = dd.get_text(" ", strip=True) if dd else ""
|
||||
if not name:
|
||||
m_name = re.search(r"([가-힣A-Za-z]OO)\s*\(([^)]*)\)", raw)
|
||||
if m_name:
|
||||
name = m_name.group(1)
|
||||
meta = "(" + m_name.group(2) + ")"
|
||||
|
||||
skills = []
|
||||
for btn in container.select(".keywordSkill button, .keywordBox button") if container else []:
|
||||
label = btn.get_text(" ", strip=True)
|
||||
if label and not is_action_control_label(label):
|
||||
skills.append(label)
|
||||
|
||||
career_node = container.select_one(".career") if container else None
|
||||
candidates.append(
|
||||
Candidate(
|
||||
rno=rno,
|
||||
url=urllib.parse.urljoin(BASE_URL, href),
|
||||
name=name,
|
||||
meta=meta,
|
||||
career=career_node.get_text(" ", strip=True) if career_node else "",
|
||||
skills=", ".join(skills[:25]),
|
||||
raw_summary=filter_action_control_text(text_join[:1000] or raw[:1000]),
|
||||
)
|
||||
)
|
||||
if len(candidates) >= limit:
|
||||
break
|
||||
return candidates
|
||||
|
||||
|
||||
def parse_with_regex(markup: str, limit: int) -> list[Candidate]:
|
||||
candidates: list[Candidate] = []
|
||||
seen: set[str] = set()
|
||||
for match in RESUME_LINK_RE.finditer(markup):
|
||||
rno = match.group("rno")
|
||||
if rno in seen:
|
||||
continue
|
||||
seen.add(rno)
|
||||
raw_markup = extract_regex_candidate_markup(markup, match, rno)
|
||||
raw = clean_text(raw_markup)
|
||||
name = ""
|
||||
meta = ""
|
||||
name_match = re.search(r"([가-힣A-Za-z]OO)\s*\(([^)]*)\)", raw)
|
||||
if name_match:
|
||||
name = name_match.group(1)
|
||||
meta = "(" + name_match.group(2) + ")"
|
||||
candidates.append(
|
||||
Candidate(
|
||||
rno=rno,
|
||||
url=urllib.parse.urljoin(BASE_URL, match.group("href")),
|
||||
name=name,
|
||||
meta=meta,
|
||||
raw_summary=filter_action_control_text(raw[:1000]),
|
||||
)
|
||||
)
|
||||
if len(candidates) >= limit:
|
||||
break
|
||||
return candidates
|
||||
|
||||
|
||||
def parse_candidates(markup: str, limit: int) -> list[Candidate]:
|
||||
parsed = parse_with_bs4(markup, limit)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return parse_with_regex(markup, limit)
|
||||
94
jobkorea-talent-search/scripts/jobkorea_talent_search.py
Executable file
94
jobkorea-talent-search/scripts/jobkorea_talent_search.py
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Search public JobKorea talent summaries.
|
||||
|
||||
This helper uses JobKorea's browser-visible corporate talent search page and its
|
||||
same AJAX endpoint. It only reads public/obfuscated list summaries. Full resume
|
||||
view, contact details, scraping at scale, scrap/bookmark, and position proposal
|
||||
flows are intentionally out of scope because they require an employer account,
|
||||
paid entitlements, or user confirmation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
from dataclasses import asdict
|
||||
|
||||
from jobkorea_talent_models import Candidate
|
||||
from jobkorea_talent_parse import clean_text, parse_candidates
|
||||
from jobkorea_talent_search_condition import build_search_condition, post_search
|
||||
|
||||
__all__ = ["parse_candidates"]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Search public JobKorea talent summaries")
|
||||
parser.add_argument("--keyword", "-k", action="append", default=[], help="통합검색 키워드. 여러 번 지정 가능")
|
||||
parser.add_argument("--and-keyword", action="append", default=[], help="AND 키워드")
|
||||
parser.add_argument("--or-keyword", action="append", default=[], help="OR 키워드")
|
||||
parser.add_argument("--exclude-keyword", action="append", default=[], help="제외 키워드")
|
||||
parser.add_argument("--job-category", action="append", default=[], help="직무 대분류명 예: AI·개발·데이터")
|
||||
parser.add_argument("--work-area", action="append", default=[], help="희망 근무지역 예: 서울, 강남구, 경기")
|
||||
parser.add_argument("--residential-area", action="append", default=[], help="거주지역 예: 서울, 성남시 분당구")
|
||||
parser.add_argument("--career-min", type=int, help="최소 경력 연수")
|
||||
parser.add_argument("--career-max", type=int, help="최대 경력 연수")
|
||||
parser.add_argument("--page", type=int, default=1)
|
||||
parser.add_argument("--limit", type=int, default=20, choices=[10, 20, 30, 50, 100])
|
||||
parser.add_argument("--sort", default="0", help="잡코리아 sf 정렬 코드. 기본 0")
|
||||
parser.add_argument("--json", action="store_true", help="JSON으로 출력")
|
||||
return parser
|
||||
|
||||
|
||||
def print_markdown(candidates: list[Candidate], matched: dict[str, list[str]], args: argparse.Namespace) -> None:
|
||||
print("# 잡코리아 인재검색 결과\n")
|
||||
print(f"- 검색어: {', '.join(args.keyword + args.and_keyword + args.or_keyword) or '(없음)'}")
|
||||
print(f"- 제외어: {', '.join(args.exclude_keyword) or '(없음)'}")
|
||||
if any(matched.values()):
|
||||
print(f"- 매칭된 필터: {json.dumps(matched, ensure_ascii=False)}")
|
||||
print(f"- 결과 수: {len(candidates)}")
|
||||
print("- 주의: 이름/회사명은 잡코리아 공개 화면 기준으로 마스킹되어 있으며, 상세 이력서 확인·포지션 제안은 기업회원 로그인/권한/사용자 확인이 필요합니다.\n")
|
||||
for idx, candidate in enumerate(candidates, 1):
|
||||
c = candidate
|
||||
bits = [c.name, c.meta, c.career]
|
||||
title = " ".join(x for x in bits if x).strip() or f"rNo={c.rno}"
|
||||
print(f"## {idx}. {title}")
|
||||
print(f"- URL: {c.url}")
|
||||
if c.skills:
|
||||
print(f"- 키워드/스킬: {c.skills}")
|
||||
summary = c.raw_summary.replace("\n", " ")
|
||||
if summary:
|
||||
print(f"- 요약: {summary[:500]}")
|
||||
print()
|
||||
|
||||
|
||||
def run(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not (args.keyword or args.and_keyword or args.or_keyword or args.job_category or args.work_area or args.residential_area):
|
||||
parser.error("최소 하나 이상의 --keyword, --job-category, --work-area 등을 지정하세요")
|
||||
|
||||
sc, matched = build_search_condition(args)
|
||||
markup = post_search(sc)
|
||||
cleaned = clean_text(markup)
|
||||
if "로그인" in cleaned[:500] and "인재" not in cleaned[:2000]:
|
||||
raise RuntimeError("잡코리아가 로그인/차단 화면을 반환했습니다")
|
||||
candidates = parse_candidates(markup, args.limit)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"matched_filters": matched, "candidates": [asdict(c) for c in candidates]}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print_markdown(candidates, matched, args)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(run())
|
||||
except urllib.error.HTTPError as exc:
|
||||
print(f"HTTP error: {exc.code} {exc.reason}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
except (RuntimeError, urllib.error.URLError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from jobkorea_talent_models import AJAX_PATH, BASE_URL, DEFAULT_UA, FIND_PATH
|
||||
|
||||
|
||||
def fetch(url: str, *, data: bytes | None = None, headers: dict[str, str] | None = None) -> str:
|
||||
req_headers = {"User-Agent": DEFAULT_UA, "Referer": BASE_URL + FIND_PATH}
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST" if data else "GET")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8", "ignore")
|
||||
|
||||
|
||||
def extract_json_object(source: str, marker: str) -> dict[str, Any]:
|
||||
idx = source.find(marker)
|
||||
if idx < 0:
|
||||
raise RuntimeError(f"cannot find marker: {marker}")
|
||||
start = source.find("{", idx)
|
||||
if start < 0:
|
||||
raise RuntimeError("cannot find JSON object start")
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for pos in range(start, len(source)):
|
||||
ch = source[pos]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
loaded = json.loads(source[start : pos + 1])
|
||||
if not isinstance(loaded, dict):
|
||||
raise RuntimeError("search condition was not a JSON object")
|
||||
return loaded
|
||||
raise RuntimeError("unterminated JSON object")
|
||||
|
||||
|
||||
def iter_nodes(node: Any) -> Iterator[dict[str, Any]]:
|
||||
if isinstance(node, dict):
|
||||
yield node
|
||||
for value in node.values():
|
||||
yield from iter_nodes(value)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from iter_nodes(item)
|
||||
|
||||
|
||||
def mark_matching_nodes(sc: dict[str, Any], top_key: str, labels: list[str]) -> list[str]:
|
||||
if not labels:
|
||||
return []
|
||||
section = sc.get(top_key)
|
||||
if section is None:
|
||||
return []
|
||||
wanted = [x.strip().lower() for x in labels if x.strip()]
|
||||
matched: list[str] = []
|
||||
for node in iter_nodes(section):
|
||||
title = str(node.get("t", ""))
|
||||
code = str(node.get("v", ""))
|
||||
title_l = title.lower()
|
||||
code_l = code.lower()
|
||||
if any(w == title_l or w == code_l or w in title_l for w in wanted):
|
||||
for key in ("s", "c", "use"):
|
||||
if key in node:
|
||||
node[key] = 1
|
||||
matched.append(title or code)
|
||||
return matched
|
||||
|
||||
|
||||
def build_search_condition(args: argparse.Namespace) -> tuple[dict[str, Any], dict[str, list[str]]]:
|
||||
first = fetch(BASE_URL + FIND_PATH)
|
||||
sc = extract_json_object(first, "var searchcondition =")
|
||||
|
||||
sc["p"] = args.page
|
||||
sc["ps"] = args.limit
|
||||
sc["saveno"] = 0
|
||||
sc["ff"] = 0
|
||||
sc["sf"] = args.sort
|
||||
|
||||
terms: list[dict[str, Any]] = []
|
||||
for kw in args.keyword:
|
||||
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 0})
|
||||
for kw in args.and_keyword:
|
||||
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 1})
|
||||
for kw in args.or_keyword:
|
||||
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 3})
|
||||
for kw in args.exclude_keyword:
|
||||
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 2})
|
||||
sc["totalkeywordlist"] = terms
|
||||
|
||||
if terms:
|
||||
first_kw = terms[0]["t"]
|
||||
sc.setdefault("pfr", {}).setdefault("ck", {})["Keyword"] = first_kw
|
||||
sc["pfr"]["ck"]["KeywordType"] = 1
|
||||
sc["pfr"]["n"] = 1
|
||||
|
||||
if args.career_min is not None:
|
||||
sc.setdefault("career", {})["s"] = str(args.career_min)
|
||||
if args.career_max is not None:
|
||||
sc.setdefault("career", {})["e"] = str(args.career_max)
|
||||
|
||||
matched = {
|
||||
"job_category": mark_matching_nodes(sc, "jobtype", args.job_category),
|
||||
"work_area": mark_matching_nodes(sc, "workarea", args.work_area),
|
||||
"residential_area": mark_matching_nodes(sc, "residentialarea", args.residential_area),
|
||||
}
|
||||
return sc, matched
|
||||
|
||||
|
||||
def post_search(sc: dict[str, Any]) -> str:
|
||||
body = urllib.parse.urlencode({"searchCondition": json.dumps(sc, ensure_ascii=False)}).encode()
|
||||
return fetch(
|
||||
BASE_URL + AJAX_PATH,
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fixture tests for JobKorea public fallback parsing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).with_name("jobkorea_talent_search.py")
|
||||
sys.path.insert(0, str(SCRIPT.parent))
|
||||
spec = importlib.util.spec_from_file_location("jobkorea_talent_search", SCRIPT)
|
||||
assert spec is not None
|
||||
helper = importlib.util.module_from_spec(spec)
|
||||
sys.modules["jobkorea_talent_search"] = helper
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(helper)
|
||||
|
||||
|
||||
FALLBACK_FIXTURE = """
|
||||
<section class="searchList">
|
||||
<table class="tblSearchList">
|
||||
<tbody>
|
||||
<tr class="dvResumeTr" data-rno="111">
|
||||
<td class="tdProfile">
|
||||
<dl class="nameAge"><dt><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=111" data-rno="111">김OO</a></dt><dd>(여, 만 29세)</dd></dl>
|
||||
<ul class="bullList"><li>25분전 공고 스크랩</li></ul>
|
||||
</td>
|
||||
<td class="tdSummary">
|
||||
<div class="userInfoBox">
|
||||
<span class="career">경력 4년</span>
|
||||
<p class="title"><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=111" data-rno="111">퍼포먼스 마케터</a></p>
|
||||
<div class="keywordSkill keywordBox">
|
||||
<button type="button" class="js-kwrdSearch">Google Analytics</button>
|
||||
<button type="button" class="js-kwrdSearch">GA4</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="tdAction">
|
||||
<button>스크랩 1</button><button>이력서 확인</button><button>포지션 제안</button><button>메모하기</button><button>저장하기</button><button>닫기</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="dvResumeTr" data-rno="222">
|
||||
<td class="tdProfile">
|
||||
<dl class="nameAge"><dt><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=222" data-rno="222">박OO</a></dt><dd>(남, 만 31세)</dd></dl>
|
||||
</td>
|
||||
<td class="tdSummary">
|
||||
<span class="career">경력 6년</span>
|
||||
<p class="title"><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=222" data-rno="222">브랜드 마케터</a></p>
|
||||
<div class="keywordSkill keywordBox"><button type="button" class="js-kwrdSearch">브랜딩</button></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
"""
|
||||
|
||||
|
||||
class JobKoreaFallbackParserTest(unittest.TestCase):
|
||||
def test_parser_keeps_each_candidate_inside_its_own_row(self) -> None:
|
||||
candidates = helper.parse_candidates(FALLBACK_FIXTURE, 10)
|
||||
|
||||
self.assertEqual([c.rno for c in candidates], ["111", "222"])
|
||||
self.assertEqual(candidates[0].name, "김OO")
|
||||
self.assertIn("Google Analytics", candidates[0].raw_summary)
|
||||
self.assertIn("GA4", candidates[0].raw_summary)
|
||||
self.assertNotIn("박OO", candidates[0].raw_summary)
|
||||
self.assertNotIn("브랜딩", candidates[0].raw_summary)
|
||||
self.assertNotIn("저장하기", candidates[0].raw_summary)
|
||||
self.assertNotIn("닫기", candidates[0].raw_summary)
|
||||
self.assertNotIn("포지션 제안", candidates[0].raw_summary)
|
||||
self.assertNotIn("이력서 확인", candidates[0].raw_summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -79,9 +79,9 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
유저에게 물어서 실제 값을 채운다.
|
||||
|
||||
서울 지하철 도착정보, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
|
||||
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
|
||||
|
||||
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 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` 가 불필요하다.
|
||||
|
||||
|
|
@ -115,8 +115,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
- SRT: `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`
|
||||
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
|
||||
- 자연휴양림 빈 객실 조회: `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD`
|
||||
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
|
||||
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
|
||||
- 한국 법령 검색: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `LAW_OC`)
|
||||
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`
|
||||
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
|
||||
|
|
|
|||
186
kakao-map/SKILL.md
Normal file
186
kakao-map/SKILL.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
---
|
||||
name: kakao-map
|
||||
description: Kakao Local (장소 검색·주소-좌표 변환) + Kakao Mobility (자동차 길찾기) 를 k-skill-proxy 경유로 조회한다. 사용자 키 불필요.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: transit
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Kakao Map
|
||||
|
||||
## What this skill does
|
||||
|
||||
Kakao Developers의 두 API를 `k-skill-proxy` 경유로 묶어 다음 두 종류 질문에 답한다:
|
||||
|
||||
1. **장소 검색** — 키워드/카테고리/좌표 기준으로 가게·시설·랜드마크를 찾고, 좌표↔주소·행정구역을 변환한다 (Kakao Local REST API).
|
||||
2. **자동차 길찾기** — 출발지·목적지 좌표를 받아 거리·소요시간·통행료·예상 택시 요금을 조회한다 (Kakao Mobility Directions API).
|
||||
|
||||
- 운영자 `KAKAO_REST_API_KEY` 를 proxy 서버에만 보관한다. 사용자는 키 발급 필요 없음.
|
||||
- 두 API 모두 같은 Kakao REST API key (KakaoAK 헤더) 로 인증한다.
|
||||
- 본 스킬은 **조회 전용**이다. 예약·결제·운전 자동화는 하지 않는다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "강남역 근처 스타벅스 찾아줘" → keyword 검색 (x,y 중심)
|
||||
- "역삼동 카페 카테고리로 보여줘" → category 검색 (FD6/CE7 등)
|
||||
- "이 좌표가 어느 동/도로명 주소야?" → coord2address / coord2region
|
||||
- "강남역에서 시청까지 자동차로 얼마나 걸려?" → Kakao Mobility directions
|
||||
- "통행료 회피 경로로 알려줘" → avoid=toll (필요 시 priority=DISTANCE 병행)
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- 대중교통(지하철·버스) 경로 → Kakao Mobility는 **자동차 전용**. 대중교통은 `korean-transit-route`(ODsay) 사용
|
||||
- 도보·자전거 경로 (Kakao Mobility에 정식 API 없음)
|
||||
- 실시간 교통 상황을 1분 단위로 추적 (proxy cache 가 있음)
|
||||
- 카카오맵 외부 임베드/렌더링 (본 스킬은 데이터 조회만 함)
|
||||
- 대량 인덱싱/스크래핑 (Kakao 약관 위반 + 일일 quota 초과 위험)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3 표준 라이브러리만 사용 가능. JS/curl 호출도 동일하게 지원.
|
||||
- optional: `KSKILL_PROXY_BASE_URL` (self-host·별도 프록시 사용 시. 비우면 hosted `https://k-skill-proxy.nomadamas.org` 기본).
|
||||
|
||||
## Required environment variables
|
||||
|
||||
사용자 머신에는 **필요 없다.** 운영자 proxy 서버에 다음을 둔다:
|
||||
|
||||
- `KAKAO_REST_API_KEY` — Kakao Developers REST API 키 (Local + Mobility 공용)
|
||||
|
||||
키가 없으면 모든 `/v1/kakao-map/*` 및 `/v1/kakao-mobility/*` 라우트가 `503 upstream_not_configured` 를 돌려준다.
|
||||
|
||||
## Proxy routes
|
||||
|
||||
| endpoint | 용도 | 주요 입력 |
|
||||
|---|---|---|
|
||||
| `GET /v1/kakao-map/search/keyword` | 키워드 장소 검색 | `q`, optional `x`,`y`(중심좌표), `radius`(0~20000m), `category_group_code`, `sort`(accuracy\|distance), `page`(1~45), `size`(1~15) |
|
||||
| `GET /v1/kakao-map/search/category` | 카테고리 장소 검색 (좌표 중심 필수) | `category_group_code`(예: FD6 음식점, CE7 카페), `x`, `y`, `radius`(기본 500), `sort`, `page`, `size` |
|
||||
| `GET /v1/kakao-map/coord2address` | 좌표 → 도로명/지번 주소 | `x`, `y`, optional `input_coord`(WGS84 기본) |
|
||||
| `GET /v1/kakao-map/coord2region` | 좌표 → 행정구역(시/도/시군구/동) | `x`, `y`, optional `input_coord` |
|
||||
| `GET /v1/kakao-mobility/directions` | 자동차 길찾기 | `origin=x,y`, `destination=x,y`, optional `waypoints`(최대 5, `\|` 구분), `priority`(RECOMMEND\|TIME\|DISTANCE), `car_fuel`(GASOLINE\|DIESEL\|LPG), `car_hipass`(true\|false), `alternatives`(true\|false), `avoid`(ferries\|toll\|motorway\|schoolzone\|uturn; `\|` 구분) |
|
||||
|
||||
**Kakao 카테고리 그룹 코드** (자주 쓰는 것):
|
||||
|
||||
| 코드 | 의미 |
|
||||
|---|---|
|
||||
| MT1 | 대형마트 |
|
||||
| CS2 | 편의점 |
|
||||
| PK6 | 주차장 |
|
||||
| OL7 | 주유소/충전소 |
|
||||
| SW8 | 지하철역 |
|
||||
| BK9 | 은행 |
|
||||
| CT1 | 문화시설 |
|
||||
| AT4 | 관광명소 |
|
||||
| AD5 | 숙박 |
|
||||
| FD6 | 음식점 |
|
||||
| CE7 | 카페 |
|
||||
| HP8 | 병원 |
|
||||
| PM9 | 약국 |
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. 키워드 검색
|
||||
|
||||
```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'
|
||||
```
|
||||
|
||||
응답의 `documents[]` 에서 `place_name`, `road_address_name`, `phone`, `place_url`, `distance` 를 추출해 사용자에게 보여준다.
|
||||
|
||||
### 2. 카테고리 검색
|
||||
|
||||
```bash
|
||||
curl -fsS --get "${BASE}/v1/kakao-map/search/category" \
|
||||
--data-urlencode 'category_group_code=FD6' \
|
||||
--data-urlencode 'x=127.0276' \
|
||||
--data-urlencode 'y=37.4979' \
|
||||
--data-urlencode 'radius=300'
|
||||
```
|
||||
|
||||
### 3. 좌표 → 주소
|
||||
|
||||
```bash
|
||||
curl -fsS --get "${BASE}/v1/kakao-map/coord2address" \
|
||||
--data-urlencode 'x=127.0276' \
|
||||
--data-urlencode 'y=37.4979'
|
||||
```
|
||||
|
||||
`documents[0].road_address.address_name`, `documents[0].address.address_name` 사용.
|
||||
|
||||
### 4. 좌표 → 행정구역
|
||||
|
||||
```bash
|
||||
curl -fsS --get "${BASE}/v1/kakao-map/coord2region" \
|
||||
--data-urlencode 'x=127.0276' \
|
||||
--data-urlencode 'y=37.4979'
|
||||
```
|
||||
|
||||
응답에 `region_type`(B=법정동, H=행정동) 별 결과가 들어있다.
|
||||
|
||||
### 5. 자동차 길찾기
|
||||
|
||||
```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'
|
||||
```
|
||||
|
||||
응답에서 `routes[0].summary` 를 읽는다:
|
||||
|
||||
- `distance` (meter) → km 환산
|
||||
- `duration` (second) → 분 환산
|
||||
- `fare.taxi`, `fare.toll` → 원
|
||||
- `priority` (요청한 값 echo)
|
||||
- `avoid` 요청 시 `toll` 등 회피 옵션 적용
|
||||
|
||||
### 6. 출력 포맷
|
||||
|
||||
장소 검색:
|
||||
|
||||
```text
|
||||
강남역 근처 스타벅스 5곳 (반경 500m, 가까운 순)
|
||||
1) 스타벅스 강남R점 — 강남구 테헤란로 ... (120m, 02-...)
|
||||
2) ...
|
||||
```
|
||||
|
||||
자동차 길찾기:
|
||||
|
||||
```text
|
||||
자동차 경로: (126.9706,37.5559) → (127.0276,37.4979)
|
||||
- 거리: 12.3km / 예상 소요시간: 25분
|
||||
- 통행료: 1,200원 / 예상 택시요금: 18,500원
|
||||
- 옵션: RECOMMEND, avoid=toll
|
||||
- 조회 시각: 2026-05-23T14:00:00.000Z
|
||||
```
|
||||
|
||||
## Failure modes
|
||||
|
||||
- `KAKAO_REST_API_KEY` 미설정 → `503 upstream_not_configured`
|
||||
- Kakao 인증 실패(401/403) → proxy가 `503` 으로 변환 (key revoke / 쿼터 초과 신호)
|
||||
- 좌표/파라미터 형식 오류 → `400 bad_request`
|
||||
- 출발지=도착지가 너무 가까움 (`result_code=104` 등) → `502 upstream_semantic_error` + `result_msg`
|
||||
- Kakao 일일 쿼터 초과 → `502` 또는 `503` (proxy cache 가 있는 만큼 호출 빈도를 줄임)
|
||||
- 네트워크 실패 → `502 upstream_error`
|
||||
|
||||
## Done when
|
||||
|
||||
- 사용자 질문에 맞는 endpoint 1~2개를 선택해 호출했고, 응답을 사람-읽기 좋게 정리했다.
|
||||
- 좌표나 주소는 출처 endpoint를 함께 명시한다 (Kakao Local vs Kakao Mobility).
|
||||
- secret/token/.env 원문은 노출되지 않았다.
|
||||
- 자동차 외 이동 수단을 요청받으면 본 스킬의 범위 외임을 명시하고 `korean-transit-route` 등 대체 안내.
|
||||
|
||||
## Notes
|
||||
|
||||
- Kakao Mobility는 **자동차 전용** API다. 대중교통 길찾기는 별도 ODsay 기반 `korean-transit-route` 스킬을 쓴다.
|
||||
- 무료 일일 쿼터(2026년 기준 Local 300,000건 / Mobility 1,000건) 안에서 proxy cache(기본 TTL 5분) + rate-limit(기본 60req/분) 으로 보호한다.
|
||||
- proxy 운영/환경변수는 [k-skill 프록시 서버 가이드](../docs/features/k-skill-proxy.md) 참고.
|
||||
- `/v1/kakao-local/geocode` (기존)도 같은 키를 쓰며 여전히 사용 가능하다 (address → keyword 자동 fallback). 본 스킬은 그 위에 keyword/category/coord 계열을 명시적으로 노출한다.
|
||||
|
|
@ -1,223 +1,199 @@
|
|||
---
|
||||
name: kakaotalk-mac
|
||||
description: Use kakaocli on macOS to read KakaoTalk chats, search messages, send replies after explicit confirmation, and delete sent messages with explicit operator intent.
|
||||
description: Search local KakaoTalk archives on Apple Silicon macOS through the katok CLI.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: messaging
|
||||
locale: ko-KR
|
||||
phase: v1.5
|
||||
phase: v2
|
||||
---
|
||||
|
||||
# KakaoTalk Mac CLI
|
||||
# KakaoTalk katok Search
|
||||
|
||||
## What this skill does
|
||||
|
||||
`kakaocli` 와 이 저장소 helper를 사용해 macOS에서 카카오톡 대화 목록을 확인하고, 메시지를 검색하고, 필요할 때 답장하거나 보낸 메시지를 삭제한다.
|
||||
`katok` CLI를 유일한 실행 표면으로 사용해 macOS 카카오톡 대화를 로컬 아카이브와 검색 인덱스로 동기화하고, keyword/BM25/semantic 검색과 chunk 조회를 수행한다.
|
||||
|
||||
이 스킬은 **macOS + 카카오톡 Mac 앱 설치**를 전제로 한다. 공식 Kakao API를 쓰는 것이 아니라 로컬 데이터베이스 읽기와 macOS 접근성 자동화 위에서 동작하므로, 권한과 안전 규칙을 먼저 확인해야 한다.
|
||||
이 스킬은 기존 `kakaotalk-mac` 설치 경로를 유지하지만 내부 동작은 `katok` 기반이다. 메시지 전송, 삭제, UI 자동화, 직접 DB 읽기, 인증 캐시 처리, 복호화 material 처리는 이 스킬의 범위가 아니다.
|
||||
|
||||
## Privacy Rules
|
||||
|
||||
- Do not inspect local database internals from this skill.
|
||||
- Do not directly read KakaoTalk DB files.
|
||||
- Do not handle auth caches or decryption material.
|
||||
- Use `katok sync --source macos --json` for live macOS KakaoTalk ingestion.
|
||||
- Search commands should return snippets and chunk ids first.
|
||||
- Retrieve full chunk content only when the user explicitly asks to open a result or provides a chunk id.
|
||||
|
||||
## When to use
|
||||
|
||||
- "카카오톡 최근 대화 목록 보여줘"
|
||||
- "특정 채팅방 최근 메시지 찾아줘"
|
||||
- "카카오톡 메시지 검색해줘"
|
||||
- "내 카톡으로 테스트 메시지 보내줘"
|
||||
- "답장 초안은 만들되 실제 전송 전에는 꼭 확인받아"
|
||||
- "카카오톡에서 특정 키워드 검색해줘"
|
||||
- "카톡에서 지난 회의/계약/약속 이야기 찾아줘"
|
||||
- "이 검색 결과 chunk를 열어줘"
|
||||
- "최근 대화가 반영됐는지 확인하고 검색해줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- macOS가 아닌 환경
|
||||
- 카카오톡 Mac 앱이 설치되어 있지 않은 환경
|
||||
- 사용자 확인 없이 다른 사람에게 메시지를 바로 보내야 하는 작업
|
||||
- 카카오 공식 API 범위 안에서 해결 가능한 서버-투-서버 연동 작업
|
||||
- Intel Mac에서 로컬 EmbeddingGemma semantic index가 필요한 경우
|
||||
- 카카오톡 메시지를 보내거나 삭제해야 하는 경우
|
||||
- 카카오톡 DB 파일, 인증 캐시, 복호화 material을 직접 다루라는 요청
|
||||
- 서버-투-서버 공식 Kakao API 연동 요청
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS
|
||||
- Apple Silicon macOS
|
||||
- KakaoTalk for Mac 설치
|
||||
- Homebrew
|
||||
- Mac App Store 로그인(`mas` 사용 시)
|
||||
- `kakaocli` 설치
|
||||
- `python3` 3.10+
|
||||
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
|
||||
- 터미널 앱에 **Full Disk Access** 와 **Accessibility** 권한 부여
|
||||
- Homebrew 또는 Cargo
|
||||
- `katok` CLI 설치
|
||||
- 현재 터미널 앱에 Full Disk Access 권한
|
||||
|
||||
## Inputs
|
||||
## Install katok
|
||||
|
||||
- 채팅방 이름 또는 검색 키워드
|
||||
- 읽기 범위: 최근 N개, `--since 1h`, `--since 7d` 등
|
||||
- 전송할 메시지 본문
|
||||
- 삭제할 로컬 메시지 ID 또는 최근 보낸 메시지 삭제 여부
|
||||
- 테스트 여부 (`--me`, `--dry-run`)
|
||||
Homebrew:
|
||||
|
||||
```bash
|
||||
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
|
||||
brew install katok
|
||||
```
|
||||
|
||||
Cargo:
|
||||
|
||||
```bash
|
||||
cargo install katok
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
```
|
||||
|
||||
설치 후 CLI가 보이는지 확인한다.
|
||||
|
||||
```bash
|
||||
katok --help
|
||||
katok doctor --json
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 0. Install KakaoTalk for Mac first when missing
|
||||
|
||||
카카오톡 Mac 앱이 없으면 먼저 설치한다. `mas` 를 쓰려면 App Store 로그인 상태여야 한다.
|
||||
### 1. Check readiness without prompting for app data
|
||||
|
||||
```bash
|
||||
brew install mas
|
||||
mas account
|
||||
mas install 869223134
|
||||
katok doctor --json
|
||||
```
|
||||
|
||||
`mas install` 이 막히면 App Store 앱에서 먼저 로그인한 뒤 다시 시도한다.
|
||||
`doctor --json`의 `freshness` 섹션에서 마지막 sync/index 상태를 확인한다. 이 기본 doctor는 macOS app-data probe를 실행하지 않으므로 권한 prompt를 띄우지 않는 준비 상태 점검에 적합하다.
|
||||
|
||||
### 1. Install `kakaocli`
|
||||
### 2. Open macOS permission settings when needed
|
||||
|
||||
공식 저장소 기준 권장 설치는 Homebrew tap 이다.
|
||||
Full Disk Access 설정이 필요하면 사용자가 직접 허용할 수 있도록 설정 화면을 연다.
|
||||
|
||||
```bash
|
||||
brew install silver-flight-group/tap/kakaocli
|
||||
katok permissions macos
|
||||
```
|
||||
|
||||
설치 후 바로 상태를 확인한다.
|
||||
KakaoTalk UI 자동화는 이 스킬 범위가 아니지만, upstream 진단을 위해 Accessibility 설정 화면까지 열어야 하는 경우에만 다음 명령을 쓴다.
|
||||
|
||||
```bash
|
||||
kakaocli status
|
||||
katok permissions macos --accessibility
|
||||
```
|
||||
|
||||
### 2. Grant the required macOS permissions
|
||||
### 3. Run explicit macOS setup diagnostics only when needed
|
||||
|
||||
**System Settings > Privacy & Security** 에서 현재 사용하는 터미널 앱(iTerm, Terminal, Warp 등)에 아래 권한을 준다.
|
||||
|
||||
- **Full Disk Access**: 카카오톡 로컬 데이터베이스 읽기용
|
||||
- **Accessibility**: 메시지 전송, harvest, inspect 같은 UI 자동화용
|
||||
|
||||
기본 규칙:
|
||||
|
||||
- `status` / `auth` / `chats` 같은 읽기 명령도 Full Disk Access 가 필요하다.
|
||||
- `send`, `delete`, `delete-last`, `harvest`, `inspect` 류 작업은 Accessibility 권한까지 필요하다.
|
||||
|
||||
### 3. Verify read access before attempting side effects
|
||||
|
||||
먼저 읽기 경로가 되는지 확인한다.
|
||||
카카오톡 앱 설치, container, DB 파일 접근 같은 macOS source adapter 상태를 확인해야 할 때만 probe를 실행한다. 이 명령은 macOS가 app-data 접근 prompt를 띄울 수 있다.
|
||||
|
||||
```bash
|
||||
kakaocli status
|
||||
kakaocli auth
|
||||
kakaocli chats --limit 10 --json
|
||||
katok doctor --macos-probe --json
|
||||
```
|
||||
|
||||
`auth` 가 성공하면 읽기 경로는 준비된 것이다.
|
||||
### 4. Sync local KakaoTalk archives
|
||||
|
||||
### 3.5. Use the helper when `kakaocli auth` fails on `user_id` auto-detection
|
||||
|
||||
실제 macOS 환경에서는 `KakaoTalk.db` 라는 literal 파일이 없어도, container 안의 **78자 hex 파일**이 실제 SQLCipher DB 인 경우가 있다. 이때 `kakaocli status` 는 정상인데 `kakaocli auth` 만 실패하는 대표 원인은:
|
||||
|
||||
- `AlertKakaoIDsList` 후보로는 복호화가 안 됨
|
||||
- plist 의 `DESIGNATEDFRIENDSREVISION:<sha512(user_id)>` 만 남아 있음
|
||||
- upstream `kakaocli` 의 기본 `user_id` brute-force 시간이 짧아서 auto-detection 이 실패함
|
||||
|
||||
이 저장소는 그 구간만 보완하는 **read-only helper** 를 함께 제공한다.
|
||||
최신 대화가 중요하거나 `freshness.recommendation.sync_before_search`가 true이면 검색 전에 sync한다.
|
||||
|
||||
```bash
|
||||
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
|
||||
katok sync --source macos --json
|
||||
```
|
||||
|
||||
- helper 는 plist 의 `AlertKakaoIDsList` 와 `DESIGNATEDFRIENDSREVISION` hash 를 읽는다.
|
||||
- 후보 `user_id` 가 모두 실패하면 SHA-512 preimage search 로 실제 `user_id` 를 더 오래 찾는다.
|
||||
- 성공한 `user_id`, DB 경로, derived key 는 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
|
||||
- 이후 read-only helper 명령(`chats`, `messages`, `search`, `schema`)은 cached `--db` / `--key` 를 붙여 `kakaocli` 를 다시 호출한다.
|
||||
|
||||
### 4. Read or search messages
|
||||
설정 파일의 기본 source adapter를 쓰는 경우:
|
||||
|
||||
```bash
|
||||
kakaocli messages --chat "지수" --since 1h --json
|
||||
kakaocli search "점심" --json
|
||||
katok sync --json
|
||||
```
|
||||
|
||||
helper 경유 예시:
|
||||
### 5. Build or refresh the semantic index
|
||||
|
||||
semantic search 전 `freshness.recommendation.index_before_semantic_search`가 true이거나 방금 sync한 내용을 semantic 검색에 반영해야 하면 index를 만든다.
|
||||
|
||||
```bash
|
||||
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1h --json
|
||||
python3 scripts/kakaotalk_mac.py search "점심" --json
|
||||
katok index --json
|
||||
```
|
||||
|
||||
응답은 가능하면 JSON 모드로 받고, 사람이 읽기 쉽게 다시 요약한다.
|
||||
`katok index`는 기본적으로 로컬 `embeddinggemma-300m-q4` embedder를 사용한다. Python, Jina, TEI, 별도 HTTP embedding server를 요구하지 않는다.
|
||||
|
||||
### 5. Use safe testing before real sends
|
||||
### 6. Search with the narrowest useful mode
|
||||
|
||||
실제 전송 전에 먼저 자기 자신에게 테스트하거나 dry-run 으로 확인한다.
|
||||
정확한 문자열, 이름, 계좌번호, 고유명사는 keyword search를 먼저 쓴다.
|
||||
|
||||
```bash
|
||||
kakaocli send --me _ "테스트 메시지"
|
||||
kakaocli send --dry-run "채팅방 이름" "보낼 문장"
|
||||
katok search keyword "검색어" --json
|
||||
```
|
||||
|
||||
`--me` 는 나와의 채팅으로 보내므로 가장 안전한 테스트 경로다.
|
||||
|
||||
### 6. Confirm before sending to other people
|
||||
|
||||
다른 사람이나 단체방으로 보내기 전에는 반드시 사용자의 최종 확인을 받는다.
|
||||
|
||||
확인 전에는 아래만 준비한다.
|
||||
|
||||
- 대상 채팅방 이름
|
||||
- 전송할 문장
|
||||
- 왜 이 문장을 보내는지 한 줄 설명
|
||||
|
||||
확인을 받았을 때만 전송한다.
|
||||
여러 단어가 섞인 일반 질의는 BM25를 쓴다.
|
||||
|
||||
```bash
|
||||
kakaocli send "채팅방 이름" "보낼 문장"
|
||||
katok search bm25 "지난주 미팅 자료" --json
|
||||
```
|
||||
|
||||
### 7. Delete a sent message only with explicit operator intent
|
||||
|
||||
삭제는 카카오톡 Mac UI(우클릭 → 삭제)를 접근성으로 자동화한다. 먼저 `messages --json` 으로 로컬 메시지 ID와 보낸 메시지 여부를 확인하고, 항상 `--dry-run` 으로 대상 채팅방/메시지를 확인한 뒤 실행한다. `--everyone` 은 내가 보낸 메시지에만 허용된다.
|
||||
표현이 정확히 기억나지 않는 의미 기반 질의는 semantic search를 쓴다.
|
||||
|
||||
```bash
|
||||
python3 scripts/kakaotalk_mac.py messages --chat "채팅방 이름" --limit 20 --json
|
||||
python3 scripts/kakaotalk_mac.py delete "채팅방 이름" 123456 --dry-run
|
||||
python3 scripts/kakaotalk_mac.py delete "채팅방 이름" 123456 --everyone
|
||||
python3 scripts/kakaotalk_mac.py delete-last "채팅방 이름" --dry-run
|
||||
python3 scripts/kakaotalk_mac.py delete-last "채팅방 이름" --everyone
|
||||
katok search semantic "최근에 논의한 세금 신고 일정" --json
|
||||
```
|
||||
|
||||
주의:
|
||||
### 7. Retrieve explicit chunks only when needed
|
||||
|
||||
- helper의 `chats`, `messages`, `search`, `schema` 는 read-only 경로다. `delete` / `delete-last` 는 UI side effect 이므로 Accessibility 권한과 명시적 실행 의도가 필요하다.
|
||||
- 메시지 ID는 로컬 DB의 `messages --json` 출력 기준이며 UI에서 동일한 DB row를 직접 증명할 수 있다는 뜻은 아니다. 실행 계약은 선택된 outbound DB 메시지의 정규화된 텍스트가 현재 활성 채팅방 transcript 영역에서 정확히 하나의 visible targetable message bubble 과 일치할 때만 삭제하는 것이다.
|
||||
- 대상 메시지 텍스트가 비어 있거나 첨부/비텍스트 메시지이거나, 정규화 후 같은 텍스트가 여러 개 있거나, 대상 bubble 이 보이지 않거나, 활성 채팅방/삭제 범위/최종 확인 버튼을 확인할 수 없으면 삭제 자동화는 실패한다.
|
||||
|
||||
### 8. Use login storage only when the user explicitly wants auto-login
|
||||
|
||||
자동 로그인 편의를 원할 때만 자격증명을 저장한다.
|
||||
검색 결과는 먼저 snippet과 chunk id 중심으로 요약한다. 사용자가 특정 결과를 열어 달라고 하거나 chunk id를 제공했을 때만 원문 chunk를 조회한다.
|
||||
|
||||
```bash
|
||||
kakaocli login
|
||||
kakaocli login --status
|
||||
katok chunk get <chunk-id> --json
|
||||
katok chunk context <chunk-id> --json
|
||||
katok chunk parent <chunk-id> --json
|
||||
```
|
||||
|
||||
비밀번호를 채팅창에 보내라고 요구하지 않는다. 사용자가 직접 로컬 터미널에서 입력하게 한다.
|
||||
- `katok chunk get <chunk-id> --json`: 해당 chunk 원문 조회
|
||||
- `katok chunk context <chunk-id> --json`: 같은 채팅방의 직전/직후 micro chunk 조회
|
||||
- `katok chunk parent <chunk-id> --json`: semantic search parent window 조회
|
||||
|
||||
## Synthetic QA only
|
||||
|
||||
실제 카카오톡 설치 없이 upstream fixture로 테스트할 때만 fixture source와 deterministic embedder를 사용한다.
|
||||
|
||||
```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를 사용하지 않는다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 읽기 요청이면 상태 확인 + 대화/메시지 조회 결과가 정리되어 있다
|
||||
- 검색 요청이면 키워드 기준 결과가 정리되어 있다
|
||||
- 전송 요청이면 테스트(`--me` 또는 `--dry-run`)와 사용자 확인이 끝난 뒤 실제 전송 여부가 명확하다
|
||||
- 삭제 요청이면 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 검증 뒤 실행 결과가 명확하다
|
||||
- readiness 요청이면 `katok doctor --json` 결과와 freshness 권장사항을 요약했다.
|
||||
- 최신 검색 요청이면 필요한 경우 `katok sync --source macos --json`과 `katok index --json` 실행 여부를 명확히 했다.
|
||||
- 검색 요청이면 keyword/BM25/semantic 중 선택한 이유와 JSON 검색 결과 요약을 제공했다.
|
||||
- chunk 조회 요청이면 사용자가 지정한 chunk id에 대해서만 `katok chunk get/context/parent` 결과를 요약했다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- `katok` 미설치 또는 Cargo binary PATH 누락
|
||||
- Apple Silicon macOS가 아님
|
||||
- KakaoTalk for Mac 미설치
|
||||
- App Store 로그인 누락으로 `mas install` 실패
|
||||
- Full Disk Access 미부여
|
||||
- Accessibility 미부여
|
||||
- `status` 는 정상인데 `auth` 만 실패하는 `user_id` auto-detection / key mismatch 케이스
|
||||
- 채팅방 이름 substring 이 애매해서 잘못된 후보가 여러 개 잡힘
|
||||
- `katok doctor --macos-probe --json`에서 container 또는 DB 파일 접근 실패
|
||||
- sync 전이라 local archive가 비어 있음
|
||||
- semantic index가 오래되었거나 아직 생성되지 않음
|
||||
- 검색 결과가 snippet/chunk id만으로 충분하지 않아 명시적 chunk 조회가 필요함
|
||||
|
||||
## Notes
|
||||
|
||||
- 이 스킬은 macOS 전용이다.
|
||||
- 다른 사람에게 보내는 메시지는 항상 confirm before sending 원칙을 지킨다.
|
||||
- 삭제는 되돌리기 어렵고 UI 상태에 민감하므로 먼저 `--dry-run` 으로 대상과 범위를 확인한다.
|
||||
- 첫 검증은 `kakaocli status` 와 `kakaocli auth` 부터 시작하는 편이 안전하다.
|
||||
- `kakaocli auth` 가 `User ID: auto-detection failed` 로 멈추면 helper 경로를 우선 사용한다.
|
||||
- helper cache 는 로컬 SQLCipher key 를 포함하므로 본인 계정에서만 유지하고 공유하지 않는다.
|
||||
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
|
||||
- 이 스킬은 read/search/retrieve 전용이다.
|
||||
- 메시지 전송과 삭제는 지원하지 않는다.
|
||||
- DB 내부 구조, auth cache, decryption material은 직접 다루지 않는다.
|
||||
- 기존 설치 이름은 `kakaotalk-mac`이지만 실행 표면은 `katok`이다.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
403
korean-humanizer/SKILL.md
Normal file
403
korean-humanizer/SKILL.md
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
---
|
||||
name: korean-humanizer
|
||||
description: 'AI가 쓴 티가 나는 한국어 글을 자연스러운 사람 글로 고친다. 번역체, AI 상투어, 과도한 명사화·피동, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재, 줄표·곡선따옴표 같은 한국어 특유의 AI 흔적을 심각도(S1/S2/S3)로 분류해 잡아내고 의미는 보존하면서 다시 쓴다. 목표 글자수를 함께 주면(예: "1000자로", length=1000) 그 분량에 맞춰 늘리거나 줄인다. "AI 티 안 나게", "사람이 쓴 것처럼", "자연스럽게 다듬어줘", "번역체 고쳐줘", "어색한 거 고쳐줘", "N자로 맞춰서" 같은 요청에 사용.'
|
||||
license: MIT
|
||||
metadata:
|
||||
category: writing
|
||||
locale: ko-KR
|
||||
phase: v2
|
||||
---
|
||||
|
||||
# Korean Humanizer: AI 한국어 글 흔적 지우기
|
||||
|
||||
당신은 AI가 생성한 한국어 글에서 "기계가 쓴 티"를 찾아 자연스러운 사람의 글로 되돌리는 편집자다. 한국어 AI 글의 가장 큰 두 정체는 (1) 영어를 직역한 듯한 **번역체**와 (2) 격식 있어 보이려고 의미 없이 부풀린 **상투어**다. 이 둘을 1순위로 잡는다.
|
||||
|
||||
이 스킬은 **프롬프트/지식 기반**이다. 외부 API나 스크립트 없이, 아래 4대 철칙 → 심각도 분류 → 탐지·윤문·감사·등급 루프와 패턴 카탈로그만으로 동작한다. 전체 A~J 분류 체계와 처방 표는 [`references/ai-tell-taxonomy.md`](references/ai-tell-taxonomy.md)에 있다.
|
||||
|
||||
## 4대 철칙 (먼저 새긴다)
|
||||
|
||||
1. **의미 불변** — 사실·주장·수치·고유명사·직접 인용은 100% 원문 보존. 한 글자도 바꾸거나 지어내지 않는다.
|
||||
2. **근거 기반** — 탐지된 흔적(span)에만 수술적으로 손댄다. 탐지 없는 멀쩡한 구간은 건드리지 않는다.
|
||||
3. **장르 유지** — 칼럼을 에세이·문학으로, 리포트를 블로그체로 옮기지 않는다. 원문의 격식(register)을 지킨다.
|
||||
4. **과윤문 금지** — 변경률이 **30%를 넘으면 경고**, **50%를 넘으면 강제 중단·롤백**. 멀쩡한 사람 글을 평균값으로 깎아내는 게 가장 흔한 실패다.
|
||||
|
||||
## 심각도 (S1 / S2 / S3)
|
||||
|
||||
흔적은 단발이 아니라 **무더기**로 판단하되, 한 흔적의 무게는 심각도로 가른다.
|
||||
|
||||
- **S1 결정적** — 한 번만 나와도 AI 확신. 무조건 제거. (예: 이중피동 "되어지다", "결론적으로", "시사하는 바가 크다", 연결어미 뒤 쉼표 떡칠, 이모지 장식, 챗봇 잔재)
|
||||
- **S2 강함** — 1~2회는 허용, 3회 이상 반복되면 제거. (예: "~을 통해", "~에 의해" 피동, 3단 공식, 미래 단정 "~할 것이다")
|
||||
- **S3 약함** — 그 자체로는 신호가 아니다. 다른 패턴과 무더기로 겹칠 때만 손댄다. (예: 곡선 따옴표 단독, 줄표 단독, 단호한 짧은 문장 하나)
|
||||
|
||||
## 절대 건드리지 않는 것 (Do-NOT)
|
||||
|
||||
탐지·윤문 양쪽에서 다음은 손대지 않는다. 이걸 바꾸면 의미 불변 철칙 위반이다.
|
||||
|
||||
- 고유명사·제품명·모델명·기관명·인명·지명
|
||||
- 수치·날짜·단위·통계·수식·화학식
|
||||
- 큰따옴표 안 직접 인용, 법률 조문
|
||||
- 업계 표준 영어 약어(LLM·GPU·API·MCP 등)
|
||||
- 글쓴이가 일부러 넣은 구체적 디테일·곁말(아래 "사람이 쓴 글의 신호" 참고)
|
||||
|
||||
## 작업 절차 (탐지 → 윤문 → 감사 → 등급)
|
||||
|
||||
글을 받으면 다음 루프를 돈다.
|
||||
|
||||
0. **트리아지** — 무엇을 어디까지 고칠지 먼저 정한다.
|
||||
- 흔적이 무더기인가? 단발 흔적(줄표 하나, 접속어 하나)으로 글을 갈아엎지 않는다.
|
||||
- **서식만 문제면 서식만 고친다.** 볼드 떡칠·이모지·가운뎃점·줄표가 전부라면 산문은 그대로 두고 서식만 정리한다.
|
||||
- **산문 자체가 AI식일 때만** 문장 단위로 다시 쓴다.
|
||||
- 목표 글자수가 있으면 함께 메모한다.
|
||||
1. **탐지** — 카탈로그(A~J)로 글을 훑어 흔적을 span·분류·심각도로 표시한다. S1부터 본다.
|
||||
2. **윤문** — 흔적을 자연스러운 표현으로 *교체*한다. 지우지 말고 다시 쓴다. 원문이 다루는 내용은 빠짐없이 다루고, 분량을 임의로 줄이지 않는다.
|
||||
3. **감사(audit)** — 다시 묻는다: "이 글이 왜 아직 AI 같은가?" 잔존 흔적을 짧게 짚고, *내가* 동의어 돌려쓰기(F계열)나 접속어 추가(H계열)로 새 흔적을 만들지 않았는지, 변경률이 30%를 넘지 않았는지 점검한다. 자가검증 6항(아래) 위반이면 해당 edit을 롤백하고 다시 윤문한다. 루프는 최대 1~2회.
|
||||
4. **등급** — 아래 품질 등급으로 자가 채점한다. C·D면 사용자에게 추가 윤문 또는 사람 검토를 권한다.
|
||||
|
||||
## 품질 등급 (윤문 후 자가 채점)
|
||||
|
||||
- **A** — S1 잔존 0건, S2 잔존 2건 이하, 변경률 10~25%, 자가검증 6항 모두 통과.
|
||||
- **B** — S1 잔존 0건, S2 잔존 4건 이하, 자가검증 5항 이상 통과.
|
||||
- **C** — S1 잔존 1~2건 또는 과윤문 시그널 → 2차 윤문 권고.
|
||||
- **D** — S1 잔존 3건 이상 또는 변경률 50% 초과 → 작업 중단, 사람 검토 권고.
|
||||
|
||||
## Length control (목표 글자수 맞추기)
|
||||
|
||||
사용자가 목표 분량을 주면 그 길이에 맞춘다. 호출 예: `length=1000`, "1000자로 맞춰서", "절반으로 줄여줘", "300자 내외로".
|
||||
|
||||
- **단위 기본값은 공백 포함 글자수.** "공백 제외"를 명시하면 그쪽으로 센다. 애매하면 두 수치를 모두 보고한다.
|
||||
- **허용 오차는 ±5%** 기본(1000자 목표 → 950~1050자). "정확히"를 요구하면 ±2% 안으로.
|
||||
- **늘릴 때**: 군더더기·AI 패딩으로 채우지 않는다(그건 이 스킬이 지우려는 흔적이다). 원문에 이미 있는 구체적 사실을 *풀어서* 분량을 만든다. **없는 사실을 지어내지 않는다.** 채울 구체가 부족하면 추측 대신 사용자에게 되묻는다.
|
||||
- **줄일 때**: 군더더기 구절·완충 표현·막연한 마무리·중복부터 덜어낸다. 구체적 디테일과 핵심 사실은 마지막까지 지킨다.
|
||||
- **글자수는 추정하지 말고 실제로 센다.** `korean-character-count` 스킬이 있으면 그것으로 결정론적으로 세고(grapheme/공백 기준), 없으면 직접 정확히 센 뒤 **공백 포함/제외 수치를 함께 표기**한다.
|
||||
- 목표 분량을 안 주면 **원문 길이를 보존**한다.
|
||||
|
||||
## Voice Calibration (선택)
|
||||
|
||||
사용자가 자기 글 샘플을 주면, 다시 쓰기 전에 먼저 분석한다.
|
||||
|
||||
1. **샘플을 읽고 메모한다.** 문장 길이 패턴(짧게 끊는지/길게 흐르는지), 종결어미·문체(해요체/합니다체/반말, 구어/문어), 어휘 수준, 입버릇·접속 습관("근데/그래서/암튼"), 한자어·외래어 비중.
|
||||
2. **그 목소리로 다시 쓴다.** AI 패턴을 지우는 데서 그치지 말고 샘플 말투로 *대체*한다. 글쓴이가 "되게/약간"을 쓰면 "매우/다소"로 격상하지 않는다.
|
||||
3. **샘플이 없으면** 기본값(자연스럽고 리듬이 살아 있는 목소리, PERSONALITY AND SOUL 참고)으로 간다.
|
||||
|
||||
제공 방법: 인라인("내 말투 샘플은 이거야: …") 또는 파일("내 스타일은 [경로] 참고").
|
||||
|
||||
## PERSONALITY AND SOUL
|
||||
|
||||
AI 패턴을 지우는 건 절반이다. 영혼 없는 글은 슬롭(slop)만큼이나 티가 난다.
|
||||
|
||||
**이 절은 글의 성격이 허락할 때만 적용한다** — 블로그·에세이·칼럼·후기·개인적 글. 백과사전·기술 문서·법률·공문에서는 중립적이고 담백한 문체 *그 자체가* 올바른 사람의 목소리다. 거기에 사견·1인칭을 억지로 넣지 않는다(장르 유지 철칙).
|
||||
|
||||
### 영혼 없는 글의 징후 (문법적으로 "깨끗"해도)
|
||||
- 모든 문장이 같은 길이·구조
|
||||
- 의견 없이 중립 보고만 함
|
||||
- 망설임이나 복잡한 심경이 없음
|
||||
- 어울리는 자리인데도 1인칭이 없음
|
||||
- 유머도, 날도, 개성도 없음 — 보도자료나 위키처럼 읽힘
|
||||
|
||||
### 목소리를 넣는 법
|
||||
- **의견을 가져라.** "솔직히 이걸 어떻게 받아들여야 할지 모르겠다"가 장단점 중립 나열보다 사람 같다.
|
||||
- **리듬을 흔들어라.** 짧게 친다. 그러다 한 번씩 끝까지 흘러가는 긴 문장을 둔다. 섞어라.
|
||||
- **약간의 흐트러짐을 허용하라.** 완벽한 구조는 알고리즘 같다. 곁가지·여담·끝맺지 못한 생각이 사람 냄새를 낸다.
|
||||
|
||||
### Before (깨끗하지만 영혼 없음)
|
||||
> 이번 실험은 흥미로운 결과를 보여주었다. 에이전트는 300만 줄의 코드를 생성했다. 일부 개발자는 깊은 인상을 받았고, 다른 이들은 회의적이었다. 그 함의는 여전히 불분명하다.
|
||||
|
||||
### After (맥박이 있음)
|
||||
> 이걸 어떻게 받아들여야 할지 솔직히 모르겠다. 코드 300만 줄을, 사람이 자는 동안 기계가 짜놨다. 개발자 절반은 멘붕이 왔고, 나머지 절반은 이게 왜 별거 아닌지 설명하느라 바쁘다. 진실은 아마 그 사이 어디 시시한 지점에 있겠지만, 나는 밤새 일했을 그 에이전트들이 자꾸 떠오른다.
|
||||
|
||||
---
|
||||
|
||||
# 패턴 카탈로그 (A ~ J)
|
||||
|
||||
각 패턴은 `분류 ID · 심각도`로 표시한다. 전체 60+ 서브 패턴 표는 [`references/ai-tell-taxonomy.md`](references/ai-tell-taxonomy.md)에 있다. 아래는 한국어 AI 글에서 가장 자주, 가장 강하게 드러나는 핵심만 추렸다.
|
||||
|
||||
## A. 번역체 (한국어 AI 글의 1순위 정체)
|
||||
|
||||
### A-1·A-2·A-3. 영어 직역식 조사·구문 — S1
|
||||
**주의:** ~을 통해(through), ~에 대해/대한(about), ~에 있어서(in), ~로서(as), ~와 함께(with), ~의 경우(in the case of), ~중 하나(one of the), ~라는 사실(the fact that). 영어 전치사 구문을 조사로 1:1 치환해 어색하게 길어진다. 한국어는 동사·어순으로 녹인다.
|
||||
|
||||
> **Before:** 이 도구를 통해 사용자는 데이터에 대한 분석을 수행함에 있어서 효율성을 가질 수 있다. 이것은 가장 중요한 기능 중 하나이다.
|
||||
> **After:** 이 도구로 사용자는 데이터를 효율적으로 분석할 수 있다. 핵심 기능이다.
|
||||
|
||||
### A-7. "가지다(have)" 직역 — S1
|
||||
**주의:** 중요성을 가지다, 의미를 가지다, 영향력을 가지다, ~을 가지고 있다. have를 "가지다"로 직역한 것. "있다"·"~다"·동사로 푼다.
|
||||
|
||||
> **Before:** 이 연구는 중요한 의미를 가진다. 또한 큰 영향력을 가지고 있다.
|
||||
> **After:** 이 연구는 중요하다. 영향력도 크다.
|
||||
|
||||
### A-8·A-9. 과도한 피동·이중피동 — S1
|
||||
**주의:** ~되어진다, ~지게 된다, ~여겨진다, ~보여진다("되어지다"는 이중피동, 비문에 가깝다), "~에 의해" 피동. 행위자를 주어로 세워 능동으로 풀면 짧고 명확해진다.
|
||||
|
||||
> **Before:** 이 방법은 효과적이라고 보여지며, AI에 의해 생성된 코드가 많은 곳에서 사용되어지고 있다.
|
||||
> **After:** 이 방법은 효과적이고, AI가 만든 코드가 여러 곳에서 쓰인다.
|
||||
|
||||
### A-16. "그/그녀/그것/그들" 강박적 사용 — S1
|
||||
**주의:** 한 단락에 영어 대명사(he/she/it/they)를 직역한 "그/그녀/그것/그들"이 3회 이상. 한국어는 주어를 자주 생략하거나(영형) 호칭·명사구로 받는다. 무생물 주어 "이것은/그것은 ~이다"도 같은 뿌리다.
|
||||
|
||||
> **Before:** 이 기능은 사용자에게 편의성을 제공한다. 그것은 작업 시간의 단축을 가능하게 한다. 그리고 그것은 비용도 줄인다.
|
||||
> **After:** 이 기능을 쓰면 편하다. 작업 시간이 줄고 비용도 준다.
|
||||
|
||||
### A-17. 복수 접미사 "~들" 남발 — S2
|
||||
영어 복수 -s를 기계적으로 "~들"로 옮긴 것. 맥락으로 복수를 알면 "~들"을 거의 안 붙인다. "많은 사용자들이"처럼 수량어와 겹치면 특히 어색하다.
|
||||
|
||||
> **Before:** 많은 개발자들이 다양한 도구들을 사용하여 여러 문제들을 해결한다.
|
||||
> **After:** 많은 개발자가 여러 도구로 다양한 문제를 해결한다.
|
||||
|
||||
### A-18. 관계절 좌향 수식 — S2
|
||||
명사 앞에 3어절 이상의 긴 관형구·관계절이 좌향으로 쌓인다. 문장을 분리하거나 후치 동격절("X를 만났는데, 그 X는 …")로 푼다.
|
||||
|
||||
> **Before:** 지난해 출시되어 시장에서 큰 호응을 얻으며 빠르게 점유율을 늘려온 이 제품은 곧 단종된다.
|
||||
> **After:** 이 제품은 곧 단종된다. 지난해 출시돼 점유율을 빠르게 늘려온 제품이다.
|
||||
|
||||
### A-19. 이중 조사 "~에서의/~으로의/~에의" — S2
|
||||
"~에서의/~에로의/~으로의/~에의/~으로부터의" 같은 겹조사. 절·구로 풀어쓴다(단순 "~의"는 대상 아님).
|
||||
|
||||
> **Before:** 일터에서의 변화와 미래로의 도약을 위한 준비
|
||||
> **After:** 일터가 어떻게 바뀌고, 미래로 나아가려면 무엇을 준비해야 하는지
|
||||
|
||||
### A-6. 명사화 과잉 — S2
|
||||
~의 진행, ~의 향상, ~을 실시/수행/진행, ~을 도모. 동사를 명사로 굳히고 "~하다/실시하다"를 덧댄 것. 동사로 풀면 살아난다.
|
||||
|
||||
> **Before:** 성능의 향상을 위해 코드의 최적화를 진행하였다.
|
||||
> **After:** 성능을 높이려고 코드를 최적화했다.
|
||||
|
||||
## B. 영어 인용·용어 과다
|
||||
|
||||
### B-1·B-2. 괄호 영어 병기·직역 가능한 영어 — S2
|
||||
한글 + 괄호 영어를 매번 병기("주권 AI(Sovereign AI)" 반복)하거나, 옮길 수 있는 영어를 그대로 둔다. 첫 등장만 병기하고 이후 한글만. 단, 업계 표준 약어는 유지(Do-NOT).
|
||||
|
||||
## C. 구조적 AI 패턴
|
||||
|
||||
### C-11. 연결어미 뒤 쉼표 — S1
|
||||
**주의:** -고, -며, -지만, -면서, -아서/-어서 같은 연결어미 **직후의 쉼표**. AI 한국어의 강한 정체로, 6회 이상이면 결정적이다. 대부분 쉼표를 빼면 된다.
|
||||
|
||||
> **Before:** 그는 회의를 마치고, 사무실로 돌아왔으며, 보고서를 작성했지만, 만족하지 못했다.
|
||||
> **After:** 그는 회의를 마치고 사무실로 돌아와 보고서를 썼지만 만족하지 못했다.
|
||||
|
||||
### C-7. "먼저·반면·결국" 3단 공식 / 3의 법칙 — S2
|
||||
포괄적으로 보이려고 항목을 억지로 셋씩 묶는다("A, B, 그리고 C", 명사 세 개 나열, 3단 접속 공식).
|
||||
|
||||
> **Before:** 이 서비스는 빠르고, 안전하며, 편리합니다. 사용자에게 혁신과 가치와 만족을 제공합니다.
|
||||
> **After:** 이 서비스는 빠르고 안전합니다. 무엇보다 쓰기 편합니다.
|
||||
|
||||
### C-5. 이모지 장식 — S1
|
||||
제목·항목 앞 이모지. 칼럼·리포트면 전부 삭제.
|
||||
|
||||
> **Before:** 🚀 **출시:** 3분기에 출시됩니다 / 💡 **핵심:** 사용자는 단순함을 선호합니다
|
||||
> **After:** 제품은 3분기에 출시된다. 사용자 조사에서 단순한 쪽이 선호됐다.
|
||||
|
||||
### C-10. 콜론 부제 헤딩 "X: Y" 반복 — S2
|
||||
헤딩마다 "X: Y" 부제 패턴. 짧은 헤딩이나 평서 헤딩으로 바꾼다.
|
||||
|
||||
## D. AI 특유의 관용구 (Signature Phrases)
|
||||
|
||||
### D-1. 결산 피벗 — S1
|
||||
**주의:** 결론적으로, 따라서, 이를 통해, 그러므로, 요약하면, 정리하면. 3회 초과면 1~2건만 다른 종결로 치환하고 나머지는 삭제.
|
||||
|
||||
### D-2·D-3. "시사하는 바가 크다 / 주목할 만하다 / 본질적으로 / 핵심적으로" — S1
|
||||
삭제하거나 구체 결론으로 바꾼다.
|
||||
|
||||
### D-4. hype 어휘 — S1
|
||||
**고빈도 AI 단어:** 다채로운, 풍부한, 깊이 있는, 진정한, 궁극적으로, 중추적인, 필수적인, 혁신적인, 독보적인, 파격적인, 압도적인, 획기적인, ~을 아우르다, ~을 녹여내다, ~을 담아내다, ~을 선사하다, 자리매김하다, 발돋움하다, 방증하다, ~의 향연. 2023년 이후 글에 한꺼번에 몰려 나온다. 구체 수치·사실로 환원.
|
||||
|
||||
> **Before:** 이번 행사는 다채로운 볼거리를 선사하며, 지역 문화의 진정한 가치를 담아낸 축제로 자리매김했다. 이는 지역의 저력을 방증한다.
|
||||
> **After:** 이번 축제에는 공연과 먹거리 장터가 열렸다. 지난해보다 방문객이 두 배 늘었다.
|
||||
|
||||
### D-5. 의인화 추상 주어 — S1
|
||||
"기술이 묻는다", "시대가 부른다" 같은 의인화. 사람·기관 주어로.
|
||||
|
||||
### D-6. 결말 공식 "~할 때다 / ~해야 한다 / 지금이야말로" — S1
|
||||
평서로 닫거나 삭제. → 막연한 긍정 마무리(귀추가 주목된다, 무한한 가능성, 밝은 미래)도 같은 부류.
|
||||
|
||||
> **Before:** 앞으로 회사의 행보가 기대된다. 무한한 가능성을 향한 도약이 계속될 것이며, 이는 더 나은 미래를 향한 큰 걸음이다.
|
||||
> **After:** 회사는 내년에 지점 두 곳을 더 열 계획이다.
|
||||
|
||||
### (내용) 과장된 의의 부여 — S1
|
||||
단순한 ~를 넘어, ~의 중요한 이정표, 한 획을 그었다, 새로운 지평을 열었다, ~의 산물, ~을 상징한다. 사소한 사실에 거대 담론을 갖다 붙인다.
|
||||
|
||||
> **Before:** 1989년 설립된 이 연구소는 지역 통계 발전사에 중요한 이정표를 세우며, 행정 분권화라는 시대적 흐름을 상징하는 산물로 자리매김했다.
|
||||
> **After:** 이 연구소는 1989년에 설립돼, 국가 통계청과 별개로 지역 통계를 수집·발표한다.
|
||||
|
||||
### (내용) 출처 없는 권위 호출 — S2
|
||||
전문가들은 ~라고 말한다, 많은 사람들이 ~로 평가한다, ~로 알려져 있다. 구체적 출처 없이 막연한 권위에 떠넘긴다.
|
||||
|
||||
> **Before:** 이 강은 독특한 특성으로 연구자들의 관심을 받고 있으며, 전문가들은 중요한 역할을 한다고 말한다.
|
||||
> **After:** 이 강에는 토종 어류 여러 종이 서식한다(2019년 ○○대 조사).
|
||||
|
||||
## E. 리듬·종결어미
|
||||
|
||||
### E-1·E-2. 문장 길이 균일 / 동일 종결어미 반복 — S2
|
||||
문장 길이 표준편차가 낮고, "~다"가 4문장 이상 연속되며, "~고 있다" 진행형이 자동 매핑된다. 단문·장문을 의도적으로 섞고 종결어미를 다양화한다("~었다·~ㄴ다·~기 마련이다·~ㄹ 것이다"). "읽고 있다" → "읽는다"처럼 단순 시제 환원 가능 시 환원.
|
||||
|
||||
### E-7. 경어법 일관성 손실 (대화·구어 한정) — S2
|
||||
한 단락 안에서 해라/하게/하오/해요/합쇼체가 뒤섞인다. 격식을 하나로 통일한다.
|
||||
|
||||
## F. 과도한 수식·중복
|
||||
|
||||
### F-4·F-5. 명사화 어미 누적 / "~적 N" 추상 체인 — S2
|
||||
-성/-적/-화 + 영어 -tion/-ment/-ness가 한 글에 12회 이상 쌓이거나, "전략적 함의·실천적 기반" 같은 "~적 N" 체인이 늘어진다. 동사·형용사 어근으로 환원("정책의 시행" → "정책을 시행").
|
||||
|
||||
### (수식) 동의어 돌려쓰기 — S2
|
||||
같은 대상을 매번 다른 말로 바꿔 부른다(주인공 → 캐릭터 → 인물 → 그 → 히어로). **윤문하는 *내가* 이 짓을 하지 않도록 특히 경계한다.**
|
||||
|
||||
> **Before:** 주인공은 많은 시련을 겪는다. 이 캐릭터는 역경을 이겨내야 한다. 해당 인물은 마침내 승리한다.
|
||||
> **After:** 주인공은 숱한 시련을 겪지만 결국 이겨낸다.
|
||||
|
||||
### (수식) 가짜 범위 "A에서 B까지" / 부정 병렬 — S2
|
||||
같은 척도에 있지도 않은 것을 "~에서 ~까지"로 묶거나, "단순한 X가 아니라 Y다"로 평범한 말을 거창하게 만든다.
|
||||
|
||||
> **Before:** 이것은 단순한 노래가 아니다. 그것은 하나의 선언이다.
|
||||
> **After:** 묵직한 비트가 곡의 공격적인 분위기를 살린다.
|
||||
|
||||
## G. Hedging (완충 남용)
|
||||
|
||||
### G-1·G-2·G-3. 미래 단정 / 추정 / 안전 균형 남발 — S2
|
||||
"~할 것이다" 미래 단정, "~로 보인다/~인 듯하다" 추정, "장점도 있지만/신중하게/균형" 안전 균형 표현이 겹겹이 쌓인다. 단언 가능한 곳은 단언한다.
|
||||
|
||||
> **Before:** 이 정책은 어느 정도 결과에 다소 영향을 미칠 수도 있다고 볼 수 있을 것이다.
|
||||
> **After:** 이 정책은 결과에 영향을 줄 수 있다.
|
||||
|
||||
## H. 접속사 남발
|
||||
|
||||
### H-1·H-3. 문두 접속사 / 메타 진입 — S1
|
||||
또한·따라서·즉·나아가·아울러·게다가·더욱이가 5회 이상, 또는 "이는·이 점에서·이 관점에서"가 3회 이상. 문단마다 첫머리에 깔린다. 대량 제거하고 문장이 스스로 흐름을 잡게 한다.
|
||||
|
||||
> **Before:** 또한, 이 기능은 편리하다. 더불어, 속도도 빠르다. 나아가, 비용도 절감된다. 이처럼, 장점이 많다.
|
||||
> **After:** 이 기능은 편리하고 빠르다. 비용도 줄어든다.
|
||||
|
||||
## I. 형식명사·의존명사
|
||||
|
||||
### I-1. "~인 것이다 / ~한 것이다" 결말 — S1
|
||||
평서형으로 바꾼다. "진정한 문제는 / 본질적으로 / 결국 중요한 것은" 같은 권위적 본질 호명도 같이 걷어낸다.
|
||||
|
||||
> **Before:** 진정한 문제는 조직이 변화할 수 있는가이다. 본질적으로, 결국 중요한 것은 조직의 준비 태세인 것이다.
|
||||
> **After:** 관건은 조직이 변할 수 있느냐다. 그건 대개 일하는 습관을 바꿀 의지에 달렸다.
|
||||
|
||||
### I-4. 설교조 당위 / 예고 멘트 — S2
|
||||
"~하는 것이 중요하다", "~할 필요가 있다", "명심해야 한다" 같은 일반론 훈계, "지금부터 ~을 살펴보자", "본격적으로 들어가기에 앞서" 같은 예고. 구체적 내용으로 대체한다.
|
||||
|
||||
> **Before:** 무엇보다 사용자 경험을 최우선으로 고려하는 것이 중요하다. 지금부터 그 방법을 자세히 살펴보겠습니다.
|
||||
> **After:** 가입 절차를 3단계에서 1단계로 줄이자 이탈률이 절반으로 떨어졌다.
|
||||
|
||||
## J. 시각 장식
|
||||
|
||||
### J-1. 볼드 남용 / 불릿+굵은 머리말 — S2
|
||||
강조할 필요 없는 구절까지 굵게 칠하거나, 항목마다 "**굵은 머리말:**"을 붙인 세로 목록으로 토막 낸다. 칼럼·리포트면 줄글로 푼다.
|
||||
|
||||
> **Before:** - **사용자 경험:** 인터페이스가 개선되었습니다. - **성능:** 알고리즘으로 향상되었습니다. - **보안:** 암호화로 강화되었습니다.
|
||||
> **After:** 이번 업데이트로 인터페이스가 새로워졌고, 알고리즘 최적화로 속도가 빨라졌으며, 종단 간 암호화가 추가됐다.
|
||||
|
||||
### J-2. 줄표(—)·가운뎃점(·)·곡선따옴표·물결표 — S1(줄표)/S2/S3
|
||||
**규칙:** 최종본에 em dash(—)·en dash(–)를 쓰지 않는다. 영어 AI 글의 최대 정체가 em dash이고 한국어 AI 글도 그대로 가져온다. 마침표·쉼표·콜론·괄호로 바꾸거나 문장을 다시 짠다. 가운뎃점(·)으로 단어를 줄줄이 잇는 것, 따옴표 강조 5회 이상, 문장 끝 물결표(~)도 정리. **최종본을 내기 전 `—`와 `–`를 검색한다. 하나라도 남으면 끝난 게 아니다.**
|
||||
|
||||
> **Before:** 이 정책은 — 예고도 없이 발표되어 — 수천 명에게 영향을 준다. 빠르고·정확하고·강력한 처리가 가능하다.
|
||||
> **After:** 이 정책은 예고 없이 발표돼 수천 명에게 영향을 준다. 처리가 빠르고 정확하다.
|
||||
|
||||
## 소통 잔재 (챗봇 흔적)
|
||||
|
||||
### 챗봇 응대·아첨 — S1
|
||||
물론이죠!, 좋은 질문이에요!, 도움이 되었으면 좋겠습니다, 더 궁금한 점이 있으면 말씀해 주세요, 아래는 ~입니다. 챗봇 대화 잔재가 본문에 섞여 든다. 통째로 삭제.
|
||||
|
||||
> **Before:** 좋은 질문이에요! 아래는 프랑스 혁명에 대한 개요입니다. 도움이 되었으면 좋겠습니다!
|
||||
> **After:** 프랑스 혁명은 1789년 재정 위기와 식량난으로 인한 불만에서 시작됐다.
|
||||
|
||||
### 지식 한계 면피·추측성 빈칸 메우기 — S2
|
||||
"공개된 정보가 제한적이지만", "~로 추정된다", "조용한 행보를 보이는 것으로". 모르면 "자료에 없다"고 하거나 문장을 뺀다. 추측을 사실처럼 포장하지 않는다(의미 불변 철칙).
|
||||
|
||||
---
|
||||
|
||||
# DETECTION GUIDANCE (오탐 방지)
|
||||
|
||||
## 깃발 꽂으면 안 되는 것 (false positive)
|
||||
|
||||
멀쩡한 사람도 위 패턴 몇 개는 친다. 다음은 그 자체로는 AI 신호가 아니다(전부 S3 취급).
|
||||
|
||||
- **반듯한 맞춤법·일관된 문체** — 다듬어졌다고 AI가 아니다.
|
||||
- **격식체·한자어** — AI는 *특정* 단어(D-4)를 과용할 뿐, 모든 한자어가 AI는 아니다. 법률·학술 글에서 "방증·기실"은 정상.
|
||||
- **접속어 한두 개** — 문단마다 줄줄이 쌓일 때만 신호.
|
||||
- **곡선 따옴표·줄표 단독** — 한글·워드·구글 문서 기본값이거나 편집자 습관. 다른 흔적과 겹칠 때만 센다.
|
||||
- **단호한 짧은 문장 하나** — 여러 개 연달아 톤을 부풀릴 때만 잡는다.
|
||||
- **개조식·번호 목록 자체** — 보고서·매뉴얼의 정상 형식.
|
||||
- **편지투 인사말·맺음말, 출처 없는 주장** — 그 자체로는 아무것도 증명하지 않는다.
|
||||
|
||||
헷갈리면 단발이 아니라 **무더기**를 봐라. 줄표 하나는 의미 없다. 줄표 + 3의 법칙 + "다채로운 향연" + "전망" 단락이 한 글에 다 있으면 자백이다.
|
||||
|
||||
## 사람이 쓴 글의 신호 (지켜라)
|
||||
|
||||
다음이 보이면 그냥 두는 쪽으로 기운다. 과하게 손대면 사람다움이 사라진다.
|
||||
|
||||
- **구체적이고 별난, 지어내기 힘든 디테일** — 실제 주소, 이상한 인용, "치과 윗층 변호사" 같은 표현.
|
||||
- **복잡한 심경·해소되지 않은 긴장** — AI는 깔끔한 결론으로 수렴한다.
|
||||
- **시대·집단에 묶인 레퍼런스** — 특정 연도·하위문화 밈·슬랭·내부 농담.
|
||||
- **글쓴이가 변호할 수 있는 1인칭 편집 선택**, **들쭉날쭉한 문장 길이**, **진짜 곁말·괄호·자기 정정.**
|
||||
|
||||
# 자가검증 체크리스트 (윤문 후 자가 점검, 한 항목이라도 위반이면 해당 edit 롤백)
|
||||
|
||||
1. **고유명사·수치·날짜·인용 100% 보존** — 원문 대비 한 글자도 다르지 않은가.
|
||||
2. **변경률 30% 이하인가** (50% 초과는 작업 중단).
|
||||
3. **장르 이탈 없음** — 칼럼이 에세이·문학으로, 리포트가 블로그체로 떨어지지 않았는가.
|
||||
4. **register 보존** — 원문이 격식체면 결과도 격식체.
|
||||
5. **잔존 S1 0건** — A-7·A-8·A-16·C-5·C-10·C-11·D-1~D-6·H-1·I-1·J-2 같은 S1이 남지 않았는가.
|
||||
6. **인공 표현 자제** — 원문에 없던 비유·수사·문학적 표현을 윤문 과정에서 임의로 더하지 않았는가.
|
||||
|
||||
# Process and Output
|
||||
|
||||
**산출물:** 초안 → "아직 AI 같은 점" 짧은 글머리표(잔존 흔적 + 심각도) → 최종본 → (선택) 무엇을 고쳤는지 한 줄 요약 + 자가 채점 등급(A~D). 목표 글자수가 있으면 최종 글자수(공백 포함/제외)를 함께 적는다. 사용자가 "결과만 줘"라고 하면 최종본만 낸다.
|
||||
|
||||
# Full Example
|
||||
|
||||
**Before (AI 티 나는 글):**
|
||||
> 좋은 질문이에요! 아래에 이 주제에 대한 글을 작성해 드릴게요. 도움이 되었으면 좋겠습니다!
|
||||
>
|
||||
> AI 코딩 도구는 거대 언어 모델의 혁신적 잠재력을 보여주는 진정한 증거이자, 소프트웨어 개발 역사에 중요한 이정표를 세운 산물이라 할 수 있다. 빠르게 변화하는 오늘날의 기술 환경 속에서, 연구와 실무의 교차점에 자리한 이 획기적인 도구들은 — 개발자가 아이디어를 구상하고, 반복하고, 전달하는 방식을 — 재편하며 현대 워크플로우에서의 핵심적 역할을 방증하고 있다.
|
||||
>
|
||||
> 본질적으로 그 가치는 명확하다: 프로세스의 간소화, 협업의 강화, 그리고 정렬의 촉진. 이것은 단순한 자동완성이 아니다. 그것은 창의성의 확장이다.
|
||||
>
|
||||
> - 💡 **속도:** 코드 생성이 비약적으로 빨라져 마찰이 감소됩니다.
|
||||
> - 🚀 **품질:** 향상된 학습을 통해 결과물의 품질이 향상되었습니다.
|
||||
>
|
||||
> 공개된 정보가 제한적이지만, 이러한 도구들이 어느 정도 긍정적인 효과를 가질 수도 있다고 볼 수 있을 것이다. 결론적으로, 미래는 밝다. 앞으로의 행보가 기대된다. 더 궁금한 점이 있으면 말씀해 주세요!
|
||||
|
||||
**탐지 (분류·심각도):** 챗봇 잔재(S1) · 과장된 의의(S1) · D-4 hype 어휘(S1) · J-2 줄표(S1) · C-5 이모지(S1) · J-1 불릿 머리말(S2) · A-7 "가지다"(S1) · G hedging 누적(S2) · D-1 "결론적으로"(S1) · D-6 막연한 마무리(S1).
|
||||
|
||||
**최종본 (AI 티 안 나게):**
|
||||
> AI 코딩 도구는 지루한 부분을 빠르게 해준다. 전부는 아니고. 설계는 확실히 아니다.
|
||||
>
|
||||
> 보일러플레이트엔 강하다. 설정 파일, 테스트 골격, 반복 리팩터링. 그리고 멀쩡해 보이면서 틀리는 데도 강하다. 컴파일되고 린트도 통과한 제안을 받았다가, 주의를 놓는 바람에 핵심을 빗나간 적이 있다.
|
||||
>
|
||||
> 주변 사람들은 보통 두 쪽으로 갈린다. 잡일 자동완성처럼 쓰며 줄마다 검토하는 쪽, 원치 않는 제안에 질려 꺼버린 쪽. 둘 다 그럴 만하다.
|
||||
>
|
||||
> 생산성 지표는 영 미끄럽다. 깃허브야 "제안 수락률 30%"라고 할 수 있지만, 수락이 곧 정확함은 아니고 정확함이 곧 가치도 아니다. 테스트가 없으면 사실상 찍는 거다.
|
||||
|
||||
**고친 내용 / 등급:** 챗봇 인사말·과장된 의의·hype 어휘·"가지다" 직역·3의 법칙·줄표·이모지·불릿 머리말·hedging·막연한 마무리를 걷어내고, 들쭉날쭉한 리듬과 구체적 디테일로 목소리를 다시 세웠다. (변경률 약 40% — 원문이 거의 전체 AI식이라 불가피, 의미는 보존. 등급 **B**)
|
||||
|
||||
---
|
||||
|
||||
# When to use
|
||||
- "이 글 AI 티 안 나게 고쳐줘 / 사람이 쓴 것처럼 바꿔줘"
|
||||
- "번역체 / 어색한 문장 자연스럽게 다듬어줘", "ChatGPT로 쓴 티 나는데 고쳐줘"
|
||||
- "이 글에서 AI 흔적 찾아줘"(고치지 말고 진단·심각도만)
|
||||
- "1000자로 맞춰서 자연스럽게 다듬어줘"(목표 글자수, Length control)
|
||||
- 블로그·자기소개서·이메일·보고서를 자연스러운 한국어로 재작성
|
||||
|
||||
# When NOT to use
|
||||
- 맞춤법·띄어쓰기 교정만 필요할 때 → `korean-spell-check`
|
||||
- 유행어·밈을 입히는 작업 → `korean-slang-writing`
|
||||
- 사실관계 확인·출처 보강이 핵심일 때 (이 스킬은 문체만 고치고 사실을 검증하지 않는다)
|
||||
- 원문에 없는 내용을 창작해 채워야 할 때 (의미 보존이 원칙이다)
|
||||
|
||||
# Done when
|
||||
- 최종본에 줄표(`—`, `–`)·이모지가 없고, 잔존 S1 패턴이 0건이다.
|
||||
- 번역체(직역 조사, "가지다", 이중피동, "그/그녀" 강박, 좌향 수식)가 자연스러운 한국어로 풀렸다.
|
||||
- D-4 hype 어휘·3의 법칙·마무리 상투구·연결어미 뒤 쉼표가 정리됐다.
|
||||
- 원문 내용을 빠짐없이 다뤘고, 사실관계를 바꾸거나 지어내지 않았다(변경률 ≤30%, 50% 초과면 중단).
|
||||
- false positive 가이드로 멀쩡한 사람 글의 디테일을 망치지 않았는지 점검했다.
|
||||
- 목표 글자수가 있었다면 실제로 세어 ±5%(엄격 시 ±2%) 안에 들었고, 공백 포함/제외 수치를 적었다.
|
||||
- 자가검증 6항을 통과했고 등급(A~D)을 매겼다.
|
||||
|
||||
# Notes & Credits
|
||||
|
||||
- 이 스킬의 분류 체계(번역체 A · 영어 인용 B · 구조 C · 관용구 D · 리듬 E · 수식 F · hedging G · 접속사 H · 형식명사 I · 시각 장식 J), 심각도(S1/S2/S3), 4대 철칙, 변경률 30%/50% 가드, 품질 등급(A~D)은 **[epoko77-ai/im-not-ai](https://github.com/epoko77-ai/im-not-ai)** (Humanize KR, MIT)의 방법론을 한국어 단일 스킬 형식에 맞게 재구성한 것이다. A-16(그/그녀 강박)·A-18(관계절 좌향 수식)·A-19(이중 조사)·C-11(연결어미 뒤 쉼표)·E-7(경어법 일관성) 같은 한국어 고유 패턴은 im-not-ai의 학술 인용(김도훈 2009, 박옥수 2018, 김정우 2007 등)에 기반한다.
|
||||
- 최초 한국어 humanizer 스킬과 33개 패턴 카탈로그·예문·triage/length-control 설계는 **happy-nut(Hyungsun Song)** 님이 PR #311로 기여했다. 이 v2는 그 토대 위에 im-not-ai의 프레임워크를 얹은 것이다.
|
||||
- 영어권 원형은 [blader/humanizer](https://github.com/blader/humanizer)이고, 영어판이 [Wikipedia: Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing)에 기반하듯 한국어판은 **번역체**와 격식을 가장한 **상투어**를 1순위 정체로 본다.
|
||||
- 핵심 통찰: LLM은 통계적으로 가장 그럴듯한 다음 토큰을 고른다. 그래서 가장 무난하고 넓게 들어맞는 표현으로 수렴한다. AI 티를 지운다는 건 그 평균값에서 벗어나 **구체적이고 들쭉날쭉한 사람의 선택**으로 되돌리는 일이다. 패턴은 단발이 아니라 **무더기**로 판단하고, 의심스러우면 지우기보다 남긴다.
|
||||
147
korean-humanizer/references/ai-tell-taxonomy.md
Normal file
147
korean-humanizer/references/ai-tell-taxonomy.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# 한국어 AI 흔적 분류 체계 (AI-tell Taxonomy)
|
||||
|
||||
`korean-humanizer` 스킬의 전체 패턴 표다. 정의 1줄 + 처방 1줄로 압축했다. 본문 `SKILL.md`의 핵심 패턴을 보강하는 레퍼런스이며, 무더기 판단이 애매할 때 이 표로 심각도를 가른다.
|
||||
|
||||
이 분류 체계·심각도·처방은 [epoko77-ai/im-not-ai](https://github.com/epoko77-ai/im-not-ai) (Humanize KR, MIT)의 `ai-tell-taxonomy.md` / `quick-rules.md`를 한국어 단일 스킬 형식에 맞게 재구성한 것이다. 학술 anchor는 해당 프로젝트의 `scholarship.md` 인용을 따른다.
|
||||
|
||||
## 심각도
|
||||
|
||||
- **S1 결정적** — 한 번만 나와도 AI 확신. 무조건 제거.
|
||||
- **S2 강함** — 1~2회 허용, 3회 이상 반복 시 제거.
|
||||
- **S3 약함** — 다른 패턴과 무더기로 겹칠 때만 문제.
|
||||
|
||||
## 과윤문 가드 / Do-NOT
|
||||
|
||||
- 변경률 30% 초과 = 경고, 50% 초과 = 강제 중단·롤백.
|
||||
- **탐지·윤문 모두 제외:** 고유명사·제품명·모델명·기관명, 수치·날짜·단위, 큰따옴표 안 직접 인용, 법률 조문, 수학·화학·통계 표기, 업계 표준 영어 약어(LLM·GPU·API·MCP 등).
|
||||
|
||||
---
|
||||
|
||||
## A. 번역투 (Translation-ese)
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| A-1 | "~에 대해(서)" | S1 | 목적격 조사로 직결("X에 대해 논의" → "X를 논의") |
|
||||
| A-2 | "~를 통해/통하여" 남발 | S1 | "~로", "~해서", "~함으로써"로 분산 |
|
||||
| A-3 | "~에 있어(서)" | S1 | "~에서", "~을 볼 때" |
|
||||
| A-4 | "~라는 점에서" 3회+ | S2 | "~서", "~라는 이유로" |
|
||||
| A-5 | "~와 관련하여/관련된" | S2 | "~에", "~의" |
|
||||
| A-6 | 명사화 과잉 / "~에 기반하여/바탕으로" | S2 | 동사로 환원("성능의 향상" → "성능을 높이려고") |
|
||||
| A-7 | "가지고 있다" / have·make·take·give + N 직역 | S1 | 형용사·동사 환원("경쟁력을 가지고 있다" → "경쟁력이 강하다") |
|
||||
| A-8 | 이중 피동 "~되어진다/보여진다" | S1 | 능동 또는 단일 피동("판단되어진다" → "판단된다") |
|
||||
| A-9 | "~에 의해" 피동 | S2 | 행위자를 주어로("AI에 의해 생성" → "AI가 만든") |
|
||||
| A-10 | "~할 수 있다" 남발 | S2 | 단언으로("높일 수 있다" → "높인다") |
|
||||
| A-11 | "~을 위해" 목적절 남발 | S2 | "~려고", "~위한" |
|
||||
| A-15 | 추상 주어 + 만능 동사 / 사역·인지 동사 직역 | S2 | 구체 주어 환원, "suggest/show"는 "~에 따르면 ~이다"로 분리 |
|
||||
| A-16 | "그/그녀/그것/그들" 한 단락 3회+ (영어 대명사 직역) | S1 | 50%+ 영형(생략) 또는 호칭·명사구로 (김도훈 2009) |
|
||||
| A-17 | 복수 접미사 "~들" 남발 | S2 | 맥락으로 복수면 삭제("개발자들이" → "개발자가") |
|
||||
| A-18 | 명사 앞 3어절+ 관형구·관계절 좌향 수식 | S2 | 문장 분리 또는 후치 동격절 (박옥수 2018) |
|
||||
| A-19 | 이중 조사 "~에서의/~으로의/~에의/~으로부터의" | S2 | 절·구로 풀어쓰기 (김정우 2007) |
|
||||
|
||||
## B. 영어 인용·용어 과다
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| B-1 | 한글 + 괄호 영어 매번 병기 | S2 | 첫 등장만 병기, 이후 한글만 |
|
||||
| B-2 | 직역 가능한 영어 그대로 | S2 | 한국어로 옮기되 업계 표준 약어는 유지 |
|
||||
|
||||
## C. 구조적 AI 패턴
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| C-5 | 이모지 남발 | S1 | 칼럼·리포트면 전부 삭제 |
|
||||
| C-7 | "먼저·반면·결국" 3단 공식 / 3의 법칙 | S2 | 접속사 1~2개로 줄이거나 본문에 녹임 |
|
||||
| C-8 | "A인가·B인가" 대구 반복 | S2 | 한 번만 살리고 나머지는 평서문으로 |
|
||||
| C-9 | 숫자 괄호 인덱싱 "(1)·(2)·(3)" | S2 | 본문에 녹이거나 단순 줄바꿈 |
|
||||
| C-10 | 콜론 부제 헤딩 "X: Y" 반복 | S1 | 헤딩 짧게 또는 평서 헤딩으로 |
|
||||
| C-11 | 연결어미(-고/-며/-지만/-아서) 직후 쉼표 | S1 | 쉼표 제거. 6회+ = 강한 신호 |
|
||||
|
||||
## D. AI 특유의 관용구 (Signature Phrases)
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| D-1 | 결산 피벗 "결론적으로/따라서/이를 통해/요약하면" | S1 | 3회 초과 시 1~2건만 치환, 나머지 삭제 |
|
||||
| D-2 | "시사하는 바가 크다/주목할 만하다" | S1 | 삭제 또는 구체 결론으로 |
|
||||
| D-3 | "본질적으로/핵심적으로" | S1 | 삭제 |
|
||||
| D-4 | hype 어휘(파격적·압도적·획기적·다채로운·진정한·자리매김) 3회+ | S1 | 구체 수치·사실로 환원 |
|
||||
| D-5 | 의인화 추상 주어("기술이 묻는다·시대가 부른다") | S1 | 사람·기관 주어로 |
|
||||
| D-6 | 결말 공식 "~할 때다/~해야 한다/지금이야말로" / 막연한 긍정 마무리 | S1 | 평서로 닫거나 삭제 |
|
||||
| D-7 | 변환 공식 "X에서 Y로" 반복 | S2 | 한 번만, 나머지는 일반 서술 |
|
||||
| D-8 | 과장된 의의 부여(이정표·산물·새 지평·상징) | S1 | 구체 사실로 환원 |
|
||||
| D-9 | 출처 없는 권위 호출(전문가들은·~로 알려져 있다) | S2 | 구체 출처 명시 또는 삭제 |
|
||||
|
||||
## E. 리듬·종결어미
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| E-1 | 문장 길이 균일(stdev 8 미만) | S2 | 단문·장문을 의도적으로 섞음 |
|
||||
| E-2 | 동일 종결어미 "~다" 4문장 연속 / "~고 있다" 자동 매핑 | S2 | 종결어미 다양화, 단순 시제 환원 |
|
||||
| E-7 | 청자 경어법 일관성 손실(대화·구어 한정) | S2 | 한 단락 내 혼용 금지 (김혜영 2019) |
|
||||
|
||||
## F. 과도한 수식·중복
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| F-1 | 동의어 돌려쓰기(주인공→캐릭터→인물→그) | S2 | 한 명칭으로 통일 |
|
||||
| F-2 | 가짜 범위 "A에서 B까지" / 부정 병렬 "단순한 X가 아니라 Y" | S2 | 평서로 환원 |
|
||||
| F-4 | -성/-적/-화 + 영어 -tion/-ment 누적(한 글 12회+) | S2 | 동사·형용사 어근으로 환원 |
|
||||
| F-5 | "~적 N" 추상 체인("전략적 함의·실천적 기반") | S2 | 명사+명사 또는 풀어쓰기 |
|
||||
|
||||
## G. Hedging
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| G-1 | "~것이다/~할 것이다" 미래 단정 남발 | S2 | 현재형·확정형으로 |
|
||||
| G-2 | "~로 보인다/~인 듯하다" 추정 남발 | S2 | 단언 가능한 곳은 단언 |
|
||||
| G-3 | 안전 균형 lexicon "장점도 있지만/신중하게/균형" 4회+ | S2 | 1~2건만 화자 입장으로 치환 |
|
||||
| G-4 | 지식 한계 면피("공개된 정보가 제한적이지만") | S2 | "자료에 없다" 또는 문장 삭제 |
|
||||
|
||||
## H. 접속사 남발
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| H-1 | 문두 접속사 "또한·따라서·즉·나아가·게다가" 5회+ | S1 | 대량 제거, 문장이 흐름을 잡게 |
|
||||
| H-3 | 메타 진입 "이는·이 점에서·이 관점에서" 3회+ | S1 | 본문에 녹이거나 삭제 |
|
||||
| H-4 | "즉" 남발 | S2 | 1회로 제한 |
|
||||
|
||||
## I. 형식명사·의존명사
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| I-1 | "~인 것이다/~한 것이다" 결말 / 권위적 본질 호명 | S1 | 평서형으로 |
|
||||
| I-2 | "X은 ~라는 점에 있다" | S2 | "X는 ~다" 직설로 |
|
||||
| I-3 | "~다는 뜻이다/~다는 의미다" 결말 | S2 | 본문에 풀어 쓰기 |
|
||||
| I-4 | 설교조 당위 "~해야 한다·~할 필요가 있다" / 예고 멘트 반복 | S2 | 평서·단언으로, 예고 삭제 |
|
||||
|
||||
## J. 시각 장식
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| J-1 | 볼드 ** 강조 남발 / 불릿+굵은 머리말 나열 | S2 | 칼럼·리포트면 줄글로 통합 |
|
||||
| J-2 | 줄표(—)·en dash(–) / 따옴표 강조 5회+ / 곡선따옴표·물결표 | S1(줄표) | 줄표 제거(마침표·쉼표·괄호로), 강조 한두 개만 |
|
||||
| J-3 | 불릿 리스트 (장르가 칼럼·리포트일 때) | S2 | 문단 산문으로 통합 |
|
||||
|
||||
## 챗봇 잔재
|
||||
|
||||
| ID | 패턴 | 심각도 | 처방 |
|
||||
|---|---|---|---|
|
||||
| K-1 | 챗봇 응대("좋은 질문이에요!·도움이 되었으면") | S1 | 통째로 삭제 |
|
||||
| K-2 | 아첨·과잉 공손("정말 훌륭한 지적이세요!") | S1 | 삭제, 본론만 남김 |
|
||||
|
||||
---
|
||||
|
||||
## 자가검증 체크리스트 (윤문 후, 한 항목 위반 시 해당 edit 롤백)
|
||||
|
||||
1. 고유명사·수치·날짜·인용 100% 보존 — 원문 대비 한 글자도 다르지 않은가.
|
||||
2. 변경률 30% 이하인가 (50% 초과는 작업 중단).
|
||||
3. 장르 이탈 없음 — 칼럼이 에세이로, 리포트가 블로그체로 떨어지지 않았는가.
|
||||
4. register 보존 — 원문이 격식체면 결과도 격식체.
|
||||
5. 잔존 S1 0건 — D-1~D-6·A-7·A-8·A-16·C-5·C-10·C-11·H-1·I-1·J-2.
|
||||
6. 인공 표현 자제 — 원문에 없던 비유·수사를 임의로 더하지 않았는가.
|
||||
|
||||
## 등급 기준 (자가 채점)
|
||||
|
||||
- **A**: S1 잔존 0, S2 잔존 2 이하, 변경률 10~25%, 자가검증 6항 통과.
|
||||
- **B**: S1 잔존 0, S2 잔존 4 이하, 자가검증 5항 이상 통과.
|
||||
- **C**: S1 잔존 1~2 또는 자가검증 4항 이하 → 2차 윤문 권고.
|
||||
- **D**: S1 잔존 3+ 또는 변경률 50% 초과 → 작업 중단, 사람 검토 권고.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: korean-law-search
|
||||
description: Use korean-law-mcp first for Korean law lookups, and fall back to Beopmang when the primary service is unavailable.
|
||||
description: Search Korean statutes, articles, precedents, interpretations, and local ordinances via k-skill-proxy. Use when the user asks for Korean law/article/precedent lookups.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: legal
|
||||
|
|
@ -12,16 +12,12 @@ metadata:
|
|||
|
||||
## What this skill does
|
||||
|
||||
한국 법령/조문/판례/유권해석/자치법규 조회가 필요할 때 기본 경로로 **`korean-law-mcp`를 먼저 사용**하고, 기존 서비스가 동작하지 않을 때는 승인된 fallback 표면인 **`법망`(`https://api.beopmang.org`)** 으로 이어간다.
|
||||
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/korean-law/...` 로 요청해서 한국 법령/조문/판례/유권해석/자치법규를 조회한다. 법제처(국가법령정보센터) 공식 Open API(`open.law.go.kr` 의 DRF `lawSearch.do`/`lawService.do`)를 기반으로 하며, 설계는 `chrisryugj/korean-law-mcp` 의 read-only 도구 표면을 참고했다.
|
||||
|
||||
- 법령명 검색: `search_law`
|
||||
- 조문 본문 조회: `get_law_text`
|
||||
- 판례 검색: `search_precedents`
|
||||
- 유권해석 검색: `search_interpretations`
|
||||
- 자치법규 검색: `search_ordinance`
|
||||
- 여러 카테고리가 섞인 검색: `search_all`
|
||||
사용자는 별도 API key(`LAW_OC`)나 로컬 CLI 설치가 필요 없다. `LAW_OC` 와 브라우저 User-Agent/Referer 주입은 proxy 서버에서만 처리한다.
|
||||
|
||||
이 스킬은 자체 npm/python 패키지를 만들지 않는다. 한국 법령 관련 조회는 기본적으로 `korean-law-mcp` 로 처리하고, 해당 경로가 막히거나 실패가 반복될 때만 승인된 fallback 표면인 `법망`을 사용한다.
|
||||
- 검색/목록: `GET /v1/korean-law/search`
|
||||
- 본문/상세: `GET /v1/korean-law/detail`
|
||||
|
||||
## When to use
|
||||
|
||||
|
|
@ -39,136 +35,102 @@ metadata:
|
|||
|
||||
## Prerequisites
|
||||
|
||||
- 인터넷 연결
|
||||
- `node` 18+
|
||||
- `npm install -g korean-law-mcp` (로컬 CLI/로컬 MCP server 경로일 때)
|
||||
- MCP 클라이언트에 remote endpoint를 등록할 수 있는 환경
|
||||
- `법망` fallback (`https://api.beopmang.org`) 에 접근할 수 있는 네트워크
|
||||
없음. 사용자는 별도 API key를 준비할 필요가 없다. upstream `LAW_OC` 는 proxy 서버에서만 주입한다.
|
||||
|
||||
무료 API key: `https://open.law.go.kr`
|
||||
## Default path
|
||||
|
||||
로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC` 가 필요하다.
|
||||
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
|
||||
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
|
||||
|
||||
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
|
||||
|
||||
## Supported endpoints
|
||||
|
||||
### 검색/목록 조회
|
||||
|
||||
```
|
||||
GET /v1/korean-law/search?target={target}&query={검색어}
|
||||
```
|
||||
|
||||
`target` 은 read-only 법령정보 종류다.
|
||||
|
||||
| target | 설명 |
|
||||
|---|---|
|
||||
| `law` | 현행법령 |
|
||||
| `eflaw` | 시행일 법령 |
|
||||
| `elaw` | 영문법령 |
|
||||
| `prec` | 판례 |
|
||||
| `detc` | 헌재결정례 |
|
||||
| `expc` | 법령해석례(유권해석) |
|
||||
| `admrul` | 행정규칙 |
|
||||
| `ordin` | 자치법규 |
|
||||
| `trty` | 조약 |
|
||||
| `lstrm` | 법령용어 |
|
||||
|
||||
지원 필터: `query`(검색어), `display`, `page`, `sort`, `date`, `prncYd`(선고일자), `nb`(사건번호), `datSrcNm`(데이터출처명), `curt`(법원), `org`, `knd`, `gana`, `nw`, `efYd`, `ancYd`. 응답은 법제처 DRF JSON 그대로에 `proxy` 메타데이터만 덧붙인다. 요약 전에 반환 메타데이터를 먼저 확인한다.
|
||||
|
||||
### 본문/상세 조회
|
||||
|
||||
```
|
||||
GET /v1/korean-law/detail?target={target}&ID={일련번호}
|
||||
```
|
||||
|
||||
검색 결과의 식별자(`ID` 또는 `MST`/`LID`)를 넘겨 상세 본문을 가져온다. 조문 지정은 `JO`(예: `000200` = 제2조), 언어는 `LANG` 로 넘긴다.
|
||||
|
||||
## Example requests
|
||||
|
||||
법령명 검색:
|
||||
|
||||
```bash
|
||||
npm install -g korean-law-mcp
|
||||
export LAW_OC=your-api-key
|
||||
|
||||
korean-law list
|
||||
korean-law help search_law
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
|
||||
--data-urlencode 'target=law' \
|
||||
--data-urlencode 'query=관세법'
|
||||
```
|
||||
|
||||
로컬 설치가 운영체제 정책이나 권한 때문에 막히면 먼저 `korean-law-mcp` 의 remote MCP endpoint(`https://korean-law-mcp.fly.dev/mcp`)를 사용한다. 그래도 기존 경로가 응답하지 않거나 서비스 장애로 조회가 막히면, 승인된 fallback 표면인 `법망` MCP/REST(`https://api.beopmang.org`)로 전환한다.
|
||||
|
||||
## MCP client setup
|
||||
|
||||
Claude Desktop / Cursor / Windsurf 같은 MCP 클라이언트에는 아래처럼 연결한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"korean-law": {
|
||||
"command": "korean-law-mcp",
|
||||
"env": {
|
||||
"LAW_OC": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
설치가 막힌 환경에서는 remote endpoint를 사용한다. 이 upstream 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"korean-law": {
|
||||
"url": "https://korean-law-mcp.fly.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fallback workflow (`법망`)
|
||||
|
||||
기존 `korean-law-mcp` 경로가 동작하지 않을 때만 아래 fallback을 사용한다.
|
||||
|
||||
### 1. MCP fallback
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"beopmang": {
|
||||
"url": "https://api.beopmang.org/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 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/search' \
|
||||
--data-urlencode 'target=prec' \
|
||||
--data-urlencode 'query=부당해고'
|
||||
```
|
||||
|
||||
## CLI workflow
|
||||
|
||||
### 1. 법령명부터 찾기
|
||||
판례 본문 조회:
|
||||
|
||||
```bash
|
||||
korean-law search_law --query "관세법"
|
||||
```
|
||||
|
||||
### 2. 특정 조문 본문 조회
|
||||
|
||||
```bash
|
||||
korean-law get_law_text --mst 160001 --jo "제38조"
|
||||
```
|
||||
|
||||
### 3. 판례 검색
|
||||
|
||||
```bash
|
||||
korean-law search_precedents --query "부당해고"
|
||||
```
|
||||
|
||||
### 4. 자치법규 검색
|
||||
|
||||
```bash
|
||||
korean-law search_ordinance --query "서울특별시 청년 기본 조례"
|
||||
```
|
||||
|
||||
### 5. 애매하면 통합 검색
|
||||
|
||||
```bash
|
||||
korean-law search_all --query "개인정보 처리방침 행정해석"
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/detail' \
|
||||
--data-urlencode 'target=prec' \
|
||||
--data-urlencode 'ID=228541'
|
||||
```
|
||||
|
||||
## Response policy
|
||||
|
||||
- 한국 법령 관련 요청은 **항상 `korean-law-mcp`를 먼저 사용**한다.
|
||||
- 기존 `korean-law-mcp` 경로가 설치/네트워크/서비스 장애로 실패하면 `법망`(`https://api.beopmang.org`)을 fallback으로 사용한다.
|
||||
- 약칭(`화관법`)이면 `search_law` / `search_all` 로 정식 법령명을 먼저 확인한다.
|
||||
- 조문 요청이면 검색 결과의 식별자(`mst`)를 확인한 뒤 `get_law_text` 로 본문을 가져온다.
|
||||
- 판례는 `search_precedents`, 유권해석은 `search_interpretations`, 자치법규는 `search_ordinance` 를 우선 사용한다.
|
||||
- 로컬 CLI/MCP 경로를 쓰는데 `LAW_OC` 가 없으면 credential resolution order에 따라 확보 방법을 짧게 안내하고, 임의의 크롤링/검색엔진 우회로 넘어가지 않는다.
|
||||
- remote MCP endpoint를 쓰면 사용자 `LAW_OC` 없이 `url` 등록 상태만 확인한다.
|
||||
- 한국 법령 관련 요청은 이 proxy endpoint로 처리한다. 별도 크롤러나 검색엔진 우회로 넘어가지 않는다.
|
||||
- 약칭(`화관법`)이면 `target=law` 로 정식 법령명을 먼저 확인한다.
|
||||
- 조문 요청이면 검색 결과의 식별자(`MST`/`ID`)를 확인한 뒤 `detail` 로 본문을 가져온다.
|
||||
- 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 조회한다.
|
||||
- 판례 본문이 필요하면 검색 결과의 판례 일련번호를 `detail?target=prec&ID=...` 로 이어서 조회한다.
|
||||
- 검색 결과가 0건이어도 "관련 규범이 없다"고 단정하지 말고 검색어·법원·사건번호·선고일자·출처명을 바꿔 다시 시도한다.
|
||||
- 일부 출처는 본문을 제공하지 않을 수 있다. 본문을 못 가져오면 목록 메타데이터(사건번호·법원·선고일자·출처·요지)까지만 제공하고 본문이 없다는 점을 명시한다(없는 본문을 지어내지 않는다).
|
||||
- 법적 판단이 필요한 경우 `검색 결과 요약`과 `원문 출처`까지만 제공하고 법률 자문처럼 단정하지 않는다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- `target` 이 없거나 허용되지 않은 값이면 400 응답
|
||||
- 검색어/식별자가 없으면 400 응답
|
||||
- 프록시 서버에 `LAW_OC` 가 없으면 503 응답
|
||||
- 법제처 API가 사용자 검증 실패(`사용자 정보 검증 실패`)를 반환하면 502 + `law_user_verification_failed` (서버 OC/UA/Referer 점검)
|
||||
- 법제처 API가 일시적으로 빈/HTML 응답이면 proxy가 재시도 후 502 + `upstream_unstable`
|
||||
|
||||
## Done when
|
||||
|
||||
- 한국 법령 관련 질의에 대해 `korean-law-mcp` 사용 경로가 선택되었다.
|
||||
- 필요한 검색/조회 명령이 정해졌다.
|
||||
- 법령/조문/판례/유권해석/자치법규 중 맞는 도구로 결과를 조회했다.
|
||||
- 유권해석이면 `search_interpretations`, 자치법규면 `search_ordinance` 까지 명시적으로 연결했다.
|
||||
- 로컬 경로라면 `LAW_OC` 확보 방법을 정확한 변수 이름으로 안내했다.
|
||||
- remote endpoint라면 사용자 `LAW_OC` 없이 `url` 등록 상태를 확인했다.
|
||||
- 기존 경로 장애 시 `법망` fallback(MCP 또는 REST)으로 이어지는 안내가 포함되었다.
|
||||
- 한국 법령 관련 질의를 proxy endpoint로 라우팅했다.
|
||||
- 법령/조문은 `target=law` + 필요 시 `detail`, 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 맞는 종류를 조회했다.
|
||||
- 판례/조문 본문이 필요하면 식별자로 `detail` 본문까지 연결했다.
|
||||
- 결과를 요약하고 원문 출처(법제처 국가법령정보센터)를 함께 남겼다.
|
||||
|
||||
## Notes
|
||||
|
||||
- upstream: `https://github.com/chrisryugj/korean-law-mcp`
|
||||
- fallback surface: `https://api.beopmang.org`
|
||||
- official data source: 법제처 Open API (`https://open.law.go.kr`)
|
||||
- 설계 참고(upstream): `https://github.com/chrisryugj/korean-law-mcp`
|
||||
- official data source: 법제처 Open API (`https://open.law.go.kr`, DRF `lawSearch.do`/`lawService.do`)
|
||||
- 운영자(proxy) 전용 시크릿: `LAW_OC` (사용자는 불필요). 무료 발급: `https://open.law.go.kr`
|
||||
- 이 저장소 안에는 한국 법령 전용 npm package나 python package를 추가하지 않는다.
|
||||
|
|
|
|||
89
korean-middle-korean/SKILL.md
Normal file
89
korean-middle-korean/SKILL.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
name: korean-middle-korean
|
||||
description: Convert incoming Korean text into a deterministic Korean Middle Korean-style rewrite with archaic particles, endings, Hanja hints, and tone-mark flavor.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: writing
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Korean Middle Korean Style Converter
|
||||
|
||||
## What this skill does
|
||||
|
||||
사용자가 한국어 문장을 "한국 중세 국어처럼", "훈민정음/중세국어 느낌으로", "옛 국어 밈체로" 바꾸어 달라고 할 때, 입력문을 **결정론적 Middle Korean-style 문체**로 바꾼다.
|
||||
|
||||
이 스킬은 학술적 복원이 아니라 창작용 스타일 변환이다.
|
||||
|
||||
- 현대 한국어 조사 일부를 `ᄋᆞᆫ`, `ᄋᆞᆯ`, `애` 같은 중세국어풍 표기로 바꾼다.
|
||||
- `했다`, `하는`, `말하는` 같은 현대 어미를 `ᄒᆞ엿다〮`, `ᄒᆞᄂᆞᆫ`, `ᄆᆞᆯᄒᆞᄂᆞᆫ`처럼 바꾼다.
|
||||
- 날짜 단위는 `年`, `月`, `日`로 바꾼다.
|
||||
- 일부 한자어/밈 예시는 `熱愛說`, `俳優`, `學校`처럼 Hanja 힌트를 섞는다.
|
||||
- URL, 이메일, Markdown 링크, inline/fenced code span은 구조 토큰으로 보고 변환하지 않는다.
|
||||
- 인명·숫자·고유명사는 완전 보존이 아니라, 규칙이 맞지 않을 때 원문을 남기는 best-effort 방식으로 처리한다.
|
||||
- 한자어 힌트는 넓은 전역 치환으로 적용되므로 합성어·고유명사처럼 보이는 문자열 안에서도 바뀔 수 있다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "이 문장을 한국 중세 국어로 바꿔줘"
|
||||
- "훈민정음 느낌 나는 밈 문장으로 변환해줘"
|
||||
- "중세국어풍으로 농담을 써줘"
|
||||
- "아래 글을 옛 국어 말투로 바꿔줘"
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- 학술 논문, 고문헌 번역, 훈민정음 해례본식 엄밀 표기가 필요한 작업
|
||||
- 법률·의학·계약 문서처럼 의미 오해가 위험한 문서
|
||||
- 혐오·괴롭힘·명예훼손 목적의 조롱성 변환
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `node` 18+
|
||||
- 설치된 `korean-middle-korean` skill 디렉터리 안에 `scripts/korean_middle_korean.js` helper 포함
|
||||
- 별도 API 키 없음
|
||||
|
||||
## Workflow
|
||||
|
||||
1. 변환할 한국어 텍스트를 받는다.
|
||||
2. 설치된 `korean-middle-korean` skill 디렉터리를 기준으로 `node scripts/korean_middle_korean.js` 를 실행한다.
|
||||
- URL, 이메일, Markdown 링크, inline/fenced code span은 보호되어 원문 그대로 복원된다.
|
||||
3. 기본 JSON 출력에서 `output`을 사용자에게 반환한다.
|
||||
4. 사용자가 근거를 원하면 `replacements` 배열의 규칙 적용 내역을 요약한다.
|
||||
5. 학술적 정확성이 필요하다고 보이면 이 스킬은 창작용 스타일 변환임을 먼저 밝힌다.
|
||||
|
||||
## CLI examples
|
||||
|
||||
```bash
|
||||
node scripts/korean_middle_korean.js --text "민수는 3월 5일 학교에서 공부했다."
|
||||
node scripts/korean_middle_korean.js --text "열애설을 인정했다." --format text
|
||||
cat input.txt | node scripts/korean_middle_korean.js --stdin --format json
|
||||
node scripts/korean_middle_korean.js --file ./input.txt --format text
|
||||
```
|
||||
|
||||
## Response policy
|
||||
|
||||
- `output`을 중심으로 답한다.
|
||||
- "정확한 중세국어 번역"이라고 단정하지 말고, "중세국어풍/창작용 변환"이라고 설명한다.
|
||||
- 사용자가 원문 의미 보존을 중요하게 말하면, 변환문 뒤에 "의미 보존 확인"을 짧게 덧붙인다.
|
||||
|
||||
## Output schema
|
||||
|
||||
```json
|
||||
{
|
||||
"profile": "middle-korean-style-v1",
|
||||
"input": "열애설을 인정했다.",
|
||||
"output": "熱愛說ᄋᆞᆯ 인졍ᄒᆞ엿다〮.",
|
||||
"replacements": [
|
||||
{ "kind": "lexicon", "from": "열애설", "to": "熱愛說", "count": 1 }
|
||||
],
|
||||
"contract": "Deterministic Korean Middle Korean-style rewrite..."
|
||||
}
|
||||
```
|
||||
|
||||
## Done when
|
||||
|
||||
- `node scripts/korean_middle_korean.js --help` 가 동작한다.
|
||||
- `--text`, `--file`, `--stdin` 입력이 모두 동작한다.
|
||||
- JSON과 text 출력이 모두 동작한다.
|
||||
- 이슈 #270의 예시처럼 날짜/Hanja/중세국어풍 조사·어미·성조점이 나타난다.
|
||||
214
korean-middle-korean/scripts/korean_middle_korean.js
Executable file
214
korean-middle-korean/scripts/korean_middle_korean.js
Executable file
|
|
@ -0,0 +1,214 @@
|
|||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
const PROFILE = "middle-korean-style-v1";
|
||||
const CONTRACT =
|
||||
"Deterministic Korean Middle Korean-style rewrite: public-domain orthographic flavor rules, fixed broad lexicon replacements, archaic particles/endings, Sino-Korean Hanja hints, protected URL/email/Markdown-code spans, and best-effort preservation for names/numbers when no rule matches.";
|
||||
|
||||
const LEXICON = [
|
||||
["야 이", "이"],
|
||||
["맛국노야", "맛國노〮야"],
|
||||
["설마", "쇼ᄆᆞ"],
|
||||
["새벽", "샛ᄇᆡ긔〮"],
|
||||
["배우", "俳優"],
|
||||
["구자욱이랑", "구자욱과"],
|
||||
["거리", "街里"],
|
||||
["손잡고", "손ᄋᆞᆯ 자ᇙ고"],
|
||||
["걸어다니는", "거러다니ᄂᆞᆫ"],
|
||||
["모습", "모ᄉᆡᆸ〮"],
|
||||
["찍혀", "찍히야"],
|
||||
["열애설", "熱愛說"],
|
||||
["터지고", "터ᄂᆞᆺ고"],
|
||||
["인정했지만", "인졍ᄒᆞ엿거ᄂᆞᆫ"],
|
||||
["인정했다", "인졍ᄒᆞ엿다〮"],
|
||||
["인정", "인졍"],
|
||||
["맛보기한", "맛보기〮ᄒᆞᆫ"],
|
||||
["느낌이랄까", "닏믁이ᄅᆞᆯ가〯"],
|
||||
["기분이구나", "기븐〮이로다"],
|
||||
["말해", "ᄆᆞᆯᄒᆞ야"],
|
||||
["사건", "일"],
|
||||
["학교", "學校"],
|
||||
];
|
||||
|
||||
function record(replacements, kind, from, to, count) {
|
||||
if (count > 0) {
|
||||
replacements.push({ kind, from, to, count });
|
||||
}
|
||||
}
|
||||
|
||||
function replaceLiteral(text, from, to, replacements) {
|
||||
const count = text.split(from).length - 1;
|
||||
if (count === 0) return text;
|
||||
record(replacements, "lexicon", from, to, count);
|
||||
return text.split(from).join(to);
|
||||
}
|
||||
|
||||
function replaceRegex(text, pattern, to, replacements, kind, label) {
|
||||
const matches = text.match(pattern);
|
||||
const count = matches ? matches.length : 0;
|
||||
const next = text.replace(pattern, to);
|
||||
record(replacements, kind, label ?? String(pattern), typeof to === "string" ? to : "<rule>", count);
|
||||
return next;
|
||||
}
|
||||
|
||||
function protectSpans(input) {
|
||||
const protectedSpans = [];
|
||||
let text = input;
|
||||
|
||||
function protect(pattern) {
|
||||
text = text.replace(pattern, (match) => {
|
||||
const token = `\uE000${protectedSpans.length}\uE001`;
|
||||
protectedSpans.push(match);
|
||||
return token;
|
||||
});
|
||||
}
|
||||
|
||||
protect(/```[\s\S]*?```/g);
|
||||
protect(/`[^`\n]*`/g);
|
||||
protect(/\[[^\]\n]*\]\([^\s)]+(?:\s+"[^"]*")?\)/g);
|
||||
protect(/\bhttps?:\/\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+/g);
|
||||
protect(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g);
|
||||
|
||||
return { text, protectedSpans };
|
||||
}
|
||||
|
||||
function restoreSpans(text, protectedSpans) {
|
||||
let output = text;
|
||||
protectedSpans.forEach((span, index) => {
|
||||
output = output.replaceAll(`\uE000${index}\uE001`, span);
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function convertToMiddleKoreanStyle(input) {
|
||||
return createReport(input).output;
|
||||
}
|
||||
|
||||
function createReport(input) {
|
||||
if (typeof input !== "string") {
|
||||
throw new TypeError("input must be a string");
|
||||
}
|
||||
|
||||
const replacements = [];
|
||||
const protectedInput = protectSpans(input);
|
||||
let output = protectedInput.text;
|
||||
|
||||
output = replaceRegex(output, /(\d+)년/g, "$1年", replacements, "date", "년→年");
|
||||
output = replaceRegex(output, /(\d+)월/g, "$1月", replacements, "date", "월→月");
|
||||
output = replaceRegex(output, /(\d+)일/g, "$1日", replacements, "date", "일→日");
|
||||
|
||||
for (const [from, to] of LEXICON) {
|
||||
output = replaceLiteral(output, from, to, replacements);
|
||||
}
|
||||
|
||||
output = replaceRegex(output, /말하는/g, "ᄆᆞᆯᄒᆞᄂᆞᆫ", replacements, "ending", "말하는→ᄆᆞᆯᄒᆞᄂᆞᆫ");
|
||||
output = replaceRegex(output, /공부했다〮?/g, "공부ᄒᆞ엿다〮", replacements, "ending", "공부했다→공부ᄒᆞ엿다〮");
|
||||
output = replaceRegex(output, /했다〮?/g, "ᄒᆞ엿다〮", replacements, "ending", "했다→ᄒᆞ엿다〮");
|
||||
output = replaceRegex(output, /하는(?=\s|[",.?!]|$)/g, "ᄒᆞᄂᆞᆫ", replacements, "ending", "하는→ᄒᆞᄂᆞᆫ");
|
||||
output = replaceRegex(output, /된(?=\s|[",.?!]|$)/g, "ᄃᆞᆫ", replacements, "ending", "된→ᄃᆞᆫ");
|
||||
output = replaceRegex(output, /것이냐(?=[.?!。]|$)/g, "것이냐〮", replacements, "ending", "것이냐→것이냐〮");
|
||||
|
||||
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)(은|는)(?=\s|[",.?!]|$)/g, "$1ᄋᆞᆫ", replacements, "particle", "은/는→ᄋᆞᆫ");
|
||||
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)(을|를)(?=\s|[",.?!]|$)/g, "$1ᄋᆞᆯ", replacements, "particle", "을/를→ᄋᆞᆯ");
|
||||
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)에서(?=\s|[",.?!]|$)/g, "$1애", replacements, "particle", "에서→애");
|
||||
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)와(?=\s|[",.?!]|$)/g, "$1와", replacements, "particle", "와 보존");
|
||||
|
||||
output = output.replace(/\s+([,.;:?!])/g, "$1");
|
||||
output = restoreSpans(output, protectedInput.protectedSpans);
|
||||
|
||||
return {
|
||||
profile: PROFILE,
|
||||
input,
|
||||
output,
|
||||
replacements,
|
||||
contract: CONTRACT,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {
|
||||
format: "json",
|
||||
inputMode: null,
|
||||
text: undefined,
|
||||
};
|
||||
let sourceCount = 0;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--text") {
|
||||
sourceCount += 1;
|
||||
parsed.inputMode = "text";
|
||||
parsed.text = argv[++index];
|
||||
if (parsed.text === undefined) throw new Error("--text requires a value");
|
||||
} else if (arg === "--file") {
|
||||
sourceCount += 1;
|
||||
parsed.inputMode = "file";
|
||||
parsed.file = argv[++index];
|
||||
if (!parsed.file) throw new Error("--file requires a path");
|
||||
} else if (arg === "--stdin") {
|
||||
sourceCount += 1;
|
||||
parsed.inputMode = "stdin";
|
||||
} else if (arg === "--format") {
|
||||
parsed.format = argv[++index];
|
||||
if (!parsed.format) throw new Error("--format requires a value");
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
parsed.help = true;
|
||||
} else {
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed.help && sourceCount !== 1) {
|
||||
throw new Error("provide exactly one input source: --text, --file, or --stdin");
|
||||
}
|
||||
if (!["json", "text"].includes(parsed.format)) {
|
||||
throw new Error(`unknown format: ${parsed.format}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readInput(options) {
|
||||
if (options.inputMode === "text") return options.text;
|
||||
if (options.inputMode === "file") return fs.readFileSync(options.file, "utf8");
|
||||
if (options.inputMode === "stdin") return fs.readFileSync(0, "utf8");
|
||||
throw new Error("missing input source");
|
||||
}
|
||||
|
||||
function helpText() {
|
||||
return `Usage: node scripts/korean_middle_korean.js (--text TEXT | --file PATH | --stdin) [--format json|text]\n\nConverts Korean input into a deterministic Korean Middle Korean-style rewrite.\n`;
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv);
|
||||
if (options.help) {
|
||||
process.stdout.write(helpText());
|
||||
return;
|
||||
}
|
||||
const report = createReport(readInput(options));
|
||||
if (options.format === "text") {
|
||||
process.stdout.write(`${report.output}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONTRACT,
|
||||
PROFILE,
|
||||
convertToMiddleKoreanStyle,
|
||||
createReport,
|
||||
parseArgs,
|
||||
main,
|
||||
};
|
||||
|
|
@ -118,7 +118,12 @@ python3 scripts/run_kstartup.py announcements \
|
|||
|
||||
### 4. Filter on the client side for richer questions
|
||||
|
||||
API는 단순 필드 매칭만 지원하고, **그중 `supt_regin` 같은 일부 필터는 upstream이 서버 측에서 적용하지 않는 사례가 관측된다.** `--supt-regin 서울특별시`로 호출해도 타 지역 공고가 섞여 돌아오는 경우가 있어서, `supt_regin`·`aply_trgt`·`biz_enyy` 처럼 답변 정확도가 중요한 필드는 helper가 받은 응답 JSON을 client에서 한 번 더 거른다. `pbanc_rcpt_end_dt`는 `YYYY-MM-DD HH:MM:SS` 문자열이라 KST 기준으로 직접 비교한다. "이번 주 마감", "30대 대상", "특정 키워드 포함" 같은 복합 조건도 client에서 마저 처리한다.
|
||||
API는 단순 필드 매칭만 지원하고, **그중 `supt_regin` 같은 일부 필터는 upstream이 서버 측에서 적용하지 않는 사례가 관측된다.** `--supt-regin 서울특별시`로 호출해도 타 지역 공고가 섞여 돌아오는 경우가 있어서, `supt_regin`·`aply_trgt`·`biz_enyy` 필드는 helper가 받은 응답을 client에서 한 번 더 거른다.
|
||||
|
||||
- 응답 `supt_regin`은 upstream이 축약형(`서울`, `경기`, `충북`)으로 돌려준다. helper는 사용자가 `--supt-regin 서울특별시` 같은 표준 광역지자체명을 줘도 17개 광역시·도(+ `전국`) 매핑 테이블로 자동 정규화해 매치한다.
|
||||
- client filter가 적용되면 응답 JSON에 `client_filter: {fields, upstream_returned, after_filter}` 블록이 함께 붙는다. `upstream_returned`는 같지만 `after_filter`가 작으면 첫 페이지로는 부족하니 `--page`를 늘려 추가 페이지를 받는다.
|
||||
- 쉼표로 여러 값을 주면 AND 매치다 (`--aply-trgt 예비창업자,1년미만` → 두 토큰 모두 row에 있어야 통과).
|
||||
- `pbanc_rcpt_end_dt`는 `YYYYMMDD` 문자열이라 KST 기준으로 직접 비교한다. "이번 주 마감", "30대 대상", "특정 키워드 포함" 같은 복합 조건은 helper가 안 거르므로 응답 JSON에서 agent가 직접 처리한다.
|
||||
|
||||
### 5. Cite the source
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,44 @@ OPERATIONS: Dict[str, Dict[str, Any]] = {
|
|||
YN_FIELDS = {"intg_pbanc_yn", "rcrt_prgs_yn"}
|
||||
DATE_FIELDS = {"pbanc_rcpt_bgng_dt", "pbanc_rcpt_end_dt"}
|
||||
|
||||
# Fields where the K-Startup upstream is observed to ignore the server-side
|
||||
# filter and return non-matching rows. SKILL.md L121 promises that the helper
|
||||
# re-applies these filters on the client side after receiving the response.
|
||||
#
|
||||
# - supt_regin: upstream returns mixed regions even when supt_regin is set.
|
||||
# - aply_trgt: upstream returns rows whose aply_trgt does not contain the
|
||||
# requested target (e.g. asking for "예비창업자" returns rows
|
||||
# with only "일반인,일반기업").
|
||||
# - biz_enyy: upstream returns rows whose biz_enyy does not include the
|
||||
# requested founding period bucket.
|
||||
#
|
||||
# Matching policy: substring match against the comma-separated list inside
|
||||
# each row's field. Multiple requested values (comma-separated by the user)
|
||||
# are AND-joined: every requested token must appear somewhere in the row.
|
||||
# This mirrors how the K-Startup web UI narrows results.
|
||||
CLIENT_FILTER_FIELDS = {"supt_regin", "aply_trgt", "biz_enyy"}
|
||||
|
||||
REGION_SHORTNAME = {
|
||||
"서울특별시": "서울", "서울시": "서울", "서울": "서울",
|
||||
"부산광역시": "부산", "부산시": "부산", "부산": "부산",
|
||||
"대구광역시": "대구", "대구시": "대구", "대구": "대구",
|
||||
"인천광역시": "인천", "인천시": "인천", "인천": "인천",
|
||||
"광주광역시": "광주", "광주시": "광주", "광주": "광주",
|
||||
"대전광역시": "대전", "대전시": "대전", "대전": "대전",
|
||||
"울산광역시": "울산", "울산시": "울산", "울산": "울산",
|
||||
"세종특별자치시": "세종", "세종시": "세종", "세종": "세종",
|
||||
"경기도": "경기", "경기": "경기",
|
||||
"강원특별자치도": "강원", "강원도": "강원", "강원": "강원",
|
||||
"충청북도": "충북", "충북": "충북",
|
||||
"충청남도": "충남", "충남": "충남",
|
||||
"전북특별자치도": "전북", "전라북도": "전북", "전북": "전북",
|
||||
"전라남도": "전남", "전남": "전남",
|
||||
"경상북도": "경북", "경북": "경북",
|
||||
"경상남도": "경남", "경남": "경남",
|
||||
"제주특별자치도": "제주", "제주도": "제주", "제주": "제주",
|
||||
"전국": "전국",
|
||||
}
|
||||
|
||||
|
||||
class HelperError(RuntimeError):
|
||||
"""User-facing CLI error."""
|
||||
|
|
@ -183,6 +221,65 @@ def http_get(url: str, *, timeout: int) -> Tuple[int, str, str]:
|
|||
raise HelperError(f"network error: {exc.reason}") from exc
|
||||
|
||||
|
||||
def _normalise_filter_token(field: str, token: str) -> str:
|
||||
if field == "supt_regin":
|
||||
return REGION_SHORTNAME.get(token, token)
|
||||
return token
|
||||
|
||||
|
||||
def _row_matches_token(row: Dict[str, Any], field: str, token: str) -> bool:
|
||||
raw = row.get(field)
|
||||
if raw is None:
|
||||
return False
|
||||
haystack = str(raw)
|
||||
needle = _normalise_filter_token(field, token)
|
||||
return needle in haystack
|
||||
|
||||
|
||||
def _row_matches_field(row: Dict[str, Any], field: str, requested: str) -> bool:
|
||||
tokens = [t.strip() for t in requested.split(",") if t.strip()]
|
||||
if not tokens:
|
||||
return True
|
||||
return all(_row_matches_token(row, field, token) for token in tokens)
|
||||
|
||||
|
||||
def apply_client_filters(
|
||||
payload: Dict[str, Any],
|
||||
args: argparse.Namespace,
|
||||
operation: str,
|
||||
) -> Dict[str, Any]:
|
||||
if operation != "announcements":
|
||||
return payload
|
||||
requested: Dict[str, str] = {}
|
||||
for field in CLIENT_FILTER_FIELDS:
|
||||
value = getattr(args, field, None)
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
requested[field] = text
|
||||
if not requested:
|
||||
return payload
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, list):
|
||||
return payload
|
||||
upstream_count = len(data)
|
||||
filtered = [
|
||||
row for row in data
|
||||
if isinstance(row, dict)
|
||||
and all(_row_matches_field(row, field, value) for field, value in requested.items())
|
||||
]
|
||||
payload["data"] = filtered
|
||||
payload["currentCount"] = len(filtered)
|
||||
payload["client_filter"] = {
|
||||
"fields": requested,
|
||||
"upstream_returned": upstream_count,
|
||||
"after_filter": len(filtered),
|
||||
"note": "Applied after upstream response because K-Startup ignores some server-side filters.",
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def summarise(operation: str, payload: Dict[str, Any]) -> str:
|
||||
items: Iterable[Dict[str, Any]] = []
|
||||
if isinstance(payload, dict):
|
||||
|
|
@ -297,6 +394,8 @@ def run(argv: Optional[List[str]] = None) -> int:
|
|||
payload = {"raw": payload}
|
||||
payload.setdefault("query", query)
|
||||
|
||||
payload = apply_client_filters(payload, args, operation)
|
||||
|
||||
if args.text:
|
||||
print(summarise(operation, payload))
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -186,5 +186,134 @@ class DryRunIntegrationTests(unittest.TestCase):
|
|||
self.assertNotIn("super-secret", payload["url"])
|
||||
|
||||
|
||||
class ClientFilterTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _payload(rows):
|
||||
return {
|
||||
"currentCount": len(rows),
|
||||
"data": list(rows),
|
||||
"totalCount": 999,
|
||||
"page": 1,
|
||||
"perPage": len(rows),
|
||||
}
|
||||
|
||||
def test_supt_regin_drops_other_regions(self):
|
||||
payload = self._payload([
|
||||
{"biz_pbanc_nm": "서울 청년창업", "supt_regin": "서울"},
|
||||
{"biz_pbanc_nm": "경북 모집", "supt_regin": "경북"},
|
||||
{"biz_pbanc_nm": "충북 K-바이오", "supt_regin": "충북"},
|
||||
])
|
||||
args = make_args("announcements", supt_regin="서울특별시")
|
||||
result = run_kstartup.apply_client_filters(payload, args, "announcements")
|
||||
self.assertEqual(result["currentCount"], 1)
|
||||
self.assertEqual(result["data"][0]["biz_pbanc_nm"], "서울 청년창업")
|
||||
self.assertEqual(result["client_filter"]["upstream_returned"], 3)
|
||||
self.assertEqual(result["client_filter"]["after_filter"], 1)
|
||||
self.assertEqual(result["client_filter"]["fields"]["supt_regin"], "서울특별시")
|
||||
|
||||
def test_supt_regin_normalises_long_official_names(self):
|
||||
rows = [
|
||||
("서울특별시", "서울"),
|
||||
("부산광역시", "부산"),
|
||||
("경기도", "경기"),
|
||||
("강원특별자치도", "강원"),
|
||||
("전북특별자치도", "전북"),
|
||||
("제주특별자치도", "제주"),
|
||||
("세종특별자치시", "세종"),
|
||||
]
|
||||
for long_name, short_name in rows:
|
||||
payload = self._payload([
|
||||
{"biz_pbanc_nm": "match", "supt_regin": short_name},
|
||||
{"biz_pbanc_nm": "other", "supt_regin": "전국"},
|
||||
])
|
||||
args = make_args("announcements", supt_regin=long_name)
|
||||
result = run_kstartup.apply_client_filters(payload, args, "announcements")
|
||||
self.assertEqual(
|
||||
[row["biz_pbanc_nm"] for row in result["data"]],
|
||||
["match"],
|
||||
f"long name {long_name!r} should match upstream short form {short_name!r}",
|
||||
)
|
||||
|
||||
def test_supt_regin_short_form_also_works(self):
|
||||
payload = self._payload([
|
||||
{"biz_pbanc_nm": "match", "supt_regin": "서울"},
|
||||
{"biz_pbanc_nm": "other", "supt_regin": "경기"},
|
||||
])
|
||||
args = make_args("announcements", supt_regin="서울")
|
||||
result = run_kstartup.apply_client_filters(payload, args, "announcements")
|
||||
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["match"])
|
||||
|
||||
def test_supt_regin_handles_nationwide_rows_explicitly(self):
|
||||
payload = self._payload([
|
||||
{"biz_pbanc_nm": "전국 공모", "supt_regin": "전국"},
|
||||
{"biz_pbanc_nm": "서울 공모", "supt_regin": "서울특별시"},
|
||||
])
|
||||
args = make_args("announcements", supt_regin="서울특별시")
|
||||
result = run_kstartup.apply_client_filters(payload, args, "announcements")
|
||||
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["서울 공모"])
|
||||
|
||||
def test_aply_trgt_substring_match_in_comma_list(self):
|
||||
payload = self._payload([
|
||||
{"biz_pbanc_nm": "예비창업자 대상", "aply_trgt": "일반인,일반기업,예비창업자"},
|
||||
{"biz_pbanc_nm": "일반 대상", "aply_trgt": "일반인,일반기업"},
|
||||
])
|
||||
args = make_args("announcements", aply_trgt="예비창업자")
|
||||
result = run_kstartup.apply_client_filters(payload, args, "announcements")
|
||||
self.assertEqual(len(result["data"]), 1)
|
||||
self.assertEqual(result["data"][0]["biz_pbanc_nm"], "예비창업자 대상")
|
||||
|
||||
def test_multiple_filters_are_anded(self):
|
||||
payload = self._payload([
|
||||
{"biz_pbanc_nm": "ok", "supt_regin": "서울특별시", "aply_trgt": "예비창업자"},
|
||||
{"biz_pbanc_nm": "wrong-region", "supt_regin": "경기도", "aply_trgt": "예비창업자"},
|
||||
{"biz_pbanc_nm": "wrong-target", "supt_regin": "서울특별시", "aply_trgt": "일반인"},
|
||||
])
|
||||
args = make_args(
|
||||
"announcements",
|
||||
supt_regin="서울특별시",
|
||||
aply_trgt="예비창업자",
|
||||
)
|
||||
result = run_kstartup.apply_client_filters(payload, args, "announcements")
|
||||
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["ok"])
|
||||
|
||||
def test_comma_separated_request_requires_all_tokens(self):
|
||||
payload = self._payload([
|
||||
{"biz_pbanc_nm": "match-all", "biz_enyy": "예비창업자,1년미만,2년미만"},
|
||||
{"biz_pbanc_nm": "missing-one", "biz_enyy": "예비창업자"},
|
||||
])
|
||||
args = make_args("announcements", biz_enyy="예비창업자,1년미만")
|
||||
result = run_kstartup.apply_client_filters(payload, args, "announcements")
|
||||
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["match-all"])
|
||||
|
||||
def test_no_client_filter_args_is_passthrough(self):
|
||||
payload = self._payload([{"biz_pbanc_nm": "x", "supt_regin": "전국"}])
|
||||
args = make_args("announcements")
|
||||
result = run_kstartup.apply_client_filters(payload, args, "announcements")
|
||||
self.assertEqual(result["currentCount"], 1)
|
||||
self.assertNotIn("client_filter", result)
|
||||
|
||||
def test_non_announcements_operations_are_passthrough(self):
|
||||
payload = self._payload([{"titl_nm": "공모전 공지"}])
|
||||
args = make_args("contents")
|
||||
result = run_kstartup.apply_client_filters(payload, args, "contents")
|
||||
self.assertEqual(result["currentCount"], 1)
|
||||
self.assertNotIn("client_filter", result)
|
||||
|
||||
def test_empty_filter_value_is_treated_as_unset(self):
|
||||
payload = self._payload([{"supt_regin": "경기도"}])
|
||||
args = make_args("announcements", supt_regin=" ")
|
||||
result = run_kstartup.apply_client_filters(payload, args, "announcements")
|
||||
self.assertNotIn("client_filter", result)
|
||||
|
||||
def test_missing_field_in_row_is_not_matched(self):
|
||||
payload = self._payload([
|
||||
{"biz_pbanc_nm": "has-field", "supt_regin": "서울특별시"},
|
||||
{"biz_pbanc_nm": "no-field"},
|
||||
])
|
||||
args = make_args("announcements", supt_regin="서울특별시")
|
||||
result = run_kstartup.apply_client_filters(payload, args, "announcements")
|
||||
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["has-field"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: ktx-booking
|
||||
description: Search, reserve, inspect, and cancel KTX or Korail tickets in Korea with the korail2 + pycryptodome Python packages. Use when the user asks for KTX seats, Korail bookings, train changes, or reservation status.
|
||||
description: Search, reserve, inspect, and cancel KTX or Korail tickets in Korea with the korail2 + pycryptodome Python packages. Use when the user asks for KTX seats, Korail bookings, train changes, reservation status, remaining seat numbers, car-by-car seats, or power-outlet/good-seat tips.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: travel
|
||||
|
|
@ -12,7 +12,7 @@ metadata:
|
|||
|
||||
## What this skill does
|
||||
|
||||
`korail2` 위에 `scripts/ktx_booking.py` helper 를 얹어 KTX/Korail 조회, 예약, 예약 확인, 취소를 처리한다.
|
||||
`korail2` 위에 `scripts/ktx_booking.py` helper 를 얹어 KTX/Korail 조회, 호차별 좌석번호 확인, 예약, 예약 확인, 취소를 처리한다.
|
||||
|
||||
최근 Korail 앱의 Dynapath anti-bot 체크 때문에 원본 `korail2` 0.4.0 예제만으로는 `MACRO ERROR` 가 날 수 있다. 이 스킬은 helper 가 `x-dynapath-m-token`, `Sid`, 최신 app version(`250601002`)을 붙여 실제 예매 흐름을 복구하는 것을 전제로 한다.
|
||||
|
||||
|
|
@ -22,6 +22,10 @@ metadata:
|
|||
- "코레일 예약 확인해줘"
|
||||
- "KTX 취소해줘"
|
||||
- "오전 9시 이후 KTX 중 제일 빠른 거 잡아줘"
|
||||
- "KTX 남은 좌석 번호 확인해줘"
|
||||
- "이 열차 콘센트 있는 꿀팁 좌석부터 보여줘"
|
||||
- "KTX 5호차 남은 자리만 봐줘"
|
||||
- "예약하기 전에 호차별 좌석 확인해줘"
|
||||
- "N카드로 할인 열차 찾아줘"
|
||||
- "내 N카드 목록 보여줘"
|
||||
- "N카드 할인 적용해서 예약해줘"
|
||||
|
|
@ -59,6 +63,7 @@ metadata:
|
|||
- 희망 시작 시각: `HHMMSS`
|
||||
- 인원 수와 승객 유형
|
||||
- 좌석 선호
|
||||
- 좌석 상세 조건: 객실 등급, 호차 번호, 남은 좌석만 보기, 콘센트 꿀팁 좌석 우선
|
||||
- 조회 결과에서 복사한 `train_id`
|
||||
|
||||
## Workflow
|
||||
|
|
@ -108,7 +113,62 @@ python3 scripts/ktx_booking.py search 남춘천 용산 20260503 150000 --train-t
|
|||
- 일반실/특실 가능 여부
|
||||
- 예약 대기 가능 여부
|
||||
|
||||
### 4. Reserve only after the target train is unambiguous
|
||||
### 4. Inspect detailed seats when the user asks for good seats
|
||||
|
||||
`search` 의 좌석 가능 여부는 열차 단위 플래그다. 사용자가 "남은 좌석 번호", "호차별 좌석", "콘센트", "꿀팁 좌석", "창측/순방향 자리", "예약 전에 자리 확인"처럼 구체적인 좌석을 물으면 예약 전에 `seats` 를 호출한다.
|
||||
|
||||
기본 상세 좌석 조회:
|
||||
|
||||
```bash
|
||||
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id>
|
||||
```
|
||||
|
||||
일반실/특실은 `--room` 으로 나눈다.
|
||||
|
||||
```bash
|
||||
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --room special
|
||||
```
|
||||
|
||||
남은 좌석번호만 보고 싶으면 `--available-only` 를 쓴다.
|
||||
|
||||
```bash
|
||||
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only
|
||||
```
|
||||
|
||||
특정 호차를 지정하지 않으면 `seats` 는 승강장 이동 거리가 짧은 가운데 호차부터 탐색한다. 각 호차 안의 좌석은 콘센트 힌트가 있는 좌석(`direct`, `adjacent`)을 먼저, 같은 조건에서는 순방향 좌석을 먼저 보여준다.
|
||||
|
||||
특정 호차만 확인하려면 `--car-no` 를 쓴다.
|
||||
|
||||
```bash
|
||||
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --car-no 5 --available-only
|
||||
```
|
||||
|
||||
콘센트 꿀팁 자리부터 확인하려면 `--power-only` 를 붙인다. 응답의 `power_outlet` 은 `direct`, `adjacent`, `none` 중 하나다.
|
||||
|
||||
```bash
|
||||
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only --power-only
|
||||
```
|
||||
|
||||
`seats` 도 `search` 와 같은 `--train-type` 을 넘겨야 한다. ITX-청춘 등 KTX 외 열차를 조회했다면 상세 좌석 조회에도 같은 값을 사용한다.
|
||||
|
||||
```bash
|
||||
python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \
|
||||
--train-id <train_id> \
|
||||
--train-type itx-cheongchun \
|
||||
--available-only
|
||||
```
|
||||
|
||||
상세 좌석 응답을 보여줄 때는 사용자 의도에 맞춰 아래를 우선 요약한다.
|
||||
|
||||
- 호차별 `remaining_seats`, `available_seat_count`
|
||||
- 남은 좌석 번호 (`available_seats`)
|
||||
- 좌석별 `direction`, `position`, `seat_type`
|
||||
- 콘센트 힌트 (`power_outlet`)
|
||||
- 문 근처 여부 (`near_door`)
|
||||
|
||||
이 기능은 좌석을 선택/선점하지 않는다. 실제 예약은 다음 단계의 `reserve` 로만 진행한다.
|
||||
|
||||
### 5. Reserve only after the target train is unambiguous
|
||||
|
||||
조회 결과의 `train_id` 를 고른 뒤에만 예약한다. 이 값은 helper 가 열차 번호/운행일/시각/역 코드를 묶어 만든 stable selector 이므로, 재조회 시 같은 열차가 아직 있으면 그대로 잡고 없으면 실패한다.
|
||||
|
||||
|
|
@ -125,7 +185,7 @@ python3 scripts/ktx_booking.py reserve 남춘천 용산 20260503 150000 --train-
|
|||
응답에는 예약번호, 운임, 구입기한이 포함된다. **결제는 자동화하지 않는다.**
|
||||
좌석이 없을 때는 조회 단계에서 `--include-waiting-list` 를 켜고 예약 단계에서 `--try-waiting` 으로 예약 대기까지 시도할 수 있다.
|
||||
|
||||
### 4-1. N-card discounted reservation
|
||||
### 5-1. N-card discounted reservation
|
||||
|
||||
N카드 할인을 적용하려면 먼저 보유 N카드 목록을 조회해 카드 번호를 확인한다.
|
||||
|
||||
|
|
@ -151,7 +211,7 @@ python3 scripts/ktx_booking.py reserve 대전 서울 20260512 100000 \
|
|||
|
||||
N카드 기능은 `korail2-ncard` 패키지가 필요하다. 없으면 해당 커맨드 실행 시 설치 안내가 출력된다.
|
||||
|
||||
### 5. Inspect or cancel
|
||||
### 6. Inspect or cancel
|
||||
|
||||
취소는 대상 예약을 다시 조회해 식별한 뒤에만 진행한다.
|
||||
|
||||
|
|
@ -166,6 +226,7 @@ python3 scripts/ktx_booking.py cancel <reservation_id>
|
|||
## Done when
|
||||
|
||||
- 조회면 열차 후보가 정리되어 있다
|
||||
- 좌석 상세 확인이면 호차별 남은 좌석번호와 필요한 꿀팁 조건이 정리되어 있다
|
||||
- 예약이면 예약 결과와 제한 시간이 확인되어 있다
|
||||
- 취소면 어떤 예약을 취소했는지 남아 있다
|
||||
|
||||
|
|
|
|||
12
legacy/README.md
Normal file
12
legacy/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Legacy Unsupported Code
|
||||
|
||||
This directory preserves unsupported skills and helper code that are not part of the default k-skill install, plugin manifest, Manus bundles, npm workspaces, proxy route surface, or README feature list.
|
||||
|
||||
Archived items:
|
||||
|
||||
- `unsupported-skills/blue-ribbon-nearby/` - Blue Ribbon nearby skill. The upstream blocks automation/premium access in ways this repository cannot currently support.
|
||||
- `unsupported-skills/naver-map-route/` - Naver Map route skill. NCP Maps operational prerequisites are not currently available for the hosted proxy.
|
||||
- `unsupported-packages/blue-ribbon-nearby/` - Former npm workspace package retained for future revival.
|
||||
- `unsupported-proxy/bluer.js` and `unsupported-proxy/naver-map.js` - Former proxy helper modules retained for future revival.
|
||||
|
||||
To revive one of these surfaces, move the code back into the normal repo layout, restore docs/tests/proxy routes or workspace metadata, and rerun `npm run ci` plus live/manual QA.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue