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)
+}