Анализ целевой аудитории сообщества VK: различия между версиями
Материал из Поле цифровой дидактики
Нет описания правки |
Нет описания правки |
||
| Строка 1: | Строка 1: | ||
{| class="wikitable" | = Анализ аудитории сообществ ВКонтакте = | ||
== Описание проекта == | |||
Веб-приложение для автоматического анализа аудитории сообществ ВКонтакте. Инструмент собирает данные о подписчиках (пол, возраст, география) и постах (типы контента, вовлечённость), визуализирует статистику в виде графиков и формирует рекомендации по оптимизации контент-стратегии. | |||
{| class="wikitable" style="width: 100%;" | |||
! Параметр | ! Параметр | ||
! Описание | ! Описание | ||
|- | |- | ||
| | | '''Область знаний''' | ||
| Веб- | | Веб-разработка, анализ данных, работа с API социальных сетей, визуализация данных, SMM-аналитика | ||
|- | |- | ||
| | | '''Стек технологий''' | ||
| | | Python 3.8+, Flask, VK API, Matplotlib, Seaborn, Pandas, HTML/CSS/JavaScript | ||
|- | |- | ||
| | | '''Среда разработки''' | ||
| | | VS Code / PyCharm, локальный сервер (localhost) | ||
|} | |} | ||
== Диаграмма работы приложения == | == Диаграмма работы приложения == | ||
{{#mermaid: | {{#mermaid: graph LR | ||
A[ | A[Пользователь] --> B[Flask] | ||
B --> C[ | B --> C[VK API] | ||
C --> D[Анализ] | |||
D --> E[Графики] | |||
D --> F[ | D --> F[Рекомендации] | ||
E --> G[Результат] | |||
F --> G | |||
}} | }} | ||
== Структура проекта == | == Структура проекта == | ||
; 📄 app.py | |||
: основной Python-скрипт приложения. Обеспечивает интеграцию с VK API, сбор данных о подписчиках и постах, генерацию 4 графиков, формирование рекомендаций и работу веб-сервера на Flask. | |||
; 📁 templates/index.html | |||
: HTML-шаблон главной страницы. Содержит форму для ввода ссылки, область для отображения результатов и JavaScript-логику. | |||
; 📁 static/style.css | |||
: файл стилей для веб-интерфейса. Адаптивный дизайн, анимации, градиентный фон, сетка графиков. | |||
== Код приложения == | |||
<pre> | === app.py === | ||
{| class="wikitable mw-collapsible mw-collapsed" | |||
! Показать код | |||
|- | |||
| <pre> | |||
from flask import Flask, render_template, request, jsonify | from flask import Flask, render_template, request, jsonify | ||
import vk_api | import vk_api | ||
| Строка 56: | Строка 51: | ||
matplotlib.use('Agg') | matplotlib.use('Agg') | ||
import matplotlib.pyplot as plt | import matplotlib.pyplot as plt | ||
import seaborn as sns | |||
from collections import Counter | from collections import Counter | ||
from datetime import datetime | from datetime import datetime | ||
| Строка 61: | Строка 57: | ||
import base64 | import base64 | ||
import io | import io | ||
import pandas as pd | |||
import time | |||
app = Flask(__name__) | app = Flask(__name__) | ||
| Строка 67: | Строка 65: | ||
os.makedirs('static', exist_ok=True) | os.makedirs('static', exist_ok=True) | ||
TOKEN = " | # Настройка стиля seaborn | ||
sns.set_style("whitegrid") | |||
sns.set_palette("husl") | |||
# ВСТАВЬТЕ ВАШ ТОКЕН СЮДА | |||
TOKEN = "ваш_токен_сюда" | |||
def get_community_data(group_id): | def get_community_data(group_id): | ||
"""Собирает данные о сообществе""" | |||
try: | try: | ||
vk_session = vk_api.VkApi(token=TOKEN) | vk_session = vk_api.VkApi(token=TOKEN) | ||
| Строка 88: | Строка 91: | ||
members_count = group['members_count'] | members_count = group['members_count'] | ||
except: | except: | ||
group = vk.groups.getById(group_id=str(group_id_for_api), fields='members_count,description')[0] | group = vk.groups.getById(group_id=str(group_id_for_api), fields='members_count,description')[0] | ||
community_name = group['name'] | community_name = group['name'] | ||
members_count = group['members_count'] | members_count = group['members_count'] | ||
# Собираем подписчиков | # Собираем подписчиков с пагинацией (до 2000) | ||
members_data = [] | members_data = [] | ||
try: | try: | ||
members = vk.groups.getMembers(group_id=group_id_for_api, fields='sex,bdate,city', count= | total_to_collect = min(2000, members_count) | ||
offset = 0 | |||
batch_size = 1000 | |||
while offset < total_to_collect: | |||
members = vk.groups.getMembers( | |||
group_id=group_id_for_api, | |||
fields='sex,bdate,city', | |||
count=batch_size, | |||
offset=offset | |||
) | |||
for user in members['items']: | |||
user_info = {} | |||
if user.get('sex') == 1: | |||
user_info['sex'] = 'Женщины' | |||
elif user.get('sex') == 2: | |||
user_info['sex'] = 'Мужчины' | |||
else: | |||
user_info['sex'] = 'Не указан' | |||
if user.get('bdate'): | |||
bdate = user['bdate'] | |||
if len(bdate.split('.')) == 3: | |||
try: | |||
year = int(bdate.split('.')[2]) | |||
age = datetime.now().year - year | |||
if 0 < age < 100: | |||
user_info['age'] = age | |||
except: | |||
pass | |||
if user.get('city') and user['city'].get('title'): | |||
user_info['city'] = user['city']['title'] | |||
members_data.append(user_info) | |||
offset += len(members['items']) | |||
time.sleep(0.34) | |||
except Exception as e: | except Exception as e: | ||
print(f"Ошибка при сборе подписчиков: {e}") | print(f"Ошибка при сборе подписчиков: {e}") | ||
| Строка 141: | Строка 155: | ||
} | } | ||
if post.get('attachments'): | if post.get('attachments'): | ||
attach_type = post['attachments'][0]['type'] | attach_type = post['attachments'][0]['type'] | ||
| Строка 155: | Строка 168: | ||
post_info['type'] = 'Текст' | post_info['type'] = 'Текст' | ||
if post_info['views'] > 0: | if post_info['views'] > 0: | ||
post_info['er'] = (post_info['likes'] + post_info['comments'] + post_info['reposts']) / post_info['views'] * 1000 | post_info['er'] = (post_info['likes'] + post_info['comments'] + post_info['reposts']) / post_info['views'] * 1000 | ||
| Строка 176: | Строка 188: | ||
return {'success': False, 'error': str(e)} | return {'success': False, 'error': str(e)} | ||
def generate_charts(members_data, posts_data): | def generate_charts(members_data, posts_data): | ||
"""Генерирует графики с помощью seaborn""" | |||
charts = {} | charts = {} | ||
plt.rcParams['font.family'] = 'sans-serif' | plt.rcParams['font.family'] = 'sans-serif' | ||
plt.rcParams['font.sans-serif'] = ['Arial', 'DejaVu Sans'] | plt.rcParams['font.sans-serif'] = ['Arial', 'DejaVu Sans'] | ||
| Строка 186: | Строка 197: | ||
# 1. Распределение по полу | # 1. Распределение по полу | ||
if members_data: | if members_data: | ||
fig, ax = plt.subplots(figsize=( | fig, ax = plt.subplots(figsize=(7, 5)) | ||
sex_counts = Counter([m.get('sex', 'Не указан') for m in members_data]) | sex_counts = Counter([m.get('sex', 'Не указан') for m in members_data]) | ||
ax.pie(sex_counts.values(), labels=sex_counts.keys(), autopct='%1.1f%%') | colors = ['#ff6b6b', '#4ecdc4', '#95a5a6'] | ||
ax.pie(sex_counts.values(), labels=sex_counts.keys(), autopct='%1.1f%%', | |||
colors=colors[:len(sex_counts)], startangle=90, | |||
explode=[0.02] * len(sex_counts), shadow=True) | |||
buf = io.BytesIO() | buf = io.BytesIO() | ||
plt.savefig(buf, format='png', bbox_inches='tight', dpi=100) | plt.savefig(buf, format='png', bbox_inches='tight', dpi=100, facecolor='white') | ||
buf.seek(0) | buf.seek(0) | ||
charts['sex'] = base64.b64encode(buf.getvalue()).decode() | charts['sex'] = base64.b64encode(buf.getvalue()).decode() | ||
| Строка 199: | Строка 213: | ||
ages = [m['age'] for m in members_data if 'age' in m] | ages = [m['age'] for m in members_data if 'age' in m] | ||
if ages: | if ages: | ||
fig, ax = plt.subplots(figsize=( | fig, ax = plt.subplots(figsize=(8, 5)) | ||
sns.histplot(ages, bins=15, kde=True, color='#3498db', edgecolor='white', linewidth=1.5, alpha=0.7) | |||
ax.set_xlabel('Возраст') | ax.set_xlabel('Возраст') | ||
ax.set_ylabel('Количество') | ax.set_ylabel('Количество подписчиков') | ||
avg_age = sum(ages) / len(ages) | |||
ax.axvline(avg_age, color='#e74c3c', linestyle='--', linewidth=2, label=f'Средний: {avg_age:.1f} лет') | |||
ax.legend() | |||
buf = io.BytesIO() | buf = io.BytesIO() | ||
plt.savefig(buf, format='png', bbox_inches='tight', dpi=100) | plt.savefig(buf, format='png', bbox_inches='tight', dpi=100, facecolor='white') | ||
buf.seek(0) | buf.seek(0) | ||
charts['age'] = base64.b64encode(buf.getvalue()).decode() | charts['age'] = base64.b64encode(buf.getvalue()).decode() | ||
| Строка 213: | Строка 230: | ||
cities = [m['city'] for m in members_data if 'city' in m and m['city'] != 'Не указан'] | cities = [m['city'] for m in members_data if 'city' in m and m['city'] != 'Не указан'] | ||
if cities: | if cities: | ||
fig, ax = plt.subplots(figsize=( | fig, ax = plt.subplots(figsize=(9, 6)) | ||
city_counts = Counter(cities).most_common(5) | city_counts = Counter(cities).most_common(5) | ||
city_names, city_values = zip(*city_counts) | city_names, city_values = zip(*city_counts) | ||
df = pd.DataFrame({'Город': city_names, 'Количество': city_values}) | |||
df = df.sort_values('Количество', ascending=True) | |||
sns.barplot(data=df, y='Город', x='Количество', hue='Город', palette='rocket', legend=False, ax=ax) | |||
ax.set_xlabel('Количество подписчиков') | |||
ax.set_ylabel('Город') | |||
plt.tight_layout() | plt.tight_layout() | ||
buf = io.BytesIO() | buf = io.BytesIO() | ||
plt.savefig(buf, format='png', bbox_inches='tight', dpi=100) | plt.savefig(buf, format='png', bbox_inches='tight', dpi=100, facecolor='white') | ||
buf.seek(0) | buf.seek(0) | ||
charts['cities'] = base64.b64encode(buf.getvalue()).decode() | charts['cities'] = base64.b64encode(buf.getvalue()).decode() | ||
| Строка 233: | Строка 250: | ||
# 4. Вовлечённость по типам контента | # 4. Вовлечённость по типам контента | ||
if posts_data: | if posts_data: | ||
fig, ax = plt.subplots(figsize=( | fig, ax = plt.subplots(figsize=(10, 6)) | ||
type_er = {} | type_er = {} | ||
for post in posts_data: | for post in posts_data: | ||
if post['type'] not in type_er: | |||
type_er[post['type']] = [] | |||
type_er[ | type_er[post['type']].append(post['er']) | ||
type_er[ | |||
avg_er = {t: sum(ers)/len(ers) for t, ers in type_er.items()} | avg_er = {t: sum(ers)/len(ers) for t, ers in type_er.items()} | ||
df_er = pd.DataFrame({'Тип контента': list(avg_er.keys()), 'ER': list(avg_er.values())}) | |||
ax.set_ylabel('ER (на 1000 просмотров)' | df_er = df_er.sort_values('ER', ascending=False) | ||
bars = sns.barplot(data=df_er, x='Тип контента', y='ER', hue='Тип контента', palette='viridis', legend=False, ax=ax) | |||
ax.set_ylabel('ER (активность на 1000 просмотров)') | |||
ax.set_xlabel('Тип контента') | |||
for bar, val in zip(bars.patches, df_er['ER'].values): | |||
for bar, val in zip(bars, | ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 1, | ||
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + | f'{val:.1f}', ha='center', va='bottom', fontsize=10, fontweight='bold') | ||
f'{val:.1f}', ha='center', va='bottom', fontsize= | |||
plt.xticks(rotation=45) | |||
plt.tight_layout() | plt.tight_layout() | ||
buf = io.BytesIO() | buf = io.BytesIO() | ||
plt.savefig(buf, format='png', bbox_inches='tight', dpi=100) | plt.savefig(buf, format='png', bbox_inches='tight', dpi=100, facecolor='white') | ||
buf.seek(0) | buf.seek(0) | ||
charts['engagement'] = base64.b64encode(buf.getvalue()).decode() | charts['engagement'] = base64.b64encode(buf.getvalue()).decode() | ||
| Строка 272: | Строка 291: | ||
return jsonify({'success': False, 'error': 'Введите ссылку на сообщество'}) | return jsonify({'success': False, 'error': 'Введите ссылку на сообщество'}) | ||
if 'vk.com/' in group_url: | if 'vk.com/' in group_url: | ||
group_id = group_url.split('vk.com/')[-1].split('/')[0] | group_id = group_url.split('vk.com/')[-1].split('/')[0] | ||
| Строка 278: | Строка 296: | ||
group_id = group_url | group_id = group_url | ||
data = get_community_data(group_id) | data = get_community_data(group_id) | ||
| Строка 284: | Строка 301: | ||
return jsonify({'success': False, 'error': data.get('error', 'Ошибка при получении данных')}) | return jsonify({'success': False, 'error': data.get('error', 'Ошибка при получении данных')}) | ||
charts = generate_charts(data['members'], data['posts']) | charts = generate_charts(data['members'], data['posts']) | ||
stats = {} | stats = {} | ||
if data['members']: | if data['members']: | ||
sex_counts = Counter([m.get('sex', 'Не указан') for m in data['members']]) | sex_counts = Counter([m.get('sex', 'Не указан') for m in data['members']]) | ||
stats['sex'] = dict(sex_counts) | stats['sex'] = dict(sex_counts) | ||
ages = [m['age'] for m in data['members'] if 'age' in m] | ages = [m['age'] for m in data['members'] if 'age' in m] | ||
if ages: | if ages: | ||
stats['avg_age'] = sum(ages) / len(ages) | stats['avg_age'] = sum(ages) / len(ages) | ||
cities = [m['city'] for m in data['members'] if 'city' in m and m['city'] != 'Не указан'] | cities = [m['city'] for m in data['members'] if 'city' in m and m['city'] != 'Не указан'] | ||
if cities: | if cities: | ||
stats['top_cities'] = Counter(cities).most_common(5) | stats['top_cities'] = Counter(cities).most_common(5) | ||
if data['posts']: | if data['posts']: | ||
avg_er = sum(p['er'] for p in data['posts']) / len(data['posts']) | avg_er = sum(p['er'] for p in data['posts']) / len(data['posts']) | ||
stats['avg_er'] = avg_er | stats['avg_er'] = avg_er | ||
type_er = {} | type_er = {} | ||
for post in data['posts']: | for post in data['posts']: | ||
| Строка 320: | Строка 328: | ||
stats['best_post_type'] = best_type | stats['best_post_type'] = best_type | ||
recommendations = [] | recommendations = [] | ||
if 'avg_age' in stats: | if 'avg_age' in stats: | ||
if stats['avg_age'] < 20: | if stats['avg_age'] < 20: | ||
recommendations.append("🎯 Аудитория молодая (до 20 лет) → используйте короткие, трендовые форматы, мемы | recommendations.append("🎯 Аудитория молодая (до 20 лет) → используйте короткие, трендовые форматы, мемы") | ||
elif stats['avg_age'] < 30: | elif stats['avg_age'] < 30: | ||
recommendations.append("🎯 Аудитория 20-30 лет → чередуйте развлекательный и полезный контент | recommendations.append("🎯 Аудитория 20-30 лет → чередуйте развлекательный и полезный контент") | ||
else: | else: | ||
recommendations.append("🎯 Аудитория 30+ лет → делайте акцент на полезный, экспертный контент | recommendations.append("🎯 Аудитория 30+ лет → делайте акцент на полезный, экспертный контент") | ||
if stats.get('best_post_type'): | if stats.get('best_post_type'): | ||
| Строка 335: | Строка 342: | ||
if data['posts'] and stats.get('avg_er'): | if data['posts'] and stats.get('avg_er'): | ||
if stats['avg_er'] < 50: | if stats['avg_er'] < 50: | ||
recommendations.append("⚠️ Вовлечённость ниже среднего → | recommendations.append("⚠️ Вовлечённость ниже среднего → добавьте опросы, конкурсы и интерактив") | ||
elif stats['avg_er'] > 150: | elif stats['avg_er'] > 150: | ||
recommendations.append("🔥 Отличная вовлечённость! Продолжайте в том же духе") | recommendations.append("🔥 Отличная вовлечённость! Продолжайте в том же духе") | ||
if stats.get('top_cities'): | if stats.get('top_cities'): | ||
main_city = stats['top_cities'][0][0] | main_city = stats['top_cities'][0][0] | ||
recommendations.append(f"📍 Основная аудитория из {main_city} → можно проводить локальные мероприятия") | |||
recommendations.append(f"📍 Основная аудитория из {main_city} → можно проводить локальные мероприятия | |||
return jsonify({ | return jsonify({ | ||
| Строка 360: | Строка 362: | ||
app.run(debug=True) | app.run(debug=True) | ||
</pre> | </pre> | ||
|} | |||
=== templates/index.html === | |||
<pre> | {| class="wikitable mw-collapsible mw-collapsed" | ||
! Показать код | |||
|- | |||
| <pre> | |||
<!DOCTYPE html> | |||
<html lang="ru"> | |||
<head> | |||
<meta charset="UTF-8"> | |||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||
<title>Анализ аудитории сообществ VK</title> | |||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> | |||
</head> | |||
<body> | |||
<div class="container"> | |||
<div class="card"> | |||
<h1>📱 Анализ аудитории сообществ VK</h1> | |||
<p class="subtitle">Анализ аудитории сообщества и рекомендации по контенту</p> | |||
<div class="input-group"> | |||
<input type="text" id="groupUrl" placeholder="Введите ссылку или ID сообщества"> | |||
<button onclick="analyze()" id="analyzeBtn">🔍 Анализировать</button> | |||
</div> | |||
<div class="loader" id="loader"> | |||
<div class="loader-spinner"></div> | |||
<p>Анализируем сообщество... Это может занять до 30 секунд</p> | |||
</div> | |||
<div class="results" id="results"> | |||
<div class="stats" id="stats"></div> | |||
<div class="chart-grid" id="charts"></div> | |||
<div class="recommendations" id="recommendations"></div> | |||
</div> | |||
</div> | |||
</div> | |||
<script> | |||
async function analyze() { | async function analyze() { | ||
const groupUrl = document.getElementById('groupUrl').value; | const groupUrl = document.getElementById('groupUrl').value; | ||
| Строка 414: | Строка 420: | ||
const response = await fetch('/analyze', { | const response = await fetch('/analyze', { | ||
method: 'POST', | method: 'POST', | ||
headers: { | headers: { 'Content-Type': 'application/json' }, | ||
body: JSON.stringify({ group_url: groupUrl }) | body: JSON.stringify({ group_url: groupUrl }) | ||
}); | }); | ||
| Строка 436: | Строка 440: | ||
function displayResults(data) { | function displayResults(data) { | ||
const statsHtml = ` | const statsHtml = ` | ||
<div class="stat"> | |||
<div class="stat-value">${data.community_name}</div> | |||
<div class="stat-label">Название сообщества</div> | |||
</div> | |||
<div class="stat"> | |||
<div class="stat-value">${data.members_count.toLocaleString()}</div> | |||
<div class="stat-label">Подписчиков</div> | |||
</div> | |||
${data.stats.avg_age ? ` | ${data.stats.avg_age ? ` | ||
<div class="stat"> | |||
<div class="stat-value">${data.stats.avg_age.toFixed(1)} лет</div> | |||
<div class="stat-label">Средний возраст</div> | |||
</div> | |||
` : ''} | ` : ''} | ||
${data.stats.avg_er ? ` | ${data.stats.avg_er ? ` | ||
<div class="stat"> | |||
<div class="stat-value">${data.stats.avg_er.toFixed(2)}</div> | |||
<div class="stat-label">Средняя вовлечённость (ER)</div> | |||
</div> | |||
` : ''} | ` : ''} | ||
`; | `; | ||
document.getElementById('stats').innerHTML = statsHtml; | document.getElementById('stats').innerHTML = statsHtml; | ||
let chartsHtml = ''; | let chartsHtml = ''; | ||
if (data.charts.sex) { | if (data.charts.sex) { | ||
chartsHtml += ` | chartsHtml += ` | ||
<div class="chart-card"> | |||
<h3>👥 Распределение по полу</h3> | |||
<img src="data:image/png;base64,${data.charts.sex}" alt="Распределение по полу"> | |||
</div> | |||
`; | `; | ||
} | } | ||
if (data.charts.age) { | if (data.charts.age) { | ||
chartsHtml += ` | chartsHtml += ` | ||
<div class="chart-card"> | |||
<h3>🎂 Возрастное распределение</h3> | |||
<img src="data:image/png;base64,${data.charts.age}" alt="Возраст"> | |||
</div> | |||
`; | `; | ||
} | } | ||
if (data.charts.cities) { | if (data.charts.cities) { | ||
chartsHtml += ` | chartsHtml += ` | ||
<div class="chart-card"> | |||
<h3>🏙️ Топ-5 городов</h3> | |||
<img src="data:image/png;base64,${data.charts.cities}" alt="Города"> | |||
</div> | |||
`; | `; | ||
} | } | ||
if (data.charts.engagement) { | if (data.charts.engagement) { | ||
chartsHtml += ` | chartsHtml += ` | ||
<div class="chart-card"> | |||
<h3>📈 Вовлечённость по типам контента</h3> | |||
<img src="data:image/png;base64,${data.charts.engagement}" alt="ER"> | |||
</div> | |||
`; | `; | ||
} | } | ||
document.getElementById('charts').innerHTML = chartsHtml; | document.getElementById('charts').innerHTML = chartsHtml; | ||
let recHtml = '<h3>💡 Рекомендации по контент-стратегии</h3><ul>'; | |||
let recHtml = ' | |||
data.recommendations.forEach(rec => { | data.recommendations.forEach(rec => { | ||
recHtml += ` | recHtml += `<li>${rec}</li>`; | ||
}); | }); | ||
recHtml += ' | recHtml += '</ul>'; | ||
document.getElementById('recommendations').innerHTML = recHtml; | document.getElementById('recommendations').innerHTML = recHtml; | ||
| Строка 511: | Строка 512: | ||
const results = document.getElementById('results'); | const results = document.getElementById('results'); | ||
results.style.display = 'block'; | results.style.display = 'block'; | ||
results.innerHTML = ` | results.innerHTML = `<div class="error">❌ Ошибка: ${message}</div>`; | ||
} | } | ||
</script> | |||
</body> | |||
</html> | |||
</pre> | </pre> | ||
|} | |||
=== static/style.css === | |||
<pre> | {| class="wikitable mw-collapsible mw-collapsed" | ||
! Показать код | |||
|- | |||
| <pre> | |||
* { | * { | ||
margin: 0; | margin: 0; | ||
| Строка 527: | Строка 532: | ||
body { | body { | ||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | ||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); | ||
min-height: 100vh; | min-height: 100vh; | ||
padding: 20px; | padding: 40px 20px; | ||
} | } | ||
.container { | .container { | ||
max-width: | max-width: 1400px; | ||
margin: 0 auto; | margin: 0 auto; | ||
} | } | ||
.card { | .card { | ||
background: | background: rgba(255, 255, 255, 0.98); | ||
backdrop-filter: blur(10px); | |||
border-radius: 32px; | |||
padding: 40px; | |||
box-shadow: 0 | box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); | ||
} | } | ||
h1 { | h1 { | ||
color: | font-size: 2.5rem; | ||
margin-bottom: | font-weight: 700; | ||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |||
-webkit-background-clip: text; | |||
background-clip: text; | |||
color: transparent; | |||
margin-bottom: 12px; | |||
} | } | ||
.subtitle { | .subtitle { | ||
color: # | font-size: 1.1rem; | ||
margin-bottom: | color: #6b7280; | ||
margin-bottom: 35px; | |||
border-left: 4px solid #667eea; | |||
padding-left: 16px; | |||
} | } | ||
.input-group { | .input-group { | ||
display: flex; | display: flex; | ||
gap: | gap: 12px; | ||
margin-bottom: | margin-bottom: 30px; | ||
flex-wrap: wrap; | |||
} | } | ||
input { | input { | ||
flex: 1; | flex: 1; | ||
padding: | padding: 16px 20px; | ||
border: 2px solid # | border: 2px solid #e5e7eb; | ||
border-radius: | border-radius: 20px; | ||
font-size: 16px; | font-size: 16px; | ||
background: #f9fafb; | |||
} | } | ||
| Строка 574: | Строка 588: | ||
outline: none; | outline: none; | ||
border-color: #667eea; | border-color: #667eea; | ||
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1); | |||
} | } | ||
button { | button { | ||
padding: | padding: 16px 32px; | ||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||
color: white; | color: white; | ||
border: none; | border: none; | ||
border-radius: | border-radius: 20px; | ||
font-size: 16px; | font-size: 16px; | ||
font-weight: 600; | |||
cursor: pointer; | cursor: pointer; | ||
transition: | transition: all 0.3s ease; | ||
} | } | ||
button:hover { | button:hover { | ||
transform: translateY(-2px); | transform: translateY(-2px); | ||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4); | |||
} | } | ||
| Строка 599: | Строка 611: | ||
display: none; | display: none; | ||
text-align: center; | text-align: center; | ||
padding: | padding: 50px; | ||
} | } | ||
.loader-spinner { | .loader-spinner { | ||
border: | width: 50px; | ||
border-top: | height: 50px; | ||
border: 4px solid #e5e7eb; | |||
border-top-color: #667eea; | |||
border-radius: 50%; | border-radius: 50%; | ||
animation: spin 0.8s linear infinite; | |||
margin: 0 auto 20px; | |||
animation: spin | |||
margin: 0 auto | |||
} | } | ||
@keyframes spin { | @keyframes spin { | ||
to { transform: rotate(360deg); } | |||
} | } | ||
.results { | .results { | ||
display: none; | display: none; | ||
animation: fadeInUp 0.5s ease; | |||
} | |||
@keyframes fadeInUp { | |||
from { | |||
opacity: 0; | |||
transform: translateY(20px); | |||
} | |||
to { | |||
opacity: 1; | |||
transform: translateY(0); | |||
} | |||
} | } | ||
. | .stats { | ||
display: grid; | display: grid; | ||
grid-template-columns: repeat(auto-fit, minmax( | grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); | ||
gap: | gap: 20px; | ||
margin: | margin-bottom: 40px; | ||
} | } | ||
. | .stat { | ||
background: #f8f9fa; | background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); | ||
padding: 24px 20px; | |||
border-radius: 24px; | |||
text-align: center; | text-align: center; | ||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); | |||
} | } | ||
. | .stat-value { | ||
font-size: 32px; | |||
font-weight: 800; | |||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |||
-webkit-background-clip: text; | |||
background-clip: text; | |||
color: transparent; | |||
} | } | ||
. | .stat-label { | ||
color: # | color: #6b7280; | ||
font-size: 14px; | |||
font- | font-weight: 500; | ||
} | } | ||
.chart- | .chart-grid { | ||
display: grid; | display: grid; | ||
grid-template-columns: repeat(auto-fit, minmax( | grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); | ||
gap: | gap: 30px; | ||
margin: | margin: 40px 0; | ||
} | } | ||
. | .chart-card { | ||
background: # | background: #ffffff; | ||
border-radius: 24px; | |||
padding: 24px; | |||
text-align: center; | text-align: center; | ||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); | |||
} | } | ||
. | .chart-card h3 { | ||
font-size: 1.3rem; | |||
font-weight: 600; | |||
margin-bottom: 20px; | |||
color: #1f2937; | |||
} | } | ||
. | .chart-card img { | ||
max-width: 100%; | |||
height: auto; | |||
border-radius: 16px; | |||
} | } | ||
.recommendations { | .recommendations { | ||
background: # | background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); | ||
border-left: | border-left: 5px solid #22c55e; | ||
padding: | padding: 28px; | ||
border-radius: | border-radius: 24px; | ||
margin-top: | margin-top: 30px; | ||
} | } | ||
.recommendations h3 { | .recommendations h3 { | ||
color: # | color: #15803d; | ||
margin-bottom: | margin-bottom: 20px; | ||
font-size: 1.3rem; | |||
font-weight: 700; | |||
} | } | ||
| Строка 703: | Строка 723: | ||
.recommendations li { | .recommendations li { | ||
padding: | padding: 12px 0; | ||
color: # | color: #166534; | ||
border-bottom: 1px solid #bbf7d0; | |||
} | |||
.recommendations li:last-child { | |||
border-bottom: none; | |||
} | } | ||
.error { | .error { | ||
background: # | background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); | ||
color: # | color: #dc2626; | ||
padding: | padding: 20px; | ||
border-radius: | border-radius: 20px; | ||
text-align: center; | |||
} | } | ||
@media (max-width: 768px) { | @media (max-width: 768px) { | ||
.input-group { | body { padding: 20px 15px; } | ||
.card { padding: 24px; } | |||
h1 { font-size: 1.8rem; } | |||
.input-group { flex-direction: column; } | |||
.chart-grid { | .chart-grid { grid-template-columns: 1fr; } | ||
.stats { grid-template-columns: 1fr; } | |||
} | |||
} | } | ||
</pre> | </pre> | ||
|} | |||
== | == Результаты работы == | ||
'''В приложении выводятся:''' | |||
=== Ключевые метрики === | === Ключевые метрики === | ||
| Строка 760: | Строка 764: | ||
=== График, выводящий 5 городов с наибольшим количеством подписчиков === | === График, выводящий 5 городов с наибольшим количеством подписчиков === | ||
[[Файл:Vk | [[Файл:Vk api9.png|600px|thumb|center|Топ-5 городов по количеству подписчиков]] | ||
=== График сравнения вовлеченности подписчиков в зависимости от типа контента === | === График сравнения вовлеченности подписчиков в зависимости от типа контента === | ||
[[Файл:Vk | [[Файл:Vk api10.png|600px|thumb|center|Вовлечённость по типам контента]] | ||
=== Рекомендации === | === Рекомендации === | ||
По итогам анализа определяется средний возраст аудитории, лучший формат контента, уровень вовлеченности и география подписчиков. В зависимости от этих показателей выводятся советы для дальнейшего развития сообщества. | По итогам анализа определяется средний возраст аудитории, лучший формат контента, уровень вовлеченности и география подписчиков. В зависимости от этих показателей выводятся советы для дальнейшего развития сообщества. | ||
[[Файл:Vk | [[Файл:Vk api11.png|1200px|thumb|center|Рекомендации по контент-стратегии]] | ||
== Выводы == | == Выводы == | ||
| Строка 781: | Строка 785: | ||
# Автоматическое формирование рекомендаций — на основе анализа данных приложение выдаёт практические советы по оптимизации контент-стратегии. | # Автоматическое формирование рекомендаций — на основе анализа данных приложение выдаёт практические советы по оптимизации контент-стратегии. | ||
# Удобный пользовательский интерфейс — адаптивный дизайн, анимации загрузки, понятная навигация. | # Удобный пользовательский интерфейс — адаптивный дизайн, анимации загрузки, понятная навигация. | ||
---- | |||
'''Дата выполнения:''' март 2026 г.<br> | |||
'''Стек технологий:''' Python, Flask, VK API, Matplotlib, Seaborn, Pandas, HTML/CSS/JavaScript | |||
Версия от 23:35, 26 марта 2026
Анализ аудитории сообществ ВКонтакте
Описание проекта
Веб-приложение для автоматического анализа аудитории сообществ ВКонтакте. Инструмент собирает данные о подписчиках (пол, возраст, география) и постах (типы контента, вовлечённость), визуализирует статистику в виде графиков и формирует рекомендации по оптимизации контент-стратегии.
| Параметр | Описание |
|---|---|
| Область знаний | Веб-разработка, анализ данных, работа с API социальных сетей, визуализация данных, SMM-аналитика |
| Стек технологий | Python 3.8+, Flask, VK API, Matplotlib, Seaborn, Pandas, HTML/CSS/JavaScript |
| Среда разработки | VS Code / PyCharm, локальный сервер (localhost) |
Диаграмма работы приложения
Структура проекта
- 📄 app.py
- основной Python-скрипт приложения. Обеспечивает интеграцию с VK API, сбор данных о подписчиках и постах, генерацию 4 графиков, формирование рекомендаций и работу веб-сервера на Flask.
- 📁 templates/index.html
- HTML-шаблон главной страницы. Содержит форму для ввода ссылки, область для отображения результатов и JavaScript-логику.
- 📁 static/style.css
- файл стилей для веб-интерфейса. Адаптивный дизайн, анимации, градиентный фон, сетка графиков.
Код приложения
app.py
| Показать код |
|---|
from flask import Flask, render_template, request, jsonify
import vk_api
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import seaborn as sns
from collections import Counter
from datetime import datetime
import os
import base64
import io
import pandas as pd
import time
app = Flask(__name__)
# Папка для статики
os.makedirs('static', exist_ok=True)
# Настройка стиля seaborn
sns.set_style("whitegrid")
sns.set_palette("husl")
# ВСТАВЬТЕ ВАШ ТОКЕН СЮДА
TOKEN = "ваш_токен_сюда"
def get_community_data(group_id):
"""Собирает данные о сообществе"""
try:
vk_session = vk_api.VkApi(token=TOKEN)
vk = vk_session.get_api()
# Определяем ID сообщества
if str(group_id).lstrip('-').isdigit():
owner_id = int(group_id)
group_id_for_api = owner_id
else:
group_id_for_api = group_id
# Получаем информацию о сообществе
try:
group = vk.groups.getById(group_id=group_id_for_api, fields='members_count,description')[0]
community_name = group['name']
members_count = group['members_count']
except:
group = vk.groups.getById(group_id=str(group_id_for_api), fields='members_count,description')[0]
community_name = group['name']
members_count = group['members_count']
# Собираем подписчиков с пагинацией (до 2000)
members_data = []
try:
total_to_collect = min(2000, members_count)
offset = 0
batch_size = 1000
while offset < total_to_collect:
members = vk.groups.getMembers(
group_id=group_id_for_api,
fields='sex,bdate,city',
count=batch_size,
offset=offset
)
for user in members['items']:
user_info = {}
if user.get('sex') == 1:
user_info['sex'] = 'Женщины'
elif user.get('sex') == 2:
user_info['sex'] = 'Мужчины'
else:
user_info['sex'] = 'Не указан'
if user.get('bdate'):
bdate = user['bdate']
if len(bdate.split('.')) == 3:
try:
year = int(bdate.split('.')[2])
age = datetime.now().year - year
if 0 < age < 100:
user_info['age'] = age
except:
pass
if user.get('city') and user['city'].get('title'):
user_info['city'] = user['city']['title']
members_data.append(user_info)
offset += len(members['items'])
time.sleep(0.34)
except Exception as e:
print(f"Ошибка при сборе подписчиков: {e}")
# Собираем посты
posts_data = []
try:
wall = vk.wall.get(owner_id=owner_id if str(group_id).lstrip('-').isdigit() else group_id, count=20, filter='owner')
for post in wall['items']:
post_info = {
'text': post.get('text', '')[:100],
'likes': post['likes']['count'],
'comments': post['comments']['count'],
'reposts': post['reposts']['count'],
'views': post.get('views', {}).get('count', 0)
}
if post.get('attachments'):
attach_type = post['attachments'][0]['type']
if attach_type == 'photo':
post_info['type'] = 'Фото'
elif attach_type == 'video':
post_info['type'] = 'Видео'
elif attach_type == 'link':
post_info['type'] = 'Ссылка'
else:
post_info['type'] = 'Текст'
else:
post_info['type'] = 'Текст'
if post_info['views'] > 0:
post_info['er'] = (post_info['likes'] + post_info['comments'] + post_info['reposts']) / post_info['views'] * 1000
else:
post_info['er'] = 0
posts_data.append(post_info)
except Exception as e:
print(f"Ошибка при сборе постов: {e}")
return {
'success': True,
'community_name': community_name,
'members_count': members_count,
'members': members_data,
'posts': posts_data
}
except Exception as e:
return {'success': False, 'error': str(e)}
def generate_charts(members_data, posts_data):
"""Генерирует графики с помощью seaborn"""
charts = {}
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = ['Arial', 'DejaVu Sans']
# 1. Распределение по полу
if members_data:
fig, ax = plt.subplots(figsize=(7, 5))
sex_counts = Counter([m.get('sex', 'Не указан') for m in members_data])
colors = ['#ff6b6b', '#4ecdc4', '#95a5a6']
ax.pie(sex_counts.values(), labels=sex_counts.keys(), autopct='%1.1f%%',
colors=colors[:len(sex_counts)], startangle=90,
explode=[0.02] * len(sex_counts), shadow=True)
buf = io.BytesIO()
plt.savefig(buf, format='png', bbox_inches='tight', dpi=100, facecolor='white')
buf.seek(0)
charts['sex'] = base64.b64encode(buf.getvalue()).decode()
plt.close(fig)
# 2. Возрастное распределение
ages = [m['age'] for m in members_data if 'age' in m]
if ages:
fig, ax = plt.subplots(figsize=(8, 5))
sns.histplot(ages, bins=15, kde=True, color='#3498db', edgecolor='white', linewidth=1.5, alpha=0.7)
ax.set_xlabel('Возраст')
ax.set_ylabel('Количество подписчиков')
avg_age = sum(ages) / len(ages)
ax.axvline(avg_age, color='#e74c3c', linestyle='--', linewidth=2, label=f'Средний: {avg_age:.1f} лет')
ax.legend()
buf = io.BytesIO()
plt.savefig(buf, format='png', bbox_inches='tight', dpi=100, facecolor='white')
buf.seek(0)
charts['age'] = base64.b64encode(buf.getvalue()).decode()
plt.close(fig)
# 3. Топ-5 городов
cities = [m['city'] for m in members_data if 'city' in m and m['city'] != 'Не указан']
if cities:
fig, ax = plt.subplots(figsize=(9, 6))
city_counts = Counter(cities).most_common(5)
city_names, city_values = zip(*city_counts)
df = pd.DataFrame({'Город': city_names, 'Количество': city_values})
df = df.sort_values('Количество', ascending=True)
sns.barplot(data=df, y='Город', x='Количество', hue='Город', palette='rocket', legend=False, ax=ax)
ax.set_xlabel('Количество подписчиков')
ax.set_ylabel('Город')
plt.tight_layout()
buf = io.BytesIO()
plt.savefig(buf, format='png', bbox_inches='tight', dpi=100, facecolor='white')
buf.seek(0)
charts['cities'] = base64.b64encode(buf.getvalue()).decode()
plt.close(fig)
# 4. Вовлечённость по типам контента
if posts_data:
fig, ax = plt.subplots(figsize=(10, 6))
type_er = {}
for post in posts_data:
if post['type'] not in type_er:
type_er[post['type']] = []
type_er[post['type']].append(post['er'])
avg_er = {t: sum(ers)/len(ers) for t, ers in type_er.items()}
df_er = pd.DataFrame({'Тип контента': list(avg_er.keys()), 'ER': list(avg_er.values())})
df_er = df_er.sort_values('ER', ascending=False)
bars = sns.barplot(data=df_er, x='Тип контента', y='ER', hue='Тип контента', palette='viridis', legend=False, ax=ax)
ax.set_ylabel('ER (активность на 1000 просмотров)')
ax.set_xlabel('Тип контента')
for bar, val in zip(bars.patches, df_er['ER'].values):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 1,
f'{val:.1f}', ha='center', va='bottom', fontsize=10, fontweight='bold')
plt.xticks(rotation=45)
plt.tight_layout()
buf = io.BytesIO()
plt.savefig(buf, format='png', bbox_inches='tight', dpi=100, facecolor='white')
buf.seek(0)
charts['engagement'] = base64.b64encode(buf.getvalue()).decode()
plt.close(fig)
return charts
@app.route('/')
def index():
return render_template('index.html')
@app.route('/analyze', methods=['POST'])
def analyze():
group_url = request.json.get('group_url', '').strip()
if not group_url:
return jsonify({'success': False, 'error': 'Введите ссылку на сообщество'})
if 'vk.com/' in group_url:
group_id = group_url.split('vk.com/')[-1].split('/')[0]
else:
group_id = group_url
data = get_community_data(group_id)
if not data['success']:
return jsonify({'success': False, 'error': data.get('error', 'Ошибка при получении данных')})
charts = generate_charts(data['members'], data['posts'])
stats = {}
if data['members']:
sex_counts = Counter([m.get('sex', 'Не указан') for m in data['members']])
stats['sex'] = dict(sex_counts)
ages = [m['age'] for m in data['members'] if 'age' in m]
if ages:
stats['avg_age'] = sum(ages) / len(ages)
cities = [m['city'] for m in data['members'] if 'city' in m and m['city'] != 'Не указан']
if cities:
stats['top_cities'] = Counter(cities).most_common(5)
if data['posts']:
avg_er = sum(p['er'] for p in data['posts']) / len(data['posts'])
stats['avg_er'] = avg_er
type_er = {}
for post in data['posts']:
if post['type'] not in type_er:
type_er[post['type']] = []
type_er[post['type']].append(post['er'])
best_type = max(type_er.keys(), key=lambda t: sum(type_er[t])/len(type_er[t]))
stats['best_post_type'] = best_type
recommendations = []
if 'avg_age' in stats:
if stats['avg_age'] < 20:
recommendations.append("🎯 Аудитория молодая (до 20 лет) → используйте короткие, трендовые форматы, мемы")
elif stats['avg_age'] < 30:
recommendations.append("🎯 Аудитория 20-30 лет → чередуйте развлекательный и полезный контент")
else:
recommendations.append("🎯 Аудитория 30+ лет → делайте акцент на полезный, экспертный контент")
if stats.get('best_post_type'):
recommendations.append(f"📈 Лучший формат контента: {stats['best_post_type']} → публикуйте чаще именно его")
if data['posts'] and stats.get('avg_er'):
if stats['avg_er'] < 50:
recommendations.append("⚠️ Вовлечённость ниже среднего → добавьте опросы, конкурсы и интерактив")
elif stats['avg_er'] > 150:
recommendations.append("🔥 Отличная вовлечённость! Продолжайте в том же духе")
if stats.get('top_cities'):
main_city = stats['top_cities'][0][0]
recommendations.append(f"📍 Основная аудитория из {main_city} → можно проводить локальные мероприятия")
return jsonify({
'success': True,
'community_name': data['community_name'],
'members_count': data['members_count'],
'stats': stats,
'charts': charts,
'recommendations': recommendations
})
if __name__ == '__main__':
app.run(debug=True)
|
templates/index.html
| Показать код |
|---|
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Анализ аудитории сообществ VK</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<div class="card">
<h1>📱 Анализ аудитории сообществ VK</h1>
<p class="subtitle">Анализ аудитории сообщества и рекомендации по контенту</p>
<div class="input-group">
<input type="text" id="groupUrl" placeholder="Введите ссылку или ID сообщества">
<button onclick="analyze()" id="analyzeBtn">🔍 Анализировать</button>
</div>
<div class="loader" id="loader">
<div class="loader-spinner"></div>
<p>Анализируем сообщество... Это может занять до 30 секунд</p>
</div>
<div class="results" id="results">
<div class="stats" id="stats"></div>
<div class="chart-grid" id="charts"></div>
<div class="recommendations" id="recommendations"></div>
</div>
</div>
</div>
<script>
async function analyze() {
const groupUrl = document.getElementById('groupUrl').value;
if (!groupUrl) {
alert('Введите ссылку или ID сообщества');
return;
}
const btn = document.getElementById('analyzeBtn');
const loader = document.getElementById('loader');
const results = document.getElementById('results');
btn.disabled = true;
loader.style.display = 'block';
results.style.display = 'none';
try {
const response = await fetch('/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_url: groupUrl })
});
const data = await response.json();
if (data.success) {
displayResults(data);
} else {
showError(data.error);
}
} catch (error) {
showError('Ошибка соединения с сервером');
} finally {
btn.disabled = false;
loader.style.display = 'none';
}
}
function displayResults(data) {
const statsHtml = `
<div class="stat">
<div class="stat-value">${data.community_name}</div>
<div class="stat-label">Название сообщества</div>
</div>
<div class="stat">
<div class="stat-value">${data.members_count.toLocaleString()}</div>
<div class="stat-label">Подписчиков</div>
</div>
${data.stats.avg_age ? `
<div class="stat">
<div class="stat-value">${data.stats.avg_age.toFixed(1)} лет</div>
<div class="stat-label">Средний возраст</div>
</div>
` : ''}
${data.stats.avg_er ? `
<div class="stat">
<div class="stat-value">${data.stats.avg_er.toFixed(2)}</div>
<div class="stat-label">Средняя вовлечённость (ER)</div>
</div>
` : ''}
`;
document.getElementById('stats').innerHTML = statsHtml;
let chartsHtml = '';
if (data.charts.sex) {
chartsHtml += `
<div class="chart-card">
<h3>👥 Распределение по полу</h3>
<img src="data:image/png;base64,${data.charts.sex}" alt="Распределение по полу">
</div>
`;
}
if (data.charts.age) {
chartsHtml += `
<div class="chart-card">
<h3>🎂 Возрастное распределение</h3>
<img src="data:image/png;base64,${data.charts.age}" alt="Возраст">
</div>
`;
}
if (data.charts.cities) {
chartsHtml += `
<div class="chart-card">
<h3>🏙️ Топ-5 городов</h3>
<img src="data:image/png;base64,${data.charts.cities}" alt="Города">
</div>
`;
}
if (data.charts.engagement) {
chartsHtml += `
<div class="chart-card">
<h3>📈 Вовлечённость по типам контента</h3>
<img src="data:image/png;base64,${data.charts.engagement}" alt="ER">
</div>
`;
}
document.getElementById('charts').innerHTML = chartsHtml;
let recHtml = '<h3>💡 Рекомендации по контент-стратегии</h3><ul>';
data.recommendations.forEach(rec => {
recHtml += `<li>${rec}</li>`;
});
recHtml += '</ul>';
document.getElementById('recommendations').innerHTML = recHtml;
document.getElementById('results').style.display = 'block';
}
function showError(message) {
const results = document.getElementById('results');
results.style.display = 'block';
results.innerHTML = `<div class="error">❌ Ошибка: ${message}</div>`;
}
</script>
</body>
</html>
|
static/style.css
| Показать код |
|---|
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.card {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
border-radius: 32px;
padding: 40px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
margin-bottom: 12px;
}
.subtitle {
font-size: 1.1rem;
color: #6b7280;
margin-bottom: 35px;
border-left: 4px solid #667eea;
padding-left: 16px;
}
.input-group {
display: flex;
gap: 12px;
margin-bottom: 30px;
flex-wrap: wrap;
}
input {
flex: 1;
padding: 16px 20px;
border: 2px solid #e5e7eb;
border-radius: 20px;
font-size: 16px;
background: #f9fafb;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
}
button {
padding: 16px 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 20px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
}
.loader {
display: none;
text-align: center;
padding: 50px;
}
.loader-spinner {
width: 50px;
height: 50px;
border: 4px solid #e5e7eb;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.results {
display: none;
animation: fadeInUp 0.5s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat {
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
padding: 24px 20px;
border-radius: 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.stat-value {
font-size: 32px;
font-weight: 800;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.stat-label {
color: #6b7280;
font-size: 14px;
font-weight: 500;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 30px;
margin: 40px 0;
}
.chart-card {
background: #ffffff;
border-radius: 24px;
padding: 24px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.chart-card h3 {
font-size: 1.3rem;
font-weight: 600;
margin-bottom: 20px;
color: #1f2937;
}
.chart-card img {
max-width: 100%;
height: auto;
border-radius: 16px;
}
.recommendations {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border-left: 5px solid #22c55e;
padding: 28px;
border-radius: 24px;
margin-top: 30px;
}
.recommendations h3 {
color: #15803d;
margin-bottom: 20px;
font-size: 1.3rem;
font-weight: 700;
}
.recommendations ul {
list-style: none;
padding-left: 0;
}
.recommendations li {
padding: 12px 0;
color: #166534;
border-bottom: 1px solid #bbf7d0;
}
.recommendations li:last-child {
border-bottom: none;
}
.error {
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
color: #dc2626;
padding: 20px;
border-radius: 20px;
text-align: center;
}
@media (max-width: 768px) {
body { padding: 20px 15px; }
.card { padding: 24px; }
h1 { font-size: 1.8rem; }
.input-group { flex-direction: column; }
.chart-grid { grid-template-columns: 1fr; }
.stats { grid-template-columns: 1fr; }
}
|
Результаты работы
В приложении выводятся:
Ключевые метрики

График, отображающий распределение подписчиков сообщества по полу

График, показывающий распределение подписчиков по возрасту с указанием среднего значения

График, выводящий 5 городов с наибольшим количеством подписчиков

График сравнения вовлеченности подписчиков в зависимости от типа контента

Рекомендации
По итогам анализа определяется средний возраст аудитории, лучший формат контента, уровень вовлеченности и география подписчиков. В зависимости от этих показателей выводятся советы для дальнейшего развития сообщества.

Выводы
- Разработано полноценное веб-приложение для анализа сообществ ВКонтакте, работающее на localhost.
- Реализована интеграция с VK API — приложение успешно получает данные о подписчиках (пол, возраст, город) и постах (типы контента, вовлечённость).
- Создана система визуализации — 4 информативных графика, отображающих ключевые метрики аудитории:
- Распределение по полу (круговая диаграмма)
- Возрастное распределение (гистограмма)
- Топ-5 городов (горизонтальная столбчатая диаграмма)
- Вовлечённость по типам контента (столбчатая диаграмма)
- Автоматическое формирование рекомендаций — на основе анализа данных приложение выдаёт практические советы по оптимизации контент-стратегии.
- Удобный пользовательский интерфейс — адаптивный дизайн, анимации загрузки, понятная навигация.
Дата выполнения: март 2026 г.
Стек технологий: Python, Flask, VK API, Matplotlib, Seaborn, Pandas, HTML/CSS/JavaScript
