mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Harden Gangnam Unni clinic lookup for review follow-up
Address PR review blockers by aligning install docs, preserving raw Next.js JSON parsing semantics, bounding upstream fetches, and reducing sensitive query leakage in errors.\n\nConstraint: Issue #220 follow-up required TDD, full CI, live CLI smoke, deslop pass, push to feature/#220, and one signed PR comment.\nRejected: Pre-decoding the entire __NEXT_DATA__ script body before JSON.parse | corrupts valid JSON strings containing literal entity-looking text.\nConfidence: high\nScope-risk: narrow\nDirective: Keep entity-decoded parsing as a tested compatibility fallback only; do not make it the primary parse path.\nTested: npm test --workspace gangnamunni-clinic-search; node --test scripts/skill-docs.test.js; node packages/gangnamunni-clinic-search/src/cli.js "강남 성형외과" --limit 1; npm run ci twice, including post-deslop.\nNot-tested: Browser-rendered Gangnam Unni UI beyond the public Next.js payload smoke.
This commit is contained in:
parent
da0632c73d
commit
fe8cb7db6e
5 changed files with 118 additions and 22 deletions
|
|
@ -80,6 +80,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill geeknews-search \
|
||||
--skill daiso-product-search \
|
||||
--skill market-kurly-search \
|
||||
--skill gangnamunni-clinic-search \
|
||||
--skill olive-young-search \
|
||||
--skill hola-poke-yeoksam \
|
||||
--skill blue-ribbon-nearby \
|
||||
|
|
@ -282,7 +283,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 donation-place-search
|
||||
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search gongsijiga-search donation-place-search gangnamunni-clinic-search
|
||||
export NODE_PATH="$(npm root -g)"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
#!/usr/bin/env node
|
||||
const { searchClinics } = require("./index")
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const result = await searchClinics(args)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
async function main(options = parseArgs(process.argv.slice(2)), io = console) {
|
||||
const result = await searchClinics(options)
|
||||
io.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
|
|
@ -13,6 +12,7 @@ function parseArgs(argv) {
|
|||
const arg = argv[i]
|
||||
if (arg === "--query" || arg === "-q") options.query = argv[++i] || ""
|
||||
else if (arg === "--limit") options.limit = Number(argv[++i])
|
||||
else if (arg === "--debug") options.debug = true
|
||||
else if (arg === "--help" || arg === "-h") {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
|
|
@ -24,16 +24,22 @@ function parseArgs(argv) {
|
|||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: gangnamunni-clinic-search [query] [options]\n\nOptions:\n -q, --query <text> Search keyword, e.g. "강남 성형외과"\n --limit <number> Maximum clinic results (default: 5)\n`)
|
||||
console.log(`Usage: gangnamunni-clinic-search [query] [options]\n\nOptions:\n -q, --query <text> Search keyword, e.g. "강남 성형외과"\n --limit <number> Maximum clinic results (default: 5)\n --debug Print stack traces for troubleshooting\n`)
|
||||
}
|
||||
|
||||
function run() {
|
||||
return main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : String(error))
|
||||
function formatError(error, options = {}) {
|
||||
if (options.debug && error && error.stack) return error.stack
|
||||
return error && error.message ? error.message : String(error)
|
||||
}
|
||||
|
||||
function run(argv = process.argv.slice(2), io = console) {
|
||||
const options = parseArgs(argv)
|
||||
return main(options, io).catch((error) => {
|
||||
io.error(formatError(error, options))
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
if (require.main === module) run()
|
||||
|
||||
module.exports = { parseArgs, printHelp, main }
|
||||
module.exports = { parseArgs, printHelp, formatError, run, main }
|
||||
|
|
|
|||
|
|
@ -8,22 +8,26 @@ function buildSearchUrl(query) {
|
|||
}
|
||||
|
||||
async function searchClinics(options = {}) {
|
||||
const { query, limit = 5, fetcher = global.fetch } = options
|
||||
const { query, limit = 5, fetcher = global.fetch, signal, timeoutMs = 10000 } = options
|
||||
const normalizedQuery = cleanText(query)
|
||||
if (!normalizedQuery) throw new Error("query is required for Gangnam Unni clinic search")
|
||||
if (!fetcher) throw new Error("fetch is required")
|
||||
|
||||
const url = buildSearchUrl(normalizedQuery)
|
||||
const response = await fetcher(url, {
|
||||
const requestOptions = {
|
||||
headers: {
|
||||
"user-agent": "Mozilla/5.0 (compatible; k-skill/gangnamunni-clinic-search)",
|
||||
accept: "text/html,application/xhtml+xml"
|
||||
}
|
||||
})
|
||||
}
|
||||
const requestSignal = signal || createTimeoutSignal(timeoutMs)
|
||||
if (requestSignal) requestOptions.signal = requestSignal
|
||||
|
||||
const response = await fetcher(url, requestOptions)
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const status = response ? `${response.status} ${response.statusText || ""}`.trim() : "no response"
|
||||
throw new Error(`request failed for ${url}: ${status}`)
|
||||
throw new Error(`request failed for ${redactSearchUrl(url)}: ${status}`)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
|
|
@ -64,10 +68,33 @@ function parseNextData(html) {
|
|||
classifyBlockedBody(source)
|
||||
const match = source.match(/<script\b[^>]*id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i)
|
||||
if (!match) throw new Error("Gangnam Unni next data payload not found")
|
||||
const payload = match[1].trim()
|
||||
try {
|
||||
return JSON.parse(decodeHtmlEntities(match[1].trim()))
|
||||
} catch (error) {
|
||||
throw new Error(`Gangnam Unni next data payload could not be parsed: ${error.message}`)
|
||||
return JSON.parse(payload)
|
||||
} catch (rawError) {
|
||||
try {
|
||||
return JSON.parse(decodeHtmlEntities(payload))
|
||||
} catch (decodedError) {
|
||||
const message = `Gangnam Unni next data payload could not be parsed: ${rawError.message}`
|
||||
throw new Error(`${message}; decoded fallback failed: ${decodedError.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTimeoutSignal(timeoutMs) {
|
||||
const numericTimeoutMs = Number(timeoutMs)
|
||||
if (!Number.isFinite(numericTimeoutMs) || numericTimeoutMs <= 0) return null
|
||||
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout !== "function") return null
|
||||
return AbortSignal.timeout(numericTimeoutMs)
|
||||
}
|
||||
|
||||
function redactSearchUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value))
|
||||
const serialized = url.toString()
|
||||
return serialized.replace(/([?&]q=)[^&]*/i, "$1<redacted>")
|
||||
} catch {
|
||||
return String(value || "").replace(/([?&]q=)[^&]*/i, "$1<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,5 +182,7 @@ module.exports = {
|
|||
parseSearchHtml,
|
||||
parseNextData,
|
||||
normalizeHospital,
|
||||
createTimeoutSignal,
|
||||
redactSearchUrl,
|
||||
cleanText
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,29 @@ test("parseNextData reads escaped Next.js JSON payloads", () => {
|
|||
assert.equal(data.props.pageProps.hospitals.length, 2)
|
||||
})
|
||||
|
||||
test("parseNextData preserves literal entity-looking text inside valid JSON strings", () => {
|
||||
const data = {
|
||||
props: {
|
||||
pageProps: {
|
||||
hospitals: [{ id: 1, name: "A " Clinic & Care" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
const html = `<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(data)}</script>`
|
||||
|
||||
const parsed = parseNextData(html)
|
||||
|
||||
assert.equal(parsed.props.pageProps.hospitals[0].name, "A " Clinic & Care")
|
||||
})
|
||||
|
||||
test("parseNextData falls back to entity-decoded legacy payloads", () => {
|
||||
const html = `<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"keyword":"강남"}}}</script>`
|
||||
|
||||
const parsed = parseNextData(html)
|
||||
|
||||
assert.equal(parsed.props.pageProps.keyword, "강남")
|
||||
})
|
||||
|
||||
test("parseNextData classifies login, captcha, blocked, and empty-shell failures", () => {
|
||||
assert.throws(() => parseNextData("로그인이 필요합니다"), /login required/i)
|
||||
assert.throws(() => parseNextData("captcha challenge"), /captcha/i)
|
||||
|
|
@ -101,10 +124,10 @@ test("parseSearchHtml returns query metadata, limited clinic items, source, and
|
|||
assert.match(result.warnings.join("\n"), /returned 1 of 2 parsed hospitals/)
|
||||
})
|
||||
|
||||
test("searchClinics fetches the search page and parses clinics", async () => {
|
||||
test("searchClinics fetches the search page with a default timeout and parses clinics", async () => {
|
||||
const seen = []
|
||||
const fetcher = async (url, options) => {
|
||||
seen.push({ url: String(url), headers: options.headers })
|
||||
seen.push({ url: String(url), headers: options.headers, signal: options.signal })
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
|
|
@ -117,9 +140,23 @@ test("searchClinics fetches the search page and parses clinics", async () => {
|
|||
|
||||
assert.equal(seen[0].url, buildSearchUrl("강남 성형외과"))
|
||||
assert.match(seen[0].headers["user-agent"], /k-skill\/gangnamunni-clinic-search/)
|
||||
assert.ok(seen[0].signal, "expected a default abort signal")
|
||||
assert.equal(result.items.length, 2)
|
||||
})
|
||||
|
||||
test("searchClinics lets callers inject an abort signal", async () => {
|
||||
const controller = new AbortController()
|
||||
let seenSignal
|
||||
const fetcher = async (_url, options) => {
|
||||
seenSignal = options.signal
|
||||
return { ok: true, status: 200, statusText: "OK", text: async () => sampleHtml }
|
||||
}
|
||||
|
||||
await searchClinics({ query: "강남", fetcher, signal: controller.signal })
|
||||
|
||||
assert.equal(seenSignal, controller.signal)
|
||||
})
|
||||
|
||||
test("searchClinics rejects missing query and failed upstream responses", async () => {
|
||||
await assert.rejects(() => searchClinics({ query: "" }), /query is required/)
|
||||
await assert.rejects(
|
||||
|
|
@ -127,18 +164,27 @@ test("searchClinics rejects missing query and failed upstream responses", async
|
|||
query: "강남",
|
||||
fetcher: async () => ({ ok: false, status: 503, statusText: "Service Unavailable" })
|
||||
}),
|
||||
/request failed.*503 Service Unavailable/
|
||||
(error) => {
|
||||
assert.match(error.message, /request failed.*503 Service Unavailable/)
|
||||
assert.match(error.message, /q=<redacted>/)
|
||||
assert.doesNotMatch(error.message, /%EA%B0%95%EB%82%A8|강남/)
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test("CLI parses options and supports help", () => {
|
||||
const cli = require("../src/cli")
|
||||
|
||||
assert.deepEqual(cli.parseArgs(["강남 성형외과", "--limit", "3"]), {
|
||||
assert.deepEqual(cli.parseArgs(["강남 성형외과", "--limit", "3", "--debug"]), {
|
||||
query: "강남 성형외과",
|
||||
limit: 3
|
||||
limit: 3,
|
||||
debug: true
|
||||
})
|
||||
|
||||
assert.equal(cli.formatError(new Error("plain failure"), { debug: false }), "plain failure")
|
||||
assert.match(cli.formatError(new Error("debug failure"), { debug: true }), /Error: debug failure/)
|
||||
|
||||
const help = spawnSync(process.execPath, ["src/cli.js", "--help"], {
|
||||
cwd: __dirname + "/..",
|
||||
encoding: "utf8"
|
||||
|
|
|
|||
|
|
@ -1041,6 +1041,20 @@ test("daiso-product-search docs record the shipped feature and official sources"
|
|||
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/api\/pd\/pdh\/selStrPkupStck/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the gangnamunni-clinic-search skill across install surfaces", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "gangnamunni-clinic-search.md");
|
||||
const skillPath = path.join(repoRoot, "gangnamunni-clinic-search", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/gangnamunni-clinic-search.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected gangnamunni-clinic-search/SKILL.md to exist");
|
||||
assert.match(readme, /\| 강남언니 병원 조회 \| `gangnamunni-clinic-search` \|/);
|
||||
assert.match(readme, /\[강남언니 병원 조회 가이드\]\(docs\/features\/gangnamunni-clinic-search\.md\)/);
|
||||
assert.match(install, /--skill gangnamunni-clinic-search/);
|
||||
assert.match(install, /npm install -g .*gangnamunni-clinic-search/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the market-kurly-search skill across the documented surfaces", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue