Feature/#212 (#214)

* Help donors choose verified recipients by place and cause

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

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

* Keep donation recommendations on requested intent

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

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

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

Confidence: high

Scope-risk: narrow

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

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

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

* Harden donation skill follow-up guarantees

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

* Clarify donation verification links

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

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

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

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

Confidence: high

Scope-risk: narrow

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

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

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

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

---------

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-08 15:41:21 +09:00 committed by GitHub
commit 4e5abf0861
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 890 additions and 18 deletions

View file

@ -0,0 +1,5 @@
---
"donation-place-search": minor
---
Add a donation place recommendation skill and package for Korean location/category-based donation recipient lookup.

View file

@ -40,6 +40,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 개별공시지가 조회 | `gongsijiga-search` | realtyprice.kr 공개 API에서 지번 단위 개별공시지가(원/㎡) 다년도 추이·전년 대비 변동률 조회 | 불필요 | [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md) |
| LH 청약 공고문 조회 | `lh-notice-search` | 한국토지주택공사(LH) 임대/분양/주거복지(신혼희망타운)/토지/상가 공고를 지역·상태·공고유형·키워드로 조회하고 마감 여부를 KST 기준으로 표시 | 불필요 | [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md) |
| 법원 경매 부동산 매각공고 조회 | `court-auction-notice-search` | 대법원경매정보(courtauction.go.kr) 부동산 매각공고를 매각기일·법원·기일/기간 입찰 조건으로 검색해 사건번호·용도·주소·감정평가액·최저매각가격을 펼치고, 사건번호로 직접 사건정보·물건내역·매각기일이력을 조회 | 불필요 | [법원 경매 부동산 매각공고 조회 가이드](docs/features/court-auction-notice-search.md) |
| 기부처 조회 | `donation-place-search` | 지역·관심 분야 기준 기부처 후보와 공식 페이지/1365 확인용 검색 링크 안내 (기부·결제 자동화 제외) | 불필요 | [기부처 조회 가이드](docs/features/donation-place-search.md) |
| 장학금 검색 및 조회 | `korean-scholarship-search` | 한국장학재단·전국 대학교·재단·기업 장학 공고를 검색해 금액·자격·지원구간·링크를 정리하고 KST 기준 현재 날짜 마감 상태와 조건별 필터링까지 제공 | 불필요 | [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md) |
| 생활쓰레기 배출정보 조회 | `household-waste-info` | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
| 학교 급식 식단 조회 | `k-schoollunch-menu` | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
@ -136,6 +137,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)
- [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md)
- [도서관 도서 조회 가이드](docs/features/library-book-search.md)
- [기부처 조회 가이드](docs/features/donation-place-search.md)
- [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md)
- [식품 안전 체크 가이드](docs/features/mfds-food-safety.md)
- [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md)

View file

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

View file

@ -92,6 +92,7 @@ npx --yes skills add <owner/repo> \
--skill k-schoollunch-menu \
--skill korean-character-count \
--skill court-auction-notice-search \
--skill donation-place-search \
--skill k-skill-cleaner
```
@ -277,7 +278,7 @@ npm run ci
### Node 패키지
```bash
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search gongsijiga-search
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search gongsijiga-search donation-place-search
export NODE_PATH="$(npm root -g)"
```

View file

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

11
package-lock.json generated
View file

@ -642,6 +642,10 @@
"node": ">=8"
}
},
"node_modules/donation-place-search": {
"resolved": "packages/donation-place-search",
"link": true
},
"node_modules/enquirer": {
"version": "2.4.1",
"dev": true,
@ -1754,6 +1758,13 @@
"node": ">=18"
}
},
"packages/donation-place-search": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/gongsijiga-search": {
"version": "0.1.0",
"license": "MIT",

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -0,0 +1,40 @@
# donation-place-search
Recommend Korean donation recipients by location and donation category.
The package combines:
- a public 1365 Give Korea (`www.1365.go.kr`) best-effort search-assist link builder for latest official verification;
- deterministic category/location ranking over a small curated fallback set of well-known donation recipients;
- Korean report formatting with cautions to verify current registration, campaign period, and donation receipt handling before donating.
No proxy and no API key are required. This package does **not** execute donations or submit personal/payment data.
```js
const {
recommendDonationPlaces,
formatDonationRecommendationReport
} = require("donation-place-search");
const result = recommendDonationPlaces({
location: "서울 마포구",
category: "동물",
limit: 3
});
console.log(formatDonationRecommendationReport(result));
```
## Exports
- `recommendDonationPlaces(options)`
- `formatDonationRecommendationReport(result)`
- `build1365DonationSearchUrl(options)`
- `normalizeCategory(input)`
- `parseLocationQuery(location)`
- `CATEGORIES`
- `DONATION_PLACES`
## Notes
Donation campaigns and registration status change frequently. Always treat returned 1365 URLs as best-effort verification assists: open the 1365 official entry/search page and the recipient's official homepage before recommending a final donation decision.

View file

@ -0,0 +1,33 @@
{
"name": "donation-place-search",
"version": "0.1.0",
"description": "Recommend Korean donation recipients by location and category with best-effort 1365 Give Korea search-assist links and curated fallback data",
"license": "MIT",
"main": "src/index.js",
"files": [
"src",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"korea",
"donation",
"charity",
"1365",
"nanum"
],
"scripts": {
"lint": "node --check src/index.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,413 @@
const OFFICIAL_1365_DONATION_URL = "https://www.1365.go.kr/dntn/main.do";
const CATEGORIES = Object.freeze({
general: {
label: "일반/종합",
keywords: ["일반", "종합", "기부", "나눔", "모금"]
},
children: {
label: "아동·청소년",
keywords: ["아동", "어린이", "청소년", "보육", "결식", "교육"]
},
elderly: {
label: "노인",
keywords: ["노인", "어르신", "독거", "요양"]
},
disability: {
label: "장애",
keywords: ["장애", "장애인", "발달장애", "이동권"]
},
animals: {
label: "동물보호",
keywords: ["동물", "동물보호", "유기동물", "반려동물"]
},
environment: {
label: "환경",
keywords: ["환경", "기후", "생태", "숲", "해양"]
},
disaster: {
label: "재난·구호",
keywords: ["재난", "구호", "긴급", "재해", "복구"]
},
health: {
label: "보건·의료",
keywords: ["의료", "보건", "환자", "치료", "질병"]
},
poverty: {
label: "생계·주거",
keywords: ["생계", "주거", "저소득", "취약계층", "노숙"]
},
international: {
label: "해외구호",
keywords: ["해외", "국제", "난민", "개발협력"]
}
});
const PROVINCE_ALIASES = Object.freeze([
["서울", /서울|서울특별시|서울시/],
["부산", /부산|부산광역시|부산시/],
["대구", /대구|대구광역시|대구시/],
["인천", /인천|인천광역시|인천시/],
["광주", /광주|광주광역시|광주시/],
["대전", /대전|대전광역시|대전시/],
["울산", /울산|울산광역시|울산시/],
["세종", /세종|세종특별자치시|세종시/],
["경기", /경기|경기도/],
["강원", /강원|강원도|강원특별자치도/],
["충북", /충북|충청북도/],
["충남", /충남|충청남도/],
["전북", /전북|전라북도|전북특별자치도/],
["전남", /전남|전라남도/],
["경북", /경북|경상북도/],
["경남", /경남|경상남도/],
["제주", /제주|제주도|제주특별자치도/]
]);
const DONATION_PLACES = Object.freeze([
{
id: "kara",
name: "동물권행동 카라",
categories: ["animals"],
coverage: "local",
locations: ["서울", "마포구"],
description: "동물권 교육, 유기동물 구조·입양, 동물복지 캠페인을 하는 비영리단체입니다.",
homepageUrl: "https://www.ekara.org/",
verification: "공식 홈페이지의 후원/결산 공시와 1365 기부포털 등록 여부를 함께 확인하세요."
},
{
id: "animal-freedom",
name: "동물자유연대",
categories: ["animals"],
coverage: "nationwide",
locations: ["전국", "경기", "남양주"],
description: "반려동물 복지, 구조동물 보호, 동물학대 대응과 정책 캠페인을 진행합니다.",
homepageUrl: "https://www.animals.or.kr/",
verification: "구조·보호 캠페인별 후원 목적과 기부금영수증 안내를 공식 페이지에서 확인하세요."
},
{
id: "korean-cat-protection",
name: "한국고양이보호협회",
categories: ["animals"],
coverage: "nationwide",
locations: ["전국"],
description: "길고양이 보호, 치료지원, 입양·캠페인 활동에 초점을 둔 동물보호 단체입니다.",
homepageUrl: "https://www.catcare.or.kr/",
verification: "치료지원·입양 캠페인의 현재 모금 상태를 공식 공지에서 확인하세요."
},
{
id: "kfem",
name: "환경운동연합",
categories: ["environment"],
coverage: "local",
locations: ["서울", "종로구"],
description: "기후위기, 생태보전, 생활환경 이슈를 다루는 환경 시민단체입니다.",
homepageUrl: "https://kfem.or.kr/",
verification: "지역 조직과 캠페인별 모금 목적을 공식 페이지에서 확인하세요."
},
{
id: "beautiful-store",
name: "아름다운가게",
categories: ["poverty", "environment", "general"],
coverage: "nationwide",
locations: ["전국"],
description: "물품 기부와 재사용 판매 수익으로 국내외 공익활동을 지원합니다.",
homepageUrl: "https://www.beautifulstore.org/",
verification: "방문 전 가까운 매장의 접수 가능 물품과 운영시간을 확인하세요."
},
{
id: "goodwill",
name: "굿윌스토어",
categories: ["disability", "poverty", "general"],
coverage: "nationwide",
locations: ["전국"],
description: "물품 기부를 장애인 일자리와 직업훈련으로 연결하는 기부처입니다.",
homepageUrl: "https://www.goodwillstore.org/",
verification: "가까운 지점의 물품 기증 기준과 방문수거 가능 여부를 확인하세요."
},
{
id: "childfund",
name: "초록우산",
categories: ["children", "poverty", "disaster"],
coverage: "nationwide",
locations: ["전국"],
description: "아동 복지, 결연, 긴급지원, 인재양성 사업을 운영하는 아동복지 전문기관입니다.",
homepageUrl: "https://www.childfund.or.kr/",
verification: "캠페인별 후원금 사용처와 연차보고서를 공식 페이지에서 확인하세요."
},
{
id: "korean-red-cross",
name: "대한적십자사",
categories: ["disaster", "health", "poverty", "international"],
coverage: "nationwide",
locations: ["전국"],
description: "재난구호, 취약계층 지원, 헌혈·보건, 국제구호 사업을 수행합니다.",
homepageUrl: "https://www.redcross.or.kr/",
verification: "긴급모금은 모금 기간과 목적이 자주 바뀌므로 공식 공지에서 최신 상태를 확인하세요."
},
{
id: "community-chest",
name: "사회복지공동모금회 사랑의열매",
categories: ["general", "poverty", "children", "elderly", "disability"],
coverage: "nationwide",
locations: ["전국"],
description: "지역 공동모금과 배분사업을 운영하는 대표 법정 모금기관입니다.",
homepageUrl: "https://chest.or.kr/",
verification: "지역지회·캠페인별 배분 분야와 공시자료를 확인하세요."
},
{
id: "miral",
name: "밀알복지재단",
categories: ["disability", "children", "poverty", "international"],
coverage: "nationwide",
locations: ["전국"],
description: "장애인, 아동, 에너지 취약계층, 해외구호 사업을 운영하는 복지재단입니다.",
homepageUrl: "https://www.miral.org/",
verification: "사업별 지정후원 가능 여부와 공시자료를 공식 페이지에서 확인하세요."
},
{
id: "okfoundation",
name: "노인의료나눔재단",
categories: ["elderly", "health", "poverty"],
coverage: "nationwide",
locations: ["전국"],
description: "취약계층 어르신 의료비와 건강 지원 사업에 초점을 둔 재단입니다.",
homepageUrl: "https://www.ok6595.or.kr/",
verification: "현재 지원사업과 후원금 사용처를 공식 페이지에서 확인하세요."
},
{
id: "babsang",
name: "밥상공동체복지재단 연탄은행",
categories: ["poverty", "elderly"],
coverage: "nationwide",
locations: ["전국", "강원", "원주"],
description: "에너지 취약계층 연탄·난방 지원과 지역 복지사업을 운영합니다.",
homepageUrl: "https://www.babsang.or.kr/",
verification: "계절성 캠페인이 많으므로 현재 모금 주제와 물품/봉사 필요 여부를 확인하세요."
},
{
id: "greenpeace-korea",
name: "그린피스 서울사무소",
categories: ["environment", "international"],
coverage: "nationwide",
locations: ["서울", "전국"],
description: "기후·해양·생물다양성 관련 국제 환경 캠페인을 진행합니다.",
homepageUrl: "https://www.greenpeace.org/korea/",
verification: "캠페인 성격과 기부금 영수증 처리 주체를 공식 페이지에서 확인하세요."
},
{
id: "snuh-children",
name: "서울대학교어린이병원 후원회",
categories: ["children", "health"],
coverage: "local",
locations: ["서울", "종로구"],
description: "중증·희귀질환 아동 치료와 병원 내 환아 지원에 초점을 둔 후원처입니다.",
homepageUrl: "https://www.snuh.org/child/",
verification: "병원 후원 경로와 지정기부 가능 범위를 공식 병원 페이지에서 확인하세요."
}
]);
function normalizeCategoryToken(value) {
return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
}
function normalizeCategory(input) {
if (Array.isArray(input)) {
return input.map(normalizeCategory).filter((value, index, values) => values.indexOf(value) === index);
}
const query = normalizeCategoryToken(input);
if (!query) {
return "general";
}
const categoryEntries = Object.entries(CATEGORIES);
for (const [key, category] of categoryEntries) {
if (query === key.toLowerCase()) {
return key;
}
if (key !== "general" && category.keywords.some((keyword) => query.includes(normalizeCategoryToken(keyword)))) {
return key;
}
}
for (const [key, category] of categoryEntries) {
if (category.keywords.some((keyword) => query.includes(normalizeCategoryToken(keyword)))) {
return key;
}
}
return "general";
}
function parseLocationQuery(location) {
const raw = String(location || "").trim();
let province = null;
for (const [normalized, pattern] of PROVINCE_ALIASES) {
if (pattern.test(raw)) {
province = normalized;
break;
}
}
const districtMatches = [...raw.matchAll(/([가-힣A-Za-z0-9]+(?:구|군|시))/g)]
.map((match) => match[1])
.filter((value) => !/^(서울|부산|대구|인천|광주|대전|울산|세종)시?$/.test(value));
const district = districtMatches[0] || null;
return { raw, province, district };
}
function normalizeCategoriesForSearch(input) {
const normalized = normalizeCategory(input);
if (!Array.isArray(normalized)) {
return [normalized];
}
return normalized.length ? normalized : ["general"];
}
function build1365DonationSearchUrl(options = {}) {
if (Object.prototype.hasOwnProperty.call(options, "baseUrl")) {
throw new Error("baseUrl is not supported for 1365 donation search-assist links.");
}
const url = new URL(OFFICIAL_1365_DONATION_URL);
const [category] = normalizeCategoriesForSearch(options.category);
const parts = [options.keyword, options.location].map((value) => String(value || "").trim()).filter(Boolean);
url.searchParams.set("query", parts.join(" ") || CATEGORIES[category].label);
url.searchParams.set("category", category);
return url.toString();
}
function buildCandidateSearchKeyword(place, keyword) {
const baseKeyword = String(keyword || "").trim();
if (!baseKeyword || baseKeyword.includes(place.name)) {
return place.name;
}
return `${place.name} ${baseKeyword}`;
}
function selectCandidateSearchCategory(place, categories) {
return categories.find((category) => place.categories.includes(category)) || categories[0];
}
function scoreDonationPlace(place, categories, location) {
const categoryMatch = categories.some((category) => place.categories.includes(category));
const provinceMatch = !!location.province && place.locations.includes(location.province);
const districtMatch = !!location.district && place.locations.includes(location.district);
const nationwide = place.coverage === "nationwide" || place.locations.includes("전국");
const localMatch = districtMatch || provinceMatch;
let score = 0;
if (categoryMatch) score += 60;
if (districtMatch) score += 35;
else if (provinceMatch) score += 25;
else if (nationwide) score += 10;
if (place.coverage === "local" && localMatch) score += 5;
return {
score,
category: categoryMatch,
local: localMatch,
nationwide
};
}
function recommendDonationPlaces(options = {}) {
const limit = normalizeLimit(options.limit);
const location = parseLocationQuery(options.location || "");
const categories = normalizeCategoriesForSearch(options.category);
const keyword = String(options.keyword || categories.map((category) => CATEGORIES[category].label).join(" ")).trim();
const ranked = DONATION_PLACES
.map((place) => ({ place, match: scoreDonationPlace(place, categories, location) }))
.filter(({ match }) => match.category)
.sort((a, b) => b.match.score - a.match.score || a.place.name.localeCompare(b.place.name, "ko"));
const items = ranked.slice(0, limit).map(({ place, match }) => ({
...place,
match,
officialSearchUrl: build1365DonationSearchUrl({
location: location.raw,
category: selectCandidateSearchCategory(place, categories),
keyword: buildCandidateSearchKeyword(place, keyword)
})
}));
const notes = [
"추천 목록은 기부 실행 전 공식 페이지와 1365 기부포털에서 등록·모금기간·기부금영수증 가능 여부를 재확인해야 합니다."
];
if (items.length > 0 && !items.some((item) => item.match.local)) {
notes.push("정확한 지역 일치 기부처를 찾지 못해 전국 단위 기부처를 우선 제안했습니다.");
}
if (items.length === 0) {
notes.push("조건에 맞는 기본 후보가 없어 1365 기부포털 확인 보조 링크로 최신 등록 기부처를 직접 확인해야 합니다.");
}
return {
location,
category: Array.isArray(options.category) ? categories : categories[0],
items,
officialSearchUrl: build1365DonationSearchUrl({ location: location.raw, category: categories[0], keyword }),
meta: {
totalCandidates: ranked.length,
limit,
source: "curated-fallback-plus-1365-search-assist",
notes
}
};
}
function normalizeLimit(value) {
if (value === undefined || value === null || value === "") {
return 5;
}
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new Error("limit must be an integer between 1 and 20.");
}
if (parsed < 1 || parsed > 20) {
throw new Error("limit must be between 1 and 20.");
}
return parsed;
}
function formatDonationRecommendationReport(result) {
const categoryLabels = (Array.isArray(result.category) ? result.category : [result.category])
.map((category) => CATEGORIES[category]?.label || category)
.join(", ");
const where = result.location.raw || "지역 미지정";
const lines = [`## 기부처 추천 (${where} / ${categoryLabels})`, ""];
if (result.items.length === 0) {
lines.push("조건에 맞는 기본 후보를 찾지 못했습니다.");
} else {
result.items.forEach((item, index) => {
const locality = item.match.local ? "지역 일치" : item.coverage === "nationwide" ? "전국" : "참고";
lines.push(`${index + 1}. ${item.name}${item.description}`);
lines.push(` - 분야: ${item.categories.map((category) => CATEGORIES[category]?.label || category).join(", ")} / 범위: ${locality}`);
lines.push(` - 공식 페이지: ${item.homepageUrl}`);
lines.push(` - 1365 확인 보조 링크: ${item.officialSearchUrl}`);
});
}
lines.push("");
lines.push("확인 메모:");
for (const note of result.meta.notes) {
lines.push(`- ${note}`);
}
lines.push(`- 1365 링크는 검색 보조용입니다. 최신 모금 상태는 1365 공식 페이지에서 직접 다시 확인하세요: ${result.officialSearchUrl}`);
return lines.join("\n");
}
module.exports = {
CATEGORIES,
DONATION_PLACES,
OFFICIAL_1365_DONATION_URL,
build1365DonationSearchUrl,
formatDonationRecommendationReport,
normalizeCategory,
parseLocationQuery,
recommendDonationPlaces
};

View file

@ -0,0 +1,169 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
CATEGORIES,
build1365DonationSearchUrl,
normalizeCategory,
parseLocationQuery,
recommendDonationPlaces,
formatDonationRecommendationReport
} = require("../src/index");
test("normalizeCategory maps Korean aliases to canonical donation categories", () => {
assert.equal(normalizeCategory("아동"), "children");
assert.equal(normalizeCategory("동물보호"), "animals");
assert.equal(normalizeCategory("재난 구호"), "disaster");
assert.equal(normalizeCategory("환경"), "environment");
assert.equal(normalizeCategory("모르는분야"), "general");
assert.ok(CATEGORIES.children.keywords.includes("아동"));
});
test("normalizeCategory prioritizes specific categories in natural donation phrases", () => {
assert.equal(normalizeCategory("동물 기부"), "animals");
assert.equal(normalizeCategory("아동 기부"), "children");
assert.equal(normalizeCategory("환경 모금"), "environment");
assert.equal(normalizeCategory("장애인 나눔"), "disability");
});
test("parseLocationQuery extracts Korean province and district hints conservatively", () => {
assert.deepEqual(parseLocationQuery("서울시 마포구 공덕동"), {
raw: "서울시 마포구 공덕동",
province: "서울",
district: "마포구"
});
assert.deepEqual(parseLocationQuery("부산 해운대구"), {
raw: "부산 해운대구",
province: "부산",
district: "해운대구"
});
assert.deepEqual(parseLocationQuery("온라인"), {
raw: "온라인",
province: null,
district: null
});
});
test("build1365DonationSearchUrl creates a public 1365 search-assist link without proxy auth", () => {
const url = new URL(build1365DonationSearchUrl({
location: "서울 마포구",
category: "animals",
keyword: "유기동물"
}));
assert.equal(url.origin, "https://www.1365.go.kr");
assert.equal(url.pathname, "/dntn/main.do");
assert.equal(url.searchParams.get("query"), "유기동물 서울 마포구");
assert.equal(url.searchParams.get("category"), "animals");
});
test("build1365DonationSearchUrl treats an empty category list like the default category", () => {
const url = new URL(build1365DonationSearchUrl({
location: "서울 마포구",
category: [],
keyword: "기부처"
}));
assert.equal(url.searchParams.get("category"), "general");
assert.equal(url.searchParams.get("query"), "기부처 서울 마포구");
});
test("recommendDonationPlaces ranks local category matches before broad national fallback", () => {
const result = recommendDonationPlaces({
location: "서울 마포구",
category: "동물",
limit: 3
});
assert.equal(result.category, "animals");
assert.equal(result.location.province, "서울");
assert.equal(result.items.length, 3);
assert.equal(result.items[0].name, "동물권행동 카라");
assert.equal(result.items[0].match.local, true);
assert.equal(result.items[0].match.category, true);
assert.ok(result.items[0].officialSearchUrl.includes("1365.go.kr"));
assert.ok(result.items.some((item) => item.coverage === "nationwide"));
assert.ok(result.items.every((item) => item.categories.includes("animals")));
});
test("recommendDonationPlaces emits candidate-specific 1365 search-assist links", () => {
const result = recommendDonationPlaces({
location: "서울 마포구",
category: "동물",
limit: 3
});
const itemUrls = result.items.map((item) => new URL(item.officialSearchUrl));
const itemQueries = itemUrls.map((url) => url.searchParams.get("query"));
assert.equal(new Set(result.items.map((item) => item.officialSearchUrl)).size, result.items.length);
result.items.forEach((item, index) => {
assert.equal(itemUrls[index].origin, "https://www.1365.go.kr");
assert.equal(itemUrls[index].searchParams.get("category"), "animals");
assert.match(itemQueries[index], new RegExp(item.name));
assert.match(itemQueries[index], /서울 마포구/);
});
});
test("recommendDonationPlaces treats an empty category list like the optional default", () => {
const result = recommendDonationPlaces({
location: "서울 마포구",
category: [],
limit: 2
});
assert.deepEqual(result.category, ["general"]);
assert.equal(result.items.length, 2);
assert.ok(result.items.every((item) => item.match.category));
});
test("recommendDonationPlaces supports multiple category filters and explains no exact local hit", () => {
const result = recommendDonationPlaces({
location: "제주 서귀포시",
category: ["장애", "노인"],
limit: 4
});
assert.deepEqual(result.category, ["disability", "elderly"]);
assert.equal(result.items.length, 4);
assert.ok(result.items.every((item) => item.match.category));
assert.ok(result.meta.notes.some((note) => note.includes("정확한 지역 일치")));
});
test("recommendDonationPlaces uses each matched candidate category in multi-category item links", () => {
const result = recommendDonationPlaces({
location: "제주 서귀포시",
category: ["장애", "노인"],
limit: 4
});
assert.deepEqual(result.category, ["disability", "elderly"]);
result.items.forEach((item) => {
const url = new URL(item.officialSearchUrl);
const urlCategory = url.searchParams.get("category");
assert.ok(item.categories.includes(urlCategory), `${item.name} URL category ${urlCategory} must match candidate categories`);
});
});
test("build1365DonationSearchUrl does not allow overriding the official 1365 endpoint", () => {
assert.throws(
() => build1365DonationSearchUrl({ baseUrl: "https://example.com/dntn/main.do" }),
/baseUrl is not supported/
);
});
test("recommendDonationPlaces rejects malformed non-integer limits", () => {
assert.throws(() => recommendDonationPlaces({ limit: "2abc" }), /limit must be an integer/);
assert.throws(() => recommendDonationPlaces({ limit: "1.9" }), /limit must be an integer/);
});
test("formatDonationRecommendationReport creates a concise Korean report with verification cautions", () => {
const result = recommendDonationPlaces({ location: "서울", category: "아동", limit: 2 });
const report = formatDonationRecommendationReport(result);
assert.match(report, /기부처 추천/);
assert.match(report, /서울/);
assert.match(report, /아동/);
assert.match(report, /공식 페이지/);
assert.match(report, /1365/);
});

View file

@ -671,8 +671,6 @@ test("ktx-booking helper python regression tests pass", () => {
);
});
test("repository docs advertise the geeknews-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -1295,17 +1293,48 @@ test("coupang-product-search docs drop non-allowlisted coupang-mcp-fallback and
test("root pack:dry-run script covers all publishable workspaces", () => {
const packageJson = readJson("package.json");
const packScript = packageJson.scripts["pack:dry-run"];
const publishableWorkspaces = fs
.readdirSync(path.join(repoRoot, "packages"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join("packages", entry.name, "package.json"))
.filter((packagePath) => fs.existsSync(path.join(repoRoot, packagePath)))
.map((packagePath) => readJson(packagePath))
.filter((workspacePackage) => workspacePackage.private !== true)
.map((workspacePackage) => workspacePackage.name);
assert.match(packageJson.scripts["pack:dry-run"], /workspace k-lotto/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace daiso-product-search/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace market-kurly-search/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace public-restroom-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace court-auction-notice-search/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kbl-results/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kleague-results/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace lck-analytics/);
assert.ok(publishableWorkspaces.includes("donation-place-search"));
for (const workspaceName of publishableWorkspaces) {
assert.match(packScript, new RegExp(`workspace ${escapeRegex(workspaceName)}(?:\\s|$)`));
}
});
test("README main capability table advertises the donation-place-search skill", () => {
const readme = read("README.md");
const tableSection = findSection(readme, "## 어떤 걸 할 수 있나");
assert.match(tableSection, /기부처 조회/);
assert.match(tableSection, /`donation-place-search`/);
assert.match(tableSection, /docs\/features\/donation-place-search\.md/);
});
test("donation-place-search install docs include the skill and npm helper", () => {
const install = read(path.join("docs", "install.md"));
assert.match(install, /--skill donation-place-search/);
assert.match(install, /npm install -g .*donation-place-search/);
});
test("donation-place-search docs describe 1365 links as best-effort verification assists", () => {
const skill = read(path.join("donation-place-search", "SKILL.md"));
const packageReadme = read(path.join("packages", "donation-place-search", "README.md"));
const featureDoc = read(path.join("docs", "features", "donation-place-search.md"));
const packageJson = readJson(path.join("packages", "donation-place-search", "package.json"));
for (const doc of [skill, packageReadme, featureDoc, packageJson.description]) {
assert.match(doc, /best-effort|보조|assist/i);
assert.doesNotMatch(doc, /candidate-verified|후보별 검증 완료/);
}
});
test("repository docs advertise the kbl-results skill across the documented surfaces", () => {
@ -1475,8 +1504,6 @@ test("blue-ribbon-nearby package README stays aligned with the location-first an
assert.match(packageReadme, /searchNearbyByLocationQuery/);
});
test("repository docs advertise the kakao-bar-nearby skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -2557,7 +2584,6 @@ test("repository docs advertise the han-river-water-level skill and rollout-pend
assert.match(roadmap, /한강 수위 정보 조회 스킬 출시/);
});
test("repository docs advertise the MFDS drug and food safety skills", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -3555,7 +3581,6 @@ test("corporate-registration-consulting skill covers court registry workflow, ta
assert.match(sources, /law\.go\.kr/);
});
test("iros-registry-automation skill documents safe IROS registry certificate automation and upstream credit", () => {
const skillPath = path.join(repoRoot, "iros-registry-automation", "SKILL.md");
const featureDocPath = path.join(repoRoot, "docs", "features", "iros-registry-automation.md");