import { PlatformType, PostMetadata } from '../../core/types/giveaway'; import { RawParticipant } from '../../core/types/participant'; import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types'; import { parseVkPostUrl } from './vk-parser'; interface VkApiResponse { response?: T; error?: { error_code: number; error_msg: string; }; } export class VkProvider implements SocialMediaProvider { readonly platform: PlatformType = 'VK'; readonly capabilities: ProviderCapabilities = { likes: true, comments: true, reposts: false, repostsNote: 'Сбор репостов ограничен политикой приватности VK для закрытых профилей', subscriptions: true, adminDetection: false, adminDetectionNote: 'Требует расширенных прав администратора группы', }; private serviceToken?: string; private apiVersion = '5.199'; private baseUrl = 'https://api.vk.com/method'; constructor(serviceToken?: string) { this.serviceToken = serviceToken || process.env.VK_SERVICE_TOKEN; } parsePostUrl(url: string): { ownerId: string; postId: string } | null { return parseVkPostUrl(url); } private async callApi(method: string, params: Record): Promise { if (!this.serviceToken) { throw new Error('VK_SERVICE_TOKEN is not configured in environment variables'); } const query = new URLSearchParams({ ...Object.entries(params).reduce((acc, [k, v]) => ({ ...acc, [k]: String(v) }), {}), access_token: this.serviceToken, v: this.apiVersion, }); const response = await fetch(`${this.baseUrl}/${method}?${query.toString()}`, { method: 'GET', headers: { 'Accept': 'application/json' }, }); if (!response.ok) { throw new Error(`VK API HTTP error: ${response.status} ${response.statusText}`); } const data: VkApiResponse = await response.json(); if (data.error) { throw new Error(`VK API Error (${data.error.error_code}): ${data.error.error_msg}`); } if (!data.response) { throw new Error('Empty response from VK API'); } return data.response; } async fetchPost(url: string): Promise { const parsed = this.parsePostUrl(url); if (!parsed) { throw new Error('Invalid VK post URL format'); } const { ownerId, postId } = parsed; const response = await this.callApi<{ items: any[]; profiles?: any[]; groups?: any[] }>( 'wall.getById', { posts: `${ownerId}_${postId}`, extended: 1, } ); if (!response.items || response.items.length === 0) { throw new Error('Post not found or access is restricted'); } const post = response.items[0]; let authorName = `VK Wall ${ownerId}`; let authorAvatarUrl = undefined; if (ownerId.startsWith('-')) { const groupId = Math.abs(parseInt(ownerId, 10)); const group = (response.groups || []).find(g => g.id === groupId); if (group) { authorName = group.name; authorAvatarUrl = group.photo_100 || group.photo_200; } } else { const userId = parseInt(ownerId, 10); const profile = (response.profiles || []).find(p => p.id === userId); if (profile) { authorName = `${profile.first_name} ${profile.last_name}`; authorAvatarUrl = profile.photo_100 || profile.photo_200; } } let imageUrl = undefined; if (post.attachments && post.attachments.length > 0) { const photoAttachment = post.attachments.find((a: any) => a.type === 'photo'); if (photoAttachment && photoAttachment.photo && photoAttachment.photo.sizes) { const sizes = photoAttachment.photo.sizes; imageUrl = sizes[sizes.length - 1]?.url; } } return { platform: 'VK', ownerId, postId, sourceUrl: url, title: post.text ? post.text.slice(0, 80) + '...' : `Запись на стене ${ownerId}_${postId}`, text: post.text || '', authorName, authorAvatarUrl, imageUrl, likesCount: post.likes?.count || 0, commentsCount: post.comments?.count || 0, repostsCount: post.reposts?.count || 0, publishedAt: post.date ? new Date(post.date * 1000) : undefined, }; } async fetchParticipants(params: FetchParticipantsParams): Promise { const { ownerId, postId } = params; const participantsMap = new Map(); // 1. Fetch Likes if (params.includeLikes !== false) { let offset = 0; const count = 1000; let totalLikes = 0; do { const likesRes = await this.callApi<{ count: number; items: any[] }>('likes.getList', { type: 'post', owner_id: ownerId, item_id: postId, filter: 'likes', extended: 1, count, offset, }); totalLikes = likesRes.count; for (const item of likesRes.items) { const userId = String(item.id); participantsMap.set(userId, { platformUserId: userId, firstName: item.first_name || '', lastName: item.last_name || '', username: item.screen_name || undefined, avatarUrl: item.photo_100 || item.photo_200, source: 'LIKES', liked: true, commented: false, commentsCount: 0, reposted: false, subscribed: false, }); } offset += count; if (params.onProgress) { params.onProgress(participantsMap.size, totalLikes, 'Загрузка лайков...'); } } while (offset < totalLikes && offset < 5000); } // 2. Fetch Comments if (params.includeComments) { let offset = 0; const count = 100; let totalComments = 0; do { const commentsRes = await this.callApi<{ count: number; items: any[]; profiles?: any[] }>( 'wall.getComments', { owner_id: ownerId, post_id: postId, extended: 1, count, offset, fields: 'photo_100,photo_200,screen_name', } ); totalComments = commentsRes.count; const profileMap = new Map( (commentsRes.profiles || []).map(p => [p.id, p]) ); for (const item of commentsRes.items) { if (item.from_id && item.from_id > 0) { const userId = String(item.from_id); const prof = profileMap.get(item.from_id); const existing = participantsMap.get(userId); if (existing) { existing.commented = true; existing.commentsCount = (existing.commentsCount || 0) + 1; } else { participantsMap.set(userId, { platformUserId: userId, firstName: prof?.first_name || 'Участник', lastName: prof?.last_name || userId, username: prof?.screen_name, avatarUrl: prof?.photo_100, source: 'COMMENTS', liked: false, commented: true, commentsCount: 1, reposted: false, subscribed: false, }); } } } offset += count; } while (offset < totalComments && offset < 1000); } return Array.from(participantsMap.values()); } async checkSubscription(userIds: string[], groupId: string): Promise> { const cleanGroupId = groupId.replace(/^-/, ''); const resultMap = new Map(); const chunkSize = 500; for (let i = 0; i < userIds.length; i += chunkSize) { const chunk = userIds.slice(i, i + chunkSize); const res = await this.callApi>( 'groups.isMember', { group_id: cleanGroupId, user_ids: chunk.join(','), } ); for (const item of res) { resultMap.set(String(item.user_id), item.member === 1); } } return resultMap; } }