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