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:
Jeffrey (Dongkyu) Kim 2026-05-13 02:13:39 +09:00
commit fe8cb7db6e
5 changed files with 118 additions and 22 deletions

View file

@ -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)"
```

View file

@ -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 }

View file

@ -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
}

View file

@ -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 &quot; Clinic &amp; 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 &quot; Clinic &amp; Care")
})
test("parseNextData falls back to entity-decoded legacy payloads", () => {
const html = `<script id="__NEXT_DATA__" type="application/json">{&quot;props&quot;:{&quot;pageProps&quot;:{&quot;keyword&quot;:&quot;강남&quot;}}}</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"

View file

@ -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"));