Перейти к основному содержимому

Алгоритм Problem Card Detection

Суть

Problem Card Detection — ядро лидогенерации. Движок анализирует карточки товаров через SalesFinder API и определяет 5 типов проблем, каждая из которых означает упущенную выручку. Комбинированный PotentialScore определяет приоритет лида.


Обзор архитектуры

graph TD
A["SF API:\nproduct/info"] --> E["Problem Detection Engine"]
B["SF API:\nproduct/overview"] --> E
C["SF API:\nproduct/keywords"] --> E
D["SF API:\ncategory/products"] --> E

E --> P1["Pattern 1:\nContent Problem\nW=25"]
E --> P2["Pattern 2:\nSEO Problem\nW=20"]
E --> P3["Pattern 3:\nContent Fatigue\nW=20"]
E --> P4["Pattern 4:\nNew Brand\nW=15"]
E --> P5["Pattern 5:\nAd Inefficiency\nW=20"]

P1 --> S["PotentialScore\nCalculator"]
P2 --> S
P3 --> S
P4 --> S
P5 --> S

S --> HOT["🔥 HOT >= 70"]
S --> WARM["🟡 WARM 50-69"]
S --> COOL["🔵 COOL 30-49"]
S --> SKIP["⏭ SKIP < 30"]

style P1 fill:#ffcdd2
style P2 fill:#fff9c4
style P3 fill:#ffe0b2
style P4 fill:#c8e6c9
style P5 fill:#bbdefb
style HOT fill:#f44336,color:#fff
style WARM fill:#ff9800,color:#fff
style COOL fill:#2196f3,color:#fff

PotentialScore — формула

Итоговый PotentialScore вычисляется как взвешенная сумма 5 паттернов:

PotentialScore = Σ (Wi × Pi × Mi)

Где:
Wi = вес паттерна (предустановленный)
Pi = значение паттерна (0.0–1.0, нормализованное)
Mi = модификатор (мультипликатор 0.5–2.0)
ПаттернВес (Wi)ОписаниеМакс вклад
P1: Content Problem25Высокий спрос + низкая конверсия50 (с модификаторами)
P2: SEO Problem20Хороший товар + плохая позиция40
P3: Content Fatigue20TOP-10 + падение продаж40
P4: New Brand15Новый бренд + плохой контент30
P5: Ad Inefficiency20Высокий CTR + низкая конверсия40
ИТОГО100Макс: 200
Нормализация

Финальный Score нормализуется к шкале 0–100. Значение 100 означает «идеальный» лид — все 5 паттернов совпали с максимальными модификаторами.


Pattern 1: Content Problem (W1 = 25)

Логика

Гипотеза: если товар часто ищут, но конверсия из просмотра в покупку значительно ниже среднего по категории — проблема в контенте карточки.

Формула

P1 = demand_factor × conversion_gap × content_quality_penalty

demand_factor = min(search_volume / category_median_search, 2.0)
conversion_gap = max(0, 1 - (product_conversion / category_avg_conversion))
content_quality_penalty = base_penalty + modifiers

Modifiers:
+0.15 если photos_count < 5
+0.15 если has_video == False
+0.10 если has_rich_content == False
+0.10 если description_length < 500
+0.05 если infographics_count == 0

Пороги

ПараметрЗначениеИсточник
MIN_SEARCH_VOLUME3 000 запросов/месМинимальный спрос для интереса
MAX_CONVERSION_GAP0.7 (70%)Конверсия на 70%+ ниже среднего
CATEGORY_AVG_CONVERSIONДинамическийSF API: среднее по ТОП-100 в категории
MIN_PHOTOS5Стандарт для WB/Ozon
MIN_DESCRIPTION_LENGTH500 символовМинимум для SEO

SF API эндпоинты

# 1. Данные о товаре (фото, видео, описание)
POST /product/info
{
"sku": 12345678,
"marketplace": "wb" # или "ozon"
}
# Response: photos_count, has_video, description, rich_content, ...

# 2. Обзор продаж (конверсия, выручка, продажи)
POST /product/overview
{
"sku": 12345678,
"marketplace": "wb",
"period": "month"
}
# Response: conversion_rate, revenue, orders, views, ...

# 3. Ключевые слова (поисковый спрос)
POST /product/keywords
{
"sku": 12345678,
"marketplace": "wb"
}
# Response: keywords[], search_volume, position, ...

Python-реализация

from dataclasses import dataclass, field
from typing import Optional
import math


@dataclass
class ProductData:
"""Данные о товаре из SalesFinder API."""
sku: int
name: str
brand: str
seller_id: str
marketplace: str # "wb" | "ozon"

# Контент
photos_count: int = 0
has_video: bool = False
has_rich_content: bool = False
description_length: int = 0
infographics_count: int = 0

# Метрики
search_volume: int = 0 # запросов/мес по основным ключевикам
views: int = 0 # просмотров/мес
orders: int = 0 # заказов/мес
revenue: float = 0.0 # выручка/мес
conversion_rate: float = 0.0 # % конверсии из просмотра в заказ
price: float = 0.0
rating: float = 0.0
reviews_count: int = 0

# Позиционирование
position: int = 0 # средняя позиция в поиске
position_trend: float = 0.0 # изменение позиции (-10 = упал на 10)

# Продажи
sales_trend: float = 0.0 # % изменения продаж за месяц
days_on_market: int = 0

# Реклама
ctr: float = 0.0 # Click-through rate рекламы
ad_conversion: float = 0.0 # Конверсия с рекламы
ad_spend: float = 0.0 # Расход на рекламу/мес


@dataclass
class PatternResult:
"""Результат одного паттерна."""
pattern_name: str
score: float # 0.0–1.0
modifier: float = 1.0 # 0.5–2.0
details: dict = field(default_factory=dict)
recommendations: list = field(default_factory=list)

@property
def effective_score(self) -> float:
return min(self.score * self.modifier, 2.0)


class ContentProblemDetector:
"""Pattern 1: Высокий спрос + низкая конверсия = проблема контента."""

MIN_SEARCH_VOLUME = 3_000
MIN_PHOTOS = 5
MIN_DESCRIPTION_LENGTH = 500

def detect(
self,
product: ProductData,
category_avg_conversion: float,
category_median_search: int,
) -> PatternResult:
# Фильтр: минимальный спрос
if product.search_volume < self.MIN_SEARCH_VOLUME:
return PatternResult(
pattern_name="content_problem",
score=0.0,
details={"reason": "search_volume_too_low"},
)

# Фактор спроса: чем выше спрос, тем больше потери
demand_factor = min(
product.search_volume / max(category_median_search, 1),
2.0,
)

# Разрыв конверсии: насколько карточка хуже среднего
if category_avg_conversion <= 0:
conversion_gap = 0.0
else:
conversion_gap = max(
0.0,
1.0 - (product.conversion_rate / category_avg_conversion),
)

# Штраф за качество контента
content_penalty = 0.0
recommendations = []

if product.photos_count < self.MIN_PHOTOS:
content_penalty += 0.15
recommendations.append(
f"Добавьте фото: {product.photos_count} → минимум {self.MIN_PHOTOS}"
)

if not product.has_video:
content_penalty += 0.15
recommendations.append("Добавьте видео-обзор товара (30–60 сек)")

if not product.has_rich_content:
content_penalty += 0.10
recommendations.append("Добавьте rich-content (инфографику в карточку)")

if product.description_length < self.MIN_DESCRIPTION_LENGTH:
content_penalty += 0.10
recommendations.append(
f"Расширьте описание: {product.description_length} → мин. "
f"{self.MIN_DESCRIPTION_LENGTH} символов"
)

if product.infographics_count == 0:
content_penalty += 0.05
recommendations.append("Добавьте инфографику с УТП товара")

# Базовый score
base_score = demand_factor * conversion_gap
modifier = 1.0 + content_penalty # от 1.0 до 1.55

# Расчёт упущенной выручки
potential_conversion = category_avg_conversion
current_orders = product.orders
potential_orders = int(product.views * potential_conversion / 100)
lost_orders = max(0, potential_orders - current_orders)
lost_revenue = lost_orders * product.price

return PatternResult(
pattern_name="content_problem",
score=min(base_score, 1.0),
modifier=modifier,
details={
"demand_factor": round(demand_factor, 2),
"conversion_gap": round(conversion_gap, 2),
"content_penalty": round(content_penalty, 2),
"product_conversion": product.conversion_rate,
"category_avg_conversion": category_avg_conversion,
"search_volume": product.search_volume,
"lost_orders_per_month": lost_orders,
"lost_revenue_per_month": round(lost_revenue),
},
recommendations=recommendations,
)

Pattern 2: SEO Problem (W2 = 20)

Логика

Гипотеза: если товар имеет высокий рейтинг (≥ 4.5) и хорошие отзывы, но стоит на позициях > 50 — проблема в SEO-оптимизации карточки (заголовок, ключевые слова, характеристики).

Формула

P2 = quality_factor × position_penalty × keyword_gap

quality_factor = min(rating / 5.0 × reviews_factor, 1.0)
reviews_factor = min(reviews_count / 100, 1.5)
position_penalty = min((position - 10) / 90, 1.0) # 0 при pos ≤ 10, 1.0 при pos ≥ 100
keyword_gap = missing_keywords / total_relevant_keywords

Пороги

ПараметрЗначениеОписание
MIN_RATING4.5Товар хорошего качества
MIN_REVIEWS20Минимум отзывов для надёжности
BAD_POSITION> 50Товар не виден в поиске
KEYWORD_GAP_THRESHOLD> 30%Более 30% ключей отсутствуют

Python-реализация

class SEOProblemDetector:
"""Pattern 2: Хороший товар + плохая позиция = проблема SEO."""

MIN_RATING = 4.5
MIN_REVIEWS = 20
GOOD_POSITION = 10
BAD_POSITION = 100

def detect(
self,
product: ProductData,
relevant_keywords: list[str],
product_keywords: list[str],
) -> PatternResult:
# Фильтр: товар должен быть качественным
if product.rating < self.MIN_RATING or product.reviews_count < self.MIN_REVIEWS:
return PatternResult(
pattern_name="seo_problem",
score=0.0,
details={"reason": "product_quality_insufficient"},
)

# Фактор качества товара
reviews_factor = min(product.reviews_count / 100, 1.5)
quality_factor = min((product.rating / 5.0) * reviews_factor, 1.0)

# Штраф за позицию
if product.position <= self.GOOD_POSITION:
position_penalty = 0.0
else:
position_penalty = min(
(product.position - self.GOOD_POSITION)
/ (self.BAD_POSITION - self.GOOD_POSITION),
1.0,
)

# Разрыв по ключевым словам
product_kw_set = set(kw.lower() for kw in product_keywords)
relevant_kw_set = set(kw.lower() for kw in relevant_keywords)
missing = relevant_kw_set - product_kw_set
keyword_gap = len(missing) / max(len(relevant_kw_set), 1)

# Score
score = quality_factor * position_penalty * max(keyword_gap, 0.3)

# Рекомендации
recommendations = []
if missing:
top_missing = sorted(missing)[:5]
recommendations.append(
f"Добавьте ключевые слова: {', '.join(top_missing)}"
)
if product.position > 50:
recommendations.append(
f"Текущая позиция: {product.position}. "
f"Оптимизация карточки может поднять до ТОП-20"
)

# Потенциальный рост трафика
position_traffic_multiplier = {
1: 15.0, 2: 10.0, 3: 7.0, 5: 4.0, 10: 2.0, 20: 1.5, 50: 1.0,
}
current_mult = 1.0
target_mult = 2.0 # цель — ТОП-20
traffic_uplift = (target_mult / max(current_mult, 0.1)) - 1
potential_extra_revenue = product.revenue * traffic_uplift

return PatternResult(
pattern_name="seo_problem",
score=min(score, 1.0),
modifier=1.0 + (0.3 if keyword_gap > 0.5 else 0.0),
details={
"quality_factor": round(quality_factor, 2),
"position": product.position,
"position_penalty": round(position_penalty, 2),
"keyword_gap": round(keyword_gap, 2),
"missing_keywords_count": len(missing),
"missing_keywords_top5": sorted(missing)[:5],
"potential_extra_revenue": round(potential_extra_revenue),
},
recommendations=recommendations,
)

Pattern 3: Content Fatigue (W3 = 20)

Логика

Гипотеза: если товар в TOP-10, но продажи падают > 20% месяц к месяцу — контент «устарел». Конкуренты обновили карточки, а этот продавец — нет. Нужен рефреш контента.

Формула

P3 = position_strength × sales_decline × staleness_factor

position_strength = max(0, 1 - (position / 20)) # 1.0 при pos=1, 0 при pos≥20
sales_decline = max(0, -sales_trend / 50) # 1.0 при падении 50%+
staleness_factor = min(days_since_update / 180, 1.0) # 1.0 если > 180 дней

Python-реализация

class ContentFatigueDetector:
"""Pattern 3: TOP-10 + падение продаж = устаревший контент."""

MAX_POSITION = 20
SIGNIFICANT_DECLINE = -20 # % падения продаж
STALENESS_DAYS = 180 # дней без обновления

def detect(
self,
product: ProductData,
days_since_content_update: int = 180,
competitor_update_rate: float = 0.0,
) -> PatternResult:
# Фильтр: товар должен быть в TOP-20
if product.position > self.MAX_POSITION:
return PatternResult(
pattern_name="content_fatigue",
score=0.0,
details={"reason": "position_too_low"},
)

# Фильтр: продажи должны падать
if product.sales_trend >= 0:
return PatternResult(
pattern_name="content_fatigue",
score=0.0,
details={"reason": "sales_not_declining"},
)

# Сила позиции
position_strength = max(0, 1 - (product.position / self.MAX_POSITION))

# Глубина падения продаж (нормализация: -50% = 1.0)
sales_decline = min(abs(product.sales_trend) / 50, 1.0)

# Фактор устаревания контента
staleness_factor = min(days_since_content_update / self.STALENESS_DAYS, 1.0)

score = position_strength * sales_decline * max(staleness_factor, 0.3)

# Модификатор: если конкуренты активно обновляют — усиливаем
modifier = 1.0
if competitor_update_rate > 0.5: # > 50% конкурентов обновили контент
modifier = 1.3

recommendations = []
recommendations.append(
f"Продажи падают на {abs(product.sales_trend):.0f}%/мес при позиции {product.position}"
)
recommendations.append(
f"Контент не обновлялся {days_since_content_update} дней"
)
recommendations.append(
"Рекомендуем: новые фото, обновлённое видео, актуальная инфографика"
)

# Расчёт потерь: если вернуть продажи к уровню 3 мес назад
recovery_revenue = product.revenue * (abs(product.sales_trend) / 100)

return PatternResult(
pattern_name="content_fatigue",
score=min(score, 1.0),
modifier=modifier,
details={
"position": product.position,
"position_strength": round(position_strength, 2),
"sales_trend": product.sales_trend,
"sales_decline_norm": round(sales_decline, 2),
"days_since_update": days_since_content_update,
"staleness_factor": round(staleness_factor, 2),
"competitor_update_rate": competitor_update_rate,
"recovery_revenue_per_month": round(recovery_revenue),
},
recommendations=recommendations,
)

Pattern 4: New Brand Opportunity (W4 = 15)

Логика

Гипотеза: новый продавец (< 90 дней), который уже продаёт, но использует непрофессиональный контент — самый лёгкий в конверсии. Он уже инвестировал в товар, видит продажи, но не понимает, почему конверсия ниже ожиданий.

Формула

P4 = newness_factor × traction_factor × content_deficit

newness_factor = max(0, 1 - (days_on_market / 180)) # 1.0 при 0 дней, 0 при 180+
traction_factor = min(orders / 50, 1.0) # есть продажи (≥50 = max)
content_deficit = (missing_elements / 5) # 5 элементов: фото≥7, видео, rich, описание≥500, инфо

Missing elements:
+1 если photos_count < 7
+1 если has_video == False
+1 если has_rich_content == False
+1 если description_length < 500
+1 если infographics_count == 0

Python-реализация

class NewBrandDetector:
"""Pattern 4: Новый бренд + непрофессиональный контент = лёгкая конверсия."""

MAX_DAYS = 180
MIN_ORDERS = 5 # уже продаёт
GOOD_PHOTOS = 7
MIN_DESCRIPTION = 500

def detect(self, product: ProductData) -> PatternResult:
# Фильтр: должен быть относительно новым
if product.days_on_market > self.MAX_DAYS:
return PatternResult(
pattern_name="new_brand",
score=0.0,
details={"reason": "not_new_brand"},
)

# Фильтр: должен уже продавать
if product.orders < self.MIN_ORDERS:
return PatternResult(
pattern_name="new_brand",
score=0.0,
details={"reason": "no_traction"},
)

# Фактор новизны
newness_factor = max(0, 1 - (product.days_on_market / self.MAX_DAYS))

# Фактор тяги (есть продажи)
traction_factor = min(product.orders / 50, 1.0)

# Дефицит контента
missing = 0
missing_items = []

if product.photos_count < self.GOOD_PHOTOS:
missing += 1
missing_items.append(f"фото: {product.photos_count} < {self.GOOD_PHOTOS}")
if not product.has_video:
missing += 1
missing_items.append("нет видео")
if not product.has_rich_content:
missing += 1
missing_items.append("нет rich-content")
if product.description_length < self.MIN_DESCRIPTION:
missing += 1
missing_items.append(
f"описание: {product.description_length} < {self.MIN_DESCRIPTION}"
)
if product.infographics_count == 0:
missing += 1
missing_items.append("нет инфографики")

content_deficit = missing / 5

score = newness_factor * traction_factor * content_deficit

recommendations = []
if missing_items:
recommendations.append(f"Недостающий контент: {', '.join(missing_items)}")
recommendations.append(
f"Бренд на рынке {product.days_on_market} дней, "
f"уже {product.orders} заказов/мес — есть потенциал для роста в 3–5 раз"
)

return PatternResult(
pattern_name="new_brand",
score=min(score, 1.0),
modifier=1.2 if product.days_on_market < 60 else 1.0,
details={
"days_on_market": product.days_on_market,
"newness_factor": round(newness_factor, 2),
"orders": product.orders,
"traction_factor": round(traction_factor, 2),
"missing_elements": missing,
"missing_items": missing_items,
"content_deficit": round(content_deficit, 2),
},
recommendations=recommendations,
)

Pattern 5: Ad Inefficiency (W5 = 20)

Логика

Гипотеза: если продавец тратит деньги на рекламу (CTR > 3% — реклама работает, привлекает клики), но конверсия карточки < 2% — деньги утекают. Реклама приводит трафик, а карточка не конвертирует. Это самый дорогой тип проблемы для продавца.

Формула

P5 = ad_effectiveness × conversion_failure × spend_factor

ad_effectiveness = min(ctr / 5.0, 1.0) # CTR 5%+ = max
conversion_failure = max(0, 1 - ad_conversion / 3.0) # CV < 3% = проблема
spend_factor = min(ad_spend / 100_000, 1.5) # чем больше тратит, тем больше теряет

Python-реализация

class AdInefficiencyDetector:
"""Pattern 5: Высокий CTR + низкая конверсия = деньги на ветер."""

MIN_CTR = 2.0 # % — реклама привлекает
MAX_CONVERSION = 3.0 # % — нормальная конверсия
HIGH_SPEND = 100_000 # руб/мес

def detect(self, product: ProductData) -> PatternResult:
# Фильтр: должна быть реклама
if product.ad_spend <= 0 or product.ctr < self.MIN_CTR:
return PatternResult(
pattern_name="ad_inefficiency",
score=0.0,
details={"reason": "no_significant_ad_activity"},
)

# Фильтр: конверсия должна быть низкой
if product.ad_conversion >= self.MAX_CONVERSION:
return PatternResult(
pattern_name="ad_inefficiency",
score=0.0,
details={"reason": "conversion_acceptable"},
)

# Эффективность рекламы (CTR хороший)
ad_effectiveness = min(product.ctr / 5.0, 1.0)

# Провал конверсии (карточка не конвертирует)
conversion_failure = max(
0, 1 - (product.ad_conversion / self.MAX_CONVERSION)
)

# Фактор расходов (масштаб потерь)
spend_factor = min(product.ad_spend / self.HIGH_SPEND, 1.5)

score = ad_effectiveness * conversion_failure

# Расчёт потерь на рекламе
wasted_ad_spend = product.ad_spend * conversion_failure
potential_extra_orders = int(
(product.views * (self.MAX_CONVERSION - product.ad_conversion) / 100)
)
potential_extra_revenue = potential_extra_orders * product.price

recommendations = [
f"CTR рекламы {product.ctr:.1f}% — реклама работает и привлекает клики",
f"Конверсия карточки {product.ad_conversion:.1f}% — карточка не конвертирует",
f"Потери на рекламе: ~{wasted_ad_spend:,.0f} ₽/мес уходит впустую",
"Решение: обновить контент карточки → конверсия вырастет → те же расходы "
"на рекламу дадут в 2–3 раза больше заказов",
]

return PatternResult(
pattern_name="ad_inefficiency",
score=min(score, 1.0),
modifier=min(spend_factor, 2.0),
details={
"ctr": product.ctr,
"ad_conversion": product.ad_conversion,
"ad_spend": product.ad_spend,
"ad_effectiveness": round(ad_effectiveness, 2),
"conversion_failure": round(conversion_failure, 2),
"spend_factor": round(spend_factor, 2),
"wasted_ad_spend": round(wasted_ad_spend),
"potential_extra_orders": potential_extra_orders,
"potential_extra_revenue": round(potential_extra_revenue),
},
recommendations=recommendations,
)

Combined PotentialScore Calculator

Класс-калькулятор

from dataclasses import dataclass, field
from enum import Enum


class LeadTemperature(Enum):
HOT = "hot"
WARM = "warm"
COOL = "cool"
SKIP = "skip"


@dataclass
class ScoringResult:
"""Итоговый результат скоринга продукта."""
sku: int
seller_id: str
potential_score: float # 0–100
temperature: LeadTemperature
patterns: list[PatternResult] # результаты 5 паттернов
total_lost_revenue: float # суммарная упущенная выручка
top_recommendations: list[str] # ТОП-5 рекомендаций
priority_rank: int = 0 # позиция в очереди на аутрич


class PotentialScoreCalculator:
"""
Калькулятор комбинированного PotentialScore.

Объединяет 5 паттернов с весами и нормализует результат к шкале 0–100.
"""

WEIGHTS = {
"content_problem": 25,
"seo_problem": 20,
"content_fatigue": 20,
"new_brand": 15,
"ad_inefficiency": 20,
}

TEMPERATURE_THRESHOLDS = {
"hot": 70,
"warm": 50,
"cool": 30,
}

def __init__(self):
self.content_detector = ContentProblemDetector()
self.seo_detector = SEOProblemDetector()
self.fatigue_detector = ContentFatigueDetector()
self.new_brand_detector = NewBrandDetector()
self.ad_detector = AdInefficiencyDetector()

def calculate(
self,
product: ProductData,
category_avg_conversion: float,
category_median_search: int,
relevant_keywords: list[str],
product_keywords: list[str],
days_since_content_update: int = 180,
competitor_update_rate: float = 0.0,
) -> ScoringResult:
"""Рассчитывает PotentialScore для одного продукта."""

# Запускаем все 5 паттернов
patterns = [
self.content_detector.detect(
product, category_avg_conversion, category_median_search
),
self.seo_detector.detect(
product, relevant_keywords, product_keywords
),
self.fatigue_detector.detect(
product, days_since_content_update, competitor_update_rate
),
self.new_brand_detector.detect(product),
self.ad_detector.detect(product),
]

# Рассчитываем взвешенную сумму
raw_score = 0.0
max_possible = 0.0

for pattern in patterns:
weight = self.WEIGHTS.get(pattern.pattern_name, 0)
raw_score += weight * pattern.effective_score
max_possible += weight * 2.0 # макс effective_score = 2.0

# Нормализация к 0–100
if max_possible > 0:
potential_score = (raw_score / max_possible) * 100
else:
potential_score = 0.0

potential_score = round(min(potential_score, 100.0), 1)

# Определяем температуру
if potential_score >= self.TEMPERATURE_THRESHOLDS["hot"]:
temperature = LeadTemperature.HOT
elif potential_score >= self.TEMPERATURE_THRESHOLDS["warm"]:
temperature = LeadTemperature.WARM
elif potential_score >= self.TEMPERATURE_THRESHOLDS["cool"]:
temperature = LeadTemperature.COOL
else:
temperature = LeadTemperature.SKIP

# Собираем упущенную выручку из всех паттернов
total_lost = sum(
p.details.get("lost_revenue_per_month", 0)
+ p.details.get("potential_extra_revenue", 0)
+ p.details.get("recovery_revenue_per_month", 0)
+ p.details.get("wasted_ad_spend", 0)
for p in patterns
)

# ТОП рекомендации (сортируем по score паттерна)
all_recs = []
for p in sorted(patterns, key=lambda x: x.effective_score, reverse=True):
all_recs.extend(p.recommendations)
top_recs = all_recs[:5]

return ScoringResult(
sku=product.sku,
seller_id=product.seller_id,
potential_score=potential_score,
temperature=temperature,
patterns=patterns,
total_lost_revenue=round(total_lost),
top_recommendations=top_recs,
)

Category Scanner

Массовый анализ категории

import asyncio
import logging
from typing import AsyncIterator

import httpx

logger = logging.getLogger(__name__)


class CategoryScanner:
"""
Сканирует TOP-N товаров в категории через SalesFinder API
и прогоняет каждый через PotentialScoreCalculator.
"""

def __init__(
self,
sf_client: "SalesFinderClient",
calculator: PotentialScoreCalculator,
max_concurrent: int = 5,
):
self.sf = sf_client
self.calculator = calculator
self.semaphore = asyncio.Semaphore(max_concurrent)

async def scan_category(
self,
category_id: int,
marketplace: str = "wb",
limit: int = 500,
min_score: float = 30.0,
) -> list[ScoringResult]:
"""
Сканирует категорию и возвращает проблемные карточки.

Args:
category_id: ID категории в SalesFinder
marketplace: "wb" или "ozon"
limit: макс. кол-во товаров для анализа
min_score: минимальный PotentialScore для включения

Returns:
Список ScoringResult, отсортированный по score DESC
"""

logger.info(
f"Scanning category {category_id} on {marketplace}, limit={limit}"
)

# 1. Получаем список товаров в категории
products_raw = await self.sf.get_category_products(
category_id=category_id,
marketplace=marketplace,
limit=limit,
sort="revenue_desc", # сначала самые продаваемые
)

logger.info(f"Found {len(products_raw)} products in category")

# 2. Рассчитываем средние метрики по категории
category_stats = self._calculate_category_stats(products_raw)
logger.info(
f"Category stats: avg_conversion={category_stats['avg_conversion']:.2f}%, "
f"median_search={category_stats['median_search']}"
)

# 3. Получаем релевантные ключевые слова категории
relevant_keywords = await self.sf.get_category_keywords(
category_id=category_id,
marketplace=marketplace,
limit=50,
)

# 4. Параллельно анализируем каждый товар
tasks = []
for raw in products_raw:
tasks.append(
self._analyze_product(
raw, category_stats, relevant_keywords, marketplace
)
)

results = await asyncio.gather(*tasks, return_exceptions=True)

# 5. Фильтруем и сортируем
scored = []
errors = 0
for r in results:
if isinstance(r, Exception):
errors += 1
logger.warning(f"Product analysis failed: {r}")
continue
if r and r.potential_score >= min_score:
scored.append(r)

scored.sort(key=lambda x: x.potential_score, reverse=True)

# Присваиваем ранги
for i, s in enumerate(scored):
s.priority_rank = i + 1

logger.info(
f"Scan complete: {len(scored)} problem cards found "
f"(of {len(products_raw)} scanned, {errors} errors)"
)

return scored

async def _analyze_product(
self,
raw_product: dict,
category_stats: dict,
relevant_keywords: list[str],
marketplace: str,
) -> ScoringResult | None:
"""Анализирует один товар с rate limiting."""
async with self.semaphore:
try:
sku = raw_product["sku"]

# Получаем полные данные
info = await self.sf.get_product_info(sku, marketplace)
overview = await self.sf.get_product_overview(sku, marketplace)
keywords = await self.sf.get_product_keywords(sku, marketplace)

product = self._build_product_data(
raw_product, info, overview, keywords, marketplace
)

product_kw = [kw["keyword"] for kw in keywords.get("keywords", [])]

return self.calculator.calculate(
product=product,
category_avg_conversion=category_stats["avg_conversion"],
category_median_search=category_stats["median_search"],
relevant_keywords=relevant_keywords,
product_keywords=product_kw,
)

except Exception as e:
logger.error(f"Failed to analyze SKU {raw_product.get('sku')}: {e}")
raise

def _calculate_category_stats(self, products: list[dict]) -> dict:
"""Рассчитывает средние показатели категории."""
conversions = [p.get("conversion_rate", 0) for p in products if p.get("conversion_rate")]
searches = [p.get("search_volume", 0) for p in products if p.get("search_volume")]

return {
"avg_conversion": sum(conversions) / max(len(conversions), 1),
"median_search": sorted(searches)[len(searches) // 2] if searches else 1000,
"total_products": len(products),
"avg_revenue": sum(p.get("revenue", 0) for p in products) / max(len(products), 1),
}

def _build_product_data(
self, raw: dict, info: dict, overview: dict, keywords: dict, marketplace: str
) -> ProductData:
"""Конвертирует сырые данные SF API в ProductData."""
total_search = sum(kw.get("search_volume", 0) for kw in keywords.get("keywords", [])[:10])

return ProductData(
sku=raw["sku"],
name=info.get("name", ""),
brand=info.get("brand", ""),
seller_id=info.get("seller_id", ""),
marketplace=marketplace,
photos_count=info.get("photos_count", 0),
has_video=info.get("has_video", False),
has_rich_content=info.get("has_rich_content", False),
description_length=len(info.get("description", "")),
infographics_count=info.get("infographics_count", 0),
search_volume=total_search,
views=overview.get("views", 0),
orders=overview.get("orders", 0),
revenue=overview.get("revenue", 0.0),
conversion_rate=overview.get("conversion_rate", 0.0),
price=info.get("price", 0.0),
rating=info.get("rating", 0.0),
reviews_count=info.get("reviews_count", 0),
position=overview.get("avg_position", 100),
position_trend=overview.get("position_trend", 0.0),
sales_trend=overview.get("sales_trend", 0.0),
days_on_market=info.get("days_on_market", 365),
ctr=overview.get("ad_ctr", 0.0),
ad_conversion=overview.get("ad_conversion", 0.0),
ad_spend=overview.get("ad_spend", 0.0),
)

Ожидаемые hit rates по паттернам

Паттерн% карточекПри 500 SKUОписание
P1: Content Problem20–30%100–150Самый частый: плохие фото, нет видео
P2: SEO Problem10–15%50–75Хороший товар, нет ключевиков
P3: Content Fatigue5–10%25–50TOP-10 с падением — дорогие лиды
P4: New Brand8–12%40–60Новички, быстро конвертируются
P5: Ad Inefficiency5–8%25–40Высокий чек из-за рекламных бюджетов
Суммарно (unique)25–35%125–175С учётом пересечений
Пересечения паттернов

Один товар может попасть в несколько паттернов. Например, новый бренд (P4) с плохим контентом (P1). Пересечения повышают PotentialScore — это лучшие лиды.


Реальный пример: сканирование категории «Кухонные ножи»

Входные данные

Категория: Кухонные ножи (WB)
SKU просканировано: 500
Средняя конверсия категории: 4.2%
Медианный поисковый объём: 8 500 запросов/мес

Результаты сканирования

┌──────────────────────────────────────────────────────────────────┐
│ CATEGORY SCAN RESULTS: Кухонные ножи (WB) │
├──────────────────────────────────────────────────────────────────┤
│ Total scanned: 500 SKU │
│ Problem cards: 127 (25.4%) │
│ Unique sellers: 85 │
│ │
│ By temperature: │
│ 🔥 HOT (≥70): 18 карточек (14 продавцов) │
│ 🟡 WARM (50-69): 42 карточки (31 продавец) │
│ 🔵 COOL (30-49): 67 карточек (40 продавцов) │
│ │
│ By pattern: │
│ P1 Content Problem: 89 (70% от найденных) │
│ P2 SEO Problem: 51 (40%) │
│ P3 Content Fatigue: 23 (18%) │
│ P4 New Brand: 34 (27%) │
│ P5 Ad Inefficiency: 19 (15%) │
│ │
│ Суммарная упущенная выручка: 47.2M ₽/мес │
│ Средняя упущенная выручка: 372K ₽/мес на карточку │
└──────────────────────────────────────────────────────────────────┘

ТОП-5 лидов

RankSKUБрендScoreTempПаттерныУпущ. выручка/месПроблемы
1198345672KnifeKing87.3HOTP1+P51.2M ₽3 фото, нет видео, CTR 5.2%, CV 1.1%
2234567891SteelPro82.1HOTP1+P2890K ₽4 фото, позиция 67, рейтинг 4.8
3345678912ChefBlade76.5HOTP3+P11.5M ₽TOP-5, падение 35%/мес, фото 2-летней давности
4456789123NewCut73.2HOTP4+P1340K ₽45 дней на рынке, 2 фото с телефона
5567891234BladeMaster71.8HOTP1+P2+P5780K ₽Тратит 180K/мес на рекламу, CV 0.8%

Пример аутрич-сообщения для лида #1 (KnifeKing)

Добрый день!

Мы проанализировали карточку вашего товара «Набор кухонных ножей KnifeKing»
(артикул 198345672) на Wildberries.

Вот что обнаружили:
📊 Этот товар ищут 14 200 раз в месяц
📉 Ваша конверсия: 1.1% (среднее по категории: 4.2%)
💸 Упущенная выручка: ~1 200 000 ₽/мес
📸 В карточке 3 фото и нет видео (у ТОП-3 конкурентов: 10+ фото + видео)

При этом ваша реклама работает отлично (CTR 5.2%), но карточка не конвертирует
клики в покупки. Каждый месяц ~680 000 ₽ рекламного бюджета уходит впустую.

Мы специализируемся на продающем контенте для маркетплейсов и можем подготовить
для вас бесплатный аудит карточки с конкретными рекомендациями и прогнозом роста.

Интересно?

CLI интеграция

# Полное сканирование категории
python spider.py leadgen scan \
--category 12345 \
--marketplace wb \
--limit 500 \
--min-score 50 \
--json

# Быстрый скоринг одного SKU
python spider.py leadgen score-product \
--sku 198345672 \
--marketplace wb \
--json

# Массовое сканирование нескольких категорий
python spider.py leadgen scan-batch \
--categories "12345,67890,11111" \
--marketplace wb \
--limit 500 \
--output results/scan_2026_03.json

# Статистика по паттернам
python spider.py leadgen pattern-stats \
--scan-id latest \
--json

Калибровка и валидация

Процесс калибровки

  1. Выборка: сканируем 3 категории × 500 SKU
  2. Ручная разметка: аналитик размечает 100 карточек (проблема/нет проблемы)
  3. Сравнение: Precision/Recall по каждому паттерну
  4. Корректировка весов: подстраиваем Wi по результатам
  5. A/B тест: split-test аутрича по высокому/среднему/низкому score
  6. Итерация: каждые 2 недели корректируем пороги по фактическим конверсиям

Целевые метрики

МетрикаЦель
Precision (HOT leads)≥ 80%
Recall (все проблемные)≥ 60%
Correlation score ↔ конверсия≥ 0.6
False positive rate≤ 20%
Первые 2 недели

Пороги и веса паттернов будут «сырыми». Первые 2 недели — это калибровка. Ожидайте 30–40% false positives на старте. К неделе 4 precision должен быть ≥ 70%.