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

Система аутрич и коммуникаций

Главный принцип

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


Каналы коммуникации

Обзор каналов и метрики

#КаналResponse RateВремя ответаСтоимостьПриоритетОсобенности
1WB/Ozon DM15–20%1–3 дняБесплатноAПрямое сообщение продавцу через площадку
2Telegram10–15%2–6 часовБесплатноAБыстрые ответы, неформальный тон
3Email5–8%1–5 дней~2 ₽/письмоBДля крупных брендов, формальный тон
4Телефон8–12%Мгновенно~50 ₽/звонокBДля HOT лидов, персональный подход
graph LR
L["Lead\n(Score ≥ 50)"] --> D{"Temperature?"}
D -->|"HOT ≥ 70"| T["📞 Телефон\n+ Telegram"]
D -->|"WARM 50-69"| M["💬 WB/Ozon DM\n+ Telegram"]
D -->|"COOL 30-49"| E["📧 Email\n+ WB DM"]

T --> F1["Follow-up\nDay 3"]
M --> F1
E --> F1

style T fill:#f44336,color:#fff
style M fill:#ff9800,color:#fff
style E fill:#2196f3,color:#fff

Выбор канала по температуре лида

from enum import Enum
from dataclasses import dataclass


class OutreachChannel(Enum):
WB_DM = "wb_dm"
OZON_DM = "ozon_dm"
TELEGRAM = "telegram"
EMAIL = "email"
PHONE = "phone"


@dataclass
class ChannelStrategy:
primary: OutreachChannel
secondary: OutreachChannel
delay_between: int # часов между каналами


def get_channel_strategy(temperature: str, marketplace: str) -> ChannelStrategy:
"""Определяет оптимальную стратегию каналов по температуре лида."""

if temperature == "hot":
return ChannelStrategy(
primary=OutreachChannel.PHONE,
secondary=OutreachChannel.TELEGRAM,
delay_between=2, # 2 часа: звонок → если не ответил → Telegram
)
elif temperature == "warm":
primary = (
OutreachChannel.WB_DM
if marketplace == "wb"
else OutreachChannel.OZON_DM
)
return ChannelStrategy(
primary=primary,
secondary=OutreachChannel.TELEGRAM,
delay_between=24, # 24 часа: DM → если не ответил → Telegram
)
else: # cool
return ChannelStrategy(
primary=OutreachChannel.EMAIL,
secondary=(
OutreachChannel.WB_DM
if marketplace == "wb"
else OutreachChannel.OZON_DM
),
delay_between=48, # 48 часов: email → если не ответил → DM
)

Персонализация сообщений

Движок персонализации

Каждое сообщение строится на основе данных из SalesFinder. Обязательные элементы:

ЭлементИсточникПример
Название товараproduct/info → name«Набор кухонных ножей KnifeKing»
Артикулproduct/info → sku198345672
Поисковый спросproduct/keywords → search_volume14 200 запросов/мес
Конверсияproduct/overview → conversion_rate1.1%
Среднее по категорииРасчётное4.2%
Упущенная выручкаРасчётное1 200 000 ₽/мес
Кол-во фотоproduct/info → photos_count3 фото
Наличие видеоproduct/info → has_videoНет видео
ТОП-3 конкурентыcategory/products → top310+ фото, видео, инфографика

Python: генератор сообщений

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


@dataclass
class OutreachData:
"""Данные для персонализации сообщения."""
product_name: str
sku: int
marketplace: str
search_volume: int
conversion_rate: float
category_avg_conversion: float
lost_revenue: float
photos_count: int
has_video: bool
has_rich_content: bool
competitor_photos: int = 10
competitor_has_video: bool = True
ad_spend: float = 0.0
wasted_ad_spend: float = 0.0
position: int = 0
rating: float = 0.0
seller_name: str = ""
primary_pattern: str = "" # основная проблема


class OutreachMessageGenerator:
"""
Генератор персонализированных сообщений для аутрича.

Создаёт уникальные сообщения для каждого лида на основе
его конкретных данных из SalesFinder.
"""

# ─── Шаблоны для WB/Ozon DM ────────────────────────────────

TEMPLATES_DM = {
"content_problem": """Добрый день!

Мы проанализировали карточку «{product_name}» (арт. {sku}) и обнаружили значительный потенциал для роста продаж.

📊 Ваш товар ищут {search_volume:,} раз/мес
📉 Конверсия: {conversion_rate:.1f}% (среднее по категории: {category_avg_conversion:.1f}%)
💸 Упущенная выручка: ~{lost_revenue_formatted}/мес

При этом товар явно востребованный — спрос есть. Проблема в карточке: {content_issues}. У ТОП-3 конкурентов — {competitor_photos}+ фото{video_comparison}.

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

Интересно?""",

"ad_inefficiency": """Добрый день!

Обратили внимание на карточку «{product_name}» (арт. {sku}).

Судя по данным, вы инвестируете в рекламу и она работает — CTR хороший. Но конверсия карточки {conversion_rate:.1f}% при среднем по категории {category_avg_conversion:.1f}%.

Это значит, что:
📊 Реклама приводит трафик ✓
📉 Карточка не конвертирует ✗
💸 ~{wasted_ad_formatted} рекламного бюджета каждый месяц не приносит продаж

Причина — в контенте карточки. {content_issues}.

Мы делаем профессиональный контент для маркетплейсов. Можем подготовить бесплатный аудит вашей карточки — покажем, что именно исправить и сколько это даст в деньгах.

Будет интересно посмотреть?""",

"seo_problem": """Добрый день!

Заметили, что ваш товар «{product_name}» (арт. {sku}) с рейтингом {rating:.1f}⭐ находится на позиции {position} в поиске.

При таком рейтинге и отзывах товар должен быть в ТОП-20, но позиция {position} означает:
📉 Теряете ~{lost_revenue_formatted}/мес потенциальной выручки
🔍 Покупатели просто не видят ваш товар в поиске

Проблема, скорее всего, в SEO-оптимизации карточки (заголовок, ключевые слова, характеристики).

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

Интересно?""",

"new_brand": """Добрый день!

Заметили ваш бренд — видно, что недавно начали продавать на {marketplace_name}. При этом уже есть продажи и положительные отзывы — отличный старт! 👍

Но карточка товара «{product_name}» пока не раскрывает весь потенциал:
{content_issues}
📊 А ваш товар ищут {search_volume:,} раз/мес — спрос огромный

С профессиональным контентом (фото, видео, инфографика) продажи обычно растут в 3–5 раз в первые 2 месяца.

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

Будет полезно?""",

"content_fatigue": """Добрый день!

Ваш товар «{product_name}» (арт. {sku}) в ТОП-{position} — отличная позиция! Но мы заметили, что продажи снижаются ({sales_trend_text}).

Это типичная ситуация — контент «устаревает». Конкуренты обновляют карточки, а покупатели привыкают к одним и тем же фотографиям. Рефреш контента обычно возвращает и даже увеличивает продажи.

📊 Потенциал восстановления: +{recovery_formatted}/мес

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

Интересно?""",
}

# ─── Шаблоны для Telegram ───────────────────────────────────

TEMPLATES_TELEGRAM = {
"content_problem": """Привет! 👋

Я из Fotofactor — мы делаем фото и видео для маркетплейсов.

Посмотрели вашу карточку «{product_name}» на {marketplace_name} — товар ищут {search_volume:,} раз/мес, но конверсия {conversion_rate:.1f}% при среднем {category_avg_conversion:.1f}%.

Это ~{lost_revenue_formatted}/мес упущенной выручки. Причина — контент карточки ({content_issues_short}).

Можем сделать бесплатный аудит — покажем, что исправить и сколько это принесёт. Без обязательств.

Интересно? 📊""",

"ad_inefficiency": """Привет! 👋

Обратили внимание на вашу карточку «{product_name}» на {marketplace_name}.

Реклама работает (CTR отличный), но карточка не конвертирует — {conversion_rate:.1f}% при норме {category_avg_conversion:.1f}%. Это ~{wasted_ad_formatted}/мес рекламного бюджета уходит впустую.

Мы делаем контент для маркетплейсов и можем показать, что именно исправить. Бесплатный аудит — без обязательств.

Интересно? 💡""",
}

# ─── Шаблоны для Email ──────────────────────────────────────

TEMPLATES_EMAIL = {
"content_problem": {
"subject": "Анализ карточки «{product_name}» — {lost_revenue_formatted}/мес потенциал роста",
"body": """Здравствуйте{seller_greeting}!

Мы в Fotofactor специализируемся на создании продающего контента для маркетплейсов (фото, видео, инфографика).

При анализе категории «{category_name}» на {marketplace_name} мы обратили внимание на вашу карточку «{product_name}» (арт. {sku}).

Ключевые наблюдения:

1. Спрос: {search_volume:,} запросов/мес по релевантным ключевым словам
2. Текущая конверсия: {conversion_rate:.1f}%
3. Средняя конверсия ТОП-10 в категории: {category_avg_conversion:.1f}%
4. Разрыв конверсии: {conversion_gap:.1f}%
5. Расчётная упущенная выручка: {lost_revenue_formatted}/мес

Мы видим, что основные точки роста — в контенте карточки:
{content_issues_detailed}

Для сравнения, ТОП-3 конкурента в вашей категории используют:
— {competitor_photos}+ профессиональных фотографий
— Видео-обзор товара (30–60 сек)
— Rich-content с инфографикой и УТП
— Оптимизированные описания с ключевыми словами

Предложение: мы готовы подготовить бесплатный аудит 3 ваших ключевых карточек с конкурентным бенчмарком, расчётом потенциала роста и конкретными рекомендациями. Аудит займёт 2–3 рабочих дня.

Если интересно, просто ответьте на это письмо, и мы начнём работу.

С уважением,
Команда Fotofactor
fotofactor.ru

P.S. Данные для анализа предоставлены SalesFinder — ведущей аналитической платформой для маркетплейсов (110+ млн товаров в базе).
""",
},
}

# ─── Шаблон для телефонного скрипта ─────────────────────────

PHONE_SCRIPT = """
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ТЕЛЕФОННЫЙ СКРИПТ — {temperature} LEAD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

📞 ОТКРЫТИЕ (15 сек):
«Здравствуйте! Меня зовут [Имя], я из Fotofactor.
Мы делаем контент для маркетплейсов.
Звоню по поводу вашего товара «{product_name}» на {marketplace_name}.
У вас есть 2 минуты?»

📊 ДИАГНОЗ (30 сек):
«Мы проанализировали вашу карточку и нашли интересную ситуацию:
— Ваш товар ищут {search_volume:,} раз в месяц — это хороший спрос
— Но конверсия {conversion_rate:.1f}%, а у ТОП-3 конкурентов — {category_avg_conversion:.1f}%
— Это примерно {lost_revenue_formatted} упущенной выручки каждый месяц»

❓ ВОВЛЕЧЕНИЕ:
«Вы замечали, что конверсия ниже, чем хотелось бы?»

💡 РЕШЕНИЕ (20 сек):
«Мы видим, что главная причина — в контенте карточки.
{content_issues_phone}
Мы можем подготовить бесплатный аудит — покажем точно,
что нужно изменить и какой будет эффект.»

🎯 ЗАКРЫТИЕ:
«Мне потребуется 2–3 дня на подготовку аудита.
Куда удобнее прислать — в Telegram или на почту?»

⚠️ ВОЗРАЖЕНИЯ:
— «Нам не нужно»: «Понимаю. Но {lost_revenue_formatted}/мес — это факт.
Аудит бесплатный и ни к чему не обязывает. Просто посмотрите цифры.»
— «У нас свой фотограф»: «Отлично! Аудит покажет, что именно изменить,
и ваш фотограф сможет это реализовать. Нам не обязательно делать фото —
мы даём рекомендации.»
— «Дорого»: «Аудит бесплатный. А стоимость услуг зависит от объёма.
Обычно окупается за 2–3 недели за счёт роста конверсии.»
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""

def __init__(self):
self.templates_dm = self.TEMPLATES_DM
self.templates_tg = self.TEMPLATES_TELEGRAM
self.templates_email = self.TEMPLATES_EMAIL

def generate(
self,
data: OutreachData,
channel: OutreachChannel,
variant: str = "A",
) -> dict:
"""
Генерирует персонализированное сообщение.

Returns:
{
"channel": "telegram",
"subject": "..." (для email),
"body": "...",
"variant": "A",
"data_points_used": 5,
}
"""
# Подготавливаем общие переменные
variables = self._prepare_variables(data)

if channel in (OutreachChannel.WB_DM, OutreachChannel.OZON_DM):
template = self.templates_dm.get(
data.primary_pattern, self.templates_dm["content_problem"]
)
body = template.format(**variables)
return {
"channel": channel.value,
"subject": None,
"body": body,
"variant": variant,
"data_points_used": self._count_data_points(body),
}

elif channel == OutreachChannel.TELEGRAM:
template = self.templates_tg.get(
data.primary_pattern, self.templates_tg["content_problem"]
)
body = template.format(**variables)
return {
"channel": "telegram",
"subject": None,
"body": body,
"variant": variant,
"data_points_used": self._count_data_points(body),
}

elif channel == OutreachChannel.EMAIL:
tmpl = self.templates_email.get(
data.primary_pattern, self.templates_email["content_problem"]
)
subject = tmpl["subject"].format(**variables)
body = tmpl["body"].format(**variables)
return {
"channel": "email",
"subject": subject,
"body": body,
"variant": variant,
"data_points_used": self._count_data_points(body),
}

elif channel == OutreachChannel.PHONE:
body = self.PHONE_SCRIPT.format(**variables)
return {
"channel": "phone",
"subject": None,
"body": body,
"variant": variant,
"data_points_used": self._count_data_points(body),
}

raise ValueError(f"Unknown channel: {channel}")

def _prepare_variables(self, data: OutreachData) -> dict:
"""Подготавливает переменные для шаблонов."""
# Форматируем суммы
lost_revenue_formatted = self._format_money(data.lost_revenue)
wasted_ad_formatted = self._format_money(data.wasted_ad_spend)
recovery_formatted = self._format_money(data.lost_revenue * 0.6)

# Проблемы контента (длинная версия)
issues = []
if data.photos_count < 5:
issues.append(f"всего {data.photos_count} фото (нужно минимум 7–10)")
if not data.has_video:
issues.append("нет видео-обзора")
if not data.has_rich_content:
issues.append("нет rich-content / инфографики")
content_issues = "; ".join(issues) if issues else "контент не оптимизирован"

# Короткая версия для Telegram
short_issues = []
if data.photos_count < 5:
short_issues.append(f"{data.photos_count} фото")
if not data.has_video:
short_issues.append("нет видео")
content_issues_short = ", ".join(short_issues) if short_issues else "слабый контент"

# Детальная версия для email
detailed = []
if data.photos_count < 7:
detailed.append(
f"— Фотографии: {data.photos_count} шт. "
f"(рекомендуется 7–10 профессиональных фото с разных ракурсов)"
)
if not data.has_video:
detailed.append(
"— Видео: отсутствует (30–60 сек видео-обзор увеличивает конверсию на 15–25%)"
)
if not data.has_rich_content:
detailed.append(
"— Rich-content: отсутствует (инфографика с УТП и преимуществами)"
)
content_issues_detailed = "\n".join(detailed)

# Телефонная версия
phone_issues = []
if data.photos_count < 5:
phone_issues.append(f"Сейчас {data.photos_count} фото, а у конкурентов 10+")
if not data.has_video:
phone_issues.append("Нет видео — а видео даёт +20% к конверсии")
content_issues_phone = ". ".join(phone_issues)

# Видео-сравнение
video_comparison = (
" + видео" if data.competitor_has_video and not data.has_video else ""
)

# Маркетплейс
marketplace_name = "Wildberries" if data.marketplace == "wb" else "Ozon"

# Приветствие для email
seller_greeting = f", {data.seller_name}" if data.seller_name else ""

# Разрыв конверсии
conversion_gap = data.category_avg_conversion - data.conversion_rate

return {
"product_name": data.product_name,
"sku": data.sku,
"marketplace": data.marketplace,
"marketplace_name": marketplace_name,
"search_volume": data.search_volume,
"conversion_rate": data.conversion_rate,
"category_avg_conversion": data.category_avg_conversion,
"conversion_gap": conversion_gap,
"lost_revenue_formatted": lost_revenue_formatted,
"wasted_ad_formatted": wasted_ad_formatted,
"recovery_formatted": recovery_formatted,
"photos_count": data.photos_count,
"has_video": data.has_video,
"competitor_photos": data.competitor_photos,
"video_comparison": video_comparison,
"content_issues": content_issues,
"content_issues_short": content_issues_short,
"content_issues_detailed": content_issues_detailed,
"content_issues_phone": content_issues_phone,
"position": data.position,
"rating": data.rating,
"seller_name": data.seller_name,
"seller_greeting": seller_greeting,
"temperature": "HOT" if data.lost_revenue > 500_000 else "WARM",
"sales_trend_text": "падение 20–30% за последний месяц",
"category_name": "вашей категории",
}

@staticmethod
def _format_money(amount: float) -> str:
"""Форматирует сумму: 1200000 → '1.2 млн ₽', 350000 → '350 тыс ₽'."""
if amount >= 1_000_000:
return f"{amount / 1_000_000:.1f} млн ₽"
elif amount >= 1_000:
return f"{amount / 1_000:.0f} тыс ₽"
else:
return f"{amount:.0f} ₽"

@staticmethod
def _count_data_points(text: str) -> int:
"""Считает количество числовых фактов в сообщении."""
import re
numbers = re.findall(r'\d[\d\s,.]*[%₽]|\d[\d\s,.]+раз|\d[\d\s,.]+запрос', text)
return len(numbers)

Follow-up последовательность

Расписание follow-up

graph TD
D0["Day 0\nПервое касание"] --> W{"Ответил?"}
W -->|"Да"| A["→ Аудит"]
W -->|"Нет"| D3["Day 3\nFollow-up #1\n(другой угол)"]
D3 --> W2{"Ответил?"}
W2 -->|"Да"| A
W2 -->|"Нет"| D7["Day 7\nFollow-up #2\n(кейс/social proof)"]
D7 --> W3{"Ответил?"}
W3 -->|"Да"| A
W3 -->|"Нет"| D14["Day 14\nFollow-up #3\n(новые данные)"]
D14 --> W4{"Ответил?"}
W4 -->|"Да"| A
W4 -->|"Нет"| D30["Day 30\nFinale\n(breakup email)"]
D30 --> N["→ Nurturing\n(monthly digest)"]

style D0 fill:#4caf50,color:#fff
style D3 fill:#ff9800,color:#fff
style D7 fill:#ff9800,color:#fff
style D14 fill:#f44336,color:#fff
style D30 fill:#9e9e9e,color:#fff
style A fill:#2196f3,color:#fff

Содержание follow-up

ДеньТемаСтратегияПример
Day 0ДиагнозКонкретные цифры потерь«Ваш товар теряет 1.2 млн ₽/мес...»
Day 3Другой уголФокус на конкурентах«Ваш конкурент X обновил карточку → продажи +40%»
Day 7Social proofКейс похожего клиента«Помогли бренду Y — конверсия выросла с 2% до 6%»
Day 14Новые данныеОбновлённая аналитика«За 2 недели ваш товар потерял ещё ~300K ₽»
Day 30BreakupПоследний шанс«Закрываем анализ. Если будет актуально — напишите»

Follow-up шаблоны

FOLLOW_UP_TEMPLATES = {
"day_3_competitor": """Добрый день!

Писал вам 3 дня назад по поводу карточки «{product_name}».

Пока готовил аудит для другого клиента из вашей категории, заметил интересное: ваш конкурент «{competitor_brand}» недавно обновил карточку — добавил видео и инфографику. За последний месяц его продажи выросли на {competitor_growth}%.

Это как раз то, что мы рекомендовали бы и для вашего товара.

Аудит бесплатный — покажем конкретные шаги. Интересно?""",

"day_7_case_study": """Добрый день!

Хотел поделиться свежим кейсом из вашей ниши.

Мы помогли бренду из категории «{category_name}» обновить контент:
📸 Было: {case_photos_before} фото без видео → Стало: {case_photos_after} фото + видео 60 сек
📈 Конверсия выросла: {case_conversion_before}% → {case_conversion_after}%
💰 Доп. выручка: +{case_extra_revenue}/мес

Для вашего товара «{product_name}» потенциал ещё больше — спрос {search_volume:,}/мес.

Могу подготовить аналогичный аудит бесплатно. Нужно только ваше ОК 👍""",

"day_14_update": """Добрый день!

Возвращаюсь к анализу вашей карточки «{product_name}».

За 2 недели с момента моего первого письма ситуация изменилась:
{changes_summary}

Суммарно за этот период упущенная выручка составила ~{two_week_loss}. И каждый день ситуация не улучшается.

Бесплатный аудит — это 15 минут вашего времени на изучение отчёта. Могу подготовить за 2 дня.

Стоит попробовать?""",

"day_30_breakup": """Добрый день!

Это последнее сообщение по поводу карточки «{product_name}».

Я несколько раз писал с предложением бесплатного аудита. Понимаю, что сейчас, возможно, не приоритет.

Закрываю ваш анализ в нашей системе. Если в будущем решите оптимизировать карточки на маркетплейсах — пишите, будем рады помочь.

Успехов в продажах!
Команда Fotofactor""",
}

Автоматизация follow-up

import asyncio
from datetime import datetime, timedelta
from enum import Enum


class FollowUpStatus(Enum):
PENDING = "pending"
SENT = "sent"
RESPONDED = "responded"
SKIPPED = "skipped"


class FollowUpScheduler:
"""Автоматический планировщик follow-up сообщений."""

SCHEDULE = [
{"day": 0, "template": "initial", "channel": "primary"},
{"day": 3, "template": "day_3_competitor", "channel": "secondary"},
{"day": 7, "template": "day_7_case_study", "channel": "primary"},
{"day": 14, "template": "day_14_update", "channel": "primary"},
{"day": 30, "template": "day_30_breakup", "channel": "primary"},
]

def __init__(self, message_generator: OutreachMessageGenerator, db_session):
self.generator = message_generator
self.db = db_session

async def process_pending_followups(self):
"""
Обрабатывает все pending follow-ups.
Вызывается ежедневно через scheduler.
"""
today = datetime.utcnow().date()
pending = await self._get_pending_followups(today)

sent_count = 0
skipped_count = 0

for followup in pending:
# Проверяем, не ответил ли лид
if await self._lead_has_responded(followup["lead_id"]):
await self._mark_followup(followup["id"], FollowUpStatus.SKIPPED)
skipped_count += 1
continue

# Отправляем
success = await self._send_followup(followup)
if success:
await self._mark_followup(followup["id"], FollowUpStatus.SENT)
sent_count += 1

return {"sent": sent_count, "skipped": skipped_count, "total": len(pending)}

async def schedule_for_lead(self, lead_id: int, outreach_data: OutreachData):
"""Планирует всю цепочку follow-up для нового лида."""
first_contact = datetime.utcnow()

for step in self.SCHEDULE:
send_date = first_contact + timedelta(days=step["day"])
await self._create_followup_record(
lead_id=lead_id,
send_date=send_date,
template=step["template"],
channel=step["channel"],
outreach_data=outreach_data,
)

async def _send_followup(self, followup: dict) -> bool:
"""Отправляет одно follow-up сообщение."""
# Реализация зависит от канала (WB API, Telegram Bot, Email SMTP)
raise NotImplementedError

async def _get_pending_followups(self, date) -> list:
"""Получает все follow-up на сегодня."""
raise NotImplementedError

async def _lead_has_responded(self, lead_id: int) -> bool:
"""Проверяет, ответил ли лид."""
raise NotImplementedError

async def _mark_followup(self, followup_id: int, status: FollowUpStatus):
"""Обновляет статус follow-up."""
raise NotImplementedError

async def _create_followup_record(self, **kwargs):
"""Создаёт запись follow-up в БД."""
raise NotImplementedError

A/B тестирование

Фреймворк A/B тестов

ПараметрВариант A (Control)Вариант B (Test)
ЗаголовокФокус на потерях: «Теряете X ₽/мес»Фокус на росте: «Можете зарабатывать на X% больше»
ДлинаРазвёрнутое (5–7 абзацев)Короткое (2–3 абзаца)
CTAМягкий: «Интересно?»Прямой: «Отправить бесплатный аудит?»
Social proofБез кейсовС кейсом похожего клиента
Данные3 числовых факта5+ числовых фактов

Правила A/B тестов

import random
import hashlib
from dataclasses import dataclass


@dataclass
class ABTestConfig:
test_name: str
variants: list[str] # ["A", "B", "C"]
traffic_split: list[float] # [0.5, 0.5] или [0.33, 0.33, 0.34]
min_sample_size: int = 50 # минимум на вариант
significance_level: float = 0.05


class ABTestManager:
"""Управляет A/B тестами для аутрич-сообщений."""

def __init__(self):
self.active_tests: dict[str, ABTestConfig] = {}

def assign_variant(self, test_name: str, lead_id: int) -> str:
"""
Детерминистически назначает вариант лиду.
Один и тот же lead_id всегда получает один вариант.
"""
config = self.active_tests[test_name]

# Детерминистический хэш
hash_input = f"{test_name}:{lead_id}"
hash_value = int(hashlib.md5(hash_input.encode()).hexdigest(), 16)
bucket = (hash_value % 1000) / 1000 # 0.0–1.0

# Определяем вариант
cumulative = 0.0
for variant, split in zip(config.variants, config.traffic_split):
cumulative += split
if bucket < cumulative:
return variant

return config.variants[-1] # fallback

def evaluate_test(self, test_name: str, results: dict) -> dict:
"""
Оценивает результаты теста.

Args:
results: {"A": {"sent": 100, "responses": 12},
"B": {"sent": 100, "responses": 18}}
"""
from scipy import stats # type: ignore

config = self.active_tests[test_name]
variants = list(results.keys())

if len(variants) < 2:
return {"status": "need_more_variants"}

# Проверяем размер выборки
for v, data in results.items():
if data["sent"] < config.min_sample_size:
return {
"status": "need_more_data",
"variant": v,
"current": data["sent"],
"needed": config.min_sample_size,
}

# Chi-squared тест
a = results[variants[0]]
b = results[variants[1]]

contingency = [
[a["responses"], a["sent"] - a["responses"]],
[b["responses"], b["sent"] - b["responses"]],
]

chi2, p_value, dof, expected = stats.chi2_contingency(contingency)

rate_a = a["responses"] / a["sent"]
rate_b = b["responses"] / b["sent"]
uplift = (rate_b - rate_a) / rate_a * 100 if rate_a > 0 else 0

winner = variants[1] if rate_b > rate_a else variants[0]
significant = p_value < config.significance_level

return {
"status": "complete" if significant else "not_significant",
"winner": winner if significant else None,
"rates": {variants[0]: f"{rate_a:.1%}", variants[1]: f"{rate_b:.1%}"},
"uplift": f"{uplift:+.1f}%",
"p_value": round(p_value, 4),
"significant": significant,
"recommendation": (
f"Используйте вариант {winner} — response rate на {abs(uplift):.1f}% выше"
if significant
else f"Недостаточно данных. Нужно ещё {config.min_sample_size - min(a['sent'], b['sent'])} отправок"
),
}

CRM интеграция

Трекинг статуса аутрича

from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, DateTime, JSON, ForeignKey
from sqlalchemy.orm import declarative_base

Base = declarative_base()


class OutreachLog(Base):
"""Лог всех аутрич-сообщений."""
__tablename__ = "outreach_log"

id = Column(Integer, primary_key=True, autoincrement=True)
lead_id = Column(Integer, ForeignKey("leads.id"), nullable=False, index=True)
channel = Column(String(20), nullable=False) # wb_dm, telegram, email, phone
template = Column(String(50), nullable=False) # initial, day_3_competitor, ...
variant = Column(String(5), default="A") # A/B вариант
message_body = Column(String, nullable=False)
subject = Column(String(200)) # для email
data_points_used = Column(Integer, default=0) # сколько числовых фактов
sent_at = Column(DateTime, default=datetime.utcnow)
delivered = Column(Integer, default=0) # 1 = доставлено
opened = Column(Integer, default=0) # 1 = открыто (email)
responded = Column(Integer, default=0) # 1 = ответил
response_at = Column(DateTime)
response_text = Column(String)
ab_test_name = Column(String(50))
metadata = Column(JSON, default=dict) # доп. данные


class OutreachStats(Base):
"""Агрегированная статистика аутрича."""
__tablename__ = "outreach_stats"

id = Column(Integer, primary_key=True, autoincrement=True)
period = Column(String(10), nullable=False) # "2026-03"
channel = Column(String(20), nullable=False)
template = Column(String(50), nullable=False)
variant = Column(String(5), default="A")
sent = Column(Integer, default=0)
delivered = Column(Integer, default=0)
opened = Column(Integer, default=0)
responded = Column(Integer, default=0)
converted = Column(Integer, default=0) # запросил аудит
avg_response_hours = Column(Float)

Метрики и дашборд

Ключевые метрики аутрича

МетрикаФормулаЦель MVPЦель Scale
Delivery ratedelivered / sent≥ 95%≥ 98%
Open rate (email)opened / delivered≥ 25%≥ 35%
Response rateresponded / delivered≥ 8%≥ 12%
Positive response ratepositive / responded≥ 60%≥ 70%
Audit request rateaudit_requests / positive≥ 50%≥ 65%
Avg response timeavg(response_at - sent_at)≤ 48h≤ 24h
Data points per messageavg data_points_used≥ 3≥ 5
Follow-up effectivenessresponses_followup / total_responsesОтслеживать

Воронка аутрича по каналам

Пример за первый месяц (200 лидов):

WB DM Telegram Email Телефон
────── ───────── ────── ─────────
Sent: 80 60 40 20
Delivered: 78 58 38 20
Opened: — — 12 —
Responded: 14 7 3 2
Positive: 10 5 2 2
Audit: 6 3 1 2
───────────────────────────────────────────────
Response: 17.5% 11.7% 7.5% 10.0%
Audit: 42.9% 42.9% 33.3% 100.0%

Compliance и anti-spam

Правила

ПравилоОписание
ЧастотаМакс 1 сообщение на канал в 3 дня одному лиду
Общий лимитМакс 5 касаний за 30 дней (все каналы суммарно)
Opt-outНемедленная остановка при любом отказе / «не пишите»
ВремяТолько в рабочие дни 10:00–18:00 МСК
ПерсонализацияКаждое сообщение должно быть уникальным (не шаблонная рассылка)
ИдентификацияВсегда указываем кто мы и откуда данные
GDPRДанные из открытых источников (маркетплейсы + SF)

Blacklist и opt-out

class ComplianceManager:
"""Управляет compliance-правилами аутрича."""

MAX_TOUCHES_PER_MONTH = 5
MIN_DAYS_BETWEEN_SAME_CHANNEL = 3
WORKING_HOURS = (10, 18) # МСК

STOP_WORDS = [
"не пишите", "отписаться", "не интересно", "стоп",
"удалите", "не надо", "спам", "отстаньте", "блокирую",
]

async def can_send(self, lead_id: int, channel: str) -> tuple[bool, str]:
"""Проверяет, можно ли отправить сообщение."""

# 1. Проверяем blacklist
if await self._is_blacklisted(lead_id):
return False, "Lead is blacklisted (opted out)"

# 2. Проверяем общий лимит
touches_this_month = await self._count_touches(lead_id, days=30)
if touches_this_month >= self.MAX_TOUCHES_PER_MONTH:
return False, f"Monthly limit reached ({touches_this_month}/{self.MAX_TOUCHES_PER_MONTH})"

# 3. Проверяем интервал по каналу
last_on_channel = await self._last_touch_on_channel(lead_id, channel)
if last_on_channel:
days_since = (datetime.utcnow() - last_on_channel).days
if days_since < self.MIN_DAYS_BETWEEN_SAME_CHANNEL:
return False, f"Too soon for {channel} ({days_since} < {self.MIN_DAYS_BETWEEN_SAME_CHANNEL} days)"

# 4. Проверяем рабочие часы
from datetime import timezone, timedelta
msk = timezone(timedelta(hours=3))
now_msk = datetime.now(msk)
if not (self.WORKING_HOURS[0] <= now_msk.hour < self.WORKING_HOURS[1]):
return False, f"Outside working hours ({now_msk.hour}:00 MSK)"

if now_msk.weekday() >= 5: # суббота, воскресенье
return False, "Weekend"

return True, "OK"

def check_opt_out(self, response_text: str) -> bool:
"""Проверяет, содержит ли ответ стоп-слова."""
text_lower = response_text.lower()
return any(word in text_lower for word in self.STOP_WORDS)

Реальный пример: полный аутрич-цикл

Лид: продавец кухонных ножей (KnifeKing)

Исходные данные (из Problem Detection):

  • SKU: 198345672
  • PotentialScore: 87.3 (HOT)
  • Паттерны: P1 (Content Problem) + P5 (Ad Inefficiency)
  • Упущенная выручка: 1.2 млн ₽/мес
  • Контакт: Telegram @knifeking_supplier

Day 0: Telegram (первый канал для HOT)

Привет! 👋

Я из Fotofactor — мы делаем фото и видео для маркетплейсов.

Посмотрели вашу карточку «Набор кухонных ножей KnifeKing» на
Wildberries — товар ищут 14 200 раз/мес, но конверсия 1.1%
при среднем 4.2%.

Это ~1.2 млн ₽/мес упущенной выручки. Причина — контент
карточки (3 фото, нет видео).

Можем сделать бесплатный аудит — покажем, что исправить
и сколько это принесёт. Без обязательств.

Интересно? 📊

Day 3: WB DM (follow-up #1)

Добрый день!

Писал вам 3 дня назад по поводу карточки «Набор кухонных ножей».

Пока готовил аудит для другого клиента из вашей категории,
заметил интересное: ваш конкурент «SteelPro» недавно обновил
карточку — добавил видео и инфографику. За последний месяц
его продажи выросли на 35%.

Это как раз то, что мы рекомендовали бы и для вашего товара.

Аудит бесплатный — покажем конкретные шаги. Интересно?

Day 7: Telegram (follow-up #2, кейс)

Привет! Хотел поделиться кейсом из вашей ниши.

Мы помогли бренду из категории «кухонные принадлежности»
обновить контент:
📸 Было: 4 фото без видео → Стало: 12 фото + видео 45 сек
📈 Конверсия: 1.8% → 5.2%
💰 Доп. выручка: +680 тыс ₽/мес

Для вашего товара потенциал ещё больше — спрос 14 200/мес.

Могу подготовить бесплатный аудит. Нужно только ваше ОК 👍

Day 7 — ответ лида:

> Привет. Интересно, но пока не до этого.
> Сколько стоит ваша работа?

Результат: Response на Day 7, переход к этапу аудита. Follow-ups Day 14 и Day 30 отменены.


CLI интеграция

# Генерация сообщения для конкретного лида
python spider.py leadgen outreach-preview \
--lead-id 42 \
--channel telegram \
--variant A

# Массовая отправка
python spider.py leadgen outreach-send \
--temperature hot \
--channel telegram \
--limit 20 \
--dry-run # по умолчанию

# Обработка follow-up
python spider.py leadgen followup-process

# Статистика аутрича
python spider.py leadgen outreach-stats \
--period "2026-03" \
--by-channel \
--json

# A/B тест результаты
python spider.py leadgen ab-test \
--test-name "cta_soft_vs_direct" \
--evaluate