burterm/internal/spider/spider.go
2026-09-14 10:55:07 +03:00

286 lines
8.3 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 spider
import (
"context"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/net/html"
)
// Page — результат обхода одной страницы.
type Page struct {
URL string
Status int
Links []string
Forms []Form
}
// Form описывает найденную HTML-форму.
type Form struct {
Action string
Method string
Fields []string
}
// Spider выполняет ограниченный по scope и глубине обход сайта.
type Spider struct {
scope *url.URL
maxDepth int
client *http.Client
// headers применяются к каждому запросу обхода — сюда попадают
// сессионные Cookie/Authorization, которые TUI подхватывает из
// истории Proxy (см. proxy.ExtractSessionHeaders), чтобы краулинг
// мог заходить в авторизованные зоны без ручного копирования куки.
headers http.Header
visited sync.Map // url string -> struct{}
results chan Page
inProgress sync.WaitGroup
}
// New создаёт Spider, ограниченный доменом scope и глубиной maxDepth.
//
// Собственный Transport, а не голый http.Client{} (который неявно
// использовал бы общий пакетный http.DefaultTransport, разделяемый ещё
// с чем угодно в процессе, случайно на него опершимся) — с тем же
// тюнингом пула соединений, что и в engine.New: bfs рекурсивно плодит
// горутину на каждую найденную ссылку без явного лимита, так что
// конкурентных запросов к одному хосту может набраться заметно больше
// дефолтных двух идle-соединений.
func New(scope *url.URL, maxDepth int) *Spider {
transport := &http.Transport{
MaxIdleConnsPerHost: 50,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
}
return &Spider{
scope: scope,
maxDepth: maxDepth,
client: &http.Client{Transport: transport},
results: make(chan Page, 64),
}
}
// SetHeaders задаёт заголовки, применяемые к каждому запросу обхода.
func (s *Spider) SetHeaders(h http.Header) {
s.headers = h
}
// Crawl запускает BFS обход, ограниченный scope (домен/поддомены)
// и maxDepth. Возвращает канал с найденными страницами; канал
// закрывается, когда обход полностью завершён или ctx отменён.
func (s *Spider) Crawl(ctx context.Context, start string) <-chan Page {
s.inProgress.Add(1)
go s.bfs(ctx, start, 0)
go func() {
s.inProgress.Wait()
close(s.results)
}()
return s.results
}
func (s *Spider) bfs(ctx context.Context, link string, depth int) {
defer s.inProgress.Done()
select {
case <-ctx.Done():
return
default:
}
if depth > s.maxDepth {
return
}
if _, seen := s.visited.LoadOrStore(link, struct{}{}); seen {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
if err != nil {
return
}
for k, vv := range s.headers {
for _, v := range vv {
req.Header.Add(k, v)
}
}
resp, err := s.client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
links, forms := extractLinksAndForms(resp.Body, link)
inScope := make([]string, 0, len(links))
for _, l := range links {
if s.inScope(l) {
inScope = append(inScope, l)
}
}
// Авто-сабмит GET-форм: поисковые поля и фильтры почти всегда ведут
// на GET, и содержимое за ними иначе никогда не попало бы в обход
// (оно не существует как <a href>, пока форму не отправить). POST-формы
// сознательно пропускаем — GET по HTTP-спецификации должен быть
// безопасным/идемпотентным, POST обычно меняет состояние (логин,
// удаление, отправка данных), и автоматически дёргать их было бы
// небезопасно. Оговорка: некоторые сайты нарушают спецификацию и
// делают side-effect'ы даже через GET (напр. "GET-логаут") — это
// осознанный редкий риск ради автоматизации, а не гарантия.
for _, f := range forms {
if !strings.EqualFold(f.Method, "GET") || f.Action == "" {
continue
}
formURL, err := buildGetFormURL(f)
if err != nil {
continue
}
if s.inScope(formURL) {
inScope = append(inScope, formURL)
}
}
select {
case s.results <- Page{
URL: link,
Status: resp.StatusCode,
Links: inScope,
Forms: forms,
}:
case <-ctx.Done():
return
}
for _, next := range inScope {
s.inProgress.Add(1)
go s.bfs(ctx, next, depth+1)
}
}
// buildGetFormURL конструирует URL для GET-формы: берёт Action (уже
// абсолютный — resolve() применяется при извлечении формы) и добавляет
// в query по одному заполнителю на каждое известное поле формы. "test" —
// нейтральное значение-заглушка, не привязанное к конкретному сайту;
// поля, у которых в самом action уже есть значение (редко, но бывает
// в hidden-полях с предзаполненным query), не перезаписываются.
func buildGetFormURL(f Form) (string, error) {
u, err := url.Parse(f.Action)
if err != nil {
return "", err
}
q := u.Query()
for _, name := range f.Fields {
if name == "" || q.Get(name) != "" {
continue
}
q.Set(name, "test")
}
u.RawQuery = q.Encode()
return u.String(), nil
}
// extractLinksAndForms парсит HTML и вытаскивает абсолютные ссылки
// из <a href>, а также описания форм из <form>/<input>.
func extractLinksAndForms(body io.Reader, base string) ([]string, []Form) {
baseURL, err := url.Parse(base)
if err != nil {
return nil, nil
}
doc, err := html.Parse(body)
if err != nil {
return nil, nil
}
var links []string
var forms []Form
var curForm *Form
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.Data {
case "a":
if href, ok := attr(n, "href"); ok {
if abs := resolve(baseURL, href); abs != "" {
links = append(links, abs)
}
}
case "form":
action, _ := attr(n, "action")
method, ok := attr(n, "method")
if !ok {
method = "GET"
}
f := Form{
Action: resolve(baseURL, action),
Method: strings.ToUpper(method),
}
curForm = &f
case "input", "textarea", "select":
if curForm != nil {
if name, ok := attr(n, "name"); ok {
curForm.Fields = append(curForm.Fields, name)
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
if n.Type == html.ElementNode && n.Data == "form" && curForm != nil {
forms = append(forms, *curForm)
curForm = nil
}
}
walk(doc)
return links, forms
}
// attr возвращает значение атрибута узла по имени.
func attr(n *html.Node, key string) (string, bool) {
for _, a := range n.Attr {
if a.Key == key {
return a.Val, true
}
}
return "", false
}
// resolve превращает относительную ссылку в абсолютную относительно base.
// Игнорирует не-http(s) схемы (mailto:, javascript:, tel: и т.п.).
func resolve(base *url.URL, ref string) string {
if ref == "" {
return ""
}
u, err := url.Parse(ref)
if err != nil {
return ""
}
abs := base.ResolveReference(u)
if abs.Scheme != "http" && abs.Scheme != "https" {
return ""
}
abs.Fragment = ""
return abs.String()
}
func (s *Spider) inScope(link string) bool {
u, err := url.Parse(link)
if err != nil {
return false
}
return u.Host == s.scope.Host || strings.HasSuffix(u.Host, "."+s.scope.Host)
}