From f6d53842a3b9b3b1587c109d2e67cdbe2150d430 Mon Sep 17 00:00:00 2001 From: XOF Date: Sat, 29 Nov 2025 01:47:12 +0800 Subject: [PATCH] Fix notify path , Add notify test btn --- main.go | 1173 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 600 insertions(+), 573 deletions(-) diff --git a/main.go b/main.go index bf4e8f7..e201aa8 100644 --- a/main.go +++ b/main.go @@ -1,573 +1,600 @@ -// main.go -package main - -import ( - "context" - "encoding/json" - "html/template" - "io" - "log" - "fmt" - "math/rand" - "net/http" - "os" - "strings" - "sync" - "time" -) - -type Task struct { - ID string `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - PageLoaded string `json:"page_loaded"` - OutOfStock string `json:"out_of_stock"` - InStock bool `json:"in_stock"` - LastCheck time.Time `json:"last_check"` - Status string `json:"status"` -} - -type Config struct { - GotifyURL string `json:"gotify_url"` - GotifyToken string `json:"gotify_token"` - Interval int `json:"interval"` - Timeout int `json:"timeout"` -} - -var ( - tasks = make(map[string]*Task) - config = &Config{Interval: 60, Timeout: 20} - mu sync.RWMutex - configMu sync.RWMutex - authToken = os.Getenv("AUTH_TOKEN") - client *http.Client -) - -func main() { - loadEnv() - loadTasks() - loadConfig() - updateClient() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go checker(ctx) - - http.HandleFunc("/", handleIndex) - http.HandleFunc("/api/tasks", auth(handleTasks)) - http.HandleFunc("/api/task", auth(handleTask)) - http.HandleFunc("/api/config", auth(handleConfig)) - - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } - log.Println("启动服务:", port) - log.Fatal(http.ListenAndServe(":"+port, nil)) -} - -func loadEnv() { - data, err := os.ReadFile(".env") - if err != nil { - return - } - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - os.Setenv(parts[0], parts[1]) - } - } -} - -func auth(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if authToken != "" && r.Header.Get("Authorization") != "Bearer "+authToken { - http.Error(w, "Unauthorized", 401) - return - } - next(w, r) - } -} - -func handleIndex(w http.ResponseWriter, r *http.Request) { - tmpl := ` - - - - - 库存监控 - - - -
-

库存监控

-
- - -
- - - - - - - - - - - - -
状态名称监控历史库存最后检测操作
-
- - - - - - - -` - w.Header().Set("Content-Type", "text/html") - template.Must(template.New("").Parse(tmpl)).Execute(w, nil) -} - -func handleTasks(w http.ResponseWriter, r *http.Request) { - mu.RLock() - defer mu.RUnlock() - list := make([]*Task, 0, len(tasks)) - for _, t := range tasks { - list = append(list, t) - } - json.NewEncoder(w).Encode(list) -} - -func handleTask(w http.ResponseWriter, r *http.Request) { - if r.Method == "DELETE" { - id := r.URL.Query().Get("id") - mu.Lock() - delete(tasks, id) - mu.Unlock() - saveTasks() - return - } - - var task Task - json.NewDecoder(r.Body).Decode(&task) - if task.ID == "" { - task.ID = time.Now().Format("20060102150405") - } - - mu.Lock() - tasks[task.ID] = &task - mu.Unlock() - saveTasks() -} - -func handleConfig(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" { - var cfg Config - json.NewDecoder(r.Body).Decode(&cfg) - configMu.Lock() - config = &cfg - configMu.Unlock() - updateClient() - saveConfig() - return - } - - configMu.RLock() - defer configMu.RUnlock() - json.NewEncoder(w).Encode(config) -} - -func checker(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - default: - } - - configMu.RLock() - interval := config.Interval - configMu.RUnlock() - - mu.RLock() - list := make([]*Task, 0, len(tasks)) - for _, t := range tasks { - list = append(list, t) - } - mu.RUnlock() - - for _, task := range list { - go checkTask(task) - time.Sleep(time.Duration(2+rand.Intn(5)) * time.Second) - } - - jitter := rand.Intn(20) - 10 - time.Sleep(time.Duration(interval+jitter) * time.Second) - } -} - -func checkTask(task *Task) { - task.Status = "checking" - - body, err := fetch(task.URL) - now := time.Now() - - status := "ok" - inStock := false - - if err != nil || !strings.Contains(body, task.PageLoaded) { - status = "error" - } else { - inStock = !strings.Contains(body, task.OutOfStock) - } - - mu.Lock() - wasInStock := task.InStock - task.Status = status - task.InStock = inStock - task.LastCheck = now - mu.Unlock() - - if inStock && !wasInStock { - notify(task.Name + " 有货了!", task.URL) - } -} - -func fetch(url string) (string, error) { - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - return string(body), nil -} - -func notify(title, msg string) { - configMu.RLock() - url, token := config.GotifyURL, config.GotifyToken - configMu.RUnlock() - - if url == "" || token == "" { - return - } - - go func() { - payload := fmt.Sprintf(`{"title":"%s","message":"%s","priority":10}`, title, msg) - http.Post(url+"/message?token="+token, "application/json", strings.NewReader(payload)) - }() -} - -func updateClient() { - configMu.RLock() - timeout := config.Timeout - configMu.RUnlock() - client = &http.Client{Timeout: time.Duration(timeout) * time.Second} -} - -func saveJSON(filename string, v interface{}) { - data, _ := json.MarshalIndent(v, "", " ") - os.WriteFile(filename, data, 0644) -} - -func loadTasks() { - data, err := os.ReadFile("tasks.json") - if err != nil { - return - } - var list []*Task - json.Unmarshal(data, &list) - mu.Lock() - for _, t := range list { - tasks[t.ID] = t - } - mu.Unlock() -} - -func saveTasks() { - mu.RLock() - list := make([]*Task, 0, len(tasks)) - for _, t := range tasks { - list = append(list, t) - } - mu.RUnlock() - saveJSON("tasks.json", list) -} - -func loadConfig() { - data, err := os.ReadFile("config.json") - if err != nil { - return - } - configMu.Lock() - json.Unmarshal(data, config) - configMu.Unlock() -} - -func saveConfig() { - configMu.RLock() - defer configMu.RUnlock() - saveJSON("config.json", config) -} +// main.go +package main + +import ( + "context" + "encoding/json" + "html/template" + "io" + "log" + "fmt" + "math/rand" + "net/http" + "os" + "strings" + "sync" + "time" +) + +type Task struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + PageLoaded string `json:"page_loaded"` + OutOfStock string `json:"out_of_stock"` + InStock bool `json:"in_stock"` + LastCheck time.Time `json:"last_check"` + Status string `json:"status"` +} + +type Config struct { + GotifyURL string `json:"gotify_url"` + GotifyToken string `json:"gotify_token"` + Interval int `json:"interval"` + Timeout int `json:"timeout"` +} + +var ( + tasks = make(map[string]*Task) + config = &Config{Interval: 60, Timeout: 20} + mu sync.RWMutex + configMu sync.RWMutex + authToken = os.Getenv("AUTH_TOKEN") + client *http.Client +) + +func main() { + loadEnv() + loadTasks() + loadConfig() + updateClient() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go checker(ctx) + + http.HandleFunc("/", handleIndex) + http.HandleFunc("/api/tasks", auth(handleTasks)) + http.HandleFunc("/api/task", auth(handleTask)) + http.HandleFunc("/api/config", auth(handleConfig)) + http.HandleFunc("/api/test-notification", auth(handleTestNotification)) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + log.Println("启动服务:", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +func loadEnv() { + data, err := os.ReadFile(".env") + if err != nil { + return + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + os.Setenv(parts[0], parts[1]) + } + } +} + +func auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if authToken != "" && r.Header.Get("Authorization") != "Bearer "+authToken { + http.Error(w, "Unauthorized", 401) + return + } + next(w, r) + } +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + tmpl := ` + + + + + 库存监控 + + + +
+

库存监控

+
+ + +
+ + + + + + + + + + + + +
状态名称监控历史库存最后检测操作
+
+ + + + + + + +` + w.Header().Set("Content-Type", "text/html") + template.Must(template.New("").Parse(tmpl)).Execute(w, nil) +} + +func handleTasks(w http.ResponseWriter, r *http.Request) { + mu.RLock() + defer mu.RUnlock() + list := make([]*Task, 0, len(tasks)) + for _, t := range tasks { + list = append(list, t) + } + json.NewEncoder(w).Encode(list) +} + +func handleTask(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + id := r.URL.Query().Get("id") + mu.Lock() + delete(tasks, id) + mu.Unlock() + saveTasks() + return + } + + var task Task + json.NewDecoder(r.Body).Decode(&task) + if task.ID == "" { + task.ID = time.Now().Format("20060102150405") + } + + mu.Lock() + tasks[task.ID] = &task + mu.Unlock() + saveTasks() +} + +func handleConfig(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + var cfg Config + json.NewDecoder(r.Body).Decode(&cfg) + configMu.Lock() + config = &cfg + configMu.Unlock() + updateClient() + saveConfig() + return + } + + configMu.RLock() + defer configMu.RUnlock() + json.NewEncoder(w).Encode(config) +} + +func checker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + } + + configMu.RLock() + interval := config.Interval + configMu.RUnlock() + + mu.RLock() + list := make([]*Task, 0, len(tasks)) + for _, t := range tasks { + list = append(list, t) + } + mu.RUnlock() + + for _, task := range list { + go checkTask(task) + time.Sleep(time.Duration(2+rand.Intn(5)) * time.Second) + } + + jitter := rand.Intn(20) - 10 + time.Sleep(time.Duration(interval+jitter) * time.Second) + } +} + +func checkTask(task *Task) { + task.Status = "checking" + + body, err := fetch(task.URL) + now := time.Now() + + status := "ok" + inStock := false + + if err != nil || !strings.Contains(body, task.PageLoaded) { + status = "error" + } else { + inStock = !strings.Contains(body, task.OutOfStock) + } + + mu.Lock() + wasInStock := task.InStock + task.Status = status + task.InStock = inStock + task.LastCheck = now + mu.Unlock() + + if inStock && !wasInStock { + notify(task.Name + " 有货了!", task.URL) + } +} + +func fetch(url string) (string, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return string(body), nil +} + +func notify(title, msg string) { + configMu.RLock() + url, token := config.GotifyURL, config.GotifyToken + configMu.RUnlock() + + if url == "" || token == "" { + return + } + + go func() { + endpoint := strings.TrimRight(url, "/") + "/message" + payload := fmt.Sprintf(`{"title":"%s","message":"%s","priority":10}`, title, msg) + req, _ := http.NewRequest("POST", endpoint, strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Gotify-Key", token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("发送通知失败: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + log.Printf("通知返回错误 %d: %s", resp.StatusCode, body) + } + }() +} + +func handleTestNotification(w http.ResponseWriter, r *http.Request) { + notify("测试通知", "Gotify 配置正常") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func updateClient() { + configMu.RLock() + timeout := config.Timeout + configMu.RUnlock() + client = &http.Client{Timeout: time.Duration(timeout) * time.Second} +} + +func saveJSON(filename string, v interface{}) { + data, _ := json.MarshalIndent(v, "", " ") + os.WriteFile(filename, data, 0644) +} + +func loadTasks() { + data, err := os.ReadFile("tasks.json") + if err != nil { + return + } + var list []*Task + json.Unmarshal(data, &list) + mu.Lock() + for _, t := range list { + tasks[t.ID] = t + } + mu.Unlock() +} + +func saveTasks() { + mu.RLock() + list := make([]*Task, 0, len(tasks)) + for _, t := range tasks { + list = append(list, t) + } + mu.RUnlock() + saveJSON("tasks.json", list) +} + +func loadConfig() { + data, err := os.ReadFile("config.json") + if err != nil { + return + } + configMu.Lock() + json.Unmarshal(data, config) + configMu.Unlock() +} + +func saveConfig() { + configMu.RLock() + defer configMu.RUnlock() + saveJSON("config.json", config) +}