k-skill 实战:用 ev-subsidy-status 无头查询韩国地区电动汽车购车补贴余量
【免费下载链接】k-skill한국인을 위한 스킬 모음집 - 에이전트를 한국인으로项目地址: https://gitcode.com/GitHub_Trending/ks/k-skill
韩国环境部(환경부)"无公害车综合网站"(무공해차 통합누리집)会公开各地区电动汽车购车补贴的申请、交付与剩余数量,但页面正文受pnp4web/penc脚本保护,且接口通常依赖人工操作。k-skill 项目中的ev-subsidy-status技能封装了同名 npm 包(源码位于 packages/ev-subsidy-status),在不登录、不开浏览器、不配置 API Key 的情况下,直接解析官方公开响应并恢复受保护的 HTML,从而查询特定地区、特定车种(电动乘用车/货车/客车)的补贴余量、公告状态与模型级换算金额。读完本文,你将掌握该技能的完整命令用法、pnp4web无代码执行的解码原理、公告状态判定优先级与全部失败模式,可直接用于本地查询或作为 Agent 技能接入。
技能定位与适用场景
ev-subsidy-status 在仓库中既是一个 k-skill(ev-subsidy-status/skill.json),也是一个可独立安装的 npm CLI 包。其定位非常明确:只读查询韩国地方自治团体发布的电动汽车购车补贴"民간공고(民间公告)、접수(受理)、출고(交付)、출고잔여(交付剩余)"数量与公告状态,不涉及申请、支付或任何写操作。
技能元数据(见 ev-subsidy-status/SKILL.md)将该技能归类为local-info,面向ko-KR用户,典型问题是"我家所在地区的电动乘用车补贴还剩多少、还能不能申请、某车型能拿多少补贴"。在 Dolshoi 等运行时中,它还会基于官方页面继续完成后续动作;在通用环境中则只负责查询与汇总。
核心卖点:基本查询无需浏览器、API 密钥、登录或代理。只有在官方页面结构变更需要诊断、或需要精确读取模型级补贴时,才可选择接入用户自行启动的 Aside Browser、BrowserOS 或 Chrome/Chromium CDP 会话。由于该公开页面本身是无密钥(keyless)公开接口,因此不经过k-skill-proxy代理转发,减少了链路复杂度。
前置条件与公共访问路径
运行环境要求
- Node.js 18+(package.json 中
engines.node明确声明>=18,因为代码使用全局fetch与URLSearchParams); ev-subsidy-statusnpm 包(仓库内版本为 0.2.0,通过npx ev-subsidy-status ...直接调用)。
官方公开端点
技能操作的两个官方页面:
- 基础(全国)支付现状页(POST 提交查询参数):
https://ev.or.kr/nportal/buySupprt/initSubsidyPaymentCheckAction.do- 模型别补贴弹窗页(POST 提交查询参数):
POST /nportal/buySupprt/psPopupLocalCarModelPrice.do year=<year>&local_cd=<region-code>&car_type=<11|12|13>这两个端点在源码 constants.js 中分别定义为STATUS_URL与MODEL_SUBSIDY_PATH。car_type的取值范围11|12|13与三种车种一一对应:11=电动乘用车(승용),12=电动货车(화물),13=电动客车(승합)。
已确认的 DOM 结构
技能在开发时确认了官方页面的关键 DOM 选择器:
- 市道(시도)下拉:
#localDo_cd - 市郡区(시군구)下拉:
#local_cd1 - 结果表格列:시도(市道)、지역구분(地区区分)、차종구분(车种区分)、공고파일(公告文件)、접수방법(受理方式)、민간공고대수(民间公告数量)、접수대수(受理数量)、출고대수(交付数量)、출고잔여대수(交付剩余数量)、비고(备注)
这些选择器是技能解析表格、识别"包含출고잔여列的目标表格"的依据(见 http.js 中的extractStatusRows:遍历所有<table>,找到文本包含출고잔여的那张表)。
核心原理:pnp4web 保护页面的无代码执行解码
页面虽然无需登录即可访问,但正文被pnp4web/penc混淆脚本保护——直接抓取得到的只是"空壳"(shell HTML),真正的支付现状表格被打包进了一个受保护的 payload,需要通过官方下发的pnp4web.js字符表才能还原。
ev-subsidy-status 的默认传输路径(direct-http)在 pnp.js 中实现了完整的解码流程,其安全底线是:绝不执行远程 JavaScript(不使用eval/vm),只做字符替换级解析。具体步骤如下:
- 识别空壳响应:通过正则
/meta[^>]+name=['"]penc['"]/i判断页面是否带penc元标记,只有带该标记才需要解码(无标记则直接返回 HTML)。 - 提取
pnp4web.jsURL:用正则匹配<script name="pnp4web" src="...">,将相对路径基于STATUS_URL解析为绝对地址。 - 下载官方字符表源码:抓取
pnp4web.js文本(带缓存cachedPnp,避免重复请求;当 URL 变化或传入自定义 fetch 时重新获取)。 - 解析字符表:先抽取
var In=[...]数组中的字符串分片(逐一做\xHH/\uHHHH十六进制与 Unicode 转义还原),再按o0:...到o6:...七个表达式从分片索引中拼出 7 组基础字母表;任一字母表长度不足 64 即判定UPSTREAM_DECODE_FAILED。 - 还原受保护 payload:从空壳页面的
onload="_0xac('...')"中提取加密串,其首字符为字母表索引、次字符为轮换量(rotation),用"轮换后的字母表"按 Base64 变体规则逐 4 字符解出 3 字节,最终Buffer.toString("utf8")得到真实 HTML。
从源码结构看,这种实现把"脚本保护"降维成"静态字符表解析",既绕过了对浏览器渲染的依赖,也避免了在 Node 进程里执行第三方混淆代码的安全风险。官方脚本或 payload 格式一旦变化,技能会以UPSTREAM_DECODE_FAILED(找不到字符表、空 payload、非法标识符等)明确报错,而不是静默返回错误数据。
解码完成后,HTTP 路径还做了一件事:检查解码后 HTML 中<option selected>的年份是否与请求年份一致,不一致则抛YEAR_NOT_AVAILABLE(http.js 的fetchDecodedStatusHtml),从源头杜绝"请求 2026 却拿到 2025 数据"的错位。
CLI 安装与命令总览
包通过bin字段暴露ev-subsidy-status命令(入口 cli.js),支持status(查询支付现状)与regions(搜索地区候选)两个子命令。完整参数如下:
| 参数 | 别名 | 说明 |
|---|---|---|
--region <지역> | -r | 市道 + 市郡区,如경기 성남시(status 命令必填) |
--query <검색어> | -q | regions 命令的地区搜索词 |
--vehicle <type> | — | 车种:passenger/승용、cargo/화물、bus/승합,默认passenger |
--year <year> | — | 基准年份,默认 Asia/Seoul 当前年 |
--model <model> | — | 查询模型别补贴与剩余换算值 |
--transport <t> | — | direct-http(默认)或browser |
--provider <p> | — | browser 传输时的auto/aside/browseros/chrome-cdp |
--cdp-url <url> | — | 用户已启动的 Chrome/BrowserOS CDP 地址 |
--timeout <ms> | — | 页面等待超时(毫秒) |
--json | — | 输出 JSON(默认输出人类可读文本) |
命令行解析在parseArgs中实现,同时支持--vehicle/--vehicle-type两种写法;非 JSON 输出由formatStatus生成,其中数量格式化函数formatCount会区分우선(优先)/법인·기관(法人·机构)/택시(出租车)/중소기업(中小企业)/일반(一般)明细。
工作流一:地区与车种归一化
技能的第一步是规范化查询参数(对应原指令 Workflow 第 1 步):
- 地区:尽量以
시도 + 시군구形式输入,如경기 성남시。 - 重名歧义:像
중구(首尔、大田、釜山、仁川都有)、강서구这类同名市郡区,先用regions命令列出候选,再让用户指定市道:
npx ev-subsidy-status regions --query 중구- 车种:默认
passenger(전기승용),支持passenger/승용、cargo/화물、bus/승합三种。 - 年份:默认取 Asia/Seoul 时区的当前年份。
地区与车种归一化在源码中有完整映射(constants.js):
VEHICLE_TYPES定义了三种车种及其carTypeCode(11/12/13)与别名表,resolveVehicleType对别名做小写归一后匹配,无法识别时抛出VEHICLE_TYPE_NOT_AVAILABLE;SIDO_ALIASES收录了 17 个市道的全部写法(如서울/서울시/서울특별시、강원/강원도/강원특별자치도),canonicalSidoFromQuery用它把用户输入收敛为标准市道名;localMatches支持去掉시/군/구后缀的模糊匹配(如성남可命中성남시),但必须short.length >= 2防止过度匹配。
地区解析最终落到resolveRegionFromRows(http.js):在全表行中按市道 + 市郡区双重过滤,去重后若为 0 行抛REGION_NOT_FOUND,多于 1 个抛REGION_AMBIGUOUS(并在details.candidates中给出全部候选,方便用户二次选择),恰好 1 个才确定地区代码。local_code从公告下载函数goDownloadFile(...)的第二个参数中读取,市道代码则由其前两位拼00得出。
工作流二:查询官方支付现状
确定地区后,执行 status 查询:
npx ev-subsidy-status status \ --region "경기 성남시" \ --vehicle passenger \ --year 2026 \ --json该命令内部(getSubsidyStatusHttp)会向initSubsidyPaymentCheckAction.doPOST 提交car_type、year1、localDo_cd=all、local_cd1=all(请求全国行,再在客户端过滤目标地区)。正常结果中transport应为direct-http,说明走的是无需浏览器的默认路径。
关键返回字段
| 字段 | 含义 |
|---|---|
status.notice_count | 民间公告数量 |
status.application_count | 受理(申请)数量 |
status.delivered_count | 交付数量 |
status.delivery_remaining_count | 交付剩余数量 |
availability.label | 公告状态判定(open/scheduled/closed/unknown 等) |
status.note | 地方自治团体备注原文 |
source.fetched_at | KST 时区查询时间 |
数值单元格以전체(总计) / 우선순위(优先) / 법인·기관(法人·机构) / 예약 대상군(预约对象群) / 일반(一般)五档保留:乘用车的预约对象群是taxi(出租车),货车的对应别名为small_business(中小企业),CLI 文本输出中会按车种动态插入相应明细标签。
值得注意的鲁棒性处理:如果目标群体(대상군)数值为负数、或整体与部分之和不等,技能不会擅自将值"修正"为 0,而是保留原始数字并附加警告返回,把异常如实暴露给上层(原指令 Failure modes 结尾明确这一点)。
工作流三:备注优先于数字的公告状态判定
官方页面只给数量,不给明确的"还能不能申请"结论,因此技能实现了备注文本优先的状态判定(availability.js 的classifyAvailability),判定优先级如下:
- 备注含
마감(截止)/소진(售罄)/접수 종료(受理结束)/신청 종료(申请结束)→closed - 备注含
접수 예정(受理预定)/추경 예정(追加预算预定)/추가 공고 예정(追加公告预定)/재공고 예정(重新公告预定)→scheduled - 备注含
접수 중(受理中)/신청 기간(申请期间)/접수 기간(受理期间)/신청 가능(可申请)→open - 无上述明示语、但剩余数量为正数 →
unknown_with_remaining_count - 无任何判定依据 →
unknown
classifyAvailability同时输出basis(判定依据,如note:closed、remaining_count:positive)与warnings。一个关键规则是:即便剩余数量为正,只要备注明确写了"截止/售罄",就不得声称可申请——此时会生成警告 "출고잔여대수는 양수지만 지자체 비고에는 마감 또는 소진으로 표시됩니다."(剩余数量虽为正数,但备注标注截止或售罄),并仍以closed为准。
工作流四:模型指定时的补贴与换算
官方页面只提供"数量",不提供"金额"。当用户关心具体车型能拿多少钱时,可通过--model触发模型级查询:
npx ev-subsidy-status status \ --region "서울 강남구" \ --vehicle passenger \ --model "모델명" \ --json即使走直接 HTTP 路径,技能也会用同一个年份、地区代码与车种调用psPopupLocalCarModelPrice.do,解析模型表(http.js 的extractModelSubsidySnapshot会筛选表头同时包含제조사(制造商)、모델(型号)、국비(国费)的表格,并取行数最多的候选),逐行解析制造商、模型、国费、地方费与合计。
匹配处理有三条分支(parseModelSubsidyRows+attachModelEstimate):
- 匹配到多个细分车型:不擅自任选其一,而是在
model_subsidy_candidates中返回全部候选及各自的 1 台补贴与剩余换算值,并追加警告提示"按配置(trim)区分确认"; - 恰好匹配一个细分车型:额外返回
model_subsidy与remaining_budget.model_equivalent_estimate_krw; - 匹配不到:保留地区级数量结果,设置
model_lookup_error并输出警告,模型查询失败不影响地区数量结果。
换算值的含义与边界
remaining_budget.model_equivalent_estimate_krw的计算假设是:
官方交付剩余数量 × 所选模型的(国费+地方费)其实现位于 estimate.js 的estimateModelEquivalent:当剩余数量或单台补贴额不是有限正数时,返回exact_available: false、exact_amount_krw: null的"不可用预算"结构;否则用remainingCount * subsidyPerVehicleKrw取整,并在estimate_assumptions中写明三条假设(全部剩余数都分配给所选模型、按确认的国费+地方费计算、不含按购买者特征的追加支持与对象群间数量转换)。
因此该换算值不是地方自治团体的精确预算余额:模型别金额、购买者追加支持、数量转换、受理后的预约/取消都会使其偏离实际可用预算。回答用户时必须明确这是"换算值/估算"而非"精确可用金额",这也是下一条工作流的基本原则。
工作流五:保守作答的输出规范
最终答复必须包含(原指令 Workflow 第 5 步):
- 地区、车种、基准年份;
- 民间公告/受理/交付/交付剩余四项数量;
- 公告状态与备注核心语句;
- 若有模型换算值,说明计算假设;
- 固定警告语:"출고잔여대수는 실제 신청 가능 대수 및 정확한 원화 잔액과 다를 수 있음"(交付剩余数量可能与实际可申请数量及精确韩元余额不同);
- 官方来源 URL 与 KST 查询时间。
CLI 的formatStatus默认输出已经内置了以上要素,包括末尾的注意语与출처: <source.url>行;--json模式下则由调用方按相同规则组织。在exact_available=false的场景,必须同时给出原因,不能把估算伪装成事实。
可选浏览器路径:诊断与模型查询的兜底
默认direct-http无需浏览器,但在两种情况下可显式切换到浏览器传输:官方页面结构变更需要诊断,或需要更可靠地读取模型别换算值。命令示例:
npx ev-subsidy-status status --region "경기 성남시" --transport browser --provider aside npx ev-subsidy-status status --region "경기 성남시" --transport browser --provider browseros npx ev-subsidy-status status --region "경기 성남시" --transport browser --provider chrome-cdp浏览器路径的行为约束(原指令 Optional browser behavior)非常严格:
- 在
domcontentloaded事件后,显式等待目标选择器与地区行出现; - 不使用
networkidle等待策略——因为pnp4web有后台请求,networkidle 会导致永久等待; - 通过渲染后的
<option>的 label/value动态解析地区代码; - 不关闭用户既有的标签页与配置文件(profile);
- 只清理技能自己创建的页面/上下文,并仅对受支持的 client 执行 disconnect;
- 绝不绕过登录、CAPTCHA、支付、电子签名、最终提交等边界。
浏览器接入基于k-skill-browser-runtime(见 package.json 的依赖声明),连接用户已启动的 Aside Browser、BrowserOS 或 Chrome CDP 会话;若没有可连接的浏览器,则报BROWSER_UNAVAILABLE。错误包装逻辑(errors.js 的wrapBrowserError)会把上游CAPTCHA_DETECTED、AUTH_REQUIRED、UNKNOWN_PROVIDER、UNAVAILABLE/PLAYWRIGHT_UNAVAILABLE分别映射为技能级错误码,其余统一归为UPSTREAM_FAILED,并保留upstream_code便于排查。
完成标准(Done when)
一次成功的查询应满足:
- 请求的地区与车种与官方表格中的行一致;
- 返回公告/受理/交付/交付剩余数值与地方自治团体备注;
- 显式截止语句优先于正数剩余数量;
- 无法确知精确韩元预算余额时,返回
exact_available=false并说明原因; - 展示来源 URL 与 KST 查询时间。
失败模式全表
技能以稳定错误码暴露全部异常(原指令 Failure modes,与 errors.js 及 http.js 的实现一致):
| 错误码 | 触发条件 |
|---|---|
REGION_REQUIRED | 未提供地区输入 |
REGION_AMBIGUOUS | 同名市郡区出现在多个市道,需用户指定市道 |
REGION_NOT_FOUND | 地区不在官方地区选项中 |
YEAR_NOT_AVAILABLE | 请求年份不在页面选择列表中 |
VEHICLE_TYPE_NOT_AVAILABLE | 请求的车种不受支持或页面中找不到 |
BROWSER_UNAVAILABLE | 没有可连接的用户浏览器 |
UPSTREAM_BLOCKED | 官方返回空壳、拦截或异常页面 |
CAPTCHA_DETECTED | 出现 CAPTCHA,技能不绕过 |
AUTH_REQUIRED | 公开页面变为登录流程,技能不绕过 |
RESULT_EMPTY | 目标地区无结果行 |
DOM_CHANGED | 官方页面选择器或表格结构变化 |
UPSTREAM_DECODE_FAILED | pnp4web字符表或保护 payload 格式变化 |
MODEL_LOOKUP_FAILED | 模型别补贴表读取失败 |
UPSTREAM_TIMEOUT | 官方站点响应超时(默认超时 30000ms,见 http.js) |
CLI 出错时以 JSON 输出{ error: { code, message, details } }(formatError),便于 Agent 或上层脚本结构化处理;EvSubsidyError实例携带code、message、details与可选cause。
测试与质量保障
该技能在仓库中配有完整测试:node --test驱动 test/index.test.js、test/http.test.js、test/browser.test.js,覆盖状态聚合、HTTP 解码链路与浏览器行为;lint脚本对所有源码与测试文件做node --check语法校验。感兴趣的可直接运行:
cd packages/ev-subsidy-status && npm test总而言之,ev-subsidy-status 的价值在于把"被脚本保护的官方公开数据"安全、可重复、无浏览器地转化为结构化 JSON:先归一化地区与车种,再以备注优先判定公告状态,最后在需要时给出带假设说明的模型换算值——整个过程对上游格式变化以明确错误码快速暴露,保证了查询结果的可审计性与数据真实性。
【免费下载链接】k-skill한국인을 위한 스킬 모음집 - 에이전트를 한국인으로项目地址: https://gitcode.com/GitHub_Trending/ks/k-skill
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考