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

ContentScore — Алгоритм оценки

Зачем ContentScore?

ContentScore — это единое число от 0 до 100, которое показывает клиенту качество его контента на маркетплейсе в привязке к продажам. Вместо десятков метрик — одна понятная оценка, как "температура здоровья" карточки товара.

Обзор алгоритма

Формула верхнего уровня

ContentScore = Σ (SubScore_i × Weight_i)

Где:
SubScore_i — суб-скор компонента (0-100)
Weight_i — весовой коэффициент (сумма = 1.0)

Компоненты и веса

pie title "ContentScore — веса компонентов"
"PositionScore (25%)" : 25
"SalesScore (25%)" : 25
"KeywordCoverage (20%)" : 20
"VisualCompleteness (15%)" : 15
"CompetitivePosition (15%)" : 15
Суб-скорВесЧто измеряетИсточник данных
PositionScore0.25Позиция в категории и поискеSF: /products/mp/sku/position
SalesScore0.25Динамика продаж и выручкиSF: /products/mp/sku/sales
KeywordCoverage0.20Покрытие ключевых слов в текстахSF: /products/mp/sku/keywords
VisualCompleteness0.15Полнота визуального контентаSF: /products/mp/sku (базовая инфо)
CompetitivePosition0.15Позиция относительно конкурентовSF: /categories/mp/cat/stats

Суб-скоры: детальное описание

1. PositionScore (вес: 25%)

Что измеряет: насколько хорошо товар ранжируется в каталоге и поиске маркетплейса.

Логика: чем выше позиция (ближе к 1), тем выше скор. Используем логарифмическую шкалу, потому что разница между позициями 1 и 10 гораздо значимее, чем между 100 и 110.

Формула:

def calculate_position_score(position: int, total_in_category: int) -> float:
"""
Расчёт PositionScore.

Args:
position: Текущая позиция товара в категории (1 = лучшая)
total_in_category: Общее количество товаров в категории

Returns:
float: Скор 0-100

Логика:
- Позиция 1-3: 90-100 (топ)
- Позиция 4-10: 75-89 (отлично)
- Позиция 11-50: 50-74 (хорошо)
- Позиция 51-200: 25-49 (средне)
- Позиция 200+: 0-24 (плохо)
"""
if position is None or position <= 0:
return 0.0

# Нормализация через перцентиль
if total_in_category > 0:
percentile = (1 - position / total_in_category) * 100
percentile = max(0, min(100, percentile))
else:
percentile = 50.0

# Логарифмическая шкала для позиции
import math
if position <= 3:
log_score = 100 - (position - 1) * 5 # 100, 95, 90
elif position <= 10:
log_score = 90 - (position - 3) * 2.14 # 90 → 75
elif position <= 50:
log_score = 75 - (position - 10) * 0.625 # 75 → 50
elif position <= 200:
log_score = 50 - (position - 50) * 0.167 # 50 → 25
else:
log_score = max(0, 25 - math.log10(position - 200) * 8)

# Комбинируем: 60% логарифмическая позиция + 40% перцентиль
score = log_score * 0.6 + percentile * 0.4
return round(max(0, min(100, score)), 1)

Данные SF API:

// GET /api/v2/products/wildberries/12345678/position
{
"sku": 12345678,
"category_position": 45,
"category_total": 12500,
"search_positions": {
"кухонный нож": 12,
"нож для кухни": 23,
"нож шеф-повара": 8
}
}

2. SalesScore (вес: 25%)

Что измеряет: объём и динамика продаж. Растут ли продажи, стабильны, или падают.

Формула:

def calculate_sales_score(
sales_current: int,
sales_previous: int,
revenue_current: float,
avg_sales_in_category: float,
days: int = 30
) -> float:
"""
Расчёт SalesScore.

Учитывает:
1. Абсолютный объём продаж (vs категория) — 40%
2. Динамика продаж (рост/падение) — 35%
3. Стабильность (низкая дисперсия) — 25%

Args:
sales_current: Продажи за текущий период
sales_previous: Продажи за предыдущий период
revenue_current: Выручка за текущий период
avg_sales_in_category: Средние продажи в категории
days: Количество дней периода
"""
# 1. Абсолютный объём (vs категория)
if avg_sales_in_category > 0:
volume_ratio = sales_current / avg_sales_in_category
# Сигмоида: 1.0 = среднее → 50, 2.0 = 2x среднего → 80, 0.5 = половина → 30
volume_score = 100 / (1 + math.exp(-2.5 * (volume_ratio - 1)))
else:
volume_score = 50.0 # Нет данных по категории

# 2. Динамика продаж
if sales_previous > 0:
growth_rate = (sales_current - sales_previous) / sales_previous
# Рост +50% = 80 баллов, рост +100% = 95, падение -50% = 20
growth_score = 50 + growth_rate * 60
growth_score = max(0, min(100, growth_score))
elif sales_current > 0:
growth_score = 70.0 # Появились продажи с нуля
else:
growth_score = 0.0 # Нет продаж

# 3. Стабильность (бонус за стабильный рост, штраф за хаотичность)
# Рассчитывается из дневных данных, здесь упрощённо
stability_score = 70.0 # Default, пересчитывается при наличии daily data

# Комбинируем
score = volume_score * 0.40 + growth_score * 0.35 + stability_score * 0.25
return round(max(0, min(100, score)), 1)

Данные SF API:

// GET /api/v2/products/wildberries/12345678/sales?days=30
{
"sku": 12345678,
"period_days": 30,
"sales_count": 342,
"revenue": 1026000.00,
"avg_price": 3000.00,
"daily_sales": [
{"date": "2026-02-01", "sales": 12, "revenue": 36000},
{"date": "2026-02-02", "sales": 15, "revenue": 45000},
// ... 28 дней
]
}

3. KeywordCoverage (вес: 20%)

Что измеряет: насколько полно тексты карточки (название, описание, характеристики) покрывают релевантные поисковые запросы.

Формула:

def calculate_keyword_score(
product_keywords: list[dict],
title: str,
description: str,
characteristics: dict
) -> float:
"""
Расчёт KeywordCoverage.

Учитывает:
1. Покрытие топ-ключей (ключи в title/description) — 50%
2. Глубина покрытия (позиции по ключам) — 30%
3. Полнота характеристик — 20%

Args:
product_keywords: Список ключевых слов с частотностью и позицией
[{keyword: str, frequency: int, position: int | None}, ...]
title: Название товара
description: Описание товара
characteristics: Заполненные характеристики
"""
if not product_keywords:
return 30.0 # Нет данных о ключах

# 1. Покрытие топ-ключей
# Берём топ-20 ключей по частотности
top_keywords = sorted(product_keywords, key=lambda x: x["frequency"], reverse=True)[:20]

text_lower = f"{title} {description}".lower()
covered = sum(1 for kw in top_keywords if kw["keyword"].lower() in text_lower)
coverage_pct = covered / len(top_keywords) * 100

# Нелинейная шкала: 100% покрытие = 100, 50% = 60, 25% = 35
coverage_score = min(100, coverage_pct * 1.2) # Slight boost

# 2. Глубина: средняя позиция по ключам (где мы видны)
positioned_keywords = [kw for kw in product_keywords if kw.get("position")]
if positioned_keywords:
avg_kw_position = sum(kw["position"] for kw in positioned_keywords) / len(positioned_keywords)
# Позиция 1-10 = 100, 11-50 = 70, 51-200 = 40, 200+ = 10
if avg_kw_position <= 10:
depth_score = 100
elif avg_kw_position <= 50:
depth_score = 100 - (avg_kw_position - 10) * 0.75
elif avg_kw_position <= 200:
depth_score = 70 - (avg_kw_position - 50) * 0.2
else:
depth_score = 10
else:
depth_score = 30.0

# 3. Полнота характеристик
# Разные категории имеют разное кол-во обязательных характеристик
# Минимум: 5 заполненных характеристик
char_count = len(characteristics) if characteristics else 0
char_score = min(100, char_count * 10) # 10 характеристик = 100

# Комбинируем
score = coverage_score * 0.50 + depth_score * 0.30 + char_score * 0.20
return round(max(0, min(100, score)), 1)

Данные SF API:

// GET /api/v2/products/wildberries/12345678/keywords
{
"sku": 12345678,
"keywords": [
{"keyword": "кухонный нож", "frequency": 45000, "position": 12},
{"keyword": "нож для кухни", "frequency": 32000, "position": 23},
{"keyword": "нож шеф-повара", "frequency": 18000, "position": 8},
{"keyword": "кухонный нож набор", "frequency": 15000, "position": null},
{"keyword": "нож керамический", "frequency": 12000, "position": 156}
]
}

4. VisualCompleteness (вес: 15%)

Что измеряет: полнота визуального контента карточки. Есть ли достаточно фото, видео, инфографика, 360-обзор.

Формула:

def calculate_visual_score(
photo_count: int,
video_count: int,
has_360: bool,
has_infographics: bool,
marketplace: str = "wildberries"
) -> float:
"""
Расчёт VisualCompleteness.

Оптимальные значения (на основе анализа топ-100 товаров):
- WB: 7-10 фото, 1+ видео, инфографика
- Ozon: 5-8 фото, 1+ видео, инфографика

Система начисления баллов:
- Фото: до 50 баллов
- Видео: до 20 баллов
- Инфографика: до 15 баллов
- 360-обзор: до 15 баллов
"""
# Фото (до 50 баллов)
optimal_photos = 8 if marketplace == "wildberries" else 6
if photo_count >= optimal_photos:
photo_score = 50
elif photo_count >= optimal_photos * 0.7:
photo_score = 40
elif photo_count >= 3:
photo_score = 25
elif photo_count >= 1:
photo_score = 10
else:
photo_score = 0

# Видео (до 20 баллов)
if video_count >= 2:
video_score = 20
elif video_count == 1:
video_score = 15
else:
video_score = 0

# Инфографика (до 15 баллов)
infographic_score = 15 if has_infographics else 0

# 360-обзор (до 15 баллов)
view_360_score = 15 if has_360 else 0

total = photo_score + video_score + infographic_score + view_360_score
return round(max(0, min(100, total)), 1)
Определение инфографики

SF API не определяет наличие инфографики напрямую. Для MVP используем эвристику:

  • Если photo_count >= 5 и в metadata есть упоминания "инфографика" — считаем has_infographics = True
  • В Phase 2 добавим Computer Vision (распознавание текста на фото = инфографика)

5. CompetitivePosition (вес: 15%)

Что измеряет: положение товара относительно конкурентов в той же категории. Перцентиль по ключевым метрикам.

Формула:

def calculate_competitive_score(
product_position: int,
product_sales: int,
product_rating: float,
product_price: float,
benchmark: dict # Данные из category_benchmarks
) -> float:
"""
Расчёт CompetitivePosition.

Сравниваем товар с бенчмарками категории:
1. Позиция vs медиана категории — 30%
2. Продажи vs среднее категории — 30%
3. Рейтинг vs среднее категории — 20%
4. Ценовая конкурентоспособность — 20%
"""
# 1. Позиция vs категория
if benchmark.get("median_position") and product_position:
pos_ratio = benchmark["median_position"] / max(product_position, 1)
# ratio > 1 = мы лучше медианы
pos_score = min(100, 50 * pos_ratio)
else:
pos_score = 50.0

# 2. Продажи vs категория
if benchmark.get("avg_sales_per_day") and product_sales:
sales_ratio = product_sales / max(benchmark["avg_sales_per_day"], 1)
sales_comp_score = min(100, 50 * sales_ratio)
else:
sales_comp_score = 50.0

# 3. Рейтинг vs категория
if benchmark.get("avg_rating") and product_rating:
rating_diff = product_rating - benchmark["avg_rating"]
# +0.5 vs среднее = 100, 0 = 50, -0.5 = 0
rating_score = max(0, min(100, 50 + rating_diff * 100))
else:
rating_score = 50.0

# 4. Ценовая конкурентоспособность
# Оптимум: цена = 0.8-1.1 от средней (конкурентна, но не демпинг)
if benchmark.get("avg_price") and product_price:
price_ratio = product_price / benchmark["avg_price"]
if 0.8 <= price_ratio <= 1.1:
price_score = 100 # Оптимальный диапазон
elif 0.6 <= price_ratio < 0.8:
price_score = 70 # Дешевле (может восприниматься как некачественный)
elif 1.1 < price_ratio <= 1.5:
price_score = 60 # Немного дороже (нужен премиальный контент)
else:
price_score = 30 # Сильный выброс
else:
price_score = 50.0

# Комбинируем
score = (pos_score * 0.30 + sales_comp_score * 0.30 +
rating_score * 0.20 + price_score * 0.20)
return round(max(0, min(100, score)), 1)

Полный класс ContentScore Calculator

# src/engine/content_score.py

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

logger = structlog.get_logger()


@dataclass
class SubScores:
"""Все суб-скоры с деталями расчёта."""
position: float = 0.0
sales: float = 0.0
keyword: float = 0.0
visual: float = 0.0
competitive: float = 0.0

def to_dict(self) -> dict:
return {
"position": self.position,
"sales": self.sales,
"keyword": self.keyword,
"visual": self.visual,
"competitive": self.competitive,
}


@dataclass
class ContentScoreResult:
"""Результат расчёта ContentScore."""
total_score: float
sub_scores: SubScores
weights: dict
grade: str # "excellent", "good", "average", "poor", "critical"
recommendations: list[str]
calculated_at: datetime = field(default_factory=datetime.utcnow)
details: dict = field(default_factory=dict)

@property
def grade_emoji(self) -> str:
return {
"excellent": "🟢",
"good": "🔵",
"average": "🟡",
"poor": "🟠",
"critical": "🔴",
}.get(self.grade, "⚪")

def to_dict(self) -> dict:
return {
"total_score": self.total_score,
"grade": self.grade,
"sub_scores": self.sub_scores.to_dict(),
"weights": self.weights,
"recommendations": self.recommendations,
"calculated_at": self.calculated_at.isoformat(),
"details": self.details,
}


class ContentScoreEngine:
"""
Движок расчёта ContentScore.

Usage:
engine = ContentScoreEngine()
result = engine.calculate(product_data, benchmark_data)
print(f"Score: {result.total_score} ({result.grade})")
"""

DEFAULT_WEIGHTS = {
"position": 0.25,
"sales": 0.25,
"keyword": 0.20,
"visual": 0.15,
"competitive": 0.15,
}

GRADE_THRESHOLDS = [
(90, "excellent"),
(70, "good"),
(50, "average"),
(30, "poor"),
(0, "critical"),
]

def __init__(self, weights: Optional[dict] = None):
self.weights = weights or self.DEFAULT_WEIGHTS.copy()
assert abs(sum(self.weights.values()) - 1.0) < 0.01, "Weights must sum to 1.0"

def calculate(
self,
product_data: dict,
sales_data: dict,
keywords_data: list[dict],
benchmark_data: dict,
) -> ContentScoreResult:
"""
Полный расчёт ContentScore для одного товара.

Args:
product_data: Основные данные товара (фото, видео, позиция, и т.д.)
sales_data: Данные о продажах (текущий и предыдущий период)
keywords_data: Ключевые слова с позициями
benchmark_data: Бенчмарки категории

Returns:
ContentScoreResult со скором, грейдом и рекомендациями
"""
sub_scores = SubScores()
recommendations = []

# 1. PositionScore
sub_scores.position = self._position_score(
position=product_data.get("category_position"),
total=product_data.get("category_total", 0),
)
if sub_scores.position < 50:
recommendations.append(
"Позиция в категории ниже среднего. Рекомендуется SEO-оптимизация "
"текстов и улучшение визуального контента для повышения ранжирования."
)

# 2. SalesScore
sub_scores.sales = self._sales_score(
current=sales_data.get("sales_count", 0),
previous=sales_data.get("sales_count_prev", 0),
revenue=sales_data.get("revenue", 0),
category_avg=benchmark_data.get("avg_sales_per_day", 0),
)
if sub_scores.sales < 50:
recommendations.append(
"Продажи ниже среднего по категории. Проверьте ценовое позиционирование, "
"качество главного фото и конверсионные элементы в описании."
)

# 3. KeywordCoverage
sub_scores.keyword = self._keyword_score(
keywords=keywords_data,
title=product_data.get("name", ""),
description=product_data.get("description", ""),
characteristics=product_data.get("characteristics", {}),
)
if sub_scores.keyword < 50:
recommendations.append(
"Низкое покрытие ключевых слов. Добавьте релевантные поисковые запросы "
"в название, описание и характеристики товара."
)

# 4. VisualCompleteness
sub_scores.visual = self._visual_score(
photos=product_data.get("photo_count", 0),
videos=product_data.get("video_count", 0),
has_360=product_data.get("has_360", False),
has_infographics=product_data.get("has_infographics", False),
marketplace=product_data.get("marketplace", "wildberries"),
)
if sub_scores.visual < 50:
missing = []
if product_data.get("photo_count", 0) < 5:
missing.append(f"фото (сейчас {product_data.get('photo_count', 0)}, нужно 7+)")
if product_data.get("video_count", 0) == 0:
missing.append("видеообзор товара")
if not product_data.get("has_infographics"):
missing.append("инфографика с ключевыми характеристиками")
recommendations.append(
f"Неполный визуальный контент. Добавьте: {', '.join(missing)}."
)

# 5. CompetitivePosition
sub_scores.competitive = self._competitive_score(
position=product_data.get("category_position", 999),
sales=sales_data.get("sales_count", 0),
rating=product_data.get("rating", 0),
price=product_data.get("price", 0),
benchmark=benchmark_data,
)
if sub_scores.competitive < 50:
recommendations.append(
"Позиция среди конкурентов слабая. Рассмотрите улучшение рейтинга "
"(ответы на отзывы, качество), оптимизацию цены или усиление контента."
)

# Итоговый скор
total = (
sub_scores.position * self.weights["position"]
+ sub_scores.sales * self.weights["sales"]
+ sub_scores.keyword * self.weights["keyword"]
+ sub_scores.visual * self.weights["visual"]
+ sub_scores.competitive * self.weights["competitive"]
)
total = round(max(0, min(100, total)), 1)

# Определяем грейд
grade = "critical"
for threshold, g in self.GRADE_THRESHOLDS:
if total >= threshold:
grade = g
break

# Сортируем рекомендации по приоритету (самый низкий суб-скор первым)
score_map = {
"position": sub_scores.position,
"sales": sub_scores.sales,
"keyword": sub_scores.keyword,
"visual": sub_scores.visual,
"competitive": sub_scores.competitive,
}
# Топ-3 рекомендации (по самым слабым скорам)
recommendations = recommendations[:3]

return ContentScoreResult(
total_score=total,
sub_scores=sub_scores,
weights=self.weights,
grade=grade,
recommendations=recommendations,
details={
"product_sku": product_data.get("sku"),
"marketplace": product_data.get("marketplace"),
"score_breakdown": score_map,
"weakest_area": min(score_map, key=score_map.get),
"strongest_area": max(score_map, key=score_map.get),
},
)

# ============================================================
# Private methods (sub-score calculators)
# ============================================================

def _position_score(self, position: Optional[int], total: int) -> float:
if position is None or position <= 0:
return 0.0
percentile = ((1 - position / max(total, 1)) * 100) if total > 0 else 50.0
percentile = max(0, min(100, percentile))

if position <= 3:
log_score = 100 - (position - 1) * 5
elif position <= 10:
log_score = 90 - (position - 3) * 2.14
elif position <= 50:
log_score = 75 - (position - 10) * 0.625
elif position <= 200:
log_score = 50 - (position - 50) * 0.167
else:
log_score = max(0, 25 - math.log10(max(position - 200, 1)) * 8)

return round(log_score * 0.6 + percentile * 0.4, 1)

def _sales_score(
self, current: int, previous: int, revenue: float, category_avg: float
) -> float:
# Объём
if category_avg > 0:
volume_ratio = current / category_avg
volume_score = 100 / (1 + math.exp(-2.5 * (volume_ratio - 1)))
else:
volume_score = 50.0

# Динамика
if previous > 0:
growth = (current - previous) / previous
growth_score = max(0, min(100, 50 + growth * 60))
elif current > 0:
growth_score = 70.0
else:
growth_score = 0.0

stability_score = 70.0 # default
return round(volume_score * 0.4 + growth_score * 0.35 + stability_score * 0.25, 1)

def _keyword_score(
self, keywords: list[dict], title: str, description: str, characteristics: dict
) -> float:
if not keywords:
return 30.0

top_kw = sorted(keywords, key=lambda x: x.get("frequency", 0), reverse=True)[:20]
text = f"{title} {description}".lower()
covered = sum(1 for kw in top_kw if kw.get("keyword", "").lower() in text)
coverage_score = min(100, (covered / len(top_kw)) * 120)

positioned = [kw for kw in keywords if kw.get("position")]
if positioned:
avg_pos = sum(kw["position"] for kw in positioned) / len(positioned)
if avg_pos <= 10:
depth_score = 100
elif avg_pos <= 50:
depth_score = 100 - (avg_pos - 10) * 0.75
elif avg_pos <= 200:
depth_score = 70 - (avg_pos - 50) * 0.2
else:
depth_score = 10
else:
depth_score = 30.0

char_count = len(characteristics) if characteristics else 0
char_score = min(100, char_count * 10)

return round(coverage_score * 0.5 + depth_score * 0.3 + char_score * 0.2, 1)

def _visual_score(
self, photos: int, videos: int, has_360: bool, has_infographics: bool,
marketplace: str = "wildberries"
) -> float:
optimal = 8 if marketplace == "wildberries" else 6
if photos >= optimal:
photo_s = 50
elif photos >= int(optimal * 0.7):
photo_s = 40
elif photos >= 3:
photo_s = 25
elif photos >= 1:
photo_s = 10
else:
photo_s = 0

video_s = 20 if videos >= 2 else (15 if videos == 1 else 0)
infographic_s = 15 if has_infographics else 0
view360_s = 15 if has_360 else 0

return round(min(100, photo_s + video_s + infographic_s + view360_s), 1)

def _competitive_score(
self, position: int, sales: int, rating: float, price: float, benchmark: dict
) -> float:
median_pos = benchmark.get("median_position")
if median_pos and position:
pos_s = min(100, 50 * (median_pos / max(position, 1)))
else:
pos_s = 50.0

avg_sales = benchmark.get("avg_sales_per_day")
if avg_sales and sales:
sales_s = min(100, 50 * (sales / max(avg_sales, 1)))
else:
sales_s = 50.0

avg_rating = benchmark.get("avg_rating")
if avg_rating and rating:
rating_s = max(0, min(100, 50 + (rating - avg_rating) * 100))
else:
rating_s = 50.0

avg_price = benchmark.get("avg_price")
if avg_price and price:
ratio = price / avg_price
if 0.8 <= ratio <= 1.1:
price_s = 100
elif 0.6 <= ratio < 0.8 or 1.1 < ratio <= 1.5:
price_s = 65
else:
price_s = 30
else:
price_s = 50.0

return round(pos_s * 0.3 + sales_s * 0.3 + rating_s * 0.2 + price_s * 0.2, 1)

Калибровка весов

Методология

Веса суб-скоров можно настраивать по категориям. Например, для категории "Одежда" визуальный контент важнее, чем для "Электроника".

# Примеры калибровки по категориям

CATEGORY_WEIGHTS = {
# Одежда, обувь: визуал критичен
"fashion": {
"position": 0.20,
"sales": 0.20,
"keyword": 0.15,
"visual": 0.30, # Увеличен
"competitive": 0.15,
},

# Электроника: характеристики и ключевые слова важнее
"electronics": {
"position": 0.25,
"sales": 0.25,
"keyword": 0.25, # Увеличен
"visual": 0.10, # Уменьшен
"competitive": 0.15,
},

# Продукты питания: рейтинг и отзывы критичны
"food": {
"position": 0.20,
"sales": 0.30, # Увеличен
"keyword": 0.15,
"visual": 0.10,
"competitive": 0.25, # Увеличен (рейтинг внутри)
},

# Default для неизвестных категорий
"default": {
"position": 0.25,
"sales": 0.25,
"keyword": 0.20,
"visual": 0.15,
"competitive": 0.15,
},
}

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

flowchart LR
A["Собрать данные<br/>100+ товаров<br/>в категории"] --> B["Расчитать ContentScore<br/>с default весами"]
B --> C["Сравнить с реальными<br/>продажами (корреляция)"]
C --> D{"Корреляция<br/>> 0.7?"}
D -->|"Да"| E["Зафиксировать веса"]
D -->|"Нет"| F["Grid search:<br/>перебор весов"]
F --> G["Найти комбинацию<br/>с макс. корреляцией"]
G --> C
# scripts/calibrate_weights.py

import itertools
import numpy as np
from scipy.stats import pearsonr


def calibrate_weights(
products: list[dict],
actual_sales: list[float],
weight_step: float = 0.05
) -> dict:
"""
Grid search для оптимальных весов ContentScore.

Перебирает все комбинации весов с шагом weight_step,
находит комбинацию с максимальной корреляцией Пирсона
между ContentScore и реальными продажами.
"""
components = ["position", "sales", "keyword", "visual", "competitive"]
steps = np.arange(0.05, 0.50, weight_step)

best_corr = -1
best_weights = None

# Генерируем все комбинации весов, суммирующиеся в 1.0
for combo in itertools.product(steps, repeat=5):
if abs(sum(combo) - 1.0) > 0.01:
continue

weights = dict(zip(components, combo))
engine = ContentScoreEngine(weights=weights)

# Считаем скоры
scores = []
for product in products:
result = engine.calculate(
product["data"], product["sales"],
product["keywords"], product["benchmark"]
)
scores.append(result.total_score)

# Корреляция
corr, p_value = pearsonr(scores, actual_sales)
if corr > best_corr and p_value < 0.05:
best_corr = corr
best_weights = weights

return {
"weights": best_weights,
"correlation": round(best_corr, 4),
"sample_size": len(products),
}

Шкала оценки и интерпретация

Грейды

ДиапазонГрейдИндикаторИнтерпретацияДействие
90-100Excellent🟢Карточка оптимизирована идеальноПоддерживать текущий уровень
70-89Good🔵Хорошая карточка, есть что улучшитьТочечные улучшения
50-69Average🟡Средняя карточка, теряет продажиКомплексная оптимизация
30-49Poor🟠Слабая карточка, значительные потериСрочная переработка контента
0-29Critical🔴Критически плохая карточкаПолное пересоздание карточки

Ожидаемое распределение

Распределение ContentScore (типичная категория WB):

%

15 │ ┌───┐
│ ┌───┤ ├───┐
10 │ │ │ │ │
│ ┌──┤ │ │ ├──┐
5 │ │ │ │ │ │ │
│ │ │ │ │ │ ├──┐
2 │─┤ │ │ │ │ │ │
└─┴──┴───┴───┴───┴──┴──┴───
0-29 30-49 50-69 70-89 90+
Critical Poor Average Good Excellent

Ожидаемое распределение:
Critical: ~8% (запущенные карточки)
Poor: ~18% (нуждаются в переработке)
Average: ~35% (основная масса)
Good: ~28% (хорошо оптимизированные)
Excellent: ~11% (идеальные карточки)

Практический пример расчёта

Товар: Кухонный нож "Chef Master" (WB SKU 456789012)

Исходные данные от SF API:

product_data = {
"sku": 456789012,
"marketplace": "wildberries",
"name": "Кухонный нож Chef Master шеф-повара 20 см",
"category_position": 45,
"category_total": 12500,
"price": 2990.0,
"rating": 4.6,
"photo_count": 6,
"video_count": 1,
"has_360": False,
"has_infographics": True,
"description": "Профессиональный кухонный нож из высокоуглеродистой стали...",
"characteristics": {
"Длина лезвия": "20 см",
"Материал лезвия": "Высокоуглеродистая сталь X50CrMoV15",
"Материал рукоятки": "Полиоксиметилен",
"Вес": "230 г",
"Тип": "Шеф-нож",
"Твёрдость": "58 HRC",
}
}

sales_data = {
"sales_count": 342, # За 30 дней
"sales_count_prev": 285, # За предыдущие 30 дней
"revenue": 1026000.0,
}

keywords_data = [
{"keyword": "кухонный нож", "frequency": 45000, "position": 12},
{"keyword": "нож шеф-повара", "frequency": 18000, "position": 8},
{"keyword": "нож для кухни", "frequency": 32000, "position": 23},
{"keyword": "профессиональный нож", "frequency": 8000, "position": 67},
{"keyword": "нож 20 см", "frequency": 5000, "position": None},
]

benchmark_data = {
"median_position": 120,
"avg_sales_per_day": 8.5,
"avg_rating": 4.3,
"avg_price": 2500.0,
"avg_photo_count": 5.2,
}

Расчёт:

engine = ContentScoreEngine()
result = engine.calculate(product_data, sales_data, keywords_data, benchmark_data)

print(f"ContentScore: {result.total_score}")
print(f"Grade: {result.grade_emoji} {result.grade}")
print(f"\nСуб-скоры:")
print(f" Position: {result.sub_scores.position}/100 (вес 25%)")
print(f" Sales: {result.sub_scores.sales}/100 (вес 25%)")
print(f" Keyword: {result.sub_scores.keyword}/100 (вес 20%)")
print(f" Visual: {result.sub_scores.visual}/100 (вес 15%)")
print(f" Competitive: {result.sub_scores.competitive}/100 (вес 15%)")
print(f"\nРекомендации:")
for rec in result.recommendations:
print(f" - {rec}")

Ожидаемый результат:

ContentScore: 72.4
Grade: 🔵 good

Суб-скоры:
Position: 66.3/100 (вес 25%) ← Позиция 45 из 12500 — неплохо
Sales: 78.2/100 (вес 25%) ← Рост +20%, выше среднего по категории
Keyword: 71.5/100 (вес 20%) ← 3 из 5 ключей в текстах
Visual: 80.0/100 (вес 15%) ← 6 фото + видео + инфографика
Competitive: 69.4/100 (вес 15%) ← Выше среднего, но цена чуть выше

Рекомендации:
- Позиция в категории ниже среднего. Рекомендуется SEO-оптимизация
текстов и улучшение визуального контента для повышения ранжирования.

Что делать для роста до 85+?

ДействиеОжидаемый эффект на ContentScore
Добавить ещё 2 фото (= 8 оптимум)Visual: 80 → 85 (+1 total)
Добавить 360-обзорVisual: 85 → 100 (+2.2 total)
Оптимизировать SEO текста (покрыть все ключи)Keyword: 71 → 90 (+3.8 total)
Улучшить позицию до топ-20Position: 66 → 80 (+3.5 total)
Суммарный рост72.4 → ~82.9

Связь ContentScore с продажами

Корреляция != Каузация

ContentScore показывает корреляцию с продажами, а не причинно-следственную связь. Товар с высоким скором в среднем продаётся лучше, но есть исключения (сезонность, промо, тренды). Используйте Before/After трекер для доказательства каузации.

Ожидаемые корреляции (по результатам бета-тестирования)

КатегорияКорреляция ContentScore vs SalesЗначимость
Одежда0.72p < 0.001
Электроника0.68p < 0.001
Дом и кухня0.75p < 0.001
Красота0.71p < 0.001
Спорт0.65p < 0.01
Среднее0.70

Связанные документы

ДокументСодержание
MVP ОбзорВидение, roadmap, бюджет
Техническая архитектураКомпоненты, схема БД
Before/After трекерЗамеры до/после обновления контента
Клиентский дашбордВизуализация ContentScore
Финансовая модельМонетизация ContentScore