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/README.md +26 -0
- package/package.json +37 -0
- package/src/availability.js +32 -0
- package/src/browser.js +449 -0
- package/src/cli.js +148 -0
- package/src/constants.js +65 -0
- package/src/errors.js +39 -0
- package/src/estimate.js +30 -0
- package/src/http.js +332 -0
- package/src/index.js +61 -0
- package/src/parse.js +215 -0
- package/src/pnp.js +102 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# ev-subsidy-status
|
|
2
|
+
|
|
3
|
+
환경부 무공해차 통합누리집의 공개 구매보조금 지급현황을 로그인이나 사용자 브라우저 없이 조회한다.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx ev-subsidy-status status --region "경기 성남시" --vehicle passenger --year 2026
|
|
7
|
+
npx ev-subsidy-status status --region "서울 강남구" --model "모델명" --json
|
|
8
|
+
npx ev-subsidy-status regions --query "중구"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
기본 전송 방식은 `direct-http`다. 공개 응답에서 공식 `pnp4web.js` 문자표를 읽고,
|
|
12
|
+
원격 코드를 실행하지 않은 채 보호된 HTML을 복원한다. 프록시와 API 키를 사용하지 않는다.
|
|
13
|
+
|
|
14
|
+
공식 페이지 구조가 바뀌었을 때 진단용 브라우저 경로를 명시적으로 선택할 수 있다.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx ev-subsidy-status status --region "경기 성남시" --transport browser --provider auto
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
브라우저 경로는 `k-skill-browser-runtime`으로 사용자가 실행한 Aside Browser,
|
|
21
|
+
BrowserOS 또는 Chrome CDP 세션에 연결하며 기존 프로필이나 탭을 종료하지 않는다.
|
|
22
|
+
|
|
23
|
+
공식 화면은 원화 잔액이 아니라 공고·접수·출고·출고잔여 대수를 제공한다.
|
|
24
|
+
모델을 지정하면 직접 HTTP 경로에서도 모델별 국비·지방비·합계와 잔여 환산치를 조회한다.
|
|
25
|
+
입력한 이름이 여러 세부 모델과 일치하면 모든 후보를 반환하며 한 세부 모델을 임의로 선택하지 않는다.
|
|
26
|
+
환산치는 정확한 가용 예산 잔액이 아니다.
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ev-subsidy-status",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Korean regional electric-vehicle subsidy availability lookup",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ev-subsidy-status": "src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/NomaDamas/k-skill.git"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"k-skill",
|
|
26
|
+
"korea",
|
|
27
|
+
"electric-vehicle",
|
|
28
|
+
"subsidy"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"lint": "node --check src/index.js src/cli.js src/browser.js src/http.js src/pnp.js src/constants.js src/errors.js src/parse.js src/availability.js src/estimate.js test/index.test.js test/browser.test.js test/http.test.js",
|
|
32
|
+
"test": "node --test"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"k-skill-browser-runtime": "^0.4.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
function classifyAvailability(note, remainingCount) {
|
|
4
|
+
const text = String(note || "").replace(/\s+/g, " ").trim()
|
|
5
|
+
const basis = []
|
|
6
|
+
const warnings = []
|
|
7
|
+
|
|
8
|
+
const closedPattern = /(마감|소진|접수\s*종료|신청\s*종료)/
|
|
9
|
+
const scheduledPattern = /(접수\s*예정|추경.{0,12}예정|추가\s*공고.{0,12}예정|재공고.{0,12}예정)/
|
|
10
|
+
const openPattern = /(접수\s*중|신청\s*기간|접수\s*기간|신청\s*가능)/
|
|
11
|
+
|
|
12
|
+
if (closedPattern.test(text)) basis.push("note:closed")
|
|
13
|
+
if (scheduledPattern.test(text)) basis.push("note:scheduled")
|
|
14
|
+
if (openPattern.test(text)) basis.push("note:open")
|
|
15
|
+
|
|
16
|
+
let label = "unknown"
|
|
17
|
+
if (closedPattern.test(text)) label = "closed"
|
|
18
|
+
else if (scheduledPattern.test(text)) label = "scheduled"
|
|
19
|
+
else if (openPattern.test(text)) label = "open"
|
|
20
|
+
else if (Number.isFinite(remainingCount) && remainingCount > 0) {
|
|
21
|
+
label = "unknown_with_remaining_count"
|
|
22
|
+
basis.push("remaining_count:positive")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (label === "closed" && Number.isFinite(remainingCount) && remainingCount > 0) {
|
|
26
|
+
warnings.push("출고잔여대수는 양수지만 지자체 비고에는 마감 또는 소진으로 표시됩니다.")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { label, basis, warnings }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { classifyAvailability }
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const { MODEL_SUBSIDY_PATH, SIDO_ALIASES, STATUS_URL, resolveVehicleType } = require("./constants")
|
|
4
|
+
const { createError, wrapBrowserError } = require("./errors")
|
|
5
|
+
const {
|
|
6
|
+
attachModelEstimate,
|
|
7
|
+
buildStatusResult,
|
|
8
|
+
normalizeRegionKey,
|
|
9
|
+
normalizeText,
|
|
10
|
+
parseModelSubsidyRows,
|
|
11
|
+
parseStatusRows
|
|
12
|
+
} = require("./parse")
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 30000
|
|
15
|
+
const POLL_INTERVAL_MS = 300
|
|
16
|
+
|
|
17
|
+
async function evaluateDom(page, operation, payload = {}) {
|
|
18
|
+
return page.evaluate(({ operation, payload }) => {
|
|
19
|
+
const normalize = (value) => String(value == null ? "" : value).replace(/\s+/g, " ").trim()
|
|
20
|
+
const visible = (element) => {
|
|
21
|
+
if (!element) return false
|
|
22
|
+
const style = window.getComputedStyle(element)
|
|
23
|
+
return style.display !== "none" && style.visibility !== "hidden"
|
|
24
|
+
}
|
|
25
|
+
const optionRows = (select) => Array.from(select ? select.options : []).map((option) => ({
|
|
26
|
+
value: option.value,
|
|
27
|
+
label: normalize(option.textContent),
|
|
28
|
+
disabled: Boolean(option.disabled)
|
|
29
|
+
}))
|
|
30
|
+
const dispatchChange = (select, value) => {
|
|
31
|
+
select.value = value
|
|
32
|
+
select.dispatchEvent(new Event("input", { bubbles: true }))
|
|
33
|
+
select.dispatchEvent(new Event("change", { bubbles: true }))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (operation === "page-state") {
|
|
37
|
+
const bodyText = normalize(document.body ? document.body.innerText : "")
|
|
38
|
+
return {
|
|
39
|
+
bodyText: bodyText.slice(0, 50000),
|
|
40
|
+
htmlLength: document.documentElement ? document.documentElement.outerHTML.length : 0,
|
|
41
|
+
hasPassword: Boolean(document.querySelector("input[type=password]")),
|
|
42
|
+
hasCaptcha: /captcha|캡차|자동입력\s*방지|로봇이\s*아닙니다/i.test(bodyText)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (operation === "options") {
|
|
47
|
+
return optionRows(document.querySelector(payload.selector))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (operation === "select-value") {
|
|
51
|
+
const select = document.querySelector(payload.selector)
|
|
52
|
+
if (!select) return { ok: false, reason: "select-not-found" }
|
|
53
|
+
const option = Array.from(select.options).find((candidate) => candidate.value === payload.value)
|
|
54
|
+
if (!option) return { ok: false, reason: "option-not-found" }
|
|
55
|
+
const changed = select.value !== option.value
|
|
56
|
+
dispatchChange(select, option.value)
|
|
57
|
+
return { ok: true, changed, value: option.value, label: normalize(option.textContent) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (operation === "select-label-any") {
|
|
61
|
+
const wanted = normalize(payload.label)
|
|
62
|
+
for (const select of Array.from(document.querySelectorAll("select"))) {
|
|
63
|
+
const option = Array.from(select.options).find((candidate) => normalize(candidate.textContent) === wanted)
|
|
64
|
+
if (option) {
|
|
65
|
+
dispatchChange(select, option.value)
|
|
66
|
+
return { ok: true, selector: select.id ? `#${select.id}` : select.name, value: option.value }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { ok: false }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (operation === "click-text") {
|
|
73
|
+
const wanted = normalize(payload.text)
|
|
74
|
+
const near = payload.nearSelector ? document.querySelector(payload.nearSelector) : null
|
|
75
|
+
const scope = near ? (near.closest("form") || near.parentElement || document) : document
|
|
76
|
+
const candidates = Array.from(scope.querySelectorAll("a,button,input[type=button],input[type=submit]"))
|
|
77
|
+
const element = candidates.find((candidate) => {
|
|
78
|
+
const text = normalize(candidate.textContent || candidate.value)
|
|
79
|
+
return visible(candidate) && text === wanted
|
|
80
|
+
})
|
|
81
|
+
if (!element) return { ok: false }
|
|
82
|
+
element.click()
|
|
83
|
+
return { ok: true, tag: element.tagName, id: element.id || null }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (operation === "status-rows") {
|
|
87
|
+
const wanted = normalize(payload.localName)
|
|
88
|
+
const rows = []
|
|
89
|
+
for (const tr of Array.from(document.querySelectorAll("tr"))) {
|
|
90
|
+
const cells = Array.from(tr.querySelectorAll("td,th"))
|
|
91
|
+
const text = cells.map((cell) => normalize(cell.textContent))
|
|
92
|
+
if (text.length < 10 || text[1] !== wanted) continue
|
|
93
|
+
const noticeCell = cells[3]
|
|
94
|
+
const noticeFiles = Array.from(noticeCell.querySelectorAll("a,button,input[type=button]")).map((item) => ({
|
|
95
|
+
label: normalize(item.textContent || item.value),
|
|
96
|
+
title: normalize(item.getAttribute("title")),
|
|
97
|
+
onclick: normalize(item.getAttribute("onclick"))
|
|
98
|
+
}))
|
|
99
|
+
rows.push({ cells: text, notice_files: noticeFiles })
|
|
100
|
+
}
|
|
101
|
+
return rows
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (operation === "submit-model-form") {
|
|
105
|
+
const form = document.createElement("form")
|
|
106
|
+
form.method = "post"
|
|
107
|
+
form.action = payload.action
|
|
108
|
+
for (const [name, value] of Object.entries(payload.fields)) {
|
|
109
|
+
const input = document.createElement("input")
|
|
110
|
+
input.type = "hidden"
|
|
111
|
+
input.name = name
|
|
112
|
+
input.value = String(value)
|
|
113
|
+
form.appendChild(input)
|
|
114
|
+
}
|
|
115
|
+
document.body.appendChild(form)
|
|
116
|
+
form.submit()
|
|
117
|
+
return { ok: true }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (operation === "model-table") {
|
|
121
|
+
let selected = null
|
|
122
|
+
for (const table of Array.from(document.querySelectorAll("table"))) {
|
|
123
|
+
const rows = Array.from(table.querySelectorAll("tbody tr"))
|
|
124
|
+
.map((tr) => Array.from(tr.querySelectorAll("td")).map((cell) => normalize(cell.textContent)))
|
|
125
|
+
.filter((cells) => cells.length >= 6)
|
|
126
|
+
if (rows.length && (!selected || rows.length > selected.rows.length)) {
|
|
127
|
+
selected = {
|
|
128
|
+
headers: Array.from(table.querySelectorAll("thead th, thead td")).map((cell) => normalize(cell.textContent)),
|
|
129
|
+
rows
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return selected || { headers: [], rows: [] }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null
|
|
137
|
+
}, { operation, payload })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function waitFor(page, predicate, options = {}) {
|
|
141
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS
|
|
142
|
+
const intervalMs = Number.isFinite(options.intervalMs) ? options.intervalMs : POLL_INTERVAL_MS
|
|
143
|
+
const deadline = Date.now() + timeoutMs
|
|
144
|
+
let lastValue
|
|
145
|
+
while (Date.now() <= deadline) {
|
|
146
|
+
lastValue = await predicate()
|
|
147
|
+
if (lastValue) return lastValue
|
|
148
|
+
await page.waitForTimeout(intervalMs)
|
|
149
|
+
}
|
|
150
|
+
const error = createError(options.code || "UPSTREAM_TIMEOUT", options.message || "공식 페이지 응답을 기다리다 시간이 초과되었습니다.")
|
|
151
|
+
error.details.last_value = lastValue
|
|
152
|
+
throw error
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function assertPublicStatusPage(page, timeoutMs) {
|
|
156
|
+
const state = await waitFor(page, async () => {
|
|
157
|
+
const current = await evaluateDom(page, "page-state")
|
|
158
|
+
if (current.hasCaptcha) throw createError("CAPTCHA_DETECTED", "공식 페이지에 CAPTCHA가 표시되어 자동 조회를 중단했습니다.")
|
|
159
|
+
if (current.hasPassword && !current.bodyText.includes("구매보조금 지급현황")) {
|
|
160
|
+
throw createError("AUTH_REQUIRED", "공식 조회 페이지가 로그인을 요구합니다.")
|
|
161
|
+
}
|
|
162
|
+
return current.bodyText.includes("구매보조금 지급현황") ? current : null
|
|
163
|
+
}, {
|
|
164
|
+
timeoutMs,
|
|
165
|
+
code: "UPSTREAM_BLOCKED",
|
|
166
|
+
message: "공식 구매보조금 지급현황 본문이 렌더링되지 않았습니다."
|
|
167
|
+
})
|
|
168
|
+
if (state.htmlLength < 1000) {
|
|
169
|
+
throw createError("UPSTREAM_BLOCKED", "공식 페이지가 빈 응답을 반환했습니다.")
|
|
170
|
+
}
|
|
171
|
+
return state
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function findSidoOption(query, options) {
|
|
175
|
+
const normalizedQuery = normalizeRegionKey(query)
|
|
176
|
+
const candidates = []
|
|
177
|
+
for (const option of options) {
|
|
178
|
+
const normalizedLabel = normalizeRegionKey(option.label)
|
|
179
|
+
const canonical = Object.keys(SIDO_ALIASES).find((key) => {
|
|
180
|
+
const aliases = SIDO_ALIASES[key]
|
|
181
|
+
return aliases.some((alias) => normalizedQuery.includes(normalizeRegionKey(alias)))
|
|
182
|
+
})
|
|
183
|
+
if (
|
|
184
|
+
normalizedQuery.includes(normalizedLabel) ||
|
|
185
|
+
(canonical && SIDO_ALIASES[canonical].some((alias) => normalizeRegionKey(alias) === normalizedLabel))
|
|
186
|
+
) {
|
|
187
|
+
candidates.push(option)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return candidates.length === 1 ? candidates[0] : null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function findLocalOptions(query, options) {
|
|
194
|
+
const normalizedQuery = normalizeRegionKey(query)
|
|
195
|
+
return options.filter((option) => {
|
|
196
|
+
if (!option.value || option.disabled) return false
|
|
197
|
+
const label = normalizeRegionKey(option.label)
|
|
198
|
+
if (!label) return false
|
|
199
|
+
if (normalizedQuery.includes(label)) return true
|
|
200
|
+
const shortLabel = label.replace(/(시|군|구)$/u, "")
|
|
201
|
+
return shortLabel.length >= 2 && normalizedQuery.includes(shortLabel)
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function selectValue(page, selector, option, timeoutMs) {
|
|
206
|
+
const result = await evaluateDom(page, "select-value", { selector, value: option.value })
|
|
207
|
+
if (!result || !result.ok) {
|
|
208
|
+
throw createError("DOM_CHANGED", `${selector}에서 ${option.label} 옵션을 선택하지 못했습니다.`)
|
|
209
|
+
}
|
|
210
|
+
await page.waitForTimeout(Math.min(800, timeoutMs))
|
|
211
|
+
return result
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function localOptions(page) {
|
|
215
|
+
return evaluateDom(page, "options", { selector: "#local_cd1" })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function waitForLocalOptions(page, previousSignature, timeoutMs) {
|
|
219
|
+
return waitFor(page, async () => {
|
|
220
|
+
const options = await localOptions(page)
|
|
221
|
+
const signature = JSON.stringify(options)
|
|
222
|
+
const usable = options.filter((option) => option.value && !option.disabled)
|
|
223
|
+
if (usable.length && signature !== previousSignature) return options
|
|
224
|
+
return null
|
|
225
|
+
}, {
|
|
226
|
+
timeoutMs,
|
|
227
|
+
code: "DOM_CHANGED",
|
|
228
|
+
message: "시도 선택 후 시군구 목록이 갱신되지 않았습니다."
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function resolveRegion(page, query, timeoutMs) {
|
|
233
|
+
if (!normalizeText(query)) throw createError("REGION_REQUIRED", "조회할 시도와 시군구를 입력하세요.")
|
|
234
|
+
const sidoOptions = (await evaluateDom(page, "options", { selector: "#localDo_cd" }))
|
|
235
|
+
.filter((option) => option.value && !option.disabled)
|
|
236
|
+
if (!sidoOptions.length) throw createError("DOM_CHANGED", "시도 선택 목록을 찾지 못했습니다.")
|
|
237
|
+
|
|
238
|
+
const explicitSido = findSidoOption(query, sidoOptions)
|
|
239
|
+
const matches = []
|
|
240
|
+
const candidates = explicitSido ? [explicitSido] : sidoOptions
|
|
241
|
+
|
|
242
|
+
for (const sido of candidates) {
|
|
243
|
+
const before = JSON.stringify(await localOptions(page))
|
|
244
|
+
const selection = await selectValue(page, "#localDo_cd", sido, timeoutMs)
|
|
245
|
+
const locals = selection.changed ? await waitForLocalOptions(page, before, timeoutMs) : await localOptions(page)
|
|
246
|
+
for (const local of findLocalOptions(query, locals)) {
|
|
247
|
+
matches.push({ sidoName: sido.label, sidoCode: sido.value, localName: local.label, localCode: local.value })
|
|
248
|
+
}
|
|
249
|
+
if (explicitSido) break
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const unique = Array.from(new Map(matches.map((item) => [`${item.sidoCode}:${item.localCode}`, item])).values())
|
|
253
|
+
if (!unique.length) throw createError("REGION_NOT_FOUND", `공식 지역 목록에서 "${query}"를 찾지 못했습니다.`)
|
|
254
|
+
if (unique.length > 1) {
|
|
255
|
+
throw createError("REGION_AMBIGUOUS", `"${query}"에 해당하는 지역이 여러 곳입니다. 시도를 함께 입력하세요.`, {
|
|
256
|
+
candidates: unique
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const region = unique[0]
|
|
261
|
+
if (!explicitSido || explicitSido.value !== region.sidoCode) {
|
|
262
|
+
const before = JSON.stringify(await localOptions(page))
|
|
263
|
+
const selection = await selectValue(page, "#localDo_cd", { value: region.sidoCode, label: region.sidoName }, timeoutMs)
|
|
264
|
+
if (selection.changed) await waitForLocalOptions(page, before, timeoutMs)
|
|
265
|
+
}
|
|
266
|
+
await selectValue(page, "#local_cd1", { value: region.localCode, label: region.localName }, timeoutMs)
|
|
267
|
+
return region
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function chooseVehicleAndYear(page, vehicle, year, timeoutMs) {
|
|
271
|
+
let selected = await evaluateDom(page, "select-label-any", { label: vehicle.label })
|
|
272
|
+
if (!selected || !selected.ok) {
|
|
273
|
+
selected = await evaluateDom(page, "click-text", { text: vehicle.label })
|
|
274
|
+
if (selected && selected.ok) {
|
|
275
|
+
await page.waitForTimeout(800)
|
|
276
|
+
await assertPublicStatusPage(page, timeoutMs)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if ((!selected || !selected.ok) && vehicle.key !== "passenger") {
|
|
280
|
+
throw createError("VEHICLE_TYPE_NOT_AVAILABLE", `${vehicle.label} 선택 항목을 찾지 못했습니다.`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const yearResult = await evaluateDom(page, "select-label-any", { label: String(year) })
|
|
284
|
+
if (!yearResult || !yearResult.ok) {
|
|
285
|
+
throw createError("YEAR_NOT_AVAILABLE", `${year}년 선택 항목을 찾지 못했습니다.`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function clickSearchAndReadRows(page, region, timeoutMs) {
|
|
290
|
+
const clicked = await evaluateDom(page, "click-text", { text: "조회", nearSelector: "#local_cd1" })
|
|
291
|
+
if (!clicked || !clicked.ok) throw createError("DOM_CHANGED", "조회 버튼을 찾지 못했습니다.")
|
|
292
|
+
return waitFor(page, async () => {
|
|
293
|
+
const rows = await evaluateDom(page, "status-rows", { localName: region.localName })
|
|
294
|
+
return rows.length ? rows : null
|
|
295
|
+
}, {
|
|
296
|
+
timeoutMs,
|
|
297
|
+
code: "RESULT_EMPTY",
|
|
298
|
+
message: `${region.localName} 지급현황 행이 나타나지 않았습니다.`
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function readModelSubsidies(page, { region, vehicle, year, model, timeoutMs }) {
|
|
303
|
+
const action = new URL(MODEL_SUBSIDY_PATH, STATUS_URL).toString()
|
|
304
|
+
await evaluateDom(page, "submit-model-form", {
|
|
305
|
+
action,
|
|
306
|
+
fields: {
|
|
307
|
+
year,
|
|
308
|
+
year1: year,
|
|
309
|
+
local_cd: region.localCode,
|
|
310
|
+
car_type: vehicle.carTypeCode,
|
|
311
|
+
carType: vehicle.key === "passenger" ? "car" : vehicle.key,
|
|
312
|
+
evCarTypeDtl: vehicle.carTypeCode
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
const snapshot = await waitFor(page, async () => {
|
|
316
|
+
const current = await evaluateDom(page, "model-table")
|
|
317
|
+
return current.rows.length ? current : null
|
|
318
|
+
}, {
|
|
319
|
+
timeoutMs,
|
|
320
|
+
code: "MODEL_LOOKUP_FAILED",
|
|
321
|
+
message: "지자체 차종별 보조금 표가 나타나지 않았습니다."
|
|
322
|
+
})
|
|
323
|
+
return parseModelSubsidyRows(snapshot, { model })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function connectionOptions(options) {
|
|
327
|
+
const output = {}
|
|
328
|
+
for (const key of ["provider", "platform", "cdpUrl", "probe", "asideCommand", "asideTimeoutMs", "connectLoader", "chromiumLoader"]) {
|
|
329
|
+
if (options[key] !== undefined) output[key] = options[key]
|
|
330
|
+
}
|
|
331
|
+
return output
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function withAutomationPage(options, work) {
|
|
335
|
+
if (options.page) return work(options.page, { provider: "injected" })
|
|
336
|
+
const runtime = options.runtime || require("k-skill-browser-runtime")
|
|
337
|
+
let browser
|
|
338
|
+
let session
|
|
339
|
+
let provider
|
|
340
|
+
try {
|
|
341
|
+
const connected = await runtime.connect(connectionOptions(options))
|
|
342
|
+
browser = connected.browser
|
|
343
|
+
provider = connected.provider
|
|
344
|
+
session = await runtime.getAutomationPage(browser, {
|
|
345
|
+
reuseDefaultContext: false,
|
|
346
|
+
contextOptions: {
|
|
347
|
+
locale: "ko-KR",
|
|
348
|
+
timezoneId: "Asia/Seoul",
|
|
349
|
+
viewport: { width: 1280, height: 900 }
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
return await work(session.page, { provider })
|
|
353
|
+
} catch (error) {
|
|
354
|
+
throw wrapBrowserError(error)
|
|
355
|
+
} finally {
|
|
356
|
+
if (session && runtime.cleanupAutomationPage) await runtime.cleanupAutomationPage(session).catch(() => {})
|
|
357
|
+
if (browser && typeof browser.disconnect === "function") await browser.disconnect().catch(() => {})
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function getSubsidyStatus(options = {}) {
|
|
362
|
+
const vehicle = resolveVehicleType(options.vehicleType)
|
|
363
|
+
const year = Number(options.year || new Date().getFullYear())
|
|
364
|
+
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS
|
|
365
|
+
|
|
366
|
+
return withAutomationPage(options, async (page, browserInfo) => {
|
|
367
|
+
await page.goto(STATUS_URL, { waitUntil: "domcontentloaded", timeout: timeoutMs })
|
|
368
|
+
await assertPublicStatusPage(page, timeoutMs)
|
|
369
|
+
await chooseVehicleAndYear(page, vehicle, year, timeoutMs)
|
|
370
|
+
const region = await resolveRegion(page, options.region, timeoutMs)
|
|
371
|
+
const rawRows = await clickSearchAndReadRows(page, region, timeoutMs)
|
|
372
|
+
const rows = parseStatusRows(rawRows, {
|
|
373
|
+
localName: region.localName,
|
|
374
|
+
vehicleType: vehicle.key
|
|
375
|
+
})
|
|
376
|
+
let result = buildStatusResult({
|
|
377
|
+
query: {
|
|
378
|
+
region: options.region,
|
|
379
|
+
year,
|
|
380
|
+
vehicle_type: vehicle.key,
|
|
381
|
+
category: options.category || "all",
|
|
382
|
+
model: options.model || null
|
|
383
|
+
},
|
|
384
|
+
region,
|
|
385
|
+
rows,
|
|
386
|
+
sourceUrl: STATUS_URL
|
|
387
|
+
})
|
|
388
|
+
result.browser_provider = browserInfo.provider
|
|
389
|
+
|
|
390
|
+
if (options.model) {
|
|
391
|
+
try {
|
|
392
|
+
const modelResult = await readModelSubsidies(page, { region, vehicle, year, model: options.model, timeoutMs })
|
|
393
|
+
if (!modelResult.items.length) {
|
|
394
|
+
result.warnings.push(`"${options.model}" 모델을 지자체 차종별 보조금 표에서 찾지 못했습니다.`)
|
|
395
|
+
} else {
|
|
396
|
+
result = attachModelEstimate(result, modelResult.items[0])
|
|
397
|
+
result.model_candidates = modelResult.items
|
|
398
|
+
}
|
|
399
|
+
} catch (error) {
|
|
400
|
+
result.warnings.push(`모델별 보조금 조회 실패: ${error.message}`)
|
|
401
|
+
result.model_lookup_error = { code: error.code || "MODEL_LOOKUP_FAILED", message: error.message }
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return result
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function searchRegions(options = {}) {
|
|
409
|
+
const query = normalizeText(options.query)
|
|
410
|
+
if (!query) throw createError("REGION_REQUIRED", "검색할 지역명을 입력하세요.")
|
|
411
|
+
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS
|
|
412
|
+
return withAutomationPage(options, async (page) => {
|
|
413
|
+
await page.goto(STATUS_URL, { waitUntil: "domcontentloaded", timeout: timeoutMs })
|
|
414
|
+
await assertPublicStatusPage(page, timeoutMs)
|
|
415
|
+
const sidoOptions = (await evaluateDom(page, "options", { selector: "#localDo_cd" }))
|
|
416
|
+
.filter((option) => option.value && !option.disabled)
|
|
417
|
+
const matches = []
|
|
418
|
+
for (const sido of sidoOptions) {
|
|
419
|
+
const before = JSON.stringify(await localOptions(page))
|
|
420
|
+
const selection = await selectValue(page, "#localDo_cd", sido, timeoutMs)
|
|
421
|
+
const locals = selection.changed ? await waitForLocalOptions(page, before, timeoutMs) : await localOptions(page)
|
|
422
|
+
for (const local of findLocalOptions(query, locals)) {
|
|
423
|
+
matches.push({
|
|
424
|
+
sido_name: sido.label,
|
|
425
|
+
sido_code: sido.value,
|
|
426
|
+
local_name: local.label,
|
|
427
|
+
local_code: local.value
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { query, items: matches, source_url: STATUS_URL }
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
module.exports = {
|
|
436
|
+
DEFAULT_TIMEOUT_MS,
|
|
437
|
+
assertPublicStatusPage,
|
|
438
|
+
chooseVehicleAndYear,
|
|
439
|
+
clickSearchAndReadRows,
|
|
440
|
+
evaluateDom,
|
|
441
|
+
findLocalOptions,
|
|
442
|
+
findSidoOption,
|
|
443
|
+
getSubsidyStatus,
|
|
444
|
+
readModelSubsidies,
|
|
445
|
+
resolveRegion,
|
|
446
|
+
searchRegions,
|
|
447
|
+
waitFor,
|
|
448
|
+
withAutomationPage
|
|
449
|
+
}
|