|
|
| Строка 43: |
Строка 43: |
|
| |
|
| == Структура проекта == | | == Структура проекта == |
| <pre>
| |
| vk_analytics/
| |
| │
| |
| ├── app.py # Основной файл приложения (Flask)
| |
| │
| |
| ├── templates/
| |
| │ └── index.html # HTML-шаблон главной страницы
| |
| │
| |
| └── static/
| |
| └── style.css # Стили для веб-интерфейса
| |
| </pre>
| |
|
| |
|
| === Описание файлов ===
| |
| {| class="wikitable"
| |
| ! Файл
| |
| ! Назначение
| |
| |-
| |
| | app.py
| |
| | Содержит логику приложения: маршруты Flask, функции для работы с VK API, обработку данных, генерацию графиков и формирование рекомендаций.
| |
| |-
| |
| | templates/index.html
| |
| | HTML-страница с формой ввода и областью для отображения результатов. Включает JavaScript для отправки запросов к серверу.
| |
| |-
| |
| | static/style.css
| |
| | CSS-стили для оформления интерфейса: адаптивная сетка, анимации, цветовая схема.
| |
| |}
| |
|
| |
| == Ход работы над проектом ==
| |
|
| |
| === 1. Настройка окружения ===
| |
| Установка необходимых библиотек:
| |
| <pre>
| |
| pip install flask vk_api matplotlib pandas
| |
| </pre>
| |
|
| |
| === 2. Получение токена доступа VK API ===
| |
| Для работы с VK API используется токен доступа, полученный через сервис vkhost.github.io:
| |
| <pre>
| |
| TOKEN = "ваш_токен_сюда"
| |
| </pre>
| |
|
| |
| === 3. Функция сбора данных о сообществе ===
| |
| <pre>
| |
| def get_community_data(group_id):
| |
| """Собирает данные о сообществе"""
| |
| try:
| |
| vk_session = vk_api.VkApi(token=TOKEN)
| |
| vk = vk_session.get_api()
| |
|
| |
| # Получаем информацию о сообществе
| |
| group = vk.groups.getById(group_id=group_id, fields='members_count,description')[0]
| |
| community_name = group['name']
| |
| members_count = group['members_count']
| |
|
| |
| # Собираем подписчиков (500 записей)
| |
| members = vk.groups.getMembers(group_id=group_id, fields='sex,bdate,city', count=500)
| |
| members_data = []
| |
|
| |
| 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') and len(user['bdate'].split('.')) == 3:
| |
| year = int(user['bdate'].split('.')[2])
| |
| age = datetime.now().year - year
| |
| if 0 < age < 100:
| |
| user_info['age'] = age
| |
|
| |
| # Город
| |
| if user.get('city') and user['city'].get('title'):
| |
| user_info['city'] = user['city']['title']
| |
|
| |
| members_data.append(user_info)
| |
|
| |
| # Собираем посты
| |
| wall = vk.wall.get(owner_id=group_id, count=20, filter='owner')
| |
| posts_data = []
| |
|
| |
| 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'] = 'Текст'
| |
|
| |
| # Расчёт вовлечённости (ER)
| |
| 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)
| |
|
| |
| 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)}
| |
| </pre>
| |
|
| |
| === 4. Функция генерации графиков ===
| |
| <pre>
| |
| def generate_charts(members_data, posts_data):
| |
| """Генерирует графики и возвращает их в base64"""
| |
| charts = {}
| |
|
| |
| # Настройка шрифтов для русских букв
| |
| plt.rcParams['font.family'] = 'sans-serif'
| |
| plt.rcParams['font.sans-serif'] = ['Arial', 'DejaVu Sans']
| |
|
| |
| # 1. Распределение по полу
| |
| if members_data:
| |
| fig, ax = plt.subplots(figsize=(6, 4))
| |
| sex_counts = Counter([m.get('sex', 'Не указан') for m in members_data])
| |
| ax.pie(sex_counts.values(), labels=sex_counts.keys(), autopct='%1.1f%%')
| |
| ax.set_title('Распределение по полу')
| |
|
| |
| buf = io.BytesIO()
| |
| plt.savefig(buf, format='png', bbox_inches='tight', dpi=100)
| |
| 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=(6, 4))
| |
| ax.hist(ages, bins=15, color='skyblue', edgecolor='black')
| |
| ax.set_title('Возрастное распределение')
| |
| ax.set_xlabel('Возраст')
| |
| ax.set_ylabel('Количество')
| |
|
| |
| buf = io.BytesIO()
| |
| plt.savefig(buf, format='png', bbox_inches='tight', dpi=100)
| |
| 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]
| |
| if cities:
| |
| fig, ax = plt.subplots(figsize=(8, 5))
| |
| city_counts = Counter(cities).most_common(5)
| |
| city_names, city_values = zip(*city_counts)
| |
|
| |
| ax.barh(city_names, city_values, color='lightcoral', edgecolor='darkred')
| |
| ax.set_title('Топ-5 городов аудитории', fontsize=14, fontweight='bold')
| |
| ax.set_xlabel('Количество подписчиков')
| |
| ax.set_ylabel('Город')
| |
|
| |
| plt.tight_layout()
| |
|
| |
| buf = io.BytesIO()
| |
| plt.savefig(buf, format='png', bbox_inches='tight', dpi=100)
| |
| buf.seek(0)
| |
| charts['cities'] = base64.b64encode(buf.getvalue()).decode()
| |
| plt.close(fig)
| |
|
| |
| # 4. Вовлечённость по типам контента
| |
| if posts_data:
| |
| fig, ax = plt.subplots(figsize=(8, 5))
| |
| 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()}
| |
| ax.bar(avg_er.keys(), avg_er.values(), color='lightgreen', edgecolor='darkgreen')
| |
| ax.set_title('Вовлечённость по типам контента', fontsize=14, fontweight='bold')
| |
| ax.set_ylabel('ER (на 1000 просмотров)')
| |
| plt.xticks(rotation=45)
| |
|
| |
| plt.tight_layout()
| |
|
| |
| buf = io.BytesIO()
| |
| plt.savefig(buf, format='png', bbox_inches='tight', dpi=100)
| |
| buf.seek(0)
| |
| charts['engagement'] = base64.b64encode(buf.getvalue()).decode()
| |
| plt.close(fig)
| |
|
| |
| return charts
| |
| </pre>
| |
|
| |
| === 5. Веб-интерфейс ===
| |
|
| |
| ==== HTML-шаблон (templates/index.html) ====
| |
| <pre>
| |
| <!DOCTYPE html>
| |
| <html lang="ru">
| |
| <head>
| |
| <meta charset="UTF-8">
| |
| <meta name="viewport" content="width=device-width, initial-scale=1.0">
| |
| <title>VK Community Analytics</title>
| |
| <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
| |
| </head>
| |
| <body>
| |
| <div class="container">
| |
| <div class="card">
| |
| <h1>📊 VK Community Analytics</h1>
| |
| <p class="subtitle">Анализ аудитории сообщества и рекомендации по контенту</p>
| |
|
| |
| <div class="input-group">
| |
| <input type="text" id="groupUrl" placeholder="Введите ссылку или ID сообщества (например: durov или vk.com/durov)">
| |
| <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>
| |
| </pre>
| |
|
| |
| ==== CSS-стили (static/style.css) ====
| |
| <pre>
| |
| * {
| |
| margin: 0;
| |
| padding: 0;
| |
| box-sizing: border-box;
| |
| }
| |
|
| |
| body {
| |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
| |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
| |
| min-height: 100vh;
| |
| padding: 20px;
| |
| }
| |
|
| |
| .container {
| |
| max-width: 1200px;
| |
| margin: 0 auto;
| |
| }
| |
|
| |
| .card {
| |
| background: white;
| |
| border-radius: 20px;
| |
| padding: 30px;
| |
| margin-bottom: 20px;
| |
| box-shadow: 0 10px 40px rgba(0,0,0,0.1);
| |
| }
| |
|
| |
| h1 {
| |
| color: #333;
| |
| margin-bottom: 10px;
| |
| }
| |
|
| |
| .subtitle {
| |
| color: #666;
| |
| margin-bottom: 30px;
| |
| }
| |
|
| |
| .input-group {
| |
| display: flex;
| |
| gap: 10px;
| |
| margin-bottom: 20px;
| |
| }
| |
|
| |
| input {
| |
| flex: 1;
| |
| padding: 15px;
| |
| border: 2px solid #e0e0e0;
| |
| border-radius: 10px;
| |
| font-size: 16px;
| |
| transition: border-color 0.3s;
| |
| }
| |
|
| |
| input:focus {
| |
| outline: none;
| |
| border-color: #667eea;
| |
| }
| |
|
| |
| button {
| |
| padding: 15px 30px;
| |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
| |
| color: white;
| |
| border: none;
| |
| border-radius: 10px;
| |
| font-size: 16px;
| |
| cursor: pointer;
| |
| transition: transform 0.2s;
| |
| }
| |
|
| |
| button:hover {
| |
| transform: translateY(-2px);
| |
| }
| |
|
| |
| button:disabled {
| |
| opacity: 0.6;
| |
| cursor: not-allowed;
| |
| }
| |
|
| |
| .loader {
| |
| display: none;
| |
| text-align: center;
| |
| padding: 40px;
| |
| }
| |
|
| |
| .loader-spinner {
| |
| border: 3px solid #f3f3f3;
| |
| border-top: 3px solid #667eea;
| |
| border-radius: 50%;
| |
| width: 40px;
| |
| height: 40px;
| |
| animation: spin 1s linear infinite;
| |
| margin: 0 auto 15px;
| |
| }
| |
|
| |
| @keyframes spin {
| |
| 0% { transform: rotate(0deg); }
| |
| 100% { transform: rotate(360deg); }
| |
| }
| |
|
| |
| .results {
| |
| display: none;
| |
| }
| |
|
| |
| .chart-grid {
| |
| display: grid;
| |
| grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
| |
| gap: 25px;
| |
| margin: 30px 0;
| |
| }
| |
|
| |
| .chart-card {
| |
| background: #f8f9fa;
| |
| border-radius: 15px;
| |
| padding: 20px;
| |
| text-align: center;
| |
| transition: transform 0.2s, box-shadow 0.2s;
| |
| }
| |
|
| |
| .chart-card:hover {
| |
| transform: translateY(-5px);
| |
| box-shadow: 0 5px 20px rgba(0,0,0,0.1);
| |
| }
| |
|
| |
| .chart-card h3 {
| |
| color: #333;
| |
| margin-bottom: 15px;
| |
| font-size: 18px;
| |
| }
| |
|
| |
| .chart-card img {
| |
| max-width: 100%;
| |
| height: auto;
| |
| border-radius: 10px;
| |
| }
| |
|
| |
| .stats {
| |
| display: grid;
| |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
| |
| gap: 15px;
| |
| margin: 20px 0;
| |
| }
| |
|
| |
| .stat {
| |
| background: #f8f9fa;
| |
| padding: 15px;
| |
| border-radius: 10px;
| |
| text-align: center;
| |
| transition: transform 0.2s;
| |
| }
| |
|
| |
| .stat:hover {
| |
| transform: translateY(-3px);
| |
| }
| |
|
| |
| .stat-value {
| |
| font-size: 28px;
| |
| font-weight: bold;
| |
| color: #667eea;
| |
| }
| |
|
| |
| .stat-label {
| |
| color: #666;
| |
| font-size: 14px;
| |
| margin-top: 5px;
| |
| }
| |
|
| |
| .recommendations {
| |
| background: #e8f5e9;
| |
| border-left: 4px solid #4caf50;
| |
| padding: 20px;
| |
| border-radius: 10px;
| |
| margin-top: 20px;
| |
| }
| |
|
| |
| .recommendations h3 {
| |
| color: #2e7d32;
| |
| margin-bottom: 15px;
| |
| }
| |
|
| |
| .recommendations ul {
| |
| list-style: none;
| |
| padding-left: 0;
| |
| }
| |
|
| |
| .recommendations li {
| |
| padding: 8px 0;
| |
| color: #1b5e20;
| |
| }
| |
|
| |
| .error {
| |
| background: #ffebee;
| |
| color: #c62828;
| |
| padding: 15px;
| |
| border-radius: 10px;
| |
| margin-top: 20px;
| |
| }
| |
|
| |
| @media (max-width: 768px) {
| |
| .input-group {
| |
| flex-direction: column;
| |
| }
| |
|
| |
| .chart-grid {
| |
| grid-template-columns: 1fr;
| |
| }
| |
| }
| |
| </pre>
| |
|
| |
| === 6. Формирование рекомендаций ===
| |
| <pre>
| |
| # Рекомендации
| |
| recommendations = []
| |
|
| |
| # Рекомендации по возрасту
| |
| if 'avg_age' in stats:
| |
| if stats['avg_age'] < 20:
| |
| recommendations.append("🎯 Аудитория молодая (до 20 лет) → используйте короткие, трендовые форматы, мемы, TikTok-подход")
| |
| 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} → можно проводить локальные мероприятия или делать привязку к городу")
| |
| </pre>
| |
|
| |
| === 7. Запуск приложения ===
| |
| <pre>
| |
| python app.py
| |
| </pre>
| |
|
| |
|
| После запуска приложение доступно по адресу: '''http://127.0.0.1:5000''' | | После запуска приложение доступно по адресу: '''http://127.0.0.1:5000''' |
| Строка 661: |
Строка 49: |
| == Выводы == | | == Выводы == |
|
| |
|
| === Достигнутые результаты ===
| |
| # Разработано полноценное веб-приложение для анализа сообществ ВКонтакте, работающее на localhost. | | # Разработано полноценное веб-приложение для анализа сообществ ВКонтакте, работающее на localhost. |
| # Реализована интеграция с VK API — приложение успешно получает данные о подписчиках (пол, возраст, город) и постах (типы контента, вовлечённость). | | # Реализована интеграция с VK API — приложение успешно получает данные о подписчиках (пол, возраст, город) и постах (типы контента, вовлечённость). |