Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.idea
|
||||
config.toml
|
||||
doctocheck
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
ntfy_channel = "<change-me>"
|
||||
|
||||
[[doctor]]
|
||||
name = "Dr Moreau"
|
||||
motive_id = "<change me>"
|
||||
agenda_id = "<change me>"
|
||||
practice_id = "<change me>"
|
||||
|
||||
[[doctor]]
|
||||
name = "Dr Greene"
|
||||
motive_id = "<change me>"
|
||||
agenda_id = "<change me>"
|
||||
practice_id = "<change me>"
|
||||
@@ -0,0 +1,5 @@
|
||||
module doctocheck
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require github.com/BurntSushi/toml v1.6.0
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user