551 lines
14 KiB
Go
Executable file
551 lines
14 KiB
Go
Executable file
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("", 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(
|
||
"&", "&",
|
||
"<", "<",
|
||
">", ">",
|
||
""", "\"",
|
||
"'", "'",
|
||
"'", "'",
|
||
" ", " ",
|
||
"—", "—",
|
||
"–", "–",
|
||
"«", "«",
|
||
"»", "»",
|
||
"…", "…",
|
||
"©", "©",
|
||
"®", "®",
|
||
"™", "™",
|
||
"•", "•",
|
||
"·", "·",
|
||
)
|
||
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
|
||
}
|