feat: add Wechat Official Account API (#261)
This commit is contained in:
1 parent
ed41e48741
commit
4d97019bf7
83 files changed
+9237
-1166
No files matched your search
@@ -16,3 +16,5 @@ node_modules
|
||||
docs/.vitepress/cache
|
||||
docs/.vitepress/dist
|
||||
gopeed.db
|
||||
/*.json
|
||||
*.log
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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`。
|
||||
@@ -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
@@ -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.
@@ -0,0 +1,178 @@
|
||||
---
|
||||
title: 微信公众号RSS
|
||||
---
|
||||
|
||||
# 微信公众号 RSS
|
||||
|
||||
目前处于 Beta 阶段,有如下功能
|
||||
|
||||
- 本地通过代理服务,在打开公众号页面时,自动添加该公众号
|
||||
- 本地提供 <code v-pre>http://127.0.0.1:2022/mp/home</code> 页面,可以管理添加的公众号
|
||||

|
||||
- 提供 <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 订阅链接,在浏览器打开该链接,即可看到 `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 个公众号。可以自己修改源码放开限制,风险自己把控。
|
||||
@@ -7,3 +7,5 @@ title: 下载mp3
|
||||
> 251226 版本后,必须安装 `ffmpeg` 才能下载 `mp3` 文件。或者在配置文件中指定 `download.frontend` 为 `true`,则会在前端转换 `mp3` 文件,无需本地安装 `ffmpeg`。
|
||||
|
||||
在「视频详情页」的「更多」菜单中,有「下载为 mp3」菜单,点击即可下载 `mp3` 文件
|
||||
|
||||
后台下载模式下,在下载面板中显示的文件大小,和实际的会不同,因为下载为 `mp3` 文件时,会先下载 `mp4` 文件,然后使用 `ffmpeg` 转换为 `mp3` 文件。
|
||||
@@ -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` 订阅链接
|
||||
|
||||

|
||||
|
||||
内容类似 `https://rss.example.com/rss/mp?biz=MzI2NDk5NzA0Mw==`,可以在浏览器打开该链接,验证服务是否正常
|
||||
|
||||
调用「获取可请求的公众号列表」,也可以看到刚刚打开的公众号信息
|
||||
|
||||
到此,公众号RSS 服务就可以正常使用了。本地 `macOS` 或 `Windows` 上的服务不要关闭,会定时获取在列表中的公众号,刷新凭证,保持「远端服务」的 `RSS` 服务一直可用
|
||||
|
||||
|
||||
+2
-1
@@ -8,8 +8,9 @@ title: macOS 启用
|
||||
|
||||
但是通过命令行运行就完全不会触发任何的校验
|
||||
|
||||
如果有问题,仍按照下面步骤进行
|
||||
**首次打开需要使用 `sudo wx_video_download` 运行一次,后续双击打开即可**
|
||||
|
||||
如果有问题,仍按照下面步骤进行
|
||||
|
||||
## 赋予执行权限
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: v260110
|
||||
---
|
||||
|
||||
# v260110
|
||||
|
||||
增加了微信公众号 RSS 功能
|
||||
|
||||
## 更新内容
|
||||
|
||||
- 修复了暂停的任务没有继续开始按钮的问题
|
||||
- 修复了批量下载会出现重复下载的问题
|
||||
- 修复了重复启动下载器时,第二个终端无法正常退出的问题
|
||||
- 直播下载按钮增加 rate 和 质量描述
|
||||
- 增加了创建视频号下载任务接口
|
||||
- 视频号视频列表接口现在支持 `next_marker` 获取下一页
|
||||
- 视频号详情接口现在支持直接传入视频号页面地址了
|
||||
- 增加 `/play` 接口,可以传入视频地址(非视频号页面地址)和 `key`,即可直接播放
|
||||
- 优化下载面板样式,增加总下载任务数
|
||||
- 支持配置下载完成后是否播放音效
|
||||
|
||||
## 公众号RSS
|
||||
|
||||
参考 [公众号RSS](../feature/mp-rss.md)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>")
|
||||
})
|
||||
}
|
||||
@@ -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,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"
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,8 @@
|
||||
package downloader
|
||||
|
||||
type DownloadTaskClient struct {
|
||||
}
|
||||
|
||||
func NewDownloadClient() *DownloadTaskClient {
|
||||
return &DownloadTaskClient{}
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
@@ -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
@@ -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 @@
|
||||
(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
|
||||
@@ -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,
|
||||
|
||||
@@ -12,7 +12,7 @@ var WXE = (() => {
|
||||
WindowLoaded: "WindowLoaded",
|
||||
/** 页面卸载完成 */
|
||||
WindowUnLoaded: "WindowUnLoaded",
|
||||
OfficialAccountLoaded: "OfficialAccountLoaded",
|
||||
OfficialAccountRefresh: "OfficialAccountRefresh",
|
||||
APILoaded: "APILoaded",
|
||||
UtilsLoaded: "UtilsLoaded",
|
||||
Init: "Init",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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&__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(/&/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"
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 + "."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package pages
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user