package unfurl import ( "errors" "fmt" "io" "net/netip" "net/url" "strings" "golang.org/x/net/html" ) // headMetadata — найденные в значения по источникам. Приоритет // разбирается в preview: og:* → twitter:* → обычные теги. type headMetadata struct { ogTitle string twitterTitle string title string ogDescription string twitterDescription string description string siteName string ogImage string twitterImage string } // parseHead разбирает потоково и останавливается на : тело // страницы не читается. Читатель обычно ограничен LimitReader, поэтому // обрыв по лимиту — не ошибка: возвращается всё, что успели найти. func parseHead(reader io.Reader, base *url.URL, allowLoopback bool) (Preview, error) { tokenizer := html.NewTokenizer(reader) var meta headMetadata inTitle := false parse: for { switch tokenizer.Next() { case html.ErrorToken: // io.EOF — нормальное завершение (в том числе обрыв по лимиту). if err := tokenizer.Err(); err != nil && !errors.Is(err, io.EOF) { return meta.preview(base, allowLoopback), fmt.Errorf("чтение html: %w", err) } break parse case html.CommentToken, html.DoctypeToken: // Комментарии и doctype не содержат метаданных превью. case html.StartTagToken, html.SelfClosingTagToken: name, hasAttr := tokenizer.TagName() switch string(name) { case "title": inTitle = true case "meta": // Атрибуты нужно вычитать до следующего Next. meta.add(attributes(tokenizer, hasAttr)) } case html.EndTagToken: name, _ := tokenizer.TagName() switch string(name) { case "title": inTitle = false case "head": break parse } case html.TextToken: if inTitle { // Токенизатор уже декодировал HTML-сущности. meta.title += string(tokenizer.Text()) } } } return meta.preview(base, allowLoopback), nil } // add раскладывает атрибуты по источникам. Ключи property/name // сравниваются без учёта регистра, первое значение каждого источника // выигрывает (повторы в разметке игнорируются). func (m *headMetadata) add(attrs map[string]string) { key := attrs["property"] if key == "" { key = attrs["name"] } content := attrs["content"] if key == "" || content == "" { return } switch strings.ToLower(strings.TrimSpace(key)) { case "og:title": setIfEmpty(&m.ogTitle, content) case "twitter:title": setIfEmpty(&m.twitterTitle, content) case "og:description": setIfEmpty(&m.ogDescription, content) case "twitter:description": setIfEmpty(&m.twitterDescription, content) case "description": setIfEmpty(&m.description, content) case "og:site_name": setIfEmpty(&m.siteName, content) case "og:image": setIfEmpty(&m.ogImage, content) case "twitter:image": setIfEmpty(&m.twitterImage, content) } } // preview собирает Preview по приоритетам: og:* → twitter:* → обычные теги. func (m headMetadata) preview(base *url.URL, allowLoopback bool) Preview { image := m.ogImage if image == "" { image = m.twitterImage } return Preview{ Title: sanitize(firstNonEmpty(m.ogTitle, m.twitterTitle, m.title), titleLimit), Description: sanitize(firstNonEmpty(m.ogDescription, m.twitterDescription, m.description), descriptionLimit), SiteName: sanitize(m.siteName, siteNameLimit), ImageURL: absoluteImageURL(image, base, allowLoopback), } } // absoluteImageURL приводит ссылку на картинку к абсолютному виду относительно // финального URL страницы. Картинка не скачивается: проверяется только сам URL // (http/https и не внутренний адрес), иначе возвращается пустая строка. func absoluteImageURL(raw string, base *url.URL, allowLoopback bool) string { raw = strings.TrimSpace(raw) if raw == "" || base == nil { return "" } reference, err := url.Parse(raw) if err != nil { return "" } normalized, ok := Normalize(base.ResolveReference(reference).String()) if !ok { return "" } parsed, err := url.Parse(normalized) if err != nil { return "" } host := parsed.Hostname() // DNS для картинки не спрашиваем, но имя localhost отсекаем сразу. lowerHost := strings.ToLower(host) if lowerHost == "localhost" || strings.HasSuffix(lowerHost, ".localhost") { return "" } if address, err := netip.ParseAddr(host); err == nil && blockedAddr(address, allowLoopback) { return "" } return normalized } // attributes вычитывает атрибуты текущего тега в нижнем регистре. func attributes(tokenizer *html.Tokenizer, hasAttr bool) map[string]string { attrs := make(map[string]string, 4) for hasAttr { var key, value []byte key, value, hasAttr = tokenizer.TagAttr() name := strings.ToLower(string(key)) if _, exists := attrs[name]; !exists { attrs[name] = string(value) } } return attrs } func setIfEmpty(target *string, value string) { if *target == "" { *target = value } } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" }