112 lines
5.1 KiB
Go
112 lines
5.1 KiB
Go
// Package prodoctorov reads the cabinet's authenticated online-appointment export.
|
||
// This is NOT a public API key integration and does not export telephone calls.
|
||
package prodoctorov
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const downloadURL = "https://prodoctorov.ru/api/cabinet/lpu/appointments/direct/download/"
|
||
const maxDownload = 10 << 20
|
||
|
||
type Client struct {
|
||
cookie, lpu string
|
||
http *http.Client
|
||
endpoint string
|
||
}
|
||
|
||
// New keeps the cabinet session on the server. Both empty values disable the
|
||
// integration. Supplying only one, or malformed credentials, is a config error.
|
||
func New(cookie, lpu string) (*Client, error) {
|
||
lpu = strings.TrimSpace(lpu)
|
||
if cookie == "" && lpu == "" {
|
||
return nil, nil
|
||
}
|
||
if strings.TrimSpace(cookie) == "" || strings.ContainsAny(cookie, "\r\n") ||
|
||
len(cookie) > 16384 || !strings.Contains(cookie, "=") ||
|
||
!regexp.MustCompile(`^[1-9][0-9]{0,17}$`).MatchString(lpu) {
|
||
return nil, fmt.Errorf("ПроДокторов: задайте корректные PRODOCTOROV_COOKIE и PRODOCTOROV_LPU_ID на сервере")
|
||
}
|
||
return &Client{cookie: cookie, lpu: lpu, endpoint: downloadURL, http: &http.Client{
|
||
Timeout: 45 * time.Second,
|
||
// Never forward the cabinet session through a redirect, even same-host:
|
||
// login redirects are an explicit expired-session error, not empty data.
|
||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||
}}, nil
|
||
}
|
||
|
||
func (c *Client) Configured() bool { return c != nil && c.cookie != "" && c.lpu != "" }
|
||
|
||
// Download returns XLSX bytes for one calendar month. No body, cookies, patient
|
||
// values, or response URLs are included in errors or logs.
|
||
func (c *Client) Download(ctx context.Context, year int, month time.Month) ([]byte, error) {
|
||
if !c.Configured() {
|
||
return nil, fmt.Errorf("ПроДокторов не подключён")
|
||
}
|
||
if year < 2000 || year > 2100 || month < time.January || month > time.December {
|
||
return nil, fmt.Errorf("ПроДокторов: некорректный месяц выгрузки")
|
||
}
|
||
u, err := url.Parse(c.endpoint)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ПроДокторов: некорректный адрес выгрузки")
|
||
}
|
||
q := u.Query()
|
||
q.Set("month", strconv.Itoa(int(month)))
|
||
q.Set("year", strconv.Itoa(year))
|
||
q.Set("lpu", c.lpu)
|
||
u.RawQuery = q.Encode()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ПроДокторов: не удалось подготовить запрос")
|
||
}
|
||
req.Header.Set("Cookie", c.cookie)
|
||
req.Header.Set("Accept", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
res, err := c.http.Do(req)
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
return nil, ctx.Err()
|
||
}
|
||
return nil, fmt.Errorf("ПроДокторов: не удалось получить выгрузку (соединение или таймаут)")
|
||
}
|
||
defer res.Body.Close()
|
||
// Servicepipe can reject the request before it reaches the cabinet. This
|
||
// does not establish that the cabinet session has expired. Do not retry a
|
||
// challenge, expose its body, or publish it as an empty appointment export.
|
||
protected := res.Header.Get("X-SP-CRID") != ""
|
||
if res.StatusCode != http.StatusOK {
|
||
if res.StatusCode == http.StatusForbidden && protected {
|
||
return nil, fmt.Errorf("ПроДокторов: доступ запрещён (HTTP 403, ответ через Servicepipe); это не подтверждает истечение сессии. Проверьте полный Cookie; при повторном отказе согласуйте серверную выгрузку с поддержкой ПроДокторов")
|
||
}
|
||
if res.StatusCode == http.StatusUnauthorized || res.StatusCode >= 300 && res.StatusCode < 400 {
|
||
return nil, fmt.Errorf("ПроДокторов: кабинет не авторизовал выгрузку (HTTP %d); войдите заново и обновите полный Cookie", res.StatusCode)
|
||
}
|
||
if res.StatusCode == http.StatusForbidden {
|
||
return nil, fmt.Errorf("ПроДокторов: доступ к выгрузке не получен (HTTP %d); проверьте сессию кабинета и защиту сайта", res.StatusCode)
|
||
}
|
||
return nil, fmt.Errorf("ПроДокторов: ошибка выгрузки (HTTP %d)", res.StatusCode)
|
||
}
|
||
data, err := io.ReadAll(io.LimitReader(res.Body, maxDownload+1))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ПроДокторов: файл не дочитался")
|
||
}
|
||
if len(data) > maxDownload {
|
||
return nil, fmt.Errorf("ПроДокторов: файл больше 10 МБ")
|
||
}
|
||
if !bytes.HasPrefix(data, []byte{'P', 'K', 3, 4}) {
|
||
if protected {
|
||
return nil, fmt.Errorf("ПроДокторов: вместо XLSX получена другая страница через Servicepipe; автоматическая выгрузка не выполнена")
|
||
}
|
||
return nil, fmt.Errorf("ПроДокторов: вместо XLSX получена другая страница; проверьте сессию кабинета и защиту сайта")
|
||
}
|
||
return data, nil
|
||
}
|