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) } // Обычный title := reFind(html, `(?i)<title[^>]*>([^<]+)`) if title != "" { return cleanText(title) } // H1 h1 := reFind(html, `(?i)]*>([^<]+)`) if h1 != "" { return cleanText(h1) } return "article" } // extractMainContent выбирает основной контент статьи из HTML func extractMainContent(html string) string { // Удаляем ненужные секции html = removeTagContent(html, "script") html = removeTagContent(html, "style") html = removeTagContent(html, "nav") html = removeTagContent(html, "header") html = removeTagContent(html, "footer") html = removeTagContent(html, "aside") html = removeTagContent(html, "noscript") html = removeTagContent(html, "iframe") html = removeTagAttr(html, "onclick|onload|onmouseover|onmouseout") // Кандидаты — семантические теги в порядке приоритета candidates := []struct { pattern string }{ {`(?is)]*>(.*?)`}, {`(?is)]*>(.*?)`}, {`(?is)]+role=["']main["'][^>]*>(.*?)`}, {`(?is)]+class=["'][^"']*(?:article|post|content|entry|story|text|body)[^"']*["'][^>]*>(.*?)`}, {`(?is)]+id=["'][^"']*(?:article|post|content|entry|story|main)[^"']*["'][^>]*>(.*?)`}, } for _, c := range candidates { if m := regexp.MustCompile(c.pattern).FindStringSubmatch(html); m != nil { if len(stripTags(m[1])) > 200 { // минимум контента return m[1] } } } // Fallback: ищем div с максимальной плотностью текста return findDenseTextBlock(html) } // findDenseTextBlock ищет блок с наибольшим количеством текста func findDenseTextBlock(html string) string { divRe := regexp.MustCompile(`(?is)]*>(.*?)`) matches := divRe.FindAllStringSubmatch(html, -1) best := "" bestLen := 0 for _, m := range matches { text := stripTags(m[1]) if len(text) > bestLen { bestLen = len(text) best = m[1] } } if best != "" { return best } return html // совсем fallback — весь HTML } // === HTML → Markdown конвертер === func htmlToMarkdown(html, baseURL string) string { // Нормализуем переносы строк html = strings.ReplaceAll(html, "\r\n", "\n") html = strings.ReplaceAll(html, "\r", "\n") var sb strings.Builder converter := &mdConverter{baseURL: baseURL, sb: &sb} converter.convert(html) // Чистим результат result := sb.String() result = cleanMarkdown(result) return result } type mdConverter struct { baseURL string sb *strings.Builder inPre bool inCode bool listDepth int orderedList bool listCounter int } func (c *mdConverter) convert(html string) { // Обрабатываем тег за тегом tagRe := regexp.MustCompile(`(?is)(<[^>]+>)|([^<]+)`) matches := tagRe.FindAllStringSubmatch(html, -1) for _, m := range matches { tag := m[1] text := m[2] if tag != "" { c.handleTag(tag) } else if text != "" { c.handleText(text) } } } func (c *mdConverter) handleTag(tag string) { tag = strings.TrimSpace(tag) if strings.HasPrefix(tag, "