220 lines
6.7 KiB
Go
220 lines
6.7 KiB
Go
package scraper
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// technosuccessSource is technosuccess.ru — unlike Regard, prices/stock are
|
||
// only rendered once logged in (see this package's Item doc comment for
|
||
// what "wholesale login required" meant during reconnaissance): a wholesale
|
||
// account's own email/password, not a public catalog. Server-rendered
|
||
// Symfony HTML throughout, no anti-bot — a cookiejar-backed http.Client
|
||
// carrying the session through login is all that's needed, no headless
|
||
// browser required despite the auth requirement.
|
||
type technosuccessSource struct {
|
||
client *http.Client
|
||
email string
|
||
password string
|
||
|
||
mu chan struct{} // 1-buffered mutex so concurrent FetchCategory calls don't race the shared cookiejar login
|
||
loggedIn bool
|
||
}
|
||
|
||
// NewTechnosuccess returns nil if email/password aren't configured (see
|
||
// TECHNOSUCCESS_EMAIL/TECHNOSUCCESS_PASSWORD in main.go) — main.go skips
|
||
// registering a nil source rather than every call site needing to check.
|
||
func NewTechnosuccess(email, password string) Source {
|
||
if email == "" || password == "" {
|
||
return nil
|
||
}
|
||
jar, _ := cookiejar.New(nil)
|
||
mu := make(chan struct{}, 1)
|
||
mu <- struct{}{}
|
||
return &technosuccessSource{
|
||
client: &http.Client{Timeout: 30 * time.Second, Jar: jar},
|
||
email: email,
|
||
password: password,
|
||
mu: mu,
|
||
}
|
||
}
|
||
|
||
func (t *technosuccessSource) Name() string { return "technosuccess" }
|
||
|
||
var technosuccessCategories = map[string]string{
|
||
"motherboard": "materinskie-platy",
|
||
"ram": "operativnaya-pamyat",
|
||
"gpu": "videokarty",
|
||
"psu": "bloki-pitaniya",
|
||
"case": "korpusa",
|
||
"cooler": "kulery-dlya-processorov",
|
||
"storage": "ssd-nakopiteli",
|
||
// no "cpu" mapping — technosuccess.ru doesn't carry standalone desktop
|
||
// CPUs as their own category (reconnaissance found only server CPUs);
|
||
// FetchCategory returns (nil, nil) for it below, same as any other
|
||
// source simply not stocking a slot.
|
||
}
|
||
|
||
var (
|
||
csrfTokenRe = regexp.MustCompile(`(?s)id="account_login".*?name="_csrf_token"\s+value="([^"]+)"`)
|
||
dataIDRe = regexp.MustCompile(`data-id="(\d+)"`)
|
||
productURLRe = regexp.MustCompile(`<a href='([^']+)'>\s*<picture`)
|
||
productNameRe = regexp.MustCompile(`product-item-name_text[^>]*title='([^']+)'`)
|
||
// digits are thousands-separated with U+2009 THIN SPACE (not a plain
|
||
// space or U+00A0 NBSP — confirmed against the live markup), and RE2's
|
||
// \s is ASCII-only, so it's listed explicitly alongside \s and NBSP.
|
||
priceMainRe = regexp.MustCompile(`product-item-price_main">\s*([\d\s\x{00A0}\x{2009}]+)\s*₽`)
|
||
stockRe = regexp.MustCompile(`(?:Москва|Под заказ):\s*(\d+)\s*шт`)
|
||
)
|
||
|
||
// productBlockSize is generous enough to contain one product-item <li>'s
|
||
// image/name/labels/price/actions markup (observed ~3-5KB per item) without
|
||
// running into the next product — every field extractor below scans within
|
||
// one block, keyed off each data-id="..." match's position.
|
||
const productBlockSize = 6000
|
||
|
||
func (t *technosuccessSource) ensureLoggedIn(ctx context.Context) error {
|
||
<-t.mu
|
||
defer func() { t.mu <- struct{}{} }()
|
||
if t.loggedIn {
|
||
return nil
|
||
}
|
||
|
||
homeReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://technosuccess.ru/", nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
homeReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||
homeResp, err := t.client.Do(homeReq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer homeResp.Body.Close()
|
||
body, err := io.ReadAll(homeResp.Body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
m := csrfTokenRe.FindSubmatch(body)
|
||
if m == nil {
|
||
return fmt.Errorf("technosuccess: login csrf token not found")
|
||
}
|
||
csrfToken := string(m[1])
|
||
|
||
form := url.Values{
|
||
"email": {t.email},
|
||
"password": {t.password},
|
||
"_csrf_token": {csrfToken},
|
||
"_remember_me": {"1"},
|
||
}
|
||
loginReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://technosuccess.ru/profile/auth/login", strings.NewReader(form.Encode()))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
loginReq.Header.Set("User-Agent", homeReq.Header.Get("User-Agent"))
|
||
loginResp, err := t.client.Do(loginReq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer loginResp.Body.Close()
|
||
io.Copy(io.Discard, loginResp.Body)
|
||
if loginResp.StatusCode >= 400 {
|
||
return fmt.Errorf("technosuccess: login failed with status %d", loginResp.StatusCode)
|
||
}
|
||
|
||
t.loggedIn = true
|
||
return nil
|
||
}
|
||
|
||
func (t *technosuccessSource) FetchCategory(ctx context.Context, componentType string) ([]Item, error) {
|
||
slug, ok := technosuccessCategories[componentType]
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
if err := t.ensureLoggedIn(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
categoryURL := "https://technosuccess.ru/" + slug + "/"
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, categoryURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||
resp, err := t.client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("technosuccess: unexpected status %d for %s", resp.StatusCode, categoryURL)
|
||
}
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
html := string(body)
|
||
|
||
var out []Item
|
||
seen := map[string]bool{}
|
||
for _, m := range dataIDRe.FindAllStringSubmatchIndex(html, -1) {
|
||
id := html[m[2]:m[3]]
|
||
if seen[id] {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
|
||
blockEnd := m[1] + productBlockSize
|
||
if blockEnd > len(html) {
|
||
blockEnd = len(html)
|
||
}
|
||
block := html[m[1]:blockEnd]
|
||
|
||
nameM := productNameRe.FindStringSubmatch(block)
|
||
urlM := productURLRe.FindStringSubmatch(block)
|
||
priceM := priceMainRe.FindStringSubmatch(block)
|
||
if nameM == nil || urlM == nil || priceM == nil {
|
||
continue
|
||
}
|
||
price := parseRUPrice(priceM[1])
|
||
if price <= 0 {
|
||
continue
|
||
}
|
||
|
||
productURL := urlM[1]
|
||
if strings.HasPrefix(productURL, "/") {
|
||
productURL = "https://technosuccess.ru" + productURL
|
||
}
|
||
|
||
out = append(out, Item{
|
||
ExternalID: id,
|
||
Name: nameM[1],
|
||
Price: price,
|
||
ProductURL: productURL,
|
||
InStock: stockRe.MatchString(block),
|
||
Spec: specFromText(componentType, nameM[1]),
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func parseRUPrice(s string) float64 {
|
||
s = strings.ReplaceAll(s, " ", "")
|
||
s = strings.ReplaceAll(s, " ", "") // non-breaking space, common in RU price formatting
|
||
s = strings.ReplaceAll(s, " ", "") // thin space — technosuccess.ru's actual thousands separator
|
||
n, err := strconv.ParseFloat(s, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|