一个可控、可复现的 mock 目标站,用来验证内容监控/采集系统是否抓对了页面、识别了数据变化、能回到基线。站点不绑定采集技术栈:同一份页面同时提供稳定的 CSS 与 XPath 选择器,状态通过 HTTP 管理 API 和浏览器控制台修改。
演示主题是单条高校通知公告,结构对齐 notice.xidian.edu.cn / notice.ysu.edu.cn 这类真实门户。WebMonitor 的真实用法就是盯住一条通知,内容一变就发邮件;因此这里只提供一条通知详情页,不提供列表、分页或多条数据。
当前状态(2026-10-30)
- 已完成:阶段 0 工具链、阶段 1 状态内核、阶段 2 双端口 HTTP、阶段 3 单页管理后台、阶段 5 容器与运行、阶段 9 单条通知收敛。
- 运行形态:一个进程、一个事件循环、两个 uvicorn 监听器共享同一个
StateStore;站点9000,管理9001。- 站点内容:一条通知详情页(static/SSR)、点击次数 JSON、健康检查、本地页面资产。
- 管理端:单页控制台(场景、手动编辑、实时预览、事实摘要)与
/_admin/*API。- 构建方式:只从本仓库源码构建,不发布 GHCR 镜像;
compose.yaml构建本地的mocksite:local。- 未实现:阶段 6/P4 的
/flaky、/slow、私网重定向与子资源探针。故障注入、列表/分页、React/Vue 目标页、结构性破坏场景均已移除或不在范围内(见 变更记录)。本 README 是当前目标契约;交付计划 跟踪已完成与未实现项;变更记录 是按日期保留的历史记录,其中的数字是当日实测,不代表本次文档整理重新验证过。
文档入口:交付计划与任务进度 · 变更记录 · 浏览器验收。
mocksite 负责产生受控输入,并给出可核对的页面状态;监控平台负责采集、提取、比较、调度和通知。
| 需要验证的问题 | mocksite 提供的条件 |
|---|---|
| 内容是否真的发生变化 | 固定默认数据 + 绝对覆盖的场景,每次只改指定字段 |
| HTTP 与浏览器采集有何差异 | 服务端渲染正文,点击次数由页面脚本异步填充 |
| 采集器是否选对节点 | 金额、日期、标题都存在形状相似但语义不同的诱饵节点 |
| 数据改回原值后平台如何表现 | 场景不叠加;baseline/reset 恢复与默认值逐字节相同的业务内容 |
| 管理端口是否真正隔离 | 基础 Compose 只发布 9000;9001 仅控制网络/开发 overlay 可达 |
| Agent 出口限制是否生效 | Agent 部署方把 egress 接入与 mocksite 共享的 Docker 网络,并只放行 site:9000 |
任何采集方都可以接入:页面同时给出 CSS 与 XPath,便于 HTTP 抓取、无头浏览器、Selenium 等验证各自的提取与等待逻辑。各平台的错误码、重试策略和告警能力由平台自身契约决定。
本项目不实现监控引擎、邮件发送、多用户与权限管理、任意脚本执行或真实网站的反爬绕过。mocksite 也不替代采集方自带的 smoke fixture。
一次完整验证按以下顺序执行:
- 打开管理后台,输入
MOCKSITE_TOKEN,确认状态、revision和默认数据。 - 在监控平台配置目标 URL 与选择器,完成首次采集并建立基线。
- 在后台应用一个场景(或手动编辑通知字段),确认右侧实时预览和返回的
revision已变化。 - 触发或等待平台下一次采集,核对页面内容、提取结果和平台事件。
- 验证结束后显式恢复:应用
baseline或点击“恢复默认(reset)”,再采集一次确认回到基线。
mocksite 不等待平台采集完成。验证脚本必须以平台的运行结果为准,不能仅凭固定休眠时间判定成功。一个实例只服务一组串行场景;需要并行验证时,为每组测试启动独立实例。
| 路径 | 内容与渲染方式 | 主要验证点 |
|---|---|---|
/static/notice |
通知详情页:状态提交时预渲染完整 HTML,请求时直接返回;点击次数由脚本异步填充 | HTTP 提取基线、诱饵节点、缓存页 |
/ssr/notice |
同一份详情页,每次请求根据当前状态渲染 | 服务端实时渲染与静态页的一致性 |
/api/views |
点击次数 JSON:{"views": 128, "revision": 0} |
脚本异步填充的计数 |
/health |
{"status":"ok"} |
进程存活,不随业务数据变化 |
/static/shared/* |
页面脚本、样式表、附件图标 | 离线可用,不依赖 CDN |
/static/notice 与 /ssr/notice 使用同一个通知对象。页面刻意贴近真实高校门户,包含以下容易误选的结构;它们在所有场景下都保持不变:
- 除
#current-fee(现行标准,唯一应监控的金额)外,还有#previous-fee(调整前标准)、.unit-fee strong(首小时后每 30 分钟加收,约为现行标准的 1/4)、.cap-fee strong(单日封顶,约为 1/10)、.previous-fee-note(原标准与差额说明段)。这些节点都能被选择器选中并返回数值,选错不会报错,只会静默取到错误金额。 - 同一发布时刻被渲染多次:
#date-badge(10月20日)、#published-date-cn(2026年10月20日)、#published-at(2026-10-20 14:32),页面还含同值的#published-timestamp。这些节点全部由published_at派生,永远不会互相矛盾,但都不是执行日期。真正的执行日期是#effective-date(2026-11-01)。 - 点击次数由脚本异步填充:静态 HTML 里
#view-count是空节点,notice.js请求/api/views后才写入并设置data-loaded="true",对齐真实门户的_showDynClicks。用 HTTP 抓取拿到的是空字符串。 - 上一篇/下一篇标题(
#prev-notice、#next-notice)是页面导航,包含另一条通知的标题;整页文本比对会把它们当成本页内容。 - 撤回不是 404:
status="withdrawn"时页面顶部出现#withdrawn-banner,状态行data-status="withdrawn";正文与标准照常渲染。 - 正文是
#notice-body下逐段<p>;附件条目携带data-kind与文件大小;状态行是.status-line[data-status]。
所有业务数据响应携带 X-Mocksite-Revision 与 Cache-Control: no-store。
{
"notice": {
"id": "notice-54493",
"title": "关于调整校园停车收费标准的通知",
"department": "后勤保障部",
"doc_no": "校后保〔2026〕42 号",
"category": "学校通知",
"current_fee": "128.50",
"previous_fee": "199.00",
"currency": "CNY",
"published_at": "2026-10-20 14:32",
"effective_date": "2026-11-01",
"view_count": 128,
"status": "published",
"body": [
"为缓解校园停车压力、规范停车秩序,经2026年第12次校长办公会研究通过,决定调整校园停车收费标准。现将有关事项通知如下。",
"一、调整范围。本次调整适用于长安、雁塔两校区的道路泊位与校内公共停车场,教工住宅区自有车位不在此次调整范围内。",
"二、收费标准。小型车首小时由 199.00 元调整为 128.50 元,首小时后每30分钟加收 32.12 元;单日累计不超过 12.85 元。中型车、大型车按车型系数另行计收。",
"三、执行时间。本通知自 2026 年 11 月 1 日起执行,执行前按原标准计收。",
"四、其他事项。军车、警车、消防车、救护车等特种车辆按规定免费停放;新能源汽车继续执行半价优惠。",
"请各单位做好宣传解释工作。如有疑问,请联系后勤保障部交通管理科,电话:029-81891818。"
],
"attachments": [
{"id": "att-1", "name": "校园停车收费标准调整表.pdf", "src": "/static/shared/img/attachment-pdf.svg", "kind": "pdf", "size_text": "184 KB"},
{"id": "att-2", "name": "2026年第12次校长办公会纪要.pdf", "src": "/static/shared/img/attachment-pdf.svg", "kind": "pdf", "size_text": "312 KB"},
{"id": "att-3", "name": "停车收费标准公示回执.doc", "src": "/static/shared/img/attachment-doc.svg", "kind": "doc", "size_text": "96 KB"},
{"id": "att-4", "name": "意见征集汇总表.xls", "src": "/static/shared/img/attachment-xls.svg", "kind": "xls", "size_text": "72 KB"}
],
"prev_title": "关于CURA中国大学生路跑联赛期间临时交通管制的通知",
"next_title": "关于举办第二届“百班千人国防尖兵挑战赛”的通知"
}
}数据约束:
- 金额以十进制字符串传输与渲染,后端使用
Decimal;非负、有限、最多 64 字符,不接受浮点、指数或负号。 - 通知 ID、标题、发布单位、文号、分类、附件名等文本为非空字符串,最多 256 字符;正文每段最多 2000 字符,最多 40 段;附件最多 20 个。
published_at是唯一的发布时刻,格式YYYY-MM-DD HH:MM(门户风格,不是 RFC 3339);页面上的中文日期、徽章和时间戳全部由它派生。effective_date是YYYY-MM-DD,只有它决定新标准何时执行。- 通知状态为
published | withdrawn;币种为三个大写 ASCII 字母。 view_count是 0–10000000 的整数,只由页面脚本异步呈现。- 所有插入 HTML 的文本必须转义;模板不得把数据解释为 HTML。
页面结构在所有场景下保持稳定,因此选择器始终可用。下表由 src/mocksite/site/pages.py 单一来源维护,并通过 GET /_admin/meta 提供给后台。
| 目标 | 角色 | CSS | XPath |
|---|---|---|---|
| 现行标准 | 主要 | #notice #current-fee |
//*[@id="notice"]//*[@id="current-fee"] |
| 执行日期 | 主要 | #notice #effective-date |
//*[@id="notice"]//*[@id="effective-date"] |
| 正文段落 | 主要 | #notice-body p |
//*[@id="notice-body"]/p |
| 通知标题 | 辅助 | #notice #notice-title |
//*[@id="notice"]//*[@id="notice-title"] |
| 文号 | 辅助 | #notice-doc-no |
//*[@id="notice-doc-no"] |
| 发布单位 | 辅助 | #notice-department |
//*[@id="notice-department"] |
| 通知状态 | 辅助 | #notice-status |
//*[@id="notice-status"] |
| 附件条目 | 辅助 | #attachments .attachment-item |
//*[@id="attachments"]//li[contains(@class,"attachment-item")] |
| 点击次数就绪标记 | 就绪 | #view-count[data-loaded="true"] |
//*[@id="view-count" and @data-loaded="true"] |
| 原标准(划线价) | 诱饵 | #notice #previous-fee |
//*[@id="notice"]//*[@id="previous-fee"] |
| 折算单位费 | 诱饵 | .unit-fee strong |
//*[contains(@class,"unit-fee")]//strong |
| 单日封顶 | 诱饵 | .cap-fee strong |
//*[contains(@class,"cap-fee")]//strong |
| 原标准说明段 | 诱饵 | .previous-fee-note |
//*[contains(@class,"previous-fee-note")] |
| 日期徽章(发布时间) | 诱饵 | #date-badge |
//*[@id="date-badge"] |
| 中文发布日期 | 诱饵 | #published-date-cn |
//*[@id="published-date-cn"] |
| 发布时间戳 | 诱饵 | #published-at |
//*[@id="published-at"] |
| 上一篇标题 | 诱饵 | #prev-notice |
//*[@id="prev-notice"] |
「角色」的含义:主要是通常应当监控的节点;就绪是等待渲染完成的判据;辅助是可选监控的补充字段;诱饵形状相似但语义不同,误选会得到错误数据。
选择器、示例 HTML 和实际页面需要一起验收,避免文档中的 XPath 与真实 DOM 层级不符。页面资产随站点分发,不依赖 CDN;离线要求指镜像准备完成后的运行阶段。
单实例使用进程内状态,包含业务数据和只读 revision。采用单进程服务;重启后恢复默认数据,版本重新计数。状态只有两个顶层键:business、revision;PATCH 只接受 business。
每次成功写入都在同一把锁内完成“校验 → 渲染静态页 → 提交 → 版本递增”;请求读取一致的状态快照。非法输入不修改状态,也不推进版本。静态页面缓存随状态一起提交,不存在“状态已更新、静态页面仍旧”的窗口。
管理 API 返回的版本用于串行验证和结果关联,不承担多客户端并发编辑控制。多请求的浏览器采集期间应保持状态不变;若发现页面数据版本不一致,验证脚本应重新建立该次验证条件。
每个预设场景都从默认状态构造完整目标状态,再一次性提交。重复应用同一场景得到相同业务内容,只有 revision 递增;场景之间不隐式叠加。组合条件通过状态 PATCH 显式设置。
| 场景名 | 分组 | 受控变化 | 核对重点 |
|---|---|---|---|
baseline |
基线 | 恢复默认数据 | 基线输入明确 |
no_change |
基线 | 保持默认业务数据,仅版本变化 | 版本元数据不应被误选为业务字段 |
fee_change |
收费 | 现行标准 128.50 → 96.00 |
两个通知页均变化;正文、日期、原标准不变 |
fee_increase |
收费 | 现行标准 128.50 → 158.00 |
上调方向的变更同样被识别 |
notice_withdrawn |
内容 | 状态 published → withdrawn |
撤回横幅出现,标准与正文不变(不是 404) |
effective_date_shift |
内容 | 执行日期 2026-11-01 → 2026-12-01 |
只有执行日期变化;发布时间渲染不变 |
title_change |
内容 | 标题追加“补充”字样 | 标题提取而非整页文本比对 |
view_count_grow |
内容 | 点击次数 128 → 1531 |
脚本异步计数不应被当成内容变化 |
每个场景在服务端还带 title(中文标题)、outcome(采集方会看到什么)、data_impact(哪个数据字段变化)、group(分组)与 compare_with_baseline,通过 GET /_admin/scenarios 返回。后台直接渲染这些字段,不维护第二份文案。
范围界定:本项目的场景只切换页面数据,不改变页面结构。 所有场景下 DOM 骨架完全相同——#current-fee、#previous-fee、#effective-date、#notice-body、#attachments 等节点始终存在,只有文本与数值变化。不存在“节点缺失”“ID 改名”“单元格 class 改名”这类结构性场景,也没有 presentation 层;PATCH /_admin/state 只接受 business。
提取器的健壮性(选择器写错、节点消失、页面改版)由使用方自己的测试覆盖,不属于本项目的输入契约。
场景不会自动回滚。验证脚本改过数据后,再调用 baseline 或 reset 恢复,消除“还没采集就回滚了”的时序不确定性。
故障注入不在范围内。 早期版本提供过 500/延迟注入(outage、slow_site),现已删除:采集方如何处理超时、重试与 5xx 是它自己的契约,不属于“输入”;把它混进 mocksite 会让“内容变了”和“网络挂了”两类信号无法区分。验证失败路径请使用采集方自带的 fixture 或网关层。PATCH 收到 faults 会返回 400,旧客户端会立刻失败而不是静默忽略。
以下能力尚未实现,文档不把它们描述为可用:
- 瞬时失败序列:
/flaky使用固定序列,例如500, 500, 200;按端点请求计数推进,序列耗尽后持续返回 200,reset 清零。不使用随机失败率。 - 单请求延迟:
/slow?ms=3000仅影响当前请求;只接受 0–30000 的整数,非法输入返回 400。 - 安全探针:
/redirect-private?target=loopback|metadata|ipv6只接受固定枚举;/private-resource在页面内请求固定私网子资源。验证必须走监控平台的真实采集出口。
后台入口为管理端口的 /,与管理 API 同源部署。开发者不需要编写 curl 请求,就能完成“查看状态 → 修改数据或切换场景 → 打开目标页面 → 恢复默认”的完整流程。
后台是单页控制台:左侧场景与数据编辑,右侧常驻目标页实时预览,顶部一行是刷新、恢复默认与退出。
┌─ 场景(基线 / 收费标准 / 通知内容)─┬─ 实时预览 ─┐
├─ 手动编辑通知字段 ─────────────────┤ revision │
└───────────────────────────────────┴─ 事实摘要 ─┘
| 区域 | 内容 | 操作结果 |
|---|---|---|
| 连接与认证 | 输入管理 token、连接状态、退出 | 认证成功后读取状态;认证失败显示可读错误,不展示受保护数据 |
| 场景 | 按“基线 / 收费标准 / 通知内容”分组的场景卡片 | 应用前二次确认;每张卡片显示中文标题、采集方会看到什么、哪个数据字段变化;成功后显示 revision N → N+1 |
| 手动编辑 | 通知全部业务字段(含正文,每行一段) | 保存后当前状态不再对应任何预设场景;字段错误就地标红并保留草稿 |
| 实时预览 | 目标页 iframe,标签切换 /static/notice、/ssr/notice |
任何变更后自动重载;以 1180 px 桌面宽度渲染再缩放,保证金额区与侧栏并排可见 |
| 事实摘要 | 当前 revision、生效场景、现行标准、点击次数 | 只读展示;手动编辑后标记“手动编辑的状态” |
| 操作反馈 | 提交中、成功、字段错误、认证失效、网络失败 | 请求期间禁用重复提交;失败时保留编辑内容,不伪报保存成功 |
预览以桌面宽度渲染的原因:通知页在 900 px 断点会改为单列,若直接按预览面板宽度渲染,金额区会被挤到侧栏下方,恰好看不到要验证的内容。预览 iframe 带 sandbox="allow-scripts allow-same-origin":脚本必须允许(点击次数由页面脚本填充),但目标页不应能操作后台。
后台复用下述 API,不维护独立业务状态或另一套场景实现。未保存的表单内容与已生效状态明确区分;服务端校验是最终依据。管理请求结果未知时先刷新状态核对,不自动重放写入。
token 由使用者输入,只保存在当前页面内存中;刷新或退出后清除,不写入 URL、localStorage、sessionStorage 或日志,也不内置进前端资产。未认证时只提供连接界面,状态与写操作仍由 API 认证保护。后台及其资产离线可用。
页面服务与管理服务使用两个独立监听端口。页面端口不提供管理后台或挂载 /_admin/* 路由;所有管理 API 都需要 Bearer token。
| 方法 | 路径 | 目标行为 |
|---|---|---|
GET |
/_admin/state |
返回 {revision, business} |
PATCH |
/_admin/state |
部分修改 business,成功后返回完整状态 |
POST |
/_admin/reset |
恢复默认数据,推进版本,返回 {before, after} |
GET |
/_admin/scenarios |
列出已实现的场景、影响范围与预期页面变化 |
POST |
/_admin/scenario/{name} |
原子应用场景,返回 {before, after}(含版本) |
GET |
/_admin/meta |
页面目录、采集/预览地址、选择器角色与 CSS/XPath |
PATCH 中对象字段递归合并,数组整体替换;null 不表示删除且不允许作为字段值;revision 只读,未知键、类型错误、越界值均返回 400,状态保持不变。未知场景返回 404。没有语义重复的整状态 PUT 接口。
管理服务的实现要求:
- 使用
MOCKSITE_TOKEN;未配置或不足 32 个非空白字符时拒绝启动。部署者使用安全随机值,不将其写进仓库。 - 缺失或错误凭据返回 401,不返回状态内容;管理 API 不启用跨域访问。
- 管理写入只接受
application/json(POST /_admin/reset允许无 body;若带 body 仍必须是 JSON),请求体最多 64 KiB;不支持的媒体类型返回 415,超限返回 413,JSON 或字段校验失败返回 400。 - 日志不输出 Authorization、请求体和完整 query;只记录方法、路由模板、状态码及版本等诊断信息。
- 状态、业务数据与页面响应使用
Cache-Control: no-store,避免客户端缓存干扰验证。 - 管理后台构建产物缺失时,
/返回 503admin_build_missing,不伪造成功。
采用 Python 3.12 + FastAPI:用 schema 校验控制输入,异步处理请求,状态管理与页面渲染分别组织。一个进程持有状态并运行两个监听端口,不启用多个独立 worker,以免产生多份状态。
服务端 Jinja2 模板生成静态及 SSR HTML;页面脚本与样式表作为本地资产随包分发。管理后台使用本地 React 资产构建单页控制台,通过同源 API 读写状态,不引入独立前端服务。容器以非 root 用户运行,支持只读根文件系统,不需要数据库或外部存储。
前端统一使用 pnpm 管理依赖和执行构建。web/ 共用一个 package.json 和一个 pnpm-lock.yaml,只覆盖管理后台;不使用 package-lock.json 或 Yarn 锁文件。
前端代码全部在 web/ 下,src/ 只放 Python 后端。 具体分工:
| 位置 | 内容 | 是否参与构建 |
|---|---|---|
web/admin/ |
管理后台源码(React + Vite) | 是,产物到 web/dist |
web/targets/shared/ |
框架无关的目标页脚本与样式(notice.js、notice.css、img/attachment-*.svg) |
否,原样分发 |
web/targets/manifest.json |
本地资产清单;React/Vue 运行时删除后 assets 为空。不挂载、不打包,仅作仓库内清单 |
否 |
web/targets/ 不经过 Vite:shared/ 由站点进程挂载在 /static/shared。构建 wheel 时通过 hatchling 的 force-include 把该子树复制进包的 mocksite/_static/,因此生产镜像不依赖仓库里的 web/ 目录。运行时解析顺序为:MOCKSITE_TARGET_ASSETS 显式配置 → 包内 _static → 仓库 web/targets(仅便于可编辑安装)。依赖安装和前端构建均在 Docker 内执行,部署构建使用冻结锁文件安装。
Python 依赖同样在 Docker 内安装。宿主侧可选使用工作区共用的 conda 环境(../.github/env/environment.yml,环境名 webmonitor,Python 3.12),用于编辑、lint 与快速跑测试;pyproject.toml 的 pin 需与 ../.github/env/requirements-workspace.txt 保持一致,容器构建仍以 pyproject.toml 为准。
mocksite 没有 GHCR 镜像,也不使用镜像 overlay。 所有运行方式都从本仓库源码构建:
compose.yaml的site服务使用build: .+deploy/Dockerfile,本地镜像名为mocksite:local;compose.yaml的dev服务使用deploy/dev.Dockerfile,本地镜像名为mocksite-dev:local;- 生产镜像两阶段构建:Node 阶段冻结安装并用 Vite 构建后台,Python 阶段用 hash 锁安装依赖、安装 wheel(包含
web/targets/shared的包内副本),以 UID10001运行。
不要从注册表拉取 mocksite 镜像,也不要引用不存在的 tag;部署示例中的 docker compose up --build 是唯一受支持的方式。
| 端口 | 用途 | 默认暴露方式 |
|---|---|---|
9000 |
目标页面、业务数据及健康检查 | 容器内监听;基础 Compose 映射到宿主 127.0.0.1:9000 |
9001 |
管理后台及管理 API | 容器内监听;基础 Compose 不发布,仅控制网络可达;本地开发 overlay 映射 127.0.0.1:9001 |
容器内的 127.0.0.1 不是宿主机回环地址。本地开发启动方式必须通过开发 overlay 配置 127.0.0.1:9001:9001,提供可从浏览器打开的管理后台;基础 Compose 仍不发布管理端口。
端口分离不等于网络隔离。部署时需限制控制网络的参与者,并确保采集出口不能访问管理端口;token 仅用于管理后台和控制脚本,不提供给被采集页面或模型上下文。
mocksite 只负责在 9000(页面)与 9001(管理)上提供内容与状态控制,不关心采集方如何出网。采集方需要知道三件事:
- 采集地址:容器网络内的服务名,Compose 下为
http://site:9000(注意是服务名,不是项目名),由MOCKSITE_COLLECT_ORIGIN决定后台展示值; - 预览地址:开发者浏览器可打开的地址,由
MOCKSITE_PUBLIC_ORIGIN决定,默认http://127.0.0.1:9000; - 管理端口:
9001不得暴露给被采集页面或采集出口,token 只交给后台与控制脚本。
WebMonitor-Agent 联调由 Agent 部署方自行配置,本仓库不提供 Agent 专用 overlay,也不维护 Agent 侧的网络或出口变量文件。做法是:把 Agent 的 egress 接入与 mocksite 共享的 Docker 网络(mocksite 的 Compose 项目网络,默认 mocksite_default),在 Agent 的出口代理中只放行 http://site:9000,并把 Agent 的 target allowlist 限定为 http://site:9000。Agent 的采集目标是 http://site:9000/static/notice,管理状态仍在宿主 127.0.0.1:9001;9001 不在放行范围。
Agent 的网络名、出口白名单和 target allowlist 都是 Agent 部署配置,不属于 mocksite 仓库;改网络名或换端口时,两侧要同步修改,不要假设默认值永远成立。若采集方自带出口代理或私网限制,放行 mocksite 的精确主机与端口属于该采集方自己的配置。
cd mocksite
cp .env.example .env # 填入 MOCKSITE_TOKEN(openssl rand -hex 32)
docker compose up --build -d --wait
curl --fail http://127.0.0.1:9000/health # {"status":"ok"}基础 Compose 只发布 127.0.0.1:9000(页面)。管理端口 9001 仅在控制网络内可达。
需要在本机浏览器打开管理后台时,叠加开发 overlay:
docker compose -f compose.yaml -f compose.dev.yaml up --build -d --wait
open http://127.0.0.1:9001 # 输入 .env 里的 MOCKSITE_TOKENoverlay 只额外映射 127.0.0.1:9001,不改变镜像或状态。
后台与脚本走同一套 API,二选一即可:
export MOCKSITE_TOKEN=$(grep ^MOCKSITE_TOKEN .env | cut -d= -f2)
python scripts/control.py state # 当前状态(含 revision)
python scripts/control.py scenarios # 场景清单
python scripts/control.py scenario fee_change # 应用预设,输出 before/after
python scripts/control.py patch '{"business":{"notice":{"current_fee":"96.00"}}}'
python scripts/control.py pages # 页面、采集/预览地址与选择器
python scripts/control.py reset # 恢复默认数据退出码:0 成功,1 请求失败(含 401/404/400),2 用法或 JSON 错误。脚本对 loopback 显式绕过 *_proxy。
开发工具链在 dev 服务中(profile tools,不随默认 up 启动),不发布任何端口:
docker compose build dev
docker compose run --rm dev python -c "import mocksite"
export MOCKSITE_TOKEN=$(grep ^MOCKSITE_TOKEN .env | cut -d= -f2)
docker compose run --rm -e MOCKSITE_TOKEN dev mocksite --check # 只校验配置;退出码 2 = 配置错误
docker compose run --rm dev pytest -q
# 前端,在 dev 容器内
docker compose run --rm dev sh -lc 'cd web && pnpm install --frozen-lockfile'
docker compose run --rm dev sh -lc 'cd web && pnpm typecheck'
docker compose run --rm dev sh -lc 'cd web && pnpm build' # -> web/dist宿主机只跑测试/lint 时用共享 conda 环境(仍需 env -u all_proxy -u http_proxy -u https_proxy 绕开代理):
env -u no_proxy -u NO_PROXY -u all_proxy -u http_proxy -u https_proxy \
conda run -n webmonitor pytest -qpytest 只收集 tests/unit 与 tests/integration;浏览器验收在 tests/acceptance/,见 7.5。
两个脚本位于 tests/acceptance/(不属于 pytest),都以真实 Chromium 驱动已启动的站点,并先调用 reset 建立已知基线:
# 先启动带管理端口的栈(见 7.1 的 overlay)
export MOCKSITE_TOKEN=$(grep ^MOCKSITE_TOKEN .env | cut -d= -f2)
conda run -n webmonitor python tests/acceptance/check_admin_ui.py # 38 项
conda run -n webmonitor python tests/acceptance/check_target_pages.py # 47 项默认访问 http://127.0.0.1:9000 与 9001,可用 --site-origin / --admin-origin / --token 覆盖。Playwright 包来自共享 conda 环境;首次运行前如未安装浏览器,执行 conda run -n webmonitor python -m playwright install chromium。最近一次完整记录(2026-10-26)为 38/38 与 47/47;本次文档整理未重新运行。
本节是开发/验收路径;普通用户安装 Agent 不需要 clone 产品仓库,而是用 curl 下载发布页的部署文件并自行创建 .env。Agent 的默认启动统一为 docker compose up -d --wait,基础文件拉取公开 GHCR 镜像;源码构建用 compose.build.yaml 覆盖。
使用公开镜像时,Agent 的 .env 至少包含(显式版本,不依赖隐式默认):
WEBMONITOR_IMAGE=ghcr.io/webmonitor/webmonitor-agent:1.0.0
# 或 WEBMONITOR_IMAGE=ghcr.io/webmonitor/webmonitor-agent:latest以下命令从工作区根目录执行为开发验收路径。先启动 mocksite,再按 Agent 自己的默认方式启动(或源码构建):
# 1) mocksite: site:9000 + admin:9001(本仓库源码构建)
cd mocksite
cp .env.example .env
# 在 .env 写入 MOCKSITE_TOKEN(至少 32 个非空白字符)
docker compose -f compose.yaml -f compose.dev.yaml up --build -d --wait
cd ..
# 2a) Agent 默认启动(公开镜像)
cd WebMonitor-Agent
cp .env.example .env
# 设置 POSTGRES_PASSWORD、SECRET_KEY、ADMIN_EMAIL/ADMIN_PASSWORD 与 WEBMONITOR_IMAGE
docker compose up -d --wait
# 3) 网络/出口由部署方自行配置:接入 mocksite 网络,并且只放行 site:9000Agent 源码构建使用 compose.build.yaml 覆盖:
cd WebMonitor-Agent
docker compose -f compose.yaml -f compose.build.yaml up --build -d --wait网络与出口不通过本仓库的文件接入:Agent 部署方自行把 egress 接到 mocksite 的 Compose 网络,并在出口代理与 target allowlist 中只放行 site:9000。本仓库不提供 Agent 专用 overlay、专用 Squid 配置或相应的网络变量。
无论默认镜像还是源码构建,s3/egress 辅助镜像都在本机构建。上面的 cp .env.example .env 是开发验收路径;普通用户按 Agent 发布页的部署说明自行创建 .env,不需要 clone 仓库。
GHCR 包 ghcr.io/webmonitor/webmonitor-agent:1.0.0 与 ghcr.io/webmonitor/webmonitor:1.0.0 已公开,可匿名拉取,支持 linux/amd64 与 linux/arm64;latest 只随稳定发布移动。旧的 agent-api / agent-web 包已删除,不要引用。
验收入口:
- Agent UI:
http://localhost:8080 - 采集目标:
http://site:9000/static/notice(Agent target allowlist 内的唯一目标) - 管理状态:
http://127.0.0.1:9001/_admin/state(mocksite 的 token,不给 Agent)
预期顺序:Agent 首次采集只建立基线、不发事件;在 mocksite 应用 fee_change 后,下一次采集应产生变化事件;baseline/reset 后回到基线。Agent 的错误码、重试和邮件投递以其自身契约为准。
docker compose down # 保留镜像
docker compose down --rmi local状态保存在进程内存中,容器重启即恢复默认数据,revision 重新计数。
以下为历史实测(2026-10-25/26,见 变更记录),本次文档整理未重跑:
- 镜像以 UID
10001运行,根文件系统只读(read_only: true),仅/tmp可写;实测/app、/etc写入被拒绝。 - 基础 Compose 下宿主机直连
9001被拒绝;overlay 下才可访问。 - 容器内
http://site:9000与http://site:9001均可访问(site是 Compose 服务名;mocksite只是项目名,不解析)。 - 已删除的列表路由
/react/list、/vue/list、/dynamic/archive、/api/data与对应资产返回 404。
按依赖顺序推进;当前首轮范围为 P1、P3(P2 已随列表页作废)。每个阶段的验收应保留实际输出,不能只证明页面返回了 HTTP 200。
| 阶段 | 交付内容 | 完成标准 |
|---|---|---|
| P1:管理后台与通知页面 | 状态校验、管理认证、版本、reset、static/SSR、数据场景;后台认证、场景、通知编辑、实时预览与操作反馈 | 在后台完成“默认标准 → 修改 → 恢复”;非法写入不改变状态;API 返回状态与后台显示一致 |
| P2:浏览器与列表(已作废) | 列表页、分页、React/Vue 运行时 | 2026-10-26 随单条通知收敛删除;保留历史记录 |
| P3:容器与运行 | Dockerfile、基础 Compose、本地开发 overlay、控制脚本、Agent 接入说明、运行文档 | 一条命令从源码启动并可访问后台;管理端口无公网发布;说明采集方自行配置共享网络与精准出口白名单(Agent 端到端结果由 Agent 仓库记录) |
| P4:后续扩展 | /flaky、/slow、单页覆盖、安全探针 |
尚未实现;相同序列产生相同响应,受限地址未被访问 |
各阶段都需满足以下通用标准:
- 可复现:从 reset 开始执行相同操作,得到相同业务内容;版本元数据除外。
- 状态一致:非法请求不产生部分更新;读响应对应完整快照;重启恢复默认状态。
- 提取可验证:通知页的正文检查原始 HTML;点击次数必须用浏览器检查脚本填充后的 DOM。
- 后台可用:通过真实浏览器验证认证、表单保存、场景切换和重置;覆盖错误 token、非法输入、失败反馈与管理功能仍可用。
- 结果归属清楚:mocksite 证明输入是什么;平台联调证明提取、事件与通知实际发生了什么。
- 实现与文档一致:只有已经通过验收的能力才标记为可用;不发布虚构镜像、无效环境变量或未经验证的能力。
本仓库采用 CC BY-NC 4.0(署名—非商业性使用)。第三方前端资产保留各自许可证与声明。