feat: add Wechat Official Account API (#261)

This commit is contained in:
ltaoo authored and GitHub committed 2026-01-10 16:43:23 +08:00
1 parent ed41e48741
commit 4d97019bf7
83 files changed
+9237 -1166

No files matched your search

+2
View File
@@ -16,3 +16,5 @@ node_modules
docs/.vitepress/cache
docs/.vitepress/dist
gopeed.db
/*.json
*.log
+11
View File
@@ -65,6 +65,17 @@ CGO_ENABLED=1 GOOS=darwin SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build
```bash
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -ldflags="-s -w" -o wx_video_download
```
### Linux
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o wx_video_download
upx --best --lzma wx_video_download
```
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o wx_video_download
```
## 感谢
+484
View File
@@ -0,0 +1,484 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/pterm/pterm"
"wx_channel/pkg/cloudflare/worker"
)
var deploy_cmd = &cobra.Command{
Use: "deploy",
Short: "部署 Cloudflare Worker",
Long: "读取配置文件中的 Cloudflare 配置,通过 Cloudflare REST API 直接部署 Worker",
Run: func(cmd *cobra.Command, args []string) {
deploy()
},
}
func init() {
Register(deploy_cmd)
}
func deploy() {
pterm.DefaultSection.Println("开始部署 Cloudflare Worker (REST API)")
// 1. 获取配置
account_id := viper.GetString("cloudflare.accountId")
api_token := viper.GetString("cloudflare.apiToken")
worker_name := viper.GetString("cloudflare.workerName")
d1_database_id := viper.GetString("cloudflare.d1Id")
d1_database_name := viper.GetString("cloudflare.d1Name") // 新增:支持通过名称查找/创建
admin_token := viper.GetString("cloudflare.adminToken")
refresh_token := viper.GetString("cloudflare.refreshToken")
remote_server_hostname := viper.GetString("mp.remoteServer.hostname")
if api_token == "" || account_id == "" {
pterm.Error.Println("错误: 未配置 Cloudflare Auth Token 或 Account ID")
return
}
// 1.2 如果未配置 Database ID 但配置了 Name,尝试查找或创建
if d1_database_id == "" && d1_database_name != "" {
spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("未配置 Database ID,正在尝试查找名为 '%s' 的 D1 数据库...", d1_database_name))
id, err := find_d1_database_by_name(account_id, api_token, d1_database_name)
if err != nil {
spinner.Fail(fmt.Sprintf("查找数据库失败: %v", err))
return
}
if id != "" {
d1_database_id = id
spinner.Success(fmt.Sprintf("找到现有的 D1 数据库: %s (ID: %s)", d1_database_name, d1_database_id))
} else {
spinner.UpdateText(fmt.Sprintf("数据库 '%s' 不存在,正在创建...", d1_database_name))
new_id, err := create_d1_database(account_id, api_token, d1_database_name)
if err != nil {
spinner.Fail(fmt.Sprintf("创建数据库失败: %v", err))
return
}
d1_database_id = new_id
spinner.Success(fmt.Sprintf("成功创建 D1 数据库: %s (ID: %s)", d1_database_name, d1_database_id))
}
}
if d1_database_id == "" {
pterm.Error.Println("错误: 未配置 D1 Database ID (cloudflare.d1.databaseId) 且无法通过名称找到或创建")
return
}
// 1.5 执行数据库初始化 (直接调用 API)
spinner, _ := pterm.DefaultSpinner.Start("正在验证 D1 数据库连接...")
if err := verify_d1_database(account_id, api_token, d1_database_id); err != nil {
spinner.Fail(fmt.Sprintf("D1 数据库验证失败: %v", err))
return
}
spinner.Success("D1 数据库连接验证成功")
worker_dir := filepath.Join(Cfg.RootDir, "internal", "officialaccount", "worker")
// 1.6 执行数据库迁移
spinner, _ = pterm.DefaultSpinner.Start("正在检查并执行数据库迁移...")
if err := run_migrations(account_id, api_token, d1_database_id, filepath.Join(worker_dir, "migrations")); err != nil {
spinner.Warning(fmt.Sprintf("数据库迁移失败: %v", err))
} else {
spinner.Success("数据库迁移完成")
}
script_path := filepath.Join(worker_dir, "index.js")
script_content, err := os.ReadFile(script_path)
if err != nil {
pterm.Error.Printf("读取 Worker 脚本失败: %v\n", err)
return
}
// 3. 构造部署参数
deploy_body := worker.DeployBody{
AccountID: account_id,
AuthToken: api_token,
WorkerName: worker_name,
ScriptContent: script_content,
CompatibilityDate: "2024-01-01",
Bindings: []worker.Binding{
{Type: "d1", Name: "DB", ID: d1_database_id},
{Type: "plain_text", Name: "ADMIN_TOKEN", Text: admin_token},
{Type: "plain_text", Name: "REFRESH_TOKEN", Text: refresh_token},
{Type: "plain_text", Name: "REMOTE_SERVER", Text: remote_server_hostname},
},
}
// 4. 执行部署
spinner, _ = pterm.DefaultSpinner.Start(fmt.Sprintf("正在部署到 Cloudflare (Account: %s, Worker: %s)...", account_id, worker_name))
worker_id, err := worker.Deploy(deploy_body)
if err != nil {
spinner.Fail(fmt.Sprintf("部署失败: %v", err))
return
}
spinner.Success("部署成功!")
// 5. 获取子域名并输出访问地址
spinner, _ = pterm.DefaultSpinner.Start("正在获取 Worker 访问地址...")
subdomain, err := get_workers_subdomain(account_id, api_token)
workerUrl := ""
if err != nil {
spinner.Warning(fmt.Sprintf("获取子域名失败: %v", err))
workerUrl = fmt.Sprintf("https://%s.<your-subdomain>.workers.dev", worker_name)
} else {
workerUrl = fmt.Sprintf("https://%s.%s.workers.dev", worker_name, subdomain)
spinner.Success("获取访问地址成功")
}
pterm.Println()
pterm.DefaultHeader.WithFullWidth().Println("部署摘要")
panels := pterm.Panels{
{{Data: pterm.DefaultBox.WithTitle("Worker Info").Sprint(
pterm.Sprintf("%s: %s\n%s: %s\n%s: %s",
pterm.Bold.Sprint("Worker Name"), pterm.Cyan(worker_name),
pterm.Bold.Sprint("Worker ID"), pterm.Cyan(worker_id),
pterm.Bold.Sprint("URL"), pterm.LightGreen(workerUrl),
),
)}},
}
pterm.DefaultPanel.WithPanels(panels).Render()
pterm.Println()
pterm.DefaultHeader.WithFullWidth().Println("可用 API 列表")
tableData := [][]string{
{"Method", "Path", "Description"},
{"GET", "/api/mp/list", "获取公众号列表"},
{"GET", "/api/mp/msg/list", "获取公众号消息列表"},
{"POST", "/api/mp/refresh", "刷新/同步公众号信息 (需要 Refresh Token)"},
{"GET", "/rss/mp", "RSS 订阅地址 (参数: biz)"},
{"GET", "/mp/proxy", "微信文章代理 (解决防盗链)"},
{"POST", "/admin/token/add", "添加访问 Token (需要 Admin Token)"},
{"POST", "/admin/token/delete", "删除访问 Token (需要 Admin Token)"},
}
pterm.DefaultTable.WithHasHeader().WithBoxed().WithData(tableData).Render()
pterm.Println()
pterm.Info.Printf("提示: 请确保在 Cloudflare 后台为 Token 授予了足够的权限 (Workers:Edit, D1:Edit)\n")
}
func verify_d1_database(accountID, authToken, databaseID string) error {
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/d1/database/%s", accountID, databaseID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("create request failed: %v", err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
return nil
}
func run_migrations(accountID, authToken, databaseID, migrationsDir string) error {
// 1. Ensure migrations table exists
_, err := query_d1(accountID, authToken, databaseID, `CREATE TABLE IF NOT EXISTS d1_migrations (
id INTEGER PRIMARY KEY,
name TEXT,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`, nil)
if err != nil {
return fmt.Errorf("failed to ensure migrations table: %v", err)
}
// 2. Get applied migrations
resp, err := query_d1(accountID, authToken, databaseID, "SELECT id FROM d1_migrations", nil)
if err != nil {
return fmt.Errorf("failed to get applied migrations: %v", err)
}
applied := make(map[int]bool)
if len(resp.Result) > 0 && len(resp.Result[0].Results) > 0 {
for _, row := range resp.Result[0].Results {
if id, ok := row["id"].(float64); ok {
applied[int(id)] = true
}
}
}
// 3. Read migration files
files, err := os.ReadDir(migrationsDir)
if err != nil {
return fmt.Errorf("failed to read migrations directory: %v", err)
}
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".sql" {
continue
}
// Simple parsing: name should start with ID (e.g., 0001_init.sql)
var id int
if _, err := fmt.Sscanf(file.Name(), "%d_", &id); err != nil {
fmt.Printf("Skipping invalid migration file: %s\n", file.Name())
continue
}
if applied[id] {
continue
}
fmt.Printf("Applying migration: %s\n", file.Name())
content, err := os.ReadFile(filepath.Join(migrationsDir, file.Name()))
if err != nil {
return fmt.Errorf("failed to read migration file %s: %v", file.Name(), err)
}
// Execute migration and record it in a single batch (transaction)
// We append the INSERT statement to ensure atomicity.
fullSQL := string(content) + fmt.Sprintf("\nINSERT INTO d1_migrations (id, name) VALUES (%d, '%s');", id, file.Name())
if _, err := query_d1(accountID, authToken, databaseID, fullSQL, nil); err != nil {
return fmt.Errorf("failed to execute migration %s: %v", file.Name(), err)
}
fmt.Printf("Migration %s applied successfully\n", file.Name())
}
return nil
}
// Helper structs for D1 API response
type D1Response struct {
Result []struct {
Meta struct {
ChangedDB bool `json:"changed_db"`
Changes int `json:"changes"`
Duration float64 `json:"duration"`
} `json:"meta"`
Results []map[string]any `json:"results"`
Success bool `json:"success"`
} `json:"result"`
Success bool `json:"success"`
Errors []struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"errors"`
}
func query_d1(accountID, authToken, databaseID, sqlStr string, params []any) (*D1Response, error) {
req_body := map[string]any{
"sql": sqlStr,
"params": params,
}
if req_body["params"] == nil {
req_body["params"] = []any{}
}
json_body, err := json.Marshal(req_body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %v", err)
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/d1/database/%s/query", accountID, databaseID)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(json_body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
var errorResp D1Response
if jsonErr := json.Unmarshal(body, &errorResp); jsonErr == nil && len(errorResp.Errors) > 0 {
var sb strings.Builder
for _, e := range errorResp.Errors {
sb.WriteString(fmt.Sprintf("[%d] %s; ", e.Code, e.Message))
if e.Code == 7500 {
sb.WriteString(" (提示: Token 缺少 'D1:Edit' 权限,请在 Cloudflare 后台为 Token 添加 Account->Workers D1->Edit 权限)")
}
if strings.Contains(e.Message, "SQLITE_AUTH") {
sb.WriteString(fmt.Sprintf(" (Hint: Check if Token has 'D1:Edit' permission, and AccountID '%s' matches DatabaseID '%s'. Also ensure you are using a Token, not Global Key)", accountID, databaseID))
}
}
return nil, fmt.Errorf("D1 API error (Status %d): %s", resp.StatusCode, sb.String())
}
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var d1_resp D1Response
if err := json.Unmarshal(body, &d1_resp); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v, body: %s", err, string(body))
}
if !d1_resp.Success {
var sb strings.Builder
for _, e := range d1_resp.Errors {
sb.WriteString(fmt.Sprintf("[%d] %s; ", e.Code, e.Message))
if strings.Contains(e.Message, "SQLITE_AUTH") {
sb.WriteString(fmt.Sprintf(" (Hint: Check if Token has 'D1:Edit' permission, and AccountID '%s' matches DatabaseID '%s')", accountID, databaseID))
}
}
return nil, fmt.Errorf("D1 API error: %s", sb.String())
}
return &d1_resp, nil
}
// Helper to list/find D1 database by name
func find_d1_database_by_name(accountID, authToken, name string) (string, error) {
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/d1/database", accountID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("create request failed: %v", err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
var listResp struct {
Result []struct {
UUID string `json:"uuid"`
Name string `json:"name"`
} `json:"result"`
Success bool `json:"success"`
}
if err := json.Unmarshal(body, &listResp); err != nil {
return "", fmt.Errorf("unmarshal failed: %v", err)
}
if !listResp.Success {
return "", fmt.Errorf("api returned success=false")
}
for _, db := range listResp.Result {
if db.Name == name {
return db.UUID, nil
}
}
return "", nil
}
// Helper to create D1 database
func create_d1_database(accountID, authToken, name string) (string, error) {
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/d1/database", accountID)
reqBody := map[string]string{"name": name}
jsonBody, _ := json.Marshal(reqBody)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return "", fmt.Errorf("create request failed: %v", err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
var createResp struct {
Result struct {
UUID string `json:"uuid"`
Name string `json:"name"`
} `json:"result"`
Success bool `json:"success"`
}
if err := json.Unmarshal(body, &createResp); err != nil {
return "", fmt.Errorf("unmarshal failed: %v", err)
}
if !createResp.Success {
return "", fmt.Errorf("api returned success=false")
}
return createResp.Result.UUID, nil
}
func get_workers_subdomain(accountID, authToken string) (string, error) {
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/workers/subdomain", accountID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("create request failed: %v", err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
var subdomainResp struct {
Result struct {
Subdomain string `json:"subdomain"`
} `json:"result"`
Success bool `json:"success"`
}
if err := json.Unmarshal(body, &subdomainResp); err != nil {
return "", fmt.Errorf("unmarshal failed: %v", err)
}
if !subdomainResp.Success {
return "", fmt.Errorf("api returned success=false")
}
return subdomainResp.Result.Subdomain, nil
}
+245
View File
@@ -0,0 +1,245 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"time"
"github.com/fatih/color"
"github.com/rs/zerolog"
"github.com/spf13/cobra"
"wx_channel/internal/api"
"wx_channel/internal/manager"
"wx_channel/internal/officialaccount"
"wx_channel/pkg/system"
)
var (
mp_daemon bool
mp_daemon_child bool
)
var mp_cmd = &cobra.Command{
Use: "mp",
Short: "公众号服务",
Long: "仅启用公众号相关功能",
Run: func(cmd *cobra.Command, args []string) {
command := cmd.Name()
if command != "mp" {
return
}
if mp_daemon {
mp_start_daemon()
return
}
mp_command()
},
}
func init() {
mp_cmd.Flags().BoolVarP(&mp_daemon, "daemon", "d", false, "以守护进程运行")
mp_cmd.Flags().BoolVar(&mp_daemon_child, "daemon-child", false, "内部参数")
_ = mp_cmd.Flags().MarkHidden("daemon-child")
root_cmd.AddCommand(mp_cmd)
mp_cmd.AddCommand(mp_status_cmd)
mp_cmd.AddCommand(mp_stop_cmd)
}
func mp_command() {
ctx, stop := signal.NotifyContext(context.Background(), system.Signals()...)
defer stop()
cfg := Cfg
fmt.Printf("\nv%v\n", cfg.Version)
fmt.Printf("问题反馈 https://github.com/ltaoo/wx_channels_download/issues\n\n")
log_filepath := filepath.Join(cfg.RootDir, "app.log")
log_file, err := os.OpenFile(log_filepath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
color.Red(fmt.Sprintf("创建日志文件失败,%s\n\n", err))
return
}
defer log_file.Close()
logger := zerolog.New(log_file).With().Timestamp().Logger()
if cfg.FullPath != "" {
fmt.Printf("配置文件 %s\n", color.New(color.Underline).Sprint(cfg.FullPath))
}
mgr := manager.NewServerManager()
api_cfg := api.NewAPIConfig(Cfg, true)
mp_token_filepath, err := officialaccount.ValidateTokenFilepath(api_cfg.OfficialAccountTokenFilepath, cfg.RootDir)
if mp_token_filepath != "" && err == nil {
fmt.Printf("公众号授权凭证文件 %s\n", color.New(color.Underline).Sprint(mp_token_filepath))
}
l, err := net.Listen("tcp", api_cfg.Addr)
if err != nil {
color.Red(fmt.Sprintf("启动API服务失败,%s 被占用\n\n", api_cfg.Addr))
os.Exit(0)
return
}
l.Close()
api_srv := api.NewAPIServer(api_cfg, &logger)
mgr.RegisterServer(api_srv)
if mp_daemon_child {
_ = write_mp_pidfile(os.Getpid())
defer func() {
_ = remove_mp_pidfile()
}()
}
cleanup := func() {
fmt.Printf("\n正在关闭服务...\n")
if err := mgr.StopServer("api"); err != nil {
color.Red(fmt.Sprintf("⚠️ 关闭API服务失败: %v\n", err))
}
color.Green("服务已关闭")
}
if err := mgr.StartServer("api"); err != nil {
color.Red(fmt.Sprintf("ERROR 启动API服务失败: %v\n", err.Error()))
cleanup()
os.Exit(0)
return
}
color.Green(fmt.Sprintf("API服务启动成功, 地址: %v", api_srv.Addr()))
fmt.Println("\n按 Ctrl+C 退出...")
<-ctx.Done()
cleanup()
}
func mp_start_daemon() {
exe, err := os.Executable()
if err != nil {
color.Red(fmt.Sprintf("ERROR 获取可执行文件失败: %v\n", err))
return
}
log_fp := filepath.Join(Cfg.RootDir, "mp.log")
log_file, err := os.OpenFile(log_fp, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
color.Red(fmt.Sprintf("ERROR 打开日志文件失败: %v\n", err))
return
}
defer log_file.Close()
c := exec.Command(exe, "mp", "--daemon-child")
c.Stdout = log_file
c.Stderr = log_file
c.SysProcAttr = system.SetSysProcAttrForDaemon()
if err := c.Start(); err != nil {
color.Red(fmt.Sprintf("ERROR 启动守护进程失败: %v\n", err))
return
}
if err := write_mp_pidfile(c.Process.Pid); err != nil {
color.Red(fmt.Sprintf("ERROR 写入PID文件失败: %v\n", err))
} else {
color.Green(fmt.Sprintf("守护进程已启动, PID: %d", c.Process.Pid))
}
fmt.Printf("日志文件 %s\n", color.New(color.Underline).Sprint(log_fp))
}
func mp_pidfile_path() string {
return filepath.Join(Cfg.RootDir, "mp.pid")
}
func write_mp_pidfile(pid int) error {
fp := mp_pidfile_path()
return os.WriteFile(fp, []byte(strconv.Itoa(pid)), 0644)
}
func read_mp_pidfile() (int, error) {
fp := mp_pidfile_path()
data, err := os.ReadFile(fp)
if err != nil {
return 0, err
}
p, err := strconv.Atoi(string(data))
if err != nil {
return 0, err
}
return p, nil
}
func remove_mp_pidfile() error {
fp := mp_pidfile_path()
if _, err := os.Stat(fp); err == nil {
return os.Remove(fp)
}
return nil
}
var mp_status_cmd = &cobra.Command{
Use: "status",
Short: "查看公众号服务状态",
Run: func(cmd *cobra.Command, args []string) {
api_cfg := api.NewAPIConfig(Cfg, true)
pid, err := read_mp_pidfile()
if err != nil || pid == 0 {
color.Red("未发现守护进程")
return
}
running := system.IsProcessRunning(pid)
if !running {
color.Red(fmt.Sprintf("进程未运行, PID: %d", pid))
_ = remove_mp_pidfile()
return
}
ok := port_listening(api_cfg.Addr)
type Status struct {
PID int `json:"pid"`
Addr string `json:"addr"`
Listening bool `json:"listening"`
}
s := Status{PID: pid, Addr: api_cfg.Addr, Listening: ok}
b, _ := json.Marshal(s)
fmt.Println(string(b))
},
}
var mp_stop_cmd = &cobra.Command{
Use: "stop",
Short: "停止公众号服务",
Run: func(cmd *cobra.Command, args []string) {
pid, err := read_mp_pidfile()
if err != nil || pid == 0 {
color.Red("未发现守护进程")
return
}
if !system.IsProcessRunning(pid) {
color.Green("进程已停止")
_ = remove_mp_pidfile()
return
}
_ = system.TerminateProcess(pid)
expire := time.After(8 * time.Second)
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-tick.C:
if !system.IsProcessRunning(pid) {
_ = remove_mp_pidfile()
color.Green("服务已关闭")
return
}
case <-expire:
color.Red("关闭超时")
return
}
}
},
}
func port_listening(addr string) bool {
conn, err := net.DialTimeout("tcp", addr, 800*time.Millisecond)
if err != nil {
return false
}
_ = conn.Close()
return true
}
+50 -20
View File
@@ -6,9 +6,13 @@ import (
"net"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/fatih/color"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@@ -17,17 +21,18 @@ import (
"wx_channel/internal/interceptor"
"wx_channel/internal/interceptor/proxy"
"wx_channel/internal/manager"
"wx_channel/internal/officialaccount"
"wx_channel/pkg/certificate"
)
var (
Version string
Cfg *config.Config
CertFiles *certificate.CertFileAndKeyFile
device string
hostname string
port int
debug bool
CertFiles *certificate.CertFileAndKeyFile
Cfg *config.Config
)
var root_cmd = &cobra.Command{
@@ -52,10 +57,10 @@ func init() {
viper.BindPFlag("proxy.port", root_cmd.PersistentFlags().Lookup("port"))
}
func Execute(app_ver string, cert *certificate.CertFileAndKeyFile, cfg *config.Config) error {
func Execute(cert *certificate.CertFileAndKeyFile, cfg *config.Config) error {
cobra.MousetrapHelpText = ""
Version = app_ver
Version = cfg.Version
CertFiles = cert
Cfg = cfg
@@ -72,41 +77,66 @@ func root_command(cfg *config.Config) {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
fmt.Printf("\nv%v\n", Version)
fmt.Printf("\nv%v\n", cfg.Version)
fmt.Printf("问题反馈 https://github.com/ltaoo/wx_channels_download/issues\n\n")
interceptor_settings := interceptor.NewInterceptorSettings(cfg)
zerolog.SetGlobalLevel(zerolog.InfoLevel)
zerolog.TimeFieldFormat = time.RFC3339Nano
log_filepath := filepath.Join(cfg.RootDir, "app.log")
log_file, err := os.OpenFile(log_filepath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
color.Red(fmt.Sprintf("创建日志文件失败,%s\n\n", err))
return
}
defer log_file.Close()
logger := zerolog.New(log_file).With().Timestamp().Logger()
log.Logger = log.Output(os.Stderr)
log.Logger = log.With().
Str("service", "WechatHelper").
Str("version", cfg.Version).
Logger()
interceptor_cfg := interceptor.NewInterceptorSettings(cfg)
if cfg.FullPath != "" {
fmt.Printf("配置文件 %s\n", color.New(color.Underline).Sprint(cfg.FullPath))
}
if script_byte := interceptor_settings.InjectGlobalScript; script_byte != "" {
fmt.Printf("全局脚本 %s\n", color.New(color.Underline).Sprint(interceptor_settings.InjectGlobalScriptFilepath))
if script_byte := interceptor_cfg.InjectGlobalScript; script_byte != "" {
fmt.Printf("全局脚本 %s\n", color.New(color.Underline).Sprint(interceptor_cfg.InjectGlobalScriptFilepath))
}
mgr := manager.NewServerManager()
interceptor_srv := interceptor.NewInterceptorServer(interceptor_settings, CertFiles)
interceptor_srv := interceptor.NewInterceptorServer(interceptor_cfg, CertFiles)
interceptor_srv.Interceptor.AddPostPlugin(&proxy.Plugin{
Match: "api.weixin.qq.com",
Target: &proxy.TargetConfig{
Protocol: "http",
Host: interceptor_settings.APIServerHostname,
Port: interceptor_settings.APIServerPort,
Host: interceptor_cfg.APIServerHostname,
Port: interceptor_cfg.APIServerPort,
},
})
official_cfg := officialaccount.NewOfficialAccountConfig(Cfg, false)
interceptor_srv.Interceptor.AddPostPlugin(&proxy.Plugin{
Match: "official.weixin.qq.com",
Target: &proxy.TargetConfig{
Protocol: official_cfg.RemoteServerProtocol,
Host: official_cfg.RemoteServerHostname,
Port: official_cfg.RemoteServerPort,
},
})
mgr.RegisterServer(interceptor_srv)
api_settings := api.NewAPISettings(Cfg)
fmt.Printf("下载目录 %s\n\n", color.New(color.Underline).Sprint(api_settings.DownloadDir))
l, err := net.Listen("tcp", api_settings.Addr)
api_cfg := api.NewAPIConfig(Cfg, false)
interceptor_cfg.DownloadMaxRunning = api_cfg.MaxRunning
fmt.Printf("下载目录 %s\n\n", color.New(color.Underline).Sprint(api_cfg.DownloadDir))
l, err := net.Listen("tcp", api_cfg.Addr)
if err != nil {
color.Red(fmt.Sprintf("启动API服务失败,%s 被占用\n\n", api_settings.Addr))
color.Red(fmt.Sprintf("启动API服务失败,%s 被占用\n\n", api_cfg.Addr))
os.Exit(0)
return
}
l.Close()
api_srv := api.NewAPIServer(api_settings)
api_srv.APIClient.Interceptor = interceptor_srv.Interceptor
api_srv := api.NewAPIServer(api_cfg, &logger)
mgr.RegisterServer(api_srv)
interceptor_srv.Interceptor.FrontendVariables["downloadMaxRunning"] = api_settings.MaxRunning
interceptor_srv.Interceptor.FrontendVariables["downloadDir"] = api_settings.DownloadDir
interceptor_srv.Interceptor.FrontendVariables["downloadMaxRunning"] = api_cfg.MaxRunning
interceptor_srv.Interceptor.FrontendVariables["downloadDir"] = api_cfg.DownloadDir
cleanup := func() {
fmt.Printf("\n正在关闭服务...\n")
@@ -132,7 +162,7 @@ func root_command(cfg *config.Config) {
}
color.Green("代理服务启动成功")
if !interceptor_settings.ProxySetSystem {
if !interceptor_cfg.ProxySetSystem {
color.Red(fmt.Sprintf("当前未设置系统代理,请通过软件将流量转发至 %v", interceptor_srv.Addr()))
color.Red("设置成功后再打开视频号页面下载")
} else {
+271
View File
@@ -0,0 +1,271 @@
package cmd
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/blang/semver"
"github.com/inconshreveable/go-update"
"github.com/pterm/pterm"
"github.com/rhysd/go-github-selfupdate/selfupdate"
"github.com/spf13/cobra"
)
var updateCmd = &cobra.Command{
Use: "update",
Short: "检查并更新到最新版本",
Run: func(cmd *cobra.Command, args []string) {
do_update()
},
}
func init() {
root_cmd.AddCommand(updateCmd)
}
type GitHubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt time.Time `json:"published_at"`
Body string `json:"body"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func do_update() {
spinner, _ := pterm.DefaultSpinner.Start("正在检查更新...")
releases, err := fetch_releases("ltaoo/wx_channels_download")
if err != nil {
spinner.Fail(fmt.Sprintf("检查更新失败: %v", err))
return
}
if len(releases) == 0 {
spinner.Warning("未找到发布版本")
return
}
spinner.Success("检查完成")
latest := releases[0]
currentVer, err := semver.ParseTolerant(Version)
if err != nil {
pterm.Warning.Printf("当前版本号(%s)格式不正确,无法比较版本\n", Version)
}
latestVer, err := semver.ParseTolerant(latest.TagName)
if err != nil {
pterm.Error.Printf("最新版本号(%s)格式不正确,无法比较版本\n", latest.TagName)
return
}
if currentVer.GE(latestVer) {
pterm.Info.Printf("当前已是最新版本: %s\n", Version)
return
}
pterm.DefaultSection.Println("发现新版本")
pterm.Info.Printf("最新版本: %s (当前版本: %s)\n", latest.TagName, Version)
pterm.Info.Printf("发布时间: %s\n", latest.PublishedAt.Format("2006-01-02 15:04:05"))
pterm.Println()
pterm.Println(pterm.Yellow("发布说明:"))
pterm.Println(latest.Body)
pterm.Println()
result, _ := pterm.DefaultInteractiveConfirm.Show("是否开始更新?")
if !result {
pterm.Info.Println("已取消更新")
return
}
assetURL, assetName := find_asset(latest)
if assetURL == "" {
pterm.Error.Printf("未找到适用于当前系统 (%s/%s) 的安装包\n", runtime.GOOS, runtime.GOARCH)
return
}
exe, err := os.Executable()
if err != nil {
pterm.Error.Println("无法获取当前程序路径:", err)
return
}
pterm.Info.Println("正在下载并更新...")
// If it's a compressed file, we need to handle it manually
if strings.HasSuffix(assetName, ".zip") || strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
if err := update_from_compressed(assetURL, assetName, exe); err != nil {
pterm.Error.Println("更新失败:", err)
return
}
} else {
// If it's a binary directly (unlikely based on description but possible)
spinner, _ := pterm.DefaultSpinner.Start("正在下载并应用更新...")
if err := selfupdate.UpdateTo(assetURL, exe); err != nil {
spinner.Fail(fmt.Sprintf("更新失败: %v", err))
return
}
spinner.Success("更新完成")
}
pterm.Success.Printf("成功更新至版本 %s\n", latest.TagName)
}
func fetch_releases(slug string) ([]GitHubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases", slug)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status: %s", resp.Status)
}
var releases []GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, err
}
return releases, nil
}
func find_asset(release GitHubRelease) (string, string) {
os := runtime.GOOS
arch := runtime.GOARCH
targetArch := arch
if arch == "amd64" {
targetArch = "x86_64"
} else if arch == "386" {
targetArch = "x86"
}
for _, asset := range release.Assets {
name := strings.ToLower(asset.Name)
if strings.Contains(name, os) && strings.Contains(name, targetArch) {
return asset.BrowserDownloadURL, asset.Name
}
}
return "", ""
}
// ProgressReader wraps an io.Reader and updates a pterm.ProgressbarPrinter
type ProgressReader struct {
Reader io.Reader
Bar *pterm.ProgressbarPrinter
}
func (pr *ProgressReader) Read(p []byte) (n int, err error) {
n, err = pr.Reader.Read(p)
if n > 0 {
pr.Bar.Add(n)
}
return
}
func update_from_compressed(url, filename, exePath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status: %s", resp.Status)
}
// Create progress bar
bar, _ := pterm.DefaultProgressbar.WithTotal(int(resp.ContentLength)).WithTitle("正在下载").Start()
defer bar.Stop()
proxyReader := &ProgressReader{
Reader: resp.Body,
Bar: bar,
}
// Read content into memory to avoid temp files if possible,
// or stream to decompressor.
// Since we need to find the binary inside, we stream.
var binaryReader io.Reader
if strings.HasSuffix(filename, ".zip") {
// For zip, we need random access, so we have to read it all or save to temp file.
// Reading to memory is cleaner if not too huge.
bodyBytes, err := io.ReadAll(proxyReader)
if err != nil {
return err
}
zipReader, err := zip.NewReader(bytes.NewReader(bodyBytes), int64(len(bodyBytes)))
if err != nil {
return err
}
for _, file := range zipReader.File {
if is_executable_file(file.Name) {
rc, err := file.Open()
if err != nil {
return err
}
defer rc.Close()
binaryReader = rc
break
}
}
} else if strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz") {
gzipReader, err := gzip.NewReader(proxyReader)
if err != nil {
return err
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
if is_executable_file(header.Name) {
binaryReader = tarReader
break
}
}
}
if binaryReader == nil {
return fmt.Errorf("executable not found in archive")
}
return update.Apply(binaryReader, update.Options{})
}
func is_executable_file(name string) bool {
// Simple check: name matches our binary name "wx_channels_download" or "wx_video_download"
// or ends with .exe on windows
base := filepath.Base(name)
if runtime.GOOS == "windows" {
return strings.EqualFold(base, "wx_channels_download.exe") || strings.EqualFold(base, "wx_video_download.exe")
}
return base == "wx_channels_download" || base == "wx_video_download"
}
+2 -1
View File
@@ -58,7 +58,8 @@ export default defineConfig({
text: "功能",
items: [
{ text: "API", link: "/feature/api" },
{ text: "长视频下载", link: "/feature/long_video" },
{ text: "公众号RSS", link: "/feature/mp-rss" },
{ text: "长视频下载", link: "/feature/long-video" },
{ text: "mp3下载", link: "/feature/mp3" },
{ text: "直播下载", link: "/feature/live" },
{ text: "批量下载", link: "/feature/batch" },
Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

+40
View File
@@ -0,0 +1,40 @@
---
title: 公众号远端服务
---
# 公众号远端服务
仅开启公众号接口、RSS能力,使其可以在 `linux` 服务器上运行
## 用法
```sh
wx_video_download mp -d
```
将以守护进程模式运行,端口默认为 `2022`,可以在 `config.yaml` 中修改。
### 非守护进程模式运行
```bash
wx_video_download mp start
```
将启动服务,但默认在前台运行,服务器退出后服务也会关闭
### 查看服务状态
```bash
wx_video_download mp status
```
查看端口号、是否运行中
### 停止服务
```bash
wx_video_download mp stop
```
将停止服务
+14 -3
View File
@@ -51,7 +51,7 @@ type params = {
```yaml
download:
dir: %UserDownloads%
dir: "%UserDownloads%"
```
指定下载目录,默认用户下载目录。仅在后台下载时生效
@@ -65,7 +65,7 @@ download:
是否使用前端下载,默认 `false`
设置为 `true` 后,会在前端进行下载、解密,下载长视频大概率会有问题。并且「下载目录」会失效;下载文件名不支持 `/` 字符来创建目录。
设置为 `true` 后,会在前端进行下载、解密,下载长视频大概率会有问题。并且「下载目录」配置会失效、下载文件名不支持 `/` 字符来创建目录。
## 下载视频时暂停视频播放
@@ -74,5 +74,16 @@ download:
pauseVideoWhenDownload: false
```
是否在前端下载时暂停视频播放,默认 `false`
是否在前端下载时暂停视频播放,默认 `false`。下载完成后会继续播放。
## 完成音效
```yaml
```yaml
download:
playDoneAudio: true
```
后台下载模式下,下载完成后是否播放完成音效,默认 `true`。
+53
View File
@@ -0,0 +1,53 @@
---
title: 公众号服务
---
# 公众号服务
对公众号的 RSS、刷新凭证、请求凭证进行配置
## remoteServer
```yaml
mp:
remoteServer:
protocol: "https"
hostname: "rss.example.com"
port: 80
```
在本地打开公众号文章页面,会向该服务提交凭证信息。同时,如果 `RSS` 链接指定了 `proxy=1`,添加的代理前缀也是该服务的地址
如果在本地运行,需要和 `api` 的服务配置相同
### 远端服务模式
即部署在 `linux` 服务器上,仅提供 `RSS` 和 `API` 服务。该模式下,`remoteServer` 需要设置为服务器的地址,或者实际的域名,比如
```yaml
mp:
remoteServer:
protocol: "https"
hostname: "rss.example.com"
port: 80
```
同时配套的「本地服务」的配置,和上面保持一致
## 刷新凭证
```yaml
mp:
refreshToken: "123"
```
同样本地和远端都要配置,本地向远端提交公众号凭证时,通过该 `token` 校验请求是否授权
## 请求凭证
```yaml
mp:
tokenFilepath: "./token.txt"
```
是一个文件路径,可以指定一个绝对路径。启动后,如果该文件存在,则会读取内容,将**每一行作为一个token**,内容不为空(存在`token`时),每次 `API` 请求或 `RSS` 请求,都需要在 `query` 加上 `token=` 参数,否则会拒绝访问
+29 -1
View File
@@ -22,7 +22,7 @@ title: API
curl http://localhost:2022/api/channels/contact/search?keyword=龙虾
```
## 获取视频号账号的视频列表
## 获取视频号指定账号的视频列表
```bash
curl http://localhost:2022/api/channels/contact/feed/list?username=v2_060000231003b20faec8c4e48e1dc3ddcd03ec3cb077bb11b3c6c9a42ee3cea8073a64e6e2bd@finder
@@ -33,3 +33,31 @@ curl http://localhost:2022/api/channels/contact/feed/list?username=v2_0600002310
```bash
curl http://localhost:2022/api/channels/feed/profile?oid=14545102784038246591&nid=17453311911488030792_0_140_2_32_5217671461141767_bf99bd48-e5eb-11f0-9859-81948764da50
```
或者直接传入视频号页面地址,如 `https://channels.weixin.qq.com/web/pages/feed?oid=zagCB5LjCrE&nid=d3pMFaDgxy4`
传入的地址需要进行编码
```js
encodeURIComponent(
"https://channels.weixin.qq.com/web/pages/feed?oid=zagCB5LjCrE&nid=d3pMFaDgxy4"
);
```
```bash
curl http://localhost:2022/api/channels/feed/profile?url=https%3A%2F%2Fchannels.weixin.qq.com%2Fweb%2Fpages%2Ffeed%3Foid%3DzagCB5LjCrE%26nid%3Dd3pMFaDgxy4
```
## 创建视频号下载任务
```bash
curl -X POST "http://127.0.0.1:2022/api/task/create_channels" \
-H "Content-Type: application/json" \
-d '{"cover": true, "url": "https://channels.weixin.qq.com/web/pages/feed?oid=zagCB5LjCrE&nid=d3pMFaDgxy4&context_id=33-9-141-18a2bc728e23eacd62e8fc98e3bbff391768023553823&entrance_id=1002&req_time=1767887337&exportkey=n_ChQIAhIQeQFDxKsyNw296ySx31udbxKMAgIE97dBBAEAAAAAAJtcOgVgeQAAAAAOpnltbLcz9gKNyK89dVj0Z5WHOTv3WqpHc4LJgnpXV6g383YJo8%2BUIOjT2Y9k%2FNCj%2BnAXGPEP5rSwX6eTMFijG9xRV5wJM7F4%2F%2BKked55Q2Ao8WRg7LI05FClrpb0iNlfi%2B4HttbXt0E5o4U3vpzAAb%2F3WXhHUBrbc3DgmXHpxOSHPx3BdgQaE7IotUe9IS5cv%2Bf3BJCBEI1pZHs3e5%2FMs1ZRjV3Crwg0%2FShUoUG%2FqKstXMRHn2KJ0uM4H93DWxIBxtTMnDbk3%2F9CFjCo6n4J73vvGRoIex8nLMUZ%2FC6mW3GYqn%2Fp9hp70GlLg5ScHexjw3HklyQ%3D&pass_ticket=VgBMoEBGN9Dup64gcPQ%2BHeruABRSIVerbzmQp9w1bCZsXDsoZwddjH0M%2Bzaey5yuJsXz02LqYJZrqzgl57DvKw%3D%3D&wx_header=0"}'
```
其中 `body` 和详情接口一样,支持传 `oid`+`nid`,或者 `url`。
传 `mp3`,表示下载为 `mp3` 文件
传 `cover`,表示下载视频封面
File renamed without changes.
+178
View File
@@ -0,0 +1,178 @@
---
title: 微信公众号RSS
---
# 微信公众号 RSS
目前处于 Beta 阶段,有如下功能
- 本地通过代理服务,在打开公众号页面时,自动添加该公众号
- 本地提供 <code v-pre>http://127.0.0.1:2022/mp/home</code> 页面,可以管理添加的公众号
![RSS按钮](../assets/mp_rss_home.png)
- 提供 <code v-pre>{{APIServerAddr}}/rss/mp</code> 接口,用于订阅指定公众号
- 提供 `deploy` 命令,用于部署到 `Cloudflare Workers`
- 提供 `mp` 命令,用于在 `linux` 服务器上运行,仅包含 `API` 和 `RSS` 服务
## 原理说明
访问公众号文章页面时,可以获取到该公众号的一些参数,包括 `biz`、`key` 和 `pass_ticket`,使用这些参数(下面称为授权凭证),可以调用微信公众号的接口,获取到该公众号的推送消息列表。
但是授权凭证,30 分钟 就失效了,所以需要定时再通过访问公众号文章页面重新获取
## 使用方式
下面的步骤,都是为了可以在公网上使用 `RSS` 能力
**仍需要 macOS 或 Windows 机器,用于同步「授权凭证」到远端服务**
### cloudflare
在 `config.yaml` 中,配置好 `cloudflare` 的配置项
```yaml
cloudflare:
accountId: ""
apiToken: ""
refreshToken: "refresh_token"
adminToken: "admin_token"
workerName: "mp-rss-api"
d1Name: "mp-rss-db"
```
其中 `accountId` 可以登录后查看地址,域名后面 32 长度的,字母+数字的字符串就是 `accountId`。`apiToken` 可以在 `Cloudflare` 登录后,点击 `Profile` -> `API Tokens` 查看。
https://dash.cloudflare.com/profile/api-tokens
`api token` 必须包含 `worker` 的查看、编辑权限,和 `d1` 的查看、编辑权限,否则会报错
其中 `refreshToken` 用于「本地」向「远端服务」提交「授权凭证」时的校验,可以避免被无授权的人修改公众号授权凭证,导致服务不可用
上述配置完成后,执行下面命令即可部署到 `Cloudflare`
```bash
./wx_channels_download deploy
```
### 本地刷新配置
完成后,继续修改配置
```yaml
mp:
remoteServer:
protocol: "https"
hostname: "mp-rss-api.xx.workers.dev" # 这里填部署好的 cloudflare worker 访问域名
port: 80
refreshToken: "refresh_token" # 这里填 cloudflare worker 配置的 refreshToken
```
完成后,启动 `wx_channels_download`,然后,打开**任意公众号文章页面**,可以在下方看到 `RSS` 按钮
![RSS按钮](../assets/official_account_rss.png)
> 如果没有,手动刷新一下页面即可
点击即可复制该公众号 RSS 订阅链接,在浏览器打开该链接,即可看到 `XML` 格式的订阅内容
> 在浏览器打开时,如果提示 `Account not found`,就是没有提交成功,给终端设置科学上网后,重新启动 `wx_channels_download`、打开公众号文章页面刷新即可
> 可以查看本地的 `app.log`,如果出现 `push credential to remote server: request failed` 内容,就是提交到 `cloudflare worker` 失败了
至此,整个流程就完成了
### 公众号管理
在 <code>http://127.0.0.1:2022/mp/home</code> 页面,可以管理添加的公众号。当公众号授权凭证过期,在页面上会显示「失效」,在有公众号文章页面打开的状态下后,在 `/mp/home` 页面点击「刷新」即可重新获取授权凭证,该凭证会同步提交到部署的 `cloudflare worker`
### 部署到 linux 服务
和 `cloudflare worker` 一样,仅提供 `API` 和 `RSS` 服务。但是相比 `cloudflare worker` 支持更多能力
下载构建包到 `linux` 服务器,修改配置文件
```yaml
# config.yaml
mp:
remoteServer:
protocol: "https"
hostname: "rss.example.com"
port: 80
refreshToken: "123"
tokenFilepath: ""
```
`tokenFilepath` 指定调用 `API` 或 `RSS` 时的授权凭证文件,可以留空。或者指定一个 `./token.txt`,那么会读取该文件,每行作为一个 `token`,调用接口时必须传入 `&token=`,否则会拒绝访问。
使用 `chmod +x ./wx_video_download` 给二进制文件添加权限,然后运行命令 `./wx_video_download mp -d` 启动,该命令仅运行公众号相关的功能,且作为守护进程运行
同样,修改本地配置文件,用于向远端服务提交授权凭证
```yaml
# config.yaml
mp:
remoteServer:
protocol: "https"
hostname: "rss.example.com"
port: 80
refreshToken: "123"
```
`linux` 上部署的服务,可以打开 `rss.example.com/mp/home` 查看公众号列表,并且可以查看公众号是否可用。
## API
## 获取指定公众号消息列表
```bash
curl http://localhost:2022/api/mp/msg/list?biz=MzI2NDk5NzA0Mw==
```
通过 `offset` 指定偏移量,可以获取到更多的消息记录
```bash
curl http://localhost:2022/api/mp/msg/list?biz=MzI2NDk5NzA0Mw==&offset=10
```
## 获取可请求的公众号列表
```bash
curl http://localhost:2022/api/mp/list
```
返回公众号昵称、头像和「授权凭证」。授权凭证用来请求「推送消息列表」,凭证有效期大概是半小时,需要定时刷新凭证
> 凭证如果过期,不会从列表移除
## 公众号 RSS
```bash
curl http://localhost:2022/rss/mp?biz=MzI2NDk5NzA0Mw==
```
同样可以传入 `offset` 获取到更多消息记录。
还提供 `proxy` 和 `content` 参数,分别用于代理公众号内容和默认获取公众号文章全文
### proxy
> 仅在 `linux` 部署的服务上支持
如果阅读器提供获取全文能力,但是无法正确获取到公众号文章正文,可以指定 `proxy=1`,那么返回文章列表中,文章链接都会添加 <code>{{APIServerAddr}}/mp/proxy</code> 前缀,当打开文章时,将使用代理代为请求微信公众号返回正文内容
### proxy_cover
> 仅在 `linux` 部署的服务上支持
相比 `proxy`,`proxy_cover` 仅代理封面图片,可以用在仅需查看列表,点击跳转到原文的场景
### content
> 仅在 `linux` 部署的服务上支持
如果希望直接获取到正文,可以指定 `content=1`,那么请求 `RSS` 接口时,就会同时获取正文。但是缺点就是列表接口会比较慢(因为要依次请求到正文)
## 注意事项
调用推送消息列表接口会有频率限制,所以不能添加太多的公众号。经过测试 300 个公众号,每 20 分钟重新获取授权凭证,3 个小时后帐号被风控,无法获取推送消息列表了,但是微信本身还是可以正常使用,包括公众号功能也是正常的,不影响
基于上面的风险,现在限制只能添加 20 个公众号。可以自己修改源码放开限制,风险自己把控。
+2
View File
@@ -7,3 +7,5 @@ title: 下载mp3
> 251226 版本后,必须安装 `ffmpeg` 才能下载 `mp3` 文件。或者在配置文件中指定 `download.frontend` 为 `true`,则会在前端转换 `mp3` 文件,无需本地安装 `ffmpeg`。
在「视频详情页」的「更多」菜单中,有「下载为 mp3」菜单,点击即可下载 `mp3` 文件
后台下载模式下,在下载面板中显示的文件大小,和实际的会不同,因为下载为 `mp3` 文件时,会先下载 `mp4` 文件,然后使用 `ffmpeg` 转换为 `mp3` 文件。
+116
View File
@@ -0,0 +1,116 @@
---
title: 公众号
---
# 公众号
和视频号相同的注入方案,可以获取指定公众号的推送消息列表,并提供 `API` 调用
- 获取指定公众号的推送消息列表
- RSS
需要打开某个公众号文章页面,并且不能关闭,否则调用接口时会提示 `{"code":400,"msg":"请先初始化客户端 socket 连接"}`。当出现该提示时,打开任意公众号文章页面即可,如果已经打开,刷新一下即可。
## 获取指定公众号消息列表
```bash
curl http://localhost:2022/api/mp/msg/list?biz=MzI2NDk5NzA0Mw==
```
通过 `offset` 指定偏移量,可以获取到更多的消息记录
```bash
curl http://localhost:2022/api/mp/msg/list?biz=MzI2NDk5NzA0Mw==&offset=10
```
## 获取可请求的公众号列表
```bash
curl http://localhost:2022/api/mp/list
```
返回公众号昵称、头像和「授权凭证」。授权凭证用来请求「推送消息列表」,凭证有效期大概是半小时,需要定时刷新凭证
> 凭证如果过期,不会从列表移除
## 公众号 RSS
```bash
curl http://localhost:2022/rss/mp?biz=MzI2NDk5NzA0Mw==
```
同样可以传入 `offset` 获取到更多消息记录。
还提供 `proxy` 和 `content` 参数,分别用于代理公众号内容和默认获取公众号文章全文
### proxy
如果阅读器提供获取全文能力,但是无法正确获取到公众号文章正文,可以指定 `proxy=1`,那么返回文章列表中,文章链接都会添加 `{ServiceAddr}/mp/proxy` 前缀,当打开文章时,将使用代理代为请求微信公众号返回正文内容
### content
如果希望直接获取到正文,可以指定 `content=1`,那么请求 `RSS` 接口时,就会同时获取正文。但是缺点就是列表接口会比较慢(因为要依次请求到正文)
## 远端服务模式
本下载器依赖微信应用本体,导致无法在 `linux` 服务器上部署。所以额外增加「远端服务」模式,同时配合 本地+远端,可以在 `linux` 服务器上部署 `API` 和 `RSS` 服务
**仍需要 macOS 或 Windows 机器,用于同步「公众号授权凭证」到远端服务**
### 部署说明
下载构建包到 `linux` 服务器,修改配置文件
```yaml
# config.yaml
api:
protocol: "http"
hostname: "127.0.0.1"
port: 2022
mp:
remoteServer:
protocol: "https"
hostname: "rss.example.com"
port: 80
refreshToken: "123"
tokenFilepath: ""
```
其中 `refreshToken` 用于「本地」向「远端服务」提交「公众号授权凭证」时的校验,可以避免被无授权的人修改公众号授权凭证,导致服务不可用
`tokenFilepath` 指定调用 `API` 或 `RSS` 时的授权凭证文件,可以留空。或者指定一个 `./token.txt`,那么会读取该文件,每行作为一个 `token`,调用接口时必须传入 `&token=`,否则会拒绝访问。
使用 `chmod +x ./wx_video_download` 给二进制文件添加权限,然后运行命令 `./wx_video_download mp -d` 启动,该命令仅运行公众号相关的功能,且作为守护进程运行
运行成功之后,调用「获取可请求的公众号列表」会返回空列表。调用「获取指定公众号消息列表」会提示 `Please adding Credentials first`
表示「远端服务」部署成功,接下来运行「本地」服务
在任意 `macOS` 或 `Windows` 机器上,下载对应平台构建包,修改配置文件
```yaml
# config.yaml
mp:
remoteServer:
protocol: "https"
hostname: "rss.example.com"
port: 80
refreshToken: "123"
tokenFilepath: ""
```
内容和「远端服务」的配置文件保持相同。然后,通过命令 `./wx_video_download` 启动,**打开任意公众号文章页面**,会自动向「远端服务」提交该公众号的授权凭证
同时,在打开的公众号文章页面,账号名称旁会出现 RSS 图标,点击会复制 `RSS` 订阅链接
![RSS按钮](../assets/official_account_rss.png)
内容类似 `https://rss.example.com/rss/mp?biz=MzI2NDk5NzA0Mw==`,可以在浏览器打开该链接,验证服务是否正常
调用「获取可请求的公众号列表」,也可以看到刚刚打开的公众号信息
到此,公众号RSS 服务就可以正常使用了。本地 `macOS` 或 `Windows` 上的服务不要关闭,会定时获取在列表中的公众号,刷新凭证,保持「远端服务」的 `RSS` 服务一直可用
+2 -1
View File
@@ -8,8 +8,9 @@ title: macOS 启用
但是通过命令行运行就完全不会触发任何的校验
如果有问题,仍按照下面步骤进行
**首次打开需要使用 `sudo wx_video_download` 运行一次,后续双击打开即可**
如果有问题,仍按照下面步骤进行
## 赋予执行权限
+26
View File
@@ -0,0 +1,26 @@
---
title: v260110
---
# v260110
增加了微信公众号 RSS 功能
## 更新内容
- 修复了暂停的任务没有继续开始按钮的问题
- 修复了批量下载会出现重复下载的问题
- 修复了重复启动下载器时,第二个终端无法正常退出的问题
- 直播下载按钮增加 rate 和 质量描述
- 增加了创建视频号下载任务接口
- 视频号视频列表接口现在支持 `next_marker` 获取下一页
- 视频号详情接口现在支持直接传入视频号页面地址了
- 增加 `/play` 接口,可以传入视频地址(非视频号页面地址)和 `key`,即可直接播放
- 优化下载面板样式,增加总下载任务数
- 支持配置下载完成后是否播放音效
## 公众号RSS
参考 [公众号RSS](../feature/mp-rss.md)
+24 -1
View File
@@ -6,25 +6,35 @@ require (
github.com/GopeedLab/gopeed v1.8.3
github.com/adrg/xdg v0.5.3
github.com/andybalholm/brotli v1.2.0
github.com/blang/semver v3.5.1+incompatible
github.com/ebitengine/oto/v3 v3.4.0
github.com/fatih/color v1.17.0
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf
github.com/ltaoo/echo v0.5.2
github.com/pterm/pterm v0.12.82
github.com/qtgolang/SunnyNet v1.4.0
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/rs/zerolog v1.34.0
github.com/samber/lo v1.52.0
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.21.0
github.com/zeebo/blake3 v0.2.4
golang.org/x/text v0.32.0
)
replace github.com/GopeedLab/gopeed => ./pkg/gopeed
require (
atomicgo.dev/cursor v0.2.0 // indirect
atomicgo.dev/keyboard v0.2.9 // indirect
atomicgo.dev/schedule v0.1.0 // indirect
github.com/Trisia/gosysproxy v1.1.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/console v1.0.5 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
@@ -34,24 +44,31 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/protobuf v1.5.0 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gopacket v1.1.19 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/matoous/go-nanoid/v2 v2.0.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-ieproxy v0.0.12 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
@@ -61,6 +78,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
@@ -69,7 +87,9 @@ require (
github.com/tklauser/numcpus v0.8.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/ulikunitz/xz v0.5.9 // indirect
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.3.11 // indirect
go.uber.org/mock v0.6.0 // indirect
@@ -78,7 +98,10 @@ require (
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
google.golang.org/appengine v1.3.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)
+154 -2
View File
@@ -1,15 +1,38 @@
atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg=
atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ=
atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw=
atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU=
atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k=
github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI=
github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c=
github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE=
github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4=
github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY=
github.com/Trisia/gosysproxy v1.1.0 h1:rBU1mazMXLsZiiaAHXtRoDPt2gzWdA6uxjaLskzaoAA=
github.com/Trisia/gosysproxy v1.1.0/go.mod h1:PTPPgpRpyTJEL/FMxEE2OcQcGNbUm961xZcgihwraZM=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -23,6 +46,7 @@ github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
@@ -43,32 +67,58 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/ltaoo/echo v0.5.2 h1:hYawSpAhyfapfJb7Z8O/7BsOH2eGG26O5oHrf8w/Mdc=
github.com/ltaoo/echo v0.5.2/go.mod h1:H+CK1PmdwBrOAB1ckMwL7RPd3KXgTNrKIPsxokuuvEw=
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
@@ -82,23 +132,44 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI=
github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg=
github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE=
github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU=
github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE=
github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8=
github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
github.com/pterm/pterm v0.12.82 h1:+D9wYhCaeaK0FIQoZtqbNQuNpe2lB2tajKKsTd5paVQ=
github.com/pterm/pterm v0.12.82/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw=
github.com/qtgolang/SunnyNet v1.4.0 h1:w4XHWo8n8UzPs7O2FvZhONS8VBW4eJW6LWMN4j7QcXs=
github.com/qtgolang/SunnyNet v1.4.0/go.mod h1:0BWp1v66qOwnWiiDIpgFk7BqySzln/mDVEAfqSCP2hE=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -107,6 +178,10 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
@@ -128,7 +203,9 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -136,6 +213,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -153,12 +232,24 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad h1:QtuWk6dsrNXc/ugwCBEN0jG52q/4tXRzMvZm4fH6T9g=
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad/go.mod h1:Jj8p9bgKGTPQ+M8CdUMS9p7Mmdoxa3OAcAjJQBu0CcI=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -169,39 +260,100 @@ golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+16
View File
@@ -0,0 +1,16 @@
package api
import (
_ "embed"
)
//go:embed ui/index.html
var html_home []byte
type Assets struct {
HTMLHome []byte
}
// var files = &Assets{
// HTMLHome: html_home,
// }
+57 -87
View File
@@ -5,94 +5,62 @@ import (
"net/http"
"os"
"strconv"
"sync"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
downloadpkg "github.com/GopeedLab/gopeed/pkg/download"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"wx_channel/internal/assets"
"wx_channel/internal/interceptor"
"wx_channel/pkg/cache"
"wx_channel/internal/channels"
"wx_channel/internal/officialaccount"
"wx_channel/pkg/decrypt"
"wx_channel/pkg/system"
"wx_channel/pkg/util"
)
type Client struct {
hub *APIClient
conn *websocket.Conn
send chan []byte
type APIClient struct {
downloader *downloadpkg.Downloader
official *officialaccount.OfficialAccountClient
channels *channels.ChannelsClient
formatter *util.FilenameProcessor
cfg *APIConfig
engine *gin.Engine
logger *zerolog.Logger
}
func (c *Client) writePump() {
ticker := time.NewTicker(5 * time.Second)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
func NewAPIClient(cfg *APIConfig, logger *zerolog.Logger) *APIClient {
data_dir := cfg.RootDir
var downloader *downloadpkg.Downloader
var channels_client *channels.ChannelsClient
official_cfg := officialaccount.NewOfficialAccountConfig(cfg.Original, cfg.OfficialAccountRemote)
officialaccount_client := officialaccount.NewOfficialAccountClient(official_cfg, logger)
if !cfg.OfficialAccountRemote {
downloader = downloadpkg.NewDownloader(&downloadpkg.DownloaderConfig{
RefreshInterval: 360,
Storage: downloadpkg.NewBoltStorage(data_dir),
StorageDir: data_dir,
})
channels_client = channels.NewChannelsClient(cfg.Addr)
channels_client.OnConnected = func(client *channels.Client) {
// Initial tasks
tasks := downloader.GetTasks()
if data, err := json.Marshal(APIClientWSMessage{Type: "tasks", Data: tasks}); err == nil {
client.Send <- data
}
}
}
}
type APIClient struct {
decryptor *ChannelsVideoDecryptor
downloader *downloadpkg.Downloader
Interceptor *interceptor.Interceptor
formatter *util.FilenameProcessor
cfg *APIConfig
ws_clients map[*Client]bool
ws_mu sync.RWMutex
engine *gin.Engine
requests map[string]chan ClientWebsocketResponse
requests_mu sync.RWMutex
cache *cache.Cache
req_seq uint64
}
func NewAPIClient(cfg *APIConfig) *APIClient {
data_dir := cfg.RootDir
downloader := downloadpkg.NewDownloader(&downloadpkg.DownloaderConfig{
RefreshInterval: 360,
Storage: downloadpkg.NewBoltStorage(data_dir),
StorageDir: data_dir,
})
client := &APIClient{
decryptor: NewChannelsVideoDecryptor(),
downloader: downloader,
official: officialaccount_client,
channels: channels_client,
formatter: util.NewFilenameProcessor(cfg.DownloadDir),
cfg: cfg,
ws_clients: make(map[*Client]bool),
requests: make(map[string]chan ClientWebsocketResponse),
engine: gin.Default(),
cache: cache.New(),
req_seq: uint64(time.Now().UnixNano()),
logger: logger,
}
client.setupRoutes()
client.SetupRoutes()
return client
}
@@ -118,6 +86,9 @@ type ClientWebsocketResponse struct {
}
func (c *APIClient) Start() error {
if c.cfg.OfficialAccountRemote {
return nil
}
if err := c.downloader.Setup(); err != nil {
return err
}
@@ -132,17 +103,18 @@ func (c *APIClient) Start() error {
Extra: map[string]any{},
Proxy: &base.DownloaderProxyConfig{},
})
c.downloader.Listener(func(evt *downloadpkg.Event) {
if evt == nil || evt.Task == nil || evt.Task.ID == "" {
return
}
c.broadcast(APIClientWSMessage{
c.channels.Broadcast(APIClientWSMessage{
Type: "event",
Data: evt,
})
if evt.Key == downloadpkg.EventKeyDone {
go assets.PlayDoneAudio()
if c.cfg.PlayDoneAudio {
go assets.PlayDoneAudio()
}
task := c.downloader.GetTask(evt.Task.ID)
file_path := task.Meta.SingleFilepath()
go func() {
@@ -179,12 +151,12 @@ func (c *APIClient) Start() error {
}
func (c *APIClient) Stop() error {
c.ws_mu.Lock()
for client := range c.ws_clients {
close(client.send)
delete(c.ws_clients, client)
if c.downloader != nil {
c.downloader.Pause(nil)
}
if c.channels != nil {
c.channels.Stop()
}
c.ws_mu.Unlock()
return nil
}
@@ -192,7 +164,7 @@ func (c *APIClient) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.engine.ServeHTTP(w, r)
}
func (c *APIClient) resolveConnections(url string) int {
func (c *APIClient) resolve_connections(url string) int {
client := &http.Client{
Timeout: 5 * time.Second,
}
@@ -208,19 +180,17 @@ func (c *APIClient) resolveConnections(url string) int {
return 4
}
func (c *APIClient) broadcast(v interface{}) {
data, err := json.Marshal(v)
if err != nil {
return
}
c.ws_mu.Lock()
defer c.ws_mu.Unlock()
for client := range c.ws_clients {
select {
case client.send <- data:
default:
close(client.send)
delete(c.ws_clients, client)
func (c *APIClient) check_existing_feed(tasks []*downloadpkg.Task, body *FeedDownloadTaskBody) bool {
for _, t := range tasks {
if t == nil || t.Meta == nil || t.Meta.Req == nil || t.Meta.Req.Labels == nil {
continue
}
same_id := t.Meta.Req.Labels["id"] == body.Id
same_spec := t.Meta.Req.Labels["spec"] == body.Spec
same_suffix := t.Meta.Req.Labels["suffix"] == body.Suffix
if same_id && same_spec && same_suffix {
return true
}
}
return false
}
+23 -22
View File
@@ -14,39 +14,40 @@ import (
)
type APIConfig struct {
RootDir string
DownloadDir string
MaxRunning int // 最多同时下载的任务数
Addr string
Original *config.Config
RootDir string
DownloadDir string
PlayDoneAudio bool
MaxRunning int // 最多同时下载的任务数
Addr string
OfficialAccountRemote bool // 是否为公众号远端服务模式
OfficialAccountRefreshToken string
OfficialAccountTokenFilepath string
}
func SetupConfig(cfg *config.Config) {
config.Register(config.ConfigItem{
Key: "download.dir",
Type: config.ConfigTypeString,
Default: "%UserDownloads%",
Description: "下载目录",
})
}
func NewAPISettings(c *config.Config) *APIConfig {
func NewAPIConfig(c *config.Config, mp_remote_mode bool) *APIConfig {
dir := viper.GetString("download.dir")
dir = strings.ReplaceAll(dir, "%UserDownloads%", xdg.UserDirs.Download)
dir = strings.ReplaceAll(dir, "%CWD%", c.RootDir)
dir = filepath.Clean(dir)
if !filepath.IsAbs(dir) {
dir = filepath.Join(c.RootDir, dir)
}
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Printf("Warning: Failed to create download directory: %s, error: %v\n", dir, err)
}
api_settings := &APIConfig{
RootDir: c.RootDir,
DownloadDir: dir,
MaxRunning: 3,
Addr: viper.GetString("api.hostname") + ":" + strconv.Itoa(viper.GetInt("api.port")),
mp_refresh_token := viper.GetString("mp.refreshToken")
mp_token_filepath := viper.GetString("mp.tokenFilepath")
api_cfg := &APIConfig{
Original: c,
RootDir: c.RootDir,
DownloadDir: dir,
PlayDoneAudio: viper.GetBool("download.playDoneAudio"),
MaxRunning: 3,
Addr: viper.GetString("api.hostname") + ":" + strconv.Itoa(viper.GetInt("api.port")),
OfficialAccountRemote: mp_remote_mode,
OfficialAccountTokenFilepath: mp_token_filepath,
OfficialAccountRefreshToken: mp_refresh_token,
}
return api_settings
return api_cfg
}
+277 -171
View File
@@ -18,35 +18,32 @@ import (
gopeedstream "github.com/GopeedLab/gopeed/pkg/protocol/stream"
"github.com/gin-gonic/gin"
"wx_channel/internal/api/types"
"wx_channel/internal/channels"
"wx_channel/internal/interceptor"
result "wx_channel/internal/util"
"wx_channel/pkg/system"
"wx_channel/pkg/util"
)
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
func (c *APIClient) handleSearchChannelsContact(ctx *gin.Context) {
keyword := ctx.Query("keyword")
resp, err := c.SearchChannelsContact(keyword)
resp, err := c.channels.SearchChannelsContact(keyword)
if err != nil {
c.jsonError(ctx, 400, err.Error())
result.Err(ctx, 400, err.Error())
return
}
c.jsonSuccess(ctx, resp)
result.Ok(ctx, resp)
}
func (c *APIClient) handleFetchFeedListOfContact(ctx *gin.Context) {
username := ctx.Query("username")
nextMarker := ctx.Query("next_marker")
resp, err := c.FetchChannelsFeedListOfContact(username, nextMarker)
next_marker := ctx.Query("next_marker")
resp, err := c.channels.FetchChannelsFeedListOfContact(username, next_marker)
if err != nil {
c.jsonError(ctx, 400, err.Error())
result.Err(ctx, 400, err.Error())
return
}
c.jsonSuccess(ctx, resp)
result.Ok(ctx, resp)
}
type AtomAuthor struct {
@@ -85,13 +82,12 @@ type AtomFeed struct {
func (c *APIClient) handleFetchFeedListOfContactRSS(ctx *gin.Context) {
username := ctx.Query("username")
nextMarker := ctx.Query("next_marker")
resp, err := c.FetchChannelsFeedListOfContact(username, nextMarker)
next_marker := ctx.Query("next_marker")
resp, err := c.channels.FetchChannelsFeedListOfContact(username, next_marker)
if err != nil {
c.jsonError(ctx, 400, err.Error())
result.Err(ctx, 400, err.Error())
return
}
entries := make([]AtomEntry, 0, len(resp.Data.Object))
for _, obj := range resp.Data.Object {
var mediaURL, coverURL string
@@ -168,12 +164,12 @@ func (c *APIClient) handleFetchFeedProfile(ctx *gin.Context) {
oid := ctx.Query("oid")
uid := ctx.Query("nid")
url := ctx.Query("url")
resp, err := c.FetchChannelsFeedProfile(oid, uid, url)
resp, err := c.channels.FetchChannelsFeedProfile(oid, uid, url)
if err != nil {
c.jsonError(ctx, 400, err.Error())
result.Err(ctx, 400, err.Error())
return
}
c.jsonSuccess(ctx, resp)
result.Ok(ctx, resp)
}
type FeedDownloadTaskBody struct {
@@ -189,32 +185,33 @@ type FeedDownloadTaskBody struct {
func (c *APIClient) handleCreateTask(ctx *gin.Context) {
var body FeedDownloadTaskBody
if err := ctx.ShouldBindJSON(&body); err != nil {
c.jsonError(ctx, 400, "不合法的参数")
result.Err(ctx, 400, "不合法的参数")
return
}
if body.Id == "" {
c.jsonError(ctx, 400, "缺少 feed id 参数")
result.Err(ctx, 400, "缺少 feed id 参数")
return
}
if body.Suffix == ".mp3" {
has_ffmpeg := system.ExistingCommand("ffmpeg")
if !has_ffmpeg {
c.jsonError(ctx, 400, "下载 mp3 需要支持 ffmpeg 命令")
result.Err(ctx, 3001, "下载 mp3 需要支持 ffmpeg 命令")
return
}
}
tasks := c.downloader.GetTasks()
existing := c.check_existing_feed(tasks, &body)
if existing {
ctx.JSON(http.StatusOK, Response{Code: 409, Msg: "已存在该下载内容", Data: body})
result.Err(ctx, 409, "已存在该下载内容")
// ctx.JSON(http.StatusOK, Response{Code: 409, Msg: , Data: body})
return
}
filename, dir, err := c.formatter.ProcessFilename(body.Filename)
if err != nil {
c.jsonError(ctx, 409, "不合法的文件名")
result.Err(ctx, 409, "不合法的文件名")
return
}
connections := c.resolveConnections(body.URL)
connections := c.resolve_connections(body.URL)
id, err := c.downloader.CreateDirect(
&base.Request{
URL: body.URL,
@@ -235,14 +232,14 @@ func (c *APIClient) handleCreateTask(ctx *gin.Context) {
},
)
if err != nil {
c.jsonError(ctx, 500, "下载失败")
result.Err(ctx, 500, "下载失败")
return
}
c.broadcast(APIClientWSMessage{
c.channels.Broadcast(APIClientWSMessage{
Type: "tasks",
Data: c.downloader.GetTasks(),
})
c.jsonSuccess(ctx, gin.H{"id": id})
result.Ok(ctx, gin.H{"id": id})
}
type LiveDownloadTaskBody struct {
@@ -255,14 +252,14 @@ type LiveDownloadTaskBody struct {
func (c *APIClient) handleCreateLiveTask(ctx *gin.Context) {
var body LiveDownloadTaskBody
if err := ctx.ShouldBindJSON(&body); err != nil {
c.jsonError(ctx, 400, "不合法的参数")
result.Err(ctx, 400, "不合法的参数")
return
}
if body.Url == "" {
c.jsonError(ctx, 400, "缺少 url 参数")
result.Err(ctx, 400, "缺少 url 参数")
return
}
name := body.Name
if name == "" {
// Try to parse from URL or use timestamp
@@ -277,7 +274,7 @@ func (c *APIClient) handleCreateLiveTask(ctx *gin.Context) {
if !strings.HasSuffix(name, ".mp4") && !strings.HasSuffix(name, ".ts") && !strings.HasSuffix(name, ".flv") && !strings.HasSuffix(name, ".mkv") {
name += ".mp4"
}
reqExtra := &gopeedstream.ReqExtra{
Header: make(map[string]string),
}
@@ -287,10 +284,10 @@ func (c *APIClient) handleCreateLiveTask(ctx *gin.Context) {
for k, v := range body.Headers {
reqExtra.Header[k] = v
}
id, err := c.downloader.CreateDirect(
&base.Request{
URL: body.Url,
URL: body.Url,
Extra: reqExtra,
Labels: map[string]string{
"type": "live",
@@ -302,57 +299,68 @@ func (c *APIClient) handleCreateLiveTask(ctx *gin.Context) {
},
)
if err != nil {
c.jsonError(ctx, 500, "创建任务失败: "+err.Error())
result.Err(ctx, 500, "创建任务失败: "+err.Error())
return
}
c.broadcast(APIClientWSMessage{
c.channels.Broadcast(APIClientWSMessage{
Type: "tasks",
Data: c.downloader.GetTasks(),
})
c.jsonSuccess(ctx, gin.H{"id": id})
result.Ok(ctx, gin.H{"id": id})
}
// 批量创建下载任务
func (c *APIClient) handleBatchCreateTask(ctx *gin.Context) {
var body struct {
Feeds []FeedDownloadTaskBody `json:"feeds"`
}
if err := ctx.ShouldBindJSON(&body); err != nil {
c.jsonError(ctx, 400, "不合法的参数")
result.Err(ctx, 400, "不合法的参数")
return
}
tasks := c.downloader.GetTasks()
existing_task_map := make(map[string]struct{})
for _, t := range tasks {
if t == nil || t.Meta == nil || t.Meta.Req == nil || t.Meta.Req.Labels == nil {
continue
}
key := fmt.Sprintf("%s|%s|%s", t.Meta.Req.Labels["id"], t.Meta.Req.Labels["spec"], t.Meta.Req.Labels["suffix"])
existing_task_map[key] = struct{}{}
}
var items []map[string]string
for _, req := range body.Feeds {
if c.check_existing_feed(tasks, &req) {
key := fmt.Sprintf("%s|%s|%s", req.Id, req.Spec, req.Suffix)
if _, exists := existing_task_map[key]; exists {
continue
}
items = append(items, map[string]string{
"name": req.Filename,
"id": req.Id,
"url": req.URL,
"title": req.Title,
"key": strconv.Itoa(req.Key),
"spec": req.Spec,
"suffix": req.Suffix,
"url": req.URL,
"name": req.Filename,
})
}
if len(items) == 0 {
c.jsonSuccess(ctx, gin.H{"ids": []string{}})
result.Ok(ctx, gin.H{"ids": []string{}})
return
}
processed_reqs, err := util.ProcessFilenames(items, c.cfg.DownloadDir)
if err != nil {
c.jsonError(ctx, 500, "文件名处理失败: "+err.Error())
result.Err(ctx, 500, "文件名处理失败: "+err.Error())
return
}
task := base.CreateTaskBatch{}
for _, item := range processed_reqs {
url := item["url"]
fullPath := item["full_path"]
full_path := item["full_path"]
// 从 full_path 中提取目录
relDir := filepath.Dir(fullPath)
rel_dir := filepath.Dir(full_path)
connections := c.resolveConnections(url)
connections := c.resolve_connections(url)
task.Reqs = append(task.Reqs, &base.CreateTaskBatchItem{
Req: &base.Request{
@@ -361,29 +369,172 @@ func (c *APIClient) handleBatchCreateTask(ctx *gin.Context) {
"id": item["id"],
"title": item["title"],
"key": item["key"],
"spec": item["spec"],
"suffix": item["suffix"],
},
},
Opts: &base.Options{
Name: item["name"] + item["suffix"],
Path: filepath.Join(c.cfg.DownloadDir, relDir),
Path: filepath.Join(c.cfg.DownloadDir, rel_dir),
Extra: &gopeedhttp.OptsExtra{
Connections: connections,
},
},
})
}
ids, err := c.downloader.CreateDirectBatch(&task)
if err != nil {
c.jsonError(ctx, 500, "创建失败")
result.Err(ctx, 500, "创建失败")
return
}
c.broadcast(APIClientWSMessage{
c.channels.Broadcast(APIClientWSMessage{
Type: "tasks",
Data: c.downloader.GetTasks(),
})
c.jsonSuccess(ctx, gin.H{"ids": ids})
result.Ok(ctx, gin.H{"ids": ids})
}
func (c *APIClient) handleCreateChannelsTask(ctx *gin.Context) {
var body struct {
Oid string `json:"oid"`
Nid string `json:"Nid"`
URL string `json:"url"`
MP3 bool `json:"mp3"` // 是否下载为 mp3
Cover bool `json:"cover"` // 是否下载封面
}
if err := ctx.ShouldBindJSON(&body); err != nil {
result.Err(ctx, 400, "不合法的参数")
return
}
if body.Oid == "" && body.Nid == "" && body.URL == "" {
result.Err(ctx, 400, "缺少参数")
return
}
r, err := c.channels.FetchChannelsFeedProfile(body.Oid, body.Nid, body.URL)
if err != nil {
result.Err(ctx, 500, "获取详情失败: "+err.Error())
return
}
if r.ErrCode != 0 {
result.Err(ctx, 500, "获取详情失败: "+r.ErrMsg)
return
}
if len(r.Data.Object.ObjectDesc.Media) == 0 {
result.Err(ctx, 500, "缺少可下载的视频内容")
return
}
media := r.Data.Object.ObjectDesc.Media[0]
key, err := strconv.Atoi(media.DecodeKey)
if err != nil {
result.Err(ctx, 500, "获取详情失败: "+err.Error())
return
}
spec := "original"
if !c.cfg.Original.GetBool("download.defaultHighest") {
if len(media.Spec) > 0 {
spec = media.Spec[0].FileFormat
}
}
build_filename := func(feed types.ChannelsObject, spec string) string {
default_name := feed.ObjectDesc.Description
if default_name == "" {
if feed.ID != "" {
default_name = feed.ID
} else {
default_name = util.NowSecondsStr()
}
}
template := c.cfg.Original.GetString("download.filenameTemplate")
if template == "" {
return default_name
}
params := map[string]string{
"filename": default_name,
"id": feed.ID,
"title": feed.ObjectDesc.Description,
"spec": spec,
"created_at": strconv.Itoa(feed.CreateTime),
"download_at": util.NowSecondsStr(),
"author": feed.Contact.Nickname,
}
result := template
for k, v := range params {
result = strings.ReplaceAll(result, "{{"+k+"}}", v)
}
return result
}
filename := build_filename(r.Data.Object, spec)
payload := FeedDownloadTaskBody{
Id: r.Data.Object.ID,
Title: r.Data.Object.ObjectDesc.Description,
Key: key,
Spec: spec,
Suffix: ".mp4",
URL: media.URL + media.URLToken,
Filename: filename,
}
if body.MP3 {
payload.Suffix = ".mp3"
}
if body.Cover {
payload.Suffix += ".jpg"
cover_url := media.CoverUrl
payload.URL = cover_url
}
if payload.Id == "" {
result.Err(ctx, 400, "缺少 feed id 参数")
return
}
if payload.Suffix == ".mp3" {
has_ffmpeg := system.ExistingCommand("ffmpeg")
if !has_ffmpeg {
result.Err(ctx, 3001, "下载 mp3 需要支持 ffmpeg 命令")
return
}
}
tasks := c.downloader.GetTasks()
existing := c.check_existing_feed(tasks, &payload)
if existing {
result.Err(ctx, 409, "已存在该下载内容")
// ctx.JSON(http.StatusOK, Response{Code: 409, Msg: , Data: body})
return
}
filename, dir, err := c.formatter.ProcessFilename(payload.Filename)
if err != nil {
result.Err(ctx, 409, "不合法的文件名")
return
}
connections := c.resolve_connections(payload.URL)
id, err := c.downloader.CreateDirect(
&base.Request{
URL: payload.URL,
Labels: map[string]string{
"id": payload.Id,
"title": payload.Title,
"key": strconv.Itoa(payload.Key),
"spec": payload.Spec,
"suffix": payload.Suffix,
},
},
&base.Options{
Name: filename + payload.Suffix,
Path: filepath.Join(c.cfg.DownloadDir, dir),
Extra: &gopeedhttp.OptsExtra{
Connections: connections,
},
},
)
if err != nil {
result.Err(ctx, 500, "下载失败")
return
}
c.channels.Broadcast(APIClientWSMessage{
Type: "tasks",
Data: c.downloader.GetTasks(),
})
result.Ok(ctx, gin.H{"id": id})
}
func (c *APIClient) handleStartTask(ctx *gin.Context) {
@@ -391,17 +542,17 @@ func (c *APIClient) handleStartTask(ctx *gin.Context) {
Id string `json:"id"`
}
if err := ctx.ShouldBindJSON(&body); err != nil {
c.jsonError(ctx, 400, "不合法的参数")
result.Err(ctx, 400, "不合法的参数")
return
}
if body.Id == "" {
c.jsonError(ctx, 400, "缺少 feed id 参数")
result.Err(ctx, 400, "缺少 feed id 参数")
return
}
c.downloader.Continue(&downloadpkg.TaskFilter{
IDs: []string{body.Id},
})
c.jsonSuccess(ctx, gin.H{"id": body.Id})
result.Ok(ctx, gin.H{"id": body.Id})
}
func (c *APIClient) handlePauseTask(ctx *gin.Context) {
@@ -409,17 +560,17 @@ func (c *APIClient) handlePauseTask(ctx *gin.Context) {
Id string `json:"id"`
}
if err := ctx.ShouldBindJSON(&body); err != nil {
c.jsonError(ctx, 400, "不合法的参数")
result.Err(ctx, 400, "不合法的参数")
return
}
if body.Id == "" {
c.jsonError(ctx, 400, "缺少 feed id 参数")
result.Err(ctx, 400, "缺少 feed id 参数")
return
}
c.downloader.Pause(&downloadpkg.TaskFilter{
IDs: []string{body.Id},
})
c.jsonSuccess(ctx, gin.H{"id": body.Id})
result.Ok(ctx, gin.H{"id": body.Id})
}
func (c *APIClient) handleResumeTask(ctx *gin.Context) {
@@ -427,17 +578,17 @@ func (c *APIClient) handleResumeTask(ctx *gin.Context) {
Id string `json:"id"`
}
if err := ctx.ShouldBindJSON(&body); err != nil {
c.jsonError(ctx, 400, "不合法的参数")
result.Err(ctx, 400, "不合法的参数")
return
}
if body.Id == "" {
c.jsonError(ctx, 400, "缺少 feed id 参数")
result.Err(ctx, 400, "缺少 feed id 参数")
return
}
c.downloader.Continue(&downloadpkg.TaskFilter{
IDs: []string{body.Id},
})
c.jsonSuccess(ctx, gin.H{"id": body.Id})
result.Ok(ctx, gin.H{"id": body.Id})
}
func (c *APIClient) handleDeleteTask(ctx *gin.Context) {
@@ -445,38 +596,38 @@ func (c *APIClient) handleDeleteTask(ctx *gin.Context) {
Id string `json:"id"`
}
if err := ctx.ShouldBindJSON(&body); err != nil {
c.jsonError(ctx, 400, "不合法的参数")
result.Err(ctx, 400, "不合法的参数")
return
}
if body.Id == "" {
c.jsonError(ctx, 400, "缺少 feed id 参数")
result.Err(ctx, 400, "缺少 feed id 参数")
return
}
task := c.downloader.GetTask(body.Id)
c.downloader.Delete(&downloadpkg.TaskFilter{
IDs: []string{body.Id},
}, true)
c.broadcast(APIClientWSMessage{
c.channels.Broadcast(APIClientWSMessage{
Type: "event",
Data: map[string]any{
"Type": "delete",
"Task": task,
},
})
c.jsonSuccess(ctx, gin.H{"id": body.Id})
result.Ok(ctx, gin.H{"id": body.Id})
}
func (c *APIClient) handleClearTasks(ctx *gin.Context) {
c.downloader.Delete(nil, true)
c.broadcast(APIClientWSMessage{
c.channels.Broadcast(APIClientWSMessage{
Type: "clear",
Data: c.downloader.GetTasks(),
})
c.jsonSuccess(ctx, nil)
result.Ok(ctx, nil)
}
func (c *APIClient) handleIndex(ctx *gin.Context) {
readAsset := func(path string, defaultData []byte) string {
read_asset := func(path string, defaultData []byte) string {
fullPath := filepath.Join("internal", "interceptor", path)
data, err := os.ReadFile(fullPath)
if err == nil {
@@ -484,116 +635,63 @@ func (c *APIClient) handleIndex(ctx *gin.Context) {
}
return string(defaultData)
}
html := readAsset("inject/index.html", interceptor.Assets.IndexHTML)
// html := read_asset("inject/index.html", files.HTMLHome)
files := interceptor.Assets
css := readAsset("inject/lib/weui.min.css", files.CSSWeui)
html = strings.Replace(html, "/* INJECT_CSS */", css, 1)
// css := read_asset("inject/lib/weui.min.css", files.CSSWeui)
// html = strings.Replace(html, "/* INJECT_CSS */", css, 1)
var inserted_scripts string
cfg_byte, _ := json.Marshal(c.cfg)
inserted_scripts += fmt.Sprintf(`<script>var __wx_channels_config__ = %s; var __wx_channels_version__ = "local";</script>`, string(cfg_byte))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, read_asset("inject/lib/mitt.umd.js", files.JSMitt))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, read_asset("inject/src/eventbus.js", files.JSEventBus))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, read_asset("inject/src/utils.js", files.JSUtils))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, read_asset("inject/lib/floating-ui.core.1.7.4.min.js", files.JSFloatingUICore))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, read_asset("inject/lib/floating-ui.dom.1.7.4.min.js", files.JSFloatingUIDOM))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, read_asset("inject/lib/weui.min.js", files.JSWeui))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, read_asset("inject/lib/wui.umd.js", files.JSWui))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, read_asset("inject/src/components.js", files.JSComponents))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, read_asset("inject/src/downloader.js", files.JSDownloader))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, readAsset("inject/lib/mitt.umd.js", files.JSMitt))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, readAsset("inject/src/eventbus.js", files.JSEventBus))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, readAsset("inject/src/utils.js", files.JSUtils))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, readAsset("inject/lib/floating-ui.core.1.7.4.min.js", files.JSFloatingUICore))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, readAsset("inject/lib/floating-ui.dom.1.7.4.min.js", files.JSFloatingUIDOM))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, readAsset("inject/lib/weui.min.js", files.JSWeui))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, readAsset("inject/lib/wui.umd.js", files.JSWui))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, readAsset("inject/src/components.js", files.JSComponents))
inserted_scripts += fmt.Sprintf(`<script>%s</script>`, readAsset("inject/src/downloader.js", files.JSDownloader))
html = strings.Replace(html, "<!-- INJECT_JS -->", inserted_scripts, 1)
// html = strings.Replace(html, "<!-- INJECT_JS -->", inserted_scripts, 1)
ctx.Header("Content-Type", "text/html; charset=utf-8")
ctx.String(http.StatusOK, html)
ctx.String(http.StatusOK, "<html><body><div id=\"app\"></div></body></html>")
}
func (c *APIClient) handlePlay(ctx *gin.Context) {
target_url := ctx.Query("url")
if target_url == "" {
result.Err(ctx, 400, "missing targetURL")
return
}
if !strings.HasPrefix(target_url, "http") {
target_url = "https://" + target_url
}
if _, err := url.Parse(target_url); err != nil {
result.Err(ctx, 400, "Invalid URL")
return
}
decrypt_key_str := ctx.Query("key")
decryptor := channels.NewChannelsVideoDecryptor()
if decrypt_key_str != "" {
decryptKey, err := strconv.ParseUint(decrypt_key_str, 0, 64)
if err != nil {
result.Err(ctx, 400, "invalid decryptKey")
return
}
decryptor.DecryptOnlyInline(ctx.Writer, ctx.Request, target_url, decryptKey, 131072)
return
}
decryptor.SimpleProxy(target_url, ctx.Writer, ctx.Request)
}
func (c *APIClient) handleOpenDownloadDir(ctx *gin.Context) {
dir := c.cfg.DownloadDir
if err := system.Open(dir); err != nil {
c.jsonError(ctx, 500, err.Error())
result.Err(ctx, 500, err.Error())
return
}
c.jsonSuccess(ctx, nil)
}
func (c *APIClient) handleTest(ctx *gin.Context) {
dir := c.cfg.DownloadDir
if err := system.Open(dir); err != nil {
c.jsonError(ctx, 500, err.Error())
return
}
c.jsonSuccess(ctx, nil)
}
// func (c *APIClient) handleDownload(ctx *gin.Context) {
// targetURL := ctx.Query("url")
// if targetURL == "" {
// c.jsonError(ctx, 400, "missing targetURL")
// return
// }
// if !strings.HasPrefix(targetURL, "http") {
// targetURL = "https://" + targetURL
// }
// if _, err := url.Parse(targetURL); err != nil {
// c.jsonError(ctx, 400, "Invalid URL")
// return
// }
// filename := ctx.Query("filename")
// if filename == "" {
// if u, err := url.Parse(targetURL); err == nil {
// if base := path.Base(u.Path); base != "" && base != "/" {
// filename = base
// }
// }
// if filename == "" {
// filename = "download.mp4"
// }
// }
// decryptKeyStr := ctx.Query("key")
// toMP3 := ctx.Query("mp3")
// mp := NewChannelsVideoDecryptor()
// if decryptKeyStr != "" {
// decryptKey, err := strconv.ParseUint(decryptKeyStr, 0, 64)
// if err != nil {
// c.jsonError(ctx, 400, "invalid decryptKey")
// return
// }
// if toMP3 == "1" {
// mp.convertWithDecrypt(ctx.Writer, targetURL, decryptKey, 131072, filename)
// return
// }
// mp.decryptOnly(ctx.Writer, ctx.Request, targetURL, decryptKey, 131072, filename)
// return
// }
// mp.convertOnly(targetURL, ctx.Writer, filename, "mp3")
// c.jsonSuccess(ctx, nil)
// }
func (c *APIClient) handlePlay(ctx *gin.Context) {
targetURL := ctx.Query("url")
if targetURL == "" {
c.jsonError(ctx, 400, "missing targetURL")
return
}
if !strings.HasPrefix(targetURL, "http") {
targetURL = "https://" + targetURL
}
if _, err := url.Parse(targetURL); err != nil {
c.jsonError(ctx, 400, "Invalid URL")
return
}
decryptKeyStr := ctx.Query("key")
mp := NewChannelsVideoDecryptor()
if decryptKeyStr != "" {
decryptKey, err := strconv.ParseUint(decryptKeyStr, 0, 64)
if err != nil {
c.jsonError(ctx, 400, "invalid decryptKey")
return
}
mp.decryptOnlyInline(ctx.Writer, ctx.Request, targetURL, decryptKey, 131072)
return
}
mp.simpleProxy(targetURL, ctx.Writer, ctx.Request)
result.Ok(ctx, nil)
}
type OpenFolderAndHighlightFileBody struct {
@@ -605,23 +703,31 @@ type OpenFolderAndHighlightFileBody struct {
func (c *APIClient) handleHighlightFileInFolder(ctx *gin.Context) {
var body OpenFolderAndHighlightFileBody
if err := ctx.ShouldBindJSON(&body); err != nil {
c.jsonError(ctx, 400, err.Error())
result.Err(ctx, 400, err.Error())
return
}
if body.Path == "" || body.Name == "" {
c.jsonError(ctx, 400, "Missing the `path` or `name`")
result.Err(ctx, 400, "Missing the `path` or `name`")
return
}
full_filepath := filepath.Join(body.Path, body.Name)
_, err := os.Stat(full_filepath)
if err != nil {
c.jsonError(ctx, 500, err.Error())
result.Err(ctx, 500, "找不到文件")
return
}
if err := system.ShowInExplorer(full_filepath); err != nil {
c.jsonError(ctx, 500, err.Error())
result.Err(ctx, 500, err.Error())
return
}
c.jsonSuccess(ctx, nil)
return
result.Ok(ctx, nil)
}
func (c *APIClient) handleTest(ctx *gin.Context) {
dir := c.cfg.DownloadDir
if err := system.Open(dir); err != nil {
result.Err(ctx, 500, err.Error())
return
}
result.Ok(ctx, nil)
}
-101
View File
@@ -1,101 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var official_ws_upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func (c *APIClient) handleFetchOfficialAccountMsgList(ctx *gin.Context) {
var body struct {
Biz string `json:"biz"`
Uin string `json:"uin"`
}
if err := ctx.ShouldBindJSON(&body); err != nil {
c.jsonError(ctx, 400, err.Error())
return
}
resp, err := c.RequestAPI("/api/official_account/fetch_msg_list", body, 15*time.Second)
if err != nil {
c.jsonError(ctx, 500, err.Error())
return
}
var data interface{}
if err := json.Unmarshal(resp.Data, &data); err != nil {
c.jsonError(ctx, 500, "Invalid response data")
return
}
c.jsonSuccess(ctx, data)
}
func (c *APIClient) handleFetchOfficialAccountHome(ctx *gin.Context) {
var body struct {
Biz string `json:"biz"`
Uin string `json:"uin"`
}
if err := ctx.ShouldBindJSON(&body); err != nil {
c.jsonError(ctx, 400, err.Error())
return
}
resp, err := c.RequestAPI("/api/official_account/fetch_account_home", body, 15*time.Second)
if err != nil {
c.jsonError(ctx, 500, err.Error())
return
}
var data interface{}
if err := json.Unmarshal(resp.Data, &data); err != nil {
c.jsonError(ctx, 500, "Invalid response data")
return
}
c.jsonSuccess(ctx, data)
}
func (c *APIClient) handleOfficialAccountWebsocket(ctx *gin.Context) {
conn, err := official_ws_upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
return
}
c.ws_mu.Lock()
client := &Client{hub: c, conn: conn, send: make(chan []byte, 256)}
c.ws_clients[client] = true
c.ws_mu.Unlock()
go client.writePump()
defer func() {
c.ws_mu.Lock()
if _, ok := c.ws_clients[client]; ok {
delete(c.ws_clients, client)
close(client.send)
}
c.ws_mu.Unlock()
conn.Close()
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
return
}
// 前端「响应」给 ws api 请求的响应值
var resp ClientWebsocketResponse
if err := json.Unmarshal(message, &resp); err == nil && resp.Id != "" {
c.requests_mu.RLock()
ch, ok := c.requests[resp.Id]
c.requests_mu.RUnlock()
if ok {
ch <- resp
}
}
}
}
-38
View File
@@ -1,38 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"`
}
func (c *APIClient) jsonSuccess(ctx *gin.Context, data interface{}) {
ctx.JSON(http.StatusOK, Response{
Code: 0,
Msg: "success",
Data: data,
})
}
func (c *APIClient) jsonError(ctx *gin.Context, code int, msg string) {
ctx.JSON(http.StatusOK, Response{
Code: code,
Msg: msg,
})
}
func writeErrorResponse(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(Response{
Code: code,
Msg: msg,
})
}
+46 -24
View File
@@ -1,31 +1,53 @@
package api
import "github.com/gin-gonic/gin"
import (
"net/http"
func (c *APIClient) setupRoutes() {
c.engine.GET("/ws/channels", c.handleChannelsWebsocket)
c.engine.GET("/ws/mp", c.handleOfficialAccountWebsocket)
c.engine.POST("/api/task/create", c.handleCreateTask)
c.engine.POST("/api/task/create_batch", c.handleBatchCreateTask)
c.engine.POST("/api/task/start", c.handleStartTask)
c.engine.POST("/api/task/create_live", c.handleCreateLiveTask)
c.engine.POST("/api/task/pause", c.handlePauseTask)
c.engine.POST("/api/task/resume", c.handleResumeTask)
c.engine.POST("/api/task/delete", c.handleDeleteTask)
c.engine.POST("/api/task/clear", c.handleClearTasks)
c.engine.POST("/api/show_file", c.handleHighlightFileInFolder)
c.engine.POST("/api/open_download_dir", c.handleOpenDownloadDir)
c.engine.GET("/api/channels/contact/search", c.handleSearchChannelsContact)
c.engine.GET("/api/channels/contact/feed/list", c.handleFetchFeedListOfContact)
c.engine.GET("/api/channels/feed/profile", c.handleFetchFeedProfile)
c.engine.POST("/api/official_account/msg/list", c.handleFetchOfficialAccountMsgList)
c.engine.POST("/api/official_account/home", c.handleFetchOfficialAccountHome)
c.engine.GET("/api/test", c.handleTest)
c.engine.GET("/rss/channels/contact/feed", c.handleFetchFeedListOfContactRSS)
c.engine.GET("/play", c.handlePlay)
"github.com/gin-gonic/gin"
)
func (c *APIClient) SetupRoutes() {
// 只在本地有的接口
if !c.cfg.OfficialAccountRemote {
// 下载任务接口
c.engine.POST("/api/task/create", c.handleCreateTask)
c.engine.POST("/api/task/create_batch", c.handleBatchCreateTask)
c.engine.POST("/api/task/start", c.handleStartTask)
c.engine.POST("/api/task/create_channels", c.handleCreateChannelsTask)
// c.engine.POST("/api/task/create_live", c.handleCreateLiveTask)
c.engine.POST("/api/task/pause", c.handlePauseTask)
c.engine.POST("/api/task/resume", c.handleResumeTask)
c.engine.POST("/api/task/delete", c.handleDeleteTask)
c.engine.POST("/api/task/clear", c.handleClearTasks)
c.engine.POST("/api/show_file", c.handleHighlightFileInFolder)
c.engine.POST("/api/open_download_dir", c.handleOpenDownloadDir)
// 视频号接口
c.engine.GET("/ws/channels", c.channels.HandleChannelsWebsocket)
c.engine.GET("/api/channels/contact/search", c.handleSearchChannelsContact)
c.engine.GET("/api/channels/contact/feed/list", c.handleFetchFeedListOfContact)
c.engine.GET("/api/channels/feed/profile", c.handleFetchFeedProfile)
c.engine.GET("/rss/channels", c.handleFetchFeedListOfContactRSS)
c.engine.GET("/play", c.handlePlay)
// 公众号接口 本地服务
c.engine.GET("/ws/mp", c.official.HandleWebsocket)
c.engine.GET("/ws/manage", c.official.HandleManageWebsocket)
c.engine.GET("/api/mp/refresh_remote", c.official.HandleRefreshAllRemoteOfficialAccount)
c.engine.GET("/api/mp/refresh_account_with_frontend", c.official.HandleRefreshOfficialAccountWithFrontend)
c.engine.GET("/api/mp/ws_pool", c.official.HandleFetchOfficialAccountClients)
}
// 公众号接口 远端和本地都有的接口
c.engine.GET("/api/mp/list", c.official.HandleFetchList)
c.engine.GET("/api/mp/msg/list", c.official.HandleFetchMsgList)
c.engine.POST("/api/mp/delete", c.official.HandleDelete)
c.engine.POST("/api/mp/refresh", c.official.HandleRefreshEvent)
c.engine.GET("/rss/mp", c.official.HandleOfficialAccountRSS)
c.engine.GET("/mp/proxy", c.official.HandleOfficialAccountProxy)
c.engine.GET("/mp/home", c.official.HandleOfficialAccountManagerHome)
// 其他
// c.engine.GET("/api/test", c.handleTest)
c.engine.NoRoute(func(ctx *gin.Context) {
c.handleIndex(ctx)
// c.decryptor.ServeHTTP(ctx.Writer, ctx.Request)
ctx.Header("Content-Type", "text/html; charset=utf-8")
ctx.String(http.StatusNotFound, "<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>404 Not Found</title><style>body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#0b0c0f;color:#e6e6e6;display:flex;align-items:center;justify-content:center;height:100vh}.box{max-width:560px;padding:24px 28px;border-radius:12px;background:#14171f;box-shadow:0 8px 24px rgba(0,0,0,.3)}h1{margin:0 0 8px;font-size:24px}p{margin:0;color:#b0b0b0}a{color:#8ab4f8;text-decoration:none}a:hover{text-decoration:underline}</style></head><body><div class=\"box\"><h1>404 未找到页面</h1><p>请求的路径不存在。返回 <a href=\"/\">首页</a></p></div></body></html>")
})
}
+5 -4
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"net"
"github.com/rs/zerolog"
"wx_channel/internal/manager"
)
@@ -12,13 +14,13 @@ type APIServer struct {
APIClient *APIClient
}
func NewAPIServer(cfg *APIConfig) *APIServer {
func NewAPIServer(cfg *APIConfig, logger *zerolog.Logger) *APIServer {
srv := manager.NewHTTPServer("API服务", "api", cfg.Addr)
client := NewAPIClient(cfg)
client := NewAPIClient(cfg, logger)
srv.SetHandler(withCORS(client))
return &APIServer{
APIClient: client,
HTTPServer: srv,
APIClient: client,
}
}
@@ -35,7 +37,6 @@ func (s *APIServer) Start() error {
}
func (s *APIServer) Stop() error {
s.APIClient.downloader.Pause(nil)
if err := s.APIClient.Stop(); err != nil {
return err
}
File renamed without changes.
+1 -1
View File
@@ -1,8 +1,8 @@
//go:build linux
// +build linux
package assets
func PlayDoneAudio() error {
return nil
}
@@ -1,18 +1,19 @@
package api
package channels
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"sync"
"sync/atomic"
"time"
downloadpkg "github.com/GopeedLab/gopeed/pkg/download"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"wx_channel/internal/api/types"
"wx_channel/pkg/cache"
)
var channels_ws_upgrader = websocket.Upgrader{
@@ -23,29 +24,56 @@ var channels_ws_upgrader = websocket.Upgrader{
},
}
func (c *APIClient) handleChannelsWebsocket(ctx *gin.Context) {
type ChannelsClient struct {
// decryptor *ChannelsVideoDecryptor
// downloader *downloadpkg.Downloader
// Interceptor *interceptor.Interceptor
// official *officialaccount.OfficialAccountBrowser
// formatter *util.FilenameProcessor
// Cookies []*http.Cookie
// cfg *APIConfig
ws_clients map[*Client]bool
ws_mu sync.RWMutex
engine *gin.Engine
requests map[string]chan ClientWebsocketResponse
requests_mu sync.RWMutex
cache *cache.Cache
req_seq uint64
OnConnected func(client *Client)
}
func NewChannelsClient(addr string) *ChannelsClient {
return &ChannelsClient{
// ServerAddr: addr,
ws_clients: make(map[*Client]bool),
requests: make(map[string]chan ClientWebsocketResponse),
// engine: gin.Default(),
cache: cache.New(),
req_seq: uint64(time.Now().UnixNano()),
}
}
func (c *ChannelsClient) HandleChannelsWebsocket(ctx *gin.Context) {
conn, err := channels_ws_upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
return
}
c.ws_mu.Lock()
client := &Client{hub: c, conn: conn, send: make(chan []byte, 256)}
client := &Client{Conn: conn, Send: make(chan []byte, 256)}
c.ws_clients[client] = true
c.ws_mu.Unlock()
go client.writePump()
// Initial tasks
tasks := c.downloader.GetTasks()
if data, err := json.Marshal(APIClientWSMessage{Type: "tasks", Data: tasks}); err == nil {
client.send <- data
if c.OnConnected != nil {
c.OnConnected(client)
}
defer func() {
c.ws_mu.Lock()
if _, ok := c.ws_clients[client]; ok {
delete(c.ws_clients, client)
close(client.send)
close(client.Send)
}
c.ws_mu.Unlock()
conn.Close()
@@ -67,8 +95,31 @@ func (c *APIClient) handleChannelsWebsocket(ctx *gin.Context) {
}
}
}
func (wc *APIClient) Validate() error {
func (c *ChannelsClient) Stop() {
c.ws_mu.Lock()
for client := range c.ws_clients {
close(client.Send)
delete(c.ws_clients, client)
}
c.ws_mu.Unlock()
}
func (c *ChannelsClient) Broadcast(v interface{}) {
data, err := json.Marshal(v)
if err != nil {
return
}
c.ws_mu.Lock()
defer c.ws_mu.Unlock()
for client := range c.ws_clients {
select {
case client.Send <- data:
default:
close(client.Send)
delete(c.ws_clients, client)
}
}
}
func (wc *ChannelsClient) Validate() error {
// wc.clientsMu.Lock()
// defer wc.clientsMu.Unlock()
if len(wc.ws_clients) == 0 {
@@ -76,7 +127,7 @@ func (wc *APIClient) Validate() error {
}
return nil
}
func (c *APIClient) RequestAPI(endpoint string, body interface{}, timeout time.Duration) (*ClientWebsocketResponse, error) {
func (c *ChannelsClient) RequestFrontend(endpoint string, body interface{}, timeout time.Duration) (*ClientWebsocketResponse, error) {
if err := c.Validate(); err != nil {
return nil, err
}
@@ -109,7 +160,6 @@ func (c *APIClient) RequestAPI(endpoint string, body interface{}, timeout time.D
c.ws_mu.Unlock()
return nil, errors.New("没有可用的客户端")
}
data, err := json.Marshal(msg)
if err != nil {
c.ws_mu.Unlock()
@@ -117,7 +167,7 @@ func (c *APIClient) RequestAPI(endpoint string, body interface{}, timeout time.D
}
select {
case client.send <- data:
case client.Send <- data:
default:
c.ws_mu.Unlock()
return nil, errors.New("发送缓冲区已满")
@@ -131,14 +181,14 @@ func (c *APIClient) RequestAPI(endpoint string, body interface{}, timeout time.D
}
}
func (c *APIClient) SearchChannelsContact(keyword string) (*types.ChannelsContactSearchResp, error) {
func (c *ChannelsClient) SearchChannelsContact(keyword string) (*types.ChannelsContactSearchResp, error) {
cache_key := "search:" + keyword
if val, found := c.cache.Get(cache_key); found {
if resp, ok := val.(*types.ChannelsContactSearchResp); ok {
return resp, nil
}
}
resp, err := c.RequestAPI("/api/contact/search", types.ChannelsAccountSearchBody{Keyword: keyword}, 20*time.Second)
resp, err := c.RequestFrontend("key:channels:contact_list", types.ChannelsAccountSearchBody{Keyword: keyword}, 20*time.Second)
if err != nil {
return nil, err
}
@@ -150,7 +200,7 @@ func (c *APIClient) SearchChannelsContact(keyword string) (*types.ChannelsContac
return &r, nil
}
func (c *APIClient) FetchChannelsFeedListOfContact(username, next_marker string) (*types.ChannelsFeedListOfAccountResp, error) {
func (c *ChannelsClient) FetchChannelsFeedListOfContact(username, next_marker string) (*types.ChannelsFeedListOfAccountResp, error) {
// fmt.Println("[API]fetch feed list of contact", username)
// cache_key := "feed:" + username
// if val, found := c.cache.Get(cache_key); found {
@@ -158,7 +208,7 @@ func (c *APIClient) FetchChannelsFeedListOfContact(username, next_marker string)
// return resp, nil
// }
// }
resp, err := c.RequestAPI("/api/contact/feed/list", types.ChannelsFeedListBody{Username: username, NextMarker: next_marker}, 10*time.Second)
resp, err := c.RequestFrontend("key:channels:feed_list", types.ChannelsFeedListBody{Username: username, NextMarker: next_marker}, 10*time.Second)
if err != nil {
return nil, err
}
@@ -170,7 +220,7 @@ func (c *APIClient) FetchChannelsFeedListOfContact(username, next_marker string)
return &r, nil
}
func (c *APIClient) FetchChannelsFeedProfile(oid, uid, url string) (*types.ChannelsFeedProfileResp, error) {
func (c *ChannelsClient) FetchChannelsFeedProfile(oid, uid, url string) (*types.ChannelsFeedProfileResp, error) {
// fmt.Println("[API]fetch feed profile", oid, uid)
// cache_key := "feed:" + username
// if val, found := c.cache.Get(cache_key); found {
@@ -178,7 +228,7 @@ func (c *APIClient) FetchChannelsFeedProfile(oid, uid, url string) (*types.Chann
// return resp, nil
// }
// }
resp, err := c.RequestAPI("/api/feed/profile", types.ChannelsFeedProfileBody{ObjectId: oid, NonceId: uid, URL: url}, 10*time.Second)
resp, err := c.RequestFrontend("key:channels:feed_profile", types.ChannelsFeedProfileBody{ObjectId: oid, NonceId: uid, URL: url}, 10*time.Second)
if err != nil {
return nil, err
}
@@ -189,18 +239,3 @@ func (c *APIClient) FetchChannelsFeedProfile(oid, uid, url string) (*types.Chann
// c.cache.Set(cache_key, &r, 5*time.Minute)
return &r, nil
}
func (c *APIClient) check_existing_feed(tasks []*downloadpkg.Task, body *FeedDownloadTaskBody) bool {
for _, t := range tasks {
if t == nil || t.Meta == nil || t.Meta.Req == nil || t.Meta.Req.Labels == nil {
continue
}
same_id := t.Meta.Req.Labels["id"] == body.Id
same_spec := t.Meta.Req.Labels["spec"] == body.Spec
same_suffix := t.Meta.Req.Labels["suffix"] == body.Suffix
if same_id && same_spec && same_suffix {
return true
}
}
return false
}
@@ -1,4 +1,4 @@
package api
package channels
import (
"bufio"
@@ -33,7 +33,7 @@ func NewChannelsVideoDecryptor() *ChannelsVideoDecryptor {
// handleDownload(w, r)
// }
func (mp *ChannelsVideoDecryptor) convertWithDecrypt(w http.ResponseWriter, targetURL string, key uint64, encLimit uint64, filename string) {
func (mp *ChannelsVideoDecryptor) ConvertWithDecrypt(w http.ResponseWriter, targetURL string, key uint64, encLimit uint64, filename string) {
req, err := mp.prepareRequest(http.MethodGet, targetURL, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
@@ -84,7 +84,7 @@ func (mp *ChannelsVideoDecryptor) convertWithDecrypt(w http.ResponseWriter, targ
_ = cmd.Wait()
}
func (mp *ChannelsVideoDecryptor) decryptOnly(w http.ResponseWriter, r *http.Request, targetURL string, key uint64, encLimit uint64, filename string) {
func (mp *ChannelsVideoDecryptor) DecryptOnly(w http.ResponseWriter, r *http.Request, targetURL string, key uint64, encLimit uint64, filename string) {
req, err := mp.prepareRequest(r.Method, targetURL, r.Header)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
@@ -129,7 +129,7 @@ func (mp *ChannelsVideoDecryptor) decryptOnly(w http.ResponseWriter, r *http.Req
}
// Inline playback variants (no attachment headers)
func (mp *ChannelsVideoDecryptor) decryptOnlyInline(w http.ResponseWriter, r *http.Request, targetURL string, key uint64, encLimit uint64) {
func (mp *ChannelsVideoDecryptor) DecryptOnlyInline(w http.ResponseWriter, r *http.Request, targetURL string, key uint64, encLimit uint64) {
req, err := mp.prepareRequest(r.Method, targetURL, r.Header)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
@@ -168,7 +168,7 @@ func (mp *ChannelsVideoDecryptor) decryptOnlyInline(w http.ResponseWriter, r *ht
io.Copy(w, decryptReader)
}
func (mp *ChannelsVideoDecryptor) convertWithDecryptInline(w http.ResponseWriter, targetURL string, key uint64, encLimit uint64) {
func (mp *ChannelsVideoDecryptor) ConvertWithDecryptInline(w http.ResponseWriter, targetURL string, key uint64, encLimit uint64) {
req, err := mp.prepareRequest(http.MethodGet, targetURL, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
@@ -210,9 +210,9 @@ func (mp *ChannelsVideoDecryptor) convertWithDecryptInline(w http.ResponseWriter
_ = cmd.Wait()
}
func (mp *ChannelsVideoDecryptor) convertOnlyInline(targetURL string, w http.ResponseWriter, format string) {
func (mp *ChannelsVideoDecryptor) ConvertOnlyInline(targetURL string, w http.ResponseWriter, format string) {
if format != "mp3" {
mp.simpleProxy(targetURL, w, nil)
mp.SimpleProxy(targetURL, w, nil)
return
}
req, err := mp.prepareRequest(http.MethodGet, targetURL, nil)
@@ -254,9 +254,9 @@ func (mp *ChannelsVideoDecryptor) convertOnlyInline(targetURL string, w http.Res
}
_ = cmd.Wait()
}
func (mp *ChannelsVideoDecryptor) convertOnly(targetURL string, w http.ResponseWriter, filename string, format string) {
func (mp *ChannelsVideoDecryptor) ConvertOnly(targetURL string, w http.ResponseWriter, filename string, format string) {
if format != "mp3" {
mp.simpleProxy(targetURL, w, nil)
mp.SimpleProxy(targetURL, w, nil)
return
}
req, err := mp.prepareRequest(http.MethodGet, targetURL, nil)
@@ -322,7 +322,7 @@ func (mp *ChannelsVideoDecryptor) prepareRequest(method, targetURL string, heade
return req, nil
}
func (mp *ChannelsVideoDecryptor) simpleProxy(targetURL string, w http.ResponseWriter, r *http.Request) {
func (mp *ChannelsVideoDecryptor) SimpleProxy(targetURL string, w http.ResponseWriter, r *http.Request) {
var header http.Header
method := http.MethodGet
if r != nil {
@@ -1,4 +1,4 @@
package api
package channels
import (
"encoding/binary"
+66
View File
@@ -0,0 +1,66 @@
package channels
import (
"encoding/json"
"time"
"github.com/gorilla/websocket"
)
type Client struct {
Conn *websocket.Conn
Send chan []byte
}
func (c *Client) writePump() {
ticker := time.NewTicker(5 * time.Second)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.Send:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.Conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
type APIClientWSMessage struct {
Type string `json:"type"`
Data interface{} `json:"data"`
Error string `json:"error"`
}
type ClientWSMessage struct {
Type string `json:"type"`
Data string `json:"data"`
}
type ClientWebsocketRequestBody struct {
ID string `json:"id"`
Key string `json:"key"`
Body interface{} `json:"data"`
}
type ClientWebsocketResponse struct {
Id string `json:"id"`
// 调用 wx api 原始响应
Data json.RawMessage `json:"data"`
}
+276 -2
View File
@@ -21,7 +21,7 @@ type Config struct {
Version string
}
func New(ver string) (*Config, error) {
func New(ver string, mode string) *Config {
exe, _ := os.Executable()
exe_dir := filepath.Dir(exe)
base_dir := exe_dir
@@ -59,10 +59,284 @@ func New(ver string) (*Config, error) {
Existing: has_config,
Version: ver,
}
return c, nil
return c
}
func (c *Config) LoadConfig() error {
Register(ConfigItem{
Key: "proxy.system",
Type: ConfigTypeBool,
Default: true,
Description: "是否设置系统代理为代理服务",
Title: "设置系统代理",
Group: "Proxy",
})
Register(ConfigItem{
Key: "proxy.hostname",
Type: ConfigTypeString,
Default: "127.0.0.1",
Description: "代理服务的主机名",
Title: "代理主机",
Group: "Proxy",
})
Register(ConfigItem{
Key: "proxy.port",
Type: ConfigTypeInt,
Default: 2080,
Description: "代理服务的端口",
Title: "代理端口",
Group: "Proxy",
})
Register(ConfigItem{
Key: "pagespy.enabled",
Type: ConfigTypeSelect,
Default: false,
Description: "是否开启 PageSpy",
Title: "启用",
Group: "Pagespy",
})
Register(ConfigItem{
Key: "pagespy.protocol",
Type: ConfigTypeSelect,
Default: "https",
Options: []string{"http", "https"},
Description: "PageSpy 调试协议",
Title: "协议头",
Group: "Pagespy",
})
Register(ConfigItem{
Key: "pagespy.api",
Type: ConfigTypeString,
Default: "debug.weixin.qq.com",
Description: "PageSpy 调试 API 地址",
Title: "API 地址",
Group: "Pagespy",
})
Register(ConfigItem{
Key: "debug.error",
Type: ConfigTypeBool,
Default: true,
Description: "是否全局捕获前端错误,出现错误时弹窗展示错误信息",
Title: "错误展示",
Group: "Debug",
})
Register(ConfigItem{
Key: "channels.disableLocationToHome",
Type: ConfigTypeBool,
Default: false,
Description: "是否禁止从视频号详情页重定向到首页(视频号默认行为)",
Title: "禁止重定向",
Group: "Channels",
})
Register(ConfigItem{
Key: "channel.disableLocationToHome",
Type: ConfigTypeBool,
Default: false,
Description: "是否禁止从视频号详情页重定向到首页(视频号默认行为)",
Title: "禁止重定向",
Group: "Channels",
})
Register(ConfigItem{
Key: "inject.extraScript.afterJSMain",
Type: ConfigTypeString,
Default: "",
Description: "额外注入的 JS 脚本路径",
Title: "注入脚本",
Group: "Inject",
})
Register(ConfigItem{
Key: "inject.globalScript",
Type: ConfigTypeString,
Default: "global.js",
Description: "全局用户脚本",
Title: "全局脚本",
Group: "Inject",
})
Register(ConfigItem{
Key: "download.dir",
Type: ConfigTypeString,
Default: "%UserDownloads%",
Description: "指定下载的目录,当 frontend 为 true 时不生效",
Title: "下载目录",
Group: "Download",
})
Register(ConfigItem{
Key: "download.inFrontend",
Type: ConfigTypeBool,
Default: false,
Description: "是否通过前端解密、下载,不调用后台下载能力",
Title: "前端下载",
Group: "Download",
})
Register(ConfigItem{
Key: "download.defaultHighest",
Type: ConfigTypeBool,
Default: false,
Description: "点击下载图标时是否下载原始视频",
Title: "原始视频",
Group: "Download",
})
Register(ConfigItem{
Key: "download.filenameTemplate",
Type: ConfigTypeString,
Default: "{{filename}}_{{spec}}",
Description: "用于配置下载文件的名称,支持 {{filename}} 和 {{spec}} 等变量",
Title: "文件名模板",
Group: "Download",
})
Register(ConfigItem{
Key: "download.pauseWhenDownload",
Type: ConfigTypeBool,
Default: false,
Description: "点击下载时是否暂停播放",
Title: "暂停播放",
Group: "Download",
})
Register(ConfigItem{
Key: "download.playDoneAudio",
Type: ConfigTypeBool,
Default: true,
Description: "下载完成时是否播放完成音效",
Title: "播放完成音效",
Group: "Download",
})
Register(ConfigItem{
Key: "api.protocol",
Type: ConfigTypeString,
Default: "http",
Description: "指定 API 服务的协议头",
Title: "API 服务协议",
Group: "API",
})
Register(ConfigItem{
Key: "api.hostname",
Type: ConfigTypeString,
Default: "127.0.0.1",
Description: "指定 API 服务的主机名",
Title: "API 服务主机",
Group: "API",
})
Register(ConfigItem{
Key: "api.port",
Type: ConfigTypeInt,
Default: 2022,
Description: "指定 API 服务的端口",
Title: "API 服务端口",
Group: "API",
})
Register(ConfigItem{
Key: "mp.disabled",
Type: ConfigTypeBool,
Default: false,
Description: "是否禁用公众号本地服务,本地服务会提供接口、RSS 等功能",
Title: "启用本地服务",
Group: "OfficialAccount",
})
Register(ConfigItem{
Key: "mp.remoteServer.protocol",
Type: ConfigTypeString,
Default: "http",
Description: "公众号远端服务协议头",
Title: "服务协议头",
Group: "OfficialAccount",
})
Register(ConfigItem{
Key: "mp.remoteServer.hostname",
Type: ConfigTypeString,
Default: "127.0.0.1",
Description: "公众号远端服务主机名",
Title: "服务主机名",
Group: "OfficialAccount",
})
Register(ConfigItem{
Key: "mp.remoteServer.port",
Type: ConfigTypeInt,
Default: 2022,
Description: "公众号远端服务端口",
Title: "服务端口",
Group: "OfficialAccount",
})
Register(ConfigItem{
Key: "mp.refreshToken",
Type: ConfigTypeString,
Default: "",
Description: "公众号远端服务刷新凭证",
Title: "刷新凭证",
Group: "OfficialAccount",
})
Register(ConfigItem{
Key: "mp.tokenFilepath",
Type: ConfigTypeString,
Default: "",
Description: "公众号远端服务授权凭证",
Title: "授权凭证",
Group: "OfficialAccount",
})
Register(ConfigItem{
Key: "mp.refreshSkipMinutes",
Type: ConfigTypeInt,
Default: 20,
Description: "刷新时若账号在最近 N 分钟已更新则跳过",
Title: "刷新跳过时间(分钟)",
Group: "OfficialAccount",
})
Register(ConfigItem{
Key: "cloudflare.accountId",
Type: ConfigTypeString,
Default: "",
Description: "Cloudflare 帐号 ID",
Title: "Account ID",
Group: "Cloudflare",
})
Register(ConfigItem{
Key: "cloudflare.apiToken",
Type: ConfigTypeString,
Default: "",
Description: "Cloudflare Worker 认证 Token",
Title: "API Token",
Group: "Cloudflare",
})
Register(ConfigItem{
Key: "cloudflare.refreshToken",
Type: ConfigTypeString,
Default: "",
Description: "调用 mp-rss 凭证刷新接口所需的 token",
Title: "Refresh Token",
Group: "Cloudflare",
})
Register(ConfigItem{
Key: "cloudflare.adminToken",
Type: ConfigTypeString,
Default: "",
Description: "调用 mp-rss 管理员接口所需的凭证",
Title: "Admin Token",
Group: "Cloudflare",
})
Register(ConfigItem{
Key: "cloudflare.workerName",
Type: ConfigTypeString,
Default: "official-account-api",
Description: "Cloudflare mp-rss Worker 名称",
Title: "Worker Name",
Group: "Cloudflare",
})
Register(ConfigItem{
Key: "cloudflare.d1Id",
Type: ConfigTypeString,
Default: "",
Description: "Cloudflare mp-rss d1数据库 ID",
Title: "D1 Database ID",
Group: "Cloudflare",
})
Register(ConfigItem{
Key: "cloudflare.d1Name",
Type: ConfigTypeString,
Default: "",
Description: "Cloudflare mp-rss d1数据库 Name",
Title: "D1 Database Name",
Group: "Cloudflare",
})
if c.Existing {
// config.FilePath = config_filepath
if err := viper.ReadInConfig(); err != nil {
+22 -1
View File
@@ -9,9 +9,11 @@ download:
filenameTemplate: "{{filename}}_{{spec}}"
dir: "%UserDownloads%"
pauseWhenDownload: false
playDoneAudio: true
frontend: false
api:
protocol: "http"
hostname: "127.0.0.1"
port: 2022
@@ -20,5 +22,24 @@ proxy:
hostname: "127.0.0.1"
port: 2023
channel:
channels:
disableLocationToHome: false
mp:
remoteServer:
protocol: "http"
hostname: "127.0.0.1"
port: 2022
refreshToken: ""
tokenFilepath: ""
refreshSkipMinutes: 20
maxWebsocketClients: 5
cloudflare:
accountId: ""
apiToken: ""
refreshToken: ""
authToken: ""
workerName: "mp-rss-api"
d1Id: ""
d1Name: "mp-rss-db"
+8
View File
@@ -0,0 +1,8 @@
package downloader
type DownloadTaskClient struct {
}
func NewDownloadClient() *DownloadTaskClient {
return &DownloadTaskClient{}
}
+313
View File
@@ -0,0 +1,313 @@
package downloader
// "fmt"
// "net/url"
// "path/filepath"
// "strconv"
// "strings"
// "time"
// "github.com/GopeedLab/gopeed/pkg/base"
// "github.com/gin-gonic/gin"
// result "wx_channel/internal/util"
// "wx_channel/pkg/system"
// "wx_channel/pkg/util"
type FeedDownloadTaskBody struct {
Id string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Filename string `json:"filename"`
Key int `json:"key"`
Spec string `json:"spec"`
Suffix string `json:"suffix"`
}
// func (c *DownloadClient) handleCreateTask(ctx *gin.Context) {
// var body FeedDownloadTaskBody
// if err := ctx.ShouldBindJSON(&body); err != nil {
// result.Err(ctx, 400, "不合法的参数")
// return
// }
// if body.Id == "" {
// result.Err(ctx, 400, "缺少 feed id 参数")
// return
// }
// if body.Suffix == ".mp3" {
// has_ffmpeg := system.ExistingCommand("ffmpeg")
// if !has_ffmpeg {
// result.Err(ctx, 400, "下载 mp3 需要支持 ffmpeg 命令")
// return
// }
// }
// tasks := c.downloader.GetTasks()
// existing := c.check_existing_feed(tasks, &body)
// if existing {
// result.Err(ctx, 409, "已存在该下载内容")
// // ctx.JSON(http.StatusOK, Response{Code: 409, Msg: , Data: body})
// return
// }
// filename, dir, err := c.formatter.ProcessFilename(body.Filename)
// if err != nil {
// result.Err(ctx, 409, "不合法的文件名")
// return
// }
// connections := c.resolveConnections(body.URL)
// id, err := c.downloader.CreateDirect(
// &base.Request{
// URL: body.URL,
// Labels: map[string]string{
// "id": body.Id,
// "title": body.Title,
// "key": strconv.Itoa(body.Key),
// "spec": body.Spec,
// "suffix": body.Suffix,
// },
// },
// &base.Options{
// Name: filename + body.Suffix,
// Path: filepath.Join(c.cfg.DownloadDir, dir),
// Extra: &gopeedhttp.OptsExtra{
// Connections: connections,
// },
// },
// )
// if err != nil {
// result.Err(ctx, 500, "下载失败")
// return
// }
// c.channels.Broadcast(APIClientWSMessage{
// Type: "tasks",
// Data: c.downloader.GetTasks(),
// })
// result.Ok(ctx, gin.H{"id": id})
// }
// type LiveDownloadTaskBody struct {
// Url string `json:"url"`
// Name string `json:"name"`
// UserAgent string `json:"userAgent"`
// Headers map[string]string `json:"headers"`
// }
// func (c *DownloadClient) handleCreateLiveTask(ctx *gin.Context) {
// var body LiveDownloadTaskBody
// if err := ctx.ShouldBindJSON(&body); err != nil {
// result.Err(ctx, 400, "不合法的参数")
// return
// }
// if body.Url == "" {
// result.Err(ctx, 400, "缺少 url 参数")
// return
// }
// name := body.Name
// if name == "" {
// // Try to parse from URL or use timestamp
// u, _ := url.Parse(body.Url)
// if u != nil {
// name = filepath.Base(u.Path)
// }
// if name == "" || name == "." || name == "/" {
// name = fmt.Sprintf("live_%d.mp4", time.Now().Unix())
// }
// }
// if !strings.HasSuffix(name, ".mp4") && !strings.HasSuffix(name, ".ts") && !strings.HasSuffix(name, ".flv") && !strings.HasSuffix(name, ".mkv") {
// name += ".mp4"
// }
// reqExtra := &gopeedstream.ReqExtra{
// Header: make(map[string]string),
// }
// if body.UserAgent != "" {
// reqExtra.Header["User-Agent"] = body.UserAgent
// }
// for k, v := range body.Headers {
// reqExtra.Header[k] = v
// }
// id, err := c.downloader.CreateDirect(
// &base.Request{
// URL: body.Url,
// Extra: reqExtra,
// Labels: map[string]string{
// "type": "live",
// },
// },
// &base.Options{
// Name: name,
// Path: c.cfg.DownloadDir,
// },
// )
// if err != nil {
// result.Err(ctx, 500, "创建任务失败: "+err.Error())
// return
// }
// c.channels.Broadcast(APIClientWSMessage{
// Type: "tasks",
// Data: c.downloader.GetTasks(),
// })
// result.Ok(ctx, gin.H{"id": id})
// }
// func (c *DownloadClient) handleBatchCreateTask(ctx *gin.Context) {
// var body struct {
// Feeds []FeedDownloadTaskBody `json:"feeds"`
// }
// if err := ctx.ShouldBindJSON(&body); err != nil {
// result.Err(ctx, 400, "不合法的参数")
// return
// }
// tasks := c.downloader.GetTasks()
// var items []map[string]string
// for _, req := range body.Feeds {
// if c.check_existing_feed(tasks, &req) {
// continue
// }
// items = append(items, map[string]string{
// "name": req.Filename,
// "id": req.Id,
// "url": req.URL,
// "title": req.Title,
// "key": strconv.Itoa(req.Key),
// "suffix": req.Suffix,
// })
// }
// if len(items) == 0 {
// result.Ok(ctx, gin.H{"ids": []string{}})
// return
// }
// processed_reqs, err := util.ProcessFilenames(items, c.cfg.DownloadDir)
// if err != nil {
// result.Err(ctx, 500, "文件名处理失败: "+err.Error())
// return
// }
// task := base.CreateTaskBatch{}
// for _, item := range processed_reqs {
// url := item["url"]
// full_path := item["full_path"]
// // 从 full_path 中提取目录
// rel_dir := filepath.Dir(full_path)
// connections := c.resolveConnections(url)
// task.Reqs = append(task.Reqs, &base.CreateTaskBatchItem{
// Req: &base.Request{
// URL: url,
// Labels: map[string]string{
// "id": item["id"],
// "title": item["title"],
// "key": item["key"],
// "suffix": item["suffix"],
// },
// },
// Opts: &base.Options{
// Name: item["name"] + item["suffix"],
// Path: filepath.Join(c.cfg.DownloadDir, rel_dir),
// Extra: &gopeedhttp.OptsExtra{
// Connections: connections,
// },
// },
// })
// }
// ids, err := c.downloader.CreateDirectBatch(&task)
// if err != nil {
// result.Err(ctx, 500, "创建失败")
// return
// }
// c.channels.Broadcast(APIClientWSMessage{
// Type: "tasks",
// Data: c.downloader.GetTasks(),
// })
// result.Ok(ctx, gin.H{"ids": ids})
// }
// func (c *DownloadClient) handleStartTask(ctx *gin.Context) {
// var body struct {
// Id string `json:"id"`
// }
// if err := ctx.ShouldBindJSON(&body); err != nil {
// result.Err(ctx, 400, "不合法的参数")
// return
// }
// if body.Id == "" {
// result.Err(ctx, 400, "缺少 feed id 参数")
// return
// }
// c.downloader.Continue(&downloadpkg.TaskFilter{
// IDs: []string{body.Id},
// })
// result.Ok(ctx, gin.H{"id": body.Id})
// }
// func (c *DownloadClient) handlePauseTask(ctx *gin.Context) {
// var body struct {
// Id string `json:"id"`
// }
// if err := ctx.ShouldBindJSON(&body); err != nil {
// result.Err(ctx, 400, "不合法的参数")
// return
// }
// if body.Id == "" {
// result.Err(ctx, 400, "缺少 feed id 参数")
// return
// }
// c.downloader.Pause(&downloadpkg.TaskFilter{
// IDs: []string{body.Id},
// })
// result.Ok(ctx, gin.H{"id": body.Id})
// }
// func (c *DownloadClient) handleResumeTask(ctx *gin.Context) {
// var body struct {
// Id string `json:"id"`
// }
// if err := ctx.ShouldBindJSON(&body); err != nil {
// result.Err(ctx, 400, "不合法的参数")
// return
// }
// if body.Id == "" {
// result.Err(ctx, 400, "缺少 feed id 参数")
// return
// }
// c.downloader.Continue(&downloadpkg.TaskFilter{
// IDs: []string{body.Id},
// })
// result.Ok(ctx, gin.H{"id": body.Id})
// }
// func (c *DownloadClient) handleDeleteTask(ctx *gin.Context) {
// var body struct {
// Id string `json:"id"`
// }
// if err := ctx.ShouldBindJSON(&body); err != nil {
// result.Err(ctx, 400, "不合法的参数")
// return
// }
// if body.Id == "" {
// result.Err(ctx, 400, "缺少 feed id 参数")
// return
// }
// task := c.downloader.GetTask(body.Id)
// c.downloader.Delete(&downloadpkg.TaskFilter{
// IDs: []string{body.Id},
// }, true)
// c.channels.Broadcast(APIClientWSMessage{
// Type: "event",
// Data: map[string]any{
// "Type": "delete",
// "Task": task,
// },
// })
// result.Ok(ctx, gin.H{"id": body.Id})
// }
// func (c *DownloadClient) handleClearTasks(ctx *gin.Context) {
// c.downloader.Delete(nil, true)
// c.channels.Broadcast(APIClientWSMessage{
// Type: "clear",
// Data: c.downloader.GetTasks(),
// })
// result.Ok(ctx, nil)
// }
+3 -4
View File
@@ -52,8 +52,8 @@ var js_utils []byte
//go:embed inject/src/downloader.js
var js_downloader []byte
//go:embed inject/src/wechat_official_account.js
var js_wechat_official_account []byte
//go:embed inject/src/officialaccount.js
var js_wechat_officialaccount []byte
//go:embed inject/src/main.js
var js_feed_profile_or_recommand_page []byte
@@ -84,7 +84,6 @@ type ChannelInjectedFiles struct {
JSMain []byte
JSLiveMain []byte
JSContactMain []byte
IndexHTML []byte
JSWechatOfficialAccount []byte
}
@@ -108,5 +107,5 @@ var Assets = &ChannelInjectedFiles{
JSMain: js_feed_profile_or_recommand_page,
JSLiveMain: js_live_profile_page,
JSContactMain: js_contact_profile_page,
JSWechatOfficialAccount: js_wechat_official_account,
JSWechatOfficialAccount: js_wechat_officialaccount,
}
+58 -172
View File
@@ -12,185 +12,71 @@ import (
)
type InterceptorConfig struct {
Version string `json:"version"`
FilePath string // 配置文件路径
DownloadDefaultHighest bool `json:"defaultHighest"` // 默认下载最高画质
DownloadFilenameTemplate string `json:"downloadFilenameTemplate"` // 下载文件名模板
DownloadPauseWhenDownload bool `json:"downloadPauseWhenDownload"` // 下载时暂停播放
DownloadInFrontend bool `json:"downloadInFrontend"` // 在前端下载
APIServerHostname string `json:"apiServerHostname"` // API服务器主机名
APIServerPort int `json:"apiServerPort"` // API服务器端口
APIServerAddr string `json:"apiServerAddr"`
ProxyDevice string
ProxySetSystem bool
ProxyServerHostname string
ProxyServerPort int
PagespyEnabled bool
PageppyServerProtocol string `json:"pagespyServerProtocol"` // pagespy调试地址协议,如 http
PageppyServerAPI string `json:"pagespyServerAPI"` // pagespy调试地址,如 debug.weixin.qq.com
DebugShowError bool
ChannelDisableLocationToHome bool // 禁止从feed重定向到home
InjectExtraScriptAfterJSMain string // 额外注入的 js
InjectGlobalScriptFilepath string // 全局脚本路径
InjectGlobalScript string // 全局用户脚本
Version string `json:"version"`
FilePath string // 配置文件路径
DownloadDefaultHighest bool `json:"defaultHighest"` // 默认下载最高画质
DownloadFilenameTemplate string `json:"downloadFilenameTemplate"` // 下载文件名模板
DownloadPauseWhenDownload bool `json:"downloadPauseWhenDownload"` // 下载时暂停播放
DownloadInFrontend bool `json:"downloadInFrontend"` // 在前端下载
DownloadMaxRunning int `json:"downloadMaxRunning"` // 最大同时下载数
APIServerProtocol string `json:"apiServerProtocol"` // API服务器主机名
APIServerHostname string `json:"apiServerHostname"` // API服务器主机名
APIServerPort int `json:"apiServerPort"` // API服务器端口
APIServerAddr string `json:"apiServerAddr"`
OfficialAccountServerRefreshToken string `json:"officialServerRefreshToken"`
OfficialAccountServerDisabled bool `json:"officialServerDisabled"`
OfficialAccountRemoteServerProtocol string `json:"officialRemoteServerProtocol"`
OfficialAccountRemoteServerHostname string `json:"officialRemoteServerHostname"`
OfficialAccountRemoteServerPort int `json:"officialRemoteServerPort"`
ProxyDevice string
ProxySetSystem bool
ProxyServerHostname string
ProxyServerPort int
PagespyEnabled bool
PageppyServerProtocol string `json:"pagespyServerProtocol"` // pagespy调试地址协议,如 http
PageppyServerAPI string `json:"pagespyServerAPI"` // pagespy调试地址,如 debug.weixin.qq.com
DebugShowError bool
ChannelsDisableLocationToHome bool // 禁止从feed重定向到home
InjectExtraScriptAfterJSMain string // 额外注入的 js
InjectGlobalScriptFilepath string // 全局脚本路径
InjectGlobalScript string // 全局用户脚本
// CertFiles *certificate.CertFileAndKeyFile
t *config.Config
}
func SetupConfig(cfg *config.Config) {
config.Register(config.ConfigItem{
Key: "download.defaultHighest",
Type: config.ConfigTypeBool,
Default: false,
Description: "默认下载原始视频",
Title: "原始视频",
Group: "Download",
})
config.Register(config.ConfigItem{
Key: "download.filenameTemplate",
Type: config.ConfigTypeString,
Default: "{{filename}}_{{spec}}",
Description: "下载文件名模板,支持 {{filename}} 和 {{spec}} 变量",
Title: "文件名模板",
Group: "Download",
})
config.Register(config.ConfigItem{
Key: "download.pauseWhenDownload",
Type: config.ConfigTypeBool,
Default: false,
Description: "下载时暂停播放",
Title: "暂停播放",
Group: "Download",
})
config.Register(config.ConfigItem{
Key: "download.inFrontend",
Type: config.ConfigTypeBool,
Default: false,
Description: "在前端下载",
Title: "前端下载",
Group: "Download",
})
config.Register(config.ConfigItem{
Key: "api.hostname",
Type: config.ConfigTypeString,
Default: "127.0.0.1",
Description: "API 服务主机名",
Title: "API 服务主机",
Group: "API",
})
config.Register(config.ConfigItem{
Key: "api.port",
Type: config.ConfigTypeInt,
Default: 2022,
Description: "API 服务端口",
Title: "API 服务端口",
Group: "API",
})
config.Register(config.ConfigItem{
Key: "proxy.system",
Type: config.ConfigTypeBool,
Default: true,
Description: "是否设置系统代理",
Title: "系统代理",
Group: "Proxy",
})
config.Register(config.ConfigItem{
Key: "proxy.hostname",
Type: config.ConfigTypeString,
Default: "127.0.0.1",
Description: "代理主机名",
Title: "代理主机",
Group: "Proxy",
})
config.Register(config.ConfigItem{
Key: "proxy.port",
Type: config.ConfigTypeInt,
Default: 2080,
Description: "代理端口",
Title: "代理端口",
Group: "Proxy",
})
config.Register(config.ConfigItem{
Key: "pagespy.enabled",
Type: config.ConfigTypeSelect,
Default: false,
Description: "是否开启 PageSpy",
Title: "启用",
Group: "Pagespy",
})
config.Register(config.ConfigItem{
Key: "pagespy.protocol",
Type: config.ConfigTypeSelect,
Default: "https",
Options: []string{"http", "https"},
Description: "PageSpy 调试协议",
Title: "协议头",
Group: "Pagespy",
})
config.Register(config.ConfigItem{
Key: "pagespy.api",
Type: config.ConfigTypeString,
Default: "debug.weixin.qq.com",
Description: "PageSpy 调试 API 地址",
Title: "API 地址",
Group: "Pagespy",
})
config.Register(config.ConfigItem{
Key: "debug.error",
Type: config.ConfigTypeBool,
Default: true,
Description: "在弹窗展示错误信息",
Title: "错误展示",
Group: "Debug",
})
config.Register(config.ConfigItem{
Key: "channel.disableLocationToHome",
Type: config.ConfigTypeBool,
Default: false,
Description: "禁止从 Feed 重定向到 Home",
Title: "禁止重定向",
Group: "Channel",
})
config.Register(config.ConfigItem{
Key: "inject.extraScript.afterJSMain",
Type: config.ConfigTypeString,
Default: "",
Description: "额外注入的 JS 脚本路径",
Title: "注入脚本",
Group: "Inject",
})
config.Register(config.ConfigItem{
Key: "inject.globalScript",
Type: config.ConfigTypeString,
Default: "global.js",
Description: "全局用户脚本",
Title: "全局脚本",
Group: "Inject",
})
}
func NewInterceptorSettings(c *config.Config) *InterceptorConfig {
settings := &InterceptorConfig{
Version: c.Version,
DebugShowError: viper.GetBool("debug.error"),
PagespyEnabled: viper.GetBool("pagespy.enabled"),
PageppyServerProtocol: viper.GetString("pagespy.protocol"),
PageppyServerAPI: viper.GetString("pagespy.api"),
ChannelDisableLocationToHome: viper.GetBool("channel.disableLocationToHome"),
DownloadDefaultHighest: viper.GetBool("download.defaultHighest"),
DownloadFilenameTemplate: viper.GetString("download.filenameTemplate"),
DownloadPauseWhenDownload: viper.GetBool("download.pauseWhenDownload"),
DownloadInFrontend: viper.GetBool("download.frontend"),
APIServerHostname: viper.GetString("api.hostname"),
APIServerPort: viper.GetInt("api.port"),
APIServerAddr: viper.GetString("api.hostname") + ":" + strconv.Itoa(viper.GetInt("api.port")),
ProxySetSystem: viper.GetBool("proxy.system"),
ProxyServerPort: viper.GetInt("proxy.port"),
ProxyServerHostname: viper.GetString("proxy.hostname"),
InjectExtraScriptAfterJSMain: viper.GetString("inject.extraScript.afterJSMain"),
InjectGlobalScriptFilepath: viper.GetString("inject.globalScript"),
t: c,
Version: c.Version,
DebugShowError: viper.GetBool("debug.error"),
PagespyEnabled: viper.GetBool("pagespy.enabled"),
PageppyServerProtocol: viper.GetString("pagespy.protocol"),
PageppyServerAPI: viper.GetString("pagespy.api"),
ChannelsDisableLocationToHome: viper.GetBool("channel.disableLocationToHome"),
DownloadDefaultHighest: viper.GetBool("download.defaultHighest"),
DownloadFilenameTemplate: viper.GetString("download.filenameTemplate"),
DownloadPauseWhenDownload: viper.GetBool("download.pauseWhenDownload"),
DownloadInFrontend: viper.GetBool("download.frontend"),
APIServerProtocol: viper.GetString("api.protocol"),
APIServerHostname: viper.GetString("api.hostname"),
APIServerPort: viper.GetInt("api.port"),
APIServerAddr: viper.GetString("api.hostname") + ":" + strconv.Itoa(viper.GetInt("api.port")),
OfficialAccountServerRefreshToken: viper.GetString("mp.refreshToken"),
OfficialAccountServerDisabled: viper.GetBool("mp.disabled"),
OfficialAccountRemoteServerProtocol: viper.GetString("mp.remoteServer.protocol"),
OfficialAccountRemoteServerHostname: viper.GetString("mp.remoteServer.hostname"),
OfficialAccountRemoteServerPort: viper.GetInt("mp.remoteServer.port"),
ProxySetSystem: viper.GetBool("proxy.system"),
ProxyServerPort: viper.GetInt("proxy.port"),
ProxyServerHostname: viper.GetString("proxy.hostname"),
InjectExtraScriptAfterJSMain: viper.GetString("inject.extraScript.afterJSMain"),
InjectGlobalScriptFilepath: viper.GetString("inject.globalScript"),
t: c,
}
if viper.GetBool("channels.disableLocationToHome") {
// channels.disableLocationToHome 是新的写法,之前 channel.disableLocationToHome 不对
// 所以这里做个兼容,保证旧的配置项仍然有效
settings.ChannelsDisableLocationToHome = true
}
global_script_path := path.Join(c.RootDir, "global.js")
if _, err := os.Stat(global_script_path); err == nil {
@@ -35,11 +35,14 @@ export default [
WXE: "writable",
render_extra_menu_items: "writable",
FakeAPIServerAddr: "writeable",
FakeOfficialAccountServerAddr: "writeable",
insert_channels_style: "writeable",
MoreIcon: "writable",
download_icon1: "writable",
download_icon2: "writable",
download_icon3: "writable",
download_icon4: "writable",
RSSIcon: "writeable",
download_btn1: "writable",
download_btn2: "writable",
download_btn3: "writable",
+1 -1
View File
@@ -1 +1 @@
(function($,g){typeof exports=="object"&&typeof module<"u"?module.exports=g():typeof define=="function"&&define.amd?define(g):($=typeof globalThis<"u"?globalThis:$||self,$.WUI=g())})(this,(function(){"use strict";function $(y){return Array.isArray(y)?y:y&&Array.isArray(y.children)?y.children:y&&Array.isArray(y.menus)?y.menus:[]}function g(y={}){const{children:T=[],menus:t=null,onMouseLeave:a=null,template:D=null}=y,c={children:Array.isArray(T)?T:Array.isArray(t)?t:[],isOpen:!1,isHover:!1,template:D||g._DEFAULT_TEMPLATE||null,show(){c.isOpen=!0,c.__ref&&c.__ref.open&&c.__ref.open()},hide(){c.isOpen=!1,c.__ref&&c.__ref.close&&c.__ref.close()},setChildren(N){c.children=$(N),c.__ref&&c.__ref.listEl&&(c.__ref.listEl.innerHTML=""),c.__ref&&typeof c.__ref.renderItemsTo=="function"&&c.__ref.listEl?c.__ref.renderItemsTo(c.__ref.listEl,c.children):typeof c.refresh=="function"&&c.refresh()},onMouseLeave:a,ui:{$submenu:null},__ref:null};return c}g._DEFAULT_TEMPLATE=null,g.setTemplate=function(T){g._DEFAULT_TEMPLATE=T},g.getTemplate=function(){return g._DEFAULT_TEMPLATE};let ie=null,ue=null;function h(y={}){const{label:T="",disabled:t=!1,onClick:a=null,onMouseEnter:D=null,onMouseLeave:c=null,children:N=null,submenu:j=null,menus:P=null,template:A=null,indicatorTemplate:W=null}=y;return{label:T,disabled:t,onClick:a,onMouseEnter:D,onMouseLeave:c,children:Array.isArray(N)?N:Array.isArray(P)?P:null,submenu:j||null,isHover:!1,ui:{},template:A??ie,indicatorTemplate:W??ue}}h.setTemplate=function(T){ie=T},h.getTemplate=function(){return ie},h.setIndicatorTemplate=function(T){ue=T},h.getIndicatorTemplate=function(){return ue};const{computePosition:pe,offset:me,flip:ye,shift:ve,autoUpdate:ae,arrow:Me}=window.FloatingUIDOM;function Ce(y,T={}){let t=y,a=T;t&&t.$trigger&&(a=t,t=t.$trigger);const{placement:D="bottom-start",closeOnClickOutside:c=!0,closeOnSelect:N=!1,hoverOpenDelay:j=80,hoverCloseDelay:P=120,showArrow:A=!1,onClose:W=null,manual:S=!0,zIndex:de=1e3}=a,J=Number(de)||1e3,V=a&&a.template!=null?a.template:typeof g.getTemplate=="function"?g.getTemplate():null;let o,s;V?(o=B(V,a)||document.createElement("div"),s=o.querySelector('[data-menu-list="1"]')||o.querySelector(".weui-dropdown-list"),s||(s=document.createElement("div"),s.className="weui-dropdown-list",o.appendChild(s)),o.style.position="fixed",o.style.zIndex=String(J),o.style.display="none"):(o=document.createElement("div"),o.className="weui-dropdown",o.style.position="fixed",o.style.zIndex=String(J),o.style.display="none",o.style.minWidth="160px",o.style.background="var(--weui-dropdown-bg)",o.style.borderRadius="12px",o.style.boxShadow="var(--weui-dropdown-shadow)",o.style.border="0",o.style.padding="8px 0",s=document.createElement("div"),s.className="weui-dropdown-list",o.appendChild(s));const v=document.createElement("div");v.className="weui-dropdown-arrow",v.style.position="absolute",v.style.width="10px",v.style.height="10px",v.style.background="var(--weui-dropdown-bg)",v.style.borderLeft="0",v.style.borderTop="0",v.style.transform="rotate(45deg)",v.style.pointerEvents="none",v.style.display=A?"block":"none",o.appendChild(v);const L=[];function q(){L.forEach(n=>{n.close(),n.el.remove()}),L.length=0}function I(){L.forEach(n=>n.close())}function R(n,l,e){if(typeof n=="function"){const d=n(l);if(d instanceof Node)return d;if(typeof d=="string"){const i=document.createElement("div");let f=d.replace(/\{\{\s*label\s*\}\}/g,String(l.label??""));const b=l&&l.indicatorTemplate!=null?l.indicatorTemplate:h&&typeof h.getIndicatorTemplate=="function"?h.getIndicatorTemplate():null;return b&&e&&/\{\{\s*indicator\s*\}\}/.test(f)&&(typeof b=="string"?f=f.replace(/\{\{\s*indicator\s*\}\}/g,b):f=f.replace(/\{\{\s*indicator\s*\}\}/g,"")),i.innerHTML=f,i.firstElementChild||i}return null}if(n instanceof Node)return n;if(typeof n=="string"){const d=document.createElement("div");let i=n.replace(/\{\{\s*label\s*\}\}/g,String(l.label??""));const f=l&&l.indicatorTemplate!=null?l.indicatorTemplate:h&&typeof h.getIndicatorTemplate=="function"?h.getIndicatorTemplate():null;return f&&e&&/\{\{\s*indicator\s*\}\}/.test(i)&&(typeof f=="string"?i=i.replace(/\{\{\s*indicator\s*\}\}/g,f):i=i.replace(/\{\{\s*indicator\s*\}\}/g,"")),d.innerHTML=i,d.firstElementChild||d}return null}function B(n,l){const e=()=>'<div class="weui-dropdown-list" data-menu-list="1"></div>';if(typeof n=="function"){const d=n(l);if(d instanceof Node)return d;if(typeof d=="string"){const i=document.createElement("div");let f=d.replace(/\{\{\s*label\s*\}\}/g,String(l.label??""));return f=f.replace(/\{\{\s*list\s*\}\}/g,e()),i.innerHTML=f,i.firstElementChild||i}return null}if(n instanceof Node)return n;if(typeof n=="string"){const d=document.createElement("div");let i=n.replace(/\{\{\s*label\s*\}\}/g,String(l.label??""));return i=i.replace(/\{\{\s*list\s*\}\}/g,e()),d.innerHTML=i,d.firstElementChild||d}return null}function C(n,l){n.innerHTML="",l.forEach((e,d)=>{const i=e.submenu&&Array.isArray(e.submenu.children)?e.submenu.childrLine truncated
(function(N,b){typeof exports=="object"&&typeof module<"u"?module.exports=b():typeof define=="function"&&define.amd?define(b):(N=typeof globalThis<"u"?globalThis:N||self,N.WUI=b())})(this,(function(){"use strict";function N(y){return Array.isArray(y)?y:y&&Array.isArray(y.children)?y.children:y&&Array.isArray(y.menus)?y.menus:[]}function b(y={}){const{children:E=[],menus:t=null,onMouseLeave:u=null,template:$=null}=y,c={children:Array.isArray(E)?E:Array.isArray(t)?t:[],isOpen:!1,isHover:!1,template:$||b._DEFAULT_TEMPLATE||null,show(){c.isOpen=!0,c.__ref&&c.__ref.open&&c.__ref.open()},hide(){c.isOpen=!1,c.__ref&&c.__ref.close&&c.__ref.close()},setChildren(P){c.children=N(P),c.__ref&&c.__ref.listEl&&(c.__ref.listEl.innerHTML=""),c.__ref&&typeof c.__ref.renderItemsTo=="function"&&c.__ref.listEl?c.__ref.renderItemsTo(c.__ref.listEl,c.children):typeof c.refresh=="function"&&c.refresh()},onMouseLeave:u,ui:{$submenu:null},__ref:null};return c}b._DEFAULT_TEMPLATE=null,b.setTemplate=function(E){b._DEFAULT_TEMPLATE=E},b.getTemplate=function(){return b._DEFAULT_TEMPLATE};let ue=null,ae=null;function g(y={}){const{label:E="",disabled:t=!1,onClick:u=null,onMouseEnter:$=null,onMouseLeave:c=null,children:P=null,submenu:z=null,menus:U=null,template:L=null,indicatorTemplate:F=null}=y;return{label:E,disabled:t,onClick:u,onMouseEnter:$,onMouseLeave:c,children:Array.isArray(P)?P:Array.isArray(U)?U:null,submenu:z||null,isHover:!1,ui:{},template:L??ue,indicatorTemplate:F??ae}}g.setTemplate=function(E){ue=E},g.getTemplate=function(){return ue},g.setIndicatorTemplate=function(E){ae=E},g.getIndicatorTemplate=function(){return ae};const{computePosition:pe,offset:me,flip:ye,shift:ve,autoUpdate:de,arrow:Me}=window.FloatingUIDOM;function Ce(y,E={}){let t=y,u=E;t&&t.$trigger&&(u=t,t=t.$trigger);const{placement:$="bottom-start",closeOnClickOutside:c=!0,closeOnSelect:P=!1,hoverOpenDelay:z=80,hoverCloseDelay:U=120,showArrow:L=!1,onClose:F=null,manual:H=!0,zIndex:ce=1e3}=u,G=Number(ce)||1e3,Y=u&&u.template!=null?u.template:typeof b.getTemplate=="function"?b.getTemplate():null;let o,h;Y?(o=W(Y,u)||document.createElement("div"),h=o.querySelector('[data-menu-list="1"]')||o.querySelector(".weui-dropdown-list"),h||(h=document.createElement("div"),h.className="weui-dropdown-list",o.appendChild(h)),o.style.position="fixed",o.style.zIndex=String(G),o.style.display="none"):(o=document.createElement("div"),o.className="weui-dropdown",o.style.position="fixed",o.style.zIndex=String(G),o.style.display="none",o.style.minWidth="160px",o.style.background="var(--weui-dropdown-bg)",o.style.borderRadius="12px",o.style.boxShadow="var(--weui-dropdown-shadow)",o.style.border="0",o.style.padding="8px 0",h=document.createElement("div"),h.className="weui-dropdown-list",o.appendChild(h));const s=document.createElement("div");s.className="weui-dropdown-arrow",s.style.position="absolute",s.style.width="10px",s.style.height="10px",s.style.background="var(--weui-dropdown-bg)",s.style.borderLeft="0",s.style.borderTop="0",s.style.transform="rotate(45deg)",s.style.pointerEvents="none",s.style.display=L?"block":"none",o.appendChild(s);const w=[];function j(){w.forEach(n=>{n.close(),n.el.remove()}),w.length=0}function J(){w.forEach(n=>n.close())}function _(n,l,e){if(typeof n=="function"){const d=n(l);if(d instanceof Node)return d;if(typeof d=="string"){const i=document.createElement("div");let f=d.replace(/\{\{\s*label\s*\}\}/g,String(l.label??""));const v=l&&l.indicatorTemplate!=null?l.indicatorTemplate:g&&typeof g.getIndicatorTemplate=="function"?g.getIndicatorTemplate():null;return v&&e&&/\{\{\s*indicator\s*\}\}/.test(f)&&(typeof v=="string"?f=f.replace(/\{\{\s*indicator\s*\}\}/g,v):f=f.replace(/\{\{\s*indicator\s*\}\}/g,"")),i.innerHTML=f,i.firstElementChild||i}return null}if(n instanceof Node)return n;if(typeof n=="string"){const d=document.createElement("div");let i=n.replace(/\{\{\s*label\s*\}\}/g,String(l.label??""));const f=l&&l.indicatorTemplate!=null?l.indicatorTemplate:g&&typeof g.getIndicatorTemplate=="function"?g.getIndicatorTemplate():null;return f&&e&&/\{\{\s*indicator\s*\}\}/.test(i)&&(typeof f=="string"?i=i.replace(/\{\{\s*indicator\s*\}\}/g,f):i=i.replace(/\{\{\s*indicator\s*\}\}/g,"")),d.innerHTML=i,d.firstElementChild||d}return null}function W(n,l){const e=()=>'<div class="weui-dropdown-list" data-menu-list="1"></div>';if(typeof n=="function"){const d=n(l);if(d instanceof Node)return d;if(typeof d=="string"){const i=document.createElement("div");let f=d.replace(/\{\{\s*label\s*\}\}/g,String(l.label??""));return f=f.replace(/\{\{\s*list\s*\}\}/g,e()),i.innerHTML=f,i.firstElementChild||i}return null}if(n instanceof Node)return n;if(typeof n=="string"){const d=document.createElement("div");let i=n.replace(/\{\{\s*label\s*\}\}/g,String(l.label??""));return i=i.replace(/\{\{\s*list\s*\}\}/g,e()),d.innerHTML=i,d.firstElementChild||d}return null}function A(n,l){n.innerHTML="",l.forEach((e,d)=>{const i=e.submenu&&Array.isArray(e.submenu.children)?e.submenu.childrLine truncated
+63 -49
View File
@@ -1,7 +1,24 @@
const inserted_style = `<style>
:root {
--popup-bg-color: #f6f6f6;
--popup-content-bg-color: #e7e7e7;
}
body[data-weui-theme=dark] {
--popup-bg-color: #272727;
--popup-content-bg-color: #323232;
}
@media (prefers-color-scheme: dark) {
body:not([data-weui-theme=light]) {
--popup-bg-color: #272727;
--popup-content-bg-color: #323232;
}
}
.flex {
display: flex;
}
.custom-menu {
z-index: 99999;
background: var(--weui-BG-2);
background: var(--popup-bg-color);
box-shadow: 0 0 6px rgb(0 0 0 / 20%);
border-radius: 4px;
color: var(--weui-FG-0);
@@ -20,7 +37,7 @@ const inserted_style = `<style>
transition: background .15s ease-in-out
}
.custom-menu-item:hover {
background: var(--weui-BG-COLOR-ACTIVE);
background: var(--popup-content-bg-color);
}
.custom-menu-item-arrow {
position: absolute;
@@ -29,6 +46,30 @@ const inserted_style = `<style>
font-size: 18px;
line-height: 12px;
}
.custom-menu .weui-cells {
margin: 0;
background: transparent;
}
.custom-menu .weui-cell {
align-items: center;
padding: 8px;
border-radius: 4px;
}
.custom-menu .weui-cell:hover {
background: var(--FG-6);
}
.custom-menu .weui-cell__bd p {
color: var(--weui-FG-0);
font-size: 14px;
line-height: 1.4;
}
.custom-menu .wx-download-item-open {
display: none;
margin-left: 8px;
}
.custom-menu .weui-cell:hover .wx-download-item-open {
display: inline-flex;
}
.wx-footer {
position: fixed;
right: 0;
@@ -80,7 +121,7 @@ const inserted_style = `<style>
.wx-dl-panel-container {
width: 400px;
max-height: 450px;
background-color: var(--weui-BG-2);
background-color: var(--popup-bg-color);
border-radius: 8px;
display: flex;
flex-direction: column;
@@ -122,14 +163,16 @@ const inserted_style = `<style>
.wx-dl-item {
padding: 16px;
background-color: var(--weui-BG-0);
background-color: var(--popup-content-bg-color);
border-radius: 8px;
margin-bottom: 8px;
align-items: center;
}
</style>`;
document.head.insertAdjacentHTML("beforeend", inserted_style);
function insert_channels_style() {
document.head.insertAdjacentHTML("beforeend", inserted_style);
}
var download_icon1 = `<svg data-v-132dee25 class="svg-icon icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="28" height="28"><path d="M213.333333 853.333333h597.333334v-85.333333H213.333333m597.333334-384h-170.666667V128H384v256H213.333333l298.666667 298.666667 298.666667-298.666667z"></path></svg>`;
var download_icon2 =
@@ -145,6 +188,7 @@ var PauseIcon = `<svg viewBox="0 0 24 24" width="20" height="20" fill="currentCo
var PlayIcon = `<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`;
var DeleteIcon = `<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
var MoreIcon = `<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>`;
var RSSIcon = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em"><path d="M204.8 938.666667 204.8 938.666667C140.8 938.666667 85.333333 883.2 85.333333 819.2L85.333333 819.2C85.333333 755.2 140.8 699.733333 204.8 699.733333L204.8 699.733333C268.8 699.733333 324.266667 755.2 324.266667 819.2L324.266667 819.2C324.266667 883.2 273.066667 938.666667 204.8 938.666667M85.333333 85.333333 85.333333 213.333333C486.4 213.333333 810.666667 537.6 810.666667 938.666667L938.666667 938.666667C938.666667 467.2 556.8 85.333333 85.333333 85.333333M85.333333 345.6 85.333333 473.6C341.333333 473.6 550.4 682.666667 550.4 938.666667L678.4 938.666667C678.4 610.133333 413.866667 345.6 85.333333 345.6Z" fill="currentColor"></path></svg>`;
/**
* @returns {HTMLDivElement}
@@ -217,38 +261,6 @@ function render_extra_menu_items(items, $dropdown) {
});
}
/** 增强下载面板的样式,遵循现有主题变量与暗黑模式 */
(function () {
var style = document.createElement("style");
style.innerHTML = `
.custom-menu .weui-cells {
margin: 0;
background: transparent;
}
.custom-menu .weui-cell {
align-items: center;
padding: 8px;
border-radius: 4px;
}
.custom-menu .weui-cell:hover {
background: var(--FG-6);
}
.custom-menu .weui-cell__bd p {
color: var(--weui-FG-0);
font-size: 14px;
line-height: 1.4;
}
.custom-menu .wx-download-item-open {
display: none;
margin-left: 8px;
}
.custom-menu .weui-cell:hover .wx-download-item-open {
display: inline-flex;
}
`;
document.head.appendChild(style);
})();
function format_download_speed(bps) {
const kb = 1024,
mb = kb * 1024;
@@ -294,6 +306,11 @@ function __wx_refresh_downloader(selector, tasks) {
const list = Array.from(tasks.values()).reverse(); // Newest first
const runningCount = list.filter((t) => t.status === "running").length;
const countEl = document.getElementById("wx-dl-count");
if (countEl) {
countEl.innerText = list.length > 0 ? `(${list.length})` : "";
}
if (list.length === 0) {
container.innerHTML = `<div class="weui-loadmore weui-loadmore_line"><span class="weui-loadmore__tips">暂无下载任务</span></div>`;
return;
@@ -359,7 +376,7 @@ function __wx_refresh_downloader(selector, tasks) {
`;
} else if (isPaused || t.status === "failed") {
// Allow resume if paused or failed
var MaxRunning = WXU.config.DownloadMaxRunning;
var MaxRunning = WXU.config.downloadMaxRunning;
if (runningCount < MaxRunning) {
actionButtons += `
<a href="javascript:" class="wx-download-item-resume" aria-label="继续" title="继续" data-id="${t.id}" data-action="resume" style="${btnStyle}">
@@ -445,18 +462,15 @@ function __wx_refresh_downloader(selector, tasks) {
const footer = document.createElement("div");
footer.className = "weui-loadmore weui-loadmore_line";
footer.style.marginTop = "20px";
footer.innerHTML = '<span class="weui-loadmore__tips">没有更多内容了</span>';
footer.innerHTML =
'<span class="weui-loadmore__tips">没有更多内容了</span>';
container.appendChild(footer);
}
}
(() => {
var { Menu, MenuItem } = WUI;
MenuItem.setTemplate(
'<div class="custom-menu-item"><span class="label">{{ label }}</span></div>'
);
MenuItem.setIndicatorTemplate(
'<span class="custom-menu-item-arrow">›</span>'
);
Menu.setTemplate('<div><div class="custom-menu">{{ list }}</div></div>');
})();
var { Menu, MenuItem } = WUI;
MenuItem.setTemplate(
'<div class="custom-menu-item"><span class="label">{{ label }}</span></div>'
);
MenuItem.setIndicatorTemplate('<span class="custom-menu-item-arrow">›</span>');
Menu.setTemplate('<div><div class="custom-menu">{{ list }}</div></div>');
@@ -30,8 +30,8 @@ var isWin = /Windows|Win/i.test(ua);
}
function connect(selector) {
return new Promise((resolve, reject) => {
const protocol = "ws://";
const pathname = WXU.config.apiServerAddr;
const protocol = "wss://";
const pathname = FakeAPIServerAddr;
const ws = new WebSocket(protocol + pathname + "/ws/channels");
ws.onopen = () => {
@@ -166,7 +166,7 @@ var isWin = /Windows|Win/i.test(ua);
$download_panel.innerHTML = `
<div class="wx-dl-panel-container">
<div class="wx-dl-header">
<div class="wx-dl-title">Downloads</div>
<div class="wx-dl-title">Downloads <span id="wx-dl-count"></span></div>
</div>
<div id="downloader_container" class="wx-dl-list wx-dl-dark-scroll" style="background-color: transparent; margin-top: 0;"></div>
</div>
@@ -175,6 +175,7 @@ var isWin = /Windows|Win/i.test(ua);
content: $download_panel.innerHTML,
placement: "bottom-end",
closeOnClickOutside: true,
offset: { mainAxis: -4, crossAxis: 20 },
});
var $more = document.createElement("div");
$more.innerHTML = `<div class="wx-dl-more-btn" id="wx_dl_more_btn">${MoreIcon}</div>`;
@@ -272,7 +273,7 @@ async function __wx_handle_api_call(msg, socket) {
})
);
}
if (key === "/api/contact/search") {
if (key === "key:channels:contact_list") {
var payload = {
query: data.keyword,
scene: 13,
@@ -288,7 +289,7 @@ async function __wx_handle_api_call(msg, socket) {
});
return;
}
if (key === "/api/contact/feed/list") {
if (key === "key:channels:feed_list") {
var payload = {
username: data.username,
finderUsername: __wx_username,
@@ -306,13 +307,13 @@ async function __wx_handle_api_call(msg, socket) {
});
return;
}
if (key === "/api/feed/profile") {
if (key === "key:channels:feed_profile") {
console.log("before finderGetCommentProfile", data.oid, data.nid);
try {
if (data.url) {
var u = new URL(decodeURIComponent(data.url));
data.oid = WXU.API.decodeBase64Uint64(u.searchParams.get("oid"));
data.nid = WXU.API.decodeBase64Uint64(u.searchParams.get("nid"));
data.oid = WXU.API.decodeBase64ToUint64String(u.searchParams.get("oid"));
data.nid = WXU.API.decodeBase64ToUint64String(u.searchParams.get("nid"));
}
var payload = {
needObject: 1,
+1 -1
View File
@@ -12,7 +12,7 @@ var WXE = (() => {
WindowLoaded: "WindowLoaded",
/** 页面卸载完成 */
WindowUnLoaded: "WindowUnLoaded",
OfficialAccountLoaded: "OfficialAccountLoaded",
OfficialAccountRefresh: "OfficialAccountRefresh",
APILoaded: "APILoaded",
UtilsLoaded: "UtilsLoaded",
Init: "Init",
+22 -31
View File
@@ -10,7 +10,8 @@ function __wx_copy_live_download_command(url) {
WXU.log({ prefix: "", msg: "" });
WXU.log({ prefix: "", msg: "直播下载命令" });
WXU.log({ prefix: "", msg: command });
WXU.toast("请在终端查看下载命令");
WXU.copy(command);
WXU.toast("直播下载命令已复制到粘贴板");
}
async function __wx_insert_live_download_btn($btn) {
@@ -39,20 +40,7 @@ function __wx_attach_live_download_dropdown_menu(trigger) {
const dropdown$ = DropdownMenu({
$trigger: trigger,
zIndex: 99999,
children: [
MenuItem({
label: "更多",
submenu: submenu$,
onMouseEnter() {
submenu$.show();
},
onMouseLeave() {
if (!submenu$.isHover) {
submenu$.hide();
}
},
}),
],
children: [],
onMouseEnter() {
if (submenu$.isOpen) {
submenu$.hide();
@@ -69,6 +57,7 @@ function __wx_attach_live_download_dropdown_menu(trigger) {
}
(() => {
insert_channels_style();
var error_tip_timer = setTimeout(() => {
WXU.error({ msg: "没有捕获到视频详情", alert: 0 });
}, 5000);
@@ -95,7 +84,6 @@ function __wx_attach_live_download_dropdown_menu(trigger) {
__wx_copy_live_download_command(profile.url);
};
var success = await __wx_insert_live_download_btn($btn);
console.log("[live.js]insert btn success", success);
if (!success) {
return;
}
@@ -111,22 +99,25 @@ function __wx_attach_live_download_dropdown_menu(trigger) {
var options = i[1].payload.channelParams.cdn_trans_info.filter(
(vv) => vv.url
);
var [dropdown$, submenu$] = __wx_attach_live_download_dropdown_menu($btn);
dropdown$.ui.$trigger.onMouseEnter(() => {
const download_menus = [
...(() => {
return options.map((opt) => {
return MenuItem({
label: opt.tag_name,
onClick() {
__wx_copy_live_download_command(opt.url);
dropdown$.hide();
},
});
var [dropdown$] = __wx_attach_live_download_dropdown_menu($btn);
const download_menus = [
...(() => {
return options.map((opt) => {
var level_desc = opt.video_quality_level_desc
? `<div style="inline-block;margin-left: 4px;">(${opt.video_quality_level_desc})</div>`
: "";
return MenuItem({
label: `<div class="flex"><div style="inline-block;width: 56px;">${opt.tag_name}</div><div style="inline-block;width: 32px;">${opt.rate}</div>${level_desc}</div>`,
onClick() {
__wx_copy_live_download_command(opt.url);
dropdown$.hide();
},
});
})(),
];
submenu$.setChildren(download_menus);
});
})(),
];
dropdown$.setChildren(download_menus);
dropdown$.ui.$trigger.onMouseEnter(() => {
dropdown$.show();
});
}
+1
View File
@@ -483,6 +483,7 @@ function __wx_render_sider_tools() {
}
(() => {
insert_channels_style();
var error_tip_timer = setTimeout(() => {
WXU.error({ msg: "没有获取到视频详情", alert: 0 });
}, 5000);
@@ -0,0 +1,266 @@
(() => {
var style = document.createElement("style");
style.textContent = `
#wechat-tools-container {
position: fixed;
top: 12px;
right: 12px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
width: 160px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
#__wx_channels_credentials__,
#__wx_channels_curl__,
#__wx_channels_api__ {
padding: 12px;
background-color: var(--weui-BG-2, #fff);
color: var(--weui-FG-0, #000);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
font-size: 11px;
line-height: 1.4;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
#__wx_channels_credentials__:hover,
#__wx_channels_curl__:hover,
#__wx_channels_api__:hover {
opacity: 1;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
@media (prefers-color-scheme: dark) {
#__wx_channels_credentials__,
#__wx_channels_curl__,
#__wx_channels_api__ {
background-color: var(--weui-BG-2, #2c2c2c);
color: var(--weui-FG-0, #fff);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
}
`;
function insert_style() {
document.head.appendChild(style);
}
async function submit_credential(acct) {
if (!acct.biz || !acct.key) {
return;
}
WXU.emit(WXU.Events.OfficialAccountRefresh, acct);
var origin = `https://${FakeAPIServerAddr}`;
var [err, res] = await WXU.request({
method: "POST",
url: `${origin}/api/mp/refresh?token=${
WXU.config.officialServerRefreshToken ?? ""
}`,
body: acct,
});
if (err) {
WXU.error({
msg: err.message,
});
return;
}
}
async function handle_api_call(msg, socket) {
var { id, key, data } = msg;
function resp(body) {
socket.send(
JSON.stringify({
id,
data: body,
})
);
}
if (key === "key:fetch_account_home") {
var [error, res] = await fetchAccountHome(data);
if (error) {
resp({
errCode: 1001,
errMsg: error.message,
});
return;
}
resp({
errCode: 0,
data: res,
});
return;
}
resp({
errCode: 1000,
errMsg: "未匹配的key",
payload: msg,
});
}
function connect(acct) {
return new Promise((resolve, reject) => {
const protocol = "wss://";
const ws = new WebSocket(protocol + FakeAPIServerAddr + "/ws/mp");
let ping_timer = null;
ws.onopen = () => {
WXU.log({
msg: "ws/mp connected",
});
submit_credential(acct);
var page_title = document.title || acct.nickname || "公众号页面";
try {
ws.send(
JSON.stringify({
type: "ping",
data: page_title,
})
);
} catch (e) {
// ...
}
ping_timer = setInterval(() => {
console.log("[]ping");
if (ws.readyState === 1) {
try {
ws.send(
JSON.stringify({
type: "ping",
data: page_title,
})
);
} catch (e) {
// ...
}
}
}, 5 * 1000);
resolve(true);
};
ws.onclose = () => {
console.log("ws/mp disconnected");
if (ping_timer) {
clearInterval(ping_timer);
ping_timer = null;
}
};
ws.onerror = (e) => {
console.error("ws/mp error", e);
reject(e);
};
ws.onmessage = (ev) => {
const [err, msg] = WXU.parseJSON(ev.data);
if (err) {
return;
}
if (msg.type === "api_call") {
handle_api_call(msg.data, ws);
}
};
});
}
async function fetchAccountHome(params) {
console.log("[]fetchAccountHome", params);
return new Promise((resolve) => {
window.location.href = params.refresh_uri;
resolve([null, params.refresh_uri]);
});
}
function render_rss_button(acct) {
var $btn = document.createElement("div");
$btn.style.cssText = `position: relative; top: -3px; width: 16px; height: 16px; margin-left: 6px; cursor: pointer;`;
$btn.innerHTML = RSSIcon;
$btn.onclick = function () {
var origin = `${WXU.config.officialRemoteServerProtocol}://${WXU.config.officialRemoteServerHostname}`;
if (WXU.config.officialRemoteServerPort != 80) {
origin += `:${WXU.config.officialRemoteServerPort}`;
}
var url = `${origin}/rss/mp?biz=${acct.biz}`;
WXU.copy(url);
WXU.toast("RSS 地址已复制");
};
return $btn;
}
function insert_rss_button(acct) {
if (!acct.biz || !acct.key) {
return;
}
var $wraps = document.querySelectorAll(".wx_follow_media");
var $container = $wraps[$wraps.length - 1];
console.log("$container", $container);
var $btn = render_rss_button(acct);
$container.appendChild($btn);
}
insert_style();
WXU.onWindowLoaded(() => {
async function main() {
if (location.pathname === "/s") {
var _OfficialAccountCredentials = {
nickname: (() => {
if (window.nickname) {
return window.nickname;
}
if (window.cgiData) {
if (window.cgiData.nick_name) {
return window.cgiData.nick_name;
}
}
if (window.cgiDataNew) {
if (window.cgiDataNew.nick_name) {
return window.cgiDataNew.nick_name;
}
}
return "";
})(),
avatar_url: (() => {
if (window.headimg) {
return window.headimg;
}
if (window.cgiData) {
if (window.cgiData.round_head_img) {
return window.cgiData.round_head_img;
}
if (window.cgiData.hd_head_img) {
return window.cgiData.hd_head_img;
}
}
if (window.cgiDataNew) {
if (window.cgiDataNew.round_head_img) {
return window.cgiDataNew.round_head_img;
}
if (window.cgiDataNew.hd_head_img) {
return window.cgiDataNew.hd_head_img;
}
}
return "";
})(),
biz: window.biz || window.__biz,
uin: window.uin,
key: window.key,
refresh_uri: (() => {
const params = new URLSearchParams(window.location.search);
const biz = params.get("__biz");
const mid = params.get("mid");
const idx = params.get("idx");
const sn = params.get("sn");
return `https://mp.weixin.qq.com/s?__biz=${biz}&mid=${mid}&idx=${idx}&sn=${sn}`;
})(),
pass_ticket: window.pass_ticket,
appmsg_token: window.appmsg_token,
};
if (!WXU.config.officialServerDisabled) {
connect(_OfficialAccountCredentials);
}
WXU.observe_node(".wx_follow_media", () => {
setTimeout(() => {
insert_rss_button(_OfficialAccountCredentials);
}, 1200);
});
}
}
main();
});
})();
+14 -2
View File
@@ -2,6 +2,7 @@
* @file 用户主页
*/
(() => {
insert_channels_style();
var my_username = "";
function __wx_insert_batch_download_btn() {
const $operation = document.querySelector(".opr-area");
@@ -20,7 +21,7 @@
$loading.className = "weui-loading";
$btn.prepend($loading);
const stopLoading = () => {
const stop_loading = () => {
$btn.classList.remove("weui-btn_loading");
$loading.remove();
};
@@ -52,6 +53,7 @@
});
return;
}
let download_open = false;
let next_marker = "";
let has_more = true;
while (has_more) {
@@ -84,6 +86,16 @@
has_more = false;
return;
}
if (data.ids.length === 0) {
WXU.toast("没有新的视频可以下载");
WXU.downloader.hide();
has_more = false;
return;
}
if (!download_open) {
download_open = true;
WXU.downloader.show();
}
if (
!r.data.lastBuffer ||
r.data.object.length < 15 ||
@@ -95,7 +107,7 @@
next_marker = r.data.lastBuffer;
}
} finally {
stopLoading();
stop_loading();
}
};
$operation.appendChild($btn);
+10 -6
View File
@@ -2,6 +2,7 @@
* @file 所有的工具函数 + API + 事件总线
*/
var FakeAPIServerAddr = "api.weixin.qq.com";
var FakeOfficialAccountServerAddr = "official.weixin.qq.com";
var __wx_channels_tip__ = {};
var __wx_channels_cur_video = null;
/** 全局的存储 */
@@ -183,6 +184,7 @@ var WXU = (() => {
* @param {LogMsg} params
*/
function __wx_log(params) {
console.log("[log]", params);
fetch("/__wx_channels_api/tip", {
method: "POST",
headers: {
@@ -413,7 +415,7 @@ var WXU = (() => {
var bitRate = wavView[34] + (wavView[35] << 8);
//搜索data块的位置
var dataPos = 0; // 44 或有更多块
for (var i = 12, iL = wavView.length - 8; i < iL;) {
for (var i = 12, iL = wavView.length - 8; i < iL; ) {
if (
wavView[i] == 100 &&
wavView[i + 1] == 97 &&
@@ -598,9 +600,9 @@ var WXU = (() => {
return WXAPI2;
},
downloader: {
show() { },
hide() { },
toggle() { },
show() {},
hide() {},
toggle() {},
/**
* 提交下载任务
* @param {FeedProfile} feed
@@ -849,7 +851,7 @@ var WXU = (() => {
observe_node(selector, cb) {
var $existing = document.querySelector(selector);
if ($existing) {
cb();
cb($existing);
return;
}
var observer = new MutationObserver((mutations, obs) => {
@@ -858,7 +860,9 @@ var WXU = (() => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
if (node.matches(selector) || node.querySelector(selector)) {
cb();
cb(
node.matches(selector) ? node : node.querySelector(selector)
);
if (document.querySelector(selector)) {
obs.disconnect();
}
@@ -1,291 +0,0 @@
(() => {
// Inject styles
var style = document.createElement("style");
style.textContent = `
#wechat-tools-container {
position: fixed;
top: 12px;
right: 12px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
width: 160px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
#__wx_channels_credentials__,
#__wx_channels_curl__,
#__wx_channels_api__ {
padding: 12px;
background-color: var(--weui-BG-2, #fff);
color: var(--weui-FG-0, #000);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
font-size: 11px;
line-height: 1.4;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
#__wx_channels_credentials__:hover,
#__wx_channels_curl__:hover,
#__wx_channels_api__:hover {
opacity: 1;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
@media (prefers-color-scheme: dark) {
#__wx_channels_credentials__,
#__wx_channels_curl__,
#__wx_channels_api__ {
background-color: var(--weui-BG-2, #2c2c2c);
color: var(--weui-FG-0, #fff);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
}
`;
document.head.appendChild(style);
var _AccountCredentials = {
biz: window.biz || window.__biz,
uin: window.uin,
key: window.key,
pass_ticket: window.pass_ticket,
appmsg_token: window.appmsg_token,
};
WXU.emit(WXU.Events.WechatArticleLoaded, _AccountCredentials);
var credentials_text = JSON.stringify(_AccountCredentials);
var $container = document.createElement("div");
$container.id = "wechat-tools-container";
document.body.appendChild($container);
var $credentials = document.createElement("div");
$credentials.id = "__wx_channels_credentials__";
$credentials.title = "点击复制";
$credentials.onclick = function () {
WXU.copy(credentials_text);
var originalText = $credentials.innerText;
var originalText = $curl.innerText;
$curl.innerText = "已复制";
setTimeout(() => {
$curl.innerText = originalText;
}, 1000);
};
$credentials.innerText = "复制 Credentials";
$container.appendChild($credentials);
// curl 'https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=MzI2NDk5NzA0Mw==&scene=124&uin=NzIyNzg0Mjg5&key=daf9bdc5abc4e8d024c44b5efab2e731529799575615bc0f3779046d2e448a5fbfc8116ff62eed74c88ca6a8d2f939a537357eedb5d2dbb6429c56a95d7622dba0fdd2127fa83d83026f13b21bb4dd9898ebd1b942ec07bfeed8227610582aa6ba7fa0f9885a8c65696c5eff942b63612c701cdaf3aaf403516892b09ef84007&devicetype=UnifiedPCWindows&version=f2541022&lang=zh_CN&a8scene=1&acctmode=0&pass_ticket=B1y03MI%2FZ0cmvxk5CbeZzFOmTluTZ4Jf7qqiCdF03vEV2rcwE%2Bf7ZoqXefD1hQlx' \
// -H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/wxpic,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \
// -H 'accept-language: en-US,en;q=0.9' \
// -H 'cache-control: max-age=0' \
// -b 'wxuin=722784289; lang=zh_CN; devicetype=android-29; version=28004252; pass_ticket=KRt7o2719r0AAXY6cqj5cHC9upd9CfwDFX5pgsRkjETR/gQaB70IJWNnwojs/2C; wap_sid2=CKGg09gCEooBeV9ISWN0SzZsQ0RuVEJJamRyMVRMV1ZfTlFyamc4WGZrNU9GbzBsYTZMeFVhQ3c3NlNhWG9RU0kwQkhPeUNQREk4UU9PUXhTR1JycDk5b2FYZTdCcVRKYW1BTXBPTUkySHczUzl4X0labUZnaHlBejA0ZHVVN2JrT010dzZiYXY3aTN5Z1RBQUF+MP/R38oGOA1AlU4=' \
// -H 'priority: u=0, i' \
// -H 'referer: https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=MzI2NDk5NzA0Mw==&scene=124&uin=NzIyNzg0Mjg5&key=daf9bdc5abc4e8d0ab3e68569bb12245a3296216bc0eae50ebe69fc80045755463f628a455f83131b3883d750897b91fedbe2bf7e0921959d20f14beef576cad06f0ce2bf01836180b24a26e721bd03165207c70fda4a658daf548febfb908045d02813d9c0304400b0cd6c451699bb1694d1bb84f2a76c226175c82c791f7da&devicetype=UnifiedPCWindows&version=f2541022&lang=zh_CN&a8scene=1&acctmode=0&pass_ticket=KRt7o2719r0AAXY6cqj5cHC9upd9CfwDFX5pgsRkjETR%2FgQa%2BB70IJWNnwojs%2F2C' \
// -H 'sec-fetch-dest: document' \
// -H 'sec-fetch-mode: navigate' \
// -H 'sec-fetch-site: none' \
// -H 'upgrade-insecure-requests: 1' \
// -H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 NetType/WIFI MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090a13) UnifiedPCWindowsWechat(0xf2541022) XWEB/16467 Flue'
var $curl = document.createElement("div");
$curl.id = "__wx_channels_curl__";
$curl.title = "点击复制";
var wxuin = "";
try {
wxuin = atob(_AccountCredentials.uin);
} catch (e) {
console.error("Failed to decode uin", e);
}
var wap_sid2 = "";
var match = document.cookie.match(/wap_sid2=([^;]+)/);
if (match) {
wap_sid2 = match[1];
}
var targetUrl = `https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=${_AccountCredentials.biz}&scene=124&uin=${_AccountCredentials.uin}&key=${_AccountCredentials.key}&devicetype=UnifiedPCWindows&version=f2541022&lang=zh_CN&a8scene=1&acctmode=0&pass_ticket=${_AccountCredentials.pass_ticket}`;
var curl_text = `curl '${targetUrl}' \\
-H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/wxpic,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \\
-H 'accept-language: en-US,en;q=0.9' \\
-H 'cache-control: max-age=0' \\
-b 'wxuin=${wxuin}; lang=zh_CN; devicetype=android-29; version=28004252; pass_ticket=${_AccountCredentials.pass_ticket}; wap_sid2=${wap_sid2}' \\
-H 'priority: u=0, i' \\
-H 'referer: ${targetUrl}' \\
-H 'sec-fetch-dest: document' \\
-H 'sec-fetch-mode: navigate' \\
-H 'sec-fetch-site: none' \\
-H 'upgrade-insecure-requests: 1' \\
-H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 NetType/WIFI MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090a13) UnifiedPCWindowsWechat(0xf2541022) XWEB/16467 Flue'`;
$curl.onclick = function () {
WXU.copy(curl_text);
var originalText = $curl.innerText;
$curl.innerText = "已复制";
setTimeout(() => {
$curl.innerText = originalText;
}, 1000);
};
$curl.innerText = "复制 CURL";
$container.appendChild($curl);
// https://mp.weixin.qq.com/mp/profile_ext?action=getmsg&__biz=MzI2NDk5NzA0Mw==&uin=NzIyNzg0Mjg5&key=daf9bdc5abc4e8d05e47caccaebcf6909badd928bd0b683914818f2ca40f5a86eab1665701a978c1ab058c4fa31f4ab97ed66b63a4b9027f6c72f20a60d8e97c9e9c68c667ad534815bd594d4afa2d7f3be1e2ccab3b42c141caf167a35d0f689138447980f87ad03e00ca5046cbdc9d9ae64729e1f5b51d289cc05d79d85c4f&pass_ticket=iP%2BaAr4MU15FR5JXi6pwetcx8cc9xuhukJ6dV20bSXw3UcPBttNEcJshLgow%2BE0G&wxtoken=&appmsg_token=1355_YzQa3bgKNZJNQKMl9f1P5XvRMmVE2oyzmICVsA~~&x5=0&count=10&offset=0&f=json
var $api = document.createElement("div");
$api.id = "__wx_channels_api__";
$api.title = "点击复制";
var apiUrl = `https://mp.weixin.qq.com/mp/profile_ext?action=getmsg&__biz=${_AccountCredentials.biz}&uin=${_AccountCredentials.uin}&key=${_AccountCredentials.key}&pass_ticket=${_AccountCredentials.pass_ticket}&wxtoken=&appmsg_token=${_AccountCredentials.appmsg_token}&x5=0&count=10&offset=0&f=json`;
$api.onclick = function () {
WXU.copy(apiUrl);
var originalText = $api.innerText;
$api.innerText = "已复制";
setTimeout(() => {
$api.innerText = originalText;
}, 1000);
};
$api.innerText = "复制 API";
$container.appendChild($api);
var API_PREFIX = "https://mp.weixin.qq.com";
async function handle_api_call(msg, socket) {
var { id, key, data } = msg;
function resp(body) {
socket.send(
JSON.stringify({
id,
data: body,
})
);
}
if (key === "/api/official_account/fetch_msg_list") {
try {
const res = await fetchMsgList(data);
resp({
errCode: 0,
data: res,
});
} catch (err) {
resp({
errCode: 1001,
errMsg: err.message,
});
}
return;
}
if (key === "/api/official_account/fetch_account_home") {
try {
const res = await fetchAccountHome(data);
resp({
errCode: 0,
data: res,
});
} catch (err) {
resp({
errCode: 1001,
errMsg: err.message,
});
}
return;
}
resp({
errCode: 1000,
errMsg: "未匹配的key",
payload: msg,
});
}
function connect() {
return new Promise((resolve, reject) => {
const protocol = "ws://";
const pathname = WXU.config.apiServerAddr;
const ws = new WebSocket(protocol + pathname + "/ws/mp");
ws.onopen = () => {
console.log("ws/mp connected");
resolve(true);
};
ws.onclose = () => {
console.log("ws/mp disconnected");
};
ws.onerror = (e) => {
console.error("ws/mp error", e);
reject(e);
};
ws.onmessage = (ev) => {
const [err, msg] = WXU.parseJSON(ev.data);
if (err) {
return;
}
if (msg.type === "api_call") {
handle_api_call(msg.data, ws);
}
};
});
}
connect();
async function fetchMsgList(params) {
console.log("fetchMsgList params", params);
var query = {
action: "getmsg",
__biz: params.biz,
uin: params.uin,
key: _AccountCredentials.key,
pass_ticket: _AccountCredentials.pass_ticket,
wxtoken: "",
appmsg_token: _AccountCredentials.appmsg_token,
x5: 0,
count: params.count ?? 10,
offset: params.offset ?? 0,
f: "json",
};
var endpoint =
window.location.origin +
"/mp/profile_ext?" +
Object.keys(query)
.map((key) => `${key}=${query[key]}`)
.join("&");
WXU.log({
msg: endpoint,
});
var resp = await fetch(endpoint, {
credentials: "include",
});
var res = await resp.json();
console.log("fetchMsgList res", res.data);
return res;
}
async function fetchAccountHome(params) {
console.log("fetchAccountHome params", params);
var query = {
action: "home",
__biz: params.biz,
scene: 124,
};
var endpoint =
window.location.origin +
"/mp/profile_ext?" +
Object.keys(query)
.map((key) => `${key}=${query[key]}`)
.join("&");
WXU.log({
msg: endpoint,
});
var resp = await fetch(endpoint, {
credentials: "include",
});
console.log("fetchAccountHome res", resp.data);
return resp;
}
})();
+1
View File
@@ -25,6 +25,7 @@ type Interceptor struct {
PostPlugins []interface{} // echo 的插件,将在 echo 初始化后传给 echo
FrontendVariables map[string]any // 前端额外的全局变量
log *zerolog.Logger
OnCookies func(url string, cookies []*http.Cookie) // 捕获到 cookie 时的回调
}
func NewInterceptor(cfg *InterceptorConfig, cert *certificate.CertFileAndKeyFile) *Interceptor {
+48 -41
View File
@@ -54,20 +54,23 @@ func CreateChannelInterceptorPlugin(interceptor *Interceptor, files *ChannelInje
Match: "qq.com",
OnRequest: func(ctx proxy.Context) {
pathname := ctx.Req().URL.Path
if util.Includes(pathname, "jszip.min") {
if strings.Contains(pathname, "jszip.min") {
ctx.Mock(200, map[string]string{
"Content-Type": "application/javascript",
}, string(files.JSZip))
return
}
if util.Includes(pathname, "FileSaver.min") {
if strings.Contains(pathname, "FileSaver.min") {
ctx.Mock(200, map[string]string{
"Content-Type": "application/javascript",
}, string(files.JSFileSaver))
return
}
if util.Includes(pathname, "recorder.min") {
if strings.Contains(pathname, "recorder.min") {
ctx.Mock(200, map[string]string{
"Content-Type": "application/javascript",
}, string(files.JSRecorder))
return
}
if pathname == "/__wx_channels_api/profile" {
var data ChannelMediaProfile
@@ -78,6 +81,7 @@ func CreateChannelInterceptorPlugin(interceptor *Interceptor, files *ChannelInje
ctx.Mock(200, map[string]string{
"Content-Type": "application/json",
}, "{}")
return
}
if pathname == "/__wx_channels_api/tip" {
var data FrontendTip
@@ -101,6 +105,7 @@ func CreateChannelInterceptorPlugin(interceptor *Interceptor, files *ChannelInje
ctx.Mock(200, map[string]string{
"Content-Type": "application/json",
}, "{}")
return
}
if pathname == "/__wx_channels_api/error" {
var data FrontendErrorTip
@@ -112,14 +117,53 @@ func CreateChannelInterceptorPlugin(interceptor *Interceptor, files *ChannelInje
ctx.Mock(200, map[string]string{
"Content-Type": "application/json",
}, "{}")
return
}
},
OnResponse: func(ctx proxy.Context) {
resp_content_type := strings.ToLower(ctx.GetResponseHeader("Content-Type"))
hostname := ctx.Req().URL.Hostname()
pathname := ctx.Req().URL.Path
if hostname == "mp.weixin.qq.com" && strings.Contains(resp_content_type, "text/html") {
resp_body, err := ctx.GetResponseBody()
if err != nil {
return
}
html := string(resp_body)
csp := ctx.GetResponseHeader("Content-Security-Policy-Report-Only")
script_attr := ""
if match := cspNonceReg.FindStringSubmatch(csp); len(match) > 1 {
script_attr = fmt.Sprintf(` nonce="%s" reportloaderror`, match[1])
}
inserted_scripts := ""
cfg_byte, _ := json.Marshal(cfg)
script_config := fmt.Sprintf(`<script%s>var __wx_channels_config__ = %s; var __wx_channels_version__ = "%s";</script>`, script_attr, string(cfg_byte), version)
inserted_scripts += script_config
variable_byte, _ := json.Marshal(variables)
script_variable := fmt.Sprintf(`<script%s>var WXVariable = %s;</script>`, script_attr, string(variable_byte))
inserted_scripts += script_variable
inserted_scripts += fmt.Sprintf(`<script%s>%s</script>`, script_attr, files.JSMitt)
inserted_scripts += fmt.Sprintf(`<script%s>%s</script>`, script_attr, files.JSEventBus)
inserted_scripts += fmt.Sprintf(`<script%s>%s</script>`, script_attr, files.JSUtils)
inserted_scripts += fmt.Sprintf(`<script%s>%s</script>`, script_attr, files.JSComponents)
inserted_scripts += fmt.Sprintf(`<script%s>%s</script>`, script_attr, files.JSWechatOfficialAccount)
if cfg.DebugShowError {
/** 全局错误捕获并展示弹窗 */
script_error := fmt.Sprintf(`<script%s>%s</script>`, script_attr, files.JSError)
inserted_scripts += script_error
}
if cfg.PagespyEnabled {
/** 在线调试 */
script_pagespy := fmt.Sprintf(`<script%s>%s</script>`, script_attr, files.JSPageSpy)
script_pagespy2 := fmt.Sprintf(`<script%s>%s</script>`, script_attr, files.JSDebug)
inserted_scripts += script_pagespy + script_pagespy2
}
html = strings.Replace(html, "</body>", inserted_scripts+"</body>", 1)
ctx.SetResponseBody(html)
return
}
// fmt.Println("response", hostname, pathname, resp_content_type, ctx.Res().StatusCode)
if cfg.ChannelDisableLocationToHome && pathname == "/web/pages/feed" && ctx.Res().StatusCode == 302 {
if pathname == "/web/pages/feed" && cfg.ChannelsDisableLocationToHome && ctx.Res().StatusCode == 302 {
original_req := ctx.Req()
u := &url.URL{Scheme: "https", Host: original_req.URL.Hostname(), Path: pathname, RawQuery: original_req.URL.RawQuery}
q := u.Query()
@@ -155,43 +199,6 @@ func CreateChannelInterceptorPlugin(interceptor *Interceptor, files *ChannelInje
}
}
}
if hostname == "mp.weixin.qq.com" && strings.Contains(resp_content_type, "text/html") {
resp_body, err := ctx.GetResponseBody()
if err != nil {
return
}
csp := ctx.GetResponseHeader("Content-Security-Policy-Report-Only")
scriptAttr := ""
if match := cspNonceReg.FindStringSubmatch(csp); len(match) > 1 {
scriptAttr = fmt.Sprintf(` nonce="%s" reportloaderror`, match[1])
}
html := string(resp_body)
inserted_scripts := ""
cfg_byte, _ := json.Marshal(cfg)
script_config := fmt.Sprintf(`<script%s>var __wx_channels_config__ = %s; var __wx_channels_version__ = "%s";</script>`, scriptAttr, string(cfg_byte), version)
inserted_scripts += script_config
variable_byte, _ := json.Marshal(variables)
script_variable := fmt.Sprintf(`<script%s>var WXVariable = %s;</script>`, scriptAttr, string(variable_byte))
inserted_scripts += script_variable
inserted_scripts += fmt.Sprintf(`<script%s>%s</script>`, scriptAttr, files.JSMitt)
inserted_scripts += fmt.Sprintf(`<script%s>%s</script>`, scriptAttr, files.JSEventBus)
inserted_scripts += fmt.Sprintf(`<script%s>%s</script>`, scriptAttr, files.JSUtils)
inserted_scripts += fmt.Sprintf(`<script%s>%s</script>`, scriptAttr, files.JSWechatOfficialAccount)
if cfg.DebugShowError {
/** 全局错误捕获并展示弹窗 */
script_error := fmt.Sprintf(`<script%s>%s</script>`, scriptAttr, files.JSError)
inserted_scripts += script_error
}
if cfg.PagespyEnabled {
/** 在线调试 */
script_pagespy := fmt.Sprintf(`<script%s>%s</script>`, scriptAttr, files.JSPageSpy)
script_pagespy2 := fmt.Sprintf(`<script%s>%s</script>`, scriptAttr, files.JSDebug)
inserted_scripts += script_pagespy + script_pagespy2
}
html = strings.Replace(html, "</body>", inserted_scripts+"</body>", 1)
ctx.SetResponseBody(html)
return
}
if hostname == "channels.weixin.qq.com" && strings.Contains(resp_content_type, "text/html") {
resp_body, err := ctx.GetResponseBody()
if err != nil {
+4 -3
View File
@@ -41,9 +41,10 @@ type contextImpl struct {
impl any
}
type ContextReq struct {
URL *ContextURL
Body io.Reader
Header http.Header
URL *ContextURL
Body io.Reader
Header http.Header
Cookies []*http.Cookie
}
type ContextURL struct {
Path string
+14 -14
View File
@@ -11,24 +11,24 @@ import (
// HTTPServer 实现
type HTTPServer struct {
title string
key string
addr string
status ServerStatus
mux http.Handler
server *http.Server
disabled bool
mu sync.RWMutex
stopChan chan struct{}
title string
key string
addr string
status ServerStatus
mux http.Handler
server *http.Server
disabled bool
mu sync.RWMutex
stop_chan chan struct{}
}
func NewHTTPServer(title string, key string, addr string) *HTTPServer {
return &HTTPServer{
title: title,
key: key,
addr: addr,
status: StatusStopped,
stopChan: make(chan struct{}),
title: title,
key: key,
addr: addr,
status: StatusStopped,
stop_chan: make(chan struct{}),
}
}
+8
View File
@@ -0,0 +1,8 @@
package officialaccount
import (
_ "embed"
)
//go:embed ui/manager.html
var manager_html []byte
File diff suppressed because it is too large. Load diff
+48
View File
@@ -0,0 +1,48 @@
package officialaccount
import (
"strconv"
"github.com/spf13/viper"
"wx_channel/internal/config"
)
type OfficialAccountConfig struct {
RootDir string
DisableServer bool // 本地不启用服务
Protocol string
Hostname string
Port int
Addr string
RemoteMode bool
RemoteServerProtocol string
RemoteServerHostname string
RemoteServerPort int
RefreshToken string
TokenFilepath string
RefreshSkipMinutes int
MaxWebsocketClients int
}
func NewOfficialAccountConfig(c *config.Config, remote_mode bool) *OfficialAccountConfig {
protocol := viper.GetString("mp.protocol")
hostname := viper.GetString("mp.hostname")
port := viper.GetInt("mp.port")
cfg := &OfficialAccountConfig{
RootDir: c.RootDir,
Protocol: protocol,
Hostname: hostname,
Port: port,
Addr: hostname + ":" + strconv.Itoa(port),
RefreshToken: viper.GetString("mp.refreshToken"),
TokenFilepath: viper.GetString("mp.tokenFilepath"),
RemoteMode: remote_mode,
RemoteServerProtocol: viper.GetString("mp.remoteServer.protocol"),
RemoteServerHostname: viper.GetString("mp.remoteServer.hostname"),
RemoteServerPort: viper.GetInt("mp.remoteServer.port"),
RefreshSkipMinutes: viper.GetInt("mp.refreshSkipMinutes"),
MaxWebsocketClients: viper.GetInt("mp.maxWebsocketClients"),
}
return cfg
}
+49
View File
@@ -0,0 +1,49 @@
package officialaccount
import "encoding/xml"
type AtomAuthor struct {
Name string `xml:"name"`
URI string `xml:"uri"`
}
type AtomLink struct {
Rel string `xml:"rel,attr"`
Href string `xml:"href,attr"`
}
type AtomContent struct {
Type string `xml:"type,attr"`
Body string `xml:",chardata"`
}
type MediaThumbnail struct {
XMLName xml.Name `xml:"media:thumbnail"`
XMLNSMedia string `xml:"xmlns:media,attr"`
URL string `xml:"url,attr"`
Width int `xml:"width,attr,omitempty"`
Height int `xml:"height,attr,omitempty"`
}
type AtomEntry struct {
ID string `xml:"id"`
Title string `xml:"title"`
Updated string `xml:"updated"`
Published string `xml:"published"`
Author AtomAuthor `xml:"author"`
Link []AtomLink `xml:"link"`
Content AtomContent `xml:"content"`
Summary AtomContent `xml:"summary"`
MediaThumbnail *MediaThumbnail `xml:"media:thumbnail"`
}
type AtomCategory struct {
Term string `xml:"term,attr"`
}
type AtomFeed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
Title string `xml:"title"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Generator string `xml:"generator"`
Icon string `xml:"icon"`
Category []AtomCategory `xml:"category"`
Link []AtomLink `xml:"link"`
Author AtomAuthor `xml:"author"`
Entry []AtomEntry `xml:"entry"`
}
+777
View File
@@ -0,0 +1,777 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>公众号RSS管理</title>
<style>
:root {
color-scheme: dark light
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
background: #0b0c0f;
color: #e6e6e6
}
.container {
max-width: 1200px;
margin: 24px auto;
padding: 0 16px
}
h1 {
margin: 0 0 12px;
font-size: 22px
}
section {
background: #14171f;
padding: 16px;
border-radius: 10px;
margin-bottom: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, .25)
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px
}
th,
td {
padding: 6px 8px;
border-bottom: 1px solid rgba(255, 255, 255, .06);
text-align: left
}
th {
color: #a8b3cf;
font-weight: 600
}
a {
color: #8ab4f8;
text-decoration: none
}
a:hover {
text-decoration: underline
}
.w-80 {
width: 80px;
}
.w-120 {
width: 120px;
}
.w-160 {
width: 160px;
}
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
font-size: 12px
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ok {
background: #19361a;
color: #8fce95
}
.bad {
background: #3a1919;
color: #f28b82
}
.warn {
background: #332b12;
color: #fdd663
}
.muted {
color: #9aa0a6
}
.grid {
display: grid;
grid-template-columns: 1fr 360px;
gap: 12px
}
.footer {
margin-top: 8px;
color: #9aa0a6;
font-size: 12px
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.toolbar-left,
.toolbar-right {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.input,
.select {
height: 28px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, .14);
background: #1b1f2a;
color: #e6e6e6;
font-size: 12px;
outline: none;
}
.input::placeholder {
color: rgba(230, 230, 230, .45)
}
.select {
padding-right: 26px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, .14);
background: #1b1f2a;
color: #e6e6e6;
font-size: 12px;
cursor: pointer;
}
.btn:hover {
background: #222836
}
.btn:disabled {
opacity: .6;
cursor: not-allowed
}
.btn-primary {
border-color: rgba(138, 180, 248, .3);
background: #223049;
color: #cfe1ff;
}
.btn-sm {
height: 24px;
padding: 0 8px;
font-size: 12px
}
.btn-fixed-width {
min-width: 60px;
}
.col-action {
width: 180px;
}
.nowrap {
white-space: nowrap
}
.col-title {
min-width: 160px
}
.error-text {
margin-top: 4px;
color: #f28b82;
font-size: 12px;
word-break: break-word;
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: #222836;
object-fit: cover;
flex: 0 0 auto;
}
.avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
display: block;
}
.row-missing td {
background: rgba(253, 214, 99, .06);
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
visibility: hidden;
opacity: 0;
transition: all 0.2s;
}
.modal-overlay.visible {
visibility: visible;
opacity: 1;
}
.modal {
background: #1b1f2a;
border-radius: 12px;
padding: 24px;
width: 400px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-bar-bg {
height: 8px;
background: #2a2f3e;
border-radius: 4px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-bar-fill {
height: 100%;
background: #8ab4f8;
width: 0%;
transition: width 0.3s ease;
}
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.stat-item {
background: rgba(255, 255, 255, 0.05);
padding: 12px;
border-radius: 8px;
text-align: center;
}
.stat-val {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: #9aa0a6;
}
.text-success { color: #8fce95; }
.text-error { color: #f28b82; }
</style>
<script>
const REMOTE_SERVER = "%%REMOTE_SERVER%%";
const TOKEN = "%%TOKEN%%";
const REMOTE_MODE = "%%REMOTE_MODE%%" === "1";
let ACCOUNTS_CACHE = [];
let LIST_STATE = { page: 1, pageSize: 10, keyword: "", isEffective: "" };
function proxy(u) {
if (!u) return "";
const t = TOKEN ? "&token=" + encodeURIComponent(TOKEN) : "";
return "/mp/proxy?url=" + encodeURIComponent(u) + t;
}
function fmtTime(ts) {
if (!ts) return "-";
const d = new Date(ts * 1000);
const pad = (n) => String(n).padStart(2, '0');
const y = d.getFullYear();
const m = pad(d.getMonth() + 1);
const day = pad(d.getDate());
const hh = pad(d.getHours());
const mm = pad(d.getMinutes());
const ss = pad(d.getSeconds());
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
}
function timeSince(ts) {
if (!ts) return "-";
const diff = Math.floor(Date.now() / 1000 - ts);
if (diff < 60) return diff + "s";
const m = Math.floor(diff / 60);
if (m < 60) return m + "m";
const h = Math.floor(m / 60);
return h + "h";
}
function buildListUrl(params) {
const sp = new URLSearchParams();
if (TOKEN) sp.set("token", TOKEN);
sp.set("page", String(params.page || 1));
sp.set("page_size", String(params.pageSize || 10));
if (params.keyword && String(params.keyword).trim()) {
sp.set("keyword", String(params.keyword).trim());
}
return "/api/mp/list?" + sp.toString();
}
async function fetchAccountPage(params) {
const r = await fetch(buildListUrl(params));
const j = await r.json();
if (j.code !== 0) {
throw new Error(j.msg || "接口错误");
}
const data = j.data || {};
const list = (data && data.list) || [];
return {
list: Array.isArray(list) ? list : [],
total: Number.isFinite(Number(data.total)) ? Number(data.total) : 0,
page: Number.isFinite(Number(data.page)) ? Number(data.page) : (params.page || 1),
pageSize: Number.isFinite(Number(data.page_size)) ? Number(data.page_size) : (params.pageSize || 10),
keyword: (data && typeof data.keyword === "string") ? data.keyword : (params.keyword || ""),
};
}
function syncControlsFromState() {
const kw = document.getElementById("account-keyword");
if (kw && kw.value !== LIST_STATE.keyword) kw.value = LIST_STATE.keyword || "";
const eff = document.getElementById("account-is-effective");
if (eff && eff.value !== LIST_STATE.isEffective) eff.value = LIST_STATE.isEffective || "";
const ps = document.getElementById("page-size");
if (ps && String(ps.value) !== String(LIST_STATE.pageSize)) ps.value = String(LIST_STATE.pageSize);
}
function setPaginationInfo(page, pageSize, total) {
const totalPages = Math.max(1, Math.ceil((total || 0) / (pageSize || 10)));
const info = document.getElementById("page-info");
if (info) info.textContent = `第 ${page} / ${totalPages} 页`;
const prev = document.getElementById("btn-prev");
const next = document.getElementById("btn-next");
if (prev) prev.disabled = page <= 1;
if (next) next.disabled = page >= totalPages;
}
async function loadAccounts(partial) {
try {
LIST_STATE = Object.assign({}, LIST_STATE, partial || {});
if (!Number.isFinite(Number(LIST_STATE.page)) || LIST_STATE.page < 1) LIST_STATE.page = 1;
if (!Number.isFinite(Number(LIST_STATE.pageSize)) || LIST_STATE.pageSize <= 0) LIST_STATE.pageSize = 10;
LIST_STATE.pageSize = Number(LIST_STATE.pageSize);
LIST_STATE.page = Number(LIST_STATE.page);
LIST_STATE.keyword = String(LIST_STATE.keyword || "").trim();
LIST_STATE.isEffective = String(LIST_STATE.isEffective || "");
syncControlsFromState();
const data = await fetchAccountPage(LIST_STATE);
ACCOUNTS_CACHE = data.list;
const countEl = document.getElementById("account-count");
if (countEl) countEl.textContent = `(${data.total})`;
setPaginationInfo(data.page, data.pageSize, data.total);
const tbody = document.querySelector("#accounts tbody");
tbody.innerHTML = "";
for (const it of data.list) {
const rss = REMOTE_SERVER ? `${REMOTE_SERVER}/rss/mp?biz=${encodeURIComponent(it.biz)}${TOKEN ? `&token=${encodeURIComponent(TOKEN)}` : ''}` : "-";
const tr = document.createElement("tr");
const hasRefreshUri = !!(it && it.refresh_uri && String(it.refresh_uri).trim());
if (!REMOTE_MODE && !hasRefreshUri) tr.className = "row-missing";
const eff = it.is_effective ? '<span class="pill ok nowrap">有效</span>' : '<span class="pill bad nowrap">失效</span>';
const errMsg = String(it.error || "").trim();
// const errPill = errMsg ? '<span class="pill bad">出错</span>' : '<span class="pill ok">正常</span>';
const errText = errMsg ? `<div class="error-text nowrap">${errMsg}</div>` : '';
const available = (it.is_effective && !it.error) ? '<span class="pill ok nowrap">可用</span>' : '<span class="pill warn nowrap">不可用</span>';
const missingPill = (!REMOTE_MODE && !hasRefreshUri) ? '<span class="pill warn nowrap">无 refresh_uri</span>' : '';
const avatar = `<div class="avatar">${it.avatar_url ? `<img src="${proxy(it.avatar_url)}" alt="" onerror="this.remove()">` : ''}</div>`;
const colName = `
<div class="row">
${avatar}
<div>
<div class="nowrap">${it.nickname || ""}</div>
<div class="muted"><div>${timeSince(it.update_time || 0)} ago</div><div class="nowrap">${fmtTime(it.created_at || 0)}</div></div>
</div>
</div>
`;
const colId = `
<div class="nowrap">${it.biz || ""}</div>
${rss && rss !== "-" ? `<div><a href="${rss}" target="_blank">${rss}</a></div>` : ""}
`;
const colStatus = REMOTE_MODE ? `${available}` : `${eff} ${missingPill}${errText}`;
const colRefresh = REMOTE_MODE ? '' : `<button class="btn btn-sm nowrap" onclick="refreshOne(this, '${it.biz || ""}')">刷新</button>`;
const colDelete = REMOTE_MODE ? '' : `<button class="btn btn-sm nowrap" style="margin-left: 4px; border-color: rgba(242, 139, 130, .3); color: #f28b82;" onclick="deleteAccount(this, '${it.biz || ""}')">删除</button>`;
tr.innerHTML = `
<td>${colName}</td>
<td>${colId}</td>
<td>${colStatus}</td>
${REMOTE_MODE ? '' : `<td class="col-action">${colRefresh}${colDelete}</td>`}
`;
tbody.appendChild(tr);
}
} catch (e) {
console.error(e);
alert(String(e && e.message ? e.message : e));
}
}
function applySearch() {
const kw = document.getElementById("account-keyword");
const keyword = kw ? String(kw.value || "").trim() : "";
loadAccounts({ page: 1, keyword });
}
function clearSearch() {
const kw = document.getElementById("account-keyword");
if (kw) kw.value = "";
loadAccounts({ page: 1, keyword: "" });
}
function changePageSize(v) {
const next = parseInt(String(v || "10"), 10);
loadAccounts({ page: 1, pageSize: Number.isFinite(next) && next > 0 ? next : 10 });
}
function prevPage() {
loadAccounts({ page: Math.max(1, (LIST_STATE.page || 1) - 1) });
}
function nextPage() {
loadAccounts({ page: (LIST_STATE.page || 1) + 1 });
}
async function copyRefreshLinks(btn) {
if (REMOTE_MODE) return;
let links = [];
try {
const data = await fetchAccountPage({ page: 1, pageSize: 5, keyword: LIST_STATE.keyword || "" });
links = (data.list || [])
.map((it) => (it && it.refresh_uri ? String(it.refresh_uri).trim() : ""))
.filter(Boolean);
} catch (e) {
links = (Array.isArray(ACCOUNTS_CACHE) ? ACCOUNTS_CACHE : [])
.map((it) => (it && it.refresh_uri ? String(it.refresh_uri).trim() : ""))
.filter(Boolean)
.slice(0, 5);
}
if (!links.length) {
alert("列表中没有可复制的 refresh_uri");
return;
}
const text = links.join("\n");
const old = btn ? btn.textContent : "";
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
ta.style.top = "-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
if (!ok) throw new Error("复制失败");
}
if (btn) btn.textContent = "已复制";
setTimeout(() => {
if (btn) btn.textContent = old;
}, 1200);
} catch (e) {
alert(String(e && e.message ? e.message : e));
}
}
function closeProgressModal() {
const modal = document.getElementById("progress-modal");
if (modal) modal.classList.remove("visible");
}
let manageWs = null;
function initManageWebSocket() {
if (manageWs) return;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = proto + "//" + window.location.host + "/ws/manage";
manageWs = new WebSocket(url);
manageWs.onopen = () => {
console.log("Manage WS connected");
// Send a ping every 30s to keep alive
setInterval(() => {
if (manageWs.readyState === WebSocket.OPEN) {
manageWs.send(JSON.stringify({ type: "ping", data: "manager" }));
}
}, 30000);
};
manageWs.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "refresh_progress") {
updateProgressModal(msg.data);
}
} catch (e) {
console.error("WS message error", e);
}
};
manageWs.onclose = () => {
console.log("Manage WS closed, retrying in 5s...");
manageWs = null;
setTimeout(initManageWebSocket, 5000);
};
}
function updateProgressModal(data) {
const modal = document.getElementById("progress-modal");
if (!modal || !modal.classList.contains("visible")) return;
const total = data.total || 0;
const current = data.current || 0;
const success = data.success || 0;
const failed = data.failed || 0;
const percent = data.percent || 0;
document.getElementById("prog-success").textContent = success;
document.getElementById("prog-failed").textContent = failed;
document.getElementById("prog-fill").style.width = percent + "%";
document.getElementById("prog-percent").textContent = percent + "%";
document.getElementById("prog-text").textContent = `正在处理: ${current} / ${total}`;
if (current >= total && total > 0) {
document.getElementById("prog-text").textContent = "处理完成";
document.getElementById("modal-close-btn").style.visibility = "visible";
}
}
async function refreshAll(btn) {
// Show modal
const modal = document.getElementById("progress-modal");
if (modal) {
modal.classList.add("visible");
document.getElementById("prog-success").textContent = "0";
document.getElementById("prog-failed").textContent = "0";
document.getElementById("prog-fill").style.width = "0%";
document.getElementById("prog-percent").textContent = "0%";
document.getElementById("prog-text").textContent = "正在请求任务...";
document.getElementById("modal-close-btn").style.visibility = "hidden";
}
if (btn) {
btn.disabled = true;
var old = btn.textContent;
btn.textContent = "刷新中...";
}
try {
const r = await fetch("/api/mp/refresh_remote");
const j = await r.json().catch(() => ({}));
if (j && j.code !== undefined && j.code !== 0) {
alert(j.msg || "接口错误");
closeProgressModal();
}
} catch (e) {
alert(String(e && e.message ? e.message : e));
closeProgressModal();
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = old;
}
}
loadAccounts();
loadWsPool();
}
async function refreshOne(btn, biz) {
if (!biz) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = "刷新中";
try {
const url = "/api/mp/refresh_account_with_frontend?biz=" + encodeURIComponent(biz);
const r = await fetch(url);
const j = await r.json().catch(() => ({}));
if (j && j.code !== undefined && j.code !== 0) {
alert(j.msg || "接口错误");
}
} catch (e) {
alert(String(e && e.message ? e.message : e));
}
btn.disabled = false;
btn.textContent = old;
loadAccounts();
}
async function loadWsPool() {
try {
const r = await fetch("/api/mp/ws_pool");
const j = await r.json();
if (j.code !== 0) {
alert(j.msg || "接口错误");
return;
}
const list = (j.data && j.data.list) || [];
const tbody = document.querySelector("#ws tbody");
tbody.innerHTML = "";
for (const it of list) {
const healthy = (Math.floor(Date.now() / 1000) - (it.last_ping || 0)) <= 15 && it.available;
const tr = document.createElement("tr");
tr.innerHTML = `
<td class=""><div class="w-80 ellipsis">${it.title || ""}</div></td>
<td class="nowrap">
${healthy ? '<div class="pill ok nowrap">可用</div>' : '<div class="pill warn nowrap">不可用</div>'}
<div class="muted">${fmtTime(it.last_ping || 0)} (${timeSince(it.last_ping || 0)} ago)</div>
</td>
`;
tbody.appendChild(tr);
}
} catch (e) {
console.error(e);
alert(String(e && e.message ? e.message : e));
}
}
function bootstrap() {
loadAccounts();
if (!REMOTE_MODE) {
loadWsPool();
initManageWebSocket();
}
const grid = document.querySelector(".grid");
const toolbar = document.querySelector("#accounts .toolbar");
const actions = document.getElementById("toolbar-actions");
if (actions) actions.style.display = REMOTE_MODE ? "none" : "";
const wsSection = document.querySelector("#ws");
if (wsSection) {
wsSection.style.display = REMOTE_MODE ? "none" : "";
}
if (grid) {
grid.style.gridTemplateColumns = REMOTE_MODE ? "1fr" : "1fr 360px";
}
const theadRow = document.querySelector("#accounts thead tr");
if (theadRow && REMOTE_MODE) {
theadRow.innerHTML = `
<th>公众号</th>
<th>ID</th>
<th>是否可用</th>
`;
}
const kw = document.getElementById("account-keyword");
if (kw) {
kw.addEventListener("keydown", (e) => {
if (e.key === "Enter") applySearch();
});
}
}
document.addEventListener("DOMContentLoaded", bootstrap);
</script>
</head>
<body>
<div class="container">
<div class="grid">
<section id="accounts">
<h2>公众号列表 <span id="account-count" style="font-size: 14px; color: #9aa0a6; margin-left: 8px;"></span></h2>
<div class="toolbar">
<div class="toolbar-left" id="toolbar-actions">
<button class="btn btn-primary" onclick="refreshAll()">刷新全部</button>
<button class="btn" onclick="copyRefreshLinks(this)">复制刷新链接</button>
</div>
<div class="toolbar-right">
<select id="account-is-effective" class="select" style="width: 100px;">
<option value="">全部状态</option>
<option value="1">有效</option>
<option value="0">无效</option>
</select>
<input id="account-keyword" class="input" style="width: 220px" placeholder="搜索昵称 / biz" />
<button class="btn" onclick="applySearch()">搜索</button>
<button class="btn" onclick="clearSearch()">清空</button>
<select id="page-size" class="select" onchange="changePageSize(this.value)">
<option value="10">10/页</option>
<option value="20">20/页</option>
<option value="50">50/页</option>
<option value="100">100/页</option>
<option value="200">200/页</option>
</select>
<button id="btn-prev" class="btn btn-sm btn-fixed-width" onclick="prevPage()">上一页</button>
<span id="page-info" class="muted" style="align-self: center;"></span>
<button id="btn-next" class="btn btn-sm btn-fixed-width" onclick="nextPage()">下一页</button>
</div>
</div>
<table>
<thead>
<tr>
<th>公众号</th>
<th>ID</th>
<th class="w-120">状态</th>
<th class="w-160">操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<section id="ws">
<h2>WebSocket 池</h2>
<table>
<thead>
<tr>
<th class="w-80">标题</th>
<th>是否可用 / 最近心跳</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
</div>
</div>
<div id="progress-modal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<span>刷新进度</span>
<button class="btn btn-sm" onclick="closeProgressModal()" id="modal-close-btn" style="visibility: hidden;">关闭</button>
</div>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-val text-success" id="prog-success">0</div>
<div class="stat-label">成功</div>
</div>
<div class="stat-item">
<div class="stat-val text-error" id="prog-failed">0</div>
<div class="stat-label">失败</div>
</div>
</div>
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="prog-fill"></div>
</div>
<div style="display: flex; justify-content: space-between; font-size: 12px; color: #9aa0a6;">
<span id="prog-text">准备中...</span>
<span id="prog-percent">0%</span>
</div>
</div>
</div>
</body>
</html>
+152
View File
@@ -0,0 +1,152 @@
package officialaccount
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/gorilla/websocket"
)
type Client struct {
conn *websocket.Conn
send chan []byte
title string
available bool
last_ping int64
}
func (c *Client) write_pump() {
ticker := time.NewTicker(5 * time.Second)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func ValidateTokenFilepath(file_path string, root_dir string) (string, error) {
token_filepath := file_path
if file_path == "" {
return "", nil
}
if !filepath.IsAbs(file_path) {
token_filepath = filepath.Join(root_dir, file_path)
}
if _, err := os.Stat(token_filepath); err != nil {
return "", err
}
// script_byte, err := os.ReadFile(token_filepath)
// if err != nil {
// return err
// }
// settings.InjectExtraScriptAfterJSMain = string(script_byte)
return token_filepath, nil
}
type APIClientWSMessage struct {
Type string `json:"type"`
Data interface{} `json:"data"`
Error string `json:"error"`
}
type ClientWSMessage struct {
Type string `json:"type"`
Data string `json:"data"`
}
type ClientWebsocketRequestBody struct {
ID string `json:"id"`
Key string `json:"key"`
Body interface{} `json:"data"`
}
type ClientWebsocketResponse struct {
Id string `json:"id"`
// 调用 wx api 原始响应
Data json.RawMessage `json:"data"`
}
type OfficialMsgListResp struct {
Ret int `json:"ret"`
ErrMsg string `json:"errmsg"`
MsgList string `json:"general_msg_list"`
HasMore int `json:"can_msg_continue"`
MsgCount int `json:"msg_count"`
NextOffset int `json:"next_offset"`
}
type CommonMsgInfo struct {
Id int `json:"id"`
Type int `json:"type"`
Datetime int `json:"datetime"`
Fakeid string `json:"fakeid"`
Status int `json:"status"`
Content string `json:"content"`
}
type OfficialAccountMsgListRespItem struct {
MsgExtInfo OfficialAccountMsg `json:"app_msg_ext_info"`
CommonMsgInfo CommonMsgInfo `json:"comm_msg_info"`
}
type OfficialArticle struct {
Title string `json:"title"`
Digest string `json:"digest"`
Content string `json:"content"`
Fileid int `json:"fileid"`
ContentUrl string `json:"content_url"`
SourceUrl string `json:"source_url"`
Cover string `json:"cover"`
Author string `json:"author"`
CopyrightStat int `json:"copyright_stat"`
DelFlag int `json:"del_flag"`
ItemShowType int `json:"item_show_type"`
AudioFileid int `json:"audio_fileid"`
Duration int `json:"duration"`
PlayUrl string `json:"play_url"`
MaliciousTitleReasonId int `json:"malicious_title_reason_id"`
MaliciousContentType int `json:"malicious_content_type"`
}
// 推送,就是比 OfficialArticle 多了 IsMulti 和 MultiAppMsgItemList 两个字段
type OfficialAccountMsg struct {
Title string `json:"title"`
Digest string `json:"digest"`
Content string `json:"content"`
Fileid int `json:"fileid"`
ContentUrl string `json:"content_url"`
SourceUrl string `json:"source_url"`
Cover string `json:"cover"`
Subtype int `json:"subtype"`
IsMulti int `json:"is_multi"`
MultiAppMsgItemList []OfficialArticle `json:"multi_app_msg_item_list"`
Author string `json:"author"`
CopyrightStat int `json:"copyright_stat"`
Duration int `json:"duration"`
DelFlag int `json:"del_flag"`
ItemShowType int `json:"item_show_type"`
AudioFileid int `json:"audio_fileid"`
PlayUrl string `json:"play_url"`
MaliciousTitleReasonId int `json:"malicious_title_reason_id"`
MaliciousContentType int `json:"malicious_content_type"`
}
+137
View File
@@ -0,0 +1,137 @@
# Official Account API - Cloudflare Worker
A simplified Node.js version of the Official Account API, designed to run on Cloudflare Workers.
## Features
This implementation provides the following API endpoints from the original Go version:
- `GET /api/mp/list` - Fetch official account list
- `GET /api/mp/msg/list` - Fetch official account message list
- `POST /api/mp/refresh` - Refresh official account credentials
- `GET /rss/mp` - Generate RSS feed for official account messages
- `GET /mp/proxy` - Proxy requests to external URLs
- `GET /mp/home` - Management dashboard
## Deployment
### Prerequisites
1. Install [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/):
```bash
npm install -g wrangler
```
2. Authenticate with Cloudflare:
```bash
wrangler login
```
### Setup
1. Create KV namespaces in Cloudflare:
```bash
wrangler kv:namespace create "OFFICIAL_ACCOUNTS"
wrangler kv:namespace create "OFFICIAL_MESSAGES"
```
2. Update `wrangler.toml` with your KV namespace IDs
3. Set environment variables:
```bash
wrangler secret put AUTH_TOKEN
wrangler secret put REFRESH_TOKEN
```
### Deploy
```bash
npm run deploy
```
### Development
```bash
npm run dev
```
## API Usage
### Authentication
Most endpoints require a token parameter. Set `AUTH_TOKEN` and `REFRESH_TOKEN` in your environment variables.
### Endpoints
#### Get Account List
```
GET /api/mp/list?token=YOUR_TOKEN
```
#### Get Message List
```
GET /api/mp/msg/list?token=YOUR_TOKEN&biz=ACCOUNT_BIZ&offset=0
```
#### Refresh Account Credentials
```
POST /api/mp/refresh?token=REFRESH_TOKEN
Content-Type: application/json
{
"biz": "account_biz",
"key": "account_key",
"nickname": "Account Name",
"avatar_url": "https://example.com/avatar.jpg",
"uin": "uin_value",
"pass_ticket": "pass_ticket_value",
"appmsg_token": "appmsg_token_value"
}
```
#### Get RSS Feed
```
GET /rss/mp?biz=ACCOUNT_BIZ
```
#### Proxy Request
```
GET /mp/proxy?token=YOUR_TOKEN&url=TARGET_URL
```
#### Management Dashboard
```
GET /mp/home
```
## Data Storage
The worker uses Cloudflare KV for data persistence:
- `OFFICIAL_ACCOUNTS` namespace stores account information
- `OFFICIAL_MESSAGES` namespace stores message data
## Error Codes
- `1001` - Invalid token
- `1002` - Missing required parameters
- `1003` - Account not found
- `500` - Internal server error
## Security Notes
- Always use HTTPS in production
- Keep your tokens secure
- Consider implementing rate limiting for production use
- Use proper JWT tokens instead of simple string tokens for better security
## Limitations
This is a simplified version compared to the original Go implementation:
- No WebSocket support
- No real-time features
- Simplified authentication
- Basic error handling
- Limited message storage (KV has size limits)
For full functionality, consider using the original Go implementation with proper infrastructure.
+655
View File
@@ -0,0 +1,655 @@
// Cloudflare Worker for Official Account API
// Simplified version of the Go implementation
// Response utilities
function apiResponse(code, msg, data, status = 200) {
return new Response(
JSON.stringify({
code,
msg,
data,
}),
{
status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
}
);
}
const Result = {
Ok(data = {}, msg = "") {
return apiResponse(0, msg, data, 200);
},
Err(code, message, status = 400) {
return apiResponse(code, message, {}, status);
},
};
// Global cache for tokens
const TOKEN_CACHE = new Set();
// Load tokens from DB to cache
async function loadTokens(env) {
if (TOKEN_CACHE !== null) return;
try {
// Create table if not exists (in case migration didn't run)
// In production, migrations should be run via wrangler, but for safety:
// await env.DB.prepare(`CREATE TABLE IF NOT EXISTS tokens (token TEXT PRIMARY KEY, description TEXT, created_at INTEGER)`).run();
const { results } = await env.DB.prepare("SELECT token FROM tokens").all();
if (results && results.length > 0) {
results.forEach((row) => TOKEN_CACHE.add(row.token));
}
console.log(`Loaded ${TOKEN_CACHE.size} tokens from DB`);
} catch (error) {
console.error("Error loading tokens:", error);
// Fallback to empty set if DB fails, so we don't crash, but auth might fail for DB tokens
}
}
// Token validation
function validateToken(token) {
if (!token) return false;
// Check environment variables first (Superuser/Admin tokens)
// Initialize cache if needed
// Check cache
return TOKEN_CACHE.has(token);
}
// Fetch messages from WeChat API
async function getMsgList(account, offset = 0) {
const params = new URLSearchParams({
action: "getmsg",
__biz: account.biz,
uin: account.uin,
key: account.key,
pass_ticket: account.pass_ticket,
wxtoken: "",
x5: "0",
count: "10",
offset: offset.toString(),
f: "json",
});
const url = `https://mp.weixin.qq.com/mp/profile_ext?${params.toString()}`;
// Construct Referer
const refererParams = new URLSearchParams({
action: "home",
__biz: account.biz,
scene: "124",
uin: account.uin,
key: account.key,
devicetype: "UnifiedPCWindows",
version: "f2541022",
lang: "zh_CN",
a8scene: "1",
acctmode: "0",
pass_ticket: account.pass_ticket,
});
const referer = `https://mp.weixin.qq.com/mp/profile_ext?${refererParams.toString()}`;
const response = await fetch(url, {
method: "GET",
headers: {
"content-type": "application/json",
"accept-language": "en-US,en;q=0.9",
priority: "u=1, i",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 NetType/WIFI MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090a13) UnifiedPCWindowsWechat(0xf2541022) XWEB/16467 Flue",
referer: referer,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}
// Generate RSS feed from messages
function generateRSS(officialAccount, messages) {
const now = new Date().toUTCString();
let rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${officialAccount.nickname || officialAccount.biz}</title>
<link>https://mp.weixin.qq.com/mp/profile_ext?action=home&amp;__biz=${
officialAccount.biz
}</link>
<description>公众号文章 RSS</description>
<language>zh-CN</language>
<lastBuildDate>${now}</lastBuildDate>
<generator>WeChat Official Account RSS</generator>`;
if (messages && messages.length > 0) {
messages.forEach((msg) => {
const pubDate = new Date(msg.create_time * 1000).toUTCString();
rss += `
<item>
<title><![CDATA[${msg.title || "无标题"}]]></title>
<link>${msg.content_url || "#"}</link>
<description><![CDATA[${msg.digest || ""}]]></description>
<pubDate>${pubDate}</pubDate>
<guid>${msg.content_url || msg.id}</guid>
</item>`;
});
}
rss += `</channel>
</rss>`;
return rss;
}
// API Handlers
async function handleFetchOfficialAccountList(request, env) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
const page = Math.max(
1,
parseInt(url.searchParams.get("page") || "1", 10) || 1
);
let pageSize = parseInt(url.searchParams.get("page_size") || "10", 10) || 10;
if (pageSize <= 0) pageSize = 10;
if (pageSize > 200) pageSize = 200;
const keyword = (url.searchParams.get("keyword") || "").trim();
if (!validateToken(token)) {
return Result.Err(1001, "Invalid token");
}
try {
const where = keyword
? "WHERE (LOWER(nickname) LIKE ? OR LOWER(biz) LIKE ?)"
: "";
const kw = keyword ? `%${keyword.toLowerCase()}%` : "";
const countStmt = env.DB.prepare(
`SELECT COUNT(*) as total FROM accounts ${where}`
);
const countRow = keyword
? await countStmt.bind(kw, kw).first()
: await countStmt.first();
const total = countRow ? countRow.total || 0 : 0;
const offset = (page - 1) * pageSize;
const listStmt = env.DB.prepare(
`SELECT * FROM accounts ${where} ORDER BY update_time DESC LIMIT ? OFFSET ?`
);
const { results } = keyword
? await listStmt.bind(kw, kw, pageSize, offset).all()
: await listStmt.bind(pageSize, offset).all();
// Filter effective accounts and format response
const now = Math.floor(Date.now() / 1000);
const list = results.map((account) => {
const isEffective =
account.is_effective === 1 && now - account.update_time < 30 * 60;
return {
nickname: account.nickname,
avatar_url: account.avatar_url,
biz: account.biz,
refresh_uri: account.refresh_uri,
is_effective: isEffective,
created_at: account.created_at,
update_time: account.update_time,
};
});
return Result.Ok({ list, total, page, page_size: pageSize, keyword });
} catch (error) {
console.error("Error fetching account list:", error);
return Result.Err(500, "Internal server error");
}
}
async function handleFetchOfficialAccountMsgList(request, env) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
const biz = url.searchParams.get("biz");
const offset = parseInt(url.searchParams.get("offset") || "0");
if (!validateToken(token)) {
return Result.Err(1001, "Invalid token");
}
if (!biz) {
return Result.Err(1002, "Missing biz parameter");
}
try {
// Get account info
const account = await env.DB.prepare(
"SELECT nickname FROM accounts WHERE biz = ?"
)
.bind(biz)
.first();
if (!account) {
return Result.Err(1003, "Account not found");
}
// Get messages count
const countResult = await env.DB.prepare(
"SELECT COUNT(*) as total FROM messages WHERE biz = ?"
)
.bind(biz)
.first();
const total = countResult ? countResult.total : 0;
// Get messages for this account
const pageSize = 10;
const { results } = await env.DB.prepare(
"SELECT raw_json FROM messages WHERE biz = ? ORDER BY create_time DESC LIMIT ? OFFSET ?"
)
.bind(biz, pageSize, offset)
.all();
const paginatedMessages = results.map((row) => {
try {
return JSON.parse(row.raw_json);
} catch (e) {
return {};
}
});
return Result.Ok({
msg_count: total,
title: account.nickname,
list: paginatedMessages,
});
} catch (error) {
console.error("Error fetching message list:", error);
return Result.Err(500, "Internal server error");
}
}
async function handleRefreshOfficialAccountEvent(request, env) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
if (token !== env.REFRESH_TOKEN) {
return Result.Err(1001, "Invalid refresh token");
}
try {
const body = await request.json();
if (
!body.biz ||
!body.key ||
!body.nickname ||
!body.uin ||
!body.pass_ticket ||
!body.refresh_uri
) {
return Result.Err(
1002,
"Missing required parameters: biz, key, nickname, uin, pass_ticket, refresh_uri"
);
}
// Update or create account
const {
nickname = "",
avatar_url = "",
uin = "",
key,
pass_ticket = "",
appmsg_token = "",
refresh_uri = "",
created_at = 0,
} = body;
const update_time = Math.floor(Date.now() / 1000);
const create_time = created_at || update_time;
await env.DB.prepare(
`INSERT INTO accounts (biz, nickname, avatar_url, uin, key, pass_ticket, appmsg_token, refresh_uri, is_effective, created_at, update_time, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, '')
ON CONFLICT(biz) DO UPDATE SET
nickname=excluded.nickname,
avatar_url=excluded.avatar_url,
uin=excluded.uin,
key=excluded.key,
pass_ticket=excluded.pass_ticket,
appmsg_token=excluded.appmsg_token,
refresh_uri=excluded.refresh_uri,
is_effective=1,
update_time=excluded.update_time,
error=''`
)
.bind(
body.biz,
nickname,
avatar_url,
uin,
key,
pass_ticket,
appmsg_token,
refresh_uri,
create_time,
update_time
)
.run();
return Result.Ok();
} catch (error) {
console.error("Error refreshing account:", error);
return Result.Err(500, "Internal server error");
}
}
async function handleFetchMsgListOfOfficialAccountRSS(request, env) {
const url = new URL(request.url);
const biz = url.searchParams.get("biz");
if (!biz) {
return new Response("Missing biz parameter", { status: 400 });
}
try {
// Get account info
const account = await env.DB.prepare("SELECT * FROM accounts WHERE biz = ?")
.bind(biz)
.first();
if (!account) {
return new Response("Account not found", { status: 404 });
}
let messages = [];
let fetchError = null;
// Try to fetch fresh messages
try {
const data = await getMsgList(account, 0);
if (data.ret === 0) {
const listData = JSON.parse(data.general_msg_list);
if (listData.list && listData.list.length > 0) {
listData.list.forEach((item) => {
const msg = item.app_msg_ext_info;
const common = item.comm_msg_info;
// Add main message
messages.push({
title: msg.title,
digest: msg.digest,
content_url: msg.content_url,
cover: msg.cover,
create_time: common.datetime,
id: common.id,
});
// Add sub-messages (multi-app messages)
if (
msg.is_multi === 1 &&
msg.multi_app_msg_item_list &&
msg.multi_app_msg_item_list.length > 0
) {
msg.multi_app_msg_item_list.forEach((art) => {
messages.push({
title: art.title,
digest: art.digest,
content_url: art.content_url,
cover: art.cover,
create_time: common.datetime,
id: art.fileid || common.id,
});
});
}
});
}
} else if (data.ret === -3) {
// Session expired, mark as ineffective
await env.DB.prepare(
"UPDATE accounts SET is_effective = 0 WHERE biz = ?"
)
.bind(biz)
.run();
console.log(`Account ${biz} expired`);
} else {
console.error(`WeChat API error: ${data.errmsg} (ret: ${data.ret})`);
}
} catch (e) {
console.error("Error fetching from WeChat:", e);
fetchError = e;
}
// Fallback to DB if no messages fetched or error occurred
if (messages.length === 0) {
const { results } = await env.DB.prepare(
"SELECT raw_json FROM messages WHERE biz = ? ORDER BY create_time DESC LIMIT 20"
)
.bind(biz)
.all();
const dbMessages = results.map((row) => {
try {
return JSON.parse(row.raw_json);
} catch (e) {
return {};
}
});
if (dbMessages.length > 0) {
messages = dbMessages;
}
}
// Generate RSS feed
const rss = generateRSS(account, messages);
return new Response(rss, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
} catch (error) {
console.error("Error generating RSS:", error);
return new Response("Internal server error", { status: 500 });
}
}
async function handleOfficialAccountProxy(request, env) {
const url = new URL(request.url);
const targetURL = url.searchParams.get("url");
const token = url.searchParams.get("token");
if (!validateToken(token)) {
return Result.Err(1001, "Invalid token");
}
if (!targetURL) {
return Result.Err(1002, "Missing url parameter");
}
try {
// Decode HTML entities in URL
const decodedURL = targetURL.replace(/&amp;/g, "&");
// Create proxy request
const proxyRequest = new Request(decodedURL, {
method: "GET",
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "zh-CN,zh;q=0.9",
priority: "u=0, i",
"sec-ch-ua":
'"Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
},
});
const response = await fetch(proxyRequest);
// Return the response with appropriate headers
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: {
"Content-Type": response.headers.get("Content-Type") || "text/html",
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.error("Proxy error:", error);
return Result.Err(500, "Proxy request failed");
}
}
async function handleAddToken(request, env) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
// Only Admin/Auth Token can add new tokens
if (!token || token !== env.ADMIN_TOKEN) {
return Result.Err(1001, "Unauthorized: Only admin can add tokens");
}
try {
const body = await request.json();
const { token, description = "" } = body;
if (!token) {
return Result.Err(1002, "Missing token parameter");
}
const now = Math.floor(Date.now() / 1000);
// Insert into DB
await env.DB.prepare(
`INSERT INTO tokens (token, description, created_at) VALUES (?, ?, ?)`
)
.bind(token, description, now)
.run();
// Update cache
if (TOKEN_CACHE === null) {
await loadTokens(env);
} else {
TOKEN_CACHE.add(token);
}
return Result.Ok({ token }, "Token added successfully");
} catch (error) {
if (error.message && error.message.includes("UNIQUE constraint failed")) {
return Result.Err(1003, "Token already exists");
}
console.error("Error adding token:", error);
return Result.Err(500, "Internal server error");
}
}
async function handleDeleteToken(request, env) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
// Only Admin/Auth Token can delete tokens
if (!token || token !== env.ADMIN_TOKEN) {
return Result.Err(1001, "Unauthorized: Only admin can delete tokens");
}
try {
const body = await request.json();
const { token } = body;
if (!token) {
return Result.Err(1002, "Missing token parameter");
}
// Delete from DB
await env.DB.prepare(`DELETE FROM tokens WHERE token = ?`)
.bind(token)
.run();
// Update cache
if (TOKEN_CACHE !== null) {
TOKEN_CACHE.delete(token);
}
return Result.Ok({ token }, "Token deleted successfully");
} catch (error) {
console.error("Error deleting token:", error);
return Result.Err(500, "Internal server error");
}
}
// Main request handler
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
// Handle CORS preflight
if (method === "OPTIONS") {
return new Response(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}
// Route handling
try {
if (path === "/api/mp/list" && method === "GET") {
return await handleFetchOfficialAccountList(request, env);
}
if (path === "/api/mp/msg/list" && method === "GET") {
return await handleFetchOfficialAccountMsgList(request, env);
}
if (path === "/api/mp/refresh" && method === "POST") {
return await handleRefreshOfficialAccountEvent(request, env);
}
if (path === "/admin/token/add" && method === "POST") {
return await handleAddToken(request, env);
}
if (path === "/admin/token/delete" && method === "POST") {
return await handleDeleteToken(request, env);
}
if (path === "/rss/mp" && method === "GET") {
return await handleFetchMsgListOfOfficialAccountRSS(request, env);
}
if (path === "/mp/proxy" && method === "GET") {
return await handleOfficialAccountProxy(request, env);
}
// 404 for unmatched routes
return Result.Err(404, "Not Found", 404);
} catch (error) {
console.error("Worker error:", error);
return Result.Err(500, "Internal server error");
}
},
};
@@ -0,0 +1,36 @@
-- Migration number: 0001 2024-01-08T00:00:00.000Z
-- Init schema
CREATE TABLE IF NOT EXISTS accounts (
biz TEXT PRIMARY KEY,
nickname TEXT,
avatar_url TEXT,
uin TEXT,
key TEXT,
pass_ticket TEXT,
appmsg_token TEXT,
refresh_uri TEXT,
is_effective INTEGER DEFAULT 1, -- 0 or 1 for boolean
created_at INTEGER,
update_time INTEGER,
error TEXT
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
biz TEXT NOT NULL,
msg_id INTEGER, -- The 'id' field from the message object
title TEXT,
digest TEXT,
content_url TEXT,
create_time INTEGER,
raw_json TEXT -- Store the full JSON object for flexibility
);
CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY,
description TEXT,
created_at INTEGER
);
@@ -0,0 +1,23 @@
{
"name": "official-account-worker",
"version": "1.0.0",
"description": "Cloudflare Worker for Official Account API - Simplified Node.js version",
"main": "index.js",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"keywords": [
"cloudflare",
"worker",
"wechat",
"official-account",
"api"
],
"author": "Your Name",
"license": "MIT",
"devDependencies": {
"wrangler": "^3.0.0"
}
}
@@ -0,0 +1,31 @@
name = "official-account-api"
main = "index.js"
compatibility_date = "2024-01-01"
[env.production]
name = "official-account-api-prod"
[env.staging]
name = "official-account-api-staging"
# [[kv_namespaces]]
# binding = "OFFICIAL_ACCOUNTS"
# id = "your_accounts_kv_namespace_id"
# preview_id = "your_accounts_kv_preview_id"
# [[kv_namespaces]]
# binding = "OFFICIAL_MESSAGES"
# id = "your_messages_kv_namespace_id"
# preview_id = "your_messages_kv_preview_id"
[[d1_databases]]
binding = "DB"
database_name = "official_account_db"
database_id = "your_d1_database_id" # Replace with your D1 database ID
[vars]
# Set these in Cloudflare dashboard or wrangler secrets
# AUTH_TOKEN = "your_auth_token_here"
# REFRESH_TOKEN = "your_refresh_token_here"
# REMOTE_SERVER = "https://your-remote-server.com"
+98
View File
@@ -0,0 +1,98 @@
package util
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
// 错误码定义
const (
CodeSuccess = 0
CodeInvalidParams = 400
CodeMissingBiz = 4001
CodeMissingUrl = 4002
CodeMissingKey = 4003
CodeMissingRefreshUri = 4004
CodeTooManyAccounts = 4005
CodeTokenInvalid = 1002 // 统一 Token 无效错误码
CodeAccountNotFound = 1003
CodeAccountExpired = 1004
CodeAccountBanned = 1005
CodeClientNotReady = 5001
CodeFetchMsgFailed = 2002
CodeDataParseFailed = 2003
CodeProxyRequestErr = 2000
CodeProxyDispatchErr = 2001
CodeRemotePushFailed = 2004
CodeTimeout = 5002
CodeClientBusy = 5003
)
// 错误文案映射 [0]英文 [1]中文
var errMsgMap = map[int][2]string{
CodeSuccess: {"success", "成功"},
CodeInvalidParams: {"Invalid parameters", "参数错误"},
CodeMissingBiz: {"Missing biz parameter", "缺少参数:biz"},
CodeMissingUrl: {"Missing url parameter", "缺少参数:url"},
CodeMissingKey: {"Missing key parameter", "缺少参数:key"},
CodeMissingRefreshUri: {"Missing refresh uri parameter", "缺少参数:refresh_uri"},
CodeTooManyAccounts: {"Too many accounts", "公众号数量已达上限"},
CodeTokenInvalid: {"Invalid token", "令牌无效"},
CodeAccountNotFound: {"Account not found", "未找到匹配的公众号"},
CodeAccountExpired: {"Account expired", "公众号凭证已失效"},
CodeAccountBanned: {"Account banned", "账号被封禁"},
CodeClientNotReady: {"Client not ready", "客户端未就绪"},
CodeFetchMsgFailed: {"Fetch message list failed", "获取消息列表失败"},
CodeDataParseFailed: {"Data parse error", "数据解析失败"},
CodeProxyRequestErr: {"Proxy request creation failed", "代理请求创建失败"},
CodeProxyDispatchErr: {"Proxy request dispatch failed", "代理请求转发失败"},
CodeRemotePushFailed: {"Push credential failed", "同步凭证到远程服务器失败"},
CodeTimeout: {"Request timeout", "请求超时"},
CodeClientBusy: {"Client busy", "发送缓冲区已满,请稍后重试"},
}
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"`
}
func GetMsg(code int) string {
if msgs, ok := errMsgMap[code]; ok {
return msgs[1]
}
return "Unknown error"
}
func Ok(ctx *gin.Context, data interface{}) {
ctx.JSON(http.StatusOK, Response{
Code: CodeSuccess,
Msg: GetMsg(CodeSuccess),
Data: data,
})
}
func Err(ctx *gin.Context, code int, msg string) {
ctx.JSON(http.StatusOK, Response{
Code: code,
Msg: msg,
})
}
func ErrCode(ctx *gin.Context, code int) {
ctx.JSON(http.StatusOK, Response{
Code: code,
Msg: GetMsg(code),
})
}
func writeErrorResponse(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(Response{
Code: code,
Msg: msg,
})
}
+5 -12
View File
@@ -7,29 +7,22 @@ import (
"github.com/spf13/viper"
"wx_channel/cmd"
"wx_channel/internal/api"
"wx_channel/internal/config"
"wx_channel/internal/interceptor"
"wx_channel/pkg/certificate"
"wx_channel/pkg/platform"
)
var AppVer = "26010301"
var AppVer = "260108"
var Mode = "debug"
func main() {
if Mode == "release" {
gin.SetMode(gin.ReleaseMode)
}
cfg, err := config.New(AppVer)
if err != nil {
fmt.Printf("加载配置文件失败 %v", err.Error())
return
}
interceptor.SetupConfig(cfg)
api.SetupConfig(cfg)
cfg := config.New(AppVer, Mode)
if err := cfg.LoadConfig(); err != nil {
fmt.Printf("加载配置文件失败 %v", err.Error())
fmt.Printf("加载配置文件失败 %v\n", err.Error())
return
}
need_admin_for_proxy := viper.GetBool("proxy.system") || interceptor.UsingSunnyNet
@@ -40,7 +33,7 @@ func main() {
}
return
}
if err := cmd.Execute(AppVer, certificate.DefaultCertFiles, cfg); err != nil {
fmt.Printf("初始化失败 %v\n", err.Error())
if err := cmd.Execute(certificate.DefaultCertFiles, cfg); err != nil {
fmt.Printf("启动失败 %v\n", err.Error())
}
}
+130
View File
@@ -0,0 +1,130 @@
package d1
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// D1Result represents the response from D1 query API
type D1Response struct {
Success bool `json:"success"`
Errors []ApiError `json:"errors"`
Messages []ApiError `json:"messages"`
Result []D1Result `json:"result"`
}
type ApiError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type D1Result struct {
Success bool `json:"success"`
Meta Meta `json:"meta"`
Results []map[string]any `json:"results"`
}
type Meta struct {
ChangedDB bool `json:"changed_db"`
Changes int `json:"changes"`
Duration float64 `json:"duration"`
LastRowID int `json:"last_row_id"`
RowsRead int `json:"rows_read"`
RowsWritten int `json:"rows_written"`
SizeAfter int `json:"size_after"`
}
// QueryRequest represents the request body for D1 query
type QueryRequest struct {
SQL string `json:"sql"`
Params []any `json:"params,omitempty"`
}
// Client handles D1 API interactions
type Client struct {
AccountID string
AuthToken string
Client *http.Client
}
func NewClient(accountID, authToken string) *Client {
return &Client{
AccountID: accountID,
AuthToken: authToken,
Client: &http.Client{},
}
}
// Query executes a SQL query on D1
func (c *Client) Query(databaseID string, sql string, params []any) (*D1Response, error) {
reqBody := QueryRequest{
SQL: sql,
Params: params,
}
if reqBody.Params == nil {
reqBody.Params = []any{}
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %v", err)
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/d1/database/%s/query", c.AccountID, databaseID)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+c.AuthToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
// Try to parse the error body even if status is not 200
var errorResp D1Response
if jsonErr := json.Unmarshal(body, &errorResp); jsonErr == nil && len(errorResp.Errors) > 0 {
var errMsgs string
for _, e := range errorResp.Errors {
errMsgs += fmt.Sprintf("[%d] %s; ", e.Code, e.Message)
if strings.Contains(e.Message, "SQLITE_AUTH") {
errMsgs += fmt.Sprintf(" (Hint: Check if Token has 'D1:Edit' permission, and AccountID '%s' matches DatabaseID '%s'. Also ensure you are using a Token, not Global Key)", c.AccountID, databaseID)
}
}
return nil, fmt.Errorf("D1 API error (Status %d): %s", resp.StatusCode, errMsgs)
}
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var d1Resp D1Response
if err := json.Unmarshal(body, &d1Resp); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %v, body: %s", err, string(body))
}
if !d1Resp.Success {
var errMsgs string
for _, e := range d1Resp.Errors {
errMsgs += fmt.Sprintf("[%d] %s; ", e.Code, e.Message)
if strings.Contains(e.Message, "SQLITE_AUTH") {
errMsgs += fmt.Sprintf(" (Hint: Check if Token has 'D1:Edit' permission, and AccountID '%s' matches DatabaseID '%s')", c.AccountID, databaseID)
}
}
return nil, fmt.Errorf("D1 API error (Account: %s, DB: %s): %s", c.AccountID, databaseID, errMsgs)
}
return &d1Resp, nil
}
+72
View File
@@ -0,0 +1,72 @@
package d1
import (
"fmt"
"strings"
)
// Migration represents a database migration
type Migration struct {
ID int
Name string
Content string
}
// EnsureMigrationsTable creates the migrations table if it doesn't exist
func (c *Client) EnsureMigrationsTable(databaseID string) error {
sql := `CREATE TABLE IF NOT EXISTS _cf_migrations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`
_, err := c.Query(databaseID, sql, nil)
return err
}
// GetAppliedMigrations returns a list of applied migration IDs
func (c *Client) GetAppliedMigrations(databaseID string) (map[int]bool, error) {
sql := "SELECT id FROM _cf_migrations"
resp, err := c.Query(databaseID, sql, nil)
if err != nil {
return nil, err
}
applied := make(map[int]bool)
if len(resp.Result) > 0 && len(resp.Result[0].Results) > 0 {
for _, row := range resp.Result[0].Results {
if id, ok := row["id"].(float64); ok {
applied[int(id)] = true
}
}
}
return applied, nil
}
// ApplyMigration applies a single migration
func (c *Client) ApplyMigration(databaseID string, migration Migration) error {
// 1. Run the migration SQL
// Split by semicolon to handle multiple statements
// Note: This is a simple split and might break if semicolons are inside strings/comments
// But for our schema files it should be fine.
statements := strings.Split(migration.Content, ";")
for _, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
_, err := c.Query(databaseID, stmt, nil)
if err != nil {
return fmt.Errorf("failed to execute migration statement in %s: %v\nSQL: %s", migration.Name, err, stmt)
}
}
// 2. Record the migration
recordSQL := "INSERT INTO _cf_migrations (id, name) VALUES (?, ?)"
_, err := c.Query(databaseID, recordSQL, []any{migration.ID, migration.Name})
if err != nil {
return fmt.Errorf("failed to record migration %s: %v", migration.Name, err)
}
return nil
}
+431
View File
@@ -0,0 +1,431 @@
package pages
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
func Api_fetch_missing_files(hashes []string, jwt string) ([]string, error) {
// CLOUDFLARE_API_TOKEN := "d1zCvKDV_wyGepEW4rFkyY6ueXXuzURqrUisIQIb"
CLOUDFLARE_API_BASE_URL := "https://api.cloudflare.com/client/v4"
methods := "POST"
request_url := CLOUDFLARE_API_BASE_URL + "/pages/assets/check-missing"
headers := map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer " + jwt,
}
body, err := json.Marshal(map[string]interface{}{
"hashes": hashes,
})
if err != nil {
return []string{}, err
}
req, err := http.NewRequest(methods, request_url, strings.NewReader(string(body)))
if err != nil {
return []string{}, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return []string{}, err
}
defer resp.Body.Close()
resp_bytes, err := io.ReadAll(resp.Body)
if err != nil {
return []string{}, err
}
fmt.Println("Api_fetch_missing_files", string(resp_bytes))
// 返回指定类型
var result struct {
Success bool `json:"success"`
Result []string `json:"result"`
}
if err := json.Unmarshal(resp_bytes, &result); err != nil {
return []string{}, err
}
if !result.Success {
return []string{}, errors.New("fetch missing files failed")
}
return result.Result, nil
}
type UploadTokenResp struct {
Result struct {
JWT string `json:"jwt"`
} `json:"result"`
Success bool `json:"success"`
Errors []string `json:"errors"`
Messages []string `json:"messages"`
}
func Api_fetch_upload_token(account_id string, project_name string) (*UploadTokenResp, error) {
// CF_PAGES_UPLOAD_JWT := ""
CLOUDFLARE_API_TOKEN := "d1zCvKDV_wyGepEW4rFkyY6ueXXuzURqrUisIQIb"
CLOUDFLARE_API_BASE_URL := "https://api.cloudflare.com/client/v4"
methods := "GET"
request_url := CLOUDFLARE_API_BASE_URL + "/accounts/" + account_id + "/pages/projects/" + project_name + "/upload-token"
headers := map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer " + CLOUDFLARE_API_TOKEN,
}
body := map[string][]string{}
req, err := http.NewRequest(methods, request_url, strings.NewReader(url.Values(body).Encode()))
if err != nil {
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 读取原始响应体
resp_bytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var r UploadTokenResp
if err := json.Unmarshal(resp_bytes, &r); err != nil {
return nil, err
}
return &r, nil
}
func Api_upload(files []FilePayloadToUpload, jwt string) (string, error) {
CLOUDFLARE_API_BASE_URL := "https://api.cloudflare.com/client/v4"
methods := "POST"
request_url := CLOUDFLARE_API_BASE_URL + "/pages/assets/upload"
headers := map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer " + jwt,
}
body, err := json.Marshal(files)
if err != nil {
return "", err
}
req, err := http.NewRequest(methods, request_url, strings.NewReader(string(body)))
if err != nil {
return "", err
}
for key, value := range headers {
req.Header.Set(key, value)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
resp_bytes, err := io.ReadAll(resp.Body)
fmt.Println("Api_upload", string(resp_bytes))
if err != nil {
return "", err
}
return string(resp_bytes), nil
}
func Api_upsert_hashes(hashes []string, jwt string) (string, error) {
CLOUDFLARE_API_BASE_URL := "https://api.cloudflare.com/client/v4"
request_method := "POST"
request_url := CLOUDFLARE_API_BASE_URL + "/pages/assets/upsert-hashes"
request_headers := map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer " + jwt,
}
request_body, err := json.Marshal(map[string]interface{}{
"hashes": hashes,
})
if err != nil {
return "", err
}
req, err := http.NewRequest(request_method, request_url, strings.NewReader(string(request_body)))
if err != nil {
return "", err
}
for key, value := range request_headers {
req.Header.Set(key, value)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
resp_bytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
fmt.Println("Api_upsert_hashes", string(resp_bytes))
return string(resp_bytes), nil
}
type DeploymentBody struct {
AccountId string `json:"account_id"`
ProjectName string `json:"project_name"`
Manifest map[string]string `json:"manifest"`
// Buffer *bytes.Buffer `json:""buffer`
}
func Api_create_deployment(body DeploymentBody) (string, error) {
CLOUDFLARE_API_TOKEN := "d1zCvKDV_wyGepEW4rFkyY6ueXXuzURqrUisIQIb"
CLOUDFLARE_API_BASE_URL := "https://api.cloudflare.com/client/v4"
request_method := "POST"
request_url := CLOUDFLARE_API_BASE_URL + "/accounts/" + body.AccountId + "/pages/projects/" + body.ProjectName + "/deployments"
var request_body bytes.Buffer
writer := multipart.NewWriter(&request_body)
request_body_manifest, err := json.Marshal(body.Manifest)
if err != nil {
return "", err
}
writer.WriteField("manifest", string(request_body_manifest))
writer.Close()
request_headers := map[string]string{
"Content-Type": writer.FormDataContentType(),
"Authorization": "Bearer " + CLOUDFLARE_API_TOKEN,
}
req, err := http.NewRequest(request_method, request_url, &request_body)
if err != nil {
return "", err
}
for key, value := range request_headers {
req.Header.Set(key, value)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
resp_bytes, err := io.ReadAll(resp.Body)
fmt.Println("Api_create_deployment", string(resp_bytes))
if err != nil {
return "", err
}
return string(resp_bytes), nil
}
// IsJwtExpired checks if a JWT token is expired
// Returns true if expired, false if not expired, and error if token is invalid
func IsJwtExpired(token string) (bool, error) {
// During testing we don't use valid JWTs, so don't try and parse them
if token == "<<funfetti-auth-jwt>>" ||
token == "<<funfetti-auth-jwt2>>" ||
token == "<<aus-completion-token>>" {
return false, nil
}
// Split the JWT token by dots
parts := strings.Split(token, ".")
if len(parts) != 3 {
return false, errors.New("invalid token format: expected 3 parts separated by dots")
}
// Decode the payload (second part)
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return false, fmt.Errorf("invalid token: failed to decode payload: %v", err)
}
// Parse the JSON payload
var payload map[string]interface{}
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return false, fmt.Errorf("invalid token: failed to parse payload JSON: %v", err)
}
// Get the expiration time
expInterface, exists := payload["exp"]
if !exists {
return false, errors.New("invalid token: missing expiration claim")
}
// Convert expiration to float64 (JWT exp is typically a number)
var exp float64
switch v := expInterface.(type) {
case float64:
exp = v
case int:
exp = float64(v)
case int64:
exp = float64(v)
case string:
// Try to parse as string
expFloat, err := strconv.ParseFloat(v, 64)
if err != nil {
return false, fmt.Errorf("invalid token: expiration claim is not a valid number: %v", err)
}
exp = expFloat
default:
return false, fmt.Errorf("invalid token: expiration claim has unexpected type: %T", expInterface)
}
// Get current time in seconds since epoch
now := time.Now().Unix()
// Check if token is expired
return int64(exp) <= now, nil
}
// 示例:发送简单的表单数据
func Api_send_simple_formdata() error {
url := "https://api.example.com/upload"
// 创建表单数据
form_date := make(map[string][]string)
form_date["name"] = []string{"test file"}
form_date["description"] = []string{"这是一个测试文件"}
form_date["category"] = []string{"document"}
// 发送请求
resp, err := http.PostForm(url, form_date)
if err != nil {
return err
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
fmt.Println("Api_send_simple_formdata", string(body))
return nil
}
// 发送包含文件的 FormData
func Api_send_multipart_formdata() error {
url := "https://api.example.com/upload"
// 创建 multipart writer
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// 添加文本字段
writer.WriteField("name", "test file")
writer.WriteField("description", "这是一个测试文件")
// 添加文件字段
fileWriter, err := writer.CreateFormFile("file", "test.txt")
if err != nil {
return err
}
// 写入文件内容
fileContent := []byte("这是文件内容")
fileWriter.Write(fileContent)
// 关闭 writer
writer.Close()
// 创建请求
req, err := http.NewRequest("POST", url, &buf)
if err != nil {
return err
}
// 设置 Content-Type header(包含 boundary)
req.Header.Set("Content-Type", writer.FormDataContentType())
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
fmt.Println("Api_send_multipart_formdata", string(body))
return nil
}
// 上传本地文件
func Api_upload_local_file(filePath string) error {
url := "https://api.example.com/upload"
// 打开文件
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
// 创建 multipart writer
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// 添加文本字段
writer.WriteField("name", "uploaded file")
writer.WriteField("description", "从本地文件上传")
// 创建文件字段
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
return err
}
// 复制文件内容到表单
_, err = io.Copy(part, file)
if err != nil {
return err
}
// 关闭 writer
writer.Close()
// 创建请求
req, err := http.NewRequest("POST", url, &buf)
if err != nil {
return err
}
// 设置 Content-Type header
req.Header.Set("Content-Type", writer.FormDataContentType())
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
fmt.Println("Api_upload_local_file", string(body))
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package pages
import (
"fmt"
)
type DeployBody struct {
Directory string `json:"directory"`
JWT string `json:"jwt"`
AccountId string `json:"account_id"`
ProjectName string `json:"project_name"`
}
// https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/api/pages/deploy.ts#L102
func Deploy(body DeployBody) error {
file_map, err := Validate(body.Directory)
if err != nil {
return err
}
manifest, err := Upload(UploadPayload{
FilesMap: file_map,
JWT: body.JWT,
})
if err != nil {
return err
}
fmt.Println("before Api_create_deployment", manifest.Files)
Api_create_deployment(DeploymentBody{
AccountId: body.AccountId,
ProjectName: body.ProjectName,
Manifest: manifest.Files,
// Buffer: &buf,
})
var files_hash []string
for _, file := range file_map {
files_hash = append(files_hash, file.Hash)
}
_, err = Api_upsert_hashes(files_hash, body.JWT)
if err != nil {
return err
}
return nil
}
+95
View File
@@ -0,0 +1,95 @@
package pages
import (
"encoding/base64"
"encoding/json"
"testing"
"time"
)
func TestIsJwtExpired(t *testing.T) {
tests := []struct {
name string
token string
expectExpired bool
expectError bool
}{
{
name: "Test token for testing environment",
token: "<<funfetti-auth-jwt>>",
expectExpired: false,
expectError: false,
},
{
name: "Test token 2 for testing environment",
token: "<<funfetti-auth-jwt2>>",
expectExpired: false,
expectError: false,
},
{
name: "Test completion token for testing environment",
token: "<<aus-completion-token>>",
expectExpired: false,
expectError: false,
},
{
name: "Invalid token format",
token: "invalid.token",
expectExpired: false,
expectError: true,
},
{
name: "Expired token",
token: createTestJWT(time.Now().Add(-1 * time.Hour).Unix()),
expectExpired: true,
expectError: false,
},
{
name: "Valid token not expired",
token: createTestJWT(time.Now().Add(1 * time.Hour).Unix()),
expectExpired: false,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
expired, err := IsJwtExpired(tt.token)
if tt.expectError && err == nil {
t.Errorf("Expected error but got none")
}
if !tt.expectError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if expired != tt.expectExpired {
t.Errorf("Expected expired=%v, got expired=%v", tt.expectExpired, expired)
}
})
}
}
// Helper function to create a test JWT token
func createTestJWT(exp int64) string {
header := map[string]string{
"alg": "HS256",
"typ": "JWT",
}
payload := map[string]interface{}{
"exp": exp,
"iat": time.Now().Unix(),
"sub": "test",
}
headerJSON, _ := json.Marshal(header)
payloadJSON, _ := json.Marshal(payload)
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
// Note: This creates an unsigned JWT for testing purposes
return headerB64 + "." + payloadB64 + "."
}
+1
View File
@@ -0,0 +1 @@
package pages
+237
View File
@@ -0,0 +1,237 @@
package pages
// https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/pages/upload.ts
import (
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/samber/lo"
)
// 分桶算法相关常量
const (
BULK_UPLOAD_CONCURRENCY = 4 // 并发上传数量
MAX_BUCKET_SIZE = 50 * 1024 * 1024 // 50MB 每个桶的最大大小
MAX_BUCKET_FILE_COUNT = 100 // 每个桶的最大文件数量
)
// 桶结构体
type Bucket struct {
Files []FileContainer `json:"files"`
RemainingSize int `json:"remaining_size"`
}
// 分桶结果
type BucketResult struct {
Buckets []Bucket `json:"buckets"`
TotalFiles int `json:"total_files"`
TotalSize int `json:"total_size"`
}
type FileContainer struct {
Path string `json:"path"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeInBytes int `json:"size_in_bytes"`
Hash string `json:"hash"`
}
var MAX_ASSET_COUNT = 50
// 检查文件路径是否应该被忽略
func should_ignore(path string, ignorePatterns []string) bool {
for _, pattern := range ignorePatterns {
// 处理 ** 通配符模式
if strings.Contains(pattern, "**") {
// 将 ** 转换为通配符匹配
pattern = strings.ReplaceAll(pattern, "**", "*")
if matched, _ := filepath.Match(pattern, filepath.Base(path)); matched {
return true
}
} else {
// 精确匹配文件名
if filepath.Base(path) == pattern {
return true
}
}
}
return false
}
type FileMetadata struct {
ContentType string `json:"contentType"`
}
type FilePayloadToUpload struct {
Key string `json:"key"`
Value string `json:"value"`
Metadata FileMetadata `json:"metadata"`
Base64 bool
}
func distribute_files_to_buckets(files []FileContainer) BucketResult {
// 初始化桶数组
buckets := make([]Bucket, BULK_UPLOAD_CONCURRENCY)
for i := range buckets {
buckets[i] = Bucket{
Files: []FileContainer{},
RemainingSize: MAX_BUCKET_SIZE,
}
}
bucket_offset := 0
total_files := 0
total_size := 0
// 遍历所有文件进行分桶
for _, file := range files {
inserted := false
// 尝试将文件插入到现有桶中
for i := 0; i < len(buckets); i++ {
bucket_index := (i + bucket_offset) % len(buckets)
bucket := &buckets[bucket_index]
// 检查桶是否有足够空间和文件数量限制
if bucket.RemainingSize >= file.SizeInBytes &&
len(bucket.Files) < MAX_BUCKET_FILE_COUNT {
bucket.Files = append(bucket.Files, file)
bucket.RemainingSize -= file.SizeInBytes
inserted = true
break
}
}
// 如果现有桶都无法容纳,创建新桶
if !inserted {
newBucket := Bucket{
Files: []FileContainer{file},
RemainingSize: MAX_BUCKET_SIZE - file.SizeInBytes,
}
buckets = append(buckets, newBucket)
}
bucket_offset++
total_files++
total_size += file.SizeInBytes
}
// 过滤掉空的桶
non_empty_buckets := []Bucket{}
for _, bucket := range buckets {
if len(bucket.Files) > 0 {
non_empty_buckets = append(non_empty_buckets, bucket)
}
}
return BucketResult{
Buckets: non_empty_buckets,
TotalFiles: total_files,
TotalSize: total_size,
}
}
// 打印分桶结果(用于调试)
func print_bucket_result(result BucketResult) {
fmt.Printf("\n=== 分桶结果 ===\n")
fmt.Printf("总文件数: %d\n", result.TotalFiles)
fmt.Printf("总大小: %d bytes (%.2f MB)\n", result.TotalSize, float64(result.TotalSize)/(1024*1024))
fmt.Printf("桶数量: %d\n", len(result.Buckets))
for i, bucket := range result.Buckets {
bucketSize := MAX_BUCKET_SIZE - bucket.RemainingSize
fmt.Printf("桶 %d: %d 个文件, %d bytes (%.2f MB), 剩余空间: %d bytes\n",
i+1, len(bucket.Files), bucketSize, float64(bucketSize)/(1024*1024), bucket.RemainingSize)
for _, f := range bucket.Files {
fmt.Println(f.Path, f.SizeInBytes)
}
}
fmt.Println("================")
}
type UploadPayload struct {
FilesMap map[string]FileContainer
JWT string
// AccountId string
// ProjectName string
}
type UploadResp struct {
Files map[string]string `json:"files"`
}
func Upload(payload UploadPayload) (*UploadResp, error) {
files_map := payload.FilesMap
jwt := payload.JWT
// account_id := payload.AccountId
// project_name := payload.ProjectName
// jwt := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJwYWdlcy1idWlsZC1tYWVzdHJvIiwiZXhwIjoxNzU0MzY5MDE0LCJmZWF0dXJlcyI6WyJmaWxlcyJdLCJpYXQiOjE3NTQzNjcyMTQsImlzcyI6ImZ1bmZldHRpIiwicHJvamVjdE5hbWVzcGFjZSI6IjI3Y2FjMGE0YWRkMTRkNDlhY2UzMDQxMWU5MzRkZWZlIn0.SZ8nSoM5Hv2T2ONaa4SZJvRZpJFpEZ0n4fLPKfwCaK2G-A-sTXD8IKEej7sgn9Vebx2evDt8zPczMSgQVS-UgxTizf1z9PYVHuwNqmFd4W0iOk8Hkas_PS0kKg78o1tpa8DsUESE0nnwzouf08P_luKIAOfo_YiS3fAMLZeen6G1gph4OdyrGoMV72sW8YmI-cDkOKaoX8CDSYsbyldxbGsUaIscJammvrsaHt_3C9nLPStD3PWcE6PE4qNZ7y_XNdFJ0SiRx0a9xtzePDeuZ1YpdcBwwDGY__J2sgLhambF-MbQlcLFmBMHeJr-3-IHLj1BhQo25_7E-IVKIfFFtIUGEUPOSVBP_UasXRHDwOodxv5BUGjEVceVlTwSbGuN6wjUL0yPOjzeYYIH_Wh52bLMulNlLM52OtN_3XSTnfLYxJL9YD2wgjPJS65BrgLA054XmO0y9TzCHTuucg16ecdk_gdbTnPikqcXNJcJxB4FRIh1kuSK-qvO_WG62SPGI8m2eCKzrlvOAiaY5BMu4ymirB7wpdowUKIjVRUjiwHoAXHDuJ-qHR8TWLVmKm14tPZP7VZGX0m9ED-r41xepVuT2r2BuGIYmA6THo2ug7kzGJJ0t5XkUrN2wXot3NzGiplklDwRcaMGJDxsjOF8giYlvo1kKe7g3Wrqdn7b31w"
// jwt, err := Api_fetch_upload_token(account_id, project_name)
// if err != nil {
// log.Fatalf("Error fetching upload token: %v", err)
// return err
// }
// fmt.Println(jwt)
files_path := []string{}
files_hash := []string{}
files_path_with_hash := map[string]string{}
for _, file := range files_map {
files_path = append(files_path, file.Path)
files_hash = append(files_hash, file.Hash)
key := "/" + file.Filename
files_path_with_hash[key] = file.Hash
}
missing_files, err := Api_fetch_missing_files(files_path, jwt)
if err != nil {
log.Fatalf("Error fetching missing files: %v", err)
return nil, err
}
sorted_files := []FileContainer{}
for _, file := range files_map {
present := lo.Contains(missing_files, file.Hash)
if !present {
sorted_files = append(sorted_files, file)
}
sorted_files = append(sorted_files, file)
}
buckets := distribute_files_to_buckets(sorted_files)
for _, bucket := range buckets.Buckets {
if len(bucket.Files) == 0 {
continue
}
var files []FilePayloadToUpload
for _, f := range bucket.Files {
fileContent, err := os.ReadFile(f.Path)
if err != nil {
log.Printf("Error reading file %s: %v", f.Path, err)
continue
}
base64Content := base64.StdEncoding.EncodeToString(fileContent)
files = append(files, FilePayloadToUpload{
Key: f.Hash,
Value: base64Content,
Metadata: FileMetadata{
ContentType: f.ContentType,
},
Base64: true,
})
}
fmt.Println("[PAGES]Upload - before Api_upload", files)
Api_upload(files, jwt)
}
// print_bucket_result(buckets)
_, err = Api_upsert_hashes(files_hash, jwt)
if err != nil {
return nil, err
}
return &UploadResp{
Files: files_path_with_hash,
}, nil
}
+248
View File
@@ -0,0 +1,248 @@
package pages
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"wx_channel/pkg/hash"
)
// getMimeType returns the appropriate MIME type based on file extension
func getMimeType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
// Text files
case ".html", ".htm":
return "text/html"
case ".css":
return "text/css"
case ".js":
return "application/javascript"
case ".json":
return "application/json"
case ".xml":
return "application/xml"
case ".txt":
return "text/plain"
case ".md":
return "text/markdown"
// Image files
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".svg":
return "image/svg+xml"
case ".webp":
return "image/webp"
case ".ico":
return "image/x-icon"
case ".bmp":
return "image/bmp"
case ".tiff", ".tif":
return "image/tiff"
// Font files
case ".woff":
return "font/woff"
case ".woff2":
return "font/woff2"
case ".ttf":
return "font/ttf"
case ".otf":
return "font/otf"
case ".eot":
return "application/vnd.ms-fontobject"
// Audio files
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/wav"
case ".ogg":
return "audio/ogg"
case ".m4a":
return "audio/mp4"
// Video files
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".avi":
return "video/x-msvideo"
case ".mov":
return "video/quicktime"
case ".wmv":
return "video/x-ms-wmv"
// Archive files
case ".zip":
return "application/zip"
case ".rar":
return "application/vnd.rar"
case ".7z":
return "application/x-7z-compressed"
case ".tar":
return "application/x-tar"
case ".gz":
return "application/gzip"
// Document files
case ".pdf":
return "application/pdf"
case ".doc":
return "application/msword"
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xls":
return "application/vnd.ms-excel"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".ppt":
return "application/vnd.ms-powerpoint"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
// Code files
case ".py":
return "text/x-python"
case ".java":
return "text/x-java-source"
case ".c":
return "text/x-c"
case ".cpp", ".cc":
return "text/x-c++src"
case ".h":
return "text/x-c"
case ".php":
return "application/x-httpd-php"
case ".rb":
return "text/x-ruby"
case ".go":
return "text/x-go"
case ".rs":
return "text/x-rust"
case ".swift":
return "text/x-swift"
case ".kt":
return "text/x-kotlin"
case ".scala":
return "text/x-scala"
case ".sh":
return "application/x-sh"
case ".bat":
return "application/x-msdos-program"
case ".ps1":
return "application/x-powershell"
// Data files
case ".csv":
return "text/csv"
case ".tsv":
return "text/tab-separated-values"
case ".yaml", ".yml":
return "application/x-yaml"
case ".toml":
return "application/toml"
case ".ini":
return "text/plain"
case ".conf":
return "text/plain"
case ".config":
return "text/plain"
default:
return "application/octet-stream"
}
}
func walk(dir string, files_map map[string]FileContainer, dir_start string, ignore_patterns []string) {
files, err := os.ReadDir(dir)
if err != nil {
log.Printf("Error reading directory %s: %v", dir, err)
return
}
for _, file := range files {
file_path := filepath.Join(dir, file.Name())
relative_filepath, _ := filepath.Rel(dir_start, file_path)
// 检查是否应该忽略此文件/目录
if should_ignore(file_path, ignore_patterns) {
fmt.Printf("Ignoring: %s\n", relative_filepath)
continue
}
if file.IsDir() {
// 递归遍历子目录
walk(file_path, files_map, dir_start, ignore_patterns)
} else {
// 处理文件
info, err := file.Info()
if err != nil {
log.Printf("Error getting file info for %s: %v", file_path, err)
continue
}
// 计算文件哈希值
file_hash, err := hash.FileHashWithExtension(file_path)
if err != nil {
log.Printf("Error calculating hash for %s: %v", file_path, err)
file_hash = "" // 如果计算失败,设置为空字符串
}
// 创建 FileContainer
_the_file := FileContainer{
Filename: file.Name(),
Path: file_path,
SizeInBytes: int(info.Size()),
ContentType: getMimeType(file.Name()), // 根据文件类型获取正确的 MIME type
Hash: file_hash,
}
files_map[relative_filepath] = _the_file
// fmt.Printf("Found: %s (%d bytes, hash: %s)\n", relative_filepath, info.Size(), file_hash)
}
}
}
func Validate(directory string) (map[string]FileContainer, error) {
IGNORE_FILES := []string{
"_worker.js",
"_redirects",
"_headers",
"_routes.json",
"functions",
"**/.DS_Store",
"**/node_modules",
"**/.git",
}
if _, err := os.Stat(directory); os.IsNotExist(err) {
// log.Fatalf("Directory does not exist: %s", directory)
return make(map[string]FileContainer), err
}
// 获取绝对路径
abs_filepath, err := filepath.Abs(directory)
if err != nil {
// log.Fatalf("Error getting absolute path: %v", err)
return make(map[string]FileContainer), err
}
file_map := make(map[string]FileContainer)
// fmt.Printf("Starting to walk directory: %s\n", abs_filepath)
// fmt.Printf("Ignore patterns: %v\n", IGNORE_FILES)
walk(abs_filepath, file_map, abs_filepath, IGNORE_FILES)
// fmt.Printf("\nTotal files found: %d\n", len(file_map))
return file_map, nil
}
+159
View File
@@ -0,0 +1,159 @@
package worker
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
)
// DeployBody 定义 Worker 部署所需的参数
type DeployBody struct {
AccountID string
AuthToken string
WorkerName string
ScriptContent []byte
CompatibilityDate string
Bindings []Binding
}
// Metadata 定义 Worker 部署所需的元数据
type Metadata struct {
MainModule string `json:"main_module"`
CompatibilityDate string `json:"compatibility_date"`
Bindings []Binding `json:"bindings"`
}
type Binding struct {
Type string `json:"type"`
Name string `json:"name"`
NamespaceID string `json:"namespace_id,omitempty"` // For kv_namespace
Text string `json:"text,omitempty"` // For plain_text
ID string `json:"id,omitempty"` // For d1
}
// DeployResult 定义部署结果
type DeployResult struct {
Success bool `json:"success"`
Errors []any `json:"errors"`
Result struct {
ID string `json:"id"`
} `json:"result"`
}
// Deploy 执行 Cloudflare Worker 部署
func Deploy(deployBody DeployBody) (string, error) {
// 构造 Metadata
metadata := Metadata{
MainModule: "index.js",
CompatibilityDate: deployBody.CompatibilityDate,
Bindings: deployBody.Bindings,
}
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return "", fmt.Errorf("构造 metadata 失败: %v", err)
}
// 构造 Multipart 请求
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Part 1: Metadata
// 注意: Cloudflare API 要求 metadata 部分必须有 Content-Type: application/json
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name="metadata"`)
h.Set("Content-Type", "application/json")
part, err := writer.CreatePart(h)
if err != nil {
return "", fmt.Errorf("创建 multipart metadata 失败: %v", err)
}
part.Write(metadataJSON)
// Part 2: index.js
// 注意: 必须标记为 application/javascript+module (ES Module)
h2 := make(textproto.MIMEHeader)
h2.Set("Content-Disposition", `form-data; name="index.js"; filename="index.js"`)
h2.Set("Content-Type", "application/javascript+module")
part2, err := writer.CreatePart(h2)
if err != nil {
return "", fmt.Errorf("创建 multipart script 失败: %v", err)
}
part2.Write(deployBody.ScriptContent)
writer.Close()
// 发送请求
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/workers/scripts/%s", deployBody.AccountID, deployBody.WorkerName)
req, err := http.NewRequest("PUT", url, body)
if err != nil {
return "", fmt.Errorf("创建请求失败: %v", err)
}
req.Header.Set("Authorization", "Bearer "+deployBody.AuthToken)
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("部署失败 (Status: %d): %s", resp.StatusCode, string(respBody))
}
// 解析响应以确认 success
var result DeployResult
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("解析响应失败: %v, body: %s", err, string(respBody))
}
if !result.Success {
return "", fmt.Errorf("部署失败 (API Error): %s", string(respBody))
}
// 部署成功后,确保 workers.dev 子域名已启用
if err := enableSubdomain(deployBody.AccountID, deployBody.AuthToken, deployBody.WorkerName); err != nil {
fmt.Printf("警告: 启用 workers.dev 子域名失败: %v\n", err)
}
return result.Result.ID, nil
}
// enableSubdomain 确保 Worker 的 workers.dev 子域名路由已启用
// PUT /accounts/{account_id}/workers/scripts/{script_name}/subdomain
func enableSubdomain(accountID, authToken, workerName string) error {
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/workers/scripts/%s/subdomain", accountID, workerName)
// Request body: {"enabled": true}
reqBody := []byte(`{"enabled": true}`)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Status %d: %s", resp.StatusCode, string(body))
}
return nil
}
+4 -5
View File
@@ -620,17 +620,16 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) {
func (d *Downloader) Close() error {
d.closed.Store(true)
closeArr := []func() error{
close_handler_arr := []func() error{
d.pauseAll,
}
for _, fm := range d.cfg.FetchManagers {
closeArr = append(closeArr, fm.Close)
close_handler_arr = append(close_handler_arr, fm.Close)
}
closeArr = append(closeArr, d.storage.Close)
close_handler_arr = append(close_handler_arr, d.storage.Close)
// Make sure all resources are released, if had error, return the last error
var lastErr error
for i, close := range closeArr {
for i, close := range close_handler_arr {
if err := close(); err != nil {
lastErr = err
d.Logger.Error().Stack().Err(err).Msgf("downloader close failed, index: %d", i)
+217
View File
@@ -0,0 +1,217 @@
package hash
// https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/pages/hash.ts
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/zeebo/blake3"
)
// HashType 定义支持的哈希类型
type HashType string
const (
MD5 HashType = "md5"
SHA1 HashType = "sha1"
SHA256 HashType = "sha256"
BLAKE3 HashType = "blake3"
)
// FileHash 计算文件的哈希值
func FileHash(filePath string, hashType HashType) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file %s: %w", filePath, err)
}
defer file.Close()
var hash interface{}
switch hashType {
case MD5:
hash = md5.New()
case SHA1:
hash = sha1.New()
case SHA256:
hash = sha256.New()
case BLAKE3:
hash = blake3.New()
default:
return "", fmt.Errorf("unsupported hash type: %s", hashType)
}
_, err = io.Copy(hash.(io.Writer), file)
if err != nil {
return "", fmt.Errorf("failed to calculate hash for %s: %w", filePath, err)
}
hashBytes := hash.(interface{ Sum([]byte) []byte }).Sum(nil)
return hex.EncodeToString(hashBytes), nil
}
// StringHash 计算字符串的哈希值
func StringHash(data string, hashType HashType) (string, error) {
var hash interface{}
switch hashType {
case MD5:
hash = md5.New()
case SHA1:
hash = sha1.New()
case SHA256:
hash = sha256.New()
case BLAKE3:
hash = blake3.New()
default:
return "", fmt.Errorf("unsupported hash type: %s", hashType)
}
_, err := io.WriteString(hash.(io.Writer), data)
if err != nil {
return "", fmt.Errorf("failed to calculate hash: %w", err)
}
hashBytes := hash.(interface{ Sum([]byte) []byte }).Sum(nil)
return hex.EncodeToString(hashBytes), nil
}
// BytesHash 计算字节数组的哈希值
func BytesHash(data []byte, hashType HashType) (string, error) {
var hash interface{}
switch hashType {
case MD5:
hash = md5.New()
case SHA1:
hash = sha1.New()
case SHA256:
hash = sha256.New()
case BLAKE3:
hash = blake3.New()
default:
return "", fmt.Errorf("unsupported hash type: %s", hashType)
}
_, err := hash.(io.Writer).Write(data)
if err != nil {
return "", fmt.Errorf("failed to calculate hash: %w", err)
}
hashBytes := hash.(interface{ Sum([]byte) []byte }).Sum(nil)
return hex.EncodeToString(hashBytes), nil
}
// FileHashAll 计算文件的所有支持的哈希值
func FileHashAll(filePath string) (map[HashType]string, error) {
hashes := make(map[HashType]string)
for _, hashType := range []HashType{MD5, SHA1, SHA256, BLAKE3} {
hash, err := FileHash(filePath, hashType)
if err != nil {
return nil, err
}
hashes[hashType] = hash
}
return hashes, nil
}
// ValidateHash 验证哈希值格式是否正确
func ValidateHash(hash string, hashType HashType) bool {
expectedLength := 0
switch hashType {
case MD5:
expectedLength = 32
case SHA1:
expectedLength = 40
case SHA256:
expectedLength = 64
case BLAKE3:
expectedLength = 64
default:
return false
}
if len(hash) != expectedLength {
return false
}
// 检查是否为有效的十六进制字符串
_, err := hex.DecodeString(hash)
return err == nil
}
// FileHashWithExtension 参考 TypeScript 实现:计算文件内容的 base64 + 扩展名的 blake3 哈希
// 返回前 32 个字符的十六进制字符串
func FileHashWithExtension(filePath string) (string, error) {
// 读取文件内容
contents, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
// 转换为 base64
base64Contents := base64.StdEncoding.EncodeToString(contents)
// 获取文件扩展名(不包含点)
extension := filepath.Ext(filePath)
if len(extension) > 0 && extension[0] == '.' {
extension = extension[1:] // 去掉点
}
// 拼接 base64 内容和扩展名
data := base64Contents + extension
// 使用 blake3 计算哈希
hasher := blake3.New()
_, err = hasher.Write([]byte(data))
if err != nil {
return "", fmt.Errorf("failed to calculate blake3 hash: %w", err)
}
// 获取哈希值并转换为十六进制字符串
hashBytes := hasher.Sum(nil)
hashHex := hex.EncodeToString(hashBytes)
// 返回前 32 个字符
if len(hashHex) > 32 {
return hashHex[:32], nil
}
return hashHex, nil
}
// StringHashWithExtension 计算字符串内容 + 扩展名的 blake3 哈希
func StringHashWithExtension(content, extension string) (string, error) {
// 转换为 base64
base64Contents := base64.StdEncoding.EncodeToString([]byte(content))
// 清理扩展名(去掉点)
cleanExtension := strings.TrimPrefix(extension, ".")
// 拼接 base64 内容和扩展名
data := base64Contents + cleanExtension
// 使用 blake3 计算哈希
hasher := blake3.New()
_, err := hasher.Write([]byte(data))
if err != nil {
return "", fmt.Errorf("failed to calculate blake3 hash: %w", err)
}
// 获取哈希值并转换为十六进制字符串
hashBytes := hasher.Sum(nil)
hashHex := hex.EncodeToString(hashBytes)
// 返回前 32 个字符
if len(hashHex) > 32 {
return hashHex[:32], nil
}
return hashHex, nil
}
+22
View File
@@ -0,0 +1,22 @@
package system
import (
"os"
"syscall"
)
func Signals() []os.Signal {
return signals()
}
func SetSysProcAttrForDaemon() *syscall.SysProcAttr {
return set_sys_proc_attr_for_daemon()
}
func TerminateProcess(pid int) error {
return terminate_process(pid)
}
func IsProcessRunning(pid int) bool {
return is_process_running(pid)
}
+27
View File
@@ -0,0 +1,27 @@
//go:build unix || darwin || linux
package system
import (
"os"
"syscall"
)
func signals() []os.Signal {
return []os.Signal{syscall.SIGINT, syscall.SIGTERM}
}
func set_sys_proc_attr_for_daemon() *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setsid: true}
}
func terminate_process(pid int) error {
return syscall.Kill(pid, syscall.SIGTERM)
}
func is_process_running(pid int) bool {
if pid <= 0 {
return false
}
return syscall.Kill(pid, syscall.Signal(0)) == nil
}
+55
View File
@@ -0,0 +1,55 @@
//go:build windows
package system
import (
"bytes"
"fmt"
"os"
"os/exec"
"syscall"
)
func signals() []os.Signal {
return []os.Signal{os.Interrupt}
}
func set_sys_proc_attr_for_daemon() *syscall.SysProcAttr {
return nil
}
func terminate_process(pid int) error {
p, err := os.FindProcess(pid)
if err != nil {
return err
}
return p.Kill()
}
func is_process_running(pid int) bool {
if pid <= 0 {
return false
}
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return false
}
// CSV output has header line and optionally a data line
// If only header exists, the process is not found
lines := bytes.Split(out.Bytes(), []byte("\n"))
for _, line := range lines {
trim := bytes.TrimSpace(line)
if len(trim) == 0 {
continue
}
// Skip header line starting with "Image Name"
if bytes.HasPrefix(trim, []byte("\"Image Name\"")) {
continue
}
// Any non-header non-empty line indicates a match
return true
}
return false
}