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

Детектор убитых карточек

Автоматизированный пайплайн для поиска SKU с высокой выручкой и плохим контентом. Каждая такая карточка -- потенциальный клиент Fotofactor с бюджетом и очевидной болью.

Проблема

В каждой категории на WB и Ozon есть товары, которые генерируют миллионы рублей выручки при ужасном контенте: мало фотографий, нет видео, низкий рейтинг, отсутствие инфографики. Такие продавцы -- идеальные клиенты для Fotofactor:

  • У них есть деньги -- товар уже продаётся
  • У них есть боль -- контент явно не работает на полную мощность
  • У них есть мотивация -- lost_profit показывает, сколько они теряют
  • Они готовы платить -- средний чек 80 000--150 000 рублей

Проблема в том, что найти таких продавцов вручную невозможно. В одной категории -- тысячи SKU. Нужен автоматический детектор.

Пайплайн данных

graph LR
A["MPStats API<br/>POST /wb/get/category"] --> B["Топ-500 товаров<br/>sort: revenue desc"]
B --> C{"Фильтр:<br/>revenue > 500K AND<br/>(picscount < 5 OR<br/>hasvideo == 0 OR<br/>lost_profit > 100K)"}
C -->|"Прошёл"| D["ContentProblemScore<br/>0-100 баллов"]
C -->|"Не прошёл"| E["Пропуск"]
D --> F["Топ-20 по скору"]
F --> G["Персонализированный<br/>cold-outreach"]

Шаг 1: Загрузка данных из MPStats

Запрос топ-500 товаров категории, отсортированных по выручке:

import httpx
from urllib.parse import quote

TOKEN = "YOUR_MPSTATS_TOKEN"
BASE_URL = "https://mpstats.io/api"
headers = {
"X-Mpstats-TOKEN": TOKEN,
"Content-Type": "application/json",
}

resp = httpx.post(
f"{BASE_URL}/wb/get/category",
headers=headers,
json={
"path": quote("Женская одежда", safe=""),
"d1": "2026-01-20",
"d2": "2026-02-19",
"startRow": 0,
"endRow": 500,
"sortModel": [{"colId": "revenue", "sort": "desc"}],
},
)

result = resp.json()
products = result.get("data", [])
print(f"Загружено {len(products)} товаров из {result.get('total', '?')}")

Шаг 2: Фильтрация «убитых карточек»

Критерии «убитой карточки»

Карточка считается «убитой», если выполняется хотя бы одно из условий при выручке > 500 000 рублей за период:

КритерийПороговое значениеПочему это проблема
Мало фотоpicscount < 5ТОП-10 категории используют 10+ фото
Нет видеоhasvideo == 0Видео повышает конверсию на 20-40%
Высокая упущенная выручкаlost_profit > 100 000 ₽Прямые потери из-за out-of-stock и плохого контента
Мало отзывов при высоких продажахcomments < 50 AND sales > 500Низкое вовлечение покупателей

Шаг 3: Ключевые поля API

Все необходимые данные приходят из одного запроса POST /wb/get/category:

ПолеТипСмыслИспользование в скоринге
picscountintКоличество фото (бенчмарк ТОПа: 10+)Чем меньше -- тем выше problem score
hasvideo0/1Наличие видео0 = +20 к problem score
lost_profitfloatУпущенная выручка (out-of-stock + плохой контент)Чем больше -- тем выше opportunity
revenuefloatТекущая выручка за периодВес клиента (платёжеспособность)
salesintКоличество продажПодтверждение спроса
commentsintКоличество отзывовИндикатор вовлечённости
ratingfloatРейтинг товараОбщий показатель качества
final_pricefloatТекущая ценаКонтекст для outreach

Анализ: ContentProblemScore

Каждая «убитая карточка» получает скор от 0 до 100. Чем выше -- тем больше потенциал для Fotofactor:

def content_problem_score(product: dict) -> int:
"""
Рассчитывает score проблем с контентом.
0 = всё хорошо, 100 = максимально убитая карточка.
"""
score = 0

# === Фото (0-30 баллов) ===
# Меньше фото = выше problem score
picscount = product.get("picscount", 0)
if picscount < 3:
score += 30 # Критически мало фото
elif picscount < 5:
score += 20 # Ниже минимума для конверсии
elif picscount < 8:
score += 10 # Ниже бенчмарка ТОПа (10+)

# === Видео (0-20 баллов) ===
# Нет видео = упущенная конверсия
if not product.get("hasvideo"):
score += 20

# === Упущенная выручка (0-25 баллов) ===
# Больше lost_profit = больше opportunity для нас
lost_profit = product.get("lost_profit", 0)
if lost_profit > 500_000:
score += 25 # Теряет полмиллиона+ -- огромная мотивация
elif lost_profit > 100_000:
score += 15 # Существенные потери

# === Вес клиента по выручке (0-25 баллов) ===
# Больше выручка = клиент может платить больше
revenue = product.get("revenue", 0)
if revenue > 5_000_000:
score += 25 # Крупный продавец (5M+)
elif revenue > 1_000_000:
score += 15 # Средний продавец (1-5M)

return score # 0-100


def find_dead_cards(products: list, min_revenue: float = 500_000) -> list:
"""
Находит убитые карточки и сортирует по problem score.
"""
dead_cards = []

for product in products:
revenue = product.get("revenue", 0)
if revenue < min_revenue:
continue

# Проверяем критерии «убитости»
picscount = product.get("picscount", 0)
hasvideo = product.get("hasvideo", 0)
lost_profit = product.get("lost_profit", 0)

is_dead = (
picscount < 5
or hasvideo == 0
or lost_profit > 100_000
)

if is_dead:
score = content_problem_score(product)
dead_cards.append({
"sku": product.get("id"),
"name": product.get("name", "")[:80],
"revenue": revenue,
"picscount": picscount,
"hasvideo": hasvideo,
"lost_profit": lost_profit,
"rating": product.get("rating", 0),
"comments": product.get("comments", 0),
"final_price": product.get("final_price", 0),
"problem_score": score,
})

# Сортируем: самые «убитые» с максимальной выручкой -- наверху
dead_cards.sort(key=lambda x: x["problem_score"], reverse=True)

return dead_cards[:20] # Топ-20 для outreach

Интерпретация скора

Problem ScoreУровеньОписаниеПриоритет outreach
80-100КритическийМало фото, нет видео, огромная выручка, большие потериЗвонить немедленно
60-79ВысокийНесколько проблем с контентом, хорошая выручкаOutreach в течение дня
40-59СреднийОдна явная проблема, средняя выручкаOutreach в течение недели
20-39НизкийМинорные проблемы или небольшая выручкаДобавить в nurture-цепочку

Действие Fotofactor: персонализированный outreach

Каждое сообщение строится на конкретных данных о карточке продавца. Не «у нас классные услуги», а «вот сколько денег вы теряете».

Шаблон outreach: мало фото

Добрый день! Заметили ваш товар {name} на Wildberries.

При выручке {revenue} рублей/мес у карточки всего {picscount} фото. Для сравнения: ТОП-3 в вашей категории используют 12+ фото + видеообзор.

По данным аналитики, ваша упущенная выручка составляет {lost_profit} рублей -- это то, что вы могли бы зарабатывать дополнительно при качественном визуальном контенте.

Мы делаем продающий фото- и видеоконтент для маркетплейсов. Подготовили мини-аудит вашей карточки -- могу отправить?

Шаблон outreach: нет видео

Добрый день! Ваш товар {name} продаётся на {revenue} рублей/мес -- отличный результат!

Обратили внимание, что у карточки нет видео. По нашей статистике, добавление видеообзора увеличивает конверсию карточки на 20-40%.

При вашем текущем объёме продаж это может дать дополнительные {revenue * 0.25} рублей/мес выручки.

Мы снимаем видеообзоры для WB за 3-5 дней. Интересно обсудить?

Шаблон outreach: комплексная проблема (score > 70)

Добрый день! Провели анализ карточки {name} (артикул {sku}).

Текущая ситуация:

  • Выручка: {revenue} рублей/мес
  • Фото: {picscount} (бенчмарк категории: 10+)
  • Видео: {'есть' if hasvideo else 'нет'} (бенчмарк: обязательно)
  • Упущенная выручка: {lost_profit} рублей

По нашей оценке, обновление контента может вернуть {lost_profit} рублей/мес упущенной выручки. Подготовили детальный аудит -- отправить?

Генерация outreach-сообщений

def generate_outreach(dead_card: dict) -> str:
"""Генерирует персонализированное outreach-сообщение."""

score = dead_card["problem_score"]
picscount = dead_card["picscount"]
hasvideo = dead_card["hasvideo"]

if score >= 70:
# Комплексная проблема -- полный аудит
template = "complex"
elif picscount < 5:
template = "few_photos"
elif not hasvideo:
template = "no_video"
else:
template = "lost_profit"

# В реальном пайплайне -- подставляем данные в шаблон
return f"[{template}] SKU {dead_card['sku']}: " \
f"score={score}, revenue={dead_card['revenue']:,.0f}₽, " \
f"photos={picscount}, video={'yes' if hasvideo else 'no'}, " \
f"lost_profit={dead_card['lost_profit']:,.0f}₽"


# Пример использования полного пайплайна
dead_cards = find_dead_cards(products, min_revenue=500_000)

print(f"\nНайдено {len(dead_cards)} убитых карточек:\n")
for i, card in enumerate(dead_cards, 1):
msg = generate_outreach(card)
print(f" {i}. {msg}")

Результат для Fotofactor

Конверсия outreach

МетрикаCold (без данных)Персонализированный (с MPStats)Разница
Конверсия в ответ1-2%5-8%x4
Конверсия в клиента0.3-0.5%2-3%x5
Средний чек сделки50 000 рублей80 000-150 000 рублейx2

Персонализация работает, потому что продавец видит: мы уже знаем его цифры. Это не спам, а экспертный анализ.

Unit-экономика одного скана категории

Вход:  500 SKU из одной категории (1 API-запрос)

Фильтр: ~30-50 «убитых карточек» (revenue > 500K + плохой контент)

Outreach: 30-50 персонализированных сообщений

Ответы: 2-4 заинтересованных (конверсия 5-8%)

Сделки: 1-2 новых клиента (конверсия в оплату ~50%)

Выручка: 80 000-300 000 рублей

Масштабирование

Категорий в месяцУбитых карточекOutreachНовых клиентовВыручка
5150-250150-2508-15640K-2.25M рублей
10300-500300-50015-301.2M-4.5M рублей
20600-1000600-100030-602.4M-9M рублей
Расход API-квот

Один скан категории = 1 запрос к POST /wb/get/category. При 20 категориях в месяц расходуется всего 20 запросов из тысяч доступных на тарифе MPStats. Это самый дешёвый канал лидогенерации.

Полный скрипт

Минимальный рабочий пайплайн в одном файле:

import httpx
import json
from urllib.parse import quote
from datetime import datetime, timedelta

TOKEN = "YOUR_MPSTATS_TOKEN"
BASE_URL = "https://mpstats.io/api"
HEADERS = {
"X-Mpstats-TOKEN": TOKEN,
"Content-Type": "application/json",
}

# Параметры сканирования
CATEGORY = "Женская одежда"
MIN_REVENUE = 500_000
TOP_N = 500
OUTPUT_TOP = 20

# Период: последние 30 дней
d2 = datetime.now().strftime("%Y-%m-%d")
d1 = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")


def fetch_category(category: str) -> list:
"""Загружает топ товаров категории."""
resp = httpx.post(
f"{BASE_URL}/wb/get/category",
headers=HEADERS,
json={
"path": quote(category, safe=""),
"d1": d1,
"d2": d2,
"startRow": 0,
"endRow": TOP_N,
"sortModel": [{"colId": "revenue", "sort": "desc"}],
},
timeout=30,
)
resp.raise_for_status()
return resp.json().get("data", [])


def content_problem_score(p: dict) -> int:
"""Скор проблем с контентом: 0 (ок) -- 100 (убитая карточка)."""
score = 0
pics = p.get("picscount", 0)
if pics < 3: score += 30
elif pics < 5: score += 20
elif pics < 8: score += 10

if not p.get("hasvideo"): score += 20

lp = p.get("lost_profit", 0)
if lp > 500_000: score += 25
elif lp > 100_000: score += 15

rev = p.get("revenue", 0)
if rev > 5_000_000: score += 25
elif rev > 1_000_000: score += 15

return score


def scan_category(category: str) -> list:
"""Полный пайплайн: загрузка -> фильтрация -> скоринг -> топ."""
products = fetch_category(category)
print(f"Загружено {len(products)} товаров из категории '{category}'")

dead_cards = []
for p in products:
rev = p.get("revenue", 0)
if rev < MIN_REVENUE:
continue

pics = p.get("picscount", 0)
video = p.get("hasvideo", 0)
lp = p.get("lost_profit", 0)

if pics < 5 or video == 0 or lp > 100_000:
dead_cards.append({
"sku": p.get("id"),
"name": p.get("name", "")[:80],
"revenue": rev,
"picscount": pics,
"hasvideo": video,
"lost_profit": lp,
"rating": p.get("rating", 0),
"comments": p.get("comments", 0),
"final_price": p.get("final_price", 0),
"problem_score": content_problem_score(p),
})

dead_cards.sort(key=lambda x: x["problem_score"], reverse=True)
result = dead_cards[:OUTPUT_TOP]

print(f"Найдено {len(dead_cards)} убитых карточек, топ-{OUTPUT_TOP}:\n")
for i, card in enumerate(result, 1):
print(
f" {i:2d}. [Score {card['problem_score']:3d}] "
f"{card['name'][:50]}... "
f"rev={card['revenue']:>12,.0f}₽ "
f"pics={card['picscount']} "
f"video={'Y' if card['hasvideo'] else 'N'} "
f"lost={card['lost_profit']:>10,.0f}₽"
)

return result


if __name__ == "__main__":
results = scan_category(CATEGORY)

# Сохраняем результат для CRM-импорта
output_file = f"dead_cards_{d2}.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\nСохранено в {output_file}")

Что дальше