Files
aura-crm/production/backend/internal/scraper/technosuccess.go
T

220 lines
6.7 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}