feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+66
-21
@@ -47,8 +47,11 @@ func EnsureDocSources(rc *RuntimeConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// pdfHrefRe — ищет в HTML href'ы, заканчивающиеся на .pdf (case-insensitive).
|
||||
var pdfHrefRe = regexp.MustCompile(`(?i)href="([^"]+\.pdf)"`)
|
||||
// docFileRe — ищет ссылки на документы (.pdf/.docx/.doc/.zip/.xlsx/.xls) в HTML
|
||||
// И в встроенном JSON. Сайт НРД — SPA (Next.js): ссылки лежат не в href="…",
|
||||
// а внутри __NEXT_DATA__ как строки "/media/docs/…". Поэтому матчим значение в
|
||||
// двойных ИЛИ экранированных кавычках, без требования атрибута href.
|
||||
var docFileRe = regexp.MustCompile(`(?i)(?:href=)?\\?"((?:https?://[^"\\]+|/[^"\\]+)\.(?:pdf|docx?|zip|xlsx?))(?:\?[^"\\]*)?\\?"`)
|
||||
|
||||
// noProxyClient — HTTP-клиент, который игнорирует переменные окружения
|
||||
// HTTPS_PROXY / HTTP_PROXY. Корпоративный прокси zetit блокирует
|
||||
@@ -84,23 +87,26 @@ func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string {
|
||||
s.DocSources[i].KnownPDFs = map[string]string{}
|
||||
}
|
||||
known := s.DocSources[i].KnownPDFs
|
||||
fmt.Fprintf(&summary, " найдено %d ссылок на PDF\n", len(pdfs))
|
||||
fmt.Fprintf(&summary, " найдено %d ссылок на документы\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 {
|
||||
// НЕ помечаем known — чтобы перекачать при следующей проверке.
|
||||
fmt.Fprintf(&summary, " ✗ %s: %v\n", pdfURL, err)
|
||||
continue
|
||||
}
|
||||
known[pdfURL] = hash // фиксируем только после успешной загрузки
|
||||
newlyAdded++
|
||||
fmt.Fprintf(&summary, " ✓ %s → %s\n", pdfURL, localPath)
|
||||
// Новость в ленту.
|
||||
_ = rc.AddNews(NewsItem{
|
||||
// Новость добавляем в ЛОКАЛЬНЫЙ снапшот s (не rc.AddNews): в конце
|
||||
// функции s целиком сохраняется через UpdateNews(s), и прямая запись
|
||||
// в rc была бы затёрта этим снимком.
|
||||
item := NewsItem{
|
||||
ID: "doc-" + hash[:12],
|
||||
At: now,
|
||||
Kind: "doc-update",
|
||||
@@ -108,7 +114,18 @@ func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string {
|
||||
Body: "Источник: " + src.Name + "\nURL: " + pdfURL +
|
||||
"\nЛокально: " + localPath + "\nSHA-256: " + hash[:16] + "…",
|
||||
URL: pdfURL,
|
||||
})
|
||||
}
|
||||
// Дедуп по ID + новые сверху.
|
||||
dup := false
|
||||
for _, ex := range s.Items {
|
||||
if ex.ID == item.ID {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
s.Items = append([]NewsItem{item}, s.Items...)
|
||||
}
|
||||
}
|
||||
s.DocSources[i].LastChecked = now
|
||||
if newlyAdded > 0 {
|
||||
@@ -124,8 +141,9 @@ func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string {
|
||||
return summary.String()
|
||||
}
|
||||
|
||||
// fetchPDFLinks качает HTML-страницу и извлекает все href'ы, заканчивающиеся
|
||||
// на .pdf. Относительные URL разворачиваются в абсолютные.
|
||||
// fetchPDFLinks качает страницу и извлекает ссылки на документы
|
||||
// (.pdf/.docx/.doc/.zip/.xlsx) как из HTML-href, так и из встроенного JSON
|
||||
// (Next.js SPA). Относительные URL разворачиваются в абсолютные.
|
||||
func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -152,20 +170,23 @@ func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matches := pdfHrefRe.FindAllStringSubmatch(string(body), -1)
|
||||
matches := docFileRe.FindAllStringSubmatch(string(body), -1)
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, m := range matches {
|
||||
ref, err := url.Parse(m[1])
|
||||
raw := strings.ReplaceAll(m[1], "\\/", "/") // JSON-экранированные слэши
|
||||
ref, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
abs := base.ResolveReference(ref).String()
|
||||
// Игнорируем «системные» PDF (политика конфиденциальности и т.п.).
|
||||
// Игнорируем «системные»/брендовые файлы и статику сборки SPA.
|
||||
low := strings.ToLower(abs)
|
||||
if strings.Contains(low, "personal_information") ||
|
||||
strings.Contains(low, "personal-information") ||
|
||||
strings.Contains(low, "razmeschenie-logotipa") {
|
||||
strings.Contains(low, "logotipa") || // руководство по логотипу (любое написание)
|
||||
strings.Contains(low, "logo-moex") ||
|
||||
strings.Contains(low, "/_next/") {
|
||||
continue
|
||||
}
|
||||
if seen[abs] {
|
||||
@@ -219,19 +240,22 @@ func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
name := filepath.Base(u.Path)
|
||||
if name == "" || !strings.HasSuffix(strings.ToLower(name), ".pdf") {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
switch ext {
|
||||
case ".pdf", ".docx", ".doc", ".zip", ".xlsx", ".xls":
|
||||
default:
|
||||
return "", errors.New("странное имя файла")
|
||||
}
|
||||
docDir := "DOC"
|
||||
if _, err := os.Stat(docDir); err != nil {
|
||||
return "", fmt.Errorf("DOC/ не доступен: %w", err)
|
||||
docDir := docsWritableDir()
|
||||
if docDir == "" {
|
||||
return "", errors.New("нет доступного для записи каталога документов")
|
||||
}
|
||||
dst := filepath.Join(docDir, name)
|
||||
// Если файл уже есть — переименуем как backup.
|
||||
// Если файл уже есть — переименуем как backup (расширение сохраняем).
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
old := filepath.Join(docDir,
|
||||
strings.TrimSuffix(name, ".pdf")+
|
||||
"."+time.Now().Format("2006-01-02")+".pdf.bak")
|
||||
strings.TrimSuffix(name, ext)+
|
||||
"."+time.Now().Format("2006-01-02")+ext+".bak")
|
||||
_ = os.Rename(dst, old)
|
||||
}
|
||||
|
||||
@@ -263,6 +287,27 @@ func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) {
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// docsWritableDir выбирает каталог для скачанных документов НРД. Под systemd
|
||||
// репозиторий /opt/bj/src/DOC монтируется read-only (ProtectSystem=strict),
|
||||
// поэтому основной путь — /var/lib/bj/.bj/nsd-docs (в ReadWritePaths). В деве
|
||||
// (репозиторий доступен на запись) используется DOC/. Возвращает "" если ни
|
||||
// один путь не записываемый.
|
||||
func docsWritableDir() string {
|
||||
for _, d := range []string{"/var/lib/bj/.bj/nsd-docs", "DOC"} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
continue
|
||||
}
|
||||
// Проверяем именно право записи (MkdirAll на существующий RO-каталог не ошибётся).
|
||||
probe := filepath.Join(d, ".wtest")
|
||||
if f, err := os.Create(probe); err == nil {
|
||||
f.Close()
|
||||
_ = os.Remove(probe)
|
||||
return d
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StartDocWatcher запускает горутину, которая раз в сутки проверяет
|
||||
// DocSources и эмитирует новости. Стартует через 60 сек после Run().
|
||||
func StartDocWatcher(rc *RuntimeConfig) func() {
|
||||
|
||||
Reference in New Issue
Block a user