@yoonion/mimi-seed-mcp 0.19.13 → 0.19.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/assets/agent-guide.md +44 -7
- package/dist/appstore/tools.js +83 -29
- package/dist/auth/browser.d.ts +2 -0
- package/dist/auth/browser.js +4 -0
- package/dist/auth/cli.js +33 -8
- package/dist/auth/google-auth.d.ts +26 -5
- package/dist/auth/google-auth.js +133 -47
- package/dist/auth/scopes.d.ts +5 -0
- package/dist/auth/scopes.js +8 -0
- package/dist/auth/youtube-channel.d.ts +8 -0
- package/dist/auth/youtube-channel.js +26 -0
- package/dist/googleads/errors.d.ts +2 -0
- package/dist/googleads/errors.js +31 -0
- package/dist/googleads/tools.d.ts +7 -0
- package/dist/googleads/tools.js +89 -46
- package/dist/helpers.d.ts +1 -1
- package/dist/helpers.js +10 -10
- package/dist/instagram/browser-login.d.ts +13 -0
- package/dist/instagram/browser-login.js +125 -0
- package/dist/instagram/setup.d.ts +1 -1
- package/dist/instagram/setup.js +5 -2
- package/dist/lib/googleapis-lite.d.ts +2 -0
- package/dist/lib/googleapis-lite.js +3 -0
- package/dist/lib/store-create-result.d.ts +221 -0
- package/dist/lib/store-create-result.js +38 -0
- package/dist/playstore/tools.d.ts +2 -0
- package/dist/playstore/tools.js +23 -8
- package/dist/registers/appstore.js +3 -47
- package/dist/registers/auth.js +30 -6
- package/dist/registers/googleads.js +5 -2
- package/dist/registers/playstore.js +3 -16
- package/dist/registers/video.js +11 -5
- package/dist/registers/youtube.d.ts +2 -0
- package/dist/registers/youtube.js +89 -0
- package/dist/server.js +2 -0
- package/dist/social/setup-cli.js +66 -19
- package/dist/threads/browser-login.d.ts +13 -0
- package/dist/threads/browser-login.js +125 -0
- package/dist/threads/config.js +2 -4
- package/dist/threads/setup.d.ts +1 -1
- package/dist/threads/setup.js +7 -4
- package/dist/video/schemas.d.ts +12 -12
- package/dist/video/youtube-publish.d.ts +14 -0
- package/dist/video/youtube-publish.js +8 -0
- package/dist/youtube/comments.d.ts +156 -0
- package/dist/youtube/comments.js +158 -0
- package/dist/youtube/insights.d.ts +82 -0
- package/dist/youtube/insights.js +155 -0
- package/dist/youtube/tools.d.ts +174 -0
- package/dist/youtube/tools.js +292 -0
- package/package.json +5 -3
- package/scripts/googleads-report.mjs +32 -0
- package/tool-manifest.json +18 -1
|
@@ -4,6 +4,7 @@ import { validateVideo } from './render.js';
|
|
|
4
4
|
export declare const YOUTUBE_SCOPE = "https://www.googleapis.com/auth/youtube.force-ssl";
|
|
5
5
|
export type YouTubePrivacyStatus = 'private' | 'unlisted' | 'public';
|
|
6
6
|
export interface UploadYouTubeVideoInput {
|
|
7
|
+
expectedChannelId: string;
|
|
7
8
|
filePath: string;
|
|
8
9
|
title: string;
|
|
9
10
|
description?: string;
|
|
@@ -36,7 +37,10 @@ declare function assertShortsCompatible(facts: ReturnType<typeof videoFacts>): v
|
|
|
36
37
|
export declare function getYouTubeVideoStatus(auth: OAuth2Client, videoId: string): Promise<{
|
|
37
38
|
videoId: string | null;
|
|
38
39
|
title: string | null;
|
|
40
|
+
channelId: string | null;
|
|
41
|
+
channelTitle: string | null;
|
|
39
42
|
privacyStatus: string | null;
|
|
43
|
+
publishAt: string | null;
|
|
40
44
|
uploadStatus: string | null;
|
|
41
45
|
processingStatus: string | null;
|
|
42
46
|
processingProgress: youtube_v3.Schema$VideoProcessingDetailsProcessingProgress | null;
|
|
@@ -46,6 +50,10 @@ export declare function getYouTubeVideoStatus(auth: OAuth2Client, videoId: strin
|
|
|
46
50
|
studioUrl: string | null;
|
|
47
51
|
}>;
|
|
48
52
|
export declare function uploadYouTubeVideo(auth: OAuth2Client, input: UploadYouTubeVideoInput): Promise<{
|
|
53
|
+
authenticatedChannel: {
|
|
54
|
+
id: string;
|
|
55
|
+
title: string;
|
|
56
|
+
};
|
|
49
57
|
requestedPrivacyStatus: YouTubePrivacyStatus;
|
|
50
58
|
file: {
|
|
51
59
|
width?: number;
|
|
@@ -57,7 +65,10 @@ export declare function uploadYouTubeVideo(auth: OAuth2Client, input: UploadYouT
|
|
|
57
65
|
validationWarnings: string[];
|
|
58
66
|
videoId: string | null;
|
|
59
67
|
title: string | null;
|
|
68
|
+
channelId: string | null;
|
|
69
|
+
channelTitle: string | null;
|
|
60
70
|
privacyStatus: string | null;
|
|
71
|
+
publishAt: string | null;
|
|
61
72
|
uploadStatus: string | null;
|
|
62
73
|
processingStatus: string | null;
|
|
63
74
|
processingProgress: youtube_v3.Schema$VideoProcessingDetailsProcessingProgress | null;
|
|
@@ -69,7 +80,10 @@ export declare function uploadYouTubeVideo(auth: OAuth2Client, input: UploadYouT
|
|
|
69
80
|
export declare function updateYouTubeVideoPrivacy(auth: OAuth2Client, input: UpdateYouTubePrivacyInput): Promise<{
|
|
70
81
|
videoId: string | null;
|
|
71
82
|
title: string | null;
|
|
83
|
+
channelId: string | null;
|
|
84
|
+
channelTitle: string | null;
|
|
72
85
|
privacyStatus: string | null;
|
|
86
|
+
publishAt: string | null;
|
|
73
87
|
uploadStatus: string | null;
|
|
74
88
|
processingStatus: string | null;
|
|
75
89
|
processingProgress: youtube_v3.Schema$VideoProcessingDetailsProcessingProgress | null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { verifyYouTubeChannel } from '../auth/youtube-channel.js';
|
|
1
2
|
import { createReadStream, existsSync, statSync } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { google } from '../lib/googleapis-lite.js';
|
|
@@ -57,7 +58,10 @@ function safeStatus(video) {
|
|
|
57
58
|
return {
|
|
58
59
|
videoId: video.id ?? null,
|
|
59
60
|
title: video.snippet?.title ?? null,
|
|
61
|
+
channelId: video.snippet?.channelId ?? null,
|
|
62
|
+
channelTitle: video.snippet?.channelTitle ?? null,
|
|
60
63
|
privacyStatus: video.status?.privacyStatus ?? null,
|
|
64
|
+
publishAt: video.status?.publishAt ?? null,
|
|
61
65
|
uploadStatus: video.status?.uploadStatus ?? null,
|
|
62
66
|
processingStatus: video.processingDetails?.processingStatus ?? null,
|
|
63
67
|
processingProgress: video.processingDetails?.processingProgress ?? null,
|
|
@@ -86,6 +90,9 @@ export async function getYouTubeVideoStatus(auth, videoId) {
|
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
export async function uploadYouTubeVideo(auth, input) {
|
|
93
|
+
if (!input.expectedChannelId)
|
|
94
|
+
throw new Error('expectedChannelId is required before uploading.');
|
|
95
|
+
const channel = await verifyYouTubeChannel(auth, input.expectedChannelId);
|
|
89
96
|
const privacyStatus = input.privacyStatus ?? 'private';
|
|
90
97
|
assertVisibilityConfirmed(privacyStatus, input.confirmVisible);
|
|
91
98
|
const file = assertUploadFile(input.filePath);
|
|
@@ -131,6 +138,7 @@ export async function uploadYouTubeVideo(auth, input) {
|
|
|
131
138
|
throw new Error('YouTube 업로드 응답에 videoId가 없습니다.');
|
|
132
139
|
return {
|
|
133
140
|
...safeStatus(response.data),
|
|
141
|
+
authenticatedChannel: channel,
|
|
134
142
|
requestedPrivacyStatus: privacyStatus,
|
|
135
143
|
file: { path: input.filePath, size: file.size, ...facts },
|
|
136
144
|
validationWarnings: validation.issues,
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { OAuth2Client } from 'google-auth-library';
|
|
2
|
+
export interface YouTubeCommentsPageInput {
|
|
3
|
+
expectedChannelId?: string;
|
|
4
|
+
videoId?: string;
|
|
5
|
+
maxResults?: number;
|
|
6
|
+
pageToken?: string;
|
|
7
|
+
}
|
|
8
|
+
/** One bounded page of top-level comments; embedded replies are deliberately not treated as complete. */
|
|
9
|
+
export declare function listYouTubeComments(auth: OAuth2Client, input: YouTubeCommentsPageInput): Promise<{
|
|
10
|
+
channel: {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
};
|
|
14
|
+
videoId: string | null;
|
|
15
|
+
threads: {
|
|
16
|
+
threadId: string | null;
|
|
17
|
+
videoId: string | null;
|
|
18
|
+
topLevelComment: {
|
|
19
|
+
id: string | null;
|
|
20
|
+
text: string | null;
|
|
21
|
+
authorDisplayName: string | null;
|
|
22
|
+
authorChannelId: string | null;
|
|
23
|
+
publishedAt: string | null;
|
|
24
|
+
updatedAt: string | null;
|
|
25
|
+
likeCount: number | null;
|
|
26
|
+
};
|
|
27
|
+
canReply: boolean | null;
|
|
28
|
+
totalReplyCount: number | null;
|
|
29
|
+
repliesIncluded: boolean;
|
|
30
|
+
repliesComplete: boolean;
|
|
31
|
+
}[];
|
|
32
|
+
nextPageToken: string | null;
|
|
33
|
+
}>;
|
|
34
|
+
interface ParentTarget {
|
|
35
|
+
expectedChannelId: string;
|
|
36
|
+
videoId: string;
|
|
37
|
+
threadId: string;
|
|
38
|
+
parentCommentId: string;
|
|
39
|
+
}
|
|
40
|
+
export interface YouTubeCommentRepliesInput extends ParentTarget {
|
|
41
|
+
maxResults?: number;
|
|
42
|
+
pageToken?: string;
|
|
43
|
+
}
|
|
44
|
+
export declare function listYouTubeCommentReplies(auth: OAuth2Client, input: YouTubeCommentRepliesInput): Promise<{
|
|
45
|
+
replies: {
|
|
46
|
+
id: string | null;
|
|
47
|
+
text: string | null;
|
|
48
|
+
authorDisplayName: string | null;
|
|
49
|
+
authorChannelId: string | null;
|
|
50
|
+
publishedAt: string | null;
|
|
51
|
+
updatedAt: string | null;
|
|
52
|
+
likeCount: number | null;
|
|
53
|
+
}[];
|
|
54
|
+
nextPageToken: string | null;
|
|
55
|
+
repliesComplete: boolean;
|
|
56
|
+
channel: {
|
|
57
|
+
id: string;
|
|
58
|
+
title: string;
|
|
59
|
+
};
|
|
60
|
+
video: {
|
|
61
|
+
id: string | null | undefined;
|
|
62
|
+
title: string | null;
|
|
63
|
+
};
|
|
64
|
+
parentComment: {
|
|
65
|
+
id: string | null;
|
|
66
|
+
text: string | null;
|
|
67
|
+
authorDisplayName: string | null;
|
|
68
|
+
authorChannelId: string | null;
|
|
69
|
+
publishedAt: string | null;
|
|
70
|
+
updatedAt: string | null;
|
|
71
|
+
likeCount: number | null;
|
|
72
|
+
};
|
|
73
|
+
canReply: boolean | null;
|
|
74
|
+
totalReplyCount: number | null;
|
|
75
|
+
}>;
|
|
76
|
+
export interface YouTubeReplyInput extends ParentTarget {
|
|
77
|
+
text: string;
|
|
78
|
+
confirm?: boolean;
|
|
79
|
+
}
|
|
80
|
+
export declare function replyToYouTubeComment(auth: OAuth2Client, input: YouTubeReplyInput): Promise<{
|
|
81
|
+
text: string;
|
|
82
|
+
public: boolean;
|
|
83
|
+
channel: {
|
|
84
|
+
id: string;
|
|
85
|
+
title: string;
|
|
86
|
+
};
|
|
87
|
+
video: {
|
|
88
|
+
id: string | null | undefined;
|
|
89
|
+
title: string | null;
|
|
90
|
+
};
|
|
91
|
+
parentComment: {
|
|
92
|
+
id: string | null;
|
|
93
|
+
text: string | null;
|
|
94
|
+
authorDisplayName: string | null;
|
|
95
|
+
authorChannelId: string | null;
|
|
96
|
+
publishedAt: string | null;
|
|
97
|
+
updatedAt: string | null;
|
|
98
|
+
likeCount: number | null;
|
|
99
|
+
};
|
|
100
|
+
canReply: boolean | null;
|
|
101
|
+
totalReplyCount: number | null;
|
|
102
|
+
status: "preview";
|
|
103
|
+
} | {
|
|
104
|
+
note: string;
|
|
105
|
+
text: string;
|
|
106
|
+
public: boolean;
|
|
107
|
+
channel: {
|
|
108
|
+
id: string;
|
|
109
|
+
title: string;
|
|
110
|
+
};
|
|
111
|
+
video: {
|
|
112
|
+
id: string | null | undefined;
|
|
113
|
+
title: string | null;
|
|
114
|
+
};
|
|
115
|
+
parentComment: {
|
|
116
|
+
id: string | null;
|
|
117
|
+
text: string | null;
|
|
118
|
+
authorDisplayName: string | null;
|
|
119
|
+
authorChannelId: string | null;
|
|
120
|
+
publishedAt: string | null;
|
|
121
|
+
updatedAt: string | null;
|
|
122
|
+
likeCount: number | null;
|
|
123
|
+
};
|
|
124
|
+
canReply: boolean | null;
|
|
125
|
+
totalReplyCount: number | null;
|
|
126
|
+
status: "unknown";
|
|
127
|
+
} | {
|
|
128
|
+
reply: {
|
|
129
|
+
id: string;
|
|
130
|
+
text: string;
|
|
131
|
+
publishedAt: string | null;
|
|
132
|
+
};
|
|
133
|
+
text: string;
|
|
134
|
+
public: boolean;
|
|
135
|
+
channel: {
|
|
136
|
+
id: string;
|
|
137
|
+
title: string;
|
|
138
|
+
};
|
|
139
|
+
video: {
|
|
140
|
+
id: string | null | undefined;
|
|
141
|
+
title: string | null;
|
|
142
|
+
};
|
|
143
|
+
parentComment: {
|
|
144
|
+
id: string | null;
|
|
145
|
+
text: string | null;
|
|
146
|
+
authorDisplayName: string | null;
|
|
147
|
+
authorChannelId: string | null;
|
|
148
|
+
publishedAt: string | null;
|
|
149
|
+
updatedAt: string | null;
|
|
150
|
+
likeCount: number | null;
|
|
151
|
+
};
|
|
152
|
+
canReply: boolean | null;
|
|
153
|
+
totalReplyCount: number | null;
|
|
154
|
+
status: "posted";
|
|
155
|
+
}>;
|
|
156
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { google } from '../lib/googleapis-lite.js';
|
|
2
|
+
import { friendlyGoogleError, googleErrorDetail } from '../lib/google-errors.js';
|
|
3
|
+
import { verifyYouTubeChannel } from '../auth/youtube-channel.js';
|
|
4
|
+
function apiError(error) {
|
|
5
|
+
const friendly = friendlyGoogleError(error);
|
|
6
|
+
const detail = googleErrorDetail(error);
|
|
7
|
+
return detail && !friendly.message.includes(detail)
|
|
8
|
+
? new Error(`${friendly.message}\nGoogle API: ${detail}`, { cause: error }) : friendly;
|
|
9
|
+
}
|
|
10
|
+
function pageSize(value) {
|
|
11
|
+
const size = value ?? 20;
|
|
12
|
+
if (!Number.isInteger(size) || size < 1 || size > 100)
|
|
13
|
+
throw new Error('maxResults must be 1–100.');
|
|
14
|
+
return size;
|
|
15
|
+
}
|
|
16
|
+
function commentSummary(comment) {
|
|
17
|
+
return {
|
|
18
|
+
id: comment?.id ?? null,
|
|
19
|
+
text: comment?.snippet?.textDisplay ?? comment?.snippet?.textOriginal ?? null,
|
|
20
|
+
authorDisplayName: comment?.snippet?.authorDisplayName ?? null,
|
|
21
|
+
authorChannelId: comment?.snippet?.authorChannelId?.value ?? null,
|
|
22
|
+
publishedAt: comment?.snippet?.publishedAt ?? null,
|
|
23
|
+
updatedAt: comment?.snippet?.updatedAt ?? null,
|
|
24
|
+
likeCount: comment?.snippet?.likeCount ?? null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function assertOwnedVideo(auth, channelId, videoId) {
|
|
28
|
+
try {
|
|
29
|
+
const result = await google.youtube({ version: 'v3', auth }).videos.list({
|
|
30
|
+
part: ['snippet'], id: [videoId],
|
|
31
|
+
});
|
|
32
|
+
const video = result.data.items?.[0];
|
|
33
|
+
if (video?.id !== videoId || video.snippet?.channelId !== channelId) {
|
|
34
|
+
throw new Error('Video is missing or does not belong to the authenticated YouTube channel.');
|
|
35
|
+
}
|
|
36
|
+
return video;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
throw apiError(error);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** One bounded page of top-level comments; embedded replies are deliberately not treated as complete. */
|
|
43
|
+
export async function listYouTubeComments(auth, input) {
|
|
44
|
+
const channel = await verifyYouTubeChannel(auth, input.expectedChannelId);
|
|
45
|
+
if (input.videoId)
|
|
46
|
+
await assertOwnedVideo(auth, channel.id, input.videoId);
|
|
47
|
+
try {
|
|
48
|
+
const result = await google.youtube({ version: 'v3', auth }).commentThreads.list({
|
|
49
|
+
part: ['snippet'], textFormat: 'plainText', order: 'time',
|
|
50
|
+
...(input.videoId ? { videoId: input.videoId } : { allThreadsRelatedToChannelId: channel.id }),
|
|
51
|
+
maxResults: pageSize(input.maxResults),
|
|
52
|
+
...(input.pageToken ? { pageToken: input.pageToken } : {}),
|
|
53
|
+
});
|
|
54
|
+
const threads = (result.data.items ?? []).map((thread) => {
|
|
55
|
+
if (thread.snippet?.channelId !== channel.id ||
|
|
56
|
+
(input.videoId && thread.snippet?.videoId !== input.videoId)) {
|
|
57
|
+
throw new Error('YouTube returned a comment thread outside the selected channel/video.');
|
|
58
|
+
}
|
|
59
|
+
const totalReplyCount = thread.snippet.totalReplyCount ?? null;
|
|
60
|
+
return {
|
|
61
|
+
threadId: thread.id ?? null,
|
|
62
|
+
videoId: thread.snippet.videoId ?? null,
|
|
63
|
+
topLevelComment: commentSummary(thread.snippet.topLevelComment),
|
|
64
|
+
canReply: thread.snippet.canReply ?? null,
|
|
65
|
+
totalReplyCount,
|
|
66
|
+
repliesIncluded: false,
|
|
67
|
+
repliesComplete: totalReplyCount === 0,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
return { channel, videoId: input.videoId ?? null, threads,
|
|
71
|
+
nextPageToken: result.data.nextPageToken ?? null };
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw apiError(error);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function verifiedParent(auth, input) {
|
|
78
|
+
if (!input.expectedChannelId || !input.videoId || !input.threadId || !input.parentCommentId) {
|
|
79
|
+
throw new Error('expectedChannelId, videoId, threadId, and parentCommentId are required.');
|
|
80
|
+
}
|
|
81
|
+
const channel = await verifyYouTubeChannel(auth, input.expectedChannelId);
|
|
82
|
+
const video = await assertOwnedVideo(auth, channel.id, input.videoId);
|
|
83
|
+
try {
|
|
84
|
+
const youtube = google.youtube({ version: 'v3', auth });
|
|
85
|
+
const commentResponse = await youtube.comments.list({
|
|
86
|
+
part: ['snippet'], id: [input.parentCommentId], textFormat: 'plainText',
|
|
87
|
+
});
|
|
88
|
+
const comment = commentResponse.data.items?.[0];
|
|
89
|
+
if (comment?.id !== input.parentCommentId)
|
|
90
|
+
throw new Error('Parent comment was not found.');
|
|
91
|
+
if (comment.snippet?.parentId)
|
|
92
|
+
throw new Error('Replies to replies are not supported; choose a top-level comment.');
|
|
93
|
+
// Comment.authorChannelId identifies the commenter, not the video's owner.
|
|
94
|
+
if (comment.snippet?.channelId !== channel.id)
|
|
95
|
+
throw new Error('Parent comment is not associated with the selected channel.');
|
|
96
|
+
const threadResponse = await youtube.commentThreads.list({
|
|
97
|
+
part: ['snippet'], id: [input.threadId], textFormat: 'plainText',
|
|
98
|
+
});
|
|
99
|
+
const thread = threadResponse.data.items?.[0];
|
|
100
|
+
if (thread?.id !== input.threadId || thread.snippet?.topLevelComment?.id !== input.parentCommentId ||
|
|
101
|
+
thread.snippet.videoId !== input.videoId || thread.snippet.channelId !== channel.id) {
|
|
102
|
+
throw new Error('Parent comment could not be verified on the selected video.');
|
|
103
|
+
}
|
|
104
|
+
return { channel, video: { id: video.id, title: video.snippet?.title ?? null },
|
|
105
|
+
parentComment: commentSummary(comment), canReply: thread.snippet.canReply ?? null,
|
|
106
|
+
totalReplyCount: thread.snippet.totalReplyCount ?? null };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
throw apiError(error);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export async function listYouTubeCommentReplies(auth, input) {
|
|
113
|
+
const target = await verifiedParent(auth, input);
|
|
114
|
+
try {
|
|
115
|
+
const result = await google.youtube({ version: 'v3', auth }).comments.list({
|
|
116
|
+
part: ['snippet'], parentId: input.parentCommentId, textFormat: 'plainText',
|
|
117
|
+
maxResults: pageSize(input.maxResults),
|
|
118
|
+
...(input.pageToken ? { pageToken: input.pageToken } : {}),
|
|
119
|
+
});
|
|
120
|
+
const replies = (result.data.items ?? []).map((comment) => {
|
|
121
|
+
if (comment.snippet?.parentId !== input.parentCommentId)
|
|
122
|
+
throw new Error('YouTube returned a reply for a different parent comment.');
|
|
123
|
+
return commentSummary(comment);
|
|
124
|
+
});
|
|
125
|
+
return { ...target, replies, nextPageToken: result.data.nextPageToken ?? null,
|
|
126
|
+
repliesComplete: !result.data.nextPageToken && !input.pageToken &&
|
|
127
|
+
target.totalReplyCount !== null && replies.length >= target.totalReplyCount };
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw apiError(error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export async function replyToYouTubeComment(auth, input) {
|
|
134
|
+
if (!input.text?.trim() || input.text.length > 10_000)
|
|
135
|
+
throw new Error('Reply text must be nonempty and at most 10000 characters.');
|
|
136
|
+
const target = await verifiedParent(auth, input);
|
|
137
|
+
const preview = { ...target, text: input.text, public: true };
|
|
138
|
+
if (input.confirm !== true)
|
|
139
|
+
return { status: 'preview', ...preview };
|
|
140
|
+
if (target.canReply === false)
|
|
141
|
+
throw new Error('YouTube says this thread cannot accept replies.');
|
|
142
|
+
try {
|
|
143
|
+
const result = await google.youtube({ version: 'v3', auth }).comments.insert({
|
|
144
|
+
part: ['snippet'], requestBody: { snippet: {
|
|
145
|
+
parentId: input.parentCommentId, textOriginal: input.text,
|
|
146
|
+
} },
|
|
147
|
+
}, { retry: false });
|
|
148
|
+
if (!result.data.id)
|
|
149
|
+
return { status: 'unknown', ...preview,
|
|
150
|
+
note: 'YouTube returned no reply ID. Call youtube_list_comment_replies and check for the text before retrying.' };
|
|
151
|
+
return { status: 'posted', ...preview,
|
|
152
|
+
reply: { id: result.data.id, text: result.data.snippet?.textDisplay ?? result.data.snippet?.textOriginal ?? input.text,
|
|
153
|
+
publishedAt: result.data.snippet?.publishedAt ?? null } };
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
throw new Error(`${apiError(error).message}\nThe reply may have posted. Call youtube_list_comment_replies and check for the text before retrying.`, { cause: error });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { OAuth2Client } from 'google-auth-library';
|
|
2
|
+
export interface YouTubeContentInsightsInput {
|
|
3
|
+
expectedChannelId: string;
|
|
4
|
+
startDate: string;
|
|
5
|
+
endDate: string;
|
|
6
|
+
maxVideos?: number;
|
|
7
|
+
minViews?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function comparisonPeriods(startDate: string, endDate: string): {
|
|
10
|
+
current: {
|
|
11
|
+
startDate: string;
|
|
12
|
+
endDate: string;
|
|
13
|
+
inclusiveDays: number;
|
|
14
|
+
};
|
|
15
|
+
previous: {
|
|
16
|
+
startDate: string;
|
|
17
|
+
endDate: string;
|
|
18
|
+
inclusiveDays: number;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export declare function getYouTubeContentInsights(auth: OAuth2Client, input: YouTubeContentInsightsInput): Promise<{
|
|
22
|
+
status: string;
|
|
23
|
+
channel: {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
};
|
|
27
|
+
periods: {
|
|
28
|
+
current: {
|
|
29
|
+
startDate: string;
|
|
30
|
+
endDate: string;
|
|
31
|
+
inclusiveDays: number;
|
|
32
|
+
};
|
|
33
|
+
previous: {
|
|
34
|
+
startDate: string;
|
|
35
|
+
endDate: string;
|
|
36
|
+
inclusiveDays: number;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
sources: {
|
|
40
|
+
channelTotals: string;
|
|
41
|
+
sampledVideos: string;
|
|
42
|
+
videoMetadata: string;
|
|
43
|
+
};
|
|
44
|
+
channelMetrics: Record<"views" | "estimatedMinutesWatched" | "averageViewDuration" | "subscribersGained", {
|
|
45
|
+
current: number | null;
|
|
46
|
+
previous: number | null;
|
|
47
|
+
absolute: number | null;
|
|
48
|
+
percent: number | null;
|
|
49
|
+
}>;
|
|
50
|
+
sample: {
|
|
51
|
+
requestedMaxVideos: number;
|
|
52
|
+
returnedVideos: number;
|
|
53
|
+
minViewsForEligibility: number;
|
|
54
|
+
sampledVideos: {
|
|
55
|
+
metadata: {
|
|
56
|
+
title: string | null;
|
|
57
|
+
publishedAt: string | null;
|
|
58
|
+
duration: string | null;
|
|
59
|
+
} | null;
|
|
60
|
+
descriptivePer1000Views: {
|
|
61
|
+
estimatedMinutesWatched: number | null;
|
|
62
|
+
subscribersGained: number | null;
|
|
63
|
+
};
|
|
64
|
+
views: number;
|
|
65
|
+
estimatedMinutesWatched: number;
|
|
66
|
+
averageViewDuration: number;
|
|
67
|
+
subscribersGained: number;
|
|
68
|
+
videoId: string;
|
|
69
|
+
}[];
|
|
70
|
+
eligibleVideos: {
|
|
71
|
+
rankByPeriodViews: number;
|
|
72
|
+
videoId: string;
|
|
73
|
+
views: number;
|
|
74
|
+
}[];
|
|
75
|
+
};
|
|
76
|
+
limitations: string[];
|
|
77
|
+
planningHandoff: {
|
|
78
|
+
instruction: string;
|
|
79
|
+
productionRoute: string;
|
|
80
|
+
generatedStoryboard: boolean;
|
|
81
|
+
};
|
|
82
|
+
}>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { google } from '../lib/googleapis-lite.js';
|
|
2
|
+
import { friendlyGoogleError, googleErrorDetail } from '../lib/google-errors.js';
|
|
3
|
+
import { getYouTubeAnalyticsReport } from './tools.js';
|
|
4
|
+
const DAY_MS = 86_400_000;
|
|
5
|
+
const METRICS = ['views', 'estimatedMinutesWatched', 'averageViewDuration', 'subscribersGained'];
|
|
6
|
+
function apiError(error) {
|
|
7
|
+
const friendly = friendlyGoogleError(error);
|
|
8
|
+
const detail = googleErrorDetail(error);
|
|
9
|
+
return detail && !friendly.message.includes(detail)
|
|
10
|
+
? new Error(`${friendly.message}\nGoogle API: ${detail}`, { cause: error }) : friendly;
|
|
11
|
+
}
|
|
12
|
+
function parseDate(value, label) {
|
|
13
|
+
const date = /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00Z`) : null;
|
|
14
|
+
if (!date || !Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value) {
|
|
15
|
+
throw new Error(`${label} must be a valid YYYY-MM-DD date.`);
|
|
16
|
+
}
|
|
17
|
+
return date.getTime();
|
|
18
|
+
}
|
|
19
|
+
function utcDate(ms) { return new Date(ms).toISOString().slice(0, 10); }
|
|
20
|
+
export function comparisonPeriods(startDate, endDate) {
|
|
21
|
+
const start = parseDate(startDate, 'startDate');
|
|
22
|
+
const end = parseDate(endDate, 'endDate');
|
|
23
|
+
if (start > end)
|
|
24
|
+
throw new Error('startDate must be on or before endDate.');
|
|
25
|
+
const days = (end - start) / DAY_MS + 1;
|
|
26
|
+
if (days > 366)
|
|
27
|
+
throw new Error('Date range must be at most 366 days.');
|
|
28
|
+
return {
|
|
29
|
+
current: { startDate, endDate, inclusiveDays: days },
|
|
30
|
+
previous: { startDate: utcDate(start - days * DAY_MS), endDate: utcDate(start - DAY_MS), inclusiveDays: days },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function columnIndex(report, name) {
|
|
34
|
+
const index = report.columnHeaders.findIndex((header) => header.name === name);
|
|
35
|
+
if (index < 0)
|
|
36
|
+
throw new Error(`YouTube Analytics response is missing column ${name}.`);
|
|
37
|
+
return index;
|
|
38
|
+
}
|
|
39
|
+
function numberCell(row, index, name) {
|
|
40
|
+
const raw = row[index];
|
|
41
|
+
const valid = typeof raw === 'number' || (typeof raw === 'string' && /^\d+(?:\.\d+)?$/.test(raw));
|
|
42
|
+
const value = valid ? Number(raw) : Number.NaN;
|
|
43
|
+
if (!Number.isFinite(value) || value < 0 || ((name === 'views' || name === 'subscribersGained') && !Number.isInteger(value))) {
|
|
44
|
+
throw new Error(`YouTube Analytics response has invalid ${name} value.`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function rowMetrics(report, row) {
|
|
49
|
+
return Object.fromEntries(METRICS.map((name) => [name, numberCell(row, columnIndex(report, name), name)]));
|
|
50
|
+
}
|
|
51
|
+
function totalMetrics(report) {
|
|
52
|
+
if (report.rows.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
if (report.rows.length !== 1)
|
|
55
|
+
throw new Error('YouTube Analytics total report returned multiple rows.');
|
|
56
|
+
return rowMetrics(report, report.rows[0]);
|
|
57
|
+
}
|
|
58
|
+
function metricDelta(current, previous) {
|
|
59
|
+
const absolute = current === null || previous === null ? null : current - previous;
|
|
60
|
+
return { current, previous, absolute,
|
|
61
|
+
percent: absolute === null || previous === null || previous === 0 ? null : (absolute / previous) * 100 };
|
|
62
|
+
}
|
|
63
|
+
function channelComparison(current, previous) {
|
|
64
|
+
return Object.fromEntries(METRICS.map((name) => [name, metricDelta(current?.[name] ?? null, previous?.[name] ?? null)]));
|
|
65
|
+
}
|
|
66
|
+
export async function getYouTubeContentInsights(auth, input) {
|
|
67
|
+
if (!input.expectedChannelId)
|
|
68
|
+
throw new Error('expectedChannelId is required for content insights.');
|
|
69
|
+
const periods = comparisonPeriods(input.startDate, input.endDate);
|
|
70
|
+
const maxVideos = input.maxVideos ?? 10;
|
|
71
|
+
const minViews = input.minViews ?? 100;
|
|
72
|
+
if (!Number.isInteger(maxVideos) || maxVideos < 1 || maxVideos > 50)
|
|
73
|
+
throw new Error('maxVideos must be 1–50.');
|
|
74
|
+
if (!Number.isInteger(minViews) || minViews < 0 || minViews > 1_000_000_000_000) {
|
|
75
|
+
throw new Error('minViews must be an integer from 0 to 1,000,000,000,000.');
|
|
76
|
+
}
|
|
77
|
+
const base = { expectedChannelId: input.expectedChannelId };
|
|
78
|
+
const current = await getYouTubeAnalyticsReport(auth, { ...base, ...periods.current, dimension: 'total' });
|
|
79
|
+
const previous = await getYouTubeAnalyticsReport(auth, { ...base, ...periods.previous, dimension: 'total' });
|
|
80
|
+
const top = await getYouTubeAnalyticsReport(auth, { ...base, ...periods.current,
|
|
81
|
+
dimension: 'video', maxResults: maxVideos });
|
|
82
|
+
if (current.channel.id !== input.expectedChannelId || previous.channel.id !== input.expectedChannelId ||
|
|
83
|
+
top.channel.id !== input.expectedChannelId)
|
|
84
|
+
throw new Error('YouTube Analytics channel identity changed during report collection.');
|
|
85
|
+
const currentTotals = totalMetrics(current);
|
|
86
|
+
const previousTotals = totalMetrics(previous);
|
|
87
|
+
const sample = top.rows.map((row) => {
|
|
88
|
+
const cells = row;
|
|
89
|
+
const id = cells[columnIndex(top, 'video')];
|
|
90
|
+
if (typeof id !== 'string' || !id.trim())
|
|
91
|
+
throw new Error('YouTube Analytics response has an invalid video ID.');
|
|
92
|
+
return { videoId: id, ...rowMetrics(top, cells) };
|
|
93
|
+
});
|
|
94
|
+
if (new Set(sample.map((video) => video.videoId)).size !== sample.length) {
|
|
95
|
+
throw new Error('YouTube Analytics response contains duplicate video IDs.');
|
|
96
|
+
}
|
|
97
|
+
let metadata = new Map();
|
|
98
|
+
if (sample.length) {
|
|
99
|
+
try {
|
|
100
|
+
const result = await google.youtube({ version: 'v3', auth }).videos.list({
|
|
101
|
+
part: ['snippet', 'contentDetails'], id: sample.map((video) => video.videoId), maxResults: 50,
|
|
102
|
+
});
|
|
103
|
+
metadata = new Map((result.data.items ?? []).filter((video) => !!video.id).map((video) => {
|
|
104
|
+
if (video.snippet?.channelId !== input.expectedChannelId) {
|
|
105
|
+
throw new Error('Sampled video metadata does not belong to the authenticated channel.');
|
|
106
|
+
}
|
|
107
|
+
return [video.id, { title: video.snippet.title ?? null,
|
|
108
|
+
publishedAt: video.snippet.publishedAt ?? null, duration: video.contentDetails?.duration ?? null }];
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
throw apiError(error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const sampledVideos = sample.map((video) => ({
|
|
116
|
+
...video,
|
|
117
|
+
metadata: metadata.get(video.videoId) ?? null,
|
|
118
|
+
descriptivePer1000Views: {
|
|
119
|
+
estimatedMinutesWatched: video.views > 0 ? video.estimatedMinutesWatched / video.views * 1000 : null,
|
|
120
|
+
subscribersGained: video.views > 0 ? video.subscribersGained / video.views * 1000 : null,
|
|
121
|
+
},
|
|
122
|
+
}));
|
|
123
|
+
const eligibleVideos = currentTotals === null || currentTotals.views === 0 ? [] : sampledVideos
|
|
124
|
+
.filter((video) => video.metadata !== null && video.views > 0 && video.views >= minViews)
|
|
125
|
+
.sort((a, b) => b.views - a.views)
|
|
126
|
+
.map((video, index) => ({ rankByPeriodViews: index + 1, videoId: video.videoId, views: video.views }));
|
|
127
|
+
const status = currentTotals === null || currentTotals.views === 0 || sample.length === 0 ? 'insufficient_data'
|
|
128
|
+
: previousTotals === null || eligibleVideos.length === 0 ? 'partial_data' : 'ready';
|
|
129
|
+
return {
|
|
130
|
+
status, channel: current.channel, periods,
|
|
131
|
+
sources: { channelTotals: 'YouTube Analytics API v2 (dimension=total)',
|
|
132
|
+
sampledVideos: 'YouTube Analytics API v2 (dimension=video, sort=-views)',
|
|
133
|
+
videoMetadata: 'YouTube Data API v3 videos.list' },
|
|
134
|
+
channelMetrics: channelComparison(currentTotals, previousTotals),
|
|
135
|
+
sample: { requestedMaxVideos: maxVideos, returnedVideos: sampledVideos.length,
|
|
136
|
+
minViewsForEligibility: minViews, sampledVideos, eligibleVideos },
|
|
137
|
+
limitations: [
|
|
138
|
+
'Top-video rows are a bounded sample ranked by views, not all channel videos or all recent uploads.',
|
|
139
|
+
'YouTube Analytics may omit recent days until requested metrics are available; periods are requested dates, not guaranteed complete coverage.',
|
|
140
|
+
'Video ages differ; period views do not make publishing strategies causally comparable.',
|
|
141
|
+
'Per-video subscribersGained counts gains on that video watch page; do not sum sampled values as the channel total.',
|
|
142
|
+
'Average view duration is seconds, not retention or CTR. No CTR or retention claim is inferred.',
|
|
143
|
+
'Rates per 1,000 views are descriptive ratios only. Missing or zero denominators produce null, not zero.',
|
|
144
|
+
...(previousTotals === null ? ['Previous-period totals were unavailable; comparison deltas are null.'] : []),
|
|
145
|
+
...(metadata.size < sample.length ? ['Some sampled video metadata was unavailable; those videos are excluded from eligibility.'] : []),
|
|
146
|
+
],
|
|
147
|
+
planningHandoff: {
|
|
148
|
+
instruction: status === 'insufficient_data'
|
|
149
|
+
? 'Gather more complete Analytics data before choosing a direction; do not declare a winning format.'
|
|
150
|
+
: 'Choose one testable next-video experiment citing the video IDs and observed metrics, then save an agent-authored plan with video_save_plan. Do not claim the observed metrics caused performance.',
|
|
151
|
+
productionRoute: 'Runway web is the primary generation route; Grok web subscription is the fallback. Avoid API spend unless separately authorized.',
|
|
152
|
+
generatedStoryboard: false,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|