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/src/parse.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const { classifyAvailability } = require("./availability")
|
|
4
|
+
const { buildUnavailableBudget, estimateModelEquivalent } = require("./estimate")
|
|
5
|
+
|
|
6
|
+
const COUNT_KEYS = Object.freeze(["total", "priority", "corporate", "reserved", "general"])
|
|
7
|
+
|
|
8
|
+
function normalizeText(value) {
|
|
9
|
+
return String(value == null ? "" : value).replace(/\s+/g, " ").trim()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeRegionKey(value) {
|
|
13
|
+
return normalizeText(value)
|
|
14
|
+
.replace(/\s+/g, "")
|
|
15
|
+
.replace(/경기도/g, "경기")
|
|
16
|
+
.replace(/강원(?:특별자치)?도/g, "강원")
|
|
17
|
+
.replace(/충청북도/g, "충북")
|
|
18
|
+
.replace(/충청남도/g, "충남")
|
|
19
|
+
.replace(/전라북도|전북특별자치도/g, "전북")
|
|
20
|
+
.replace(/전라남도/g, "전남")
|
|
21
|
+
.replace(/경상북도/g, "경북")
|
|
22
|
+
.replace(/경상남도/g, "경남")
|
|
23
|
+
.replace(/제주특별자치도|제주도/g, "제주")
|
|
24
|
+
.replace(/특별자치도|특별자치시|광역시|특별시/g, "")
|
|
25
|
+
.replace(/도$/g, "")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseNumberCell(value) {
|
|
29
|
+
const text = normalizeText(value)
|
|
30
|
+
const matches = text.match(/-?[\d,]+/g) || []
|
|
31
|
+
const numbers = matches.slice(0, COUNT_KEYS.length).map((item) => Number(item.replace(/,/g, "")))
|
|
32
|
+
while (numbers.length < COUNT_KEYS.length) numbers.push(null)
|
|
33
|
+
return Object.fromEntries(COUNT_KEYS.map((key, index) => [key, Number.isFinite(numbers[index]) ? numbers[index] : null]))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function categoryCountsForVehicle(counts, vehicleType) {
|
|
37
|
+
const output = { ...counts }
|
|
38
|
+
if (vehicleType === "passenger") output.taxi = counts.reserved
|
|
39
|
+
else if (vehicleType === "cargo") output.small_business = counts.reserved
|
|
40
|
+
return output
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function countWarnings(name, counts) {
|
|
44
|
+
const warnings = []
|
|
45
|
+
const parts = [counts.priority, counts.corporate, counts.reserved, counts.general]
|
|
46
|
+
const finiteParts = parts.filter(Number.isFinite)
|
|
47
|
+
if (finiteParts.some((value) => value < 0)) {
|
|
48
|
+
warnings.push(`${name} 대상군 값에 음수가 포함되어 있습니다. 물량 전환 또는 초과 집행 여부를 비고와 함께 확인하세요.`)
|
|
49
|
+
}
|
|
50
|
+
if (Number.isFinite(counts.total) && finiteParts.length === parts.length) {
|
|
51
|
+
const sum = finiteParts.reduce((total, value) => total + value, 0)
|
|
52
|
+
if (sum !== counts.total) {
|
|
53
|
+
warnings.push(`${name} 전체 ${counts.total}대와 대상군 합계 ${sum}대가 일치하지 않습니다.`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return warnings
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseStatusRows(rawRows, options = {}) {
|
|
60
|
+
const target = normalizeRegionKey(options.localName || options.region)
|
|
61
|
+
const vehicleType = options.vehicleType || "passenger"
|
|
62
|
+
const parsed = []
|
|
63
|
+
|
|
64
|
+
for (const raw of Array.isArray(rawRows) ? rawRows : []) {
|
|
65
|
+
const cells = Array.isArray(raw.cells) ? raw.cells.map(normalizeText) : []
|
|
66
|
+
if (cells.length < 10) continue
|
|
67
|
+
if (target && normalizeRegionKey(cells[1]) !== target) continue
|
|
68
|
+
|
|
69
|
+
const noticeCount = categoryCountsForVehicle(parseNumberCell(cells[5]), vehicleType)
|
|
70
|
+
const applicationCount = categoryCountsForVehicle(parseNumberCell(cells[6]), vehicleType)
|
|
71
|
+
const deliveredCount = categoryCountsForVehicle(parseNumberCell(cells[7]), vehicleType)
|
|
72
|
+
const remainingCount = categoryCountsForVehicle(parseNumberCell(cells[8]), vehicleType)
|
|
73
|
+
const warnings = [
|
|
74
|
+
...countWarnings("민간공고대수", noticeCount),
|
|
75
|
+
...countWarnings("접수대수", applicationCount),
|
|
76
|
+
...countWarnings("출고대수", deliveredCount),
|
|
77
|
+
...countWarnings("출고잔여대수", remainingCount)
|
|
78
|
+
]
|
|
79
|
+
if (
|
|
80
|
+
Number.isFinite(noticeCount.total) &&
|
|
81
|
+
Number.isFinite(deliveredCount.total) &&
|
|
82
|
+
Number.isFinite(remainingCount.total) &&
|
|
83
|
+
noticeCount.total - deliveredCount.total !== remainingCount.total
|
|
84
|
+
) {
|
|
85
|
+
warnings.push("출고잔여대수가 민간공고대수-출고대수와 일치하지 않습니다.")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
parsed.push({
|
|
89
|
+
sido_name: cells[0],
|
|
90
|
+
local_name: cells[1],
|
|
91
|
+
vehicle_label: cells[2],
|
|
92
|
+
notice_files: Array.isArray(raw.notice_files) ? raw.notice_files : [],
|
|
93
|
+
application_method: cells[4],
|
|
94
|
+
notice_count: noticeCount,
|
|
95
|
+
application_count: applicationCount,
|
|
96
|
+
delivered_count: deliveredCount,
|
|
97
|
+
delivery_remaining_count: remainingCount,
|
|
98
|
+
note: cells[9],
|
|
99
|
+
warnings
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
return parsed
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatKst(date = new Date()) {
|
|
106
|
+
const shifted = new Date(date.getTime() + 9 * 60 * 60 * 1000)
|
|
107
|
+
return shifted.toISOString().replace("Z", "+09:00")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildStatusResult({ query, region, rows, sourceUrl, fetchedAt = new Date() }) {
|
|
111
|
+
if (!rows.length) {
|
|
112
|
+
const error = new Error(`지급현황 표에서 ${region.localName} 행을 찾지 못했습니다.`)
|
|
113
|
+
error.code = "RESULT_EMPTY"
|
|
114
|
+
throw error
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const primary = rows[0]
|
|
118
|
+
const remaining = primary.delivery_remaining_count.total
|
|
119
|
+
const availability = classifyAvailability(primary.note, remaining)
|
|
120
|
+
const pendingApplications = Number.isFinite(primary.application_count.total) && Number.isFinite(primary.delivered_count.total)
|
|
121
|
+
? primary.application_count.total - primary.delivered_count.total
|
|
122
|
+
: null
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
query,
|
|
126
|
+
region: {
|
|
127
|
+
sido_name: primary.sido_name || region.sidoName,
|
|
128
|
+
sido_code: region.sidoCode,
|
|
129
|
+
local_name: primary.local_name || region.localName,
|
|
130
|
+
local_code: region.localCode
|
|
131
|
+
},
|
|
132
|
+
status: primary,
|
|
133
|
+
rows,
|
|
134
|
+
availability: {
|
|
135
|
+
...availability,
|
|
136
|
+
official_remaining_count: remaining,
|
|
137
|
+
pending_application_count: pendingApplications,
|
|
138
|
+
actual_application_count_known: false
|
|
139
|
+
},
|
|
140
|
+
remaining_budget: buildUnavailableBudget("차종·구매자별 지급액과 예약·취소 내역이 달라 공개 지급현황만으로 정확한 원화 잔액을 계산할 수 없습니다."),
|
|
141
|
+
source: {
|
|
142
|
+
name: "환경부 무공해차 통합누리집",
|
|
143
|
+
url: sourceUrl,
|
|
144
|
+
fetched_at: formatKst(fetchedAt)
|
|
145
|
+
},
|
|
146
|
+
warnings: [...primary.warnings, ...availability.warnings]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseMoneyKrw(value, defaultUnit = "만원") {
|
|
151
|
+
const text = normalizeText(value)
|
|
152
|
+
if (!text || /^[-–—]$/.test(text)) return null
|
|
153
|
+
const match = text.match(/-?[\d,.]+/)
|
|
154
|
+
if (!match) return null
|
|
155
|
+
const number = Number(match[0].replace(/,/g, ""))
|
|
156
|
+
if (!Number.isFinite(number)) return null
|
|
157
|
+
if (/억원?/.test(text)) return Math.round(number * 100000000)
|
|
158
|
+
if (/만원?/.test(text) || (!/[원억만]/.test(text) && defaultUnit === "만원")) return Math.round(number * 10000)
|
|
159
|
+
return Math.round(number)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseModelSubsidyRows(snapshot, options = {}) {
|
|
163
|
+
const headers = Array.isArray(snapshot && snapshot.headers) ? snapshot.headers.map(normalizeText) : []
|
|
164
|
+
const rows = Array.isArray(snapshot && snapshot.rows) ? snapshot.rows : []
|
|
165
|
+
const headerText = headers.join(" ")
|
|
166
|
+
const defaultUnit = headerText.includes("만원") || !headerText.includes("원") ? "만원" : "원"
|
|
167
|
+
const query = normalizeText(options.model).toLowerCase()
|
|
168
|
+
|
|
169
|
+
const items = rows.flatMap((cellsValue) => {
|
|
170
|
+
const cells = Array.isArray(cellsValue) ? cellsValue.map(normalizeText) : []
|
|
171
|
+
if (cells.length < 6) return []
|
|
172
|
+
const item = {
|
|
173
|
+
vehicle_class: cells[0],
|
|
174
|
+
manufacturer: cells[1],
|
|
175
|
+
model: cells[2],
|
|
176
|
+
national_subsidy_krw: parseMoneyKrw(cells[3], defaultUnit),
|
|
177
|
+
local_subsidy_krw: parseMoneyKrw(cells[4], defaultUnit),
|
|
178
|
+
total_subsidy_krw: parseMoneyKrw(cells[5], defaultUnit),
|
|
179
|
+
raw: cells
|
|
180
|
+
}
|
|
181
|
+
if (!query) return [item]
|
|
182
|
+
const haystack = `${item.manufacturer} ${item.model}`.toLowerCase()
|
|
183
|
+
return haystack.includes(query) ? [item] : []
|
|
184
|
+
})
|
|
185
|
+
return { headers, items }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function attachModelEstimate(result, modelItem) {
|
|
189
|
+
if (!modelItem) return result
|
|
190
|
+
const subsidy = Number.isFinite(modelItem.total_subsidy_krw)
|
|
191
|
+
? modelItem.total_subsidy_krw
|
|
192
|
+
: (modelItem.national_subsidy_krw || 0) + (modelItem.local_subsidy_krw || 0)
|
|
193
|
+
return {
|
|
194
|
+
...result,
|
|
195
|
+
model_subsidy: modelItem,
|
|
196
|
+
remaining_budget: estimateModelEquivalent({
|
|
197
|
+
remainingCount: result.availability.official_remaining_count,
|
|
198
|
+
subsidyPerVehicleKrw: subsidy
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
COUNT_KEYS,
|
|
205
|
+
attachModelEstimate,
|
|
206
|
+
buildStatusResult,
|
|
207
|
+
categoryCountsForVehicle,
|
|
208
|
+
formatKst,
|
|
209
|
+
normalizeRegionKey,
|
|
210
|
+
normalizeText,
|
|
211
|
+
parseModelSubsidyRows,
|
|
212
|
+
parseMoneyKrw,
|
|
213
|
+
parseNumberCell,
|
|
214
|
+
parseStatusRows
|
|
215
|
+
}
|
package/src/pnp.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const { createError } = require("./errors")
|
|
4
|
+
|
|
5
|
+
const PNP_SCRIPT_PATTERN = /<script[^>]+name=['"]pnp4web['"][^>]+src=['"]([^'"]+)['"]/i
|
|
6
|
+
const PROTECTED_PAYLOAD_PATTERN = /onload=['"][^'"]*_0xac\(["']?([A-Za-z0-9+/=]+)["']?\)/i
|
|
7
|
+
|
|
8
|
+
function decodeJavascriptString(value) {
|
|
9
|
+
return value
|
|
10
|
+
.replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
11
|
+
.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
12
|
+
.replace(/\\"/g, "\"")
|
|
13
|
+
.replace(/\\\\/g, "\\")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parsePnpAlphabets(source) {
|
|
17
|
+
const arrayMatch = source.match(/var In=\[([\s\S]*?)\],zn=/) ||
|
|
18
|
+
source.match(/var In=Array\(([\s\S]*?)\),zn=/)
|
|
19
|
+
if (!arrayMatch) {
|
|
20
|
+
throw createError("UPSTREAM_DECODE_FAILED", "pnp4web 문자표를 찾지 못했습니다.")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fragments = []
|
|
24
|
+
const stringPattern = /"((?:\\.|[^"\\])*)"/g
|
|
25
|
+
let stringMatch
|
|
26
|
+
while ((stringMatch = stringPattern.exec(arrayMatch[1]))) {
|
|
27
|
+
fragments.push(decodeJavascriptString(stringMatch[1]))
|
|
28
|
+
}
|
|
29
|
+
if (!fragments.length) {
|
|
30
|
+
throw createError("UPSTREAM_DECODE_FAILED", "pnp4web 문자표가 비어 있습니다.")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const alphabets = []
|
|
34
|
+
for (let index = 0; index <= 6; index += 1) {
|
|
35
|
+
const expressionMatch = source.match(new RegExp(`o${index}:([^,}]+)`))
|
|
36
|
+
if (!expressionMatch) {
|
|
37
|
+
throw createError("UPSTREAM_DECODE_FAILED", `pnp4web o${index} 문자표를 찾지 못했습니다.`)
|
|
38
|
+
}
|
|
39
|
+
const fragmentIndexes = Array.from(expressionMatch[1].matchAll(/In\[(\d+)\]/g), (match) => Number(match[1]))
|
|
40
|
+
const alphabet = fragmentIndexes.map((fragmentIndex) => fragments[fragmentIndex]).join("")
|
|
41
|
+
if (alphabet.length < 64) {
|
|
42
|
+
throw createError("UPSTREAM_DECODE_FAILED", `pnp4web o${index} 문자표 길이가 올바르지 않습니다.`)
|
|
43
|
+
}
|
|
44
|
+
alphabets.push(alphabet)
|
|
45
|
+
}
|
|
46
|
+
return alphabets
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function decodePnpPayload(payload, alphabets) {
|
|
50
|
+
if (typeof payload !== "string" || payload.length < 3) {
|
|
51
|
+
throw createError("UPSTREAM_DECODE_FAILED", "보호된 본문 payload가 비어 있습니다.")
|
|
52
|
+
}
|
|
53
|
+
const alphabetIndex = Number(payload[0])
|
|
54
|
+
const rotation = Number(payload[1])
|
|
55
|
+
const baseAlphabet = alphabets[alphabetIndex]
|
|
56
|
+
if (!baseAlphabet || !Number.isInteger(rotation)) {
|
|
57
|
+
throw createError("UPSTREAM_DECODE_FAILED", "보호된 본문의 문자표 식별자가 올바르지 않습니다.")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const alphabet = baseAlphabet.slice(rotation) + baseAlphabet.slice(0, rotation)
|
|
61
|
+
const encoded = payload.slice(2).replace(/[^A-Za-z0-9+/=]/g, "")
|
|
62
|
+
const bytes = []
|
|
63
|
+
for (let offset = 0; offset < encoded.length;) {
|
|
64
|
+
const a = alphabet.indexOf(encoded[offset++])
|
|
65
|
+
const b = alphabet.indexOf(encoded[offset++])
|
|
66
|
+
const c = alphabet.indexOf(encoded[offset++])
|
|
67
|
+
const d = alphabet.indexOf(encoded[offset++])
|
|
68
|
+
if (a < 0 || b < 0) break
|
|
69
|
+
bytes.push((a << 2) | (b >> 4))
|
|
70
|
+
if (c >= 0 && c !== 64) {
|
|
71
|
+
bytes.push(((b & 15) << 4) | (c >> 2))
|
|
72
|
+
if (d >= 0 && d !== 64) bytes.push(((c & 3) << 6) | d)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return Buffer.from(bytes)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractPnpScriptUrl(shellHtml, baseUrl) {
|
|
79
|
+
const match = shellHtml.match(PNP_SCRIPT_PATTERN)
|
|
80
|
+
if (!match) throw createError("UPSTREAM_DECODE_FAILED", "pnp4web 스크립트 URL을 찾지 못했습니다.")
|
|
81
|
+
return new URL(match[1], baseUrl).toString()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractProtectedPayload(shellHtml) {
|
|
85
|
+
const match = shellHtml.match(PROTECTED_PAYLOAD_PATTERN)
|
|
86
|
+
if (!match) throw createError("UPSTREAM_DECODE_FAILED", "보호된 공식 페이지 본문을 찾지 못했습니다.")
|
|
87
|
+
return match[1]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function decodeProtectedHtml(shellHtml, pnpSource) {
|
|
91
|
+
if (!/<meta[^>]+name=['"]penc['"]/i.test(shellHtml)) return shellHtml
|
|
92
|
+
const payload = extractProtectedPayload(shellHtml)
|
|
93
|
+
return decodePnpPayload(payload, parsePnpAlphabets(pnpSource)).toString("utf8")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
decodePnpPayload,
|
|
98
|
+
decodeProtectedHtml,
|
|
99
|
+
extractPnpScriptUrl,
|
|
100
|
+
extractProtectedPayload,
|
|
101
|
+
parsePnpAlphabets
|
|
102
|
+
}
|