client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
module everypublish/agentcore
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
everypublish/shared v0.0.0
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/go-rod/stealth v0.4.9
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
)
|
||||
|
||||
replace everypublish/shared => ../../shared
|
||||
@@ -0,0 +1,24 @@
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/go-rod/rod v0.113.0/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw=
|
||||
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||
github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4=
|
||||
github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc=
|
||||
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
|
||||
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
|
||||
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
|
||||
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
|
||||
github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
|
||||
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM=
|
||||
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
|
||||
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
|
||||
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
|
||||
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
|
||||
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
|
||||
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
||||
github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config 客户端配置(data/config.json)
|
||||
type Config struct {
|
||||
Server string `json:"server"`
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
Name string `json:"name"`
|
||||
Proxy string `json:"proxy"`
|
||||
SimMS int `json:"simMs"`
|
||||
FakeAll bool `json:"fakeAll"` // 全平台模拟执行(无真实账号联调用)
|
||||
}
|
||||
|
||||
// Default 默认配置
|
||||
func Default() *Config {
|
||||
return &Config{Server: "http://127.0.0.1:8090", SimMS: 50}
|
||||
}
|
||||
|
||||
// Load 读取配置(不存在则返回默认)
|
||||
func Load(dir string) (*Config, error) {
|
||||
cfg := Default()
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(raw, cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save 持久化配置
|
||||
func (c *Config) Save(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.MarshalIndent(c, "", " ")
|
||||
return os.WriteFile(filepath.Join(dir, "config.json"), raw, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Identity 设备身份:Ed25519 密钥对 + 服务器分配的设备 ID
|
||||
type Identity struct {
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
priv ed25519.PrivateKey `json:"-"`
|
||||
}
|
||||
|
||||
// LoadOrCreate 加载或生成本机设备身份(私钥 hex 文件 0600)
|
||||
func LoadOrCreate(dir string, name string) (*Identity, error) {
|
||||
keyPath := filepath.Join(dir, "device.key")
|
||||
idPath := filepath.Join(dir, "identity.json")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var priv ed25519.PrivateKey
|
||||
if raw, err := os.ReadFile(keyPath); err == nil {
|
||||
seed, err := hex.DecodeString(string(raw))
|
||||
if err != nil || len(seed) != ed25519.SeedSize {
|
||||
return nil, errors.New("device key corrupted")
|
||||
}
|
||||
priv = ed25519.NewKeyFromSeed(seed)
|
||||
} else {
|
||||
_, priv, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.WriteFile(keyPath, []byte(hex.EncodeToString(priv.Seed())), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
id := &Identity{
|
||||
Name: name,
|
||||
PublicKey: base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)),
|
||||
priv: priv,
|
||||
}
|
||||
if raw, err := os.ReadFile(idPath); err == nil {
|
||||
_ = json.Unmarshal(raw, id)
|
||||
id.priv = priv
|
||||
if id.Name == "" {
|
||||
id.Name = name
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Save 持久化设备 ID(配对成功后调用)
|
||||
func (i *Identity) Save(dir string) error {
|
||||
raw, err := json.MarshalIndent(i, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "identity.json"), raw, 0o600)
|
||||
}
|
||||
|
||||
// Sign 对载荷签名(hello 握手用)
|
||||
func (i *Identity) Sign(payload []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(ed25519.Sign(i.priv, payload))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadOrCreatePersist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id1, err := LoadOrCreate(dir, "test-device")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
// 再次加载应得到同一公钥
|
||||
id2, err := LoadOrCreate(dir, "test-device")
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if id1.PublicKey != id2.PublicKey {
|
||||
t.Fatal("public key should persist across reloads")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignVerify(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id, _ := LoadOrCreate(dir, "d")
|
||||
payload := []byte("nonce-1|123456")
|
||||
sigB64 := id.Sign(payload)
|
||||
sig, _ := base64.StdEncoding.DecodeString(sigB64)
|
||||
pub, _ := base64.StdEncoding.DecodeString(id.PublicKey)
|
||||
if !ed25519.Verify(ed25519.PublicKey(pub), payload, sig) {
|
||||
t.Fatal("signature should verify")
|
||||
}
|
||||
if ed25519.Verify(ed25519.PublicKey(pub), []byte("tampered"), sig) {
|
||||
t.Fatal("tampered payload should fail verify")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// bilibili B站执行器(无浏览器投稿 + API 扫码登录)。
|
||||
// 协议依据:ForgQi/biliup(MIT)bili_webup.py 的 upos/bda2 上传链路。
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Executor B站投稿执行器
|
||||
type Executor struct {
|
||||
Vault *vault.Vault
|
||||
HTTP *http.Client
|
||||
MemberBase string // 投稿 API 基址(测试可替换)
|
||||
PassportBase string // 登录 API 基址(测试可替换)
|
||||
UpOSScheme string // upos 上传协议(默认 https,测试 http)
|
||||
PollTimeout time.Duration // 扫码轮询总时长(默认 3 分钟)
|
||||
}
|
||||
|
||||
// New 构造执行器
|
||||
func New(v *vault.Vault) *Executor {
|
||||
return &Executor{
|
||||
Vault: v,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
MemberBase: "https://member.bilibili.com",
|
||||
PassportBase: "https://passport.bilibili.com",
|
||||
UpOSScheme: "https",
|
||||
PollTimeout: 3 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Platform 平台标识
|
||||
func (e *Executor) Platform() string { return "bilibili" }
|
||||
|
||||
// credentials 凭据(保险库 JSON)
|
||||
type credentials struct {
|
||||
Cookies string `json:"cookies"`
|
||||
}
|
||||
|
||||
// Execute 发布:下载素材 → upos 上传 → 投稿 → 返回作品链接
|
||||
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
fail := func(err string) exec.Result {
|
||||
return exec.Result{TaskID: task.TaskID, Status: "failed", Error: err}
|
||||
}
|
||||
creds, err := e.creds(task.AccountID)
|
||||
if err != nil {
|
||||
return fail("B站账号未登录(本地无凭据),请先在客户端完成扫码绑定")
|
||||
}
|
||||
if len(task.MaterialURLs) == 0 {
|
||||
return fail("任务缺少素材")
|
||||
}
|
||||
videoPath, cleanup, err := downloadFile(ctx, e.HTTP, task.MaterialURLs[0])
|
||||
if err != nil {
|
||||
return fail("素材下载失败: " + err.Error())
|
||||
}
|
||||
defer cleanup()
|
||||
part, err := e.uploadVideo(ctx, creds.Cookies, videoPath)
|
||||
if err != nil {
|
||||
return fail("视频上传失败: " + err.Error())
|
||||
}
|
||||
bvid, err := e.submit(ctx, creds.Cookies, task, part)
|
||||
if err != nil {
|
||||
return fail("投稿提交失败: " + err.Error())
|
||||
}
|
||||
return exec.Result{
|
||||
TaskID: task.TaskID,
|
||||
Status: "success",
|
||||
PublishedURL: "https://www.bilibili.com/video/" + bvid,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) creds(accountID string) (*credentials, error) {
|
||||
raw, err := e.Vault.Load(accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c credentials
|
||||
if err = json.Unmarshal(raw, &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Cookies == "" {
|
||||
return nil, fmt.Errorf("empty cookies")
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// uploadVideo 上传视频(upos/bda2 分片),返回投稿所需的 part 信息
|
||||
func (e *Executor) uploadVideo(ctx context.Context, cookies, filePath string) (map[string]interface{}, error) {
|
||||
stat, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := filepath.Base(filePath)
|
||||
total := stat.Size()
|
||||
|
||||
// 1) preupload 获取上传地址
|
||||
q := url.Values{}
|
||||
q.Set("r", "upos")
|
||||
q.Set("profile", "ugcupos/bup")
|
||||
q.Set("ssl", "0")
|
||||
q.Set("version", "2.8.12")
|
||||
q.Set("build", "2081200")
|
||||
q.Set("name", name)
|
||||
q.Set("size", strconv.FormatInt(total, 10))
|
||||
preURL := e.MemberBase + "/preupload?upcdn=bda2&probe_version=20221109&" + q.Encode()
|
||||
var pre struct {
|
||||
OK int `json:"OK"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Auth string `json:"auth"`
|
||||
BizID int `json:"biz_id"`
|
||||
UposURI string `json:"upos_uri"`
|
||||
ChunkSize int `json:"chunk_size"`
|
||||
}
|
||||
if err = e.getJSON(ctx, preURL, cookies, &pre); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pre.OK != 1 || pre.UposURI == "" {
|
||||
return nil, fmt.Errorf("preupload 返回异常")
|
||||
}
|
||||
upURL := e.UpOSScheme + ":" + pre.Endpoint + "/" + strings.TrimPrefix(pre.UposURI, "upos://")
|
||||
authH := map[string]string{"X-Upos-Auth": pre.Auth}
|
||||
|
||||
// 2) 申请 upload_id
|
||||
var up struct {
|
||||
UploadID string `json:"upload_id"`
|
||||
}
|
||||
if err = e.postJSON(ctx, upURL+"?uploads&output=json", cookies, authH, nil, &up); err != nil {
|
||||
return nil, fmt.Errorf("申请 upload_id 失败: %w", err)
|
||||
}
|
||||
|
||||
// 3) 分片上传
|
||||
chunkSize := pre.ChunkSize
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 7 * 1024 * 1024
|
||||
}
|
||||
chunks := int((total + int64(chunkSize) - 1) / int64(chunkSize))
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
parts := make([]map[string]interface{}, 0, chunks)
|
||||
buf := make([]byte, chunkSize)
|
||||
for i := 0; i < chunks; i++ {
|
||||
n, rerr := io.ReadFull(f, buf)
|
||||
if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF {
|
||||
return nil, rerr
|
||||
}
|
||||
start := i * chunkSize
|
||||
end := start + n
|
||||
pq := url.Values{}
|
||||
pq.Set("uploadId", up.UploadID)
|
||||
pq.Set("partNumber", strconv.Itoa(i+1))
|
||||
pq.Set("chunk", strconv.Itoa(i))
|
||||
pq.Set("chunks", strconv.Itoa(chunks))
|
||||
pq.Set("size", strconv.Itoa(n))
|
||||
pq.Set("start", strconv.Itoa(start))
|
||||
pq.Set("end", strconv.Itoa(end))
|
||||
pq.Set("total", strconv.FormatInt(total, 10))
|
||||
if err = e.putBody(ctx, upURL+"?"+pq.Encode(), cookies, authH, buf[:n]); err != nil {
|
||||
return nil, fmt.Errorf("分片 %d 上传失败: %w", i+1, err)
|
||||
}
|
||||
parts = append(parts, map[string]interface{}{"partNumber": i + 1, "eTag": "etag"})
|
||||
}
|
||||
|
||||
// 4) 合并分片
|
||||
mq := url.Values{}
|
||||
mq.Set("name", name)
|
||||
mq.Set("uploadId", up.UploadID)
|
||||
mq.Set("biz_id", strconv.Itoa(pre.BizID))
|
||||
mq.Set("output", "json")
|
||||
mq.Set("profile", "ugcupos/bup")
|
||||
var merged struct {
|
||||
OK int `json:"OK"`
|
||||
}
|
||||
if err = e.postJSON(ctx, upURL+"?"+mq.Encode(), cookies, authH, map[string]interface{}{"parts": parts}, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if merged.OK != 1 {
|
||||
return nil, fmt.Errorf("分片合并返回异常")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"title": strings.TrimSuffix(name, filepath.Ext(name)),
|
||||
"filename": strings.TrimSuffix(filepath.Base(pre.UposURI), filepath.Ext(filepath.Base(pre.UposURI))),
|
||||
"desc": "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// submit 投稿(x/vu/web/add)
|
||||
func (e *Executor) submit(ctx context.Context, cookies string, task exec.Task, part map[string]interface{}) (string, error) {
|
||||
csrf := cookieValue(cookies, "bili_jct")
|
||||
// 风控预热(失败不影响)
|
||||
_, _ = e.get(ctx, e.MemberBase+"/x/geetest/pre/add", cookies)
|
||||
tag := strings.Join(task.Tags, ",")
|
||||
if tag == "" {
|
||||
tag = "日常"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"title": truncateRunes(task.Title, 80),
|
||||
"desc": task.Content,
|
||||
"desc_v2": []map[string]interface{}{{"raw_text": task.Content, "type": 1, "biz_id": ""}},
|
||||
"copyright": 1,
|
||||
"source": "",
|
||||
"tid": 174,
|
||||
"tag": tag,
|
||||
"dynamic": "",
|
||||
"videos": []map[string]interface{}{part},
|
||||
}
|
||||
var ret struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Bvid string `json:"bvid"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := e.postJSON(ctx, e.MemberBase+"/x/vu/web/add?csrf="+csrf, cookies, nil, body, &ret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ret.Code != 0 {
|
||||
return "", fmt.Errorf("code=%d %s", ret.Code, ret.Message)
|
||||
}
|
||||
return ret.Data.Bvid, nil
|
||||
}
|
||||
|
||||
// SolveChallenge B站扫码登录:生成二维码 → onQR 展示 → 轮询 → 保存 cookie
|
||||
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
key, qrURL, err := e.generateQR(ctx)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
if onQR != nil {
|
||||
onQR(key, qrURL)
|
||||
}
|
||||
deadline := time.Now().Add(e.PollTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, ""
|
||||
default:
|
||||
}
|
||||
code, cookieURL, perr := e.pollQR(ctx, key)
|
||||
if perr != nil {
|
||||
return false, ""
|
||||
}
|
||||
switch code {
|
||||
case 0:
|
||||
cookies := cookiesFromCrossDomainURL(cookieURL)
|
||||
if cookies == "" {
|
||||
return false, ""
|
||||
}
|
||||
_ = e.Vault.Save(ch.AccountID, []byte(`{"cookies":"`+jsonEscape(cookies)+`"}`))
|
||||
return true, "confirmed"
|
||||
case 86090:
|
||||
case 86101:
|
||||
default:
|
||||
return false, ""
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// generateQR 生成登录二维码
|
||||
func (e *Executor) generateQR(ctx context.Context) (key, qrURL string, err error) {
|
||||
var ret struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
QrcodeKey string `json:"qrcode_key"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err = e.getJSON(ctx, e.PassportBase+"/x/passport-login/web/qrcode/generate", "", &ret); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if ret.Code != 0 || ret.Data.QrcodeKey == "" {
|
||||
return "", "", fmt.Errorf("生成二维码失败 code=%d", ret.Code)
|
||||
}
|
||||
return ret.Data.QrcodeKey, ret.Data.URL, nil
|
||||
}
|
||||
|
||||
// pollQR 轮询扫码状态:86101 未扫 / 86090 已扫待确认 / 0 成功(返回跨域 cookie URL)
|
||||
func (e *Executor) pollQR(ctx context.Context, key string) (int, string, error) {
|
||||
var ret struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := e.getJSON(ctx, e.PassportBase+"/x/passport-login/web/qrcode/poll?qrcode_key="+key, "", &ret); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return ret.Code, ret.Data.URL, nil
|
||||
}
|
||||
|
||||
// ---- HTTP 辅助 ----
|
||||
|
||||
func (e *Executor) get(ctx context.Context, rawURL, cookies string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cookies != "" {
|
||||
req.Header.Set("Cookie", cookies)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36")
|
||||
req.Header.Set("Referer", "https://member.bilibili.com")
|
||||
resp, err := e.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (e *Executor) getJSON(ctx context.Context, rawURL, cookies string, out interface{}) error {
|
||||
raw, err := e.get(ctx, rawURL, cookies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func (e *Executor) postJSON(ctx context.Context, rawURL, cookies string, extraH map[string]string, body interface{}, out interface{}) error {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
buf.Write(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cookies != "" {
|
||||
req.Header.Set("Cookie", cookies)
|
||||
}
|
||||
for k, v := range extraH {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36")
|
||||
req.Header.Set("Referer", "https://member.bilibili.com")
|
||||
resp, err := e.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("http %d", resp.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func (e *Executor) putBody(ctx context.Context, rawURL, cookies string, extraH map[string]string, data []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cookies != "" {
|
||||
req.Header.Set("Cookie", cookies)
|
||||
}
|
||||
for k, v := range extraH {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := e.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("http %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFile 下载素材到临时文件
|
||||
func downloadFile(ctx context.Context, client *http.Client, rawURL string) (string, func(), error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", nil, fmt.Errorf("下载 http %d", resp.StatusCode)
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "ep-material-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
_, err = io.Copy(tmp, resp.Body)
|
||||
tmp.Close()
|
||||
if err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return "", nil, err
|
||||
}
|
||||
return tmp.Name(), func() { _ = os.Remove(tmp.Name()) }, nil
|
||||
}
|
||||
|
||||
// cookieValue 从 cookie 串取值
|
||||
func cookieValue(cookies, key string) string {
|
||||
for _, part := range strings.Split(cookies, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if i := strings.Index(part, "="); i > 0 && part[:i] == key {
|
||||
return part[i+1:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cookiesFromCrossDomainURL 从跨域跳转 URL 提取登录 cookie
|
||||
func cookiesFromCrossDomainURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
q := u.Query()
|
||||
sess := q.Get("SESSDATA")
|
||||
jct := q.Get("bili_jct")
|
||||
dede := q.Get("DedeUserID")
|
||||
if sess == "" {
|
||||
return ""
|
||||
}
|
||||
return "SESSDATA=" + sess + "; bili_jct=" + jct + "; DedeUserID=" + dede
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
func jsonEscape(s string) string {
|
||||
raw, _ := json.Marshal(s)
|
||||
return string(raw[1 : len(raw)-1])
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// stubBili 桩:preupload + upos(uploads/分片/合并)+ 投稿 add,记录关键请求
|
||||
func stubBili(t *testing.T) (*httptest.Server, *struct {
|
||||
PartQuery string
|
||||
PartsLen int
|
||||
AddBody map[string]interface{}
|
||||
AddURL string
|
||||
XUposAuth string
|
||||
}) {
|
||||
rec := &struct {
|
||||
PartQuery string
|
||||
PartsLen int
|
||||
AddBody map[string]interface{}
|
||||
AddURL string
|
||||
XUposAuth string
|
||||
}{}
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/preupload":
|
||||
// endpoint 指向桩服务器自身
|
||||
endpoint := "//" + strings.TrimPrefix(srv.URL, "http://")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"OK": 1, "endpoint": endpoint, "auth": "auth-x", "biz_id": 1000,
|
||||
"upos_uri": "upos://video/av123", "chunk_size": 5,
|
||||
})
|
||||
case strings.Contains(r.URL.RawQuery, "uploads"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"upload_id": "upload-1"})
|
||||
case r.Method == http.MethodPut:
|
||||
rec.PartQuery = r.URL.RawQuery
|
||||
rec.XUposAuth = r.Header.Get("X-Upos-Auth")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.Method == http.MethodPost && strings.Contains(r.URL.RawQuery, "output=json"):
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if parts, ok := body["parts"].([]interface{}); ok {
|
||||
rec.PartsLen = len(parts)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]int{"OK": 1})
|
||||
case r.URL.Path == "/x/vu/web/add":
|
||||
rec.AddURL = r.URL.RawQuery
|
||||
_ = json.NewDecoder(r.Body).Decode(&rec.AddBody)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0, "data": map[string]string{"bvid": "BV1TEST123"},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
return srv, rec
|
||||
}
|
||||
|
||||
func TestExecuteUploadFlow(t *testing.T) {
|
||||
// 素材服务器
|
||||
matSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("0123456789abcdef"))
|
||||
}))
|
||||
defer matSrv.Close()
|
||||
|
||||
srv, rec := stubBili(t)
|
||||
defer srv.Close()
|
||||
|
||||
v, _ := vault.Open(t.TempDir())
|
||||
_ = v.Save("77", []byte(`{"cookies":"SESSDATA=sess; bili_jct=csrf-x; DedeUserID=1"}`))
|
||||
|
||||
e := New(v)
|
||||
e.MemberBase = srv.URL
|
||||
e.PassportBase = srv.URL
|
||||
e.UpOSScheme = "http"
|
||||
|
||||
res := e.Execute(context.Background(), exec.Task{
|
||||
TaskID: "9", AccountID: "77", Title: "测试标题", Content: "简介",
|
||||
Tags: []string{"测试", "日常"}, MaterialURLs: []string{matSrv.URL + "/v.mp4"},
|
||||
})
|
||||
if res.Status != "success" {
|
||||
t.Fatalf("expect success, got %+v", res)
|
||||
}
|
||||
if res.PublishedURL != "https://www.bilibili.com/video/BV1TEST123" {
|
||||
t.Fatalf("published url wrong: %s", res.PublishedURL)
|
||||
}
|
||||
// rec.PartQuery 记录最后一片(partNumber=4 size=1 start=15 end=16)
|
||||
if !strings.Contains(rec.PartQuery, "partNumber=4") || !strings.Contains(rec.PartQuery, "chunks=4") ||
|
||||
!strings.Contains(rec.PartQuery, "total=16") || !strings.Contains(rec.PartQuery, "size=1") {
|
||||
t.Fatalf("chunk query wrong: %s", rec.PartQuery)
|
||||
}
|
||||
if rec.PartsLen != 4 {
|
||||
t.Fatalf("expect 4 merged parts, got %d", rec.PartsLen)
|
||||
}
|
||||
if rec.AddURL != "csrf=csrf-x" {
|
||||
t.Fatalf("add csrf wrong: %s", rec.AddURL)
|
||||
}
|
||||
if rec.AddBody["title"] != "测试标题" || rec.AddBody["copyright"] != float64(1) {
|
||||
t.Fatalf("add body wrong: %+v", rec.AddBody)
|
||||
}
|
||||
if videos, ok := rec.AddBody["videos"].([]interface{}); !ok || len(videos) != 1 {
|
||||
t.Fatal("videos missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveChallengeQRLogin(t *testing.T) {
|
||||
pollAttempts := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "generate"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0, "data": map[string]string{"url": "https://qr.example/x", "qrcode_key": "key-1"},
|
||||
})
|
||||
case strings.Contains(r.URL.Path, "poll"):
|
||||
pollAttempts++
|
||||
if pollAttempts == 1 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 86101, "data": map[string]string{}})
|
||||
} else {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]string{
|
||||
"url": "https://passport.biligame.com/x/passport-login/web/crossDomain?SESSDATA=sess123&bili_jct=jct456&DedeUserID=789",
|
||||
},
|
||||
})
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
v, _ := vault.Open(t.TempDir())
|
||||
e := New(v)
|
||||
e.MemberBase = srv.URL
|
||||
e.PassportBase = srv.URL
|
||||
e.PollTimeout = 10 * time.Second
|
||||
|
||||
var gotQR string
|
||||
solved, value := e.SolveChallenge(context.Background(), proto.Challenge{ChallengeID: "1", AccountID: "77"}, func(token, qrURL string) {
|
||||
gotQR = qrURL
|
||||
})
|
||||
if !solved || value != "confirmed" {
|
||||
t.Fatalf("expect solved, got %v %s", solved, value)
|
||||
}
|
||||
if gotQR == "" {
|
||||
t.Fatal("onQR should be called")
|
||||
}
|
||||
raw, err := v.Load("77")
|
||||
if err != nil {
|
||||
t.Fatalf("vault should have cookies: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "SESSDATA=sess123") {
|
||||
t.Fatalf("cookies not saved: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollTimeoutUnscanned(t *testing.T) {
|
||||
// 一直未扫码:应超时返回未解决
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.Contains(r.URL.Path, "generate") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0, "data": map[string]string{"url": "u", "qrcode_key": "k"}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 86101, "data": map[string]string{}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
v, _ := vault.Open(t.TempDir())
|
||||
e := New(v)
|
||||
e.MemberBase = srv.URL
|
||||
e.PassportBase = srv.URL
|
||||
e.PollTimeout = 3 * time.Second
|
||||
if solved, _ := e.SolveChallenge(context.Background(), proto.Challenge{AccountID: "1"}, nil); solved {
|
||||
t.Fatal("unscanned should timeout unsolved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// cdnbase 三平台执行器公共底座:保险库 + 浏览器档案目录 + 代理 + 执行入口。
|
||||
package cdnbase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/cdp"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Platform 三平台执行器公共底座
|
||||
type Platform struct {
|
||||
Vault *vault.Vault
|
||||
RootDir string // 浏览器档案根目录(每账号子目录)
|
||||
Proxy string // 账号固定 IP 代理
|
||||
ChromePath string
|
||||
Headless bool
|
||||
ReceiptDir string // 截图回执目录
|
||||
Name string // 平台名(日志)
|
||||
PublishCfg cdp.PublishConfig
|
||||
LoginCfg cdp.QRLoginConfig
|
||||
LoginDomain string // 登录 cookie 域名
|
||||
}
|
||||
|
||||
// credentials 保险库凭据
|
||||
type credentials struct {
|
||||
Cookies string `json:"cookies"`
|
||||
}
|
||||
|
||||
// loadCookies 读账号 cookie(无则空,触发扫码)
|
||||
func (p *Platform) loadCookies(accountID string) string {
|
||||
raw, err := p.Vault.Load(accountID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var c credentials
|
||||
if json.Unmarshal(raw, &c) != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Cookies
|
||||
}
|
||||
|
||||
// profileDir 账号独立档案目录
|
||||
func (p *Platform) profileDir(accountID string) string {
|
||||
return filepath.Join(p.RootDir, p.Name, accountID)
|
||||
}
|
||||
|
||||
// Publish 通用发布入口(下载素材 → 启动浏览器 → 发布 → 回传)
|
||||
func (p *Platform) Publish(ctx context.Context, task exec.Task) exec.Result {
|
||||
fail := func(err string) exec.Result {
|
||||
return exec.Result{TaskID: task.TaskID, Status: "failed", Error: err}
|
||||
}
|
||||
cookies := p.loadCookies(task.AccountID)
|
||||
if len(task.MaterialURLs) == 0 {
|
||||
return fail("任务缺少素材")
|
||||
}
|
||||
videoPath, cleanup, err := cdp.DownloadFile(ctx, task.MaterialURLs[0])
|
||||
if err != nil {
|
||||
return fail("素材下载失败: " + err.Error())
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
br, err := cdp.Launch(ctx, cdp.Options{
|
||||
UserDataDir: p.profileDir(task.AccountID),
|
||||
Proxy: p.Proxy,
|
||||
Headless: p.Headless,
|
||||
ChromePath: p.ChromePath,
|
||||
})
|
||||
if err != nil {
|
||||
return fail("浏览器启动失败: " + err.Error())
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
cfg := p.PublishCfg
|
||||
cfg.Platform = p.Name
|
||||
cfg.ReceiptDir = p.ReceiptDir
|
||||
res, err := br.Publish(ctx, cfg, videoPath, task.Title, task.Content, task.Tags, cookies, p.LoginDomain)
|
||||
if err != nil {
|
||||
return fail("发布失败: " + err.Error())
|
||||
}
|
||||
return exec.Result{
|
||||
TaskID: task.TaskID,
|
||||
Status: "success",
|
||||
PublishedURL: res.URL,
|
||||
Receipts: res.Receipts,
|
||||
}
|
||||
}
|
||||
|
||||
// SolveChallenge CDP 扫码登录:二维码截图经 onQR 展示 → 轮询登录 → 保存 cookie
|
||||
func (p *Platform) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
br, err := cdp.Launch(ctx, cdp.Options{
|
||||
UserDataDir: p.profileDir(ch.AccountID),
|
||||
Proxy: p.Proxy,
|
||||
Headless: p.Headless,
|
||||
ChromePath: p.ChromePath,
|
||||
})
|
||||
if err != nil {
|
||||
cdp.Log("%s: 扫码浏览器启动失败: %v", p.Name, err)
|
||||
return false, ""
|
||||
}
|
||||
defer br.Close()
|
||||
cookies, err := br.QRLogin(ctx, p.LoginCfg, onQR)
|
||||
if err != nil {
|
||||
cdp.Log("%s: 扫码登录失败: %v", p.Name, err)
|
||||
return false, ""
|
||||
}
|
||||
if cookies == "" {
|
||||
return false, ""
|
||||
}
|
||||
_ = p.Vault.Save(ch.AccountID, []byte(`{"cookies":"`+cdp.EscapeJSON(cookies)+`"}`))
|
||||
return true, "confirmed"
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// cdp 共享浏览器基础设施:独立档案启动(每账号一档案)、代理、指纹、扫码登录。
|
||||
// 一期驱动 rod-launcher 管理的 Chromium;ChromePath 给定则驱动系统 Chrome/Edge。
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
rodstealth "github.com/go-rod/stealth"
|
||||
)
|
||||
|
||||
// Options 浏览器启动选项
|
||||
type Options struct {
|
||||
UserDataDir string // 每账号独立档案目录
|
||||
Proxy string // 账号固定 IP 代理(http://ip:port)
|
||||
Headless bool
|
||||
ChromePath string // 系统 Chrome/Edge 可执行文件(空=自动探测/下载)
|
||||
}
|
||||
|
||||
// Browser 浏览器封装
|
||||
type Browser struct {
|
||||
rod *rod.Browser
|
||||
Page *rod.Page
|
||||
}
|
||||
|
||||
// defaultChromePath 常见系统浏览器路径(Windows 优先,macOS 兜底)
|
||||
func defaultChromePath() string {
|
||||
candidates := []string{
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
candidates = append(candidates, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Launch 启动浏览器(独立档案 + 指纹 + 反自动化)
|
||||
func Launch(ctx context.Context, opts Options) (*Browser, error) {
|
||||
l := launcher.New()
|
||||
if opts.UserDataDir != "" {
|
||||
if err := os.MkdirAll(opts.UserDataDir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l = l.UserDataDir(opts.UserDataDir)
|
||||
}
|
||||
if opts.Proxy != "" {
|
||||
l = l.Set("proxy-server", opts.Proxy)
|
||||
}
|
||||
bin := opts.ChromePath
|
||||
if bin == "" {
|
||||
bin = defaultChromePath()
|
||||
}
|
||||
if bin != "" {
|
||||
l = l.Bin(bin)
|
||||
}
|
||||
l = l.Headless(opts.Headless).
|
||||
Set("no-first-run").
|
||||
Set("no-default-browser-check").
|
||||
Set("disable-blink-features", "AutomationControlled").
|
||||
Set("window-size", "1440,900")
|
||||
url, err := l.Launch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
browser := rod.New().ControlURL(url)
|
||||
if err = browser.Connect(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 指纹/反自动化(stealth 注入)
|
||||
page, err := rodstealth.Page(browser)
|
||||
if err != nil {
|
||||
browser.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Browser{rod: browser, Page: page}, nil
|
||||
}
|
||||
|
||||
// Close 关闭浏览器
|
||||
func (b *Browser) Close() {
|
||||
_ = b.rod.Close()
|
||||
}
|
||||
|
||||
// Screenshot 截图回执(PNG 本地保存,返回路径;二期上传服务器)
|
||||
func (b *Browser) Screenshot(receiptDir, name string) string {
|
||||
if err := os.MkdirAll(receiptDir, 0o755); err != nil {
|
||||
return ""
|
||||
}
|
||||
path := filepath.Join(receiptDir, name)
|
||||
_ = b.Page.MustWaitLoad().MustScreenshot(path)
|
||||
return path
|
||||
}
|
||||
|
||||
// InjectCookies 注入登录 cookie(免重复扫码)
|
||||
func (b *Browser) InjectCookies(cookieStr, domain string) error {
|
||||
for _, part := range strings.Split(cookieStr, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(part, "=")
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
name, value := part[:i], part[i+1:]
|
||||
cookie := &proto.NetworkCookieParam{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Domain: domain,
|
||||
Path: "/",
|
||||
}
|
||||
_ = b.rod.SetCookies([]*proto.NetworkCookieParam{cookie})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cookies 导出当前浏览器 cookie(域名过滤)
|
||||
func (b *Browser) Cookies(domain string) string {
|
||||
cookies, err := b.rod.GetCookies()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(cookies))
|
||||
for _, c := range cookies {
|
||||
if domain != "" && !strings.Contains(c.Domain, domain) {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, c.Name+"="+c.Value)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// QRLoginConfig 扫码登录配置
|
||||
type QRLoginConfig struct {
|
||||
LoginURL string
|
||||
LoginPathMarker string // 登录页 URL 特征(登录成功=URL 离开该特征)
|
||||
QRSelector string // 二维码元素选择器
|
||||
SuccessWait time.Duration
|
||||
}
|
||||
|
||||
// QRLogin 通用扫码登录:打开登录页 → 截图二维码经 onQR 展示 → 轮询 URL 直到登录成功
|
||||
func (b *Browser) QRLogin(ctx context.Context, cfg QRLoginConfig, onQR func(qrToken, qrURL string)) (string, error) {
|
||||
if err := b.Page.Navigate(cfg.LoginURL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = b.Page.WaitLoad()
|
||||
// 截图二维码 → 临时 PNG → file:// URI → onQR(壳端 BitmapImage 展示)
|
||||
qrEl, err := b.Page.Element(cfg.QRSelector)
|
||||
if err == nil {
|
||||
raw, serr := qrEl.Screenshot(proto.PageCaptureScreenshotFormatPng, 100)
|
||||
if serr == nil && len(raw) > 0 {
|
||||
qrPath := filepath.Join(os.TempDir(), "ep-qr-"+time.Now().Format("150405")+".png")
|
||||
if werr := os.WriteFile(qrPath, raw, 0o600); werr == nil && onQR != nil {
|
||||
onQR("", "file://"+qrPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 轮询登录状态
|
||||
deadline := time.Now().Add(cfg.SuccessWait)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("cancelled")
|
||||
default:
|
||||
}
|
||||
info, ierr := b.Page.Info()
|
||||
if ierr == nil && !strings.Contains(info.URL, cfg.LoginPathMarker) {
|
||||
return b.Cookies(""), nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return "", fmt.Errorf("扫码登录超时")
|
||||
}
|
||||
|
||||
// DownloadFile 下载素材到临时文件(返回路径与清理函数)
|
||||
func DownloadFile(ctx context.Context, rawURL string) (string, func(), error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", nil, fmt.Errorf("下载 http %d", resp.StatusCode)
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "ep-material-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
_, err = io.Copy(tmp, resp.Body)
|
||||
tmp.Close()
|
||||
if err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return "", nil, err
|
||||
}
|
||||
return tmp.Name(), func() { _ = os.Remove(tmp.Name()) }, nil
|
||||
}
|
||||
|
||||
// EscapeJSON 转义字符串供嵌入 JSON
|
||||
func EscapeJSON(s string) string {
|
||||
raw, _ := json.Marshal(s)
|
||||
return string(raw[1 : len(raw)-1])
|
||||
}
|
||||
|
||||
// Log 调试输出
|
||||
func Log(format string, args ...interface{}) {
|
||||
log.Printf("[cdp] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEscapeJSON(t *testing.T) {
|
||||
s := EscapeJSON("SESSDATA=ab\"c; bili_jct=d")
|
||||
// 转义后应可嵌入 JSON 且还原一致
|
||||
var decoded string
|
||||
if err := json.Unmarshal([]byte(`"`+s+`"`), &decoded); err != nil {
|
||||
t.Fatalf("escaped string not valid json: %v", err)
|
||||
}
|
||||
if decoded != "SESSDATA=ab\"c; bili_jct=d" {
|
||||
t.Fatalf("roundtrip mismatch: %q", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("video-bytes-123"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
path, cleanup, err := DownloadFile(context.Background(), srv.URL+"/v.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || string(raw) != "video-bytes-123" {
|
||||
t.Fatalf("content mismatch: %s %v", raw, err)
|
||||
}
|
||||
cleanup()
|
||||
if _, err = os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("cleanup should remove temp file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusGone)
|
||||
_, _ = io.WriteString(w, "gone")
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, _, err := DownloadFile(context.Background(), srv.URL); err == nil {
|
||||
t.Fatal("410 should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultChromePath(t *testing.T) {
|
||||
// 至少不 panic、返回字符串
|
||||
p := defaultChromePath()
|
||||
if strings.TrimSpace(p) == "" && p != "" {
|
||||
t.Fatal("unexpected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Publish 通用发布流程:进上传页 → 注入 cookie → 传视频 → 填表单 → 点发布 → 等成功跳转 → 截图回执。
|
||||
// 各平台选择器差异经 PublishConfig 配置;平台改版时只需调整配置。
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
)
|
||||
|
||||
// PublishConfig 发布流程配置(选择器按平台填写)
|
||||
type PublishConfig struct {
|
||||
Platform string
|
||||
UploadURL string
|
||||
SuccessMarker string // 发布成功后的 URL 特征
|
||||
FileInputSel string
|
||||
TitleSel string
|
||||
DescSel string
|
||||
PublishBtnSel string
|
||||
FormWait time.Duration // 等视频处理出表单的最长时间
|
||||
SuccessWait time.Duration
|
||||
ReceiptDir string
|
||||
}
|
||||
|
||||
// PublishResult 发布结果
|
||||
type PublishResult struct {
|
||||
URL string
|
||||
Receipts []string
|
||||
}
|
||||
|
||||
// Publish 执行通用发布流程
|
||||
func (b *Browser) Publish(ctx context.Context, cfg PublishConfig, videoPath, title, content string, tags []string, cookieStr, cookieDomain string) (*PublishResult, error) {
|
||||
if err := b.Page.Navigate(cfg.UploadURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = b.Page.WaitLoad()
|
||||
// 注入登录 cookie(有则免扫码)
|
||||
if cookieStr != "" {
|
||||
_ = b.InjectCookies(cookieStr, cookieDomain)
|
||||
if err := b.Page.Navigate(cfg.UploadURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = b.Page.WaitLoad()
|
||||
}
|
||||
|
||||
// 1) 传视频
|
||||
fileInput, err := b.Page.Element(cfg.FileInputSel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("找不到上传入口(%s): %w", cfg.FileInputSel, err)
|
||||
}
|
||||
if err = fileInput.SetFiles([]string{videoPath}); err != nil {
|
||||
return nil, fmt.Errorf("选择文件失败: %w", err)
|
||||
}
|
||||
Log("%s: 视频已选择,等待平台处理...", cfg.Platform)
|
||||
|
||||
// 2) 等表单渲染(视频转码后出现标题输入框)
|
||||
titleEl, err := b.Page.ElementR(cfg.TitleSel, "")
|
||||
if err != nil {
|
||||
titleEl, err = b.waitElement(ctx, cfg.TitleSel, cfg.FormWait)
|
||||
}
|
||||
if err != nil {
|
||||
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-form-fail.png")
|
||||
return nil, fmt.Errorf("等表单渲染超时(%s): %w", cfg.TitleSel, err)
|
||||
}
|
||||
|
||||
// 3) 填标题/描述(话题并入描述)
|
||||
if err = titleEl.Input(title); err != nil {
|
||||
return nil, fmt.Errorf("填标题失败: %w", err)
|
||||
}
|
||||
desc := content
|
||||
if len(tags) > 0 {
|
||||
for _, t := range tags {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" && !strings.HasPrefix(t, "#") {
|
||||
t = "#" + t
|
||||
}
|
||||
desc += " " + t
|
||||
}
|
||||
}
|
||||
if descEl, derr := b.Page.Element(cfg.DescSel); derr == nil {
|
||||
_ = descEl.Input(desc)
|
||||
}
|
||||
|
||||
// 4) 点发布
|
||||
btn, err := b.waitElement(ctx, cfg.PublishBtnSel, 15*time.Second)
|
||||
if err != nil {
|
||||
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-btn-fail.png")
|
||||
return nil, fmt.Errorf("找不到发布按钮(%s): %w", cfg.PublishBtnSel, err)
|
||||
}
|
||||
if err = btn.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
return nil, fmt.Errorf("点发布失败: %w", err)
|
||||
}
|
||||
Log("%s: 已点发布,等待结果...", cfg.Platform)
|
||||
|
||||
// 5) 等成功跳转
|
||||
if err = b.waitURL(ctx, cfg.SuccessMarker, cfg.SuccessWait); err != nil {
|
||||
b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-publish-fail.png")
|
||||
return nil, err
|
||||
}
|
||||
info, _ := b.Page.Info()
|
||||
receipt := b.Screenshot(cfg.ReceiptDir, cfg.Platform+"-success.png")
|
||||
Log("%s: 发布成功 %s", cfg.Platform, info.URL)
|
||||
return &PublishResult{URL: info.URL, Receipts: []string{receipt}}, nil
|
||||
}
|
||||
|
||||
// waitElement 轮询等待元素出现
|
||||
func (b *Browser) waitElement(ctx context.Context, selector string, timeout time.Duration) (*rod.Element, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("cancelled")
|
||||
default:
|
||||
}
|
||||
el, err := b.Page.Element(selector)
|
||||
if err == nil {
|
||||
return el, nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return nil, fmt.Errorf("timeout waiting %s", selector)
|
||||
}
|
||||
|
||||
// waitURL 轮询等待 URL 命中特征
|
||||
func (b *Browser) waitURL(ctx context.Context, marker string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("cancelled")
|
||||
default:
|
||||
}
|
||||
info, err := b.Page.Info()
|
||||
if err == nil && strings.Contains(info.URL, marker) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting url %s", marker)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// douyin 抖音执行器(CDP:creator.douyin.com)。选择器参考 dreammis/social-auto-upload(2026-06 版)。
|
||||
package douyin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/cdnbase"
|
||||
"everypublish/agentcore/internal/exec/cdp"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Executor 抖音发布执行器
|
||||
type Executor struct {
|
||||
base *cdnbase.Platform
|
||||
}
|
||||
|
||||
// New 构造执行器(rootDir=浏览器档案根目录,proxy=账号固定IP代理)
|
||||
func New(v *vault.Vault, rootDir, proxy, chromePath string, headless bool) *Executor {
|
||||
p := &cdnbase.Platform{
|
||||
Vault: v,
|
||||
RootDir: rootDir,
|
||||
Proxy: proxy,
|
||||
ChromePath: chromePath,
|
||||
Headless: headless,
|
||||
ReceiptDir: rootDir + "/receipts",
|
||||
Name: "douyin",
|
||||
LoginDomain: ".douyin.com",
|
||||
PublishCfg: cdp.PublishConfig{
|
||||
UploadURL: "https://creator.douyin.com/creator-micro/content/upload",
|
||||
SuccessMarker: "creator-micro/content/manage",
|
||||
FileInputSel: "input[type=\"file\"]",
|
||||
TitleSel: "input[placeholder*=\"填写作品标题\"]",
|
||||
DescSel: "div.zone-container[contenteditable=\"true\"]",
|
||||
PublishBtnSel: "button:has-text(\"发布\")",
|
||||
FormWait: 180 * time.Second, // 视频转码渲染表单较慢
|
||||
SuccessWait: 120 * time.Second,
|
||||
},
|
||||
LoginCfg: cdp.QRLoginConfig{
|
||||
LoginURL: "https://creator.douyin.com/",
|
||||
LoginPathMarker: "creator.douyin.com/?",
|
||||
QRSelector: ".qrcode-v2 img, .qrcode img, img[src*=qrcode]",
|
||||
SuccessWait: 3 * time.Minute,
|
||||
},
|
||||
}
|
||||
return &Executor{base: p}
|
||||
}
|
||||
|
||||
// Platform 平台标识
|
||||
func (e *Executor) Platform() string { return "douyin" }
|
||||
|
||||
// Execute 发布
|
||||
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
return e.base.Publish(ctx, task)
|
||||
}
|
||||
|
||||
// SolveChallenge 扫码登录(QR 截图经 onQR 展示)
|
||||
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
return e.base.SolveChallenge(ctx, ch, onQR)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package exec_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/fake"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
func TestFakeExecutor(t *testing.T) {
|
||||
e := fake.Executor{SimMS: 5}
|
||||
start := time.Now()
|
||||
res := e.Execute(context.Background(), exec.Task{TaskID: "1", Platform: "douyin", Title: "t"})
|
||||
if time.Since(start) < 5*time.Millisecond {
|
||||
t.Fatal("should simulate delay")
|
||||
}
|
||||
if res.Status != "success" || res.PublishedURL == "" {
|
||||
t.Fatalf("expect success, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeSolver(t *testing.T) {
|
||||
e := fake.Executor{SimMS: 1}
|
||||
solved, value := e.SolveChallenge(context.Background(), proto.Challenge{ChallengeID: "1"}, nil)
|
||||
if !solved || value == "" {
|
||||
t.Fatalf("expect solved, got %v %s", solved, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryFallback(t *testing.T) {
|
||||
r := exec.NewRegistry()
|
||||
r.SetFallback(fake.Executor{SimMS: 1})
|
||||
if r.Get("douyin") == nil {
|
||||
t.Fatal("fallback should serve any platform")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Task 执行任务(由 task.push 转换而来)
|
||||
type Task struct {
|
||||
TaskID string
|
||||
Platform string
|
||||
AccountID string
|
||||
AccountName string
|
||||
Title string
|
||||
Content string
|
||||
Tags []string
|
||||
MaterialURLs []string
|
||||
ScheduleAt int64
|
||||
Priority int
|
||||
}
|
||||
|
||||
// Result 执行结果(回传 task.result)
|
||||
type Result struct {
|
||||
TaskID string
|
||||
Status string
|
||||
PublishedURL string
|
||||
Receipts []string
|
||||
Error string
|
||||
}
|
||||
|
||||
// Executor 平台执行器(每个平台一个实现;D10 起接入真实平台)
|
||||
type Executor interface {
|
||||
Platform() string
|
||||
Execute(ctx context.Context, task Task) Result
|
||||
}
|
||||
|
||||
// ChallengeSolver 能自动完成挑战的平台执行器(扫码/验证码)。
|
||||
// onQR:平台生成二维码时回调(把 qrToken/qrUrl 推给壳展示,可为 nil)。
|
||||
type ChallengeSolver interface {
|
||||
SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (solved bool, value string)
|
||||
}
|
||||
|
||||
// Registry 执行器注册表(并发安全)
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]Executor
|
||||
fallback Executor
|
||||
}
|
||||
|
||||
// NewRegistry 创建注册表
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{m: make(map[string]Executor)}
|
||||
}
|
||||
|
||||
// Register 注册平台执行器
|
||||
func (r *Registry) Register(e Executor) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.m[e.Platform()] = e
|
||||
}
|
||||
|
||||
// SetFallback 注册兜底执行器(未接入平台用)
|
||||
func (r *Registry) SetFallback(e Executor) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.fallback = e
|
||||
}
|
||||
|
||||
// Get 取执行器(无匹配返回 nil)
|
||||
func (r *Registry) Get(platform string) Executor {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if e, ok := r.m[platform]; ok {
|
||||
return e
|
||||
}
|
||||
return r.fallback
|
||||
}
|
||||
|
||||
// Solve 取挑战解决器(nil 表示不支持自动解决)
|
||||
func (r *Registry) Solve(platform string) ChallengeSolver {
|
||||
if s, ok := r.Get(platform).(ChallengeSolver); ok {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// fake 假执行器:D8 闭环验收用,模拟平台执行(延迟后返回成功)。
|
||||
package fake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Executor 假执行器(可作兜底:任何平台都返回成功)
|
||||
type Executor struct {
|
||||
SimMS int
|
||||
}
|
||||
|
||||
// Platform 空串表示通用(注册为兜底)
|
||||
func (e Executor) Platform() string { return "" }
|
||||
|
||||
// Execute 模拟执行:延迟 SimMS 后返回成功
|
||||
func (e Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
select {
|
||||
case <-time.After(time.Duration(e.SimMS) * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return exec.Result{TaskID: task.TaskID, Status: "failed", Error: "cancelled"}
|
||||
}
|
||||
return exec.Result{
|
||||
TaskID: task.TaskID,
|
||||
Status: "success",
|
||||
PublishedURL: "https://example.com/fake/" + task.TaskID,
|
||||
Receipts: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// SolveChallenge 模拟扫码:延迟后返回已解决
|
||||
func (e Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
if onQR != nil {
|
||||
onQR("fake-qr-token", "https://example.com/fake-qr")
|
||||
}
|
||||
select {
|
||||
case <-time.After(time.Duration(e.SimMS) * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return false, ""
|
||||
}
|
||||
return true, "fake-qr-token"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// kuaishou 快手执行器(CDP:cp.kuaishou.com)。
|
||||
package kuaishou
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/cdnbase"
|
||||
"everypublish/agentcore/internal/exec/cdp"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Executor 快手发布执行器
|
||||
type Executor struct {
|
||||
base *cdnbase.Platform
|
||||
}
|
||||
|
||||
// New 构造执行器
|
||||
func New(v *vault.Vault, rootDir, proxy, chromePath string, headless bool) *Executor {
|
||||
p := &cdnbase.Platform{
|
||||
Vault: v,
|
||||
RootDir: rootDir,
|
||||
Proxy: proxy,
|
||||
ChromePath: chromePath,
|
||||
Headless: headless,
|
||||
ReceiptDir: rootDir + "/receipts",
|
||||
Name: "kuaishou",
|
||||
LoginDomain: ".kuaishou.com",
|
||||
PublishCfg: cdp.PublishConfig{
|
||||
UploadURL: "https://cp.kuaishou.com/article/publish/video",
|
||||
SuccessMarker: "article/manage/video",
|
||||
FileInputSel: "input[type=\"file\"]",
|
||||
TitleSel: "input[placeholder*=\"标题\"]",
|
||||
DescSel: "[contenteditable=\"true\"]",
|
||||
PublishBtnSel: "button:has-text(\"发布\")",
|
||||
FormWait: 180 * time.Second,
|
||||
SuccessWait: 120 * time.Second,
|
||||
},
|
||||
LoginCfg: cdp.QRLoginConfig{
|
||||
LoginURL: "https://cp.kuaishou.com/",
|
||||
LoginPathMarker: "passport.kuaishou.com",
|
||||
QRSelector: ".qrcode-img, img[src*=qrcode], .qrcode img",
|
||||
SuccessWait: 3 * time.Minute,
|
||||
},
|
||||
}
|
||||
return &Executor{base: p}
|
||||
}
|
||||
|
||||
// Platform 平台标识
|
||||
func (e *Executor) Platform() string { return "kuaishou" }
|
||||
|
||||
// Execute 发布
|
||||
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
return e.base.Publish(ctx, task)
|
||||
}
|
||||
|
||||
// SolveChallenge 扫码登录
|
||||
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
return e.base.SolveChallenge(ctx, ch, onQR)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// xiaohongshu 小红书执行器(CDP:creator.xiaohongshu.com)。
|
||||
package xiaohongshu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/cdnbase"
|
||||
"everypublish/agentcore/internal/exec/cdp"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Executor 小红书发布执行器
|
||||
type Executor struct {
|
||||
base *cdnbase.Platform
|
||||
}
|
||||
|
||||
// New 构造执行器
|
||||
func New(v *vault.Vault, rootDir, proxy, chromePath string, headless bool) *Executor {
|
||||
p := &cdnbase.Platform{
|
||||
Vault: v,
|
||||
RootDir: rootDir,
|
||||
Proxy: proxy,
|
||||
ChromePath: chromePath,
|
||||
Headless: headless,
|
||||
ReceiptDir: rootDir + "/receipts",
|
||||
Name: "xiaohongshu",
|
||||
LoginDomain: ".xiaohongshu.com",
|
||||
PublishCfg: cdp.PublishConfig{
|
||||
UploadURL: "https://creator.xiaohongshu.com/publish/publish",
|
||||
SuccessMarker: "publish/success",
|
||||
FileInputSel: "input[type=\"file\"]",
|
||||
TitleSel: "input[placeholder*=\"填写标题\"]",
|
||||
DescSel: "#post-textarea",
|
||||
PublishBtnSel: "button:has-text(\"发布\")",
|
||||
FormWait: 180 * time.Second,
|
||||
SuccessWait: 120 * time.Second,
|
||||
},
|
||||
LoginCfg: cdp.QRLoginConfig{
|
||||
LoginURL: "https://creator.xiaohongshu.com/login",
|
||||
LoginPathMarker: "creator.xiaohongshu.com/login",
|
||||
QRSelector: ".qrcode-img, img[src*=qrcode], .qrcode img",
|
||||
SuccessWait: 3 * time.Minute,
|
||||
},
|
||||
}
|
||||
return &Executor{base: p}
|
||||
}
|
||||
|
||||
// Platform 平台标识
|
||||
func (e *Executor) Platform() string { return "xiaohongshu" }
|
||||
|
||||
// Execute 发布
|
||||
func (e *Executor) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
return e.base.Publish(ctx, task)
|
||||
}
|
||||
|
||||
// SolveChallenge 扫码登录
|
||||
func (e *Executor) SolveChallenge(ctx context.Context, ch proto.Challenge, onQR func(qrToken, qrURL string)) (bool, string) {
|
||||
return e.base.SolveChallenge(ctx, ch, onQR)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// localserver 本机控制服务:仅 127.0.0.1,token 鉴权,供 WinUI 壳调用。
|
||||
// 端点:/status /challenges /challenges/{id} /pair;端口与 token 写入 data/local.json。
|
||||
package localserver
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// CoreState 核心状态接口(由 wsclient.Client 实现)
|
||||
type CoreState interface {
|
||||
Online() bool
|
||||
SessionID() string
|
||||
Challenges() []proto.Challenge
|
||||
SolveChallenge(challengeID, value string) error
|
||||
}
|
||||
|
||||
// PairFunc 配对回调(main 提供:配对成功保存身份并启动连接)
|
||||
type PairFunc func(code, name string) error
|
||||
|
||||
// Info 本地服务信息(写 local.json 供壳读取)
|
||||
type Info struct {
|
||||
Port int `json:"port"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// ExtraFunc 额外状态字段(server/deviceId/name 等,闭包实时取值)
|
||||
type ExtraFunc func() map[string]interface{}
|
||||
|
||||
// Server 本机控制服务
|
||||
type Server struct {
|
||||
state CoreState
|
||||
pair PairFunc
|
||||
token string
|
||||
dir string
|
||||
extra ExtraFunc
|
||||
mu sync.Mutex
|
||||
addr string
|
||||
}
|
||||
|
||||
// New 构造控制服务(extra 可空)
|
||||
func New(state CoreState, pair PairFunc, token, dir string, extra ExtraFunc) *Server {
|
||||
return &Server{state: state, pair: pair, token: token, dir: dir, extra: extra}
|
||||
}
|
||||
|
||||
// Listen 绑定随机端口并服务,同时写 data/local.json
|
||||
func (s *Server) Listen() error {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
s.mu.Lock()
|
||||
s.addr = ln.Addr().String()
|
||||
s.mu.Unlock()
|
||||
raw, _ := json.Marshal(Info{Port: port, Token: s.token})
|
||||
_ = os.WriteFile(filepath.Join(s.dir, "local.json"), raw, 0o600)
|
||||
log.Printf("[local] control server %s (local.json written)", s.addr)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/status", s.handleStatus)
|
||||
mux.HandleFunc("/challenges", s.handleChallenges)
|
||||
mux.HandleFunc("/challenges/", s.handleSolve)
|
||||
mux.HandleFunc("/pair", s.handlePair)
|
||||
return http.Serve(ln, mux)
|
||||
}
|
||||
|
||||
// Addr 实际监听地址(Listen 后可用)
|
||||
func (s *Server) Addr() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.addr
|
||||
}
|
||||
|
||||
func (s *Server) auth(r *http.Request) bool {
|
||||
// 仅接受请求头;token 不进 URL(避免泄露进日志/历史)。常数时间比较防时序侧信道。
|
||||
got := r.Header.Get("X-Agent-Token")
|
||||
if len(got) != len(s.token) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.auth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
m := map[string]interface{}{
|
||||
"online": s.state.Online(),
|
||||
"session": s.state.SessionID(),
|
||||
}
|
||||
if s.extra != nil {
|
||||
for k, v := range s.extra() {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(m)
|
||||
}
|
||||
|
||||
func (s *Server) handleChallenges(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.auth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"list": s.state.Challenges()})
|
||||
}
|
||||
|
||||
func (s *Server) handleSolve(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.auth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/challenges/")
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := s.state.SolveChallenge(id, req.Value); err != nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0})
|
||||
}
|
||||
|
||||
func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.auth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Code == "" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": "配对码必填"})
|
||||
return
|
||||
}
|
||||
if err := s.pair(req.Code, req.Name); err != nil {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": 0})
|
||||
}
|
||||
|
||||
// RandToken 生成随机 token
|
||||
func RandToken() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package localserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
type fakeState struct {
|
||||
online bool
|
||||
chs []proto.Challenge
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeState) Online() bool { return f.online }
|
||||
func (f *fakeState) SessionID() string { return "s1" }
|
||||
func (f *fakeState) Challenges() []proto.Challenge { return f.chs }
|
||||
func (f *fakeState) SolveChallenge(id, value string) error { return f.err }
|
||||
|
||||
func startTestServer(t *testing.T, state CoreState, pair PairFunc) (*Server, string) {
|
||||
t.Helper()
|
||||
token := RandToken()
|
||||
s := New(state, pair, token, t.TempDir(), nil)
|
||||
go func() { _ = s.Listen() }()
|
||||
for i := 0; i < 50; i++ {
|
||||
if s.Addr() != "" {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if s.Addr() == "" {
|
||||
t.Fatal("server did not start")
|
||||
}
|
||||
return s, token
|
||||
}
|
||||
|
||||
func get(t *testing.T, base, path, token string) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", "http://"+base+path, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("X-Agent-Token", token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m map[string]interface{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&m)
|
||||
return resp.StatusCode, m
|
||||
}
|
||||
|
||||
func TestStatusAuth(t *testing.T) {
|
||||
s, token := startTestServer(t, &fakeState{online: true}, nil)
|
||||
code, _ := get(t, s.Addr(), "/status", "")
|
||||
if code != 401 {
|
||||
t.Fatalf("no token should be 401, got %d", code)
|
||||
}
|
||||
code, m := get(t, s.Addr(), "/status", token)
|
||||
if code != 200 || m["online"] != true {
|
||||
t.Fatalf("status wrong: %d %v", code, m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairCallback(t *testing.T) {
|
||||
var gotCode, gotName string
|
||||
s, token := startTestServer(t, &fakeState{}, func(code, name string) error {
|
||||
gotCode, gotName = code, name
|
||||
return nil
|
||||
})
|
||||
// 未带 token → 401
|
||||
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"AB12CD","name":"PC"}`))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("pair: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("pair without token should 401, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
// 带 token → 回调被调用
|
||||
req2, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"AB12CD","name":"PC"}`))
|
||||
req2.Header.Set("X-Agent-Token", token)
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("pair: %v", err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if gotCode != "AB12CD" || gotName != "PC" {
|
||||
t.Fatalf("callback not invoked: %q %q", gotCode, gotName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairErrorPropagates(t *testing.T) {
|
||||
s, token := startTestServer(t, &fakeState{}, func(code, name string) error {
|
||||
return errors.New("配对码无效")
|
||||
})
|
||||
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/pair", bytes.NewBufferString(`{"code":"X"}`))
|
||||
req.Header.Set("X-Agent-Token", token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("pair: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m map[string]interface{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&m)
|
||||
if m["code"] != float64(1) {
|
||||
t.Fatalf("expect error code 1, got %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolve(t *testing.T) {
|
||||
s, token := startTestServer(t, &fakeState{err: nil}, nil)
|
||||
code, m := get(t, s.Addr(), "/challenges", token)
|
||||
if code != 200 {
|
||||
t.Fatalf("challenges: %d", code)
|
||||
}
|
||||
_ = m
|
||||
req, _ := http.NewRequest("POST", "http://"+s.Addr()+"/challenges/9", bytes.NewBufferString(`{"value":"123456"}`))
|
||||
req.Header.Set("X-Agent-Token", token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("solve: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m2 map[string]interface{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&m2)
|
||||
if m2["code"] != float64(0) {
|
||||
t.Fatalf("solve should succeed, got %v", m2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package pairing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func httpJSON(method, url, token string, body interface{}) (int, []byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
buf.Write(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, &buf)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, raw, nil
|
||||
}
|
||||
|
||||
func field(raw []byte, key string) string {
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if d, ok := m["data"].(map[string]interface{}); ok {
|
||||
if v, ok := d[key]; ok {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ginH map[string]interface{}
|
||||
|
||||
// AutoPair 开发便利:登录 → 取配对码 → 配对(无 UI 场景闭环用)
|
||||
func AutoPair(server, email, password, deviceName, pubKeyB64 string) (uint64, error) {
|
||||
code, raw, err := httpJSON("POST", server+"/api/v1/auth/login", "", ginH{"email": email, "password": password})
|
||||
if err != nil || code != 200 {
|
||||
return 0, fmt.Errorf("login failed: %s", string(raw))
|
||||
}
|
||||
token := field(raw, "accessToken")
|
||||
code, raw, err = httpJSON("POST", server+"/api/v1/agent/pair-code", token, nil)
|
||||
if err != nil || code != 200 {
|
||||
return 0, fmt.Errorf("pair-code failed: %s", string(raw))
|
||||
}
|
||||
pairCode := field(raw, "code")
|
||||
return PairWithCode(server, pairCode, deviceName, "core", "0.2.0", pubKeyB64)
|
||||
}
|
||||
|
||||
// PairWithCode 凭配对码注册设备,返回服务器分配的设备 ID
|
||||
func PairWithCode(server, pairCode, deviceName, osName, version, pubKeyB64 string) (uint64, error) {
|
||||
code, raw, err := httpJSON("POST", server+"/api/v1/agent/pair", "", ginH{
|
||||
"code": pairCode, "deviceName": deviceName, "os": osName, "version": version, "publicKey": pubKeyB64,
|
||||
})
|
||||
if err != nil || code != 200 {
|
||||
return 0, fmt.Errorf("pair failed: %s", string(raw))
|
||||
}
|
||||
id, _ := strconv.ParseUint(field(raw, "deviceId"), 10, 64)
|
||||
if id == 0 {
|
||||
return 0, fmt.Errorf("pair response missing deviceId")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Vault 凭据保险库:AES-256-GCM 加密的 JSON 文件(主密钥单独 0600 文件)。
|
||||
// 二期:Windows 上主密钥改用 DPAPI 保护,文件格式不变。
|
||||
type Vault struct {
|
||||
dir string
|
||||
key []byte
|
||||
}
|
||||
|
||||
type vaultFile struct {
|
||||
Version int `json:"version"`
|
||||
Entries map[string]string `json:"entries"`
|
||||
}
|
||||
|
||||
// Open 打开/创建保险库(主密钥 data/master.key,32 字节)
|
||||
func Open(dir string) (*Vault, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPath := filepath.Join(dir, "master.key")
|
||||
var key []byte
|
||||
if raw, err := os.ReadFile(keyPath); err == nil {
|
||||
key = make([]byte, base64.RawStdEncoding.DecodedLen(len(raw)))
|
||||
if _, err = base64.RawStdEncoding.Decode(key, raw); err != nil || len(key) != 32 {
|
||||
return nil, errors.New("master key corrupted")
|
||||
}
|
||||
} else {
|
||||
key = make([]byte, 32)
|
||||
if _, err = rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.WriteFile(keyPath, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Vault{dir: dir, key: key}, nil
|
||||
}
|
||||
|
||||
func (v *Vault) path() string { return filepath.Join(v.dir, "vault.enc") }
|
||||
|
||||
// load 读取并解密全部条目
|
||||
func (v *Vault) load() (map[string]string, error) {
|
||||
raw, err := os.ReadFile(v.path())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var f vaultFile
|
||||
if err = json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.Entries == nil {
|
||||
f.Entries = map[string]string{}
|
||||
}
|
||||
return f.Entries, nil
|
||||
}
|
||||
|
||||
// Save 加密保存单条凭据(cookie/token 等,值为 JSON 字符串)
|
||||
func (v *Vault) Save(accountID string, plaintext []byte) error {
|
||||
entries, err := v.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
entries[accountID] = base64.StdEncoding.EncodeToString(sealed)
|
||||
return v.persist(entries)
|
||||
}
|
||||
|
||||
// Load 解密读取单条凭据
|
||||
func (v *Vault) Load(accountID string) ([]byte, error) {
|
||||
entries, err := v.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sealedB64, ok := entries[accountID]
|
||||
if !ok {
|
||||
return nil, errors.New("entry not found")
|
||||
}
|
||||
sealed, err := base64.StdEncoding.DecodeString(sealedB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sealed) < gcm.NonceSize() {
|
||||
return nil, errors.New("entry corrupted")
|
||||
}
|
||||
nonce, ciphertext := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():]
|
||||
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||
}
|
||||
|
||||
// Delete 删除单条凭据
|
||||
func (v *Vault) Delete(accountID string) error {
|
||||
entries, err := v.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(entries, accountID)
|
||||
return v.persist(entries)
|
||||
}
|
||||
|
||||
func (v *Vault) persist(entries map[string]string) error {
|
||||
raw, err := json.MarshalIndent(vaultFile{Version: 1, Entries: entries}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(v.path(), raw, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
secret := []byte(`{"cookies":"session=abc","token":"xyz"}`)
|
||||
if err = v.Save("acc-1", secret); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
// 文件不应包含明文
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, "vault.enc"))
|
||||
if stringContains(string(raw), "session=abc") {
|
||||
t.Fatal("vault file must not contain plaintext")
|
||||
}
|
||||
got, err := v.Load("acc-1")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if string(got) != string(secret) {
|
||||
t.Fatalf("roundtrip mismatch: %s vs %s", got, secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopenPersists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v1, _ := Open(dir)
|
||||
_ = v1.Save("acc-2", []byte("data-2"))
|
||||
v2, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
got, err := v2.Load("acc-2")
|
||||
if err != nil || string(got) != "data-2" {
|
||||
t.Fatalf("reopen mismatch: %s %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, _ := Open(dir)
|
||||
_ = v.Save("acc-3", []byte("data-3"))
|
||||
path := filepath.Join(dir, "vault.enc")
|
||||
raw, _ := os.ReadFile(path)
|
||||
raw[len(raw)/2] ^= 0xff
|
||||
_ = os.WriteFile(path, raw, 0o600)
|
||||
if _, err := v.Load("acc-3"); err == nil {
|
||||
t.Fatal("tampered ciphertext should fail auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, _ := Open(dir)
|
||||
_ = v.Save("acc-4", []byte("d4"))
|
||||
if err := v.Delete("acc-4"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := v.Load("acc-4"); err == nil {
|
||||
t.Fatal("deleted entry should not load")
|
||||
}
|
||||
}
|
||||
|
||||
func stringContains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && indexOf(s, sub) >= 0
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package wsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"everypublish/agentcore/internal/device"
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// Client WSS 客户端:指数退避重连 + 任务幂等重放 + 挑战自动处理
|
||||
type Client struct {
|
||||
ServerURL string
|
||||
Identity *device.Identity
|
||||
Registry *exec.Registry
|
||||
ExecTimeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
writeMu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
backoff time.Duration
|
||||
executed map[string]exec.Result
|
||||
|
||||
chMu sync.Mutex
|
||||
challenges map[string]proto.Challenge // 活跃挑战(WinUI 壳展示)
|
||||
online bool
|
||||
sessionID string
|
||||
}
|
||||
|
||||
// New 构造客户端
|
||||
func New(serverURL string, id *device.Identity, registry *exec.Registry) *Client {
|
||||
return &Client{
|
||||
ServerURL: serverURL,
|
||||
Identity: id,
|
||||
Registry: registry,
|
||||
ExecTimeout: 5 * time.Minute,
|
||||
executed: make(map[string]exec.Result),
|
||||
challenges: make(map[string]proto.Challenge),
|
||||
}
|
||||
}
|
||||
|
||||
// Run 主循环:断线按 1s/5s/15s/60s 指数退避重连,握手成功重置
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
for {
|
||||
err := c.session(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
c.stepBackoff()
|
||||
log.Printf("[ws] connection lost (%v), reconnect in %s", err, c.backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(c.backoff):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) stepBackoff() {
|
||||
switch c.backoff {
|
||||
case 0:
|
||||
c.backoff = time.Second
|
||||
case time.Second:
|
||||
c.backoff = 5 * time.Second
|
||||
case 5 * time.Second:
|
||||
c.backoff = 15 * time.Second
|
||||
default:
|
||||
c.backoff = 60 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// session 单次连接会话:握手 → 心跳 → 消息循环
|
||||
func (c *Client) session(ctx context.Context) error {
|
||||
wsURL := "ws" + strings.TrimPrefix(c.ServerURL, "http") + "/ws/agent"
|
||||
conn, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "bye")
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.online = false
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
|
||||
// hello 握手(Ed25519 签名)
|
||||
nonce := randHex(16)
|
||||
ts := time.Now().UnixMilli()
|
||||
sig := c.Identity.Sign([]byte(nonce + "|" + strconv.FormatInt(ts, 10)))
|
||||
hello := proto.NewEnvelope(randHex(8), proto.TypeHello, proto.DeviceHello{
|
||||
DeviceID: strconv.FormatUint(c.Identity.DeviceID, 10),
|
||||
Nonce: nonce,
|
||||
TS: ts,
|
||||
Sig: sig,
|
||||
Version: "0.2.0",
|
||||
})
|
||||
if err = c.send(conn, hello); err != nil {
|
||||
return err
|
||||
}
|
||||
rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, raw, err := conn.Read(rctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ackEnv proto.Envelope
|
||||
_ = json.Unmarshal(raw, &ackEnv)
|
||||
if ackEnv.Type != proto.TypeHelloAck {
|
||||
return fmt.Errorf("expect hello.ack, got %s", ackEnv.Type)
|
||||
}
|
||||
var ack proto.HelloAck
|
||||
_ = json.Unmarshal(ackEnv.Payload, &ack)
|
||||
c.mu.Lock()
|
||||
c.backoff = 0 // 握手成功重置退避
|
||||
c.online = true
|
||||
c.sessionID = ack.SessionID
|
||||
c.mu.Unlock()
|
||||
log.Printf("[ws] online, session %s", ack.SessionID)
|
||||
|
||||
// 心跳(15s,服务器 90s 超时检测)
|
||||
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||
defer hbCancel()
|
||||
go c.heartbeatLoop(hbCtx)
|
||||
|
||||
// 消息循环
|
||||
for {
|
||||
typ, raw, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
var env proto.Envelope
|
||||
if err = json.Unmarshal(raw, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
c.handle(conn, env)
|
||||
}
|
||||
}
|
||||
|
||||
// handle 分发服务端消息
|
||||
func (c *Client) handle(conn *websocket.Conn, env proto.Envelope) {
|
||||
switch env.Type {
|
||||
case proto.TypeTaskPush:
|
||||
var push proto.TaskPush
|
||||
if err := json.Unmarshal(env.Payload, &push); err != nil {
|
||||
return
|
||||
}
|
||||
c.handleTaskPush(conn, env, push)
|
||||
case proto.TypeChallenge:
|
||||
var ch proto.Challenge
|
||||
if err := json.Unmarshal(env.Payload, &ch); err != nil {
|
||||
return
|
||||
}
|
||||
c.handleChallenge(conn, env, ch)
|
||||
case proto.TypeHeartbeatAck:
|
||||
// 忽略
|
||||
default:
|
||||
log.Printf("[ws] ignore msg %s", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTaskPush 任务下发:幂等(已执行过直接重放结果)+ 执行 + 回传
|
||||
func (c *Client) handleTaskPush(conn *websocket.Conn, env proto.Envelope, push proto.TaskPush) {
|
||||
c.mu.Lock()
|
||||
if res, ok := c.executed[push.TaskID]; ok {
|
||||
c.mu.Unlock()
|
||||
log.Printf("[ws] task %s already executed, replay result", push.TaskID)
|
||||
_ = c.send(conn, proto.NewEnvelope(env.ID, proto.TypeTaskAck, proto.TaskAck{TaskID: push.TaskID, Accept: true}))
|
||||
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeTaskResult, proto.TaskResult{
|
||||
TaskID: res.TaskID, Status: res.Status, PublishedURL: res.PublishedURL,
|
||||
Receipts: res.Receipts, Error: res.Error, FinishedAt: time.Now().UnixMilli(),
|
||||
}))
|
||||
return
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
log.Printf("[ws] TASK push %s platform=%s title=%s", push.TaskID, push.Platform, push.Title)
|
||||
_ = c.send(conn, proto.NewEnvelope(env.ID, proto.TypeTaskAck, proto.TaskAck{TaskID: push.TaskID, Accept: true}))
|
||||
|
||||
task := exec.Task{
|
||||
TaskID: push.TaskID, Platform: push.Platform, AccountID: push.AccountID, AccountName: push.AccountName,
|
||||
Title: push.Title, Content: push.Content, Tags: push.Tags, MaterialURLs: push.MaterialURLs,
|
||||
ScheduleAt: push.ScheduleAt, Priority: push.Priority,
|
||||
}
|
||||
var res exec.Result
|
||||
e := c.Registry.Get(push.Platform)
|
||||
if e == nil {
|
||||
res = exec.Result{TaskID: push.TaskID, Status: "failed", Error: "平台执行器未接入: " + push.Platform}
|
||||
} else {
|
||||
eCtx, cancel := context.WithTimeout(context.Background(), c.ExecTimeout)
|
||||
res = e.Execute(eCtx, task)
|
||||
cancel()
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.executed[push.TaskID] = res
|
||||
if len(c.executed) > 1024 {
|
||||
c.executed = make(map[string]exec.Result)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeTaskResult, proto.TaskResult{
|
||||
TaskID: res.TaskID, Status: res.Status, PublishedURL: res.PublishedURL,
|
||||
Receipts: res.Receipts, Error: res.Error, FinishedAt: time.Now().UnixMilli(),
|
||||
}))
|
||||
log.Printf("[ws] TASK %s done status=%s", push.TaskID, res.Status)
|
||||
}
|
||||
|
||||
// handleChallenge 挑战:ack + 有自动解决器则异步解决(60s 超时),否则留人工
|
||||
func (c *Client) handleChallenge(conn *websocket.Conn, env proto.Envelope, ch proto.Challenge) {
|
||||
log.Printf("[ws] CHALLENGE %s kind=%s prompt=%s", ch.ChallengeID, ch.Kind, ch.Prompt)
|
||||
c.chMu.Lock()
|
||||
c.challenges[ch.ChallengeID] = ch
|
||||
c.chMu.Unlock()
|
||||
_ = c.send(conn, proto.NewEnvelope(env.ID, proto.TypeChallengeAck, proto.ChallengeAck{ChallengeID: ch.ChallengeID, Action: "accept"}))
|
||||
solver := c.Registry.Solve(ch.Platform)
|
||||
if solver == nil {
|
||||
log.Printf("[ws] challenge %s 无自动解决器,等待人工处理", ch.ChallengeID)
|
||||
return
|
||||
}
|
||||
// 平台生成二维码时更新挑战记录(壳经 /challenges 展示)
|
||||
onQR := func(qrToken, qrURL string) {
|
||||
c.chMu.Lock()
|
||||
if cur, ok := c.challenges[ch.ChallengeID]; ok {
|
||||
cur.QRToken = qrToken
|
||||
cur.QRURL = qrURL
|
||||
c.challenges[ch.ChallengeID] = cur
|
||||
}
|
||||
c.chMu.Unlock()
|
||||
}
|
||||
go func() {
|
||||
sCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if solved, value := solver.SolveChallenge(sCtx, ch, onQR); solved {
|
||||
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeChallengeSolve, proto.ChallengeSolve{ChallengeID: ch.ChallengeID, Value: value}))
|
||||
c.chMu.Lock()
|
||||
delete(c.challenges, ch.ChallengeID)
|
||||
c.chMu.Unlock()
|
||||
log.Printf("[ws] CHALLENGE %s solved", ch.ChallengeID)
|
||||
} else {
|
||||
log.Printf("[ws] CHALLENGE %s 未解决,等待人工", ch.ChallengeID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// heartbeatLoop 心跳(15s)
|
||||
func (c *Client) heartbeatLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
if conn == nil {
|
||||
continue
|
||||
}
|
||||
_ = c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeHeartbeat, proto.Heartbeat{DeviceID: strconv.FormatUint(c.Identity.DeviceID, 10), TS: time.Now().UnixMilli()}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send 统一写出口(写锁串行化,避免并发写)
|
||||
func (c *Client) send(conn *websocket.Conn, msg *proto.Envelope) error {
|
||||
raw, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
return conn.Write(wctx, websocket.MessageText, raw)
|
||||
}
|
||||
|
||||
// Challenges 当前活跃挑战快照(WinUI 壳轮询展示)
|
||||
func (c *Client) Challenges() []proto.Challenge {
|
||||
c.chMu.Lock()
|
||||
defer c.chMu.Unlock()
|
||||
list := make([]proto.Challenge, 0, len(c.challenges))
|
||||
for _, ch := range c.challenges {
|
||||
list = append(list, ch)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// SolveChallenge 人工完成挑战(验证码/确认),经 WSS 回传服务器
|
||||
func (c *Client) SolveChallenge(challengeID, value string) error {
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
if conn == nil {
|
||||
return errors.New("offline")
|
||||
}
|
||||
return c.send(conn, proto.NewEnvelope(randHex(8), proto.TypeChallengeSolve, proto.ChallengeSolve{ChallengeID: challengeID, Value: value}))
|
||||
}
|
||||
|
||||
// Online 当前是否在线
|
||||
func (c *Client) Online() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.online
|
||||
}
|
||||
|
||||
// SessionID 当前会话 ID
|
||||
func (c *Client) SessionID() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.sessionID
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package wsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"everypublish/agentcore/internal/device"
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/shared/proto"
|
||||
)
|
||||
|
||||
// countingExec 计数执行器(验证幂等:只执行一次)
|
||||
type countingExec struct{ n int32 }
|
||||
|
||||
func (c *countingExec) Platform() string { return "" }
|
||||
func (c *countingExec) Execute(ctx context.Context, task exec.Task) exec.Result {
|
||||
atomic.AddInt32(&c.n, 1)
|
||||
return exec.Result{TaskID: task.TaskID, Status: "success", PublishedURL: "https://ex.com/" + task.TaskID}
|
||||
}
|
||||
func (c *countingExec) SolveChallenge(ctx context.Context, ch proto.Challenge) (bool, string) {
|
||||
return true, "tok"
|
||||
}
|
||||
|
||||
// verifyHello 校验客户端 hello 签名
|
||||
func verifyHello(t *testing.T, id *device.Identity, raw []byte) {
|
||||
t.Helper()
|
||||
var env proto.Envelope
|
||||
_ = json.Unmarshal(raw, &env)
|
||||
var hello proto.DeviceHello
|
||||
_ = json.Unmarshal(env.Payload, &hello)
|
||||
pub, _ := base64.StdEncoding.DecodeString(id.PublicKey)
|
||||
sig, _ := base64.StdEncoding.DecodeString(hello.Sig)
|
||||
if !ed25519.Verify(ed25519.PublicKey(pub), []byte(hello.Nonce+"|"+strconv.FormatInt(hello.TS, 10)), sig) {
|
||||
t.Errorf("hello signature invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// sendAck 回 hello.ack
|
||||
func sendAck(r *http.Request, conn *websocket.Conn) {
|
||||
ack := proto.NewEnvelope("ack1", proto.TypeHelloAck, proto.HelloAck{ServerNonce: "n", SessionID: "s1", ServerTS: 1})
|
||||
rawAck, _ := json.Marshal(ack)
|
||||
_ = conn.Write(r.Context(), websocket.MessageText, rawAck)
|
||||
}
|
||||
|
||||
// startStub 启动 stub:验签 → ack → 推任务1 → 收结果 → 再推任务1(重放)→ 收重放结果
|
||||
func startStub(t *testing.T, id *device.Identity) (*httptest.Server, chan proto.TaskResult, chan struct{}) {
|
||||
results := make(chan proto.TaskResult, 8)
|
||||
connected := make(chan struct{}, 8)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
_, raw, err := conn.Read(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
verifyHello(t, id, raw)
|
||||
sendAck(r, conn)
|
||||
connected <- struct{}{}
|
||||
|
||||
push := proto.NewEnvelope("p1", proto.TypeTaskPush, proto.TaskPush{TaskID: "1", Platform: "douyin", Title: "t1"})
|
||||
rawPush, _ := json.Marshal(push)
|
||||
_ = conn.Write(r.Context(), websocket.MessageText, rawPush)
|
||||
|
||||
gotFirst := false
|
||||
for {
|
||||
_, raw, err = conn.Read(r.Context())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var env2 proto.Envelope
|
||||
if json.Unmarshal(raw, &env2) != nil {
|
||||
continue
|
||||
}
|
||||
if env2.Type == proto.TypeTaskResult {
|
||||
var res proto.TaskResult
|
||||
_ = json.Unmarshal(env2.Payload, &res)
|
||||
results <- res
|
||||
if !gotFirst {
|
||||
gotFirst = true
|
||||
push2 := proto.NewEnvelope("p2", proto.TypeTaskPush, proto.TaskPush{TaskID: "1", Platform: "douyin", Title: "t1"})
|
||||
rawPush2, _ := json.Marshal(push2)
|
||||
_ = conn.Write(r.Context(), websocket.MessageText, rawPush2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
return srv, results, connected
|
||||
}
|
||||
|
||||
func TestClientTaskRoundTripAndIdempotent(t *testing.T) {
|
||||
id, _ := device.LoadOrCreate(t.TempDir(), "dev")
|
||||
id.DeviceID = 1
|
||||
registry := exec.NewRegistry()
|
||||
ce := &countingExec{}
|
||||
registry.SetFallback(ce)
|
||||
|
||||
srv, results, connected := startStub(t, id)
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, id, registry)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { _ = c.Run(ctx) }()
|
||||
|
||||
select {
|
||||
case <-connected:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("handshake timeout")
|
||||
}
|
||||
|
||||
var r1 proto.TaskResult
|
||||
select {
|
||||
case r1 = <-results:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("first result timeout")
|
||||
}
|
||||
if r1.Status != "success" {
|
||||
t.Fatalf("first result should be success, got %s", r1.Status)
|
||||
}
|
||||
|
||||
var r2 proto.TaskResult
|
||||
select {
|
||||
case r2 = <-results:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("replay result timeout")
|
||||
}
|
||||
if r2.Status != "success" || r2.PublishedURL != r1.PublishedURL {
|
||||
t.Fatalf("replay should return cached result, got %+v", r2)
|
||||
}
|
||||
|
||||
if n := atomic.LoadInt32(&ce.n); n != 1 {
|
||||
t.Fatalf("executor should run exactly once, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientReconnect 服务器断开后客户端应按退避自动重连
|
||||
func TestClientReconnect(t *testing.T) {
|
||||
id, _ := device.LoadOrCreate(t.TempDir(), "dev")
|
||||
id.DeviceID = 1
|
||||
registry := exec.NewRegistry()
|
||||
registry.SetFallback(&countingExec{})
|
||||
|
||||
var connCount int32
|
||||
connected := make(chan struct{}, 8)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
_, raw, err := conn.Read(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
verifyHello(t, id, raw)
|
||||
sendAck(r, conn)
|
||||
connected <- struct{}{}
|
||||
n := atomic.AddInt32(&connCount, 1)
|
||||
if n == 1 {
|
||||
// 第一次连接:ack 后立刻断开,模拟服务器故障
|
||||
_ = conn.Close(websocket.StatusGoingAway, "restart")
|
||||
return
|
||||
}
|
||||
// 后续连接:正常读循环
|
||||
for {
|
||||
if _, _, err = conn.Read(r.Context()); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, id, registry)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { _ = c.Run(ctx) }()
|
||||
|
||||
select {
|
||||
case <-connected:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("first connect timeout")
|
||||
}
|
||||
select {
|
||||
case <-connected:
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatal("reconnect timeout")
|
||||
}
|
||||
if atomic.LoadInt32(&connCount) < 2 {
|
||||
t.Fatal("expected second connection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// agent-core 客户端核心:设备身份 + 凭据保险库 + WSS 长连接 + 平台执行器。
|
||||
// 常驻模型:WinUI 壳 spawn 本进程 → 壳读 data/local.json(随机端口+token)→
|
||||
// 经 localhost HTTP 控制(状态/配对/挑战)。CLI 亦可独立运行(-user/-pass 自动配对)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"everypublish/agentcore/internal/config"
|
||||
"everypublish/agentcore/internal/device"
|
||||
"everypublish/agentcore/internal/exec"
|
||||
"everypublish/agentcore/internal/exec/bilibili"
|
||||
"everypublish/agentcore/internal/exec/douyin"
|
||||
"everypublish/agentcore/internal/exec/fake"
|
||||
"everypublish/agentcore/internal/exec/kuaishou"
|
||||
"everypublish/agentcore/internal/exec/xiaohongshu"
|
||||
"everypublish/agentcore/internal/localserver"
|
||||
"everypublish/agentcore/internal/pairing"
|
||||
"everypublish/agentcore/internal/vault"
|
||||
"everypublish/agentcore/internal/wsclient"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
server = flag.String("server", "", "服务器地址(默认取配置)")
|
||||
dir = flag.String("dir", "./data", "数据目录(设备密钥/保险库/配置)")
|
||||
name = flag.String("name", "", "设备名")
|
||||
user = flag.String("user", "", "开发便利:自动配对登录邮箱")
|
||||
password = flag.String("pass", "", "开发便利:自动配对登录密码")
|
||||
simMS = flag.Int("sim", 0, "假执行器模拟耗时 ms(0 用配置值)")
|
||||
token = flag.String("local-token", "", "本机控制服务 token(默认随机)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*dir)
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
if *server != "" {
|
||||
cfg.Server = *server
|
||||
}
|
||||
if *name != "" {
|
||||
cfg.Name = *name
|
||||
}
|
||||
if *simMS > 0 {
|
||||
cfg.SimMS = *simMS
|
||||
}
|
||||
_ = cfg.Save(*dir)
|
||||
|
||||
// 设备身份(私钥本机持久化 0600)
|
||||
id, err := device.LoadOrCreate(*dir, cfg.Name)
|
||||
if err != nil {
|
||||
log.Fatalf("device: %v", err)
|
||||
}
|
||||
cfg.Name = id.Name
|
||||
|
||||
// 凭据保险库(平台 cookie/token 加密落盘)
|
||||
v, err := vault.Open(*dir)
|
||||
if err != nil {
|
||||
log.Fatalf("vault: %v", err)
|
||||
}
|
||||
|
||||
// 执行器注册:B站无浏览器投稿 + 抖音/快手/小红书 CDP(D10/D11);
|
||||
// fakeAll=true 时全部走假执行器(无真实账号的安装联调)。
|
||||
registry := exec.NewRegistry()
|
||||
if !cfg.FakeAll {
|
||||
browserRoot := filepath.Join(*dir, "browser") // 每账号独立档案
|
||||
registry.Register(bilibili.New(v))
|
||||
registry.Register(douyin.New(v, browserRoot, cfg.Proxy, "", false))
|
||||
registry.Register(kuaishou.New(v, browserRoot, cfg.Proxy, "", false))
|
||||
registry.Register(xiaohongshu.New(v, browserRoot, cfg.Proxy, "", false))
|
||||
}
|
||||
registry.SetFallback(fake.Executor{SimMS: cfg.SimMS})
|
||||
|
||||
// WSS 客户端(重连/幂等/心跳)
|
||||
client := wsclient.New(cfg.Server, id, registry)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// 连接启动(配对后;未配对时等壳调 /pair)
|
||||
var runOnce sync.Once
|
||||
startConn := func() {
|
||||
if id.DeviceID == 0 {
|
||||
return
|
||||
}
|
||||
runOnce.Do(func() {
|
||||
go func() { _ = client.Run(ctx) }()
|
||||
})
|
||||
}
|
||||
|
||||
// 配对回调(本机控制服务 /pair 触发)
|
||||
pairFn := func(code, name string) error {
|
||||
if id.DeviceID > 0 {
|
||||
return errors.New("设备已配对")
|
||||
}
|
||||
did, err := pairing.PairWithCode(cfg.Server, code, name, "core", "0.2.0", id.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id.DeviceID = did
|
||||
if name != "" {
|
||||
id.Name = name
|
||||
cfg.Name = name
|
||||
}
|
||||
if err = id.Save(*dir); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("paired device %d (name %s)", id.DeviceID, id.Name)
|
||||
startConn()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 开发便利:CLI 自动配对
|
||||
if id.DeviceID == 0 && *user != "" && *password != "" {
|
||||
did, err := pairing.AutoPair(cfg.Server, *user, *password, cfg.Name, id.PublicKey)
|
||||
if err != nil {
|
||||
log.Fatalf("auto pair: %v", err)
|
||||
}
|
||||
id.DeviceID = did
|
||||
if err = id.Save(*dir); err != nil {
|
||||
log.Fatalf("save identity: %v", err)
|
||||
}
|
||||
log.Printf("auto paired device %d", id.DeviceID)
|
||||
}
|
||||
|
||||
startConn()
|
||||
|
||||
// 本机控制服务(WinUI 壳入口)
|
||||
localToken := *token
|
||||
if localToken == "" {
|
||||
localToken = localserver.RandToken()
|
||||
}
|
||||
ls := localserver.New(client, pairFn, localToken, *dir, func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"server": cfg.Server,
|
||||
"deviceId": id.DeviceID,
|
||||
"name": id.Name,
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
if err = ls.Listen(); err != nil {
|
||||
log.Fatalf("local server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if id.DeviceID == 0 {
|
||||
log.Printf("agent-core started UNPAIRED: server=%s(请在客户端输入网页端配对码)", cfg.Server)
|
||||
} else {
|
||||
log.Printf("agent-core started: server=%s device=%d", cfg.Server, id.DeviceID)
|
||||
}
|
||||
<-ctx.Done()
|
||||
log.Println("agent-core stopped")
|
||||
}
|
||||
Reference in New Issue
Block a user