mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44:12 +00:00
feat(youtube): add reply thread import to comment sync
Add getCommentReplies method to YouTubeClient for fetching reply threads via the YouTube comments.list API. Modify CommentsSyncService to import reply threads during sync, storing them as type 'reply' with parentInteraction relationship in community-interactions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
13507d1361
commit
289b69380f
3 changed files with 410 additions and 5 deletions
|
|
@ -15,6 +15,7 @@ interface SyncResult {
|
|||
success: boolean
|
||||
newComments: number
|
||||
updatedComments: number
|
||||
newReplies: number
|
||||
errors: string[]
|
||||
syncedAt: Date
|
||||
}
|
||||
|
|
@ -43,6 +44,7 @@ export class CommentsSyncService {
|
|||
success: false,
|
||||
newComments: 0,
|
||||
updatedComments: 0,
|
||||
newReplies: 0,
|
||||
errors: [],
|
||||
syncedAt: new Date(),
|
||||
}
|
||||
|
|
@ -102,13 +104,15 @@ export class CommentsSyncService {
|
|||
const processResult = await this.processComment(
|
||||
comment,
|
||||
account,
|
||||
analyzeWithAI
|
||||
analyzeWithAI,
|
||||
youtubeClient,
|
||||
)
|
||||
if (processResult.isNew) {
|
||||
result.newComments++
|
||||
} else {
|
||||
result.updatedComments++
|
||||
}
|
||||
result.newReplies += processResult.newReplies
|
||||
} catch (error) {
|
||||
result.errors.push(`Fehler bei Kommentar ${comment.id}: ${error}`)
|
||||
}
|
||||
|
|
@ -139,8 +143,9 @@ export class CommentsSyncService {
|
|||
private async processComment(
|
||||
comment: CommentThread,
|
||||
account: any,
|
||||
analyzeWithAI: boolean
|
||||
): Promise<{ isNew: boolean }> {
|
||||
analyzeWithAI: boolean,
|
||||
youtubeClient?: YouTubeClient,
|
||||
): Promise<{ isNew: boolean; newReplies: number }> {
|
||||
const snippet = comment.snippet.topLevelComment.snippet
|
||||
|
||||
// Prüfen ob Kommentar bereits existiert
|
||||
|
|
@ -228,11 +233,14 @@ export class CommentsSyncService {
|
|||
}),
|
||||
}
|
||||
|
||||
let parentInteractionId: number | null = null
|
||||
|
||||
if (isNew) {
|
||||
await this.payload.create({
|
||||
const created = await this.payload.create({
|
||||
collection: 'community-interactions',
|
||||
data: interactionData,
|
||||
})
|
||||
parentInteractionId = created.id
|
||||
} else {
|
||||
await this.payload.update({
|
||||
collection: 'community-interactions',
|
||||
|
|
@ -245,9 +253,122 @@ export class CommentsSyncService {
|
|||
assignedTo: existing.docs[0].assignedTo,
|
||||
},
|
||||
})
|
||||
parentInteractionId = existing.docs[0].id
|
||||
}
|
||||
|
||||
return { isNew }
|
||||
// Reply-Threads synchronisieren
|
||||
let newReplies = 0
|
||||
if (youtubeClient && comment.snippet.totalReplyCount > 0) {
|
||||
newReplies = await this.processReplyThread(
|
||||
comment,
|
||||
account,
|
||||
parentInteractionId,
|
||||
platformId,
|
||||
linkedContentId,
|
||||
youtubeClient,
|
||||
analyzeWithAI,
|
||||
)
|
||||
}
|
||||
|
||||
return { isNew, newReplies }
|
||||
}
|
||||
|
||||
/**
|
||||
* Antworten eines Kommentar-Threads synchronisieren
|
||||
*/
|
||||
private async processReplyThread(
|
||||
comment: CommentThread,
|
||||
account: any,
|
||||
parentInteractionId: number | null,
|
||||
platformId: any,
|
||||
linkedContentId: any,
|
||||
youtubeClient: YouTubeClient,
|
||||
analyzeWithAI: boolean,
|
||||
): Promise<number> {
|
||||
let newReplies = 0
|
||||
|
||||
try {
|
||||
const { replies } = await youtubeClient.getCommentReplies(
|
||||
comment.snippet.topLevelComment.id,
|
||||
)
|
||||
|
||||
for (const reply of replies) {
|
||||
const replyExists = await this.payload.find({
|
||||
collection: 'community-interactions',
|
||||
where: {
|
||||
externalId: { equals: reply.id },
|
||||
},
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (replyExists.totalDocs > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
let replyAnalysis = null
|
||||
if (analyzeWithAI) {
|
||||
replyAnalysis = await this.claudeService.analyzeComment(
|
||||
reply.snippet.textOriginal,
|
||||
)
|
||||
}
|
||||
|
||||
await this.payload.create({
|
||||
collection: 'community-interactions',
|
||||
data: {
|
||||
platform: platformId,
|
||||
socialAccount: account.id,
|
||||
linkedContent: linkedContentId,
|
||||
type: 'reply' as const,
|
||||
externalId: reply.id,
|
||||
parentInteraction: parentInteractionId,
|
||||
author: {
|
||||
name: reply.snippet.authorDisplayName,
|
||||
handle: reply.snippet.authorChannelId?.value,
|
||||
profileUrl: reply.snippet.authorChannelUrl,
|
||||
avatarUrl: reply.snippet.authorProfileImageUrl,
|
||||
isVerified: false,
|
||||
isSubscriber: false,
|
||||
isMember: false,
|
||||
},
|
||||
message: reply.snippet.textOriginal,
|
||||
messageHtml: reply.snippet.textDisplay,
|
||||
publishedAt: new Date(reply.snippet.publishedAt).toISOString(),
|
||||
engagement: {
|
||||
likes: reply.snippet.likeCount || 0,
|
||||
replies: 0,
|
||||
isHearted: false,
|
||||
isPinned: false,
|
||||
},
|
||||
...(replyAnalysis && {
|
||||
analysis: {
|
||||
sentiment: replyAnalysis.sentiment,
|
||||
sentimentScore: replyAnalysis.sentimentScore,
|
||||
confidence: replyAnalysis.confidence,
|
||||
topics: replyAnalysis.topics.map((t) => ({ topic: t })),
|
||||
language: replyAnalysis.language,
|
||||
suggestedReply: replyAnalysis.suggestedReply,
|
||||
analyzedAt: new Date().toISOString(),
|
||||
},
|
||||
flags: {
|
||||
isMedicalQuestion: replyAnalysis.isMedicalQuestion,
|
||||
requiresEscalation: replyAnalysis.requiresEscalation,
|
||||
isSpam: replyAnalysis.isSpam,
|
||||
isFromInfluencer: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
newReplies++
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error syncing replies for comment ${comment.id}:`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
|
||||
return newReplies
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -232,6 +232,48 @@ export class YouTubeClient {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Antworten auf einen Kommentar-Thread abrufen
|
||||
*/
|
||||
async getCommentReplies(
|
||||
parentCommentId: string,
|
||||
maxResults: number = 100
|
||||
): Promise<{
|
||||
replies: Array<{
|
||||
id: string
|
||||
snippet: {
|
||||
parentId: string
|
||||
textOriginal: string
|
||||
textDisplay: string
|
||||
authorDisplayName: string
|
||||
authorProfileImageUrl: string
|
||||
authorChannelUrl: string
|
||||
authorChannelId: { value: string }
|
||||
likeCount: number
|
||||
publishedAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
}>
|
||||
nextPageToken?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await this.youtube.comments.list({
|
||||
part: ['snippet'],
|
||||
parentId: parentCommentId,
|
||||
maxResults,
|
||||
textFormat: 'plainText',
|
||||
})
|
||||
|
||||
return {
|
||||
replies: (response.data.items || []) as any[],
|
||||
nextPageToken: response.data.nextPageToken || undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching comment replies:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kanal-Statistiken abrufen
|
||||
*/
|
||||
|
|
|
|||
242
tests/unit/youtube/comment-replies.unit.spec.ts
Normal file
242
tests/unit/youtube/comment-replies.unit.spec.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* YouTubeClient.getCommentReplies Unit Tests
|
||||
*
|
||||
* Tests the comment reply retrieval method that fetches replies
|
||||
* for a given parent comment thread from the YouTube Data API.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockCommentsList = vi.fn()
|
||||
|
||||
vi.mock('googleapis', () => {
|
||||
class MockOAuth2 {
|
||||
setCredentials(): void {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
google: {
|
||||
auth: {
|
||||
OAuth2: MockOAuth2,
|
||||
},
|
||||
youtube: () => ({
|
||||
videos: { list: vi.fn() },
|
||||
commentThreads: { list: vi.fn() },
|
||||
comments: {
|
||||
list: mockCommentsList,
|
||||
insert: vi.fn(),
|
||||
setModerationStatus: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
channels: { list: vi.fn() },
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockPayload = {} as import('payload').Payload
|
||||
|
||||
describe('YouTubeClient.getCommentReplies', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return parsed replies for a parent comment', async () => {
|
||||
mockCommentsList.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 'reply-1',
|
||||
snippet: {
|
||||
parentId: 'comment-abc',
|
||||
textOriginal: 'Great point!',
|
||||
textDisplay: 'Great point!',
|
||||
authorDisplayName: 'User A',
|
||||
authorProfileImageUrl: 'https://example.com/a.jpg',
|
||||
authorChannelUrl: 'https://youtube.com/channel/UC_a',
|
||||
authorChannelId: { value: 'UC_a' },
|
||||
likeCount: 5,
|
||||
publishedAt: '2026-01-15T10:00:00Z',
|
||||
updatedAt: '2026-01-15T10:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'reply-2',
|
||||
snippet: {
|
||||
parentId: 'comment-abc',
|
||||
textOriginal: 'Thanks for sharing',
|
||||
textDisplay: 'Thanks for sharing',
|
||||
authorDisplayName: 'User B',
|
||||
authorProfileImageUrl: 'https://example.com/b.jpg',
|
||||
authorChannelUrl: 'https://youtube.com/channel/UC_b',
|
||||
authorChannelId: { value: 'UC_b' },
|
||||
likeCount: 2,
|
||||
publishedAt: '2026-01-15T11:00:00Z',
|
||||
updatedAt: '2026-01-15T11:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
nextPageToken: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const { YouTubeClient } = await import(
|
||||
'@/lib/integrations/youtube/YouTubeClient'
|
||||
)
|
||||
|
||||
const client = new YouTubeClient(
|
||||
{
|
||||
clientId: 'test-id',
|
||||
clientSecret: 'test-secret',
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
},
|
||||
mockPayload,
|
||||
)
|
||||
|
||||
const result = await client.getCommentReplies('comment-abc')
|
||||
|
||||
expect(result.replies).toHaveLength(2)
|
||||
expect(result.replies[0].id).toBe('reply-1')
|
||||
expect(result.replies[0].snippet.parentId).toBe('comment-abc')
|
||||
expect(result.replies[0].snippet.textOriginal).toBe('Great point!')
|
||||
expect(result.replies[0].snippet.likeCount).toBe(5)
|
||||
expect(result.replies[1].id).toBe('reply-2')
|
||||
expect(result.nextPageToken).toBeUndefined()
|
||||
|
||||
expect(mockCommentsList).toHaveBeenCalledWith({
|
||||
part: ['snippet'],
|
||||
parentId: 'comment-abc',
|
||||
maxResults: 100,
|
||||
textFormat: 'plainText',
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass custom maxResults parameter', async () => {
|
||||
mockCommentsList.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [],
|
||||
nextPageToken: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const { YouTubeClient } = await import(
|
||||
'@/lib/integrations/youtube/YouTubeClient'
|
||||
)
|
||||
|
||||
const client = new YouTubeClient(
|
||||
{
|
||||
clientId: 'test-id',
|
||||
clientSecret: 'test-secret',
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
},
|
||||
mockPayload,
|
||||
)
|
||||
|
||||
await client.getCommentReplies('comment-xyz', 20)
|
||||
|
||||
expect(mockCommentsList).toHaveBeenCalledWith({
|
||||
part: ['snippet'],
|
||||
parentId: 'comment-xyz',
|
||||
maxResults: 20,
|
||||
textFormat: 'plainText',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty replies array when no items exist', async () => {
|
||||
mockCommentsList.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: null,
|
||||
nextPageToken: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const { YouTubeClient } = await import(
|
||||
'@/lib/integrations/youtube/YouTubeClient'
|
||||
)
|
||||
|
||||
const client = new YouTubeClient(
|
||||
{
|
||||
clientId: 'test-id',
|
||||
clientSecret: 'test-secret',
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
},
|
||||
mockPayload,
|
||||
)
|
||||
|
||||
const result = await client.getCommentReplies('comment-empty')
|
||||
|
||||
expect(result.replies).toEqual([])
|
||||
expect(result.nextPageToken).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return nextPageToken when more replies are available', async () => {
|
||||
mockCommentsList.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 'reply-page1',
|
||||
snippet: {
|
||||
parentId: 'comment-many',
|
||||
textOriginal: 'First page reply',
|
||||
textDisplay: 'First page reply',
|
||||
authorDisplayName: 'User C',
|
||||
authorProfileImageUrl: 'https://example.com/c.jpg',
|
||||
authorChannelUrl: 'https://youtube.com/channel/UC_c',
|
||||
authorChannelId: { value: 'UC_c' },
|
||||
likeCount: 0,
|
||||
publishedAt: '2026-02-01T12:00:00Z',
|
||||
updatedAt: '2026-02-01T12:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
nextPageToken: 'page2token',
|
||||
},
|
||||
})
|
||||
|
||||
const { YouTubeClient } = await import(
|
||||
'@/lib/integrations/youtube/YouTubeClient'
|
||||
)
|
||||
|
||||
const client = new YouTubeClient(
|
||||
{
|
||||
clientId: 'test-id',
|
||||
clientSecret: 'test-secret',
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
},
|
||||
mockPayload,
|
||||
)
|
||||
|
||||
const result = await client.getCommentReplies('comment-many')
|
||||
|
||||
expect(result.replies).toHaveLength(1)
|
||||
expect(result.nextPageToken).toBe('page2token')
|
||||
})
|
||||
|
||||
it('should propagate API errors', async () => {
|
||||
mockCommentsList.mockRejectedValueOnce(new Error('commentListForbidden'))
|
||||
|
||||
const { YouTubeClient } = await import(
|
||||
'@/lib/integrations/youtube/YouTubeClient'
|
||||
)
|
||||
|
||||
const client = new YouTubeClient(
|
||||
{
|
||||
clientId: 'test-id',
|
||||
clientSecret: 'test-secret',
|
||||
accessToken: 'test-token',
|
||||
refreshToken: 'test-refresh',
|
||||
},
|
||||
mockPayload,
|
||||
)
|
||||
|
||||
await expect(
|
||||
client.getCommentReplies('comment-forbidden'),
|
||||
).rejects.toThrow('commentListForbidden')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue