// 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("