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, и содержимое за ними иначе никогда не попало бы в обход
// (оно не существует как , пока форму не отправить). 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 и вытаскивает абсолютные ссылки
// из , а также описания форм из