diff --git a/checker.go b/checker.go new file mode 100644 index 0000000..41b9cb3 --- /dev/null +++ b/checker.go @@ -0,0 +1,731 @@ +package main + +import ( + "context" + "encoding/json" + "html/template" + "io" + "log" + "fmt" + "math/rand" + "net/http" + "os" + "sort" + "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"` + Notified bool `json:"notified"` + NotifyEnabled bool `json:"notify_enabled"` + Order int `json:"order"` + History []HistoryItem `json:"history"` +} + +type Config struct { + GotifyURL string `json:"gotify_url"` + GotifyToken string `json:"gotify_token"` + Interval int `json:"interval"` + Timeout int `json:"timeout"` + NotifyEnabled bool `json:"notify_enabled"` +} + +type HistoryItem struct { + State string `json:"state"` + Time time.Time `json:"time"` +} + +var ( + tasks = make(map[string]*Task) + config = &Config{Interval: 60, Timeout: 20, NotifyEnabled: true} + 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/task/toggle-notify", auth(handleToggleNotify)) + 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() + list := make([]*Task, 0, len(tasks)) + for _, t := range tasks { + list = append(list, t) + } + mu.RUnlock() + + sort.Slice(list, func(i, j int) bool { + return list[i].Order < list[j].Order + }) + + 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) + isNew := task.ID == "" + if isNew { + task.ID = time.Now().Format("20060102150405") + task.NotifyEnabled = true + task.History = make([]HistoryItem, 90) + now := time.Now() + for i := 0; i < 90; i++ { + task.History[i] = HistoryItem{ + State: "unknown", + Time: now.Add(time.Duration(i-90) * time.Minute), + } + } + } else { + mu.RLock() + if existing, ok := tasks[task.ID]; ok { + task.History = existing.History + task.InStock = existing.InStock + task.LastCheck = existing.LastCheck + task.Status = existing.Status + task.Notified = existing.Notified + task.NotifyEnabled = existing.NotifyEnabled + } + mu.RUnlock() + } + + mu.Lock() + tasks[task.ID] = &task + mu.Unlock() + saveTasks() +} + +func handleToggleNotify(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + mu.Lock() + if task, ok := tasks[id]; ok { + task.NotifyEnabled = !task.NotifyEnabled + } + 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) { + 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) + } + + state := "unknown" + if status == "ok" { + state = "no-stock" + if inStock { + state = "stock" + } + } else if status == "error" { + state = "error" + } + + mu.Lock() + wasInStock := task.InStock + wasNotified := task.Notified + taskNotifyEnabled := task.NotifyEnabled + task.Status = status + task.InStock = inStock + task.LastCheck = now + + if task.History == nil { + task.History = []HistoryItem{} + } + task.History = append(task.History, HistoryItem{State: state, Time: now}) + if len(task.History) > 90 { + task.History = task.History[len(task.History)-90:] + } + + if !inStock { + task.Notified = false + } + mu.Unlock() + saveTasks() + + configMu.RLock() + globalNotifyEnabled := config.NotifyEnabled + configMu.RUnlock() + + if globalNotifyEnabled && taskNotifyEnabled && inStock && !wasInStock && !wasNotified { + notify(task.Name + " 有货了!", task.URL) + mu.Lock() + task.Notified = true + mu.Unlock() + saveTasks() + } +} + +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) { + var testConfig struct { + GotifyURL string `json:"gotify_url"` + GotifyToken string `json:"gotify_token"` + } + json.NewDecoder(r.Body).Decode(&testConfig) + + if testConfig.GotifyURL == "" || testConfig.GotifyToken == "" { + http.Error(w, "缺少配置", 400) + return + } + + endpoint := strings.TrimRight(testConfig.GotifyURL, "/") + "/message" + payload := `{"title":"测试通知","message":"Gotify 配置正常","priority":10}` + req, _ := http.NewRequest("POST", endpoint, strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Gotify-Key", testConfig.GotifyToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + http.Error(w, string(body), resp.StatusCode) + return + } + + 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{}) { + os.MkdirAll("data", 0755) + data, _ := json.MarshalIndent(v, "", " ") + os.WriteFile("data/"+filename, data, 0644) +} + +func loadTasks() { + data, err := os.ReadFile("data/tasks.json") + if err != nil { + return + } + var list []*Task + json.Unmarshal(data, &list) + mu.Lock() + for _, t := range list { + if t.NotifyEnabled == false && t.ID != "" { + t.NotifyEnabled = true + } + 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("data/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) +}