开发指南
本文档面向希望参与 pt-tools 开发或从源码构建的开发者。
环境要求
| 依赖 | 版本要求 | 说明 |
|---|---|---|
| Go | 1.26.7 | 后端开发语言;以 go.mod 为准 |
| Node.js | 25.2.0 | 前端构建环境;以 .node-version 为准 |
| pnpm | 10.25.0 | 前端包管理器;以 packageManager 为准 |
安装依赖
Go:
# Linux(下载官方归档)
wget https://go.dev/dl/go1.26.7.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.26.7.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
# macOS (使用 Homebrew)
brew install goNode.js 和 pnpm:
# 使用 nvm 安装 Node.js
nvm install 25.2.0
nvm use 25.2.0
# 安装仓库固定的 pnpm 版本
npm install -g pnpm@10.25.0从源码构建
克隆仓库
git clone https://github.com/sunerpy/pt-tools.git
cd pt-tools构建前端
cd web/frontend
pnpm install --frozen-lockfile
pnpm build
cd ../..构建产物会输出到 web/static/dist/,并通过 go:embed 嵌入 Go 二进制。
构建后端
go build -o pt-tools .使用 Makefile
推荐使用 Makefile 进行构建,它封装了完整的构建流程:
# 标准构建(前端 + 后端,输出 dist/pt-tools)
make build
# 带版本元数据的本机开发构建(会先格式化)
make build-local
# 仅构建前端
make build-frontend
# 完整本地门禁:工具链、格式、lint、测试与构建
make check后端单独编译可运行 go build -o dist/pt-tools .。所有二进制构建产物位于 dist/。
开发模式
开发模式下,前端和后端可以分别启动,支持热重载:
终端 1 - 启动前端开发服务器:
cd web/frontend
pnpm dev前端开发服务器默认运行在 http://localhost:5173。
终端 2 - 启动后端服务:
go run main.go web --port 8080也可以运行 make run-dev:该目标先生成前端生产产物,再在默认 8080 端口启动后端;它不提供前端热更新。
后端服务运行在 http://localhost:8080,Vite 开发服务器会把 /api 与 /logout 代理到该端口。
开发环境配置:
- 前端会自动代理 API 请求到后端
- 修改前端代码后会自动热重载
- 修改后端代码需要重新运行
技术架构
项目结构
pt-tools/
├── cmd/ # CLI 命令定义 (Cobra)
├── config/ # 日志配置 (Zap)
├── core/ # 运行时生命周期、配置存储、迁移
├── global/ # 全局单例(logger, DB)
├── internal/ # 统一站点接口、RSS 处理器、过滤器
├── models/ # GORM 模型定义
├── scheduler/ # RSS 任务调度器
├── site/v2/ # 站点驱动和定义
│ └── definitions/ # 各站点具体实现
├── thirdpart/ # 第三方集成
│ └── downloader/ # 下载器接口
├── utils/ # 工具函数
├── web/ # HTTP 服务和 API
│ ├── api_*.go # API 处理器
│ └── frontend/ # Vue 3 前端项目
├── tools/
│ └── browser-extension/ # 浏览器扩展 (TypeScript + Vue 3)
├── main.go # 程序入口
├── Makefile # 构建脚本
└── go.mod # Go 模块定义性能优化
pt-tools 采用了多项性能优化措施:
| 优化项 | 说明 |
|---|---|
| 内存缓存 | 两级缓存减少数据库访问 |
| 熔断器 | 自动检测和隔离故障站点 |
| 连接池 | HTTP 连接复用,减少连接开销 |
| 持久化限流 | SQLite 存储滑动窗口,重启后限流状态不丢失 |
| 并发控制 | 合理的并发数避免被站点封禁 |
贡献指南
欢迎贡献代码或提交问题!
提交 Issue
在提交 Issue 前,请:
- 搜索现有 Issue,避免重复
- 使用清晰的标题描述问题
- 提供详细的复现步骤
- 附上相关日志或截图
Issue 地址:GitHub Issues
提交 Pull Request
- Fork 仓库到你的账户
- 从最新
main创建分支:git switch -c feat/your-feature - 运行
make check - 使用 Conventional Commit 提交:
git commit -m "feat(scope): describe the change" - 推送分支并创建面向
main的 Pull Request
PR 地址:GitHub Pull Requests
添加新站点支持
没有编程经验? 请参考 请求新增站点支持(无需编程经验),你只需要提供站点页面数据,维护者会帮你完成适配。
添加新 PT 站点只需创建 一个定义文件,系统会自动完成以下工作:
- 从
SiteDefinitionRegistry生成运行时元数据 - 在
SiteRegistry中注册站点 - API 返回
is_builtin: true标识为内置站点 - 前端自动识别为不可删除的预置站点
无需手动编辑 models/enter.go、web/server.go 或前端代码。
站点定义模板:
package definitions
import (
v2 "github.com/sunerpy/pt-tools/site/v2"
)
var MySiteDefinition = &v2.SiteDefinition{
// 必填字段
ID: "mysite", // 唯一标识符(小写)
Name: "MySite", // 显示名称
Schema: v2.SchemaNexusPHP, // 见下方「Schema 枚举」
URLs: []string{"https://mysite.com/"},
// 可选字段(有默认值)
AuthMethod: v2.AuthMethodCookie, // 见下方「AuthMethod 枚举」(根据 Schema 自动推断,通常无需设置)
RateLimit: 2.0, // 请求频率限制(默认 2.0 req/s,持久化存储)
RateBurst: 5, // 突发请求数(默认 5)
// 可选元数据
Aka: []string{"MS", "MySite别名"},
Description: "站点描述",
FaviconURL: "https://mysite.com/favicon.ico",
TimezoneOffset: "+0800",
// 站点特定选择器(覆盖 Schema 默认值)
Selectors: &v2.SiteSelectors{
// 搜索结果页免费图标选择器
DiscountIcon: "img.pro_free",
// 自定义免费关键词映射(可选,nil 时使用默认映射)
DiscountMapping: map[string]v2.DiscountLevel{
"custom_free": v2.DiscountFree,
},
},
// 详情页解析配置(用于 RSS 详情获取)
DetailParser: &v2.DetailParserConfig{
DiscountSelector: "h1 font", // 免费标签选择器
DiscountMapping: map[string]v2.DiscountLevel{ // CSS class → 免费等级
"free": v2.DiscountFree,
"twoupfree": v2.Discount2xFree,
},
TimeLayout: "2006-01-02 15:04:05", // 时间格式
},
// 用户信息解析配置
UserInfo: &v2.UserInfoConfig{...},
// 等级要求
LevelRequirements: []v2.SiteLevelRequirement{...},
}
func init() {
v2.RegisterSiteDefinition(MySiteDefinition)
}Schema 枚举(v2.Schema 类型,定义在 site/v2/types.go):
| 枚举常量 | 值 | 站点类型 | 默认 AuthMethod |
|---|---|---|---|
v2.SchemaNexusPHP | "NexusPHP" | NexusPHP 架构站点 | cookie |
v2.SchemaMTorrent | "mTorrent" | M-Team 等 | api_key |
v2.SchemaGazelle | "Gazelle" | Gazelle 架构站点 | cookie |
v2.SchemaUnit3D | "Unit3D" | Unit3D 架构站点 | api_key |
v2.SchemaHDDolby | "HDDolby" | HDDolby 专用 | cookie_and_api_key |
v2.SchemaRousi | "Rousi" | RousiPro 自定义 API | passkey |
AuthMethod 枚举(v2.AuthMethod 类型,定义在 site/v2/types.go):
| 枚举常量 | 值 | 说明 |
|---|---|---|
v2.AuthMethodCookie | "cookie" | 浏览器 Cookie 认证 |
v2.AuthMethodAPIKey | "api_key" | API Key 认证 |
v2.AuthMethodCookieAndAPIKey | "cookie_and_api_key" | Cookie + API Key 双重认证 |
v2.AuthMethodPasskey | "passkey" | Passkey 认证(用于 RSS/下载) |
添加步骤:
- 在
site/v2/definitions/创建<sitename>.go - 参考现有实现(如
hdsky.go、mteam.go) - 必须 创建
<sitename>_fixture_test.go,提供 fixture 数据证明解析逻辑正确(见下方「Fixture 测试要求」) - 运行测试:
go test ./site/v2/...(CI 会自动验证定义的完整性和正确性) - 更新站点列表文档:
docs/sites.md - 提交 PR
注意:旧版本需要手动更新
models/enter.go中的AllowedSiteGroups和前端的预置站点列表,现已不再需要。系统会从 Registry 自动识别内置站点。
自定义驱动(单文件模式):
如果站点架构与现有 Schema 不兼容,可以在 同一个文件 中实现完整的驱动逻辑。参考 definitions/rousipro.go 的实现:
package definitions
import (
"context"
"encoding/json"
"fmt"
v2 "github.com/sunerpy/pt-tools/site/v2"
"go.uber.org/zap"
)
var CustomSiteDefinition = &v2.SiteDefinition{
ID: "customsite",
Name: "CustomSite",
Schema: v2.Schema("CustomSite"), // 在 site/v2/types.go 中添加新 Schema 常量
URLs: []string{"https://customsite.com/"},
CreateDriver: createCustomDriver,
}
func init() {
v2.RegisterSiteDefinition(CustomSiteDefinition)
}
func createCustomDriver(config v2.SiteConfig, logger *zap.Logger) (v2.Site, error) {
var opts v2.CustomOptions
if err := json.Unmarshal(config.Options, &opts); err != nil {
return nil, err
}
driver := &customDriver{baseURL: config.BaseURL, apiKey: opts.APIKey}
return v2.NewBaseSite(driver, v2.BaseSiteConfig{
ID: config.ID,
Name: config.Name,
Kind: v2.SiteKind("custom"),
Logger: logger,
}), nil
}
// customDriver 实现 v2.Driver 接口
type customDriver struct {
baseURL string
apiKey string
}
// 实现 Driver 接口的方法...
func (d *customDriver) PrepareSearch(q v2.SearchQuery) (customReq, error) { ... }
func (d *customDriver) Execute(ctx context.Context, req customReq) (customRes, error) { ... }
func (d *customDriver) ParseSearch(res customRes) ([]v2.TorrentItem, error) { ... }
func (d *customDriver) GetUserInfo(ctx context.Context) (v2.UserInfo, error) { ... }
func (d *customDriver) PrepareDownload(id string) (customReq, error) { ... }
func (d *customDriver) ParseDownload(res customRes) ([]byte, error) { ... }当设置了 CreateDriver 时,系统会优先使用它而非基于 Schema 的驱动查找。这种模式的优势是 新站点只需要一个文件,所有驱动逻辑都包含在 definitions/<site>.go 中。
Fixture 测试要求
所有站点(无论是复用 NexusPHP 还是自定义驱动)都 必须 提供 fixture 测试,以便仓库 owner 通过审查实际数据来判断 PR 的正确性。
测试文件命名:definitions/<sitename>_fixture_test.go
FixtureSuite 注册(必须)
每个站点 必须 在 init() 中注册 FixtureSuite,包含三个必填测试函数:
| 字段 | 验证内容 | 说明 |
|---|---|---|
Search | 搜索/RSS 列表解析 | 标题、大小、免费状态、免费结束时间 |
Detail | 种子详情页解析 | 免费等级、HR 状态、大小 |
UserInfo | 用户信息解析 | 上传量、下载量、分享率、等级 |
TestAllSites_FixtureCoverage(在 definitions_validation_test.go 中)会自动遍历所有注册站点,检查是否有 FixtureSuite 且三个字段非 nil。缺少任一字段的新站点 CI 会直接失败。
func init() {
RegisterFixtureSuite(FixtureSuite{
SiteID: "mysite",
Search: testMySiteSearch,
Detail: testMySiteDetail,
UserInfo: testMySiteUserInfo,
})
}辅助函数
测试框架(definitions/fixture_helper_test.go)提供以下辅助函数:
RequireNoSecrets(t, "fixture_name", data)
resp := DecodeFixtureJSON[ResponseType](t, "fixture_name", jsonString)
doc := FixtureDoc(t, "fixture_name", htmlString)隐私保护:RequireNoSecrets 自动扫描以下模式,匹配则测试失败:
| 检测内容 | 说明 |
|---|---|
c_secure_uid=... 等 NexusPHP cookie | 真实登录凭证 |
PHPSESSID=... | PHP 会话 ID |
passkey=<32+位hex> | 真实 passkey |
Bearer <32+位token> | 真实 API Token(FAKE_/TEST_ 前缀除外) |
规则:
- 所有 fixture 数据 必须 是 Go 字符串常量,定义在
_test.go文件中(不会编入生产二进制) - 用户名、ID 等应脱敏(使用虚构值)
- 凭证字段使用
FAKE_TEST_前缀
NexusPHP 站点(HTML fixture)
NexusPHP 站点通过 CSS 选择器解析 HTML 页面。参考 hdsky_fixture_test.go:
package definitions
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v2 "github.com/sunerpy/pt-tools/site/v2"
)
func init() {
RegisterFixtureSuite(FixtureSuite{
SiteID: "mysite",
Search: testMySiteSearch,
Detail: testMySiteDetail,
UserInfo: testMySiteUserInfo,
})
}
// 脱敏的搜索页 HTML — 保留真实 DOM 结构,替换敏感数据
const mysiteSearchFixture = `<html><body>
<table class="torrents"><tbody>
<tr>
<td class="rowfollow"><img alt="Movie" /></td>
<td class="rowfollow">
<table class="torrentname"><tr><td class="embedded">
<a href="details.php?id=12345">Test.Movie.2025</a>
<img class="pro_free" src="pic/trans.gif" alt="Free"
onmouseover="domTT_activate(this, event, 'content', '<span title="2026-03-01 12:00:00">29天</span>')" />
</td></tr></table>
</td>
<td class="rowfollow"></td>
<td class="rowfollow"><span title="2025-01-15 08:30:00">1天前</span></td>
<td class="rowfollow">42.5 GB</td>
<td class="rowfollow">150</td>
<td class="rowfollow">10</td>
<td class="rowfollow">500</td>
</tr>
</tbody></table>
</body></html>`
func testMySiteSearch(t *testing.T) {
def, ok := v2.GetDefinitionRegistry().Get("mysite")
require.True(t, ok)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(mysiteSearchFixture))
}))
defer server.Close()
driver := v2.NewNexusPHPDriver(v2.NexusPHPDriverConfig{
BaseURL: server.URL, Cookie: "test=1", Selectors: def.Selectors,
})
driver.SetSiteDefinition(def)
res, err := driver.Execute(context.Background(), v2.NexusPHPRequest{Path: "/torrents.php", Method: "GET"})
require.NoError(t, err)
items, err := driver.ParseSearch(res)
require.NoError(t, err)
require.Len(t, items, 1)
assert.Equal(t, v2.DiscountFree, items[0].DiscountLevel)
assert.False(t, items[0].DiscountEndTime.IsZero())
}
func testMySiteDetail(t *testing.T) {
def, ok := v2.GetDefinitionRegistry().Get("mysite")
require.True(t, ok)
doc := FixtureDoc(t, "detail", mysiteDetailFixture)
parser := v2.NewNexusPHPParserFromDefinition(def)
info := parser.ParseAll(doc.Selection)
assert.Equal(t, v2.DiscountFree, info.DiscountLevel)
assert.NotEmpty(t, info.TorrentID)
}
func testMySiteUserInfo(t *testing.T) {
def, ok := v2.GetDefinitionRegistry().Get("mysite")
require.True(t, ok)
driver := v2.NewNexusPHPDriver(v2.NexusPHPDriverConfig{
BaseURL: def.URLs[0], Cookie: "test=1",
})
driver.SetSiteDefinition(def)
doc := FixtureDoc(t, "index", mysiteIndexFixture)
for field, expected := range map[string]string{"id": "12345", "name": "TestUser"} {
sel := def.UserInfo.Selectors[field]
assert.Equal(t, expected, driver.ExtractFieldValuePublic(doc, sel))
}
}自定义驱动站点(JSON fixture)
自定义驱动站点使用 JSON API。参考 rousipro_fixture_test.go:
func init() {
RegisterFixtureSuite(FixtureSuite{
SiteID: "mysite",
Search: testMySiteSearch,
Detail: testMySiteDetail,
UserInfo: testMySiteUserInfo,
})
}
func testMySiteSearch(t *testing.T) {
resp := DecodeFixtureJSON[myResponse](t, "search", mySearchFixtureJSON)
driver := newMyDriver(myDriverConfig{BaseURL: "https://mysite.com", Passkey: "FAKE_TEST_KEY"})
items, err := driver.ParseSearch(resp)
require.NoError(t, err)
require.Len(t, items, 2)
assert.Equal(t, v2.DiscountFree, items[0].DiscountLevel)
}
func testMySiteDetail(t *testing.T) {
// 验证 JSON detail 解析 + 免费状态判断
}
func testMySiteUserInfo(t *testing.T) {
// 验证用户信息 JSON 解析
}提示:
TestCreateDriver_Smoke(在definitions_validation_test.go中)会自动遍历所有CreateDriver站点。新增自定义驱动时,在fakeOptionsForSchema()中添加对应 Schema 的 fake options 即可。
代码规范
运行代码检查
# 提交前推荐:执行工具链、格式、lint、race 测试和构建门禁
make check
# 需要分开定位问题时
make fmt-check
make lint
make unit-test
make build
# 修改后统一格式化
make fmtGo 代码规范
- 遵循 Effective Go 指南
- 使用
gofmt格式化代码 - 导入分组:标准库、第三方库、项目内部包
- 错误处理:不要忽略错误,适当包装错误信息
- 注释:导出的函数和类型必须有文档注释
前端代码规范
- 遵循 Vue 3 Composition API 风格
- 使用 TypeScript 类型注解
- 组件命名使用 PascalCase
- 使用 Oxlint、
vue-tsc与 Oxfmt;不要引入另一套格式化器
提交信息规范
<type>(<scope>): <subject>
<body>
<footer>Type 类型:
feat: 新功能fix: Bug 修复docs: 文档更新style: 代码格式(不影响功能)refactor: 重构test: 测试相关chore: 构建/工具相关
示例:
feat(site): 新增 NewSite 站点适配
- 增加 Cookie 认证与页面解析
- 补充搜索、详情和用户信息 fixture 测试
- 同步扩展站点清单与文档
Closes #123浏览器扩展开发与发布
本地开发
cd tools/browser-extension
pnpm install --frozen-lockfile
pnpm dev # watch 模式
pnpm build # 单次构建
cd ../..
make build-extension # 站点一致性检查 + 构建 + 打包 zipmake check-sites 会比较 Go 站点定义与扩展 KNOWN_SITES;新增站点时必须保持两处一致。详见扩展 README。
发布模型
浏览器扩展与 pt-tools 主程序使用同一个版本和同一个 Release,不再使用独立的 ext-v* tag:
- Conventional Commit 合入受保护的
main后,release-please 更新版本、CHANGELOG.md和扩展package.json。 - 合并 release PR 后,同一次
Releaseworkflow 创建 draft,并并行构建 Go 归档、扩展zip/crx与容器镜像。 - 所有承诺产物和
checksums.txt校验通过后,workflow 才把 draft 切换为公开 Release;任一必需 job 失败都会保留 draft。 - 稳定版公开后,
Publish to Edge Add-ons作为发布后的副作用提交pt-tools-helper.zip;RC 不提交商店。
重要
不要手工创建 release tag,也不要通过 ext-v* tag 发布扩展。普通发版由 release-please 管理;workflow_dispatch 只用于重建一个已经存在且仍为 draft 的 tag。
Edge 商店发布需要仓库 Secrets:
| Secret | 用途 |
|---|---|
EDGE_PRODUCT_ID | Edge Add-ons 产品 ID |
EDGE_CLIENT_ID | Publish API 客户端 ID |
EDGE_API_KEY | Publish API 密钥 |
仓库变量 PUBLISH_EDGE=false 可在发布链验收时跳过商店提交;变量缺失或不是 false 时保持默认发布行为。Edge 提交不影响已经通过资产门禁并公开的 GitHub Release,但除「前一次 submission 仍在审核」之外的 API 或包错误仍会让该 job 失败,需单独排查。
如有开发相关问题,欢迎在 GitHub Discussions 讨论。