隔离测试 Agent 环境部署指南 v2(完整版,从零到部署一份文档搞定)
巴黎机票监控 + 巴萨车票监控 + 假日感知 AI 行程规划
这是一份自包含文档,不需要再回看 v1。相对最初版本的核心变化:把"判断"从 LLM prompt 里挪到 Python 代码里,权限模型从裸
ask改成精确规则,密钥彻底和 OpenCode 进程隔离,Playwright 浏览器进程容器化加固,并新增一个每周跑一次的"假日规划器",结合巴伦西亚本地假期自动找出值得关注的出行窗口。
0. 这一版改了什么(对照上一轮审查)
| 问题 | v1 的做法 | v2 的做法 |
|---|---|---|
无人值守遇到 ask 会卡住 | "bash":"ask" | 改用 v2 permissions 数组,明确 allow/deny,不留裸 ask |
| Telegram token 被 OpenCode 子进程继承 | source .env 后直接跑 opencode | 拆成两个进程,token 只在 notify.py 自己的调用里出现 |
| Playwright 默认行为存疑(不同版本文档不一致,有的说默认持久化,有的说新版默认已改成内存态) | 未指定 | 显式加 --isolated,不依赖"默认行为" |
| LLM 自己判断"最低价" | prompt 里让它直接给结论 | prompt 只要求它如实列出看到的所有数据点,min() 由 Python 算 |
| 不同日期价格直接比较 | last_price.json 只存裸 price | 改成按 route+date_out+date_return 做 key |
@playwright/mcp@latest | 每次动态解析最新版 | 锁定具体版本号 |
| 浏览器可以导航到任意网站 | 未限制 | --allowed-origins 白名单(注意:官方文档明确写了这不是安全边界,只是防止误导航的辅助手段,见下文说明) |
1. 隔离环境搭建(一次性)
1.1 创建独立的本地 macOS 账户
系统设置 → 用户与群组 → 添加账户,选择「标准」账户,账户名建议 agentlab。
关键:跳过"登录 Apple ID"这一步,保持纯本地账户——避免 iCloud/Handoff 把这个环境和你的主账户串起来。
登录到这个新账户后,后续所有操作都在这个账户下进行。
1.2 安装基础工具
# 安装 Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Node.js(OpenCode 需要)
brew install node
# Python3(notify.py / price_store.py 等脚本要用)
brew install python3
# Xcode 命令行工具(很多依赖需要)
xcode-select --install
1.3 安装 OpenCode
npm install -g opencode-ai
opencode --version # 确认安装成功
跑一次 opencode --help,确认能看到 permissions 相关说明——这份文档用的是 v2 权限系统语法(permissions 数组),如果你装到的版本文档里只有旧的 permission 对象写法,去官方文档确认一下当前推荐语法,配置字段名可能需要相应调整。
1.4 Gemini 免费层配置(模型名不要抄死)
重要提醒:Gemini 的 Flash 系列这半年迭代很快(2.5 → 3 → 3.5 → 3.6 → 3.7),而且免费层覆盖的具体型号会变——3.7 Flash 目前是否进免费层,不同渠道的信息互相矛盾,我没能拿到一个确定答案。
部署那天请务必先做这一步:打开 https://ai.google.dev/gemini-api/docs/pricing ,确认当前哪个 Flash 型号标着"Free of charge",把型号 ID 填进下面配置里的 model 字段。写这份文档时相对确定还在免费层里的是 google/gemini-3.6-flash,仅供参考,请以你实际部署时的官方页面为准。
- 打开 https://aistudio.google.com/apikey ,免费创建一个 API Key(无需信用卡)
- 配置到 OpenCode:
opencode auth login -p google
# 按提示粘贴 API Key,会存到 ~/.local/share/opencode/auth.json
# 这个凭据走的是 OpenCode 自己的 auth 文件,不经过 shell 环境变量,
# 和后面 3.3 节讲的 Telegram token 隔离是同一个思路
1.5 创建 Telegram Bot(免费通知渠道)
- Telegram 里搜索
@BotFather,发送/newbot,按提示起名字,拿到一个TELEGRAM_BOT_TOKEN - 给这个新 bot 发一条任意消息(比如 “hi”),否则下一步拿不到 chat_id
- 浏览器打开
https://api.telegram.org/bot<TOKEN>/getUpdates,找到返回 JSON 里的"chat":{"id": 数字},这就是你的TELEGRAM_CHAT_ID
把这两个值先记下来,下一节建 .env 时要用。
1.6 目录结构
mkdir -p ~/agent-lab/{shared,paris-flights,bcn-train,holiday-planner}
mkdir -p ~/agent-lab/shared/{data,run}
~/agent-lab/
.env # 只给 notify.py 用,opencode 永远看不到
.aiignore
opencode.json
shared/
price_store.py # 价格历史存取(route+date 做 key,原子写)
notify.py # Telegram 推送
extract_prompt.py # 生成"只提数据不判断"的统一 prompt
data/
valencia_holidays.json
price_history.json
run/ # 中间结果,不再用 /tmp
paris-flights/
check.sh
bcn-train/
check.sh
holiday-planner/
plan.py
plan.sh
1.7 创建 .env(存放上一节拿到的 Telegram 凭据)
cat > ~/agent-lab/.env << 'EOF'
TELEGRAM_BOT_TOKEN=你的token
TELEGRAM_CHAT_ID=你的chat_id
EOF
chmod 600 ~/agent-lab/.env
再建一个 .aiignore,作为额外一层防线(注意:这只是提示级过滤,真正防泄露靠的是第 3.3 节 notify.py 的进程隔离设计,不要只依赖这个文件):
cat > ~/agent-lab/.aiignore << 'EOF'
.env
*.log
*.err
EOF
2. 核心配置:opencode.json(v2 权限系统 + 容器化 Playwright)
2.1 为什么改成容器跑 Playwright
上一版是 npx -y @playwright/[email protected] 直接在 agentlab 账户下跑浏览器进程。这样有一个 v2 权限模型管不到的缺口:安装/运行浏览器这件事本身发生在 OpenCode 的权限系统之外——npx 拉包、Chromium 渲染不可信页面,这些都是在 agentlab 账户的真实文件系统和真实内核上跑的,供应链投毒或渲染层漏洞理论上能摸到账户能碰到的一切。
把 Playwright 装进 Docker 容器后,多一层真实的隔离边界:容器有独立的文件系统(不共享 agentlab 账户的 ~)、独立的进程/网络命名空间,就算浏览器进程被攻破,攻击者拿到的是一个几乎是空的、用完即焚的容器,而不是你的账户环境。这个投入量级远小于整机跑 VM,但补上了"账户隔离 + 运行时权限"这个组合本身管不到的那块。
2.2 安装 Docker 运行时
2018 Intel Mac 上,Docker Desktop 偏重(自带一个完整的虚拟机+GUI),更推荐轻量的 Colima(开源、CLI,同样是"用一个精简 Linux VM 跑 Docker daemon",但没有 Docker Desktop 那套 GUI 和额外服务,资源占用小很多,对老硬件更友好):
brew install colima docker
colima start --cpu 2 --memory 2
docker info # 确认能正常连上 Docker daemon
2.3 拉取官方镜像并锁定版本
docker pull mcr.microsoft.com/playwright/mcp
# 拿到这次拉取的具体 digest,后续用 digest 而不是 tag 来锁定,避免镜像被静默更新
docker inspect --format='{{index .RepoDigests 0}}' mcr.microsoft.com/playwright/mcp
# 输出类似:mcr.microsoft.com/playwright/mcp@sha256:xxxxxxxx...
把输出的完整 mcr.microsoft.com/playwright/mcp@sha256:xxxx 记下来,填进下面配置里。用 digest 锁定比用版本号标签更可靠——标签可能被镜像发布方重新指向新内容,digest 不会变。
2.4 opencode.json
{
"$schema": "https://opencode.ai/config.json",
"model": "google/gemini-3.6-flash",
"permissions": [
{ "action": "*", "resource": "*", "effect": "deny" },
{ "action": "read", "resource": "*", "effect": "allow" },
{ "action": "read", "resource": "*.env", "effect": "deny" },
{ "action": "read", "resource": "*.env.*", "effect": "deny" },
{ "action": "webfetch", "resource": "*", "effect": "allow" },
{ "action": "websearch", "resource": "*", "effect": "allow" },
{ "action": "shell", "resource": "*", "effect": "deny" },
{ "action": "edit", "resource": "*", "effect": "deny" }
],
"mcp": {
"playwright": {
"type": "local",
"command": [
"docker", "run", "-i", "--rm", "--init",
"--pull=never",
"--memory=768m", "--cpus=1.5",
"--cap-drop=ALL",
"--security-opt", "no-new-privileges",
"--read-only",
"--tmpfs", "/tmp",
"-e", "PLAYWRIGHT_MCP_ISOLATED=true",
"-e", "PLAYWRIGHT_MCP_ALLOWED_ORIGINS=https://www.google.com;https://www.google.es;https://www.renfe.com;https://www.omio.com;https://www.alsa.es",
"mcr.microsoft.com/playwright/mcp@sha256:替换成你实际拉取到的digest"
],
"enabled": true
}
}
}
要点:
- 全局默认
deny,只放行read(且.env单独二次拒绝)、webfetch、websearch——没有任何shell、edit权限,Agent 只能看和查,不能改文件、不能执行命令。这样无人值守时不会卡在ask,也不需要冒险开allow给危险操作。 --pull=never+ digest 锁定:容器镜像版本完全固定,不会在某次运行时被静默换成新内容。你想手动升级时,重新走一遍 2.3 的拉取流程,换新的 digest。--cap-drop=ALL+--security-opt no-new-privileges:剥掉容器所有 Linux capability,就算容器内的浏览器进程被攻破,能做的系统调用范围也被砍到最小。--read-only+--tmpfs /tmp:容器整个文件系统只读,只有/tmp(内存里的临时空间,进程退出即清空)可写。因为我们只用 accessibility snapshot 提取数据,不需要保存截图/下载文件,容器不需要挂载任何宿主机目录——容器写不了宿主机的任何东西,也不会在宿主机上留下任何持久化痕迹。--memory=768m --cpus=1.5:给这台老 Intel 机器一个硬性资源上限,防止浏览器进程(尤其是同时开多个 tab 查询时)拖垮整台机器,也顺带防住了"failure 后疯狂重试打满资源"这类异常情况。--allowed-origins依然保留,但官方文档明确写了这不是安全边界,也不影响重定向,只是防止 Agent 被诱导"顺手"导航去无关网站的辅助措施。真正的边界现在是:容器隔离(这一节新加的)+ 零 shell/edit 权限(上一版就有)+ 账户隔离(最外层)。PLAYWRIGHT_MCP_ISOLATED=true是容器版对应--isolated的环境变量写法,保证浏览器不留 cookie/登录态,配合--tmpfs /tmp双重保证不留痕。
2.5 手动验证一次
在正式接入 OpenCode 之前,先单独跑一次确认容器本身没问题:
docker run -i --rm --init --pull=never \
--memory=768m --cpus=1.5 --cap-drop=ALL \
--security-opt no-new-privileges --read-only --tmpfs /tmp \
-e PLAYWRIGHT_MCP_ISOLATED=true \
mcr.microsoft.com/playwright/mcp@sha256:替换成你实际拉取到的digest \
--help
能正常打印帮助信息就说明容器权限配置没有把它自己也锁死。
3. 共享模块
3.1 ~/agent-lab/shared/data/price_history.json(初始化)
{}
3.2 ~/agent-lab/shared/price_store.py —— 价格历史存取,按路线+日期做 key,原子写
#!/usr/bin/env python3
import json, os, tempfile
STORE_PATH = os.path.join(os.path.dirname(__file__), "data", "price_history.json")
def _key(route, date_out, date_return=None):
return f"{route}|{date_out}|{date_return or ''}"
def load_all():
if not os.path.exists(STORE_PATH):
return {}
with open(STORE_PATH) as f:
return json.load(f)
def get_last_price(route, date_out, date_return=None):
data = load_all()
entry = data.get(_key(route, date_out, date_return))
return entry["price"] if entry else None
def get_historical_min(route):
"""同一条线路(不分具体日期)历史最低价,用于假日规划器横向比较"""
data = load_all()
prices = [v["price"] for k, v in data.items() if k.startswith(route + "|")]
return min(prices) if prices else None
def save_price(route, date_out, date_return, price, note=""):
data = load_all()
data[_key(route, date_out, date_return)] = {
"price": price, "note": note
}
# 原子写:先写临时文件,再 rename,避免中途崩溃导致 JSON 损坏
dir_name = os.path.dirname(STORE_PATH)
fd, tmp_path = tempfile.mkstemp(dir=dir_name)
with os.fdopen(fd, "w") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp_path, STORE_PATH)
3.3 ~/agent-lab/shared/notify.py —— Telegram 推送,token 只在这里出现
#!/usr/bin/env python3
import sys, os, json, urllib.request
def load_env(path):
"""手动解析 .env,不污染当前进程之外的任何环境——只在这一个脚本内使用"""
env = {}
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
return env
def send_telegram(token, chat_id, text):
url = f"https://api.telegram.org/bot{token}/sendMessage"
data = json.dumps({"chat_id": chat_id, "text": text}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=10)
def main():
# 用法: notify.py "推送文字"
message = sys.argv[1]
env_path = os.path.join(os.path.dirname(__file__), "..", ".env")
env = load_env(env_path)
send_telegram(env["TELEGRAM_BOT_TOKEN"], env["TELEGRAM_CHAT_ID"], message)
if __name__ == "__main__":
main()
关键改动:.env 只在 notify.py 内部被读取、被使用,check.sh 不再 source .env,opencode 进程的环境变量里完全不会出现 Telegram token。opencode 需要的 Gemini 凭据走的是 opencode auth login 写入的 ~/.local/share/opencode/auth.json,不经过 shell 环境变量,同样不会被裸露。
3.4 提取规范:Agent 只许"如实列数据",不许"下结论"
所有 prompt 统一遵循这个契约(这是对上一轮审查里"LLM 不该自己判断最低价"这条意见的直接回应):
角色:你只负责"看懂网页、如实记录",不负责判断哪个选项更好。
任务:{具体查询指令}
把页面上你看到的**每一个**可选日期/价格组合都记录下来,不要筛选、不要挑"最优",
就算只看到一条结果也如实只输出这一条。
只输出如下格式,前后各加一行分隔符,不要输出任何其它文字或 markdown 代码块:
RESULT_BEGIN
[{"date_out":"YYYY-MM-DD","date_return":"YYYY-MM-DD或留空","price":数字}, ...]
RESULT_END
RESULT_BEGIN/RESULT_END 分隔符是为了让 Python 端可以稳定地从 Agent 输出里截取出目标 JSON,不依赖对 opencode run 底层输出格式的假设(这部分我没能百分百确认官方输出协议的细节,用显式分隔符是更保险的做法,而不是假设它一定输出"干净 JSON")。
Python 侧统一的提取+决策函数:
#!/usr/bin/env python3
import re, json
def extract_data_points(agent_output: str):
m = re.search(r"RESULT_BEGIN\s*(.*?)\s*RESULT_END", agent_output, re.DOTALL)
if not m:
return []
try:
points = json.loads(m.group(1))
except json.JSONDecodeError:
return []
# 基础校验,防止脏数据进历史记录
valid = []
for p in points:
if isinstance(p.get("price"), (int, float)) and p.get("date_out"):
valid.append(p)
return valid
def pick_cheapest(points):
if not points:
return None
return min(points, key=lambda p: p["price"])
判断"最低价"、“是否降价"这些全部是 Python 里的 min()/比较,Agent 只负责把它在页面上看到的东西如实转录成结构化数据。
4. 场景一:巴黎机票监控(战术级,高频)
~/agent-lab/paris-flights/check.sh:
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
SHARED=../shared
PROMPT='角色:你只负责"看懂网页、如实记录",不负责判断哪个选项更好。
任务:用 Playwright 打开 Google Flights (https://www.google.com/travel/flights),
查询出发地 Valencia(VLC),目的地 Paris(不限机场),
往返日期:未来 8-10 周内、周五出发、周日或周一返回的组合。
把页面上你看到的每一个可选日期/价格组合都记录下来,不要筛选、不要挑"最优"。
只输出如下格式,前后各加一行分隔符,不要输出任何其它文字:
RESULT_BEGIN
[{"date_out":"YYYY-MM-DD","date_return":"YYYY-MM-DD","price":数字}, ...]
RESULT_END'
RAW=$(opencode run "$PROMPT" 2>>run.err) || { echo "opencode 调用失败"; exit 1; }
echo "$RAW" > "$SHARED/run/paris_raw_$(date +%Y%m%d_%H%M%S).txt"
python3 - "$RAW" << 'PYEOF'
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "shared"))
from extract import extract_data_points, pick_cheapest
from price_store import get_last_price, save_price
raw = sys.argv[1]
points = extract_data_points(raw)
best = pick_cheapest(points)
if not best:
print("解析失败或无有效数据点,跳过本次")
sys.exit(0)
route = "VLC-PAR"
last = get_last_price(route, best["date_out"], best["date_return"])
threshold = 0.9 # 同一日期组合,低于历史价的90%才提醒
if last is None or best["price"] < last * threshold:
msg = f"【巴黎机票降价提醒】\n{best['date_out']} → {best['date_return']}\n价格:€{best['price']}"
os.system(f'python3 ../shared/notify.py "{msg}"')
print("已推送:", msg)
else:
print(f"{best['date_out']} 当前 €{best['price']},未达阈值,静默跳过")
save_price(route, best["date_out"], best["date_return"], best["price"])
PYEOF
注:为了让这份文档独立可读,
extract.py里的两个函数请从第 3.4 节复制一份到~/agent-lab/shared/extract.py。
chmod +x ~/agent-lab/paris-flights/check.sh
手动测试一次,不要一上来就上 launchd:
cd ~/agent-lab/paris-flights
./check.sh
确认能收到 Telegram 消息、price_history.json 有更新之后,再进入下面的定时部署。
launchd 配置(每 4 小时跑一次):~/Library/LaunchAgents/com.agentlab.parisflight.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.agentlab.parisflight</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/agentlab/agent-lab/paris-flights/check.sh</string>
</array>
<key>StartInterval</key>
<integer>14400</integer>
<key>StandardOutPath</key>
<string>/Users/agentlab/agent-lab/paris-flights/run.log</string>
<key>StandardErrorPath</key>
<string>/Users/agentlab/agent-lab/paris-flights/run.err</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
StartInterval 单位是秒,14400 = 4 小时。把路径里的 agentlab 换成你实际的账户名。
launchctl load ~/Library/LaunchAgents/com.agentlab.parisflight.plist
launchctl list | grep agentlab # 确认已加载
5. 场景二:巴萨车票监控
结构和场景一完全一致,只换 prompt 和 route 标识。
~/agent-lab/bcn-train/check.sh:
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
SHARED=../shared
PROMPT='角色:你只负责"看懂网页、如实记录",不负责判断哪个选项更好。
任务:依次用 Playwright 打开 Renfe (https://www.renfe.com) 和 Omio (https://www.omio.com),
查询 Valencia 到 Barcelona 未来 2 周内、周五或周六出发的单程票价(火车和大巴都查)。
把你看到的每一个班次/价格组合都记录下来,不要筛选、不要判断哪个最好。
只输出如下格式,前后各加一行分隔符,不要输出任何其它文字:
RESULT_BEGIN
[{"date_out":"YYYY-MM-DD","date_return":"","price":数字,"mode":"train或bus"}, ...]
RESULT_END'
RAW=$(opencode run "$PROMPT" 2>>run.err) || { echo "opencode 调用失败"; exit 1; }
echo "$RAW" > "$SHARED/run/bcn_raw_$(date +%Y%m%d_%H%M%S).txt"
python3 - "$RAW" << 'PYEOF'
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "shared"))
from extract import extract_data_points, pick_cheapest
from price_store import get_last_price, save_price
raw = sys.argv[1]
points = extract_data_points(raw)
best = pick_cheapest(points)
if not best:
print("解析失败或无有效数据点,跳过本次")
sys.exit(0)
route = "VLC-BCN"
last = get_last_price(route, best["date_out"], best.get("date_return", ""))
threshold = 0.9
if last is None or best["price"] < last * threshold:
mode = best.get("mode", "")
msg = f"【巴萨车票降价提醒】\n{best['date_out']} ({mode})\n价格:€{best['price']}"
os.system(f'python3 ../shared/notify.py "{msg}"')
print("已推送:", msg)
else:
print(f"{best['date_out']} 当前 €{best['price']},未达阈值,静默跳过")
save_price(route, best["date_out"], best.get("date_return", ""), best["price"])
PYEOF
chmod +x ~/agent-lab/bcn-train/check.sh
cd ~/agent-lab/bcn-train && ./check.sh # 手动测试一次
launchd 配置(车票价格波动更频繁,间隔设短一点,2 小时):~/Library/LaunchAgents/com.agentlab.bcntrain.plist,结构和巴黎机票那份一样,只改这几处:
Label→com.agentlab.bcntrainProgramArguments里的路径 →/Users/agentlab/agent-lab/bcn-train/check.shStartInterval→7200(2 小时)- 日志路径 → 换成
bcn-train目录下的run.log/run.err
launchctl load ~/Library/LaunchAgents/com.agentlab.bcntrain.plist
launchctl list | grep agentlab
6. 新增:假日感知 AI 行程规划器
这是本次新增的核心功能。目标:不只盯着一个固定日期区间,而是结合巴伦西亚本地假期表,自动找出"性价比高的出行窗口”,每周汇总一次对比报告。
6.1 设计逻辑
valencia_holidays.json(巴伦西亚市 2026 官方假期表)
│
▼
find_bridges():Python 用 datetime 计算每个假日
│ 能不能和周末拼成长假(周二/周四假日天然形成"puente")
▼
生成候选出行窗口列表(每个窗口 = 一段日期范围)
│
▼
对每个窗口,复用场景一/二的"提取模块"分别查机票和车票
│ (只查数据点,不做判断——原则不变)
▼
Python 汇总所有窗口的最低价 + 是否邻近假日(省年假)
│
▼
按"价格"和"是否借上了公共假期"排序,生成对比表
│
▼
每周一次,把 Top N 选项通过 Telegram 推送
6.2 ~/agent-lab/shared/data/valencia_holidays.json
数据来源:2026 年巴伦西亚市官方劳动历(DOGV 公布的 Decreto 100/2025 + 2025 年 11 月市政府公布的地方假期决议)。这份文件需要每年手动更新一次,通常上一年年中/年末会公布次年日历。
{
"year": 2026,
"source": "DOGV Decreto 100/2025 (autonómico) + Resolución 12-nov-2025 (locales Valencia ciudad)",
"last_verified": "2026-08-27",
"holidays": [
{ "date": "2026-01-01", "name": "Año Nuevo", "scope": "nacional" },
{ "date": "2026-01-06", "name": "Epifanía del Señor", "scope": "nacional" },
{ "date": "2026-01-22", "name": "San Vicente Mártir (patrón de Valencia ciudad)", "scope": "local" },
{ "date": "2026-03-19", "name": "San José / Fallas", "scope": "autonómico" },
{ "date": "2026-04-03", "name": "Viernes Santo", "scope": "nacional" },
{ "date": "2026-04-06", "name": "Lunes de Pascua", "scope": "autonómico" },
{ "date": "2026-04-13", "name": "San Vicente Ferrer (patrón de la Comunitat Valenciana)", "scope": "local" },
{ "date": "2026-05-01", "name": "Fiesta del Trabajo", "scope": "nacional" },
{ "date": "2026-06-24", "name": "San Juan / Fogueres", "scope": "autonómico" },
{ "date": "2026-08-15", "name": "Asunción de la Virgen", "scope": "nacional" },
{ "date": "2026-10-09", "name": "Día de la Comunitat Valenciana", "scope": "autonómico" },
{ "date": "2026-10-12", "name": "Fiesta Nacional de España", "scope": "nacional" },
{ "date": "2026-12-08", "name": "Inmaculada Concepción", "scope": "nacional" },
{ "date": "2026-12-25", "name": "Natividad del Señor", "scope": "nacional" }
]
}
⚠️ 请在正式使用前自行核对一遍(比如 https://www.valenciabonita.es 或市政府官网的最新版本),日历数据每年由市议会决议确定,存在小概率的临时调整。这份 JSON 只是给规划器用的输入数据,不是我替你做的官方认证。
6.3 ~/agent-lab/shared/bridges.py —— 从假日表计算"puente"候选窗口(纯 Python,不涉及任何 AI 调用)
#!/usr/bin/env python3
import json, os
from datetime import date, timedelta
HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "data", "valencia_holidays.json")
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
def load_holidays():
with open(HOLIDAYS_PATH) as f:
data = json.load(f)
return [
{"date": date.fromisoformat(h["date"]), "name": h["name"], "scope": h["scope"]}
for h in data["holidays"]
]
def find_bridges():
"""
对每个假日判断能否和周末拼成长假:
- 假日在周二 → 周六到周二是4天长假(周一是自然连休日)
- 假日在周四 → 周四到周日是4天长假
- 假日在周一 → 周六到周一是3天长假
- 假日在周五 → 周五到周日是3天长假
- 假日在周中(周三)→ 没有自然连休,可能需要单独请1天假
"""
bridges = []
for h in load_holidays():
d, wd = h["date"], h["date"].weekday() # Mon=0 ... Sun=6
if wd == 1: # Tuesday
window_start, window_end = d - timedelta(days=3), d
span, kind = 4, "长周末(周六-周二),周一为自然连休日"
elif wd == 3: # Thursday
window_start, window_end = d, d + timedelta(days=3)
span, kind = 4, "长周末(周四-周日),周五为自然连休日"
elif wd == 0: # Monday
window_start, window_end = d - timedelta(days=2), d
span, kind = 3, "三天周末(周六-周一)"
elif wd == 4: # Friday
window_start, window_end = d, d + timedelta(days=2)
span, kind = 3, "三天周末(周五-周日)"
else:
window_start, window_end = d, d
span, kind = 1, f"假日在{WEEKDAY_NAMES[wd]},无自然连休(需额外请假才能拼假)"
bridges.append({
"holiday_date": d.isoformat(),
"holiday_name": h["name"],
"weekday": WEEKDAY_NAMES[wd],
"window_start": window_start.isoformat(),
"window_end": window_end.isoformat(),
"span_days": span,
"note": kind,
})
return bridges
def upcoming_bridges(from_date=None, within_days=180):
"""只看未来 N 天内、且自然连休 >= 3 天的窗口(1天的价值不大,过滤掉)"""
from_date = from_date or date.today()
result = []
for b in find_bridges():
hd = date.fromisoformat(b["holiday_date"])
if from_date <= hd <= from_date + timedelta(days=within_days) and b["span_days"] >= 3:
result.append(b)
return result
if __name__ == "__main__":
for b in upcoming_bridges():
print(f"{b['holiday_date']} {b['holiday_name']} ({b['weekday']}) → "
f"{b['window_start']} ~ {b['window_end']} [{b['note']}]")
先手动跑一次这个脚本,确认输出的候选窗口符合你的预期:
python3 ~/agent-lab/shared/bridges.py
6.4 ~/agent-lab/holiday-planner/plan.py —— 汇总查询 + 生成对比报告
#!/usr/bin/env python3
import sys, os, subprocess, json
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "shared"))
from bridges import upcoming_bridges
from extract import extract_data_points, pick_cheapest
from price_store import save_price, get_historical_min
DESTINATIONS = [
{"route": "VLC-PAR", "label": "巴黎",
"prompt_tpl": '角色:你只负责"看懂网页、如实记录",不负责判断哪个选项更好。\n\n'
'任务:用 Playwright 打开 Google Flights,查询 Valencia(VLC) 往返 Paris,'
'出发日期在 {window_start} 附近(±2天内可接受的选项),返程在 {window_end} 附近。\n\n'
'把你看到的每一个日期/价格组合都记录下来,不要筛选。\n\n'
'只输出:\nRESULT_BEGIN\n'
'[{{"date_out":"YYYY-MM-DD","date_return":"YYYY-MM-DD","price":数字}}, ...]\n'
'RESULT_END'},
{"route": "VLC-BCN", "label": "巴塞罗那",
"prompt_tpl": '角色:你只负责"看懂网页、如实记录",不负责判断哪个选项更好。\n\n'
'任务:用 Playwright 依次打开 Renfe 和 Omio,查询 Valencia 到 Barcelona,'
'出发日期在 {window_start} 附近(±1天内),火车和大巴都查。\n\n'
'把你看到的每一个班次/价格组合都记录下来,不要筛选。\n\n'
'只输出:\nRESULT_BEGIN\n'
'[{{"date_out":"YYYY-MM-DD","date_return":"","price":数字,"mode":"train或bus"}}, ...]\n'
'RESULT_END'},
]
def run_agent(prompt):
result = subprocess.run(["opencode", "run", prompt], capture_output=True, text=True, timeout=300)
return result.stdout
def main():
bridges = upcoming_bridges(within_days=180)
if not bridges:
print("未来半年内没有符合条件(自然连休>=3天)的假日窗口")
return
report_lines = []
for bridge in bridges:
for dest in DESTINATIONS:
prompt = dest["prompt_tpl"].format(
window_start=bridge["window_start"], window_end=bridge["window_end"]
)
raw = run_agent(prompt)
points = extract_data_points(raw)
best = pick_cheapest(points)
if not best:
continue
hist_min = get_historical_min(dest["route"])
save_price(dest["route"], best["date_out"], best.get("date_return", ""), best["price"])
is_new_low = (hist_min is None) or (best["price"] <= hist_min)
report_lines.append({
"holiday": f"{bridge['holiday_name']} ({bridge['holiday_date']})",
"window": f"{bridge['window_start']} ~ {bridge['window_end']} [{bridge['note']}]",
"destination": dest["label"],
"price": best["price"],
"is_new_low": is_new_low,
})
if not report_lines:
print("本轮没有查到有效数据,跳过推送")
return
# 按价格排序,生成 Top 5 对比报告
report_lines.sort(key=lambda r: r["price"])
top = report_lines[:5]
msg_parts = ["【本周假日出行选项 Top 5】"]
for r in top:
flag = " 🔻历史新低" if r["is_new_low"] else ""
msg_parts.append(
f"\n{r['destination']} · {r['holiday']}\n"
f"窗口:{r['window']}\n价格:€{r['price']}{flag}"
)
message = "\n".join(msg_parts)
subprocess.run(["python3", os.path.join(os.path.dirname(__file__), "..", "shared", "notify.py"), message])
print(message)
if __name__ == "__main__":
main()
~/agent-lab/holiday-planner/plan.sh:
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
python3 plan.py
chmod +x ~/agent-lab/holiday-planner/plan.sh
6.5 配额预算check
这个规划器每次运行会对"未来半年内每个 ≥3 天的假日窗口 × 2 个目的地"各查一次,粗略估算 2026 年下半年大概有 4-6 个符合条件的窗口,也就是每周一次运行大概 8-12 次 opencode run 调用。加上日常战术监控(巴黎 6 次/天 + 巴萨 12 次/天 ≈ 126 次/周),一周总调用量在 150 次左右,相对 Gemini 免费层 1500 次/天的额度非常宽松,不会成为瓶颈。
6.6 launchd 配置(每周一早上跑一次)
~/Library/LaunchAgents/com.agentlab.holidayplanner.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.agentlab.holidayplanner</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/agentlab/agent-lab/holiday-planner/plan.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key>
<integer>1</integer>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/agentlab/agent-lab/holiday-planner/run.log</string>
<key>StandardErrorPath</key>
<string>/Users/agentlab/agent-lab/holiday-planner/run.err</string>
</dict>
</plist>
这里用 StartCalendarInterval(每周一 9:00)而不是 StartInterval,两者是 launchd 支持的两种不同调度方式,前者更适合"每周固定时间"这种场景。
launchctl load ~/Library/LaunchAgents/com.agentlab.holidayplanner.plist
7. 防重叠:给所有 launchd 任务加锁
上一轮审查提到的"任务卡住时下一次调度又启动"问题,用一个简单的目录锁解决,加在每个 check.sh / plan.sh 开头:
LOCKDIR=/tmp/agentlab_$(basename "$0" .sh).lock
if ! mkdir "$LOCKDIR" 2>/dev/null; then
echo "上一次任务还没结束,跳过本次运行"
exit 0
fi
trap 'rmdir "$LOCKDIR"' EXIT
mkdir 在文件系统层面是原子操作,两个进程不可能同时成功创建同一个目录,用来实现锁比较可靠,macOS 没有 GNU flock 命令时这是最简单的替代方案。
8. 部署清单(v2)
-
agentlab账户搭好,未登录 Apple ID -
opencode.json用的是permissions数组写法,全局默认deny,没有任何裸ask -
.env只被notify.py读取,check.sh/plan.sh里没有任何source .env - Colima/Docker 已安装,
docker info能正常连上 - Playwright MCP 镜像已用 digest 锁定(不是
latest标签),容器加了--cap-drop=ALL --read-only --memory等加固参数,单独--help验证过能正常启动 - 部署当天核对过 Gemini 免费层当前对应的 Flash 型号 ID,配置里不是拍脑袋写的旧版本号
-
valencia_holidays.json里的日期已核对过官方来源 -
bridges.py单独跑通,输出的候选窗口符合预期 - 三个场景(巴黎/巴萨/假日规划器)都手动跑通过一次、收到过 Telegram 消息
- 三个 launchd job 都加了目录锁,
launchctl load成功 - 观察前几天日志,确认没有被目标网站限流、Gemini 调用量在预算内
9. 尚未解决 / 明确交给你自己判断的点
诚实说明这版仍然没做到、需要你自己权衡的地方:
--allowed-origins不是真正的安全边界(官方原话),容器化之后风险已经降低很多,但如果还想要更硬的网络层限制,可以在 Colima/Docker 的网络层再加一层出站规则,这份文档没有覆盖到这一细粒度- 假日数据需要每年手动更新,没有做成自动抓取官方页面刷新的机制——这是故意的,因为"自动解析政府公告页面"本身就是一个不小的可靠性风险点,暂时手动维护更稳妥
- Google Flights / Renfe / Omio 的页面结构变化仍然可能让 Agent 提取失败,
extract_data_points在解析失败时会静默跳过而不是报错崩溃,你需要偶尔看一眼run.err和run.log确认没有连续多次失败 - 容器化只覆盖了 Playwright 浏览器这一个环节,
opencode本身、node/npm这些还是直接跑在agentlab账户下——这是有意的取舍:浏览器是"渲染不可信第三方内容"这个最危险的动作,优先把它关进容器;opencode本体没有 shell/edit 权限,风险已经通过权限模型压低,暂不需要跟着一起容器化,等哪天权限模型放开了再重新评估