package main import ( "fmt" "io" "net/http" "net/url" "os" "regexp" "strings" "time" "unicode" ) // FetchResult — результат загрузки и конвертации страницы type FetchResult struct { Title string Markdown string Filename string // предлагаемое имя файла URL string } // IsURL проверяет что строка является HTTP(S) ссылкой func IsURL(s string) bool { s = strings.TrimSpace(s) return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") } // FetchAndConvert скачивает страницу и конвертирует в Markdown func FetchAndConvert(rawURL string) (*FetchResult, error) { // Нормализуем URL u, err := url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("invalid URL: %w", err) } if u.Scheme == "" { u.Scheme = "https" } // Скачиваем html, err := fetchHTML(u.String()) if err != nil { return nil, fmt.Errorf("fetch error: %w", err) } // Парсим и конвертируем title := extractTitle(html) contentHTML := extractMainContent(html) markdown := htmlToMarkdown(contentHTML, u.String()) // Добавляем заголовок и источник в начало документа header := fmt.Sprintf("# %s\n\n> Источник: [%s](%s)\n> Дата: %s\n\n---\n\n", title, u.Host, u.String(), time.Now().Format("02.01.2006"), ) result := &FetchResult{ Title: title, Markdown: header + markdown, Filename: slugify(title) + ".md", URL: u.String(), } return result, nil } // fetchHTML загружает HTML страницу func fetchHTML(rawURL string) (string, error) { client := &http.Client{ Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return fmt.Errorf("too many redirects") } return nil }, } req, err := http.NewRequest("GET", rawURL, nil) if err != nil { return "", err } // Представляемся нормальным браузером req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; grimoir/1.0; +https://github.com/grimoir)") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") req.Header.Set("Accept-Language", "ru,en;q=0.9") resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) } // Ограничиваем 5 MB body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) if err != nil { return "", err } return string(body), nil } // === Извлечение контента === // extractTitle извлекает заголовок страницы func extractTitle(html string) string { // Пробуем og:title (обычно чище) ogTitle := reFind(html, `(?i)]+property=["']og:title["'][^>]+content=["']([^"']+)["']`) if ogTitle == "" { ogTitle = reFind(html, `(?i)]+content=["']([^"']+)["'][^>]+property=["']og:title["']`) } if ogTitle != "" { return cleanText(ogTitle) } // Обычный