k-skill s2b-notice-search 技能深度指南:S2B 学校市场公开公告查询的浏览器优先自动化与 HTML 解析方案
【免费下载链接】k-skill한국인을 위한 스킬 모음집 - 에이전트를 한국인으로项目地址: https://gitcode.com/GitHub_Trending/ks/k-skill
docs/features/s2b-notice-search.md描述了 k-skill 仓库中面向韩国 S2B 学校市场(www.s2b.kr)公开公告/报价请求(견적요청)的只读查询技能。该技能以浏览器优先方式打开公开查询界面,同时提供可复现的tcmo001FormPOST 表单配方(recipe)与 fixture HTML 解析器,供 AI Agent 快速、合规地获取公告元数据。读完本文,你将掌握 S2B 公告查询的输入约束、三条自动化回退路径、表单字段映射、列表/详情 HTML 解析细节,以及如何通过s2b-notice-searchnpm 包在 Node.js 中直接调用这套能力。
技能定位与能力边界
s2b-notice-search是 k-skill 仓库中面向采购场景(metadata.category: procurement、locale: ko-KR)的只读(read-only)技能,对应的技能清单位于 s2b-notice-search/skill.json,详细说明位于 s2b-notice-search/instruction.md。它的核心目标非常聚焦:
- 通过关键字、学校/机构名、区域、发布日期范围查询 S2B 公开公告候选列表;
- 将「물품(物品)/공사(工程)/용역(劳务)」以及「1인/2인 수의계약(1 人/2 人随意合同)」等条件做规范化(normalize);
- 生成提交到
/S2BNCustomer/tcmo001.do的真实tcmo001FormPOST body; - 从列表 HTML fixture 中提取公告/报价代码、标题、机构、状态、品目分类、发布日期、截止日期;
- 从详情 HTML fixture 中提取正文、公告编号、机构、合同方式、附件 action 元数据。
同时它明确声明不执行任何写操作:不做登录、报价提交、投标参与、合同、支付、我的页面自动化。这是一条关键的政策边界,也是整个技能设计的前提。
访问路径与浏览器优先的自动化顺序
S2B 公开客户公告/报价请求查询界面的入口固定为:
https://www.s2b.kr/S2BNCustomer/tcmo001.do由于 S2B 页面可能依赖浏览器 session 与表单状态(form state),自动化的执行顺序被固定为三级回退链,这一点在源码 packages/s2b-notice-search/src/index.js 的buildBrowserAutomationInstructions()中也有体现:
- Aside Browser REPL snapshot/action:打开公开页面并生成 snapshot,在浏览器界面读取搜索表单、结果表以及输入/提交/action 元数据;
- BrowserOS CDP 或本地浏览器:若 Aside 不可用,则通过 CDP 附加到用户已打开的 BrowserOS 会话(经由 k-skill-browser-runtime),或使用本地浏览器打开上述 URL,提交搜索表单后解析渲染出的列表/详情 HTML;
- Direct HTTP best-effort:仅在相同的 session cookie、referer、form token 条件与浏览器一致时才向
/S2BNCustomer/tcmo001.do发送 POST form body;一旦 session/表单状态不匹配,立即回退到浏览器路径。
对应的测试 packages/s2b-notice-search/test/index.test.js 验证了三级通道顺序为["aside-browser", "browseros-cdp-or-local-browser", "direct-http-best-effort"],确保浏览器路径始终优先于直接 HTTP。
输入约束与规范化规则
原文档给出了严格的输入限制表格,这是所有查询的前置校验:
| 条件 | 说明 |
|---|---|
| 日期 | YYYYMMDD或YYYY-MM-DD |
| 期间 | 开始日~结束日超过 3 个自然月(calendar months)即失败 |
| 品目 | 물품、공사、용역、all |
| 随意合同 | 1인、2인、all |
| 页码 | 1 以上的整数 |
在源码层,normalizeSearchOptions()(src/index.js#L41-L60)会依次完成以下规范化,任何一个非法输入都会抛出RangeError:
- 日期:
normalizeDate()(src/index.js#L178-L191)先用正则匹配YYYYMMDD或YYYY-MM-DD,再用Date.UTC反查校验是否为真实存在的日历日期(例如2026-02-31、2026-0601、202606-01均会被拒绝),最终统一输出YYYYMMDD格式; - 日期范围:
assertDateRange()(src/index.js#L193-L201)先校验dateEnd >= dateStart,再按月差计算,若超过 3 个自然月(含恰满 3 个月但结束日更大)则抛出"S2B search date range must not exceed 3 calendar months"; - 品目映射:
ITEM_TYPES表把韩文/英文/代码统一到后端取值——물품/goods/1 → "1"、공사/works/2 → "2"、용역/service(s)/3 → "3"、all/전체 → "all"; - 随意合同映射:
PRIVATE_CONTRACTS表把1인/one/single/1 → "1"、2인/two/2 → "2"、all/전체 → "all"; - 关键字段(keywordField):
KEYWORD_FIELDS区分按标题(title/name/공고명 → "1")还是按编号(number/code/공고번호 → "2")搜索; - 日期字段(dateField):
DATE_FIELDS区分按公告日(posted/notice/공고일 → "1")还是按截止日(deadline/마감일/견적서제출마감일 → "2")过滤,默认"2"(截止日); - 页码:
normalizePage()要求 1 以上的整数,缺省为1。
对应测试 index.test.js#L110-L133 完整覆盖了非法日期与超宽日期窗口的拒绝逻辑。
使用示例与 POST 表单配方
npm 包调用
packages/s2b-notice-search是一个零依赖(dependency-free)的 CommonJS 包,Node.js >= 18,入口为src/index.js,参见 packages/s2b-notice-search/package.json。它对外导出五个函数:
const { buildBrowserAutomationInstructions, buildSearchRequest, normalizeSearchOptions, parseDetailHtml, parseListHtml } = require("s2b-notice-search") const options = normalizeSearchOptions({ keyword: "급식", organization: "초등학교", dateStart: "2026-06-01", dateEnd: "2026-06-30", itemType: "물품", privateContract: "1인", region: "서울", page: 1 }) const request = buildSearchRequest(options) const automation = buildBrowserAutomationInstructions(options) const rows = parseListHtml("<table>...</table>")表单字段映射
buildSearchRequest()(src/index.js#L62-L95)产出的字段名与 S2B 界面中观察到的名称完全一致,formName固定为tcmo001Form,方法为POST,编码为application/x-www-form-urlencoded; charset=UTF-8,并将同路径作为referer一并写入请求头。完整字段如下:
| 字段 | 值 | 说明 |
|---|---|---|
forwardName | list01 | 固定动作标识 |
pageNo | 页码字符串 | 由options.page转换 |
search_yn | Y | 执行搜索标志 |
process_yn | Y | 处理中状态标志 |
tender_sep1 | "1"或"2" | 关键字字段(标题/编号) |
tender_name | 关键字 | 搜索词 |
company_name_s | 机构/学校名 | 机构搜索词 |
tender_sep2 | "1"或"2" | 日期字段(公告日/截止日) |
tender_date_start/tender_date_end | YYYYMMDD | 日期范围 |
tender_item | "1"/"2"/"3"或空 | 品目(all时置空) |
estimate_kind | "1"/"2"或空 | 随意合同(all时置空) |
areaKind | 区域名 | 区域 |
测试 index.test.js#L72-L108 验证了当传入itemType: "물품"、privateContract: "1인"时,tender_item=1、estimate_kind=1;当all时对应字段会被置为空字符串,避免向后端发送无效筛选值。
列表与详情 HTML 解析器
列表解析:parseListHtml()
parseListHtml()(src/index.js#L97-L115)先用正则抽取所有<tr>...</tr>行,再逐行交给parseListRow()(packages/s2b-notice-search/src/html.js#L3-L30)解析。parseListRow的判定逻辑值得注意:
- 单元格数少于 4 的行直接忽略;
- 必须存在指向详情动作的链接——
isDetailAction()(html.js#L37-L39)只接受f_detail、fn_detail、goView三种 JavaScript 函数名; parseAction()(html.js#L71-L80)解析onclick="fn_detail('EST-2026-001','N001'); return false;"或href="javascript:goView('BID-2026-002')"形式的 action,抽出函数名与参数列表;- 公告代码优先取自 action 的第一个参数,其次从行内正则
/([A-Z]{2,}-\d{4}-\d{3,})/i提取; - 品目通过正则
^(물품|공사|용역)$在去掉序号数字后的文本中定位; - 日期字段通过
normalizeLooseDate()(html.js#L63-L69)容忍2026-06-01、2026.06.03等宽松格式并规范化输出。
S2B 真实列表常出现「标题行 + 元数据行」跨两行展示的单条记录(rowspan/colspan布局),parseListHtml对此做了特殊处理:若当前行解析失败,会将当前行与下一行拼接后再次尝试(src/index.js#L105-L113),合并成功后跳过下一行。测试 index.test.js#L187-L209 用真实形态的f_detail('202607031350436','1')跨行 fixture 验证了这一合并逻辑。
此外,当传入整个页面(而非仅结果表)时,parseListRow通过hasNoticeRowShape()(html.js#L32-L35)要求记录同时具备 action、标题、日期,并至少包含品目/机构/状态之一,从而自动过滤掉顶部导航表格中的fncGoMenu(...)菜单行(测试见 index.test.js#L161-L185)。
详情解析:parseDetailHtml()
parseDetailHtml()(src/index.js#L117-L135)面向详情页 fixture,返回结构化的详情对象:
title:优先取<h1>~<h6>标题,回退到表格中的공고명/제목;noticeCode/estimateCode:从공고번호/견적번호/견적공고번호字段提取;organization:取기관명/수요기관;status、itemType(품목구분/물품구분/구분)、privateContract(계약방법/수의계약);postedDate(게시일/공고일)经normalizeLooseDate规范化,deadline(견적마감일/마감일/입찰마감일)保留原文;contentText:优先取id="content"/contents/detailContent的 div 正文,否则回退为整页净化文本;attachments:parseAttachments()(html.js#L41-L50)扫描所有<a>,解析onclick中的下载 action(如downloadFile('A001','spec.pdf'))并配对文件名。
tableFields()(html.js#L52-L61)以「<th>标签</th>+<td>值</td>」两两配对的方式把整个字段表摊平为键值对象,是详情解析的基础。测试 index.test.js#L211-L233 验证了标题、编号、机构、品目、合同方式、正文与附件 action 的完整提取结果。
失败模式与处理策略
原文档明确了四类失败模式,技能对它们的处理态度是「显式失败、不绕行」:
- malformed curl/client error:表单编码、referer、cookie、超时等问题导致 S2B 未返回正常 HTML——属于请求构造错误,需要检查 recipe;
- login/CAPTCHA/blocked:出现登录墙、CAPTCHA、维护页或安全拦截页时,不尝试绕过,直接归类为失败;
- empty:要么搜索条件确实没有结果,要么 session/表单状态不一致导致返回空列表——需要回退到浏览器路径核验;
- upstream markup change:S2B 的列表/详情 table、JavaScript action、hidden field 或函数名变更,会导致解析结果部分缺失或为空——这是所有依赖上游标记(markup)的解析器共有的维护风险,源码中以
parseAction白名单函数名(f_detail/fn_detail/goView)和hasNoticeRowShape形态校验对此做了防御性设计。
政策边界与合规前提
最后重申技能的政策边界(与 s2b-notice-search/SKILL.md 中的硬性规则一致):
- 仅用于查询(read-only lookup),不做登录、报价提交、投标参与、合同、支付、我的页面自动化;
- 不绕过任何法律、到场、CAPTCHA、身份验证或电子签名边界,不索取/打印/存储明文凭据;
- 由于公开 endpoint 不要求 API key,该技能不放入
k-skill-proxy(详见文档「정책 경계」一节); - 在最终提交、发送、发布前必须获得用户明确批准。
完成条件(Done when)
一次成功的 S2B 公告查询应满足:已在 Aside Browser 或回退浏览器中打开公开查询界面;搜索期间有效且不超过 3 个自然月;列表/详情 fixture parser 已提取所需字段;结果仅用于只读查询,未触发任何写操作。这四条完成条件同时可作为 Agent 自检清单,也便于将s2b-notice-search技能嵌入更上层的采购情报、报价追踪等流程中。
【免费下载链接】k-skill한국인을 위한 스킬 모음집 - 에이전트를 한국인으로项目地址: https://gitcode.com/GitHub_Trending/ks/k-skill
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考