ev-subsidy-status 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ "use strict"
3
+
4
+ const { getSubsidyStatus, searchRegions } = require("./index")
5
+
6
+ function parseArgs(argv) {
7
+ const options = { command: "status" }
8
+ let index = 0
9
+ if (argv[0] && !argv[0].startsWith("-") && ["status", "regions"].includes(argv[0])) {
10
+ options.command = argv[0]
11
+ index = 1
12
+ }
13
+ for (; index < argv.length; index += 1) {
14
+ const arg = argv[index]
15
+ if (arg === "--region" || arg === "-r") options.region = argv[++index]
16
+ else if (arg === "--query" || arg === "-q") options.query = argv[++index]
17
+ else if (arg === "--vehicle" || arg === "--vehicle-type") options.vehicleType = argv[++index]
18
+ else if (arg === "--year") options.year = argv[++index]
19
+ else if (arg === "--category") options.category = argv[++index]
20
+ else if (arg === "--model") options.model = argv[++index]
21
+ else if (arg === "--transport") options.transport = argv[++index]
22
+ else if (arg === "--provider") options.provider = argv[++index]
23
+ else if (arg === "--cdp-url") options.cdpUrl = argv[++index]
24
+ else if (arg === "--timeout") options.timeoutMs = argv[++index]
25
+ else if (arg === "--json") options.json = true
26
+ else if (arg === "--help" || arg === "-h") options.help = true
27
+ else if (!options.region && options.command === "status") options.region = arg
28
+ else if (!options.query && options.command === "regions") options.query = arg
29
+ }
30
+ return options
31
+ }
32
+
33
+ function formatCount(counts) {
34
+ if (!counts) return "확인 불가"
35
+ const total = Number.isFinite(counts.total) ? `${counts.total.toLocaleString("ko-KR")}대` : "확인 불가"
36
+ const details = [
37
+ ["우선", counts.priority],
38
+ ["법인·기관", counts.corporate],
39
+ ["일반", counts.general]
40
+ ]
41
+ if (Number.isFinite(counts.taxi)) details.splice(2, 0, ["택시", counts.taxi])
42
+ if (Number.isFinite(counts.small_business)) details.splice(2, 0, ["중소기업", counts.small_business])
43
+ return `${total} (${details.filter(([, value]) => Number.isFinite(value)).map(([label, value]) => `${label} ${value.toLocaleString("ko-KR")}`).join(", ")})`
44
+ }
45
+
46
+ function formatStatus(result) {
47
+ const lines = [
48
+ `${result.region.sido_name} ${result.region.local_name} ${result.status.vehicle_label} 보조금 현황`,
49
+ `조회: ${result.source.fetched_at}`,
50
+ "",
51
+ `민간공고대수: ${formatCount(result.status.notice_count)}`,
52
+ `접수대수: ${formatCount(result.status.application_count)}`,
53
+ `출고대수: ${formatCount(result.status.delivered_count)}`,
54
+ `출고잔여대수: ${formatCount(result.status.delivery_remaining_count)}`,
55
+ `상태: ${result.availability.label}`,
56
+ `접수방법: ${result.status.application_method || "확인 불가"}`,
57
+ `비고: ${result.status.note || "없음"}`
58
+ ]
59
+ if (result.model_subsidy) {
60
+ lines.push(
61
+ "",
62
+ `모델: ${result.model_subsidy.manufacturer} ${result.model_subsidy.model}`,
63
+ `1대당 보조금: ${(result.model_subsidy.total_subsidy_krw || 0).toLocaleString("ko-KR")}원`,
64
+ `모델 기준 잔여 환산치: ${(result.remaining_budget.model_equivalent_estimate_krw || 0).toLocaleString("ko-KR")}원`
65
+ )
66
+ } else if (Array.isArray(result.model_subsidy_candidates) && result.model_subsidy_candidates.length) {
67
+ lines.push("", "일치하는 세부 모델:")
68
+ for (const item of result.model_subsidy_candidates) {
69
+ const subsidy = Number.isFinite(item.total_subsidy_krw)
70
+ ? `${item.total_subsidy_krw.toLocaleString("ko-KR")}원`
71
+ : "확인 불가"
72
+ const estimate = Number.isFinite(item.remaining_equivalent_estimate_krw)
73
+ ? `${item.remaining_equivalent_estimate_krw.toLocaleString("ko-KR")}원`
74
+ : "확인 불가"
75
+ lines.push(`- ${item.manufacturer} ${item.model}: 1대당 ${subsidy}, 잔여 환산치 ${estimate}`)
76
+ }
77
+ }
78
+ lines.push(
79
+ "",
80
+ "주의: 출고잔여대수는 실제 신청 가능 대수와 다를 수 있으며 정확한 원화 예산 잔액이 아닙니다.",
81
+ `출처: ${result.source.url}`
82
+ )
83
+ if (result.warnings.length) lines.push("", ...result.warnings.map((warning) => `경고: ${warning}`))
84
+ return lines.join("\n")
85
+ }
86
+
87
+ function printHelp(io = console) {
88
+ io.log(`Usage:
89
+ ev-subsidy-status status --region "경기 성남시" [options]
90
+ ev-subsidy-status regions --query "중구" [options]
91
+
92
+ Options:
93
+ -r, --region <region> 시도와 시군구. 예: 경기 성남시
94
+ -q, --query <text> regions 명령의 지역 검색어
95
+ --vehicle <type> passenger/승용, cargo/화물, bus/승합
96
+ --year <year> 기준년도. 기본값은 현재 연도
97
+ --model <model> 지자체 모델별 보조금과 잔여 환산치 조회
98
+ --transport <transport> 기본 direct-http, 선택값 browser
99
+ --provider <provider> browser 사용 시 auto, aside, browseros, chrome-cdp
100
+ --cdp-url <url> 사용자가 실행한 Chrome/BrowserOS CDP URL
101
+ --timeout <ms> 페이지 대기 시간
102
+ --json JSON 출력
103
+ `)
104
+ }
105
+
106
+ async function main(options = parseArgs(process.argv.slice(2)), io = console) {
107
+ if (options.help) {
108
+ printHelp(io)
109
+ return null
110
+ }
111
+ if (options.command === "regions") {
112
+ const result = await searchRegions(options)
113
+ io.log(JSON.stringify(result, null, 2))
114
+ return result
115
+ }
116
+ const result = await getSubsidyStatus(options)
117
+ io.log(options.json ? JSON.stringify(result, null, 2) : formatStatus(result))
118
+ return result
119
+ }
120
+
121
+ function formatError(error) {
122
+ return JSON.stringify({
123
+ error: {
124
+ code: error && error.code ? error.code : "UNKNOWN",
125
+ message: error && error.message ? error.message : String(error),
126
+ details: error && error.details ? error.details : {}
127
+ }
128
+ }, null, 2)
129
+ }
130
+
131
+ function run(argv = process.argv.slice(2), io = console) {
132
+ return main(parseArgs(argv), io).catch((error) => {
133
+ io.error(formatError(error))
134
+ process.exitCode = 1
135
+ })
136
+ }
137
+
138
+ if (require.main === module) run()
139
+
140
+ module.exports = {
141
+ formatCount,
142
+ formatError,
143
+ formatStatus,
144
+ main,
145
+ parseArgs,
146
+ printHelp,
147
+ run
148
+ }
@@ -0,0 +1,65 @@
1
+ "use strict"
2
+
3
+ const STATUS_URL = "https://ev.or.kr/nportal/buySupprt/initSubsidyPaymentCheckAction.do"
4
+ const MODEL_SUBSIDY_PATH = "/nportal/buySupprt/psPopupLocalCarModelPrice.do"
5
+
6
+ const VEHICLE_TYPES = Object.freeze({
7
+ passenger: Object.freeze({
8
+ key: "passenger",
9
+ label: "전기승용",
10
+ carTypeCode: "11",
11
+ aliases: ["passenger", "car", "승용", "전기승용", "전기차"]
12
+ }),
13
+ cargo: Object.freeze({
14
+ key: "cargo",
15
+ label: "전기화물",
16
+ carTypeCode: "12",
17
+ aliases: ["cargo", "truck", "화물", "전기화물"]
18
+ }),
19
+ bus: Object.freeze({
20
+ key: "bus",
21
+ label: "전기승합",
22
+ carTypeCode: "13",
23
+ aliases: ["bus", "van", "승합", "전기승합"]
24
+ })
25
+ })
26
+
27
+ const SIDO_ALIASES = Object.freeze({
28
+ 서울: ["서울", "서울시", "서울특별시"],
29
+ 부산: ["부산", "부산시", "부산광역시"],
30
+ 대구: ["대구", "대구시", "대구광역시"],
31
+ 인천: ["인천", "인천시", "인천광역시"],
32
+ 광주: ["광주", "광주시", "광주광역시"],
33
+ 대전: ["대전", "대전시", "대전광역시"],
34
+ 울산: ["울산", "울산시", "울산광역시"],
35
+ 세종: ["세종", "세종시", "세종특별자치시"],
36
+ 경기: ["경기", "경기도"],
37
+ 강원: ["강원", "강원도", "강원특별자치도"],
38
+ 충북: ["충북", "충청북도"],
39
+ 충남: ["충남", "충청남도"],
40
+ 전북: ["전북", "전라북도", "전북특별자치도"],
41
+ 전남: ["전남", "전라남도"],
42
+ 경북: ["경북", "경상북도"],
43
+ 경남: ["경남", "경상남도"],
44
+ 제주: ["제주", "제주도", "제주특별자치도"]
45
+ })
46
+
47
+ function resolveVehicleType(value) {
48
+ const normalized = String(value || "passenger").trim().toLowerCase()
49
+ for (const definition of Object.values(VEHICLE_TYPES)) {
50
+ if (definition.aliases.some((alias) => alias.toLowerCase() === normalized)) {
51
+ return definition
52
+ }
53
+ }
54
+ const error = new Error(`지원하지 않는 차종입니다: ${value}`)
55
+ error.code = "VEHICLE_TYPE_NOT_AVAILABLE"
56
+ throw error
57
+ }
58
+
59
+ module.exports = {
60
+ MODEL_SUBSIDY_PATH,
61
+ SIDO_ALIASES,
62
+ STATUS_URL,
63
+ VEHICLE_TYPES,
64
+ resolveVehicleType
65
+ }
package/src/errors.js ADDED
@@ -0,0 +1,39 @@
1
+ "use strict"
2
+
3
+ class EvSubsidyError extends Error {
4
+ constructor(code, message, details = {}) {
5
+ super(message)
6
+ this.name = "EvSubsidyError"
7
+ this.code = code
8
+ this.details = details
9
+ }
10
+ }
11
+
12
+ function createError(code, message, details) {
13
+ return new EvSubsidyError(code, message, details)
14
+ }
15
+
16
+ function wrapBrowserError(error) {
17
+ if (error instanceof EvSubsidyError) return error
18
+ const sourceCode = error && error.code
19
+ const code = sourceCode === "CAPTCHA_DETECTED"
20
+ ? "CAPTCHA_DETECTED"
21
+ : sourceCode === "AUTH_REQUIRED"
22
+ ? "AUTH_REQUIRED"
23
+ : sourceCode === "UNKNOWN_PROVIDER"
24
+ ? "BROWSER_UNAVAILABLE"
25
+ : sourceCode === "UNAVAILABLE" || sourceCode === "PLAYWRIGHT_UNAVAILABLE"
26
+ ? "BROWSER_UNAVAILABLE"
27
+ : "UPSTREAM_FAILED"
28
+ const wrapped = createError(code, error && error.message ? error.message : String(error), {
29
+ upstream_code: sourceCode || null
30
+ })
31
+ wrapped.cause = error
32
+ return wrapped
33
+ }
34
+
35
+ module.exports = {
36
+ EvSubsidyError,
37
+ createError,
38
+ wrapBrowserError
39
+ }
@@ -0,0 +1,30 @@
1
+ "use strict"
2
+
3
+ function buildUnavailableBudget(reason = "공개 지급현황은 정확한 원화 예산 잔액을 제공하지 않습니다.") {
4
+ return {
5
+ exact_available: false,
6
+ exact_amount_krw: null,
7
+ reason,
8
+ model_equivalent_estimate_krw: null,
9
+ estimate_assumptions: []
10
+ }
11
+ }
12
+
13
+ function estimateModelEquivalent({ remainingCount, subsidyPerVehicleKrw }) {
14
+ const budget = buildUnavailableBudget()
15
+ if (!Number.isFinite(remainingCount) || remainingCount < 0) return budget
16
+ if (!Number.isFinite(subsidyPerVehicleKrw) || subsidyPerVehicleKrw <= 0) return budget
17
+
18
+ budget.model_equivalent_estimate_krw = Math.round(remainingCount * subsidyPerVehicleKrw)
19
+ budget.estimate_assumptions = [
20
+ `공식 출고잔여대수 ${remainingCount}대가 모두 선택 모델에 배정된다고 가정`,
21
+ `1대당 확인된 국비+지방비 ${Math.round(subsidyPerVehicleKrw)}원 적용`,
22
+ "구매자 특성별 추가지원과 대상군 간 물량 전환은 반영하지 않음"
23
+ ]
24
+ return budget
25
+ }
26
+
27
+ module.exports = {
28
+ buildUnavailableBudget,
29
+ estimateModelEquivalent
30
+ }
package/src/http.js ADDED
@@ -0,0 +1,332 @@
1
+ "use strict"
2
+
3
+ const { MODEL_SUBSIDY_PATH, SIDO_ALIASES, STATUS_URL, resolveVehicleType } = require("./constants")
4
+ const { createError } = require("./errors")
5
+ const {
6
+ attachModelEstimate,
7
+ buildStatusResult,
8
+ normalizeRegionKey,
9
+ normalizeText,
10
+ parseModelSubsidyRows,
11
+ parseStatusRows
12
+ } = require("./parse")
13
+ const { decodeProtectedHtml, extractPnpScriptUrl } = require("./pnp")
14
+
15
+ const DEFAULT_TIMEOUT_MS = 30000
16
+ const DEFAULT_HEADERS = Object.freeze({
17
+ accept: "text/html,application/xhtml+xml",
18
+ "accept-language": "ko-KR,ko;q=0.9",
19
+ "user-agent": "ev-subsidy-status/0.1 (+https://github.com/NomaDamas/k-skill)"
20
+ })
21
+
22
+ let cachedPnp = null
23
+
24
+ function decodeHtmlEntities(value) {
25
+ return String(value)
26
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
27
+ .replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number(number)))
28
+ .replace(/&nbsp;/gi, " ")
29
+ .replace(/&amp;/gi, "&")
30
+ .replace(/&lt;/gi, "<")
31
+ .replace(/&gt;/gi, ">")
32
+ .replace(/&quot;/gi, "\"")
33
+ .replace(/&#39;|&apos;/gi, "'")
34
+ }
35
+
36
+ function htmlToText(value) {
37
+ return normalizeText(decodeHtmlEntities(String(value)
38
+ .replace(/<br\s*\/?>/gi, "\n")
39
+ .replace(/<\/(?:p|div|li)>/gi, "\n")
40
+ .replace(/<[^>]*>/g, " ")))
41
+ }
42
+
43
+ function parseNoticeFiles(cellHtml) {
44
+ const items = []
45
+ const pattern = /<(a|button)\b([^>]*)>([\s\S]*?)<\/\1>|<input\b([^>]*)>/gi
46
+ let match
47
+ while ((match = pattern.exec(cellHtml))) {
48
+ const attributes = match[2] || match[4] || ""
49
+ const attribute = (name) => {
50
+ const found = attributes.match(new RegExp(`\\b${name}=(['"])([\\s\\S]*?)\\1`, "i"))
51
+ return decodeHtmlEntities(found ? found[2] : "")
52
+ }
53
+ const label = match[1]
54
+ ? htmlToText(match[3])
55
+ : attribute("value")
56
+ const onclick = attribute("onclick")
57
+ const title = attribute("title")
58
+ if (label || onclick) items.push({ label, title, onclick })
59
+ }
60
+ return items
61
+ }
62
+
63
+ function extractStatusRows(html) {
64
+ const tables = Array.from(String(html).matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi), (match) => match[1])
65
+ const table = tables.find((candidate) => /출고잔여/.test(htmlToText(candidate)))
66
+ if (!table) throw createError("DOM_CHANGED", "공식 지급현황 표를 찾지 못했습니다.")
67
+
68
+ const bodyMatch = table.match(/<tbody\b[^>]*>([\s\S]*?)<\/tbody>/i)
69
+ const body = bodyMatch ? bodyMatch[1] : table
70
+ const rows = []
71
+ for (const rowMatch of body.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
72
+ const cellHtml = Array.from(rowMatch[1].matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi), (match) => match[1])
73
+ if (cellHtml.length < 10) continue
74
+ const cells = cellHtml.map(htmlToText)
75
+ const codeMatch = cellHtml[3].match(/goDownloadFile\(\s*['"][^'"]+['"]\s*,\s*['"]([^'"]+)['"]/i)
76
+ rows.push({
77
+ cells,
78
+ local_code: codeMatch ? codeMatch[1] : null,
79
+ notice_files: parseNoticeFiles(cellHtml[3])
80
+ })
81
+ }
82
+ if (!rows.length) throw createError("RESULT_EMPTY", "공식 지급현황 표에 데이터 행이 없습니다.")
83
+ return rows
84
+ }
85
+
86
+ function extractModelSubsidySnapshot(html) {
87
+ const candidates = Array.from(String(html).matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi), (match) => {
88
+ const table = match[1]
89
+ const headers = Array.from(table.matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi), (header) => htmlToText(header[1]))
90
+ const rows = Array.from(table.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi), (row) =>
91
+ Array.from(row[1].matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi), (cell) => htmlToText(cell[1]))
92
+ ).filter((cells) => cells.length >= 6)
93
+ return { headers, rows }
94
+ }).filter((candidate) =>
95
+ candidate.rows.length > 0 &&
96
+ /제조사/.test(candidate.headers.join(" ")) &&
97
+ /모델/.test(candidate.headers.join(" ")) &&
98
+ /국비/.test(candidate.headers.join(" "))
99
+ ).sort((a, b) => b.rows.length - a.rows.length)
100
+
101
+ if (!candidates.length) {
102
+ throw createError("DOM_CHANGED", "공식 모델별 보조금 표를 찾지 못했습니다.")
103
+ }
104
+ return candidates[0]
105
+ }
106
+
107
+ function canonicalSidoFromQuery(query) {
108
+ const key = normalizeRegionKey(query)
109
+ return Object.entries(SIDO_ALIASES).find(([, aliases]) =>
110
+ aliases.some((alias) => key.includes(normalizeRegionKey(alias)))
111
+ )?.[0] || null
112
+ }
113
+
114
+ function localMatches(query, localName) {
115
+ const key = normalizeRegionKey(query)
116
+ const local = normalizeRegionKey(localName)
117
+ if (key.includes(local)) return true
118
+ const short = local.replace(/(시|군|구)$/u, "")
119
+ return short.length >= 2 && key.includes(short)
120
+ }
121
+
122
+ function resolveRegionFromRows(query, rawRows) {
123
+ if (!normalizeText(query)) throw createError("REGION_REQUIRED", "조회할 시도와 시군구를 입력하세요.")
124
+ const explicitSido = canonicalSidoFromQuery(query)
125
+ const matches = rawRows.filter((row) => {
126
+ const [sidoName, localName] = row.cells
127
+ if (explicitSido && normalizeRegionKey(sidoName) !== normalizeRegionKey(explicitSido)) return false
128
+ return localMatches(query, localName)
129
+ }).map((row) => ({
130
+ sidoName: row.cells[0],
131
+ sidoCode: row.local_code ? `${row.local_code.slice(0, 2)}00` : null,
132
+ localName: row.cells[1],
133
+ localCode: row.local_code
134
+ }))
135
+ const unique = Array.from(new Map(matches.map((item) => [`${item.sidoName}:${item.localName}`, item])).values())
136
+ if (!unique.length) throw createError("REGION_NOT_FOUND", `공식 지급현황에서 "${query}" 지역을 찾지 못했습니다.`)
137
+ if (unique.length > 1) {
138
+ throw createError("REGION_AMBIGUOUS", `"${query}"에 해당하는 지역이 여러 곳입니다. 시도를 함께 입력하세요.`, {
139
+ candidates: unique
140
+ })
141
+ }
142
+ return unique[0]
143
+ }
144
+
145
+ async function fetchText(url, options = {}) {
146
+ const fetchImpl = options.fetch || globalThis.fetch
147
+ if (typeof fetchImpl !== "function") throw createError("UPSTREAM_FAILED", "사용 가능한 fetch 구현이 없습니다.")
148
+ const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS
149
+ const controller = new AbortController()
150
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
151
+ try {
152
+ const response = await fetchImpl(url, {
153
+ ...options.request,
154
+ headers: { ...DEFAULT_HEADERS, ...(options.request && options.request.headers) },
155
+ signal: controller.signal
156
+ })
157
+ if (!response.ok) {
158
+ throw createError("UPSTREAM_FAILED", `공식 사이트가 HTTP ${response.status}를 반환했습니다.`, {
159
+ status: response.status
160
+ })
161
+ }
162
+ return response.text()
163
+ } catch (error) {
164
+ if (error.name === "AbortError") throw createError("UPSTREAM_TIMEOUT", "공식 사이트 응답 시간이 초과되었습니다.")
165
+ throw error
166
+ } finally {
167
+ clearTimeout(timer)
168
+ }
169
+ }
170
+
171
+ async function fetchDecodedStatusHtml(options = {}) {
172
+ const vehicle = resolveVehicleType(options.vehicleType)
173
+ const year = Number(options.year || new Date().getFullYear())
174
+ const body = new URLSearchParams({
175
+ car_type: vehicle.carTypeCode,
176
+ year1: String(year),
177
+ localDo_cd: "all",
178
+ local_cd1: "all"
179
+ })
180
+ const shellHtml = await fetchText(STATUS_URL, {
181
+ ...options,
182
+ request: {
183
+ method: "POST",
184
+ headers: { "content-type": "application/x-www-form-urlencoded;charset=UTF-8" },
185
+ body: body.toString()
186
+ }
187
+ })
188
+ if (!/<meta[^>]+name=['"]penc['"]/i.test(shellHtml)) return { html: shellHtml, vehicle, year }
189
+
190
+ const pnpUrl = extractPnpScriptUrl(shellHtml, STATUS_URL)
191
+ if (!cachedPnp || cachedPnp.url !== pnpUrl || options.fetch) {
192
+ cachedPnp = {
193
+ url: pnpUrl,
194
+ source: await fetchText(pnpUrl, options)
195
+ }
196
+ }
197
+ const html = decodeProtectedHtml(shellHtml, cachedPnp.source)
198
+ const selectedYear = html.match(/<option\b[^>]*value=['"](\d{4})['"][^>]*selected/i)?.[1]
199
+ if (selectedYear && Number(selectedYear) !== year) {
200
+ throw createError("YEAR_NOT_AVAILABLE", `${year}년 지급현황을 공식 페이지에서 선택할 수 없습니다.`)
201
+ }
202
+ return { html, vehicle, year }
203
+ }
204
+
205
+ async function fetchDecodedModelHtml(options = {}) {
206
+ const vehicle = options.vehicle || resolveVehicleType(options.vehicleType)
207
+ const year = Number(options.year || new Date().getFullYear())
208
+ const modelUrl = new URL(MODEL_SUBSIDY_PATH, STATUS_URL).toString()
209
+ const body = new URLSearchParams({
210
+ year: String(year),
211
+ year1: String(year),
212
+ local_cd: String(options.localCode),
213
+ car_type: vehicle.carTypeCode
214
+ })
215
+ const shellHtml = await fetchText(modelUrl, {
216
+ ...options,
217
+ request: {
218
+ method: "POST",
219
+ headers: { "content-type": "application/x-www-form-urlencoded;charset=UTF-8" },
220
+ body: body.toString()
221
+ }
222
+ })
223
+ if (!/<meta[^>]+name=['"]penc['"]/i.test(shellHtml)) {
224
+ return { html: shellHtml, modelUrl }
225
+ }
226
+
227
+ const pnpUrl = extractPnpScriptUrl(shellHtml, modelUrl)
228
+ if (!cachedPnp || cachedPnp.url !== pnpUrl || options.fetch) {
229
+ cachedPnp = {
230
+ url: pnpUrl,
231
+ source: await fetchText(pnpUrl, options)
232
+ }
233
+ }
234
+ return {
235
+ html: decodeProtectedHtml(shellHtml, cachedPnp.source),
236
+ modelUrl
237
+ }
238
+ }
239
+
240
+ async function getSubsidyStatusHttp(options = {}) {
241
+ const { html, vehicle, year } = await fetchDecodedStatusHtml(options)
242
+ const rawRows = extractStatusRows(html)
243
+ const region = resolveRegionFromRows(options.region, rawRows)
244
+ const rows = parseStatusRows(rawRows, { localName: region.localName, vehicleType: vehicle.key })
245
+ const result = buildStatusResult({
246
+ query: {
247
+ region: options.region,
248
+ year,
249
+ vehicle_type: vehicle.key,
250
+ category: options.category || "all",
251
+ model: options.model || null
252
+ },
253
+ region,
254
+ rows,
255
+ sourceUrl: STATUS_URL
256
+ })
257
+ result.transport = "direct-http"
258
+ if (options.model) {
259
+ try {
260
+ const modelResponse = await fetchDecodedModelHtml({
261
+ ...options,
262
+ localCode: region.localCode,
263
+ vehicle
264
+ })
265
+ const parsed = parseModelSubsidyRows(extractModelSubsidySnapshot(modelResponse.html), {
266
+ model: options.model
267
+ })
268
+ const remainingCount = result.availability.official_remaining_count
269
+ result.model_subsidy_candidates = parsed.items.map((item) => ({
270
+ ...item,
271
+ remaining_equivalent_estimate_krw:
272
+ Number.isFinite(remainingCount) && Number.isFinite(item.total_subsidy_krw)
273
+ ? remainingCount * item.total_subsidy_krw
274
+ : null
275
+ }))
276
+ result.model_subsidy_source_url = modelResponse.modelUrl
277
+
278
+ if (parsed.items.length === 1) {
279
+ Object.assign(result, attachModelEstimate(result, parsed.items[0]))
280
+ } else if (parsed.items.length > 1) {
281
+ result.warnings.push(
282
+ `"${options.model}"에 해당하는 세부 모델이 ${parsed.items.length}개입니다. 트림별 보조금과 잔여 환산치를 구분해 확인하세요.`
283
+ )
284
+ } else {
285
+ result.model_lookup_error = {
286
+ code: "MODEL_LOOKUP_FAILED",
287
+ message: `공식 모델별 보조금 표에서 "${options.model}"을 찾지 못했습니다.`
288
+ }
289
+ result.warnings.push(result.model_lookup_error.message)
290
+ }
291
+ } catch (error) {
292
+ result.model_lookup_error = {
293
+ code: error && error.code ? error.code : "MODEL_LOOKUP_FAILED",
294
+ message: error && error.message ? error.message : String(error)
295
+ }
296
+ result.warnings.push(`모델별 보조금 조회 실패: ${result.model_lookup_error.message}`)
297
+ }
298
+ }
299
+ return result
300
+ }
301
+
302
+ async function searchRegionsHttp(options = {}) {
303
+ const query = normalizeText(options.query)
304
+ if (!query) throw createError("REGION_REQUIRED", "검색할 지역명을 입력하세요.")
305
+ const { html } = await fetchDecodedStatusHtml({ ...options, vehicleType: "passenger" })
306
+ const rows = extractStatusRows(html)
307
+ const items = rows.filter((row) => localMatches(query, row.cells[1])).map((row) => ({
308
+ sido_name: row.cells[0],
309
+ sido_code: row.local_code ? `${row.local_code.slice(0, 2)}00` : null,
310
+ local_name: row.cells[1],
311
+ local_code: row.local_code
312
+ }))
313
+ return {
314
+ query,
315
+ items: Array.from(new Map(items.map((item) => [`${item.sido_name}:${item.local_name}`, item])).values()),
316
+ source_url: STATUS_URL,
317
+ transport: "direct-http"
318
+ }
319
+ }
320
+
321
+ module.exports = {
322
+ decodeHtmlEntities,
323
+ extractModelSubsidySnapshot,
324
+ extractStatusRows,
325
+ fetchDecodedModelHtml,
326
+ fetchDecodedStatusHtml,
327
+ getSubsidyStatusHttp,
328
+ htmlToText,
329
+ parseNoticeFiles,
330
+ resolveRegionFromRows,
331
+ searchRegionsHttp
332
+ }
package/src/index.js ADDED
@@ -0,0 +1,61 @@
1
+ "use strict"
2
+
3
+ const { getSubsidyStatus: getSubsidyStatusBrowser, searchRegions: searchRegionsBrowser } = require("./browser")
4
+ const { getSubsidyStatusHttp, searchRegionsHttp } = require("./http")
5
+ const { STATUS_URL, VEHICLE_TYPES, resolveVehicleType } = require("./constants")
6
+ const { EvSubsidyError } = require("./errors")
7
+ const { classifyAvailability } = require("./availability")
8
+ const { buildUnavailableBudget, estimateModelEquivalent } = require("./estimate")
9
+ const {
10
+ attachModelEstimate,
11
+ buildStatusResult,
12
+ formatKst,
13
+ normalizeRegionKey,
14
+ normalizeText,
15
+ parseModelSubsidyRows,
16
+ parseMoneyKrw,
17
+ parseNumberCell,
18
+ parseStatusRows
19
+ } = require("./parse")
20
+
21
+ function browserRequested(options = {}) {
22
+ return options.transport === "browser" ||
23
+ Boolean(options.page || options.runtime || options.provider || options.cdpUrl)
24
+ }
25
+
26
+ async function getSubsidyStatus(options = {}) {
27
+ return browserRequested(options)
28
+ ? getSubsidyStatusBrowser(options)
29
+ : getSubsidyStatusHttp(options)
30
+ }
31
+
32
+ async function searchRegions(options = {}) {
33
+ return browserRequested(options)
34
+ ? searchRegionsBrowser(options)
35
+ : searchRegionsHttp(options)
36
+ }
37
+
38
+ module.exports = {
39
+ EvSubsidyError,
40
+ STATUS_URL,
41
+ VEHICLE_TYPES,
42
+ attachModelEstimate,
43
+ buildStatusResult,
44
+ buildUnavailableBudget,
45
+ classifyAvailability,
46
+ estimateModelEquivalent,
47
+ formatKst,
48
+ getSubsidyStatus,
49
+ getSubsidyStatusBrowser,
50
+ getSubsidyStatusHttp,
51
+ normalizeRegionKey,
52
+ normalizeText,
53
+ parseModelSubsidyRows,
54
+ parseMoneyKrw,
55
+ parseNumberCell,
56
+ parseStatusRows,
57
+ resolveVehicleType,
58
+ searchRegions,
59
+ searchRegionsBrowser,
60
+ searchRegionsHttp
61
+ }