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 и вытаскивает абсолютные ссылки // из , а также описания форм из
/. 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) }