grimoir/fetch.go
2026-05-07 09:52:06 +03:00

551 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 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)<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']`)
if ogTitle == "" {
ogTitle = reFind(html, `(?i)<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']`)
}
if ogTitle != "" {
return cleanText(ogTitle)
}
// Обычный <title>
title := reFind(html, `(?i)<title[^>]*>([^<]+)</title>`)
if title != "" {
return cleanText(title)
}
// H1
h1 := reFind(html, `(?i)<h1[^>]*>([^<]+)</h1>`)
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)<article[^>]*>(.*?)</article>`},
{`(?is)<main[^>]*>(.*?)</main>`},
{`(?is)<div[^>]+role=["']main["'][^>]*>(.*?)</div>`},
{`(?is)<div[^>]+class=["'][^"']*(?:article|post|content|entry|story|text|body)[^"']*["'][^>]*>(.*?)</div>`},
{`(?is)<div[^>]+id=["'][^"']*(?:article|post|content|entry|story|main)[^"']*["'][^>]*>(.*?)</div>`},
}
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)<div[^>]*>(.*?)</div>`)
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, "<!--") {
return
}
lower := strings.ToLower(tag)
closing := strings.HasPrefix(lower, "</")
name := tagName(lower)
switch name {
case "h1":
if !closing {
c.sb.WriteString("\n\n# ")
} else {
c.sb.WriteString("\n\n")
}
case "h2":
if !closing {
c.sb.WriteString("\n\n## ")
} else {
c.sb.WriteString("\n\n")
}
case "h3":
if !closing {
c.sb.WriteString("\n\n### ")
} else {
c.sb.WriteString("\n\n")
}
case "h4", "h5", "h6":
if !closing {
c.sb.WriteString("\n\n#### ")
} else {
c.sb.WriteString("\n\n")
}
case "p":
if closing {
c.sb.WriteString("\n\n")
}
case "br":
c.sb.WriteString(" \n")
case "strong", "b":
c.sb.WriteString("**")
case "em", "i":
c.sb.WriteString("*")
case "code":
if !c.inPre {
c.sb.WriteString("`")
c.inCode = !closing
}
case "pre":
if !closing {
c.inPre = true
// Пробуем определить язык из class
lang := reFind(tag, `class=["'][^"']*language-([a-z]+)["']`)
if lang == "" {
lang = reFind(tag, `class=["'][^"']*lang-([a-z]+)["']`)
}
c.sb.WriteString("\n\n```" + lang + "\n")
} else {
c.inPre = false
c.sb.WriteString("\n```\n\n")
}
case "blockquote":
if !closing {
c.sb.WriteString("\n\n> ")
} else {
c.sb.WriteString("\n\n")
}
case "ul":
if !closing {
c.listDepth++
c.orderedList = false
} else {
c.listDepth--
c.sb.WriteString("\n")
}
case "ol":
if !closing {
c.listDepth++
c.orderedList = true
c.listCounter = 0
} else {
c.listDepth--
c.sb.WriteString("\n")
}
case "li":
if !closing {
indent := strings.Repeat(" ", max(0, c.listDepth-1))
if c.orderedList {
c.listCounter++
c.sb.WriteString(fmt.Sprintf("\n%s%d. ", indent, c.listCounter))
} else {
c.sb.WriteString(fmt.Sprintf("\n%s- ", indent))
}
}
case "a":
if !closing {
href := reFind(tag, `href=["']([^"']+)["']`)
href = resolveURL(href, c.baseURL)
if href != "" {
c.sb.WriteString("[")
// Закрывающий тег допишет текст, потом нужна ссылка
// Упрощение: просто пишем ссылку как обычный текст
// (полноценная обработка требует стека тегов)
_ = href
}
} else {
href := "" // теряем href при таком подходе — нужен стек
_ = href
}
case "img":
alt := reFind(tag, `alt=["']([^"']*)["']`)
src := reFind(tag, `src=["']([^"']+)["']`)
src = resolveURL(src, c.baseURL)
if src != "" {
c.sb.WriteString(fmt.Sprintf("![%s](%s)", alt, src))
}
case "hr":
c.sb.WriteString("\n\n---\n\n")
case "table":
// Таблицы — пропускаем структуру, текст ячеек останется
case "tr":
if closing {
c.sb.WriteString(" |\n")
} else {
c.sb.WriteString("| ")
}
case "th", "td":
if closing {
c.sb.WriteString(" | ")
}
case "div", "section", "article", "main", "span":
// Блочные — просто перенос, inline — ничего
if name != "span" {
if closing {
c.sb.WriteString("\n")
}
}
}
}
func (c *mdConverter) handleText(text string) {
if c.inPre {
c.sb.WriteString(text)
return
}
// Нормализуем пробелы
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
text = strings.TrimSpace(text)
if text != "" {
c.sb.WriteString(htmlEntities(text))
}
}
// === Вспомогательные функции ===
// removeTagContent удаляет тег вместе с содержимым
func removeTagContent(html, tag string) string {
re := regexp.MustCompile(`(?is)<` + tag + `[^>]*>.*?</` + tag + `>`)
return re.ReplaceAllString(html, "")
}
// removeTagAttr удаляет атрибуты по паттерну
func removeTagAttr(html, attrPattern string) string {
re := regexp.MustCompile(`(?i)\s+(?:` + attrPattern + `)=["'][^"']*["']`)
return re.ReplaceAllString(html, "")
}
// stripTags удаляет все HTML теги
func stripTags(html string) string {
re := regexp.MustCompile(`<[^>]+>`)
return re.ReplaceAllString(html, "")
}
// tagName извлекает имя тега из строки типа "<h1 ...>" или "</h1>"
func tagName(tag string) string {
tag = strings.TrimPrefix(tag, "</")
tag = strings.TrimPrefix(tag, "<")
tag = strings.TrimSuffix(tag, ">")
tag = strings.TrimSuffix(tag, "/")
fields := strings.Fields(tag)
if len(fields) == 0 {
return ""
}
return strings.ToLower(fields[0])
}
// reFind возвращает первую захваченную группу regexp
func reFind(s, pattern string) string {
m := regexp.MustCompile(pattern).FindStringSubmatch(s)
if len(m) > 1 {
return m[1]
}
return ""
}
// cleanText чистит строку от HTML и лишних пробелов
func cleanText(s string) string {
s = stripTags(s)
s = htmlEntities(s)
s = strings.TrimSpace(s)
s = regexp.MustCompile(`\s+`).ReplaceAllString(s, " ")
return s
}
// htmlEntities декодирует базовые HTML entities
func htmlEntities(s string) string {
replacer := strings.NewReplacer(
"&amp;", "&",
"&lt;", "<",
"&gt;", ">",
"&quot;", "\"",
"&#39;", "'",
"&apos;", "'",
"&nbsp;", " ",
"&mdash;", "—",
"&ndash;", "",
"&laquo;", "«",
"&raquo;", "»",
"&hellip;", "…",
"&copy;", "©",
"&reg;", "®",
"&trade;", "™",
"&bull;", "•",
"&middot;", "·",
)
s = replacer.Replace(s)
// Числовые entities &#NNNN; — пропускаем (редко критичны)
return s
}
// resolveURL разрешает относительный URL
func resolveURL(href, base string) string {
if href == "" {
return ""
}
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href
}
u, err := url.Parse(base)
if err != nil {
return href
}
ref, err := url.Parse(href)
if err != nil {
return href
}
return u.ResolveReference(ref).String()
}
// cleanMarkdown чистит результат конвертации
func cleanMarkdown(s string) string {
// Убираем тройные+ пустые строки
s = regexp.MustCompile(`\n{4,}`).ReplaceAllString(s, "\n\n\n")
// Убираем пробелы в конце строк
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = strings.TrimRight(l, " \t")
}
s = strings.Join(lines, "\n")
return strings.TrimSpace(s) + "\n"
}
// slugify превращает заголовок в имя файла
func slugify(title string) string {
// Транслитерируем кириллицу
var sb strings.Builder
for _, r := range strings.ToLower(title) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
sb.WriteRune(r)
case r == ' ' || r == '-' || r == '_':
sb.WriteRune('-')
default:
if t := translitRune(r); t != "" {
sb.WriteString(strings.ToLower(t))
}
}
}
result := sb.String()
// Убираем повторные дефисы
result = regexp.MustCompile(`-+`).ReplaceAllString(result, "-")
result = strings.Trim(result, "-")
if result == "" {
result = "article"
}
// Ограничиваем длину
runes := []rune(result)
if len(runes) > 60 {
result = string(runes[:60])
result = strings.TrimRight(result, "-")
}
return result
}
// isOnlySpaces проверяет что строка только из пробельных символов
func isOnlySpaces(s string) bool {
for _, r := range s {
if !unicode.IsSpace(r) {
return false
}
}
return true
}
// SaveFetchResult сохраняет результат в файл
func SaveFetchResult(result *FetchResult, dir string) (string, error) {
path := result.Filename
if dir != "" {
path = dir + string(os.PathSeparator) + result.Filename
}
if err := writeFileImpl(path, result.Markdown); err != nil {
return "", err
}
return path, nil
}