dana-medical-site/internal/prodoctorov/client.go
2026-10-14 13:33:13 +00:00

112 lines
5.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}