286 lines
8.3 KiB
Go
286 lines
8.3 KiB
Go
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)
|
||
}
|