Fix parking lot lookups: force HTTPS, cache full dataset, normalize provider fields (#156)

Data.go.kr 이 tn_pubr_prkplce_info_api 를 HTTPS 로만 서비스하고 HTTP 요청은 301 로 리다이렉트하기 때문에 Node fetch 가 `response.ok=false` 로 떨어져 기능이 전체 실패하고 있었다. 이 커밋은 HTTPS 로 직접 호출하도록 수정하면서, 업스트림의 주소/지역 필터가 실제로는 동작하지 않고 페이지당 응답이 1000rows 기준 26s 에 달해 20s fetch timeout 에 꾸준히 걸리던 문제까지 함께 해결한다.

## What changed

- packages/k-skill-proxy/src/parking-lots.js
  - PARKING_LOT_API_URL 을 `http://` → `https://` 로 고정 (root cause).
  - 업스트림 address/geo 필터가 신뢰 불가하므로 full-dataset 을 한 번 로드해 프로세스 메모리에 6시간 TTL 로 캐시하고, 동시 호출자는 in-flight promise 를 공유하도록 한다. nearby 쿼리는 캐시된 행을 좌표 거리로 필터링해 서비스한다.
  - DATASET_PAGE_SIZE=300, fetch timeout 30s 로 페이지당 응답이 20s 를 넘기지 않도록 맞췄다.
- packages/k-skill-proxy/src/server.js
  - 더 이상 의미 없어진 numOfRows / maxPages 쿼리 파라미터를 라우트에서 제거하고, 응답 payload 의 query echo 도 정리했다.
- packages/k-skill-proxy/test/server.test.js
  - 새 캐시 기반 동작을 검증하는 테스트로 교체: (1) full dataset load + 좌표 필터 + 프록시 응답 캐시 재사용, (2) public_only 기본값 및 해제 시 동작, (3) 좌표 검증 실패 400, (4) 업스트림 키 미설정 시 503.
- packages/parking-lot-search/src/index.js
  - OFFICIAL_API_URL 도 HTTPS 로 맞춰 직접 호출 모드 사용자도 같은 버그를 밟지 않게 한다.
- packages/parking-lot-search/src/parse.js
  - 업스트림이 `insttCode` / `insttNm` (camelCase) 를 돌려주는데 parser 가 snake_case (`instt_code`, `instt_nm`) 만 인식해 providerCode/providerName 이 비어 있던 문제를 수정.
- packages/parking-lot-search/test/* 및 fixtures
  - HTTPS URL 매칭으로 업데이트하고, insttCode/insttNm 회귀 테스트를 fixture/assertion 에 추가.
- docs/features/parking-lot-search.md, parking-lot-search/SKILL.md, packages/parking-lot-search/README.md
  - 공식 endpoint 표기를 HTTPS 로 통일.
- .changeset/parking-lot-https-fix.md
  - parking-lot-search 패키지 patch 릴리즈 노트 추가.

## How it was verified

- `npm run ci` (lint + typecheck + tests + pack:dry-run) 통과.
- 로컬에서 실제 `DATA_GO_KR_API_KEY` 로 k-skill-proxy 를 기동해 live 호출 검증:
  - 광화문 (37.573713, 126.978338) cold cache: 30s 내 전체 18,868 rows 로드, 2km 내 47개 공영주차장 반환 (세종로 414m, 서린노외 456m 등).
  - 강남역 (37.497952, 127.027621) warm cache: 31ms 응답, 1.5km 내 13개 반환 (역삼문화공원 380m, 역삼푸른솔도서관 421m 등).
- 업스트림 직접 HTTPS 호출로 `resultCode=00 NORMAL_SERVICE` 정상 동작 확인.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-22 10:52:57 +09:00 committed by GitHub
commit e1656541a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 273 additions and 115 deletions

View file

@ -0,0 +1,8 @@
---
"parking-lot-search": patch
---
Fix parking lot lookups after Data.go.kr enforced HTTPS on `api.data.go.kr`.
- Switch the official API URL from `http://` to `https://` so callers that use `buildOfficialParkingLotApiUrl` / direct-API mode no longer hit the HTTP → HTTPS 301 redirect that broke Node `fetch` based clients.
- Recognize the upstream's camelCase `insttCode` / `insttNm` provider fields in addition to the previously-handled snake_case variants so `providerCode` / `providerName` stay populated.

View file

@ -60,4 +60,4 @@ Issue #135에서 모두의주차장 연동 가능성이 언급되었지만 v1은
- 표준데이터: <https://www.data.go.kr/data/15012896/standard.do>
- Open API: <https://www.data.go.kr/data/15012896/openapi.do>
- Endpoint: `http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
- Endpoint: `https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`

View file

@ -1,6 +1,26 @@
const { normalizeParkingLotRows } = require("../../parking-lot-search/src/parse");
const PARKING_LOT_API_URL = "http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api";
// Data.go.kr now serves this endpoint over HTTPS and redirects HTTP requests
// with a 301 Moved Permanently, which causes Node `fetch` to surface a
// non-OK response even though the actual service is healthy. Use HTTPS
// directly so the proxy never depends on cross-protocol redirect handling.
const PARKING_LOT_API_URL = "https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api";
// Upstream does not support working address-based or geographic filters, and
// per-page latency is proportional to row count. We therefore cache the
// entire dataset in-process and serve nearby lookups by coordinate-distance
// filtering over the cached rows. This keeps most requests near-instant,
// while the periodic background refresh pays the full-dataset latency once
// per TTL window regardless of how many clients are calling.
const DATASET_PAGE_SIZE = 300;
const DATASET_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
const DATASET_FETCH_TIMEOUT_MS = 45000;
const datasetCache = {
rows: null,
fetchedAt: 0,
loadPromise: null
};
function parseInteger(value, fallback) {
const parsed = Number.parseInt(value, 10);
@ -10,7 +30,7 @@ function parseInteger(value, fallback) {
function buildParkingLotApiUrl({
serviceKey,
pageNo = 1,
numOfRows = 1000,
numOfRows = DATASET_PAGE_SIZE,
addressHint = null,
addressField = "rdnmadr",
publicOnly = true,
@ -39,7 +59,7 @@ function buildParkingLotApiUrl({
async function fetchParkingLotPage({
serviceKey,
pageNo = 1,
numOfRows = 1000,
numOfRows = DATASET_PAGE_SIZE,
addressHint = null,
addressField = "rdnmadr",
publicOnly = true,
@ -56,7 +76,7 @@ async function fetchParkingLotPage({
parkingType
});
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(20000)
signal: AbortSignal.timeout(DATASET_FETCH_TIMEOUT_MS)
});
if (!response.ok) {
@ -95,21 +115,56 @@ function getItems(payload) {
return [];
}
function mergeParkingLotPayloads(payloads) {
const first = payloads[0] || { response: { header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" }, body: {} } };
const body = getBody(first);
return {
response: {
header: first.response?.header || { resultCode: "00", resultMsg: "NORMAL_SERVICE" },
body: {
...body,
items: payloads.flatMap((payload) => getItems(payload)),
async function loadFullDataset({ serviceKey, fetchImpl, publicOnly = false }) {
const now = Date.now();
if (datasetCache.rows && now - datasetCache.fetchedAt < DATASET_CACHE_TTL_MS) {
return datasetCache.rows;
}
if (datasetCache.loadPromise) {
return datasetCache.loadPromise;
}
datasetCache.loadPromise = (async () => {
try {
const firstPage = await fetchParkingLotPage({
serviceKey,
pageNo: 1,
numOfRows: payloads.reduce((sum, payload) => sum + getItems(payload).length, 0),
totalCount: body.totalCount ?? payloads.reduce((sum, payload) => sum + getItems(payload).length, 0)
numOfRows: DATASET_PAGE_SIZE,
publicOnly,
fetchImpl
});
const totalCountRaw = getBody(firstPage).totalCount;
const totalCount = parseInteger(totalCountRaw, getItems(firstPage).length);
const totalPages = Math.max(1, Math.ceil(totalCount / DATASET_PAGE_SIZE));
let remainingPages = [];
if (totalPages > 1) {
const pageNumbers = Array.from({ length: totalPages - 1 }, (_, index) => index + 2);
remainingPages = await Promise.all(pageNumbers.map((pageNo) => fetchParkingLotPage({
serviceKey,
pageNo,
numOfRows: DATASET_PAGE_SIZE,
publicOnly,
fetchImpl
})));
}
const rows = [firstPage, ...remainingPages].flatMap((payload) => getItems(payload));
datasetCache.rows = rows;
datasetCache.fetchedAt = Date.now();
return rows;
} finally {
datasetCache.loadPromise = null;
}
};
})();
return datasetCache.loadPromise;
}
function resetParkingDatasetCacheForTests() {
datasetCache.rows = null;
datasetCache.fetchedAt = 0;
datasetCache.loadPromise = null;
}
async function fetchNearbyParkingLots({
@ -121,8 +176,6 @@ async function fetchNearbyParkingLots({
addressHint = null,
publicOnly = true,
parkingType = null,
numOfRows = 1000,
maxPages = 1,
fetchImpl = global.fetch
}) {
if (!serviceKey) {
@ -132,22 +185,41 @@ async function fetchNearbyParkingLots({
};
}
const pageCount = Math.max(1, Math.min(10, parseInteger(maxPages, 1)));
const payloads = [];
for (let pageNo = 1; pageNo <= pageCount; pageNo += 1) {
payloads.push(await fetchParkingLotPage({
serviceKey,
pageNo,
numOfRows,
addressHint,
publicOnly,
parkingType,
fetchImpl
}));
}
const cacheAgeBefore = datasetCache.rows ? Date.now() - datasetCache.fetchedAt : null;
const cacheHit = datasetCache.rows !== null && cacheAgeBefore !== null && cacheAgeBefore < DATASET_CACHE_TTL_MS;
const mergedPayload = mergeParkingLotPayloads(payloads);
const allItems = normalizeParkingLotRows(mergedPayload, { latitude, longitude }, { radius, publicOnly });
const rows = await loadFullDataset({ serviceKey, fetchImpl, publicOnly: false });
const mergedPayload = {
response: {
header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" },
body: {
items: rows,
pageNo: 1,
numOfRows: rows.length,
totalCount: rows.length
}
}
};
const typeFiltered = parkingType
? rows.filter((row) => String(row.prkplceType ?? row["주차장유형"] ?? "").trim() === String(parkingType).trim())
: rows;
const filteredPayload = parkingType
? {
response: {
header: mergedPayload.response.header,
body: {
...mergedPayload.response.body,
items: typeFiltered,
numOfRows: typeFiltered.length,
totalCount: typeFiltered.length
}
}
}
: mergedPayload;
const allItems = normalizeParkingLotRows(filteredPayload, { latitude, longitude }, { radius, publicOnly });
return {
anchor: {
@ -163,21 +235,25 @@ async function fetchNearbyParkingLots({
radius,
publicOnly,
addressHint,
numOfRows,
maxPages: pageCount,
source: "data.go.kr"
source: "data.go.kr",
datasetCacheHit: cacheHit,
datasetSize: rows.length,
datasetFetchedAt: new Date(datasetCache.fetchedAt).toISOString()
},
upstream: {
endpoint: PARKING_LOT_API_URL,
pages: pageCount,
total_count: getBody(payloads[0] || {}).totalCount ?? null
total_count: rows.length
}
};
}
module.exports = {
PARKING_LOT_API_URL,
DATASET_PAGE_SIZE,
DATASET_CACHE_TTL_MS,
buildParkingLotApiUrl,
fetchNearbyParkingLots,
fetchParkingLotPage
fetchParkingLotPage,
loadFullDataset,
resetParkingDatasetCacheForTests
};

View file

@ -728,16 +728,6 @@ function normalizeParkingLotSearchQuery(query) {
throw new Error("radius must be between 1 and 50000.");
}
const numOfRows = parseInteger(query.numOfRows ?? query.num_of_rows, 1000);
if (numOfRows < 1 || numOfRows > 1000) {
throw new Error("numOfRows must be between 1 and 1000.");
}
const maxPages = parseInteger(query.maxPages ?? query.max_pages, 1);
if (maxPages < 1 || maxPages > 10) {
throw new Error("maxPages must be between 1 and 10.");
}
const publicOnlyRaw = trimOrNull(query.publicOnly ?? query.public_only);
const publicOnly = publicOnlyRaw
? !["0", "false", "n", "no"].includes(publicOnlyRaw.toLowerCase())
@ -750,8 +740,6 @@ function normalizeParkingLotSearchQuery(query) {
longitude,
limit,
radius,
numOfRows,
maxPages,
publicOnly,
addressHint,
parkingType
@ -1983,9 +1971,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
radius: normalized.radius,
public_only: normalized.publicOnly,
address_hint: normalized.addressHint,
parking_type: normalized.parkingType,
num_of_rows: normalized.numOfRows,
max_pages: normalized.maxPages
parking_type: normalized.parkingType
},
proxy: {
name: config.proxyName,

View file

@ -3474,64 +3474,75 @@ test("data4library book-exists endpoint requires library code and isbn13 then pr
});
});
test("parking lot search endpoint normalizes, caches, and keeps the proxy public", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
function buildParkingDatasetFetchMock({ fetchCalls, totalCount, seoulRow, otherRows = [] }) {
return async (url) => {
const resolved = String(url);
fetchCalls.push(resolved);
assert.match(resolved, /^http:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
const urlObject = new URL(resolved);
assert.match(resolved, /^https:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
assert.equal(urlObject.searchParams.get("serviceKey"), "data-key");
assert.equal(urlObject.searchParams.get("type"), "json");
assert.equal(urlObject.searchParams.get("prkplceSe"), "공영");
assert.equal(urlObject.searchParams.get("rdnmadr"), "서울특별시 종로구");
assert.ok(!urlObject.searchParams.has("rdnmadr"));
assert.ok(!urlObject.searchParams.has("lnmadr"));
assert.ok(!urlObject.searchParams.has("prkplceSe"));
const pageNo = Number(urlObject.searchParams.get("pageNo") || 1);
const items = pageNo === 1 ? [seoulRow, ...otherRows] : [];
return new Response(JSON.stringify({
response: {
header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" },
body: {
pageNo: 1,
numOfRows: 1000,
totalCount: 2,
items: [
{
prkplceNo: "111-2-000001",
prkplceNm: "종로구청 공영주차장",
prkplceSe: "공영",
prkplceType: "노외",
rdnmadr: "서울특별시 종로구 삼봉로 43",
prkcmprt: "50",
parkingchrgeInfo: "유료",
basicTime: "30",
basicCharge: "1000",
institutionNm: "서울특별시 종로구청",
latitude: "37.57320",
longitude: "126.97810",
pwdbsPpkZoneYn: "Y",
referenceDate: "2026-03-01"
},
{
prkplceNo: "111-2-000003",
prkplceNm: "광화문광장 공영주차장",
prkplceSe: "공영",
prkplceType: "노상",
rdnmadr: "서울특별시 종로구 세종대로 172",
prkcmprt: "20",
parkingchrgeInfo: "무료",
latitude: "37.57375",
longitude: "126.97836",
pwdbsPpkZoneYn: "N",
referenceDate: "2026-03-01"
}
]
pageNo,
numOfRows: items.length,
totalCount,
items
}
}
}), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}), { status: 200, headers: { "content-type": "application/json" } });
};
}
test("parking lot search endpoint caches the full dataset and serves nearby queries from memory", async (t) => {
const { resetParkingDatasetCacheForTests } = require("../src/parking-lots");
resetParkingDatasetCacheForTests();
const originalFetch = global.fetch;
const fetchCalls = [];
const seoulRow = {
prkplceNo: "111-2-000003",
prkplceNm: "광화문광장 공영주차장",
prkplceSe: "공영",
prkplceType: "노상",
rdnmadr: "서울특별시 종로구 세종대로 172",
prkcmprt: "20",
parkingchrgeInfo: "무료",
institutionNm: "서울특별시 종로구청",
latitude: "37.57375",
longitude: "126.97836",
pwdbsPpkZoneYn: "N",
referenceDate: "2026-03-01",
insttCode: "3000000",
insttNm: "서울특별시 종로구"
};
const jejuRow = {
prkplceNo: "500-2-000001",
prkplceNm: "제주공영주차장",
prkplceSe: "공영",
prkplceType: "노외",
rdnmadr: "제주특별자치도 제주시 연동 1",
prkcmprt: "100",
parkingchrgeInfo: "무료",
latitude: "33.4761",
longitude: "126.5472",
pwdbsPpkZoneYn: "N",
referenceDate: "2026-03-01"
};
global.fetch = buildParkingDatasetFetchMock({
fetchCalls,
totalCount: 2,
seoulRow,
otherRows: [jejuRow]
});
const app = buildServer({
env: {
@ -3542,29 +3553,101 @@ test("parking lot search endpoint normalizes, caches, and keeps the proxy public
t.after(async () => {
global.fetch = originalFetch;
resetParkingDatasetCacheForTests();
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=37.57371315593711&longitude=126.97833785777944&address_hint=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C%20%EC%A2%85%EB%A1%9C%EA%B5%AC&limit=2&radius=1000"
url: "/v1/parking-lots/search?latitude=37.57371315593711&longitude=126.97833785777944&limit=2&radius=1000"
});
const second = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?lat=37.57371315593711&lon=126.97833785777944&addressHint=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C%20%EC%A2%85%EB%A1%9C%EA%B5%AC&limit=2&radius=1000"
url: "/v1/parking-lots/search?lat=37.57371315593711&lon=126.97833785777944&limit=2&radius=1000"
});
const third = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=37.58&longitude=126.98&limit=2&radius=2000"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls.length, 1);
assert.equal(third.statusCode, 200);
assert.equal(first.json().items.length, 1);
assert.equal(first.json().items[0].name, "광화문광장 공영주차장");
assert.equal(first.json().items[0].feeInfo, "무료");
assert.equal(first.json().items[1].basicCharge, 1000);
assert.equal(first.json().query.address_hint, "서울특별시 종로구");
assert.equal(first.json().items[0].providerName, "서울특별시 종로구");
assert.equal(first.json().meta.datasetCacheHit, false);
assert.equal(third.json().meta.datasetCacheHit, true);
assert.equal(fetchCalls.length, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
});
test("parking lot search endpoint filters cached dataset by radius and excludes private lots by default", async (t) => {
const { resetParkingDatasetCacheForTests } = require("../src/parking-lots");
resetParkingDatasetCacheForTests();
const originalFetch = global.fetch;
const fetchCalls = [];
const privateSeoulRow = {
prkplceNo: "111-2-000002",
prkplceNm: "세종로 민영주차장",
prkplceSe: "민영",
prkplceType: "노외",
rdnmadr: "서울특별시 종로구 세종대로 170",
prkcmprt: "100",
parkingchrgeInfo: "유료",
latitude: "37.573714",
longitude: "126.978339",
referenceDate: "2026-03-01"
};
const publicSeoulRow = {
prkplceNo: "111-2-000003",
prkplceNm: "광화문광장 공영주차장",
prkplceSe: "공영",
prkplceType: "노상",
rdnmadr: "서울특별시 종로구 세종대로 172",
prkcmprt: "20",
parkingchrgeInfo: "무료",
latitude: "37.57375",
longitude: "126.97836",
pwdbsPpkZoneYn: "N",
referenceDate: "2026-03-01"
};
global.fetch = buildParkingDatasetFetchMock({
fetchCalls,
totalCount: 2,
seoulRow: publicSeoulRow,
otherRows: [privateSeoulRow]
});
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-key" } });
t.after(async () => {
global.fetch = originalFetch;
resetParkingDatasetCacheForTests();
await app.close();
});
const publicOnly = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=37.57375&longitude=126.97836&limit=5&radius=500"
});
const includePrivate = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=37.57375&longitude=126.97836&limit=5&radius=500&public_only=false"
});
assert.equal(publicOnly.statusCode, 200);
assert.equal(includePrivate.statusCode, 200);
assert.equal(publicOnly.json().items.length, 1);
assert.equal(publicOnly.json().items[0].name, "광화문광장 공영주차장");
const includedCategories = includePrivate.json().items.map((item) => item.category);
assert.equal(includePrivate.json().items.length, 2);
assert.ok(includedCategories.includes("공영"));
assert.ok(includedCategories.includes("민영"));
});
test("parking lot search endpoint validates coordinates before upstream calls", async (t) => {
const app = buildServer({
env: {

View file

@ -14,7 +14,7 @@ Korean parking-lot lookup backed first by the official Data.go.kr `전국주차
- Data.go.kr standard dataset: `https://www.data.go.kr/data/15012896/standard.do`
- Data.go.kr Open API docs: `https://www.data.go.kr/data/15012896/openapi.do`
- Open API endpoint: `http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
- Open API endpoint: `https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
- Kakao Map mobile search: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map place panel JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`

View file

@ -9,7 +9,7 @@ const {
const SEARCH_VIEW_URL = "https://m.map.kakao.com/actions/searchView";
const PLACE_PANEL_URL_BASE = "https://place-api.map.kakao.com/places/panel3";
const OFFICIAL_API_URL = "http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api";
const OFFICIAL_API_URL = "https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api";
const DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org";
const DEFAULT_BROWSER_HEADERS = {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",

View file

@ -288,8 +288,8 @@ function normalizeParkingLotRows(payload, origin, options = {}) {
phone: String(row.phoneNumber ?? row["전화번호"] ?? "").trim() || null,
hasAccessibleParking: toBooleanYesNo(row.pwdbsPpkZoneYn ?? row["장애인전용주차구역보유여부"]),
referenceDate: String(row.referenceDate ?? row["데이터기준일자"] ?? "").trim() || null,
providerCode: String(row.instt_code ?? row["제공기관코드"] ?? "").trim() || null,
providerName: String(row.instt_nm ?? row["제공기관기관명"] ?? row["제공기관명"] ?? "").trim() || null,
providerCode: String(row.insttCode ?? row.instt_code ?? row["제공기관코드"] ?? "").trim() || null,
providerName: String(row.insttNm ?? row.instt_nm ?? row["제공기관기관명"] ?? row["제공기관명"] ?? "").trim() || null,
mapUrl: buildMapUrl(name, itemLatitude, itemLongitude)
};
})

View file

@ -76,7 +76,8 @@
"longitude": "126.97836",
"pwdbsPpkZoneYn": "N",
"referenceDate": "2026-03-01",
"instt_nm": "서울특별시 종로구"
"insttCode": "3000000",
"insttNm": "서울특별시 종로구"
},
{
"prkplceNo": "111-2-000004",

View file

@ -40,7 +40,7 @@ test("buildOfficialParkingLotApiUrl targets the standard public parking API with
publicOnly: true
}));
assert.equal(url.origin + url.pathname, "http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api");
assert.equal(url.origin + url.pathname, "https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api");
assert.equal(url.searchParams.get("serviceKey"), "data-key");
assert.equal(url.searchParams.get("type"), "json");
assert.equal(url.searchParams.get("pageNo"), "2");
@ -65,6 +65,10 @@ test("normalizeParkingLotRows keeps public parking metadata and sorts by distanc
assert.equal(items[0].hasAccessibleParking, false);
assert.equal(items[1].basicCharge, 1000);
assert.equal(items[1].hasAccessibleParking, true);
assert.equal(items[0].providerCode, "3000000");
assert.equal(items[0].providerName, "서울특별시 종로구");
assert.equal(items[1].providerCode, "3000000");
assert.equal(items[1].providerName, "서울특별시 종로구");
assert.equal(
items[0].mapUrl,
"https://map.kakao.com/link/map/%EA%B4%91%ED%99%94%EB%AC%B8%EA%B4%91%EC%9E%A5%20%EA%B3%B5%EC%98%81%EC%A3%BC%EC%B0%A8%EC%9E%A5,37.57375,126.97836"
@ -99,7 +103,7 @@ test("searchNearbyParkingLotsByCoordinates uses the official API directly when a
assert.equal(result.items[0].name, "광화문광장 공영주차장");
assert.equal(result.meta.total, 2);
assert.equal(result.meta.source, "data.go.kr");
assert.match(calls[0], /^http:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
assert.match(calls[0], /^https:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
assert.match(calls[0], /rdnmadr=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C\+%EC%A2%85%EB%A1%9C%EA%B5%AC/);
});
@ -145,7 +149,7 @@ test("searchNearbyParkingLotsByLocationQuery resolves a Kakao anchor and derives
return makeResponse(anchorPanel, "application/json");
}
if (resolved.startsWith("http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api?")) {
if (resolved.startsWith("https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api?")) {
const urlObject = new URL(resolved);
assert.equal(urlObject.searchParams.get("rdnmadr"), "서울특별시 종로구");
return makeResponse(parkingApiResponse);

View file

@ -44,7 +44,7 @@ metadata:
- 표준데이터 안내: `https://www.data.go.kr/data/15012896/standard.do`
- Open API 안내: `https://www.data.go.kr/data/15012896/openapi.do`
- Open API endpoint: `http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
- Open API endpoint: `https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
- k-skill proxy: `/v1/parking-lots/search`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`