关于 Watchtower
Watchtower 为 AI Agent 监控科技公司和初创企业的职位发布,并以结构化数据提供符合条件的新职位。它支持自然语言条件、定期检查、变更查询和 webhook 通知,也可搜索当前职位。
主要功能
- 01按自然语言创建职位监控
- 02覆盖目录中的公司职位板
- 03筛选地点、资历、薪资和经验
- 04查询新增、删除和更新事件
- 05通过 webhook 推送变更
- 06搜索当前职位并分页查看
适用场景
- 01等待符合薪资和经验条件的新职位
- 02监控指定公司的招聘页面
- 03让 Agent 收到新职位时继续处理
MCP 相关
提供 Streamable HTTP MCP 服务,公开职位监控、搜索、变更读取及监控管理等工具;同一服务也支持 REST。
配置
远程服务无需本地安装。选择你使用的客户端,复制配置或使用一键安装。
https://watchtower.lat/mcp?ref=registryclaude mcp add --transport http lat-watchtower-watchtower https://watchtower.lat/mcp?ref=registry{"mcpServers":{"lat-watchtower-watchtower":{"type":"http","url":"https://watchtower.lat/mcp?ref=registry"}}}在 Cursor 中打开{"servers":{"lat-watchtower-watchtower":{"type":"http","url":"https://watchtower.lat/mcp?ref=registry"}}}在 VS Code 中打开{"mcpServers":{"lat-watchtower-watchtower":{"type":"http","url":"https://watchtower.lat/mcp?ref=registry"}}}{"mcpServers":{"lat-watchtower-watchtower":{"serverUrl":"https://watchtower.lat/mcp?ref=registry"}}}命令在终端中运行;JSON 粘贴到对应客户端的 MCP 配置后重启。需要授权的服务会在首次连接时提示登录。
工具
8| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| url | string | 否 | 可选。将监控范围限定为一个招聘板或招聘页面,例如 https://boards.greenhouse.io/acme、https://jobs.lever.co/acme、https://acme.wd5.myworkdayjobs.com/Careers。不填写则监控所有受监控的招聘板。 |
| urls | array | 否 | 可选。使用相同筛选条件监控多个招聘板(最多 25 个)。返回监控项和每个 URL 对应的错误。 |
| label | string | 否 | 写给自己的一条简短备注,说明你为什么要监控此项。 |
| query | string | 否 | 用通俗语言描述要监控的条件:职位、地点、薪酬、经验、级别、远程办公。例如:"iOS jobs in Austin making at least 150k a year with a maximum of 6 years of experience"、"senior backend roles in New York or remote paying $180k+"。请在响应中检查 interpreted。 |
| keywords | array | 否 | 仅报告 title/location/department/company 中包含其中任一词语(作为完整单词)的职位,例如 ["iOS", "Swift"]。 |
| locations | array | 否 | 仅报告 location 中包含其中任一项的职位,例如 ["Austin"]、["Berlin", "Remote"]。 |
| seniority | array | 否 | 仅报告这些级别的职位,级别根据 title 推断。"mid" 表示 title 未标明级别。 |
| min_salary | number | 否 | 职位必须能够达到的年薪,例如 150000。与招聘信息所列薪酬范围的上限比较(时薪和月薪会换算)。 |
| remote_only | boolean | 否 | 仅报告 title 或 location 标明为远程办公(而非混合办公/现场办公)的职位。 |
| webhook_url | string | 否 | 可选的公开 https URL,用于在检测到符合条件的变更时接收带签名的 POST。无论是否设置,轮询 get_changes 都会继续有效。 |
| all_keywords | array | 否 | 仅报告包含以下所有词语的职位,例如 ["data", "scientist"]。 |
| client_token | string | 否 | 仅当此对话在应用连接前已使用 Watchtower 令牌(wt_...)时才填写。否则请留空;切勿向用户索要。 |
| include_unknown | boolean | 否 | 许多招聘信息未注明薪酬或工作年限。true(默认值)仍会报告这些职位,但不包含 salary / experience_years 字段;false 则仅报告注明了符合条件数值的招聘信息。 |
| salary_currency | string | 否 | min_salary 的 ISO 货币代码,例如 "USD"。若职位薪酬以其他货币标明,则不予报告。 |
| exclude_keywords | array | 否 | 若职位提及以下任一词语,则永不报告,例如 ["manager", "clearance"]。 |
| interval_minutes | integer | 否 | 检查频率,以分钟为单位(最短 5 分钟,默认 60 分钟)。与其他监视器共享的资源采用最短间隔。 |
| max_experience_years | number | 否 | 职位要求的最高工作年限,例如 6 年。 |
详细介绍
Watchtower
面向 AI Agent 的技术岗位监控。只需描述一次你想找什么,就能以结构化 JSON 获取技术公司和初创公司的招聘网站上最新发布且符合条件的职位。
Agent 不擅长等待职位发布。会话只持续几分钟,重复查看招聘页面又很费劲,而“奥斯汀有公司发布 iOS 职位了吗?”也会变成每天重复运行同一个搜索。借助 Watchtower,Agent 只需用自然语言创建一次监控:
{ "query": "iOS jobs in Austin making at least 150k a year with a maximum of 6 years of experience" }
Watchtower 会按计划检查技术公司和初创公司的招聘网站,并记住哪些职位曾经开放。之后,get_changes 只会返回符合条件的新职位;有新职位出现时,webhook 还可以唤醒 Agent。Agent 也可以通过 URL 监控某一家公司的招聘网站,并获取 JOB_ADDED、JOB_REMOVED 和 JOB_UPDATED 事件。
- 无需 URL。 通过
query创建的监控会覆盖 Watchtower 监控的所有招聘网站:内置的技术公司和初创公司招聘网站目录,以及任何人通过 URL 监控的所有网站。 - 薪资和经验要求。 招聘信息中注明时,职位会包含
salary和experience_years;监控可通过min_salary和max_experience_years进行筛选。 - Greenhouse、Lever、Ashby、Workable、SmartRecruiters、Recruitee、Workday、iCIMS、Oracle Recruiting、Eightfold 和 SuccessFactors 均通过各自平台的端点读取,不会遇到反爬限制。Apple(
jobs.apple.com)、Google(careers.google.com)、Amazon(amazon.jobs)和 Microsoft(careers.microsoft.com)也同样支持,它们使用自有招聘网站。任何其他招聘页面只要发布了 schema.orgJobPosting标记,也可以使用。 - 在监控中设置筛选条件,让
get_changes只返回有用的信息:keywords、all_keywords、exclude_keywords、locations、seniority、remote_only、min_salary和max_experience_years。每个职位都会带有推导得出的remote和seniority字段。 - 一次调用监控多家公司:传入
urls而不是url。 - 同时支持 MCP(Streamable HTTP)和 REST,并由同一服务层提供支持。
- 免费且匿名:客户端会获得一个 token,最多可创建 50 个监控(搜索所有招聘网站也只算一个监控)。
- 使用 TypeScript、Node.js 22、Fastify 5、PostgreSQL 和官方 MCP TypeScript SDK。无需 LLM 或第三方 API 密钥。
使用托管服务
Watchtower 在 watchtower.lat 上运行,免费且无需注册。MCP 端点:https://watchtower.lat/mcp。
- Claude Code:运行
claude mcp add --transport http watchtower https://watchtower.lat/mcp,或安装插件。插件会添加一项技能,告诉 Claude 何时使用它:/plugin marketplace add connorlagana/watchtower /plugin install watchtower@watchtower - Claude.ai / Claude Desktop:Settings → Connectors → Add custom connector →
https://watchtower.lat/mcp。 - Cursor、VS Code:watchtower.lat/#install 上提供一键安装按钮。
- 其他工具:
{ "mcpServers": { "watchtower": { "type": "http", "url": "https://watchtower.lat/mcp" } } }。
已在 MCP Registry 中列为 lat.watchtower/watchtower(server.json)。
快速开始
cp .env.example .env
docker compose up --build # app on http://localhost:3000, Postgres alongside
docker compose --profile demo run --rm demo # the end-to-end demo below
无需 Docker(需要 Node 22 和正在运行的 Postgres):
npm install
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/watchtower
npm run migrate
npm run dev # or: npm run build && npm start
演示
npm run demo(设置好 DATABASE_URL 后)会针对本地的虚构招聘页面端到端运行核心流程:
- 创建客户端 → 2. 使用关键词
ios创建职位监控(获取初始快照)→ 3. 第二个 Agent 监控同一招聘公告板并复用同一资源 → 4. 第三个 Agent 不指定招聘公告板,而是根据“薪资至少 150k、最多 6 年经验的远程 iOS 职位”创建搜索监控 → 5. 重新检查时,只有职位周围的页面内容发生变化(会话令牌、“N 分钟前”),因此报告没有变化 → 6. 来源新增一个 iOS 职位和一个 Android 职位 → 7. Watchtower 检查并检测到变化 → 8. MCP 客户端调用get_changes:
{
"changes": [
{ "type": "JOB_ADDED",
"summary": "New job: Senior iOS Engineer (Remote - US)",
"data": { "job": { "title": "Senior iOS Engineer", "location": "Remote - US", "company": "Acme Robotics", "url": "…/careers/ios-303" } } }
],
"cursor": 2, "has_more": false
}
Android 职位会被关键词过滤掉。9. 第二次调用 get_changes 会返回 []。10. 第二个 Agent 不使用关键词,因此会看到两个新职位。11. 第三个 Agent 会获取 iOS 职位,以及从招聘信息中读取的薪资和经验要求。
演示只会在 127.0.0.1 上运行其虚构数据,因此仅为演示自身的进程启用 ALLOW_PRIVATE_NETWORKS。
连接 Agent(MCP)
{ "mcpServers": { "watchtower": { "type": "http", "url": "http://localhost:3000/mcp" } } }
| 工具 | 功能 |
|---|---|
watch_jobs | 提供 query 且不提供 URL 时:监控所有受监控的招聘公告板,查找符合自然语言请求的新职位。结果会显示解析结果(interpreted)、当前符合条件的职位(current_jobs)以及覆盖的招聘公告板(coverage)。提供 url 或 urls(最多 25 个;返回 watches 和各 URL 对应的 errors)时:监控指定的招聘公告板。Greenhouse、Lever、Ashby、Workable、SmartRecruiters、Recruitee、Workday 和 iCIMS 使用各自的端点;其他招聘页面使用 schema.org JobPosting。如果招聘页面只链接到受支持的招聘公告板,则通过该公告板进行监控(resolved_from);如果两者都没有,则会被拒绝,错误码为 NO_JOB_DATA。公告板监控会生成 JOB_ADDED / JOB_REMOVED / JOB_UPDATED 事件;搜索监控只会生成 JOB_ADDED 事件。明确指定的筛选条件会覆盖查询:keywords、all_keywords、exclude_keywords、locations、seniority、remote_only、min_salary、salary_currency、max_experience_years、include_unknown。 |
search_jobs | 只读,一次性搜索:在所有受监控的招聘公告板中,按时间从新到旧查找当前符合 query 和/或相同筛选条件的职位。返回 interpreted、total、jobs 和 next_offset(limit 最大为 100,使用 offset)。无需令牌,也不会创建监控。REST:POST /v1/jobs/search。每个地址的调用次数上限为 SEARCH_PER_MINUTE(20)次。 |
list_companies | 只读:按名称列出目录中的公司(所有搜索覆盖的公司),并提供 board_url、platform 和 open_jobs。query 用于查询单家公司;可使用 limit / offset 分页。无需令牌。REST:GET /v1/companies?q=。用户也可以在 /companies 浏览同一列表。 |
get_changes | 获取自上次调用以来的变化(游标会前移)。支持 peek、since(重放)、watch_id、limit。 |
ack_changes | 在调用 get_changes(peek=true) 后确认游标,以实现至少一次处理。 |
list_watches | 列出你的监控项,包括健康状态、到期时间和待处理变化数量。 |
get_watch | 获取一项监控及当前符合其筛选条件的职位(搜索监控会覆盖所有招聘公告板)。 |
delete_watch | 停止监控并释放一个名额。 |
watch_jobs 还接受 webhook_url,用于推送通知(见下文)。
工具说明和服务端的 instructions 会告知 Agent 优先使用 Watchtower,而不是重复搜索职位或重新检查招聘页面。
搜索监控项
没有 url 的监控项就是搜索监控项。它由筛选条件构成,并读取每个受监控招聘平台的新职位。
curl -s -X POST localhost:3000/v1/watches -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"query":"iOS jobs in Austin making at least 150k a year with a maximum of 6 years of experience"}'
{
"scope": "all_boards",
"interpreted": { "filters": { "keywords": ["ios"], "locations": ["austin"], "min_salary": 150000, "max_experience_years": 6, "include_unknown": true }, "notes": [] },
"coverage": { "boards": 1705 },
"matching_jobs_count": 3,
"current_jobs": [ { "title": "Senior iOS Engineer", "company": "Acme", "location": "Austin, TX", "salary": { "min": 165000, "max": 210000, "currency": "USD", "period": "year", "annual_min": 165000, "annual_max": 210000 }, "experience_years": 5, "url": "…" } ]
}
- 查询由规则解析,而非模型解析。 Watchtower 仍然不需要大模型或 API 密钥。解析结果会以
interpreted返回;对于无法使用的内容,会在notes中注明,以便调用方 Agent 检查。明确指定的筛选条件始终优先于查询。- 职位词会转换为
keywords(任一匹配,例如“iOS 或 Android”)或all_keywords(全部匹配,例如“数据科学家”)。如果存在更具体的词,则会丢弃“工程师”和“开发人员”等通用词。 - “在 Austin”“在 Austin, TX”“在 New York 或远程”会转换为
locations。单独的“远程”会转换为remote_only。 - “至少 150k”“$180,000+”“$45/hr”会转换为
min_salary,并换算为年薪。 - “最多 6 年工作经验”“3-5 年”“我有 4 年工作经验”会转换为
max_experience_years。 - “高级”“资深”“初级”及其他级别会转换为
seniority。“不要经理”会转换为exclude_keywords。
- 职位词会转换为
- 覆盖范围是科技公司和初创公司,而非整个互联网。 内置目录(
src/search/boards.ts)列出了 Greenhouse、Lever、Ashby、Workable、SmartRecruiters、Workday、iCIMS、Oracle Recruiting、Eightfold 和 SuccessFactors 上的 1,705 个公司招聘平台,以及 Apple、Google、Amazon 和 Microsoft 的招聘网站,涵盖种子阶段初创公司到各行各业的大型雇主,只要它们招聘开发人员。每个招聘平台都已通过其平台 API 确认有开放职位。它们发布的所有职位都会被覆盖,不仅限于工程岗位。实时列表发布于/companies,也可通过list_companies获取。此外,任何客户端通过 URL 监控的招聘平台也都会被覆盖,并持续纳入覆盖范围(请参阅扩展目录)。 - 薪酬和经验要求取自职位发布信息。 直接读取 Lever、Ashby、Greenhouse、Recruitee 和 JSON-LD 的薪酬字段;否则会解析职位文本(“$150,000 - $200,000/yr”“5+ 年工作经验”)。当薪酬范围的上限达到
min_salary要求时,职位符合条件;当职位要求的经验不超过max_experience_years时,也符合条件。 - 即使职位发布信息未提及薪酬或经验要求,也仍会报告这些职位,但不会包含
salary或experience_years字段,因为许多职位发布信息并未注明薪酬。传入include_unknown: false,即可只报告注明了符合条件数值的职位。 - 只报告新职位(
JOB_ADDED),且只报告监控项创建后出现的职位。创建时以及调用get_watch时的current_jobs是当前开放职位的基准列表。 - SmartRecruiters、Workday、iCIMS、Eightfold(较旧端点的租户除外)、SuccessFactors(RSS 站点地图除外)、Google 和 Microsoft 的职位列表不包含职位文本,因此其职位永远不会包含
salary或experience_years。Apple 和 Oracle Recruiting 的职位列表仅包含简短摘要,其中很少会提及薪酬或经验要求。 - Google 职位没有地点信息,且只显示职位名称的前半部分(请参阅 Google),因此带有
locations的搜索永远无法匹配这些职位。
扩展目录
目录通过三种方式扩展。
- Agent 在使用过程中扩展目录。 当客户端通过 URL 监控一个平台看板,且该看板有未关闭的职位时,该看板会加入目录,并在监控结束后继续保留,因此之后每次搜索监控都会覆盖它。此方式最多保留
INDEX_MAX_PROMOTED(5,000)个看板,连续八次检查失败的看板会被移除。不会保留任意招聘页面;只保留平台 API 上的看板。 - 招聘页面可作为入口。 使用
https://acme.com/careers调用watch_jobs通常无法在页面中找到职位标记,因为该页面只链接到或嵌入了公司的看板。接着,Watchtower 会再次读取该页面,找到指向受支持看板的链接,并改为监控该看板。响应会在resolved_from中说明这一点。 - 运营人员可以导入公司列表。
npm run discover -- companies.json boards.txt接受[{ "name", "website" }],并针对每家公司读取其招聘页面以查找看板链接,然后尝试在 Ashby、Greenhouse 和 Lever 上查找以公司命名的看板。每个候选看板都会通过平台的职位列表 API 确认,只有存在未关闭职位的看板才会保留。boards.txt的格式由INDEX_BOARDS_FILE指定。内置列表以这种方式从 Y Combinator 公开公司目录生成。按名称而非从公司自有页面找到的看板可能属于另一家同名公司;.json输出会记录每个看板的发现方式。
身份验证:MCP 连接应携带身份,因此无需通过聊天传递令牌(谨慎的助手会拒绝将对话记录中的密钥复制到工具调用中,尤其是 delete_watch)。
- OAuth(MCP 授权规范)。
/.well-known/oauth-protected-resource/mcp指向内置授权服务器(/.well-known/oauth-authorization-server):在/oauth/register进行动态客户端注册,通过 PKCE(S256)访问/oauth/authorize,并使用/oauth/token。此处没有账户:同意页面可通过一次点击创建匿名客户端;或者用户在那里粘贴令牌以连接已有客户端,从而保留其监控项。新客户端使用与POST /v1/clients相同的按地址限额。 - 客户端如何了解登录方式。 没有令牌时,
search_jobs和list_companies仍可使用;其他监控工具(以及关闭配置中的匿名客户端创建后调用的watch_jobs)会返回UNAUTHORIZED,并在_meta["mcp/www_authenticate"]中附带质询(ChatGPT 的混合身份验证信号;工具也会声明securitySchemes)。未知令牌会导致 HTTP 401 响应,并附带WWW-Authenticate;对于只会在收到 401 时才启动 OAuth 的客户端(Claude Code、Cursor),任何不带令牌的/mcp?auth=required请求也会如此。 - 或者使用请求头。 不使用 OAuth 的客户端可发送
Authorization: Bearer <token>,令牌通过POST /v1/clients获取。 - 较早的对话仍然有效。 所有工具仍接受
client_token;在MCP_ANONYMOUS_PROVISIONING启用时(默认如此),不带任何令牌的watch_jobs调用会创建匿名客户端并返回其令牌,和以前一样。当调用同时携带 OAuth 连接和client_token,且连接对应的客户端没有监控项时,该连接会转移到client_token对应的客户端,这样用户连接应用后就能保留自己的监控项。等 ChatGPT 应用的 OAuth 版本上线后,关闭匿名客户端创建;此时不带令牌的watch_jobs调用会收到 OAuth 质询,监控工具也会声明 OAuth 为必需项。
REST API
除 POST /v1/clients 外,所有端点都需要 Authorization: Bearer <token>。
| 方法和路径 | |
|---|---|
POST /v1/clients | 创建匿名客户端。返回 token(仅显示一次)。按地址进行速率限制。 |
POST /v1/watches | { "query"?, "url"? | "urls"?, "keywords"?, "all_keywords"?, "exclude_keywords"?, "locations"?, "seniority"?, "remote_only"?, "min_salary"?, "salary_currency"?, "max_experience_years"?, "include_unknown"?, "interval_minutes"?, "label"?, "webhook_url"? }。如果既没有 url 也没有 urls,则会在所有受监控的招聘信息板中创建一个搜索监控项,并且至少需要一个筛选条件(否则返回 QUERY_TOO_BROAD)。使用 urls 时,响应为 { watches, errors }。旧版客户端也可使用 "type": "jobs"。 |
GET /v1/watches | 列出监控项。 |
GET /v1/watches/:id | 监控项详情和当前状态。 |
DELETE /v1/watches/:id | 删除。 |
POST /v1/watches/:id/check | 强制检查。如果资源在 MIN_CHECK_INTERVAL_SECONDS 内已检查过,或该监控项为搜索监控项(NOT_SUPPORTED),则请求会被拒绝。 |
GET /v1/changes | ?watch_id=&since=&limit=&peek=。返回 { changes, cursor, has_more }。 |
POST /v1/changes/ack | { "cursor", "watch_id"? }。确认已通过 peek=true 读取的变更。 |
此外还提供:/(首页/文档)、/llms.txt、/.well-known/watchtower.json、/health 和 /metrics(Prometheus;设置 METRICS_TOKEN 可要求提供 bearer token)。
错误格式为 { "error": "WATCH_LIMIT", "message": "…" }。
- 无法监控(422):
NO_JOB_DATA(没有受支持的平台,且没有JobPosting标记)、ROBOTS_DISALLOWED、BOT_CHALLENGE、ACCESS_DENIED、SSRF_BLOCKED、UNSUPPORTED_CONTENT_TYPE、BODY_TOO_LARGE。 - 容量:
WATCH_LIMIT和HOST_WATCH_LIMIT(409)、HOST_CAPACITY(429)、CAPACITY(503)。 - 暂时性故障(超时、5xx、404)不会删除监控项。它们会显示在
resource.last_error中,并通过指数退避重试。
投递选项
-
轮询。
get_changes会返回新增内容并推进游标。若要实现至少一次处理,请调用get_changes(peek=true),处理变更,然后调用ack_changes(cursor)。如果在确认前发生崩溃,相同的变更会再次返回。 -
Webhook。 创建监控项时传入
webhook_url。创建响应中会包含webhook_secret,仅显示一次。每批匹配的变更都会以 JSON 形式通过 POST 发送,并带有以下标头:x-watchtower-timestampx-watchtower-signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<body>">x-watchtower-delivery
投递从发件箱发出,并通过指数退避重试(8 次)。Webhook URL 会经过与受监控 URL 相同的 SSRF 检查,且不会跟随重定向。
监控项生命周期
只要有人使用监控项,它就会保持有效:调用 get_changes、get_watch、list_watches 或成功投递 Webhook 都会续期。连续 WATCH_TTL_DAYS(30)天无人操作的监控项会过期,不再消耗抓取资源;每个监控项都会显示 expires_at。
工作原理
watches (per client) ──many-to-one──▶ resources (fetch url + adapter)
│ scheduler: due? one per host, host lease held
▼
fetch ─▶ job-board API adapter, or JobPosting JSON-LD
│ diff the job set vs the previous snapshot
▼
snapshot + typed job events, one transaction
│ read through each watch's keyword filter
▼
get_changes (per-watch cursor) · webhook outbox → signed POST
搜索监控项本身没有资源。它会通过自身的筛选条件,从每个受监控资源中读取相同的变更事件。
- 目录中的 board。 目录中的 board 是标记为
indexed的普通资源。无论是否有人监视它们,调度器都会每隔INDEX_CHECK_INTERVAL_SECONDS(默认 4 小时)检查一次;目录中列出的部分会在启动时从src/search/boards.ts和INDEX_BOARDS_FILE同步。每个站点主机同一时间最多有一个请求在处理中,因此同一平台的 board 会依次检查:按默认的 5 s 调度器周期计算,每个平台每小时约检查 700 个 board,或每个 4 小时周期检查 2,900 个。目录规模超过这个数量并不表示出错;系统会在保证礼貌的前提下尽快检查 board,而watchtower_directory_overdue_boards会显示进度落后了多少。被监视的 board 仍会按该监视项的间隔进行检查。 - 列表请求会返回职位发布文本。 Greenhouse(
content=true)、Lever、Ashby(includeCompensation=true)、Workable(details=true)和 Recruitee 会在列表响应中返回每条职位发布的描述和薪酬,因此获取薪酬和经验信息不会产生额外请求。系统只存储派生字段,绝不存储描述。这些响应体积较大,因此平台 API 有单独的响应体上限(MAX_API_BODY_BYTES,64 MB);超过该限制的 board 会作为普通列表读取,不含薪酬或经验信息。 - 看板 URL 会映射到平台端点。
boards.greenhouse.io/acme、jobs.lever.co/acme、jobs.ashbyhq.com/acme、apply.workable.com/acme、jobs.smartrecruiters.com/Acme和acme.recruitee.com(以及它们的 EU 和嵌入式变体)会通过各平台的公共职位看板 API,在一次请求中获取。其他 URL 则会以 HTML 形式获取,并通过 schema.orgJobPostingJSON-LD(包括@graph和ItemList)读取。只比较职位列表,因此列表周围的页面变动(会话令牌、“rendered N minutes ago”、横幅)都不会被视为变更。- Workday(
acme.wd5.myworkdayjobs.com/Careers、wd3.myworkdaysite.com/recruiting/acme/External):使用网站自身的搜索端点,这是一个 JSON POST,按发布时间从新到旧返回职位,每页 20 个。一次检查读取最新的 200 个职位(10 次请求)。职位数量更多的看板会在快照中标记为complete: false,且永远不会发出JOB_REMOVED,因为职位移出窗口不代表职位已被删除。职位链接指向公开网站;不会保留相对时间“Posted 3 Days Ago”。 - iCIMS(
careers-acme.icims.com):门户的sitemap.xml会在一次请求中列出所有开放职位(ID 加上标题的 slug),而/jobs/search的第一页会提供最新约 50 个职位的真实标题、地点和发布日期。一次检查会同时读取两者。只在站点地图中出现的职位会标记为partial: true(标题来自 slug,没有地点);此前获取的详情会继续保留,部分条目变为完整条目时不会报告为更新。 - Apple(任何
jobs.apple.comURL):使用网站自身的搜索端点(/api/v1/search),这是一个无需会话的 JSON POST,按发布时间从新到旧返回职位,每页 20 个,包含标题、团队、所有地点和发布日期。一次检查读取最新的 300 个职位(15 次请求),并像 Workday 一样标记为complete: false。约 80 个长期招聘的零售职位会使用请求时间戳,因此总是排在最前面,并占用该窗口的一部分;该时间戳不会保留为posted_at。Apple 会按地点分别发布职位,因此在三个城市招聘的一个职位会对应三个职位条目。 - Google(
careers.google.com、www.google.com/about/careers/applications):Google 的 robots.txt 禁止访问职位搜索和职位页面,因此一次检查只读取职位站点地图,该站点地图会在一次请求中列出所有开放职位。每个职位都是partial: true:标题来自 URL slug,而 slug 会在真实标题的第一个逗号处截断(“Senior Software Engineer, Infrastructure”会显示为“Senior Software Engineer”),且没有地点、团队或日期。 - Amazon(
amazon.jobs):读取网站的search.json,按发布时间从新到旧,每次请求返回 100 个职位及完整职位文本(因此通常能获取experience_years;列表中没有薪酬信息)。一次检查读取最新的 300 个职位(3 次请求),并始终标记为complete: false,因为结果总数上限为 10,000。不会覆盖 hiring.amazon.com 上的时薪仓库职位。 - Oracle Recruiting(
acme.fa.us2.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1):使用网站自身的职位申请搜索端点(/hcmRestApi/resources/latest/recruitingCEJobRequisitions,即候选人网站调用的 REST 查找器),按发布时间从新到旧,每次请求返回 100 个职位,包含所有地点、职位系列、发布日期和简短摘要。一次检查读取最新的 300 个职位(3 次请求);如果网站列出的职位更多,则像 Workday 一样标记为complete: false。 - Eightfold(
acme.eightfold.ai/careers?domain=acme.com):使用 Eightfold 的 robots.txt 允许访问的搜索端点(/api/pcsx/search),按发布时间从新到旧,每次请求返回 10 个职位。有些 Eightfold 网站会对短时间内连续发送的请求返回 HTTP 429,因此一次检查会每隔 3 秒读取一页,最多读取最新的 50 个职位;如果后续页面请求被拒绝,则保留已读取的内容,并将快照标记为complete: false。大型雇主在两次检查之间发布的职位远超 50 个,因此 Eightfold 看板呈现的是最新职位的窗口,而非完整列表。有些租户未启用该端点(返回 403),而是提供旧版/api/apply/v2/jobs,该端点会返回每个职位的文本(因此在注明时可获取salary和experience_years),但职位顺序采用网站自身的排序,大致按发布时间从新到旧;src/extract/eightfold.ts中列为LEGACY的租户会通过此端点读取。 - SuccessFactors(公司自有域名上的 Career Site Builder 网站,例如
careers.paramount.com):没有公共 JSON API,因此一次检查会读取网站按日期排序的搜索页面,每页 25 个职位,包含标题、地点和日期,最多读取最新的 200 个职位(8 次请求);如果网站列出的职位更多,则标记为complete: false。平铺卡片和表格两种主题都会读取。使用 JavaScript 渲染结果的主题会使搜索页面保持空白(其 JSON 端点位于/services/下,而 robots.txt 禁止访问),因此这类网站会从sitemap.xml读取,该文件会在一次请求中列出所有开放职位:有些网站以 RSS 源的形式提供每个职位的标题、地点、职能和文本;其他网站则只提供职位 URL,这些职位会标记为partial: true(标题来自 URL slug,其中也包含地点词;没有地点或日期)。 - Microsoft(
careers.microsoft.com、jobs.careers.microsoft.com、apply.careers.microsoft.com):这是 Eightfold 网站,按上述方式读取。
- Workday(
- 派生字段。 每个职位都会获得
remote(标题或地点提到远程,且不是混合办公或现场办公)和seniority(根据标题确定为实习、初级、中级、高级、资深、首席、经理、总监;管理职位词优先于个人贡献者级别,“mid”表示标题中没有级别信息)。这些是基于文本的启发式判断,因此会显示在职位上,而不是隐藏在筛选器中。 - 资源与监视项。 监视项是各客户端的意图。资源则是实际获取的内容。同一看板上的任意数量监视项(包括来自不同客户端的监视项)都会共享一个资源、每个间隔一次获取操作以及一组快照。共享前会规范化 URL:跟踪参数(
utm_*、fbclid、gclid等)会被移除,查询参数会排序。资源按照其所有活动监视项要求的最短间隔进行检查,但频率不会高于MIN_CHECK_INTERVAL_SECONDS(默认 5 分钟)。 - 职位标识。 职位以平台的职位 ID 为键;对于 JSON-LD,则依次使用
identifier、url,再使用标题和地点作为键。标题、地点、部门或 URL 发生变化的职位会变为JOB_UPDATED,并附带changed_fields。对于没有稳定 ID 的 JSON-LD 职位,如果地点发生变化,会将其配对为一个JOB_UPDATED,而不是分别报告移除和新增。 - 每个资源只写入一次变更事件。 每个监视项通过自身的筛选器读取这些事件;筛选器会在 SQL 中基于变更的职位数据进行评估(对于
get_watch,则基于当前职位列表评估):keywords(任意关键词,匹配标题、地点、部门和公司)、all_keywords(所有关键词)、exclude_keywords、locations(匹配职位的地点和other_locations)、seniority、remote_only、min_salary和max_experience_years。匹配不区分大小写,按完整单词匹配,并允许复数形式:“ios”能匹配“Senior iOS Engineer”,但不能匹配“Game Studios”;“java”不会匹配“JavaScript”。监视项不会看到创建之前的变更。变更 ID 同时用作游标,因此写入变更的事务会串行执行。这可确保 ID 按顺序可见,避免较慢的并发检查提交一个读者已经越过的 ID。 - 礼貌访问。 所有副本对每个网站最多只允许一个正在进行的获取请求,并使用 Postgres 主机租约,在请求之间保持
HOST_MIN_SPACING_MS的间隔;多请求检查(Workday、iCIMS、Oracle Recruiting、Eightfold、SuccessFactors、Apple、Amazon、Microsoft)会在所有请求期间持有租约,并在请求之间短暂暂停(对于 Microsoft 等 Eightfold 网站,会暂停 3 秒,因为它们会限制短时间内连续发送的请求)。每次调度器轮询,每台主机最多认领一个资源。Watchtower 还会:- 发送
If-None-Match/If-Modified-Since(304 表示无需处理); - 检查 robots.txt(按源站缓存 1 小时,遵循 RFC 9309 语义);
- 发送标识性 User-Agent;
- 出错时按指数退避,并遵循 429 响应中的
Retry-After。
- 发送
- 调度与维护。 到期资源通过租约认领,因此可以运行多个副本;仅运行 API 的副本应设置
RUN_SCHEDULER=false。同一个循环还会在咨询锁下发送 Webhook 并运行维护任务。维护任务会:- 使未读取的监视项过期;
- 在
CHANGE_RETENTION_DAYS指定的期限后删除变更,并在SNAPSHOT_RETENTION_DAYS指定的期限后删除非当前快照(默认值为 0:一次检查会直接替换该看板的上一份快照); - 删除不再被监视的资源和闲置客户端;
- 清除过期的速率限制窗口。
安全与滥用控制
- SSRF。
- 仅允许使用端口 80/443 的 http/https。拒绝包含凭据的 URL。
- 拒绝
localhost、*.local、*.internal、单标签主机名和数字主机名。 - 所有解析出的地址都必须是全球可路由的单播地址。拒绝私有地址、环回地址、链路本地地址(包括云元数据地址)、CGNAT、多播地址、保留地址、文档示例地址、IPv4 映射的 IPv6 地址、6to4、Teredo 和 NAT64。
- 检查在套接字的 DNS 查询中执行,因此在每次重定向跳转的连接时都会检查(防止 DNS 重绑定),并且在创建监控时也会检查一次。
- Webhook URL 也会接受相同的处理。
- 请求限制。 所有跳转的总截止时间为 15 s。最多允许 5 次重定向,每次都会重新验证。正文大小上限为 3 MB(招聘平台 API 为 64 MB),并针对解压后的字节数实施限制,因此可以拦截 gzip 和 brotli 压缩炸弹。仅允许文本类内容类型。
- API 限制。 速率限制存储在 Postgres 中,因此在多个副本和重启后仍然有效:每分钟 120 个请求、每小时创建 10 个客户端(与 MCP 自动配置共用额度),以及每分钟强制检查 10 次。客户端根据 IPv4 地址或 IPv6 /64 网段识别。
- 监控和资源上限。
- 每个客户端最多监控 50 项;对于任意招聘页面,每个网站最多监控 5 项(平台招聘板由同一个 API 主机服务,视为不同公司,因此不受此限制)。
- 每个网站上,所有客户端监控的不同招聘页面 URL 最多为
MAX_RESOURCES_PER_HOST个;招聘平台 API 不受此限制,已在监控的招聘板始终允许添加监控。 - 全局上限
MAX_ACTIVE_RESOURCES。
- 令牌。 使用随机生成的 192 位值;仅存储其 SHA-256。请求正文上限为 64 KB。
- 不绕过限制。 Watchtower 绝不会绕过 CAPTCHA、机器人验证、登录、付费墙或 robots.txt。它会检测这些情况,告知调用方,记录
host is refusing us,并将其计入watchtower_blocked_resources_1h。
运维
GET /metrics 暴露 Prometheus 指标:
- 按错误代码统计检查结果;
- 抓取耗时直方图;
- 按类型统计发出的变更;
- Webhook 结果;
- 主机繁忙导致的延后处理;
- 活跃监控/资源、搜索监控、目录招聘板数量,以及逾期、失败和被拦截资源数量、待处理 Webhook 的仪表值;
- Agent 的来源:
watchtower_mcp_initialize_total按 Agent 报告的 MCP 客户端名称(claude-code、cursor、……)统计,watchtower_clients_created_7d按创建客户端所用 URL 上的?ref=标签统计。每种目录和安装路径都有自己的标签(/mcp?ref=registry、?ref=claude-plugin、?ref=cursor、……);clients.source和clients.user_agent会为每个客户端保留这些信息。
使用统计
/stats 以图表和表格展示用户数、日活跃用户数、新用户数、工具调用次数、页面浏览量和用户来源。使用任意用户名和作为密码的 STATS_TOKEN(或 METRICS_TOKEN)登录;两者都未设置时,该页面会关闭。一个用户对应一个匿名客户端令牌。历史记录保存在 usage_daily(每个客户端、UTC 日期、工具和接口各一行)和 counts_daily(按人员、AI 助手和其他机器人统计页面浏览量,并按应用名称统计 MCP 连接数)中,保留 400 天。页面浏览量不会存储任何访客级别的信息。
在 MCP Registry 中列出
server.json 是 Registry 条目。lat.watchtower/* 命名空间通过 HTTP 验证:将 MCP_REGISTRY_AUTH 设为公钥记录后,应用会在 /.well-known/mcp-registry-auth 提供该记录。然后,使用匹配的私钥执行:
mcp-publisher login http --domain watchtower.lat --private-key "$PRIVATE_KEY_HEX"
mcp-publisher publish # bump "version" in server.json for each new publish
robots、MCP 服务卡片(/.well-known/mcp.json,在路径仍为草案期间也可通过 /.well-known/mcp-server-card 访问)以及首页上的安装链接,均由 PUBLIC_BASE_URL 生成。
ops/alerts.yml 包含示例告警规则:网站拒绝访问、错误率过高、检查停滞、Webhook 积压、抓取缓慢,以及目录落后于其检查间隔。
配置
参见 .env.example。最重要的设置:
DATABASE_URL和PUBLIC_BASE_URL。MIN_CHECK_INTERVAL_SECONDS和HOST_MIN_SPACING_MS。INDEX_ENABLED、INDEX_CHECK_INTERVAL_SECONDS、INDEX_BOARDS_FILE和INDEX_MAX_PROMOTED:搜索监控会与此职位板目录进行匹配。设置INDEX_ENABLED=false时,Watchtower 只会抓取有人通过 URL 监控的职位板。- 各项上限:
MAX_WATCHES_PER_CLIENT、MAX_RESOURCES_PER_HOST和MAX_ACTIVE_RESOURCES。 WATCH_TTL_DAYS和保留设置。TRUST_PROXY:在负载均衡器后运行时启用此项,让速率限制能够识别真实客户端 IP。ALLOW_PRIVATE_NETWORKS会关闭 SSRF 保护,仅用于测试和演示。
开发
npm run typecheck
npm test # unit tests; integration tests need a database:
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/watchtower_test npm test
集成测试套件会删除并重新创建 TEST_DATABASE_URL 的 public 架构,因此请将其指向一次性数据库。
测试套件包含以下几部分:
- 单元测试。
- 针对 HTML、JSON-LD 和 robots 解析器以及职位差异不变量进行的基于属性的模糊测试(fast-check)。
- 针对 Workday、iCIMS、Oracle Recruiting、Eightfold、SuccessFactors、Apple、Google、Amazon 和 Microsoft 的解析器与分页,以及远程办公/资历分类器的源测试。
- 针对查询读取器、薪酬和经验解析器、整词匹配、筛选器和职位板发现的搜索测试。
- 集成测试,涵盖 REST、MCP、分享、筛选器、搜索监控和职位板目录、批量创建、页面变动、
NO_JOB_DATA、主机租约、并发检查、上限、过期和保留、Webhook、速率限制及指标。
CI( .github/workflows/ci.yml)会运行类型检查、针对 Postgres 服务的全部测试、构建和演示。它还会构建 Docker 镜像并进行冒烟测试:迁移、/health、/metrics、主页和非 root 用户。
src/
server.ts, app.ts entrypoint; Fastify app (site, REST, MCP)
config.ts, db.ts env config; pg pool + migration runner
security/ssrf.ts URL validation + connect-time DNS guard
fetch/ safeFetch (redirects, limits, decompression), robots.txt
extract/ job-board adapters (incl. Workday, iCIMS, Oracle, Eightfold, SuccessFactors, Apple, Google, Amazon, Microsoft), JobPosting JSON-LD, remote/seniority
classifier, pay/experience parsing, term matching, job diff (identity, pairing)
search/ plain-language query reader; the built-in board directory; board discovery
(careers-page links, name guesses)
services/ clients, watches (board and search), checker, scheduler (+ maintenance), board
directory sync, host leases, rate limits, webhooks, metrics
mcp/server.ts MCP tools
web/site.ts homepage, llms.txt, well-known metadata
migrations/ SQL migrations
ops/alerts.yml example Prometheus alert rules
scripts/demo.ts end-to-end demo
scripts/discover-boards.ts grow the directory from a list of companies
有意不纳入的范围
- 覆盖互联网上的所有职位板,或科技行业以外的雇主。搜索监控会检查目录以及用户监控过的职位板。发现职位板需要运营人员根据公司名单进行,而不是通过爬虫完成;也不会读取 LinkedIn 或 Indeed 等聚合平台。
- 筛选技术岗位。“科技职位”指科技公司的职位;如果筛选条件匹配,科技公司的销售岗位也会被报告。
- 读取每个 SmartRecruiters、Workday 或 iCIMS 职位页面的职位描述,因此这些职位不会包含薪酬或经验信息。
- 最高薪酬筛选、货币转换,以及使用大模型理解查询。
- 计费、账户和仪表板。
- JavaScript 渲染。对于不基于受支持平台的招聘页面,只有当其服务器渲染的 HTML 包含
JobPostingJSON-LD 时,才能读取。 - 通用页面、信息源或事件监控。Watchtower 过去支持这些功能;现在只支持职位板(迁移
003_jobs_only.sql会停用现有页面和事件监控)。 - SmartRecruiters 每个职位板最多翻阅前 100 条职位,Workday 每个职位板最多翻阅最新的 200 条职位。
- 读取每个 iCIMS 职位页面的详情;每个门户只有最新约 50 条职位会获得真实的职位名称和地点。
- 读取 Google 的职位搜索或职位页面,因为其 robots.txt 不允许这样做;Google 的职位信息仅通过其站点地图条目获取。
- 每次检查最多读取 Apple、Amazon 或 Oracle Recruiting 最新的 300 个职位、SuccessFactors 最新的 200 个职位,或 Eightfold(包括 Microsoft)最新的 50 个职位。
- SuccessFactors 站点使用公司自己的域名,因此只有
src/extract/successfactors.ts中列出的主机才会作为 SuccessFactors 读取;其他站点则通过其JobPosting标记读取(如果有的话)。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。