mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
* 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>
169 lines
6.3 KiB
JavaScript
169 lines
6.3 KiB
JavaScript
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/);
|
|
});
|