From a686020338fc2e2a3330604374dab5f97e5335fa Mon Sep 17 00:00:00 2001 From: XOF Date: Fri, 28 Nov 2025 20:10:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E8=87=B3?= =?UTF-8?q?=E3=80=8C/=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + Dockerfile | 10 + docker-compose.yaml | 15 ++ main.go | 573 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 600 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yaml create mode 100644 main.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e58617 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +AUTH_TOKEN=YOUR_TOKEN +PORT=8080 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..63afff3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /build +COPY main.go . +RUN go build -ldflags="-s -w" -o stock-checker main.go +FROM alpine:latest +WORKDIR /app +COPY --from=builder /build/stock-checker . +RUN chmod +x stock-checker +EXPOSE 8080 +CMD ["./stock-checker"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..b327d09 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,15 @@ +services: + stock-checker: + build: . + container_name: stock-checker + ports: + - "8080:8080" + volumes: + - ./data:/app/data + - ./.env:/app/.env:ro + env_file: + - .env + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + - AUTH_TOKEN=${AUTH_TOKEN} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..bf4e8f7 --- /dev/null +++ b/main.go @@ -0,0 +1,573 @@ +// 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) +}