Files
Bridge-and-Join-s/internal/lkgateway/news.go
T
fontvielle 19a2b6dda4 fix(admin): кнопка «Проверить документацию» возвращает на /admin/news + браузерный UA для nsd.ru
Три бага в Doc-watcher / Новостях, всплывшие при первом ручном прогоне:

1. setupFlash после POST в /admin/news/check-docs редиректил на
   /admin/setup, а не на /admin/news, и оператор «выпадал» с ленты.
   Теперь setupFlash смотрит Referer и возвращает на любой из
   /admin/wizard, /admin/news, /admin/setup — на ту страницу с которой
   пришёл POST.

2. http.DefaultClient в news.go и cacerts.go подхватывал HTTPS_PROXY
   из окружения и шёл через корпоративный zetit, который блокирует
   nsd.ru (CONNECT 403). Заменил на noProxyClient с явно отключённой
   проксификацией (Transport.Proxy = nil) — doc-watcher всегда идёт
   напрямую, независимо от ENV.

3. nsd.ru отдаёт 403 на запросы с UA «bj-server/1.0» (антибот). Заменил
   на стандартный Chrome User-Agent + браузерные Accept/Accept-Language.
   После этого moex-most-dlya-m2m.pdf найден и скачан, новость
   «Обновлена документация» опубликована.

Кроме того, по запросу — убрана форма «Добавить вручную» с /admin/news.
В UI остался только мониторинг: автоматическая лента событий +
ручная кнопка «🔄 Проверить обновления документации сейчас».
Handler /admin/news/add сохранён в коде на случай ручного ввода
инцидентов в будущем.
2026-05-14 16:36:31 +03:00

388 lines
14 KiB
Go
Raw 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 lkgateway
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// defaultDocSources — стартовый набор страниц НРД, которые doc-watcher
// будет проверять раз в сутки. Пользователь может добавить/удалить через UI.
var defaultDocSources = []DocSource{
{
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
Name: "Сервис MOEX МОСТ для M2M",
},
{
URL: "https://www.nsd.ru/workflow/system/programs/",
Name: "ПО для участников ЭДО (ИШ, ФШ)",
},
{
URL: "https://www.nsd.ru/workflow/system/programs/cryptoservice/",
Name: "Криптосервис",
},
}
// EnsureDocSources гарантирует что defaultDocSources прописаны в конфиге.
// Вызывается при старте bj-server.
func EnsureDocSources(rc *RuntimeConfig) {
s := rc.Snapshot().News
if len(s.DocSources) > 0 {
return
}
s.DocSources = append([]DocSource(nil), defaultDocSources...)
if err := rc.UpdateNews(s); err != nil {
log.Printf("news: не получилось сохранить default DocSources: %v", err)
}
}
// pdfHrefRe — ищет в HTML href'ы, заканчивающиеся на .pdf (case-insensitive).
var pdfHrefRe = regexp.MustCompile(`(?i)href="([^"]+\.pdf)"`)
// noProxyClient — HTTP-клиент, который игнорирует переменные окружения
// HTTPS_PROXY / HTTP_PROXY. Корпоративный прокси zetit блокирует
// nsd.ru — поэтому doc-watcher ходит на внешние сайты НРД напрямую.
// Transport.Proxy = nil отключает любую проксификацию (включая
// автодетект через env).
var noProxyClient = &http.Client{
Timeout: 90 * time.Second,
Transport: &http.Transport{
Proxy: nil,
},
}
// CheckDocSources обходит все DocSource из настроек, парсит HTML, ищет
// новые PDF и скачивает их в DOC/. На каждое нововведение эмитирует
// NewsItem типа "doc-update". Возвращает суммарную строку для лога.
func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string {
s := rc.Snapshot().News
if len(s.DocSources) == 0 {
s.DocSources = append([]DocSource(nil), defaultDocSources...)
}
var summary strings.Builder
now := time.Now()
for i, src := range s.DocSources {
fmt.Fprintf(&summary, "→ %s\n", src.URL)
pdfs, err := fetchPDFLinks(ctx, src.URL)
if err != nil {
fmt.Fprintf(&summary, " ошибка: %v\n", err)
continue
}
if src.KnownPDFs == nil {
s.DocSources[i].KnownPDFs = map[string]string{}
}
known := s.DocSources[i].KnownPDFs
fmt.Fprintf(&summary, " найдено %d ссылок на PDF\n", len(pdfs))
newlyAdded := 0
for _, pdfURL := range pdfs {
hash, changed := checkPDF(ctx, pdfURL, known)
if !changed {
continue
}
known[pdfURL] = hash
newlyAdded++
localPath, err := downloadPDFToDOC(ctx, pdfURL)
if err != nil {
fmt.Fprintf(&summary, " ✗ %s: %v\n", pdfURL, err)
continue
}
fmt.Fprintf(&summary, " ✓ %s → %s\n", pdfURL, localPath)
// Новость в ленту.
_ = rc.AddNews(NewsItem{
ID: "doc-" + hash[:12],
At: now,
Kind: "doc-update",
Title: "Обновлена документация: " + filepath.Base(localPath),
Body: "Источник: " + src.Name + "\nURL: " + pdfURL +
"\nЛокально: " + localPath + "\nSHA-256: " + hash[:16] + "…",
URL: pdfURL,
})
}
s.DocSources[i].LastChecked = now
if newlyAdded > 0 {
fmt.Fprintf(&summary, " добавлено новых: %d\n", newlyAdded)
}
}
s.LastDocCheck = now
s.DocCheckResult = summary.String()
if err := rc.UpdateNews(s); err != nil {
log.Printf("news: save failed: %v", err)
}
return summary.String()
}
// fetchPDFLinks качает HTML-страницу и извлекает все href'ы, заканчивающиеся
// на .pdf. Относительные URL разворачиваются в абсолютные.
func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) {
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pageURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
resp, err := noProxyClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, err
}
base, err := url.Parse(pageURL)
if err != nil {
return nil, err
}
matches := pdfHrefRe.FindAllStringSubmatch(string(body), -1)
seen := map[string]bool{}
var out []string
for _, m := range matches {
ref, err := url.Parse(m[1])
if err != nil {
continue
}
abs := base.ResolveReference(ref).String()
// Игнорируем «системные» PDF (политика конфиденциальности и т.п.).
low := strings.ToLower(abs)
if strings.Contains(low, "personal_information") ||
strings.Contains(low, "personal-information") ||
strings.Contains(low, "razmeschenie-logotipa") {
continue
}
if seen[abs] {
continue
}
seen[abs] = true
out = append(out, abs)
}
return out, nil
}
// checkPDF делает HEAD-запрос (или GET если HEAD не сработал) и сравнивает
// sha256 PDF с известным значением. Возвращает (новый_hash, изменился).
// HEAD у НРД редко возвращает Content-MD5/ETag — реальная проверка =
// скачать и посчитать sha256.
func checkPDF(ctx context.Context, pdfURL string, known map[string]string) (string, bool) {
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pdfURL, nil)
if err != nil {
return "", false
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
resp, err := noProxyClient.Do(req)
if err != nil {
return "", false
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", false
}
h := sha256.New()
if _, err := io.Copy(h, io.LimitReader(resp.Body, 32<<20)); err != nil {
return "", false
}
hash := hex.EncodeToString(h.Sum(nil))
if old, ok := known[pdfURL]; ok && old == hash {
return hash, false
}
return hash, true
}
// downloadPDFToDOC скачивает PDF в DOC/. Если файл с таким именем уже
// есть — переименовывает старый в name.old-YYYYMMDD.pdf, чтобы оставить
// аудит. Возвращает путь до нового файла.
func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) {
u, err := url.Parse(pdfURL)
if err != nil {
return "", err
}
name := filepath.Base(u.Path)
if name == "" || !strings.HasSuffix(strings.ToLower(name), ".pdf") {
return "", errors.New("странное имя файла")
}
docDir := "DOC"
if _, err := os.Stat(docDir); err != nil {
return "", fmt.Errorf("DOC/ не доступен: %w", err)
}
dst := filepath.Join(docDir, name)
// Если файл уже есть — переименуем как backup.
if _, err := os.Stat(dst); err == nil {
old := filepath.Join(docDir,
strings.TrimSuffix(name, ".pdf")+
"."+time.Now().Format("2006-01-02")+".pdf.bak")
_ = os.Rename(dst, old)
}
reqCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pdfURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
resp, err := noProxyClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
f, err := os.Create(dst)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(f, io.LimitReader(resp.Body, 64<<20)); err != nil {
return "", err
}
return dst, nil
}
// StartDocWatcher запускает горутину, которая раз в сутки проверяет
// DocSources и эмитирует новости. Стартует через 60 сек после Run().
func StartDocWatcher(rc *RuntimeConfig) func() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
select {
case <-ctx.Done():
return
case <-time.After(60 * time.Second):
}
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
summary := CheckDocSources(ctx, rc)
log.Printf("doc-watcher: проверка завершена\n%s", summary)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
return cancel
}
// addManualNews — POST /admin/news/add.
func (h *setupHandlers) addManualNews(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
title := strings.TrimSpace(r.FormValue("title"))
body := strings.TrimSpace(r.FormValue("body"))
kind := r.FormValue("kind")
if kind == "" {
kind = "manual"
}
if title == "" {
setupFlash(w, r, "Новости: укажите заголовок")
return
}
item := NewsItem{
At: time.Now(),
Kind: kind,
Title: title,
Body: body,
}
if vf := r.FormValue("valid_from"); vf != "" {
if t, err := time.Parse("2006-01-02", vf); err == nil {
item.ValidFrom = t
}
}
if vt := r.FormValue("valid_to"); vt != "" {
if t, err := time.Parse("2006-01-02", vt); err == nil {
item.ValidTo = t.Add(24*time.Hour - time.Second)
}
}
if err := h.rc.AddNews(item); err != nil {
setupFlash(w, r, "Новости: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "Новость «"+title+"» добавлена в ленту")
}
// dismissNews — POST /admin/news/dismiss?id=...
func (h *setupHandlers) dismissNews(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id := r.FormValue("id")
if id == "" {
setupFlash(w, r, "Новости: id обязателен")
return
}
_ = h.rc.DismissNews(id)
setupFlash(w, r, "Новость скрыта")
}
// checkDocsNow — POST /admin/news/check-docs.
func (h *setupHandlers) checkDocsNow(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
summary := CheckDocSources(ctx, h.rc)
if len(summary) > 600 {
summary = summary[:600] + "…"
}
setupFlash(w, r, "Проверка обновлений документации завершена. "+strings.TrimSpace(summary))
}
// SeedDefaultNews добавляет в ленту известные на момент запуска события
// (окно техработ TEST3 в мае 2026 и появление робота-автотестирования).
// Вызывается из server.go при старте — дедуп по ID гарантирован AddNews.
func SeedDefaultNews(rc *RuntimeConfig) {
defaults := []NewsItem{
{
ID: "test3-maintenance-2026-05",
At: time.Date(2026, 5, 13, 0, 0, 0, 0, time.UTC),
Kind: "maintenance",
Title: "TEST3 недоступен 18.05.2026 — 22.05.2026 (техработы)",
Body: "НРД проводит техработы на тестовом контуре TEST3. На gost-t3.nsd.ru / rsa-t3.nsd.ru интеграционные прогоны в этот период не пойдут. При необходимости — переключитесь на GUEST (gost-gt.nsd.ru) или mock-режим. Источник: НРД письмо НРД-И-2026-8452 от 13.05.2026.",
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
ValidFrom: time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC),
ValidTo: time.Date(2026, 5, 22, 23, 59, 59, 0, time.UTC),
},
{
ID: "robot-autotest-2026-05-12",
At: time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC),
Kind: "feature",
Title: "Доступно автотестирование MOEX МОСТ с роботом на TEST3",
Body: "С 12.05.2026 клиенты, подключившиеся к автотестированию, могут гонять обмен сообщениями с роботом-контрагентом на TEST3. Не нужно ждать живого второго депозитария. Контакт: M2MOST@nsd.ru. Опубликованы новые инструкции: «Инструкция по тестированию с роботом» и «Инструкция для обмена при self-transfer» — обе в DOC/.",
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
},
}
for _, item := range defaults {
_ = rc.AddNews(item)
}
}