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