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

136 lines
6.1 KiB
Go

// Package sberhealth downloads the authenticated docdoc.ru clinic export.
// A cabinet session is required; this is not a public API-key integration.
// Downloaded bytes MUST be schema-validated before use in channel reports.
package sberhealth
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
const downloadURL = "https://docdoc.ru/lk/requests/listOld"
const maxDownload = 10 << 20
type Client struct {
cookie, clinicID string
endpoint string
http *http.Client
}
// Export is only a download, NOT a parsed/validated report. The XLS export may
// be binary Excel, OOXML, or an HTML table; do not guess its columns/dates.
type Export struct {
Data []byte
Format string // xls, xlsx, html; format detection does not establish schema
}
func New(cookie, clinicID string) (*Client, error) {
clinicID = strings.TrimSpace(clinicID)
if cookie == "" && clinicID == "" {
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(clinicID) {
return nil, fmt.Errorf("СберЗдоровье: требуется сессия кабинета и корректный ID клиники")
}
return &Client{cookie: cookie, clinicID: clinicID, endpoint: downloadURL, http: &http.Client{
Timeout: 45 * time.Second,
// A redirect to login is an error; never forward credentials elsewhere.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}}, nil
}
func (c *Client) Configured() bool { return c != nil && c.cookie != "" && c.clinicID != "" }
// Download reproduces the supplied export URL's date filters, without assuming
// crDateTill is inclusive/exclusive or that page=1 includes every request.
// These provider semantics must be checked against an actual cabinet export
// before adapting a 1C period [from,to) to these filters.
// periodInput is only week/month, and both date arguments are date-only Moscow.
func (c *Client) Download(ctx context.Context, dateFrom, dateTill time.Time, periodInput string) (Export, error) {
if !c.Configured() {
return Export{}, fmt.Errorf("СберЗдоровье не подключено")
}
if periodInput != "week" && periodInput != "month" {
return Export{}, fmt.Errorf("СберЗдоровье: режим выгрузки — week или month")
}
moscow := time.FixedZone("Europe/Moscow", 3*60*60)
dateFrom, dateTill = dateFrom.In(moscow), dateTill.In(moscow)
for _, date := range []time.Time{dateFrom, dateTill} {
if date.Year() < 2000 || date.Year() > 2100 || date.Hour() != 0 || date.Minute() != 0 || date.Second() != 0 || date.Nanosecond() != 0 {
return Export{}, fmt.Errorf("СберЗдоровье: фильтры должны быть датами без времени (Москва)")
}
}
if dateTill.Before(dateFrom) || dateTill.Sub(dateFrom) > 366*24*time.Hour {
return Export{}, fmt.Errorf("СберЗдоровье: некорректный период выгрузки")
}
u, err := url.Parse(c.endpoint)
if err != nil {
return Export{}, fmt.Errorf("СберЗдоровье: некорректный адрес выгрузки")
}
u.RawQuery = url.Values{
"kind": {"doctor"}, "clinic_id": {c.clinicID}, "periodInput": {periodInput},
"crDateFrom": {dateFrom.Format("02.01.2006")}, "crDateTill": {dateTill.Format("02.01.2006")},
"page": {"1"}, "admissionStatus": {"-1"}, "sourceType": {"-1"}, "type": {"xls"},
}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return Export{}, fmt.Errorf("СберЗдоровье: не удалось подготовить запрос")
}
req.Header.Set("Cookie", c.cookie)
req.Header.Set("Accept", "application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
res, err := c.http.Do(req)
if err != nil {
if ctx.Err() != nil {
return Export{}, ctx.Err()
}
return Export{}, fmt.Errorf("СберЗдоровье: не удалось получить выгрузку (соединение или таймаут)")
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
if res.StatusCode == 401 || res.StatusCode == 403 || res.StatusCode >= 300 && res.StatusCode < 400 {
return Export{}, fmt.Errorf("СберЗдоровье: доступ к выгрузке не получен (HTTP %d); проверьте сессию кабинета и защиту сайта", res.StatusCode)
}
return Export{}, fmt.Errorf("СберЗдоровье: ошибка выгрузки (HTTP %d)", res.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(res.Body, maxDownload+1))
if err != nil {
return Export{}, fmt.Errorf("СберЗдоровье: файл не дочитался")
}
if len(data) > maxDownload {
return Export{}, fmt.Errorf("СберЗдоровье: файл больше 10 МБ")
}
format := ""
switch {
case bytes.HasPrefix(data, []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}):
format = "xls"
case bytes.HasPrefix(data, []byte{'P', 'K', 3, 4}):
format = "xlsx"
default:
// Legacy exports sometimes contain HTML under an .xls filename. Only
// recognise it when explicitly offered as an Excel download, not an
// arbitrary HTML page with a login/error table. Still needs a parser.
disposition, params, _ := mime.ParseMediaType(res.Header.Get("Content-Disposition"))
ct, _, _ := mime.ParseMediaType(res.Header.Get("Content-Type"))
filename := strings.ToLower(params["filename"])
excelAttachment := disposition == "attachment" && strings.HasSuffix(filename, ".xls")
if (excelAttachment || ct == "application/vnd.ms-excel") && bytes.Contains(bytes.ToLower(data), []byte("<table")) {
format = "html"
}
}
if format == "" {
return Export{}, fmt.Errorf("СберЗдоровье: вместо Excel получен неизвестный ответ; проверьте сессию кабинета и формат выгрузки")
}
// Do not log headers, response bodies, cookies, or downloaded phone values.
return Export{Data: data, Format: format}, nil
}