Стівлобота з детектором - ASP, ., NET Core, МSK2. Цей пост пояснює, як цей двигун підключений доGateways, M SK3, Node, Applications for Java, , та будь-якого іншого стека через sidecar gRPC. МSK6, вбудований Go SDK,, і Caddy плагин, без жодного з цих споживачів, які не мають нічого знати про . NET інтерфейси.
І
github.com/scottgal/stylobot-goSDK,github.com/scottgal/caddy-stylobotплагин, іMostlylucid.BotDetection.Sidecarконтейнер буде опубліковано незабаром. Все, що зображено на екрані, описує поверхню, яку вони покриють.
Серія випуску StyloBot
- Подібність, Не ідентичність: чому StyloBot моделює клієнтів поведінково
- Подібність-Усвідомлення ASP.NET UI: сервер - віддалена поверхня для МSK2NET застосувань
- Винайти та виправити безмежний ріст у довгостроковій-Running M SK1NET Services: - дисципліна надійності, яка робить двигун нудним в процесі виробництва
- Подібність-Усвідомлення інтерфейсу TypScript: Express , Fastify, і компоненти браузера
- Архітектура Sidecar: цей artykuł
- Навчитися швидше: адаптивна система навчання , чотири МSK2 пам 'ять на рівні M SK3 і кэш вердикта
- Випробовуємо те, що не зупиняє нас: контрольна дисципліна : один БДФ файл керує регресією МSK2 завантаженням МСК3 та калібрацією
- StyloExtract - локальний інтерфейс навчання HTML для конвертера Markdown: HTML → Марка-даун, який поєднується з детектором МSK2 walker bug lucidVIEW caught МSK3 and the dogfood loop that made it honest
Перевернутий прокси є очевидним місцем для запуску розпізнавання роботів : він сидить перед всіма МSK1 бачить кожну просьбу МСК2 і може блокувати перед запуском будь-якого коду aplikacji М СК3 Ця логіка працює аж доки ви не запитуєте, що ваші програми мають робити зробити інакше на основі того, хто робить прохання. Мережа, яка блокує напряму - це воротаM SK1 те, що більшість програм насправді потребує, це рішення, яке вони можуть робити одночасно в багатьох способах : перекрутити API МSK3 персоналізувати інтерфейсу M SK4 виключити трафик з аналітики , додати крок тремтіння при перевірці MSC6 ворота не можуть зробити нічого з цього Mska7 Тріт для прийняття рішення може M Ska8
Модель канатної дороги розділяє ці дві проблеми: . Перехідник залишається швидким і безладним . канатна машина підтримує стан сеансу МSK2 засадники архетипу \ , стан дрейфу \ МSK4 та резервуар вердикта, що роблять розпізнавання точним Навчитися швидше). Обидві зв 'язуються через локальну мережу ( той самий хост чи той самій Pod МSK2 так, що кругла \ - подорож є мікросекундами до одного \ МSK4 \ цифри мілісекунд , \ а не МSK6 \ ms віддаленого API дзвінка \
Це не нова модель. Прокси посла робить саме це для обслуговування-обговорення про мережу M SK1mTLS, ретриси , розбіжність ланцюгаMSC4 Дапр чи робить це для держави та pub/subM SK1 Коллекционер OpenTelemetry чи для телеметрії? . Linkerd, Consul ConnectM SK2 і AWS App Mesh всі підтримують один і той самий модель. МSK3 Модель постійно з 'являється, тому що вона вирішує реальну проблему.
Альтернативою є компіляція розпізнавання безпосередньо в порті. Для Go це означає чистуM SK1 Переimplementацію Go або CGo прив 'язування до C-ліbraрії. Для Нода це означає запуск розпізнання уMSC3процесі поряд з програмоюMスク4
Ни одна з них не є реалістичною для двигуна такої складності . StyloBot має 49 детектори, організовані на чотири етапи хвилі (пізніші хвилі реагують тільки тоді, коли попередні сигнали підтверджують це ; довіраність МSK2 спроба поглинання запускає різні детектори, ніж пошук Googlebotу Вектори ланцюга Маркова у просторі 129-вимірного розміру . Марковський ланцюг - це лише модель ймовірності над МSK2 беручи до уваги останні речі, які зробили на цій сеансі МSK3 те, що прийде далі МСК4 і ММСК5 виміри захоплюють достатньо сторінок MСК6 форми переходу, щоб розрізнити людей та роботів Розпізнання спільноти Лейдена над цими векторами: графом - заплутаний алгоритм, який об 'єднує сеанси, які поводяться однаково МSK2 це те, як StyloBot знаходить мережу роботів навіть тоді, коли окремі сеансі виглядають непогано M SK3 І все це продовжується до SQLite між посиланнями . Цей стан потребує незалежного життєвого циклу MSC5 Він не може розпочати процес узла або розірватися, коли порт перезагрузить свою конфігурацію
Автомобіль дозволяє кожному компоненту робити те, що йому добре вдається.
graph TD
classDef input fill:none,stroke:#3b82f6,stroke-width:2px
classDef async fill:none,stroke:#a855f7,stroke-width:2px
classDef good fill:none,stroke:#22c55e,stroke-width:2px
classDef store fill:none,stroke:#f59e0b,stroke-width:2px
GW["Gateway<br/>Caddy / YARP / nginx"]:::input
SD["StyloBot Sidecar · ASP.NET Core<br/>gRPC :5090 · REST :5091<br/>≤50ms per Detect RPC"]:::async
APP["Upstream Application<br/>Node / Go / ASP.NET<br/>reads req.stylobot.verdict"]:::good
DB[("SQLite<br/>sessions · signatures · reputation")]:::store
GW --> SD
SD <--> DB
GW --> APP
Мережа називає шляховий вагон, вводить результат як HTTP заголовки , і опціонально блокуєM SK2 Пристрій зворотнього потоку читає заголовку та діє на вердикті. Шляховий vagon зберігає стан між запитамиMSC4 Піплін для виявлення запускається всередині одного виклику gRPC, а його результат розповсюджується як дев 'ять HTTP закінчений заголовківМSK5
Mostlylucid.BotDetection.Sidecar є мінімальним ASP. NET Кораовий процес . Він не має інтерфейсу МSK2 жодного статистичного файлу, який служить МСК3 і немає маршрутізації за межами gRPC та кінцевих точок REST М СК4 Він запускає повний двигун розпізнавання і відкриває два порти M СК5
/api/v1/* кінцеві точкиgRPC це високоефективний віддалений процедурний зв 'язок, розроблений в Google Буфери протоколу МSK0protobuf) як його формат дроту : компактний бінарний кодування, який швидший для серійизації і менший на дроті, ніж JSONM SK3 gRPC працює над HTTP/2, що означає, що він отримує мультиплексування MSC5множина запитів над одним TCP підключенням МSK6 безкоштовно
В інтерфейсі .proto файл. З цього файлуM SK1 генератори коду виробляють задруковані clients і серверні штуки в будь-якому піддерживаному мові . StyloBot публікує .proto файли, так що будь-яка мова з інсталяцією gRPC може називати sidecar .
Служба має три РПК:
service DetectionService {
rpc Detect(DetectRequest) returns (DetectResponse);
rpc DetectBatch(DetectBatchRequest) returns (DetectBatchResponse);
rpc RenderWidget(RenderWidgetRequest) returns (RenderWidgetResponse);
}
Detect це про per-горячий шлях запитуM SK1 Metodа передачі його , шлях МSK3 заголовки M SK4 віддалений IP , і опціональні дані пальцевих відбитків TLS MSC6 Він запускає хвилі каналу MST7 тільки детектори, які гарантують сигнали запиту \ MST8\ M ST9 оновлює вектор сеансу M ST10 балує від репутації в магазині репутаціях М ST11 та повертає вирок \ М ST12 \
DetectBatch запускає декілька запитів послідовно. Використовується для відтворення журналу та аналізу в офлайні
RenderWidget приймає стрічку рідкісного шаблону, опціональний вердикт , і ключM SK2 मानову мапу додаткових змінних МSK3 потім відображає сервер шаблонів МСК4 на стороні і повертає HTML М СК5 Ось як не-MСК6 NET викликачі виробляють бота MСК7 знають HTML без створення окремого процесу відтворювання M СК8 подібні деталі в sekції RenderWidget низу МSК9
Короткий словник перед діаграмою, оскільки ці назви будуть з 'являтьсяM SK1
request.ip.is_datacenter, detection.useragent.confidence). Детектори пишуть йому сигнали, МSK1 пізніше детектори в тій самій хвилі читають їхM SK2 Живе лише протягом одного запиту МSK3 Raw PII (IPMSC5 UA стрічка ) залишається у контексті запиту і ніколи не приземляється на чорну дошку M SK7HttpContext usługа gRPC будується з поля прото-запросів. детектор був розроблений для ASPM SK1 NET middleware і сподівається читати з HttpContext; синтезуючи один, той самий двигун запускається без змін всередині gRPCsequenceDiagram
participant GW as Gateway (Caddy)
participant SD as gRPC Service
participant ORC as BlackboardOrchestrator
participant DET as Detectors (up to 49, 4 waves)
participant DB as SQLite
GW->>SD: Detect RPC { method, path, headers, remoteIp }
SD->>ORC: DetectAsync(syntheticHttpContext)
ORC->>DET: Wave 0 - Identity + ContentSequence
ORC->>DET: Wave 1 - Fast path <1ms: UA, Header, IP, Heuristic ...
ORC->>DET: Wave 2 - Session vectors, Behavioural waveform
ORC->>DET: Wave 3 - Slow path: DNS, advanced fingerprinting
DET-->>ORC: DetectionContributions (signals, confidence deltas)
ORC->>DB: update session vector and reputation score
DB-->>ORC: ok
ORC-->>SD: AggregatedEvidence { botProbability, riskBand, ... }
SD-->>GW: DetectResponse { isBot, riskBand, recommendedAction, ... }
Весь канал запускається всередині одного gRPC виклику. Не існує асинкної роботи після того, як відповідь повертається.
Шляховий код в Go не може импортувати ASP.NET sidecarM SK1 Він може викликати його за допомогою gRPC. Go SDK (github.com/scottgal/stylobot-go) - це вбудований інтерфейс, який повністю приховує генеровані типи протобуфів від викликачів
Protobuf-генерований код verbose і має незвичайний API . Еноми представлені як цілковиті M SK2 Стрини з 'являються як сирові ім' я прото-енумів МSK3RISK_BAND_HIGH, не "High"). Field names are camelCase in some generators and snake
SDK перекладає один раз на граничному рівні (proto-енуми на канонічні струни ,protо-структури на прості Го-структурі МSK2 і викликачі ніколи їх не бачать
// the only interface you depend on: no proto imports required
type Client interface {
Detect(ctx context.Context, req DetectRequest) (*Verdict, error)
DetectBatch(ctx context.Context, reqs []DetectRequest) ([]*Verdict, error)
RenderWidget(ctx context.Context, req RenderRequest) (*RenderResponse, error)
Close() error
}
DetectRequest і Verdict є простими конструкціями Go:
type DetectRequest struct {
Method string
Path string
Headers map[string]string
RemoteIP string
Protocol string // "http" or "https"; defaults to "https" if empty
TLS *TLSInfo
}
type Verdict struct {
IsBot bool
BotProbability float32
Confidence float32
BotType string // "AiBot", "Scraper", "GoodBot", ...
BotName string
RiskBand string // "VeryLow", "Low", "Elevated", "Medium", "High", "VeryHigh"
RecommendedAction string // "Allow", "Throttle", "Challenge", "Block"
ThreatScore float32
ThreatBand string
ProcessingTimeMs float32
DetectorsRun int32
Reasons []Reason
}
Створити клієнта і запускати розпізнавання:
import (
stylobot "github.com/scottgal/stylobot-go"
"context"
"time"
)
client, err := stylobot.NewClient(
"localhost:5090",
stylobot.WithTimeout(50 * time.Millisecond),
stylobot.WithAPIKey(os.Getenv("SB_API_KEY")),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
verdict, err := client.Detect(ctx, stylobot.DetectRequest{
Method: r.Method,
Path: r.URL.RequestURI(),
RemoteIP: r.RemoteAddr,
Headers: extractHeaders(r),
Protocol: "https",
})
if err != nil {
// fail open: log and continue
log.Printf("stylobot detect failed: %v", err)
return next(w, r)
}
if verdict.RecommendedAction == "Block" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
grpc.NewClient створює клієнтський канал, але не встановлює негайного TCP зв 'язку. Зв' язок відбувається на першому дзвінку до РПКМSK1 Це означає, що ваш портовий процес запускається успішно, навіть якщо автомагістраль ще не запущена
Це відрізняється від HTTP-клиентів,, де ви зазвичай зв 'язуєтесь на створеннях. gRPC Рухаємось до документації деталізує життєвий цикл.
WithTimeout на NewClient встановлює початковий термін для кожного дзвінка Detect подзвонити. Якщо ваш код подзвону | ( | або середній інструмент, як, наприклад, плагин Caddy | МSK2 | вже має кінцевий термін |- | обмежений контекст з приchodzącego запиту \ , | SDK застосовує той термін, який перший закінчується |МSK5 | Коли вживається плагин Kaddy WithTimeout від NewClient і дозволити плагин контролювати його. Для автономного використання M SK1гортач, що викликає SDK напряму ), встановив його NewClient як показано вище.
Кадді це веб-сервер на основі Go- і зворотній прокси з автоматичним HTTPS xcaddy для створення custom Caddy бінарного коду, який включає в себе ваші плагини, виробляючи один セルб MSC1континентний бінар без залежності від часу запуску від спільних бібліотек. Це відрізняється від nginxM SK3 динамічної модулячої системи (.so files loaded at runtime). плагин StyloBot (github.com/scottgal/caddy-stylobot) записує middleware handler, який називає Go SDK на кожному запиті
Конфигурація Caddyfile:
{
order stylobot before respond
}
:80 {
stylobot {
endpoint localhost:5090 # gRPC host:port of the sidecar
timeout 50ms # per-request deadline; fails open on expiry
# on_block 503 # optional: change the block status code (default: 403)
}
reverse_proxy upstream:3000
}
плагин вводить дев 'ять заголовків вердикту на кожен просуваний запит:
| Заголовок МSK1 Поле джерела МSK2 | |
|---|---|
X-StyloBot-IsBot |
isBot МSK0bool) M SK2 |
X-StyloBot-Probability |
botProbability (0.0-1.0) |
X-StyloBot-Confidence |
confidence (0.0-1.0) |
X-StyloBot-BotType МSK0 e.gM SK2 AiBot, Scraper, GoodBot |
|
X-StyloBot-BotName МSK0 e.gM SK2 GPTBot, Googlebot |
|
X-StyloBot-RiskBand |
VeryLow ... VeryHigh |
X-StyloBot-Action |
Allow / Throttle / Challenge / Block |
X-StyloBot-ThreatScore МSK0 числові |
|
X-StyloBot-ThreatBand |
None ... Critical |
Прохання, де isBot=true і Action=Block зупиняються на шлюзі з 403 і ніколи не дістаються до верхнього потоку . Все інше МSK2 включаючи роботів з Throttle або Challenge Рекомендація) передають з усіма дев 'ятьма заголовками непошкодженими . Це намірний розрив МSK2 ворота управляють важкими блоками МСК3 верхні потоки управляються нюансами MСК4
on_block змінює код статусу, який використовується, коли порт блокує (defaultM SK1 403). Установка on_block 503 для подавлення логіки повторного спробування у скрукерах, які сприймають 403 як повторне спробування
flowchart TD
classDef input fill:none,stroke:#3b82f6,stroke-width:2px
classDef async fill:none,stroke:#a855f7,stroke-width:2px
classDef good fill:none,stroke:#22c55e,stroke-width:2px
A["1. Strip inbound X-StyloBot-* headers"]:::input
B["2. context.WithTimeout(r.Context(), 50ms)"]:::input
C["3. sbClient.Detect(ctx, DetectRequest)"]:::async
D{error?}
E["log warn - fail open<br/>forward unchanged"]:::good
F["4. injectHeaders<br/>X-StyloBot-IsBot, Probability, Confidence,<br/>BotType, BotName, RiskBand, Action,<br/>ThreatScore, ThreatBand"]:::input
I["next.ServeHTTP - forward to upstream<br/>with all verdict headers injected"]:::good
A --> B --> C --> D
D -->|yes| E --> I
D -->|no| F --> I
Шаг 1, стрічковий входний заголовок МSK1 Клиент, який знає X-StyloBot-* імена заголовків могли б самі внести позитивний результат, щоб він пережив невдачі.
Шаг 2, кінцевий термін в контексті МSK1 Часовий відрізок походить з r.Context() використовуючи context.WithTimeout (, що займає відносно тривалістьM SK1 context.WithDeadline приймає абсолютний час; вони є еквівалентніM SK1 Виходить з r.Context() замість context.Background() є ключовим моментом: якщо klient відключається перед тим, як завершити дзвінок gRPC , аннулація розповсюджується, а автозаправка зупиняється на ранній стадії МSK2
Шаг 3 і МSK1 виявити та ввести Дев 'ять вердиктів перетворюються на дев' ять X-StyloBot-* заголовки. Заголовки встановлені перед блоковою перевіркоюM SK1 так, що їх доштовхує styloBotMiddleware({ mode: 'headers' }) для всіх неблокованих запитів- isBot=true і recommendedAction=Block повертаються як 403 на порозі МSK1 все інше рухається вперед з повними заголовками вердикту
Implementacja:
// from sdk/caddy/stylobot.go
func (s *StyloBot) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
for _, name := range stylobotHeaders {
r.Header.Del(name)
}
ctx, cancel := context.WithTimeout(r.Context(), s.timeout)
defer cancel()
verdict, err := s.sbClient.Detect(ctx, sb.DetectRequest{
Method: r.Method,
Path: r.URL.RequestURI(),
RemoteIP: ExtractIP(r),
Protocol: r.Proto,
Headers: ExtractHeaders(r),
})
if err != nil {
s.logger.Warn("stylobot detect failed, failing open", zap.Error(err))
return next.ServeHTTP(w, r)
}
injectHeaders(r, verdict)
if verdict.IsBot && s.OnBlock > 0 && verdict.RecommendedAction == "Block" {
http.Error(w, "Forbidden", s.OnBlock)
return nil
}
return next.ServeHTTP(w, r)
}
Вставники Caddy повинні бути компільовані в бінарну систему, використовуючи xcaddy. Dockerfile в тестах на інтеграцію показує шаблон
# from tests/integration/caddy-sidecar/Dockerfile
FROM caddy:2-builder AS builder
WORKDIR /build
COPY sdk/caddy/ caddy-plugin/
COPY sdk/go/ go/
WORKDIR /build/caddy-plugin
RUN xcaddy build \
--with github.com/scottgal/caddy-stylobot=/build/caddy-plugin \
--with github.com/scottgal/stylobot-go=/build/go
FROM caddy:2
COPY --from=builder /build/caddy-plugin/caddy /usr/bin/caddy
COPY tests/integration/caddy-sidecar/Caddyfile /etc/caddy/Caddyfile
плагин's go.mod містить:
replace github.com/scottgal/stylobot-go => ../go
Це вказує на ланцюг інструментів Go ", коли ви бачите stylobot-go, використовувати локальний каталог замість того, щоб брати з модуля прокси go build і go test в каталогі плагинів.
xcaddy створює новий тимчасовий модуль Go для свого створення. Цей модуль не успадковується replace директиви з плагина's go.mod. Без секунди --with argument, xcaddy спробує завантажити stylobot-go від pkg.go.dev ( там, де ще не було опубліковано
І --with module=path аргумент xcaddy'с природним еквівалентом replace директива: він відображає шлях модуля до локального каталогу в час будуванняM SK1 Обидві локальні модулі повинні мати конкретне ім 'я.
RenderWidget це gRPC RPC на сторонній машині, яка приймає стрічку шаблону Liquid, відтворює її з контекстом розпізнаванняM SK1 і повертає HTML. Це дозволяє будь-кому викликателю ( Переходити на проксиMSC4 Лаyer узла SSRMska5 пакетна трубкаM Ska6 виробляти BotMска7 мати доступ до HTML без запуску окремого процесу відтворенняMSKA8
Рід це templating мова, створена Shopify, використовується Shopify themes, JekyllM SK2 Pages of GitHub , і багато інших системMSC4 Цілові характеристикиMST5 безпечна для запуску разом з користувачамиМSK6 вбудовані шаблони MSL7 без произвольного виконання кодуMNK8 достатньо проста для не-- розробників, щоб написатиMRK10 та широко зрозумілиMسک11 StyloBot використовує Fluid.Core, високий МSK1конкурентність МSK2NET-implementacja Liquid , для відтворення сервера шаблонів
Втілення бокового вагону:
// from src/Mostlylucid.BotDetection.Sidecar/Services/DetectionGrpcService.cs
private static readonly FluidParser Parser = new(); // static, shared, compiled templates cached
public override async Task<Proto.RenderWidgetResponse> RenderWidget(
Proto.RenderWidgetRequest request, ServerCallContext context)
{
if (!Parser.TryParse(request.Template, out var template, out var error))
return new Proto.RenderWidgetResponse { Success = false, Error = error };
var ctx = new TemplateContext();
if (request.Verdict is { } v)
{
ctx.SetValue("isBot", v.IsBot);
ctx.SetValue("botProbability", (double)v.BotProbability);
ctx.SetValue("botType", v.BotType);
ctx.SetValue("botName", v.BotName);
ctx.SetValue("riskBand", v.RiskBand.ToString());
ctx.SetValue("recommendedAction", v.RecommendedAction.ToString());
ctx.SetValue("threatScore", (double)v.ThreatScore);
ctx.SetValue("threatBand", v.ThreatBand.ToString());
}
foreach (var kv in request.Vars)
ctx.SetValue(kv.Key, kv.Value);
var html = await template.RenderAsync(ctx);
return new Proto.RenderWidgetResponse { Html = html, Success = true };
}
Fluid.Core підтримує внутрішній компілятор шаблонівM SK1 Повторні відтвори того ж стрічки шаблону перемикаються назад FluidParser є статичним і поширюється на всіх викликах gRPC.
Зв 'язок StyloBotGrpcClient.renderWidget() приклад і повна змінна шаблона є в Artykuł про TypScript SDK.
Від Go:
rendered, err := client.RenderWidget(ctx, stylobot.RenderRequest{
Template: `{% if isBot %}<p class="warning">Bot: {{ botType }}</p>{% endif %}`,
Verdict: verdict,
Vars: map[string]string{"locale": "en-GB"},
})
if err == nil && rendered.Success {
fmt.Fprint(w, rendered.HTML)
}
Синтаксис шаблону ідентичний, чи ви кажете RenderWidget від Go, Node, або використовувати <sb-widget> в браузері
graph LR
classDef input fill:none,stroke:#3b82f6,stroke-width:2px
classDef async fill:none,stroke:#a855f7,stroke-width:2px
classDef good fill:none,stroke:#22c55e,stroke-width:2px
classDef store fill:none,stroke:#f59e0b,stroke-width:2px
INT([Internet])
CF["Cloudflare<br/>Tunnel / CDN"]
CA["Caddy<br/>+ caddy-stylobot"]:::input
SD["StyloBot Sidecar<br/>:5090 gRPC · :5091 REST"]:::async
WEB["Upstream App<br/>Node / Go / ASP.NET"]:::good
DB[("SQLite<br/>sessions · reputation")]:::store
INT --> CF --> CA
CA -->|"gRPC Detect<br/>≤50ms"| SD
SD <-->|"persist"| DB
CA -->|"X-StyloBot-* headers"| WEB
WEB -->|"/_stylobot/partials/render<br/>(widget rendering)"| SD
Верхня програма звертає сторонню машину безпосередньо для відтворювання виджета, перехиляючи воротаM SK1 Відтворення виджету потребує повного контексту вердикту і відбувається після того, як запити вже пройшли розпізнавання ворот, тож не існує дублування розпізнанняMSC3
плагин Caddy, Node middleware, and Go SDK all fail openM SK2 a sidecar timeout or error becomes a warning log and a permissive empty verdictMSC3 not a MST4xxMSL5 The 50ms Caddy deadline is a coldMSP7start safety marginMSV8 the steadyМСП9state cost on a warm connection is МSP10msMСП11
Торгівля "-" - це кодування МSK1 , блокування законного руху через те, що його не можна виявити - гірше, ніж пропущений рух роботів під час аварії
Серія релізів продовжується. Більше статей про інтерфейси розпізнаванняМSK1 схеми використанняM SK2 спостереженнямість, і комерційна топологія ще попереду
Источник implementу: github.comM SK1scottgal/stylobot. Живий двигун , панель управления МSK2 та коммерціальні прилади stylobot.net.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.