-
Notifications
You must be signed in to change notification settings - Fork 0
Development
git clone https://github.com/yuanqimanong/IPClick.git
cd IPClick
uv sync --all-groups要连可选适配器一起装,得逐个 --extra 列出:
uv sync --all-groups \
--extra niquests --extra camoufox --extra patchright --extra drissionpage不能用 --all-extras。 [tool.uv].conflicts 里声明了两组互斥:
[camoufox] × [playwright](camoufox 钉着 playwright<1.61,而 [playwright] extra
要 >=1.62.0),以及 [linux] × [playwright]——后者是因为 [linux] 本身聚合了
camoufox,所以 --extra linux --extra playwright 同样会失败。--all-extras 会一次点亮
所有互斥项,必定解不出来。声明成互斥的好处是 uv 会为两者各解一份依赖,[playwright]
才能跟上上游版本。
pyproject 一共 7 个 extras,其中两个是按平台的聚合:[win] = DrissionPage,
[linux] = camoufox + patchright。
不用 uv 的话:
pip install -e ".[niquests,patchright]"
pip install ruff basedpyright pytest pytest-asyncio按用途分组,让每个 CI job 只装自己要的那份:
| 组 | 装什么 |
|---|---|
test |
pytest、pytest-asyncio |
lint |
ruff、basedpyright |
proto |
grpcio-tools |
release |
twine |
dev |
上面四组的聚合(默认组) |
构建后端是 uv_build,包根在 src/。
提交前跑这四条:
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
uv run basedpyright src/ tests/
uv run pytest-
basedpyright 覆盖
src/和tests/两处,warning 也算失败。 它在有 warning 时 退出码同样是 1,所以本地和 CI 判定一致。当前是全 0。 -
ruff format 不能碰生成文件 ——
task_pb2.py/task_pb2.pyi已在排除列表里。 漏掉的话 format 会改写它们,之后每次重新生成 protobuf 都产生一堆无意义 diff。 注意.pyi必须单独排除:"*_pb2*.py"这个 glob 匹配不到它。 -
没有 pytest-cov。 覆盖率不在门禁里,
--cov参数用不了;要看覆盖率自己临时装。
ci.yml 有五个 job:
| job | 干什么 |
|---|---|
checks |
上面四条门禁的前三条 + 验证 protobuf 生成产物是最新的。装 lint / proto 两组依赖 + 四个可选 extra(niquests / camoufox / patchright / drissionpage,刻意不含 playwright——它与 camoufox 互斥),好让 basedpyright 能解析它们的 import |
test (3.11) / test (3.14)
|
刻意不装任何 extra——绝大多数用户只装核心依赖,这个 job 同时验证"可选适配器缺席时仍能 import 与运行" |
build |
uv build + twine check + 校验包数据(配置模板、proto、py.typed、SKILL.md)都打进了 wheel |
docker |
先建 ENGINE=none 的 slim 镜像快速验 Dockerfile,再建实际发布的默认镜像,最后断言 ipclick component list 里 patchright 显示「就绪」——确认浏览器本体真被打进去、且非 root 用户读得到。刻意不发真实请求,CI 里依赖外部站点会引入无关的红 |
Python 版本只测 requires-python 的两端(3.11 / 3.14)。中间的 3.12 / 3.13 仍是
声称支持但不做验证——这是换 CI 时间的有意取舍。
别把矩阵写成
"3.14t":自由线程版的 SOABI 是cpython-314t,grpcio 没发对应轮子, 会退化成源码编译并卡在工具链上。
# 改完 src/ipclick/dto/proto/task.proto 之后
uv run python src/ipclick/dto/proto/generate.py这个脚本调 protoc,并把生成代码里的顶层导入改成包内相对导入。
CI 会验证生成产物是最新的——改了 .proto 不重新生成会直接失败。
| 做法 | 允许? |
|---|---|
| 加新字段(新字段号) | ✅ |
字段 string → bytes
|
✅ wire 兼容,编码完全相同 |
| 删字段 | ✅ 但字段号必须进 reserved,永不复用 |
| 删枚举值 | ❌ 标 deprecated = true 保留 |
| 改字段号 | ❌ |
| 改字段类型(除上面那条) | ❌ |
枚举值标 deprecated 而不是删掉,是为了让老客户端传 HTTPX 时能得到
"已移除,请改用 niquests"这样一句有用的话,而不是"未知枚举值"。
需要区分"没传"和"传了默认值"时用 optional(显式 presence)——verify_ssl、
allow_redirects、stream 这类布尔字段少了它就分不清"显式传 false"和"没传",
服务端只能把两者一律当成 false,"未设置时按 true 处理"这种默认值就无从实现。
src/ipclick/
├── rpc/ # gRPC 通道与选项的唯一来源
│ ├── options.py keepalive 等 channel 选项(客户端与服务端在此对齐)
│ └── channel.py secure / insecure 频道构造
├── adapters/ # 适配器:curl_cffi / niquests / 四个浏览器引擎
│ ├── base.py 适配器基类、脚本规范化、错误分类
│ ├── retry.py 重试策略与重试循环(同步 / 协程共用)
│ ├── sessions.py 会话缓存(同步 / 异步,含关闭)
│ ├── registry.py 名字 → 实现,以及"没装"与"已移除"两张提示表
│ ├── browser_adapter.py 浏览器适配器与实例复用
│ └── browser_engines.py 引擎安装状态检测(三态)
├── services/ # gRPC 服务实现
│ ├── task_service.py 主服务:准入、限流、执行、记链路
│ ├── async_task_service.py 协程版
│ ├── errors.py 异常 → gRPC status code 的规则表
│ ├── components.py 远程组件管理
│ └── detached.py 脱离真实 RPC 的 ServicerContext
├── cluster/ # 集群
│ ├── node.py 节点模型与配置
│ ├── pool.py 节点池与健康探测
│ ├── balancer.py 轮询 / 随机 / 加权
│ ├── client.py 客户端分发 + 故障转移
│ ├── forwarder.py 服务端转发(async_forwarder.py 是协程版)
│ ├── discovery.py static / dns 节点发现
│ ├── probe.py 节点探测:连通性与集群内部鉴权分开报
│ ├── status_page.py 只读状态页
│ └── tokens.py 共享密钥派生每节点令牌
├── web/ # Web 管理端
│ ├── server.py HTTP、会话、CSRF、路由、CSP
│ ├── auth.py 凭据、会话、登录限速
│ ├── snapshot.py 仪表盘 / 请求流 / 集群的数据快照
│ ├── pages/ 各页面的数据与操作(门面 + 5 个页面对象)
│ ├── templates/ 渲染,按页面分文件
│ ├── assets.py CSS 与两段内联 JS —— CSP 脚本哈希的唯一来源
│ ├── installer.py 受限子进程装 / 卸可选组件(白名单 + 后台任务)
│ ├── deploy.py 为子节点生成 toml / .env / 启动命令 / zip
│ ├── curl_parser.py 把 curl 命令解析成「试一试」表单
│ └── editable.py 可改配置的白名单
├── config_loader/ # 配置加载与写回
├── dto/ # 数据模型与 protobuf
├── utils/ # coerce / config_util / url_util / log_util …
├── server.py # 服务端装配与启动
├── async_server.py # 协程服务端
├── server_settings.py # [SERVER] 段的解析、校验与派生
├── multiprocess.py # 多进程工作模式(SO_REUSEPORT)
├── protocols.py # 跨层协议类型
├── limiter.py # 按 host 的并发与速率闸门(async_limiter.py 是协程版)
├── trace/ # 链路记录:records / counters / store / recorder 四层
└── sdk.py / aio.py # 同步 / 异步客户端
几处刻意的分层:
-
trace/拆成四个模块。records(一条记录的形状、[TRACE]配置、URL 脱敏)、counters(本进程累计计数——它不落盘、不保留单条记录,和链路记录没有共同状态)、store(SQLite 建表/迁移/异步写入/保留期/查询,以及库文件的进程认领协议)、recorder(门面与全局单例)。公开名字仍从ipclick.trace导出,from ipclick.trace import TraceRecord这类写法不受影响。 -
逐跳重定向跟随只有一份实现(
adapters/redirects.py的HopFollowingMixin), 由curl_cffi与niquests共用。这一层压着 SSRF 准入,分叉过一次就出现过 "某条路径不校验"的缺口,所以有用例断言两个适配器解析到的是同一个函数对象。 同理automation_config的解析与等待钳位统一在adapters/browser_settings.py, 由两个浏览器适配器共用。 -
web/server.py与web/pages/分开。 前者管 HTTP(路由、会话、CSRF、响应头), 后者管"页面展示什么、提交上来怎么处理"。混在一起的话,每加一页都得往 HTTP 处理器里 塞一段业务代码。 -
rpc/options.py是 keepalive 的唯一来源。 这些选项曾在三处各抄一份, 结果客户端 ping 间隔撞上服务端下限,空闲通道必然被 GOAWAY 掉。 现在min_ping_interval_without_data由客户端 keepalive 间隔推导(取一半), 改一处就够;min_time_between_pings仍是写死的 10 秒,它只要小于客户端间隔就安全。 -
utils/coerce.py收敛所有配置强转。 宽松读取(读不出来用默认值)与严格校验 (读不出来报错)是两套函数,别混用。
430 个测试(其中 1 个只在 POSIX 上跑)。
uv run pytest # 全部
uv run pytest tests/test_trace.py # 单个文件
uv run pytest -k "cluster" # 按名字筛
uv run pytest -x -q # 第一个失败就停
uv run pytest -m "not slow" # 跳过起真实端口的用例slow 标记的是起真实 gRPC 端口的用例,比纯单元测试慢一个数量级。
写测试的几条约定:
- 测行为,不测实现。 断言"配错了会报错",而不是"调用了某个内部函数"。
- 一个测试一件事,名字说清楚断言的是什么。
- 随机/时间相关的测试要真的稳。 压缩启发式那个测试早期是个 5% 概率翻车的硬币—— 阈值 10% 撞上随机字节的期望控制字符占比 10.9%。发现这种"偶尔红"的测试, 修的是判据本身,不是加重试。
-
测试里碰私有成员是允许的(
reportPrivateUsage在tests/下关掉了)—— 要盯住内部协作者的行为(错误映射、请求参数拼装)就得碰。
这几条踩过不止一次:
-
pkill -f "xxx"会匹配到它自己的命令行。 用ps -eo pid,etimes,cmd | grep "[x]xx"拿 PID 再 kill;找监听进程用ss -lptn "sport = :9528"。 -
handle_error是socketserver.BaseServer的方法,不是 handler 的。 写在 handler 上是个静默的 no-op。 -
SQLite 的
auto_vacuumPRAGMA 只在建库时生效,已有的库改不了(要VACUUM重建)。 - schema 迁移必须在建索引之前跑,否则新索引引用的列还不存在。
- Web 管理端是表单登录 + 会话 cookie,不是 HTTP Basic。 拿 Basic auth 去探路由会 每条都拿到登录页(字节数还都一样),看起来像"所有路由返回同一内容"的假 bug。
-
/nodes会跳到/config?tab=cluster;没配集群时/deploy也回落到同一页。 两者内容相同是预期行为。
Conventional Commits:
feat(cluster): 支持服务端转发
fix(browser): 复用浏览器实例前检查连接存活
docs: 补充集群鉴权说明
refactor(web)!: 拆分 HTTP 层与页面业务逻辑
! 表示破坏性变更,正文里要写清楚怎么迁移。
- 从
master开分支 - 写代码 + 测试
- 四条门禁全绿
- Conventional Commits
- PR 说明里写清楚为什么,不只是改了什么
改了行为的话记得同步:README.md、configs/default_config.toml 里的注释
(那是配置项的唯一权威来源)、以及本 wiki。
- 改
pyproject.toml的version - 四条门禁全绿
- 合并到
master - 打标签并推:
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z推标签会触发 release.yml:
build ──→ publish-pypi ──→ github-release
│ │
│ └─ environment: pypi,配了必需审批人,
│ 上传前停下来等人工确认
│
└─ 重跑全部门禁 + uv build + 校验标签与 pyproject 版本号一致
两处刻意的设计:
- PyPI 上传前有人工审批门。 PyPI 同一版本号永远不能重传,只能 yank—— 这一步不可撤销,值得停一下。
-
不设
skip-existing。 正式发布时重复版本号应当失败并让人知道, 而不是静默跳过让你以为发出去了。
发布流程里还有一道泄漏文件检查,确认测试文件没被打进 wheel。它只认真正的测试
目录和 pytest 命名约定——早先用 "test" in name 这种子串匹配,包内合法存在的
test.py 会让发版直接失败。
代码里的注释解释的是为什么这么写,尤其是"为什么不用那个看起来更自然的写法"。 一个默认值的选择、一处不重试的判断、一个显式关掉的 gRPC 选项——这些不写下来, 下一个人(包括三个月后的你)会把它改回去,然后重新踩一遍。