Тестирование и пользовательские расширения
Это руководство описывает, как настроить алгоритм скоринга контента и писать тесты для кода, использующего библиотеку HTML.
Пользовательский Scorer
Интерфейс Scorer управляет двумя ключевыми решениями: как распознать основной контент и какие узлы следует удалить.
Определение интерфейса
type Scorer interface {
Score(node ContentNode) int
ShouldRemove(node ContentNode) bool
}Score: оценка узла, чем выше балл, тем вероятнее, что узел будет выбран как контейнер основного контентаShouldRemove: возвратtrueозначает удаление узла перед извлечением
Поведение по умолчанию
Если Scorer не настроен, используется встроенный скорер по умолчанию. Он вычисляет оценку на основе характеристик узла (плотность текста, пропорция абзацев, семантика тегов и т.д.).
Реализация пользовательского Scorer
package main
import (
"fmt"
"log"
"strings"
"github.com/cybergodev/html"
)
// blogScorer — скорер, оптимизированный для блогов
type blogScorer struct{}
func (s blogScorer) Score(node html.ContentNode) int {
if node == nil {
return 0
}
score := 0
class := strings.ToLower(node.AttrValue("class"))
id := strings.ToLower(node.AttrValue("id"))
tag := node.Data()
// Позитивные сигналы: классы/id, связанные со статьёй
if containsAny(class, "article", "post", "content", "entry") {
score += 50
}
if containsAny(id, "article", "post", "content") {
score += 60
}
// Бонус за семантические теги
switch tag {
case "article":
score += 80
case "main":
score += 70
case "section":
score += 30
}
// Негативные сигналы
if containsAny(class, "sidebar", "comment", "footer", "nav", "menu") {
score -= 50
}
if containsAny(id, "sidebar", "comments", "footer") {
score -= 60
}
return score
}
func (s blogScorer) ShouldRemove(node html.ContentNode) bool {
if node == nil {
return false
}
// Удаление навигации и подвала
switch node.Data() {
case "nav", "footer", "header":
return true
}
// Удаление рекламы и области комментариев
class := strings.ToLower(node.AttrValue("class"))
return containsAny(class, "ad", "advertisement", "comment", "social-share")
}
func containsAny(s string, keywords ...string) bool {
for _, kw := range keywords {
if strings.Contains(s, kw) {
return true
}
}
return false
}
func main() {
cfg := html.DefaultConfig()
cfg.Scorer = blogScorer{}
p, err := html.New(cfg)
if err != nil {
log.Fatal(err)
}
defer p.Close()
data := []byte(`<html><body>
<article class="post"><h1>Тестовая статья</h1><p>Основной контент</p></article>
</body></html>`)
result, err := p.Extract(data)
if err != nil {
log.Fatal(err)
}
fmt.Println(result.Text)
}Интерфейс ContentNode
ContentNode — это абстракция узла, используемая в интерфейсе Scorer, скрывающая конкретные типы базового HTML-парсера:
type ContentNode interface {
Type() string // "element", "text", "comment" и др.
Data() string // Имя тега или текстовое содержимое
AttrValue(key string) string // Получение значения атрибута
Attrs() []NodeAttr // Получение всех атрибутов
FirstChild() ContentNode // Первый дочерний узел
NextSibling() ContentNode // Следующий родственный узел
Parent() ContentNode // Родительский узел
}Обход узлов
func (s myScorer) Score(root html.ContentNode) int {
score := 0
// Обход дочерних узлов
for child := root.FirstChild(); child != nil; child = child.NextSibling() {
if child.Type() == "element" {
// Проверка плотности вложенного текста
textLen := countTextLength(child)
if textLen > 200 {
score += 10
}
}
}
return score
}Режим тестирования
Отключение кэша
В тестах кэш обычно не нужен, после отключения каждый вызов будет «чистым»:
cfg := html.DefaultConfig()
cfg.MaxCacheEntries = 0 // Отключить кэшОтключение очистки
Для доверенного ввода можно отключить очистку безопасности, чтобы тестовый HTML не изменялся:
cfg := html.DefaultConfig()
cfg.EnableSanitization = falseТолько для тестирования
В производственной среде обязательно сохраняйте EnableSanitization = true.
Использование TextOnlyConfig
При тестировании логики извлечения текста используйте TextOnlyConfig для уменьшения шума:
result, err := html.Extract(data, html.TextOnlyConfig())Написание тестов
Тестирование результата извлечения
func TestExtractTitle(t *testing.T) {
data := []byte(`<html><head><title>Тестовый заголовок</title></head>
<body><p>Основной контент</p></body></html>`)
result, err := html.Extract(data)
require.NoError(t, err)
assert.Equal(t, "Тестовый заголовок", result.Title)
assert.Contains(t, result.Text, "Основной контент")
}Тестирование пользовательского Scorer
func TestBlogScorer(t *testing.T) {
cfg := html.DefaultConfig()
cfg.Scorer = blogScorer{}
cfg.MaxCacheEntries = 0 // Отключить кэш
p, err := html.New(cfg)
require.NoError(t, err)
defer p.Close()
data := []byte(`<html><body>
<nav><a href="/">Главная</a></nav>
<article class="post">
<h1>Заголовок блога</h1>
<p>Основной текст блога</p>
</article>
<aside class="sidebar">Боковая панель</aside>
</body></html>`)
result, err := p.Extract(data)
require.NoError(t, err)
assert.Contains(t, result.Text, "Основной текст блога")
assert.NotContains(t, result.Text, "Боковая панель")
assert.NotContains(t, result.Text, "Главная")
}Тестирование обработки ошибок
func TestInputTooLarge(t *testing.T) {
cfg := html.DefaultConfig()
cfg.MaxInputSize = 100 // Экстремально малый лимит
largeData := make([]byte, 200)
_, err := html.Extract(largeData, cfg)
assert.ErrorIs(t, err, html.ErrInputTooLarge)
}Тестирование журнала аудита
func TestAuditLog(t *testing.T) {
cfg := html.DefaultConfig()
cfg.Audit = html.DefaultAuditConfig()
cfg.Audit.Enabled = true
cfg.MaxCacheEntries = 0
p, _ := html.New(cfg)
defer p.Close()
data := []byte(`<html><body><script>alert(1)</script><p>Текст</p></body></html>`)
p.Extract(data)
entries := p.GetAuditLog()
t.Logf("Событий аудита: %d", len(entries))
for _, e := range entries {
t.Logf(" [%s] %s", e.EventType, e.Message)
}
}Распространённые сценарии расширения
Настройка извлечения для конкретного сайта
func newSiteScorer(site string) html.Scorer {
switch site {
case "github.com":
return githubScorer{}
case "medium.com":
return mediumScorer{}
default:
return nil // Использовать скорер по умолчанию
}
}Статистика распределения атрибутов узлов
func analyzeStructure(node html.ContentNode) map[string]int {
counts := make(map[string]int)
walk(node, counts)
return counts
}
func walk(node html.ContentNode, counts map[string]int) {
if node == nil {
return
}
if node.Type() == "element" {
counts[node.Data()]++
}
walk(node.FirstChild(), counts)
walk(node.NextSibling(), counts)
}Следующие шаги
- Справочник API: Интерфейсы - Полные определения Scorer и ContentNode
- Справочник API: Конфигурация - Поле конфигурации Scorer
- FAQ - Часто задаваемые вопросы