Initial commit

This commit is contained in:
2026-06-03 12:58:01 +02:00
commit 062787d86b
8 changed files with 284 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
)
const defaultDoctolibLimit = 5
type DoctorConfig struct {
Name string `toml:"name"`
MotiveId string `toml:"motive_id"`
AgendaId string `toml:"agenda_id"`
PracticeId string `toml:"practice_id"`
}
type Config struct {
NtfyChannel string `toml:"ntfy_channel"`
Doctors []DoctorConfig `toml:"doctor"`
}
type DoctoResp struct {
Availabilities []struct {
Date string `json:"date"`
Slots []string `json:"slots"`
} `json:"availabilities"`
Total int `json:"total"`
Reason string `json:"reason"`
Message string `json:"message"`
NextSlot string `json:"next_slot,omitempty"`
}
type DoctoError struct {
Error []string `json:"error"`
}
var httpClient = &http.Client{Timeout: 10 * time.Second}
func check(doctor DoctorConfig, startDate string, limit int) (bool, error) {
params := url.Values{}
params.Set("visit_motive_id", doctor.MotiveId)
params.Set("agenda_ids", doctor.AgendaId)
params.Set("practice_ids", doctor.PracticeId)
params.Set("start_date_time", startDate)
params.Set("limit", fmt.Sprintf("%d", limit))
u := url.URL{
Scheme: "https",
Host: "www.doctolib.fr",
Path: "/search/availabilities.json",
RawQuery: params.Encode(),
}
queryUrl := u.String()
slog.Debug("check", "Doctolib URL", queryUrl)
req, err := http.NewRequest(http.MethodGet, queryUrl, nil)
if err != nil {
return false, err
}
req.Header.Add("User-Agent", "Mozilla/5.0 (compatible; DoctoCheck/1.0)")
req.Header.Add("Referer", "https://www.doctolib.fr/search")
resp, err := httpClient.Do(req)
if err != nil {
return false, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
var doctoError DoctoError
_ = json.NewDecoder(resp.Body).Decode(&doctoError)
return false, fmt.Errorf("unexpected status %d, message:%v", resp.StatusCode, doctoError.Error)
}
var doctoResp DoctoResp
err = json.NewDecoder(resp.Body).Decode(&doctoResp)
if err != nil {
return false, err
}
slog.Debug("Doctolib response", "Availabilities", doctoResp.Availabilities)
slog.Debug(
"Doctolib response",
"Total", doctoResp.Total,
"Reason", doctoResp.Reason,
"Message", doctoResp.Message,
"NextSlot", doctoResp.NextSlot)
hasAvailability := doctoResp.Total > 0 || doctoResp.NextSlot != ""
return hasAvailability, nil
}
func ntfy(msg string, channel string) error {
ntfyUrl := fmt.Sprintf("https://ntfy.sh/%s", channel)
req, err := http.NewRequest(http.MethodPost, ntfyUrl, strings.NewReader(msg))
if err != nil {
return err
}
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("Title", "DoctoCheck")
req.Header.Set("Tags", "medical_symbol")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return nil
}
func main() {
fmt.Println("DoctoCheck")
// Parse CLI args
verboseFlag := flag.Bool("v", false, "Enable verbose logging")
alwaysNotifyFlag := flag.Bool("always-notify", false, "Always send a ntfy notification")
configPath := flag.String("config", "", "path to config file")
flag.Parse()
// Configure log handler, adjusting log level if needed
logLevel := slog.LevelInfo
if *verboseFlag {
logLevel = slog.LevelDebug
}
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
slog.SetDefault(slog.New(logHandler))
// Default to working directory for config.toml, if not specified as a CLI arg
if *configPath == "" {
cwd, err := os.Getwd()
if err != nil {
slog.Error("Failed to get cwd", "err", err)
os.Exit(1)
}
*configPath = filepath.Join(cwd, "config.toml")
}
// Parse config file
var config Config
_, err := toml.DecodeFile(*configPath, &config)
if err != nil {
slog.Error("Failed to load config", "path", *configPath, "err", err)
os.Exit(1)
}
slog.Debug("Loaded config", "Ntfy Channel", config.NtfyChannel)
startDate := time.Now().Format(time.RFC3339)
for _, doctor := range config.Doctors {
hasAvailability, err := check(doctor, startDate, defaultDoctolibLimit)
if err != nil {
slog.Error("Failed to check Doctolib", "doctor", doctor.Name, "err", err)
continue
}
if hasAvailability {
slog.Info("DoctoCheck FOUND")
msg := fmt.Sprintf("Availability found for %s, check Doctolib!", doctor.Name)
err = ntfy(msg, config.NtfyChannel)
if err != nil {
slog.Error("Failed to send notification",
"doctor", doctor.Name,
"channel", config.NtfyChannel,
"err", err)
continue
}
}
if *alwaysNotifyFlag && !hasAvailability {
msg := fmt.Sprintf("[DEBUG] No availability found for %s", doctor.Name)
err = ntfy(msg, config.NtfyChannel)
if err != nil {
slog.Error("Failed to send notification",
"doctor", doctor.Name,
"channel", config.NtfyChannel,
"err", err)
continue
}
}
}
}