Refactor: split in several files
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
.idea
|
.idea
|
||||||
config.toml
|
config.toml
|
||||||
doctocheck
|
doctocheck
|
||||||
|
doctocheck-arm64
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(path string) (Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
_, err := toml.DecodeFile(path, &cfg)
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfigFile() string {
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
configPath := filepath.Join(cwd, "config.toml")
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DoctoClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDoctoClient() *DoctoClient {
|
||||||
|
return &DoctoClient{
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
limit: defaultDoctolibLimit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (docto *DoctoClient) check(doctor DoctorConfig, startDate string) (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", docto.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 := docto.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
|
||||||
|
}
|
||||||
@@ -3,3 +3,5 @@ module doctocheck
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require github.com/BurntSushi/toml v1.6.0
|
require github.com/BurntSushi/toml v1.6.0
|
||||||
|
|
||||||
|
require github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
|||||||
@@ -1,175 +1,70 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultDoctolibLimit = 5
|
type CLIArgs struct {
|
||||||
|
Verbose bool
|
||||||
type DoctorConfig struct {
|
AlwaysNotify bool
|
||||||
Name string `toml:"name"`
|
ConfigPath string
|
||||||
MotiveId string `toml:"motive_id"`
|
|
||||||
AgendaId string `toml:"agenda_id"`
|
|
||||||
PracticeId string `toml:"practice_id"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
func parseCliArgs() *CLIArgs {
|
||||||
NtfyChannel string `toml:"ntfy_channel"`
|
var cfg CLIArgs
|
||||||
Doctors []DoctorConfig `toml:"doctor"`
|
|
||||||
|
// Define flags bound directly to the struct fields
|
||||||
|
pflag.BoolVarP(&cfg.Verbose, "verbose", "v", false, "Enable verbose (debug) logging")
|
||||||
|
pflag.BoolVarP(&cfg.AlwaysNotify, "always-notify", "a", false, "Always send a ntfy notification, even if no availability is found")
|
||||||
|
pflag.StringVarP(&cfg.ConfigPath, "config", "c", defaultConfigFile(), "Path to the JSON/YAML config file")
|
||||||
|
|
||||||
|
pflag.Parse()
|
||||||
|
return &cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type DoctoResp struct {
|
func configureLogger(verbose bool) {
|
||||||
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
|
logLevel := slog.LevelInfo
|
||||||
if *verboseFlag {
|
if verbose {
|
||||||
logLevel = slog.LevelDebug
|
logLevel = slog.LevelDebug
|
||||||
}
|
}
|
||||||
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
Level: logLevel,
|
Level: logLevel,
|
||||||
})
|
})
|
||||||
slog.SetDefault(slog.New(logHandler))
|
slog.SetDefault(slog.New(logHandler))
|
||||||
|
}
|
||||||
|
|
||||||
// Default to working directory for config.toml, if not specified as a CLI arg
|
func main() {
|
||||||
if *configPath == "" {
|
cliArgs := parseCliArgs()
|
||||||
cwd, err := os.Getwd()
|
configureLogger(cliArgs.Verbose)
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to get cwd", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
*configPath = filepath.Join(cwd, "config.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse config file
|
// Parse config file
|
||||||
var config Config
|
config, err := loadConfig(cliArgs.ConfigPath)
|
||||||
_, err := toml.DecodeFile(*configPath, &config)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to load config", "path", *configPath, "err", err)
|
slog.Error("Failed to load config", "config file", cliArgs.ConfigPath, "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
slog.Debug("Loaded config", "Ntfy Channel", config.NtfyChannel)
|
slog.Debug("Loaded config", "Ntfy Channel", config.NtfyChannel)
|
||||||
|
|
||||||
|
// Check for availabilities
|
||||||
|
notifier := NewNotifier(config.NtfyChannel)
|
||||||
|
doctoClient := NewDoctoClient()
|
||||||
startDate := time.Now().Format(time.RFC3339)
|
startDate := time.Now().Format(time.RFC3339)
|
||||||
for _, doctor := range config.Doctors {
|
for _, doctor := range config.Doctors {
|
||||||
hasAvailability, err := check(doctor, startDate, defaultDoctolibLimit)
|
hasAvailability, err := doctoClient.check(doctor, startDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to check Doctolib", "doctor", doctor.Name, "err", err)
|
slog.Error("Failed to check Doctolib", "doctor", doctor.Name, "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasAvailability {
|
if hasAvailability {
|
||||||
slog.Info("DoctoCheck FOUND")
|
slog.Info("DoctoCheck FOUND", "doctor", doctor.Name)
|
||||||
msg := fmt.Sprintf("Availability found for %s, check Doctolib!", doctor.Name)
|
msg := fmt.Sprintf("Availability found for %s, check Doctolib!", doctor.Name)
|
||||||
err = ntfy(msg, config.NtfyChannel)
|
err = notifier.Send(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to send notification",
|
slog.Error("Failed to send notification",
|
||||||
"doctor", doctor.Name,
|
"doctor", doctor.Name,
|
||||||
@@ -179,9 +74,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if *alwaysNotifyFlag && !hasAvailability {
|
if cliArgs.AlwaysNotify && !hasAvailability {
|
||||||
msg := fmt.Sprintf("[DEBUG] No availability found for %s", doctor.Name)
|
msg := fmt.Sprintf("[DEBUG] No availability found for %s", doctor.Name)
|
||||||
err = ntfy(msg, config.NtfyChannel)
|
err = notifier.Send(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to send notification",
|
slog.Error("Failed to send notification",
|
||||||
"doctor", doctor.Name,
|
"doctor", doctor.Name,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Notifier struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
channel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotifier(channel string) *Notifier {
|
||||||
|
return &Notifier{
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
channel: channel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) Send(msg string) error {
|
||||||
|
ntfyUrl := fmt.Sprintf("https://ntfy.sh/%s", n.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 := n.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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user