136 lines
6.1 KiB
Go
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
|
|
}
|