commit 062787d86b499fe29e27f9103df5faeda9ad2ff5 Author: Benjamin Sigonneau Date: Wed Jun 3 12:58:01 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a761db0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +config.toml +doctocheck diff --git a/README.md b/README.md new file mode 100644 index 0000000..b831e2f --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# DoctoCheck + +A crude tool to poll Doctolib and hopefully get an ophtalomogist appointment. +It will hit a Doctolib API endpoint and query for the availabilities of a +particular doctor for the next 3 weeks. If there is any available slot, a +notification is sent to my phone using [ntfy](https://ntfy.sh/). + +# Improvements + +* Assistant to find out config values +* Notify of next availability +* Config: override with environment +* Config: add validation after decoding config file +* Add retry logic? + +## Test run + +* Compile with `go build` +* Create `config.toml` file, using `config.example.toml` as a template +* Run once with `doctocheck` +* More verbose output with `doctocheck -v` +* Notify also when there's no availability: `doctocheck -debug` + +## Deployment + +The project ships with systemd unit files that can be used to ensure Doctolib +gets polled every 15 minutes. Note that it supposes `doctocheck` is installed +under `/usr/local/bin` and the config file is under `/etc/doctocheck`; you may +want to adjust that. To install them: + +``` +sudo cp systemd/doctocheck.{service,timer} /etc/systemd/system/ +sudo systemctl daemon-reload +``` + +Then enable the service and the timer: + +``` +sudo systemctl enable --now doctocheck.timer +``` + +We can now check when the next run is scheduled: + +``` +systemctl list-timers doctocheck.timer +``` \ No newline at end of file diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..e3e6ebb --- /dev/null +++ b/config.example.toml @@ -0,0 +1,13 @@ +ntfy_channel = "" + +[[doctor]] +name = "Dr Moreau" +motive_id = "" +agenda_id = "" +practice_id = "" + +[[doctor]] +name = "Dr Greene" +motive_id = "" +agenda_id = "" +practice_id = "" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6bd6e7a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module doctocheck + +go 1.26.3 + +require github.com/BurntSushi/toml v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9658acd --- /dev/null +++ b/main.go @@ -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 + } + } + } +} diff --git a/systemd/doctocheck.service b/systemd/doctocheck.service new file mode 100644 index 0000000..63fdbf2 --- /dev/null +++ b/systemd/doctocheck.service @@ -0,0 +1,10 @@ +[Unit] +Description=Doctocheck runner +After=network.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/doctocheck -c /etc/doctocheck/config.toml +StandardOutput=journal +StandardError=journal +User=root \ No newline at end of file diff --git a/systemd/doctocheck.timer b/systemd/doctocheck.timer new file mode 100644 index 0000000..47d4a97 --- /dev/null +++ b/systemd/doctocheck.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run doctocheck every 15 minutes +Requires=doctocheck.service + +[Timer] +OnBootSec=1min +OnUnitActiveSec=15min +AccuracySec=30s + +[Install] +WantedBy=timers.target