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