|
| 1 | +#!/usr/bin/env node |
| 2 | +// ───────────────────────────────────────────── |
| 3 | +// 공공 API 키 연결 검증 스크립트 |
| 4 | +// |
| 5 | +// 사용법 (루트에서): |
| 6 | +// node --env-file=.env apps/web/scripts/verify-public-api.mjs |
| 7 | +// |
| 8 | +// .env 의 각 서비스키로 실제 1회 호출해 연결 상태를 출력한다. |
| 9 | +// ✓ 연결됨 ✗ 오류(메시지) – 키없음(건너뜀) ? 수동확인 권장 |
| 10 | +// |
| 11 | +// 진단 정보: HTTP 상태코드 + 응답 형태(JSON/XML/HTML) + 키 지문(길이/끝 4자/중복 여부). |
| 12 | +// data.go.kr 는 계정당 인증키 1개로 승인된 모든 서비스를 호출한다 → |
| 13 | +// TOUR/KMA/AIRKOREA 키는 보통 같은 값이어야 한다(아래 지문으로 확인). |
| 14 | +// 개발계정 자동승인이라도 발급 직후 1~2시간 반영 지연이 있을 수 있다. |
| 15 | +// ───────────────────────────────────────────── |
| 16 | + |
| 17 | +const KST = () => new Date(Date.now() + 9 * 3600 * 1000).toISOString().slice(0, 10).replace(/-/g, ""); |
| 18 | + |
| 19 | +async function getRes(url, timeoutMs = 10000) { |
| 20 | + const ctrl = new AbortController(); |
| 21 | + const t = setTimeout(() => ctrl.abort(), timeoutMs); |
| 22 | + try { |
| 23 | + const res = await fetch(url, { signal: ctrl.signal }); |
| 24 | + return { status: res.status, ctype: res.headers.get("content-type") ?? "", text: await res.text() }; |
| 25 | + } finally { |
| 26 | + clearTimeout(t); |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +function shape(text) { |
| 31 | + const s = text.trimStart(); |
| 32 | + if (s.startsWith("{") || s.startsWith("[")) return "JSON"; |
| 33 | + if (/^<\?xml|^<response|^<OpenAPI/i.test(s)) return "XML"; |
| 34 | + if (s.startsWith("<")) return "HTML"; |
| 35 | + return "TEXT"; |
| 36 | +} |
| 37 | + |
| 38 | +function parseMaybeJson(text) { |
| 39 | + const s = text.trimStart(); |
| 40 | + if (s.startsWith("<")) { |
| 41 | + const msg = /<returnAuthMsg>(.*?)<\/returnAuthMsg>/.exec(s)?.[1]; |
| 42 | + const code = /<returnReasonCode>(.*?)<\/returnReasonCode>/.exec(s)?.[1]; |
| 43 | + return { xmlError: msg ? `${msg}${code ? ` (${code})` : ""}` : null }; |
| 44 | + } |
| 45 | + try { |
| 46 | + return { json: JSON.parse(text) }; |
| 47 | + } catch { |
| 48 | + return {}; |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +const build = (base, params, key) => { |
| 53 | + const u = new URL(base); |
| 54 | + u.searchParams.set("serviceKey", key); |
| 55 | + for (const [k, v] of Object.entries(params)) u.searchParams.set(k, String(v)); |
| 56 | + return u.toString(); |
| 57 | +}; |
| 58 | + |
| 59 | +const statusHint = (s) => |
| 60 | + s === 401 |
| 61 | + ? " → 키 미인식(키 값 오류/누락)" |
| 62 | + : s === 403 |
| 63 | + ? " → 키는 유효하나 이 서비스 권한 없음: data.go.kr 활용신청 '승인' 여부·반영(최대 1~2h) 확인" |
| 64 | + : s === 400 |
| 65 | + ? " → 요청 파라미터 오류" |
| 66 | + : ""; |
| 67 | +const diag = (r) => `[HTTP ${r.status} · ${shape(r.text)}] ${r.text.replace(/\s+/g, " ").trim().slice(0, 100)}${statusHint(r.status)}`; |
| 68 | + |
| 69 | +// ── 자동 프로브 (https 사용) ────────────────── |
| 70 | +async function probeTourApi(key) { |
| 71 | + const r = await getRes( |
| 72 | + build("https://apis.data.go.kr/B551011/KorService2/areaBasedList2", { MobileOS: "ETC", MobileApp: "eumgil", _type: "json", areaCode: 32, numOfRows: 1, pageNo: 1 }, key), |
| 73 | + ); |
| 74 | + const p = parseMaybeJson(r.text); |
| 75 | + if (p.json?.response?.header?.resultCode === "0000") |
| 76 | + return { ok: true, msg: `강원 totalCount=${p.json.response.body?.totalCount ?? "?"}` }; |
| 77 | + return { ok: false, msg: p.xmlError ?? diag(r) }; |
| 78 | +} |
| 79 | + |
| 80 | +async function probeKma(key) { |
| 81 | + const r = await getRes( |
| 82 | + build("https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst", { dataType: "JSON", base_date: KST(), base_time: "0500", nx: 92, ny: 131, numOfRows: 1, pageNo: 1 }, key), |
| 83 | + ); |
| 84 | + const p = parseMaybeJson(r.text); |
| 85 | + const code = p.json?.response?.header?.resultCode; |
| 86 | + if (code === "00" || code === "03") return { ok: true, msg: `resultCode=${code}` }; |
| 87 | + return { ok: false, msg: p.xmlError ?? diag(r) }; |
| 88 | +} |
| 89 | + |
| 90 | +async function probeAirKorea(key) { |
| 91 | + const r = await getRes( |
| 92 | + build("https://apis.data.go.kr/B552584/ArpltnInforInqireSvc/getCtprvnRltmMesureDnsty", { returnType: "json", sidoName: "강원", numOfRows: 1, pageNo: 1, ver: "1.0" }, key), |
| 93 | + ); |
| 94 | + const p = parseMaybeJson(r.text); |
| 95 | + if (p.json?.response?.header?.resultCode === "00") |
| 96 | + return { ok: true, msg: `강원 측정소 ${p.json.response.body?.totalCount ?? "?"}곳` }; |
| 97 | + return { ok: false, msg: p.xmlError ?? diag(r) }; |
| 98 | +} |
| 99 | + |
| 100 | +const CHECKS = [ |
| 101 | + { name: "1. 국문 관광정보(TourAPI)", env: "TOUR_API_SERVICE_KEY", probe: probeTourApi }, |
| 102 | + { name: "4. 기상청 단기예보", env: "KMA_SERVICE_KEY", probe: probeKma }, |
| 103 | + { name: "5. 기상청 생활기상지수", env: "KMA_SERVICE_KEY", manual: "단기예보와 동일 KMA 키(4번으로 검증)" }, |
| 104 | + { name: "6. 에어코리아 대기오염", env: "AIRKOREA_SERVICE_KEY", probe: probeAirKorea }, |
| 105 | + { name: "2. 관광 빅데이터 방문자수", env: "TOUR_BIGDATA_SERVICE_KEY", manual: "파라미터 확정 후 자동화 예정" }, |
| 106 | + { name: "3. 관광지 집중률 예측", env: "TOUR_BIGDATA_SERVICE_KEY", manual: "데이터랩 형태 확인 후 자동화 예정" }, |
| 107 | + { name: "7. 한국도로공사 실시간 소통", env: "EX_ROAD_SERVICE_KEY", manual: "data.ex.co.kr 확정 후 자동화 예정" }, |
| 108 | +]; |
| 109 | + |
| 110 | +const C = { green: "\x1b[32m", red: "\x1b[31m", gray: "\x1b[90m", yellow: "\x1b[33m", cyan: "\x1b[36m", reset: "\x1b[0m" }; |
| 111 | +const fp = (k) => (k ? `len=${k.length} …${k.slice(-4)}` : "(없음)"); |
| 112 | + |
| 113 | +// ── 키 지문 (같은 값인지 확인) ─────────────── |
| 114 | +console.log("\n공공 API 연결 검증\n──────────────────────────────"); |
| 115 | +const tour = process.env.TOUR_API_SERVICE_KEY; |
| 116 | +const kma = process.env.KMA_SERVICE_KEY; |
| 117 | +const air = process.env.AIRKOREA_SERVICE_KEY; |
| 118 | +console.log(`${C.cyan}키 지문${C.reset}`); |
| 119 | +console.log(` TOUR ${fp(tour)}`); |
| 120 | +console.log(` KMA ${fp(kma)}${kma && tour ? (kma === tour ? ` ${C.gray}(TOUR와 동일)${C.reset}` : ` ${C.yellow}(TOUR와 다름!)${C.reset}`) : ""}`); |
| 121 | +console.log(` AIRKOREA ${fp(air)}${air && tour ? (air === tour ? ` ${C.gray}(TOUR와 동일)${C.reset}` : ` ${C.yellow}(TOUR와 다름!)${C.reset}`) : ""}`); |
| 122 | +console.log(` ${C.gray}※ data.go.kr 는 계정당 인증키 1개 — 세 값이 같아야 정상인 경우가 많음${C.reset}`); |
| 123 | +console.log("──────────────────────────────"); |
| 124 | + |
| 125 | +for (const check of CHECKS) { |
| 126 | + const key = process.env[check.env]; |
| 127 | + if (!key) { |
| 128 | + console.log(`${C.gray}– ${check.name} — 키없음 (${check.env})${C.reset}`); |
| 129 | + continue; |
| 130 | + } |
| 131 | + if (check.manual) { |
| 132 | + console.log(`${C.yellow}? ${check.name} — 키 설정됨, ${check.manual}${C.reset}`); |
| 133 | + continue; |
| 134 | + } |
| 135 | + try { |
| 136 | + const r = await check.probe(key); |
| 137 | + console.log(`${r.ok ? C.green + "✓" : C.red + "✗"} ${check.name} — ${r.msg}${C.reset}`); |
| 138 | + } catch (e) { |
| 139 | + console.log(`${C.red}✗ ${check.name} — ${e instanceof Error ? e.message : String(e)}${C.reset}`); |
| 140 | + } |
| 141 | +} |
| 142 | +console.log("──────────────────────────────\n"); |
0 commit comments