@crowdlisten/harness 1.0.0
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/AGENTS.md +167 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/agent-proxy.d.ts +24 -0
- package/dist/agent-proxy.js +140 -0
- package/dist/agent-tools.d.ts +736 -0
- package/dist/agent-tools.js +409 -0
- package/dist/context/api.d.ts +5 -0
- package/dist/context/api.js +164 -0
- package/dist/context/cli.d.ts +19 -0
- package/dist/context/cli.js +108 -0
- package/dist/context/extractor.d.ts +12 -0
- package/dist/context/extractor.js +43 -0
- package/dist/context/index.d.ts +12 -0
- package/dist/context/index.js +11 -0
- package/dist/context/matcher.d.ts +39 -0
- package/dist/context/matcher.js +246 -0
- package/dist/context/parser.d.ts +28 -0
- package/dist/context/parser.js +157 -0
- package/dist/context/pipeline.d.ts +26 -0
- package/dist/context/pipeline.js +56 -0
- package/dist/context/prompts.d.ts +6 -0
- package/dist/context/prompts.js +60 -0
- package/dist/context/providers.d.ts +6 -0
- package/dist/context/providers.js +106 -0
- package/dist/context/redactor.d.ts +10 -0
- package/dist/context/redactor.js +68 -0
- package/dist/context/server.d.ts +5 -0
- package/dist/context/server.js +134 -0
- package/dist/context/store.d.ts +12 -0
- package/dist/context/store.js +82 -0
- package/dist/context/types.d.ts +79 -0
- package/dist/context/types.js +4 -0
- package/dist/context/user-state.d.ts +40 -0
- package/dist/context/user-state.js +144 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +385 -0
- package/dist/insights/browser/BrowserPool.d.ts +87 -0
- package/dist/insights/browser/BrowserPool.js +266 -0
- package/dist/insights/browser/RequestInterceptor.d.ts +46 -0
- package/dist/insights/browser/RequestInterceptor.js +115 -0
- package/dist/insights/cli.d.ts +8 -0
- package/dist/insights/cli.js +206 -0
- package/dist/insights/core/base/BaseAdapter.d.ts +37 -0
- package/dist/insights/core/base/BaseAdapter.js +123 -0
- package/dist/insights/core/health/HealthMonitor.d.ts +75 -0
- package/dist/insights/core/health/HealthMonitor.js +171 -0
- package/dist/insights/core/interfaces/SocialMediaPlatform.d.ts +125 -0
- package/dist/insights/core/interfaces/SocialMediaPlatform.js +42 -0
- package/dist/insights/core/utils/DataNormalizer.d.ts +53 -0
- package/dist/insights/core/utils/DataNormalizer.js +349 -0
- package/dist/insights/core/utils/InstagramUrlUtils.d.ts +11 -0
- package/dist/insights/core/utils/InstagramUrlUtils.js +60 -0
- package/dist/insights/core/utils/TikTokUrlUtils.d.ts +10 -0
- package/dist/insights/core/utils/TikTokUrlUtils.js +57 -0
- package/dist/insights/handlers.d.ts +157 -0
- package/dist/insights/handlers.js +246 -0
- package/dist/insights/index.d.ts +437 -0
- package/dist/insights/index.js +426 -0
- package/dist/insights/platforms/instagram/InstagramAdapter.d.ts +34 -0
- package/dist/insights/platforms/instagram/InstagramAdapter.js +342 -0
- package/dist/insights/platforms/moltbook/MoltbookAdapter.d.ts +31 -0
- package/dist/insights/platforms/moltbook/MoltbookAdapter.js +227 -0
- package/dist/insights/platforms/reddit/RedditAdapter.d.ts +21 -0
- package/dist/insights/platforms/reddit/RedditAdapter.js +212 -0
- package/dist/insights/platforms/tiktok/TikTokAdapter.d.ts +34 -0
- package/dist/insights/platforms/tiktok/TikTokAdapter.js +269 -0
- package/dist/insights/platforms/twitter/TwitterAdapter.d.ts +23 -0
- package/dist/insights/platforms/twitter/TwitterAdapter.js +211 -0
- package/dist/insights/platforms/xiaohongshu/XiaohongshuAdapter.d.ts +35 -0
- package/dist/insights/platforms/xiaohongshu/XiaohongshuAdapter.js +258 -0
- package/dist/insights/platforms/youtube/YouTubeAdapter.d.ts +22 -0
- package/dist/insights/platforms/youtube/YouTubeAdapter.js +254 -0
- package/dist/insights/service-config.d.ts +7 -0
- package/dist/insights/service-config.js +60 -0
- package/dist/insights/services/UnifiedSocialMediaService.d.ts +94 -0
- package/dist/insights/services/UnifiedSocialMediaService.js +259 -0
- package/dist/insights/vision/VisionExtractor.d.ts +46 -0
- package/dist/insights/vision/VisionExtractor.js +236 -0
- package/dist/learnings.d.ts +50 -0
- package/dist/learnings.js +130 -0
- package/dist/openapi.d.ts +29 -0
- package/dist/openapi.js +169 -0
- package/dist/server-factory.d.ts +20 -0
- package/dist/server-factory.js +41 -0
- package/dist/suggestions.d.ts +16 -0
- package/dist/suggestions.js +72 -0
- package/dist/telemetry.d.ts +44 -0
- package/dist/telemetry.js +93 -0
- package/dist/tools/registry.d.ts +65 -0
- package/dist/tools/registry.js +256 -0
- package/dist/tools.d.ts +2433 -0
- package/dist/tools.js +2294 -0
- package/dist/transport/http.d.ts +15 -0
- package/dist/transport/http.js +154 -0
- package/package.json +76 -0
- package/skills/catalog.json +272 -0
- package/skills/community-catalog.json +4202 -0
- package/skills/competitive-analysis/SKILL.md +174 -0
- package/skills/content-creator/SKILL.md +256 -0
- package/skills/content-strategy/SKILL.md +222 -0
- package/skills/data-storytelling/SKILL.md +248 -0
- package/skills/heuristic-evaluation/SKILL.md +201 -0
- package/skills/market-research-reports/SKILL.md +184 -0
- package/skills/user-stories/SKILL.md +178 -0
- package/skills/ux-researcher/SKILL.md +239 -0
- package/web-dist/assets/index-B1b25lNd.css +1 -0
- package/web-dist/assets/index-CDWHwHbl.js +64 -0
- package/web-dist/index.html +16 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube Platform Adapter
|
|
3
|
+
* Uses YouTube Data API v3 for video search, trending, comments, and channel content
|
|
4
|
+
*/
|
|
5
|
+
import { BaseAdapter } from '../../core/base/BaseAdapter.js';
|
|
6
|
+
import { DataNormalizer } from '../../core/utils/DataNormalizer.js';
|
|
7
|
+
import { NotFoundError } from '../../core/interfaces/SocialMediaPlatform.js';
|
|
8
|
+
import axios from 'axios';
|
|
9
|
+
export class YouTubeAdapter extends BaseAdapter {
|
|
10
|
+
client = null;
|
|
11
|
+
apiKey = '';
|
|
12
|
+
constructor(config) {
|
|
13
|
+
super(config);
|
|
14
|
+
// YouTube API quota is 10,000 units/day; keep requests conservative
|
|
15
|
+
this.maxRequestsPerWindow = 20;
|
|
16
|
+
}
|
|
17
|
+
async initialize() {
|
|
18
|
+
try {
|
|
19
|
+
this.apiKey = this.config.credentials?.apiKey || process.env.YOUTUBE_API_KEY || '';
|
|
20
|
+
if (!this.apiKey) {
|
|
21
|
+
this.log('No YouTube API key provided', 'warn');
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
this.client = axios.create({
|
|
25
|
+
baseURL: 'https://www.googleapis.com/youtube/v3',
|
|
26
|
+
timeout: 15000
|
|
27
|
+
});
|
|
28
|
+
this.isInitialized = true;
|
|
29
|
+
this.log('YouTube adapter initialized successfully');
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
this.log('Failed to initialize YouTube adapter', 'error');
|
|
34
|
+
this.isInitialized = false;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async getTrendingContent(limit = 10) {
|
|
39
|
+
this.ensureInitialized();
|
|
40
|
+
this.validateLimit(limit);
|
|
41
|
+
try {
|
|
42
|
+
await this.enforceRateLimit();
|
|
43
|
+
const response = await this.client.get('/videos', {
|
|
44
|
+
params: {
|
|
45
|
+
part: 'snippet,statistics',
|
|
46
|
+
chart: 'mostPopular',
|
|
47
|
+
regionCode: 'US',
|
|
48
|
+
maxResults: Math.min(limit, 50),
|
|
49
|
+
key: this.apiKey
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const posts = [];
|
|
53
|
+
for (const item of (response.data.items || []).slice(0, limit)) {
|
|
54
|
+
posts.push(DataNormalizer.normalizePost(item, 'youtube'));
|
|
55
|
+
}
|
|
56
|
+
this.log(`Retrieved ${posts.length} trending YouTube videos`);
|
|
57
|
+
return posts;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
this.handleError(error, 'getTrendingContent');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async getUserContent(userId, limit = 10) {
|
|
64
|
+
this.ensureInitialized();
|
|
65
|
+
this.validateUserId(userId);
|
|
66
|
+
this.validateLimit(limit);
|
|
67
|
+
try {
|
|
68
|
+
await this.enforceRateLimit();
|
|
69
|
+
// First resolve the channel ID — userId could be a channel ID or a handle
|
|
70
|
+
let channelId = userId;
|
|
71
|
+
if (!userId.startsWith('UC')) {
|
|
72
|
+
const handle = userId.startsWith('@') ? userId : `@${userId}`;
|
|
73
|
+
const channelRes = await this.client.get('/channels', {
|
|
74
|
+
params: {
|
|
75
|
+
part: 'id',
|
|
76
|
+
forHandle: handle,
|
|
77
|
+
key: this.apiKey
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
channelId = channelRes.data.items?.[0]?.id;
|
|
81
|
+
if (!channelId) {
|
|
82
|
+
throw new NotFoundError('youtube', `Channel ${userId}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
await this.enforceRateLimit();
|
|
86
|
+
// Search for videos on this channel
|
|
87
|
+
const response = await this.client.get('/search', {
|
|
88
|
+
params: {
|
|
89
|
+
part: 'snippet',
|
|
90
|
+
channelId,
|
|
91
|
+
type: 'video',
|
|
92
|
+
order: 'date',
|
|
93
|
+
maxResults: Math.min(limit, 50),
|
|
94
|
+
key: this.apiKey
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// Get video statistics in a separate call
|
|
98
|
+
const videoIds = (response.data.items || [])
|
|
99
|
+
.map((item) => item.id?.videoId)
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.join(',');
|
|
102
|
+
let statsMap = {};
|
|
103
|
+
if (videoIds) {
|
|
104
|
+
await this.enforceRateLimit();
|
|
105
|
+
const statsRes = await this.client.get('/videos', {
|
|
106
|
+
params: {
|
|
107
|
+
part: 'statistics',
|
|
108
|
+
id: videoIds,
|
|
109
|
+
key: this.apiKey
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
for (const v of statsRes.data.items || []) {
|
|
113
|
+
statsMap[v.id] = v.statistics;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const posts = [];
|
|
117
|
+
for (const item of (response.data.items || []).slice(0, limit)) {
|
|
118
|
+
const videoId = item.id?.videoId;
|
|
119
|
+
const merged = {
|
|
120
|
+
id: videoId,
|
|
121
|
+
snippet: item.snippet,
|
|
122
|
+
statistics: statsMap[videoId] || {}
|
|
123
|
+
};
|
|
124
|
+
posts.push(DataNormalizer.normalizePost(merged, 'youtube'));
|
|
125
|
+
}
|
|
126
|
+
this.log(`Retrieved ${posts.length} videos from YouTube channel ${userId}`);
|
|
127
|
+
return posts;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error.response?.status === 404) {
|
|
131
|
+
throw new NotFoundError('youtube', `Channel ${userId}`, error);
|
|
132
|
+
}
|
|
133
|
+
this.handleError(error, 'getUserContent');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async searchContent(query, limit = 10) {
|
|
137
|
+
this.ensureInitialized();
|
|
138
|
+
this.validateLimit(limit);
|
|
139
|
+
if (!query || query.trim().length === 0) {
|
|
140
|
+
throw new Error('Search query cannot be empty');
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await this.enforceRateLimit();
|
|
144
|
+
const response = await this.client.get('/search', {
|
|
145
|
+
params: {
|
|
146
|
+
part: 'snippet',
|
|
147
|
+
q: query.trim(),
|
|
148
|
+
type: 'video',
|
|
149
|
+
order: 'relevance',
|
|
150
|
+
maxResults: Math.min(limit, 50),
|
|
151
|
+
key: this.apiKey
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
// Get video statistics
|
|
155
|
+
const videoIds = (response.data.items || [])
|
|
156
|
+
.map((item) => item.id?.videoId)
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.join(',');
|
|
159
|
+
let statsMap = {};
|
|
160
|
+
if (videoIds) {
|
|
161
|
+
await this.enforceRateLimit();
|
|
162
|
+
const statsRes = await this.client.get('/videos', {
|
|
163
|
+
params: {
|
|
164
|
+
part: 'statistics',
|
|
165
|
+
id: videoIds,
|
|
166
|
+
key: this.apiKey
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
for (const v of statsRes.data.items || []) {
|
|
170
|
+
statsMap[v.id] = v.statistics;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const posts = [];
|
|
174
|
+
for (const item of (response.data.items || []).slice(0, limit)) {
|
|
175
|
+
const videoId = item.id?.videoId;
|
|
176
|
+
const merged = {
|
|
177
|
+
id: videoId,
|
|
178
|
+
snippet: item.snippet,
|
|
179
|
+
statistics: statsMap[videoId] || {}
|
|
180
|
+
};
|
|
181
|
+
posts.push(DataNormalizer.normalizePost(merged, 'youtube'));
|
|
182
|
+
}
|
|
183
|
+
this.log(`Found ${posts.length} YouTube videos for query: ${query}`);
|
|
184
|
+
return posts;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
this.handleError(error, 'searchContent');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async getContentComments(contentId, limit = 20) {
|
|
191
|
+
this.ensureInitialized();
|
|
192
|
+
this.validateContentId(contentId);
|
|
193
|
+
this.validateLimit(limit);
|
|
194
|
+
try {
|
|
195
|
+
await this.enforceRateLimit();
|
|
196
|
+
const response = await this.client.get('/commentThreads', {
|
|
197
|
+
params: {
|
|
198
|
+
part: 'snippet,replies',
|
|
199
|
+
videoId: contentId,
|
|
200
|
+
maxResults: Math.min(limit, 100),
|
|
201
|
+
order: 'relevance',
|
|
202
|
+
key: this.apiKey
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
const comments = [];
|
|
206
|
+
for (const item of (response.data.items || []).slice(0, limit)) {
|
|
207
|
+
comments.push(DataNormalizer.normalizeComment(item, 'youtube'));
|
|
208
|
+
}
|
|
209
|
+
this.log(`Retrieved ${comments.length} comments for YouTube video ${contentId}`);
|
|
210
|
+
return comments;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error.response?.status === 403 &&
|
|
214
|
+
error.response?.data?.error?.errors?.[0]?.reason === 'commentsDisabled') {
|
|
215
|
+
this.log(`Comments are disabled for video ${contentId}`, 'warn');
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
this.handleError(error, 'getContentComments');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
getPlatformName() {
|
|
222
|
+
return 'youtube';
|
|
223
|
+
}
|
|
224
|
+
getSupportedFeatures() {
|
|
225
|
+
return {
|
|
226
|
+
supportsTrending: true,
|
|
227
|
+
supportsUserContent: true,
|
|
228
|
+
supportsSearch: true,
|
|
229
|
+
supportsComments: true,
|
|
230
|
+
supportsAnalysis: true
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
isRateLimitError(error) {
|
|
234
|
+
return error.response?.status === 429 ||
|
|
235
|
+
error.response?.data?.error?.errors?.[0]?.reason === 'quotaExceeded' ||
|
|
236
|
+
error.response?.data?.error?.errors?.[0]?.reason === 'rateLimitExceeded';
|
|
237
|
+
}
|
|
238
|
+
isAuthError(error) {
|
|
239
|
+
return error.response?.status === 401 ||
|
|
240
|
+
error.response?.status === 403;
|
|
241
|
+
}
|
|
242
|
+
isNotFoundError(error) {
|
|
243
|
+
return error.response?.status === 404;
|
|
244
|
+
}
|
|
245
|
+
async cleanup() {
|
|
246
|
+
try {
|
|
247
|
+
this.client = null;
|
|
248
|
+
await super.cleanup();
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
this.log('Error during YouTube cleanup', 'warn');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CrowdListen Service Configuration
|
|
3
|
+
* Shared service factory used by MCP and CLI entry points.
|
|
4
|
+
*/
|
|
5
|
+
import { UnifiedSocialMediaService, UnifiedServiceConfig } from './services/UnifiedSocialMediaService.js';
|
|
6
|
+
export declare function createServiceConfig(): UnifiedServiceConfig;
|
|
7
|
+
export declare function createService(): UnifiedSocialMediaService;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CrowdListen Service Configuration
|
|
3
|
+
* Shared service factory used by MCP and CLI entry points.
|
|
4
|
+
*/
|
|
5
|
+
import * as dotenv from 'dotenv';
|
|
6
|
+
import { UnifiedSocialMediaService } from './services/UnifiedSocialMediaService.js';
|
|
7
|
+
dotenv.config();
|
|
8
|
+
export function createServiceConfig() {
|
|
9
|
+
const config = {
|
|
10
|
+
platforms: {},
|
|
11
|
+
globalOptions: {
|
|
12
|
+
timeout: 30000,
|
|
13
|
+
retries: 3,
|
|
14
|
+
fallbackStrategy: 'continue',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
// Twitter — uses twitter-scraper, needs TWITTER_USERNAME + TWITTER_PASSWORD
|
|
18
|
+
config.platforms.twitter = {
|
|
19
|
+
platform: 'twitter',
|
|
20
|
+
credentials: {},
|
|
21
|
+
};
|
|
22
|
+
// TikTok — browser adapter
|
|
23
|
+
config.platforms.tiktok = {
|
|
24
|
+
platform: 'tiktok',
|
|
25
|
+
credentials: {},
|
|
26
|
+
};
|
|
27
|
+
// Instagram — browser adapter
|
|
28
|
+
config.platforms.instagram = {
|
|
29
|
+
platform: 'instagram',
|
|
30
|
+
credentials: {},
|
|
31
|
+
};
|
|
32
|
+
// Xiaohongshu — browser adapter
|
|
33
|
+
config.platforms.xiaohongshu = {
|
|
34
|
+
platform: 'xiaohongshu',
|
|
35
|
+
credentials: {},
|
|
36
|
+
};
|
|
37
|
+
// Reddit (no credentials needed for public content)
|
|
38
|
+
config.platforms.reddit = {
|
|
39
|
+
platform: 'reddit',
|
|
40
|
+
credentials: {},
|
|
41
|
+
};
|
|
42
|
+
// YouTube
|
|
43
|
+
if (process.env.YOUTUBE_API_KEY) {
|
|
44
|
+
config.platforms.youtube = {
|
|
45
|
+
platform: 'youtube',
|
|
46
|
+
credentials: { apiKey: process.env.YOUTUBE_API_KEY },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Moltbook
|
|
50
|
+
if (process.env.MOLTBOOK_API_KEY) {
|
|
51
|
+
config.platforms.moltbook = {
|
|
52
|
+
platform: 'moltbook',
|
|
53
|
+
credentials: { apiKey: process.env.MOLTBOOK_API_KEY },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return config;
|
|
57
|
+
}
|
|
58
|
+
export function createService() {
|
|
59
|
+
return new UnifiedSocialMediaService(createServiceConfig());
|
|
60
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Social Media Service
|
|
3
|
+
* Coordinates all platform adapters and provides a single interface.
|
|
4
|
+
* Each platform has one adapter — no visual vs legacy distinction.
|
|
5
|
+
*/
|
|
6
|
+
import { PlatformType, PlatformConfig, Post, Comment } from '../core/interfaces/SocialMediaPlatform.js';
|
|
7
|
+
export interface UnifiedServiceConfig {
|
|
8
|
+
platforms: {
|
|
9
|
+
tiktok?: PlatformConfig;
|
|
10
|
+
twitter?: PlatformConfig;
|
|
11
|
+
reddit?: PlatformConfig;
|
|
12
|
+
instagram?: PlatformConfig;
|
|
13
|
+
youtube?: PlatformConfig;
|
|
14
|
+
moltbook?: PlatformConfig;
|
|
15
|
+
xiaohongshu?: PlatformConfig;
|
|
16
|
+
};
|
|
17
|
+
globalOptions?: {
|
|
18
|
+
timeout?: number;
|
|
19
|
+
retries?: number;
|
|
20
|
+
fallbackStrategy?: 'fail' | 'continue' | 'mock';
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare class UnifiedSocialMediaService {
|
|
24
|
+
private adapters;
|
|
25
|
+
private config;
|
|
26
|
+
private isInitialized;
|
|
27
|
+
constructor(config: UnifiedServiceConfig);
|
|
28
|
+
/**
|
|
29
|
+
* Initialize all configured platform adapters.
|
|
30
|
+
* One adapter per platform — flat, no tiers.
|
|
31
|
+
*/
|
|
32
|
+
initialize(): Promise<{
|
|
33
|
+
[key in PlatformType]?: boolean;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Get trending content from a specific platform
|
|
37
|
+
*/
|
|
38
|
+
getTrendingContent(platform: PlatformType, limit?: number): Promise<Post[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Get trending content from all available platforms
|
|
41
|
+
*/
|
|
42
|
+
getAllTrendingContent(limit?: number): Promise<{
|
|
43
|
+
[key in PlatformType]?: Post[];
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* Get user content from a specific platform
|
|
47
|
+
*/
|
|
48
|
+
getUserContent(platform: PlatformType, userId: string, limit?: number): Promise<Post[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Search content on a specific platform
|
|
51
|
+
*/
|
|
52
|
+
searchContent(platform: PlatformType, query: string, limit?: number): Promise<Post[]>;
|
|
53
|
+
/**
|
|
54
|
+
* Search content across all available platforms
|
|
55
|
+
*/
|
|
56
|
+
searchAllPlatforms(query: string, limit?: number): Promise<{
|
|
57
|
+
[key in PlatformType]?: Post[];
|
|
58
|
+
}>;
|
|
59
|
+
/**
|
|
60
|
+
* Get comments for content on a specific platform
|
|
61
|
+
*/
|
|
62
|
+
getContentComments(platform: PlatformType, contentId: string, limit?: number): Promise<Comment[]>;
|
|
63
|
+
/**
|
|
64
|
+
* Get the list of initialized platform types.
|
|
65
|
+
* Used by HealthMonitor to know which platforms to probe.
|
|
66
|
+
*/
|
|
67
|
+
getInitializedPlatforms(): PlatformType[];
|
|
68
|
+
/**
|
|
69
|
+
* Get available platforms and their capabilities
|
|
70
|
+
*/
|
|
71
|
+
getAvailablePlatforms(): {
|
|
72
|
+
[key in PlatformType]?: any;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Get combined trending content with platform attribution
|
|
76
|
+
*/
|
|
77
|
+
getCombinedTrendingContent(limit?: number): Promise<Post[]>;
|
|
78
|
+
/**
|
|
79
|
+
* Search across all platforms and return combined results
|
|
80
|
+
*/
|
|
81
|
+
getCombinedSearchResults(query: string, limit?: number): Promise<Post[]>;
|
|
82
|
+
/**
|
|
83
|
+
* Platform health check
|
|
84
|
+
*/
|
|
85
|
+
healthCheck(): Promise<{
|
|
86
|
+
[key in PlatformType]?: 'healthy' | 'degraded' | 'down';
|
|
87
|
+
}>;
|
|
88
|
+
/**
|
|
89
|
+
* Cleanup all adapters and browser pool
|
|
90
|
+
*/
|
|
91
|
+
cleanup(): Promise<void>;
|
|
92
|
+
private getAdapter;
|
|
93
|
+
private calculateRelevance;
|
|
94
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Social Media Service
|
|
3
|
+
* Coordinates all platform adapters and provides a single interface.
|
|
4
|
+
* Each platform has one adapter — no visual vs legacy distinction.
|
|
5
|
+
*/
|
|
6
|
+
import { SocialMediaError } from '../core/interfaces/SocialMediaPlatform.js';
|
|
7
|
+
import { TwitterAdapter } from '../platforms/twitter/TwitterAdapter.js';
|
|
8
|
+
import { RedditAdapter } from '../platforms/reddit/RedditAdapter.js';
|
|
9
|
+
import { YouTubeAdapter } from '../platforms/youtube/YouTubeAdapter.js';
|
|
10
|
+
import { MoltbookAdapter } from '../platforms/moltbook/MoltbookAdapter.js';
|
|
11
|
+
import { TikTokAdapter } from '../platforms/tiktok/TikTokAdapter.js';
|
|
12
|
+
import { InstagramAdapter } from '../platforms/instagram/InstagramAdapter.js';
|
|
13
|
+
import { XiaohongshuAdapter } from '../platforms/xiaohongshu/XiaohongshuAdapter.js';
|
|
14
|
+
import { getBrowserPool } from '../browser/BrowserPool.js';
|
|
15
|
+
export class UnifiedSocialMediaService {
|
|
16
|
+
adapters = new Map();
|
|
17
|
+
config;
|
|
18
|
+
isInitialized = false;
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Initialize all configured platform adapters.
|
|
24
|
+
* One adapter per platform — flat, no tiers.
|
|
25
|
+
*/
|
|
26
|
+
async initialize() {
|
|
27
|
+
const results = {};
|
|
28
|
+
const adapterMap = [
|
|
29
|
+
{ platform: 'twitter', create: () => new TwitterAdapter(this.config.platforms.twitter) },
|
|
30
|
+
{ platform: 'tiktok', create: () => new TikTokAdapter(this.config.platforms.tiktok) },
|
|
31
|
+
{ platform: 'instagram', create: () => new InstagramAdapter(this.config.platforms.instagram) },
|
|
32
|
+
{ platform: 'xiaohongshu', create: () => new XiaohongshuAdapter(this.config.platforms.xiaohongshu) },
|
|
33
|
+
{ platform: 'reddit', create: () => new RedditAdapter(this.config.platforms.reddit) },
|
|
34
|
+
{ platform: 'youtube', create: () => new YouTubeAdapter(this.config.platforms.youtube) },
|
|
35
|
+
{ platform: 'moltbook', create: () => new MoltbookAdapter(this.config.platforms.moltbook) },
|
|
36
|
+
];
|
|
37
|
+
for (const { platform, create } of adapterMap) {
|
|
38
|
+
if (!this.config.platforms[platform])
|
|
39
|
+
continue;
|
|
40
|
+
try {
|
|
41
|
+
const adapter = create();
|
|
42
|
+
const success = await adapter.initialize();
|
|
43
|
+
if (success) {
|
|
44
|
+
this.adapters.set(platform, adapter);
|
|
45
|
+
}
|
|
46
|
+
results[platform] = success;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error(`Failed to initialize ${platform} adapter:`, error);
|
|
50
|
+
results[platform] = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
this.isInitialized = true;
|
|
54
|
+
console.log(`Unified Service initialized with ${this.adapters.size} platforms:`, Array.from(this.adapters.keys()));
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get trending content from a specific platform
|
|
59
|
+
*/
|
|
60
|
+
async getTrendingContent(platform, limit) {
|
|
61
|
+
const adapter = this.getAdapter(platform);
|
|
62
|
+
return await adapter.getTrendingContent(limit);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Get trending content from all available platforms
|
|
66
|
+
*/
|
|
67
|
+
async getAllTrendingContent(limit) {
|
|
68
|
+
const results = {};
|
|
69
|
+
const limitPerPlatform = limit ? Math.ceil(limit / this.adapters.size) : 10;
|
|
70
|
+
const promises = Array.from(this.adapters.entries()).map(async ([platform, adapter]) => {
|
|
71
|
+
try {
|
|
72
|
+
const posts = await adapter.getTrendingContent(limitPerPlatform);
|
|
73
|
+
results[platform] = posts;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
console.error(`Failed to get trending content from ${platform}:`, error);
|
|
77
|
+
if (this.config.globalOptions?.fallbackStrategy === 'continue') {
|
|
78
|
+
results[platform] = [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
await Promise.allSettled(promises);
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Get user content from a specific platform
|
|
87
|
+
*/
|
|
88
|
+
async getUserContent(platform, userId, limit) {
|
|
89
|
+
const adapter = this.getAdapter(platform);
|
|
90
|
+
return await adapter.getUserContent(userId, limit);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Search content on a specific platform
|
|
94
|
+
*/
|
|
95
|
+
async searchContent(platform, query, limit) {
|
|
96
|
+
const adapter = this.getAdapter(platform);
|
|
97
|
+
return await adapter.searchContent(query, limit);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Search content across all available platforms
|
|
101
|
+
*/
|
|
102
|
+
async searchAllPlatforms(query, limit) {
|
|
103
|
+
const results = {};
|
|
104
|
+
const limitPerPlatform = limit ? Math.ceil(limit / this.adapters.size) : 10;
|
|
105
|
+
const promises = Array.from(this.adapters.entries()).map(async ([platform, adapter]) => {
|
|
106
|
+
try {
|
|
107
|
+
const posts = await adapter.searchContent(query, limitPerPlatform);
|
|
108
|
+
results[platform] = posts;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
console.error(`Failed to search ${platform} for "${query}":`, error);
|
|
112
|
+
if (this.config.globalOptions?.fallbackStrategy === 'continue') {
|
|
113
|
+
results[platform] = [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
await Promise.allSettled(promises);
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Get comments for content on a specific platform
|
|
122
|
+
*/
|
|
123
|
+
async getContentComments(platform, contentId, limit) {
|
|
124
|
+
const adapter = this.getAdapter(platform);
|
|
125
|
+
return await adapter.getContentComments(contentId, limit);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Get the list of initialized platform types.
|
|
129
|
+
* Used by HealthMonitor to know which platforms to probe.
|
|
130
|
+
*/
|
|
131
|
+
getInitializedPlatforms() {
|
|
132
|
+
return Array.from(this.adapters.keys());
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get available platforms and their capabilities
|
|
136
|
+
*/
|
|
137
|
+
getAvailablePlatforms() {
|
|
138
|
+
const platforms = {};
|
|
139
|
+
for (const [platform, adapter] of this.adapters) {
|
|
140
|
+
platforms[platform] = {
|
|
141
|
+
name: platform,
|
|
142
|
+
capabilities: adapter.getSupportedFeatures(),
|
|
143
|
+
initialized: true
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return platforms;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Get combined trending content with platform attribution
|
|
150
|
+
*/
|
|
151
|
+
async getCombinedTrendingContent(limit = 30) {
|
|
152
|
+
const allTrending = await this.getAllTrendingContent(limit);
|
|
153
|
+
const combinedPosts = [];
|
|
154
|
+
for (const [, posts] of Object.entries(allTrending)) {
|
|
155
|
+
if (posts && Array.isArray(posts)) {
|
|
156
|
+
combinedPosts.push(...posts);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
combinedPosts.sort((a, b) => {
|
|
160
|
+
const aEngagement = (a.engagement.likes || 0) + (a.engagement.comments || 0);
|
|
161
|
+
const bEngagement = (b.engagement.likes || 0) + (b.engagement.comments || 0);
|
|
162
|
+
if (aEngagement !== bEngagement) {
|
|
163
|
+
return bEngagement - aEngagement;
|
|
164
|
+
}
|
|
165
|
+
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
166
|
+
});
|
|
167
|
+
return combinedPosts.slice(0, limit);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Search across all platforms and return combined results
|
|
171
|
+
*/
|
|
172
|
+
async getCombinedSearchResults(query, limit = 30) {
|
|
173
|
+
const allResults = await this.searchAllPlatforms(query, limit);
|
|
174
|
+
const combinedPosts = [];
|
|
175
|
+
for (const [, posts] of Object.entries(allResults)) {
|
|
176
|
+
if (posts && Array.isArray(posts)) {
|
|
177
|
+
combinedPosts.push(...posts);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
combinedPosts.sort((a, b) => {
|
|
181
|
+
const aRelevance = this.calculateRelevance(a.content, query);
|
|
182
|
+
const bRelevance = this.calculateRelevance(b.content, query);
|
|
183
|
+
if (aRelevance !== bRelevance) {
|
|
184
|
+
return bRelevance - aRelevance;
|
|
185
|
+
}
|
|
186
|
+
const aEngagement = (a.engagement.likes || 0) + (a.engagement.comments || 0);
|
|
187
|
+
const bEngagement = (b.engagement.likes || 0) + (b.engagement.comments || 0);
|
|
188
|
+
return bEngagement - aEngagement;
|
|
189
|
+
});
|
|
190
|
+
return combinedPosts.slice(0, limit);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Platform health check
|
|
194
|
+
*/
|
|
195
|
+
async healthCheck() {
|
|
196
|
+
const health = {};
|
|
197
|
+
const promises = Array.from(this.adapters.entries()).map(async ([platform, adapter]) => {
|
|
198
|
+
try {
|
|
199
|
+
await adapter.getTrendingContent(1);
|
|
200
|
+
health[platform] = 'healthy';
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
console.warn(`Health check failed for ${platform}:`, error);
|
|
204
|
+
health[platform] = 'down';
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
await Promise.allSettled(promises);
|
|
208
|
+
return health;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Cleanup all adapters and browser pool
|
|
212
|
+
*/
|
|
213
|
+
async cleanup() {
|
|
214
|
+
const cleanupPromises = Array.from(this.adapters.values()).map(adapter => adapter.cleanup().catch(error => console.warn('Cleanup error:', error)));
|
|
215
|
+
await Promise.allSettled(cleanupPromises);
|
|
216
|
+
// Cleanup browser pool if any browser-based adapters were used
|
|
217
|
+
const browserPlatforms = ['tiktok', 'instagram', 'xiaohongshu'];
|
|
218
|
+
const hasBrowserAdapters = browserPlatforms.some(p => this.adapters.has(p));
|
|
219
|
+
if (hasBrowserAdapters) {
|
|
220
|
+
try {
|
|
221
|
+
const pool = getBrowserPool();
|
|
222
|
+
await pool.cleanup();
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
console.warn('Browser pool cleanup error:', error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this.adapters.clear();
|
|
229
|
+
this.isInitialized = false;
|
|
230
|
+
console.log('Unified Social Media Service cleaned up');
|
|
231
|
+
}
|
|
232
|
+
getAdapter(platform) {
|
|
233
|
+
if (!this.isInitialized) {
|
|
234
|
+
throw new SocialMediaError('Service not initialized', 'NOT_INITIALIZED', 'tiktok');
|
|
235
|
+
}
|
|
236
|
+
const adapter = this.adapters.get(platform);
|
|
237
|
+
if (!adapter) {
|
|
238
|
+
throw new SocialMediaError(`Platform ${platform} not available`, 'PLATFORM_NOT_AVAILABLE', platform);
|
|
239
|
+
}
|
|
240
|
+
return adapter;
|
|
241
|
+
}
|
|
242
|
+
calculateRelevance(content, query) {
|
|
243
|
+
if (!content || !query)
|
|
244
|
+
return 0;
|
|
245
|
+
const contentLower = content.toLowerCase();
|
|
246
|
+
const queryLower = query.toLowerCase();
|
|
247
|
+
const queryWords = queryLower.split(/\s+/);
|
|
248
|
+
let score = 0;
|
|
249
|
+
if (contentLower.includes(queryLower)) {
|
|
250
|
+
score += 10;
|
|
251
|
+
}
|
|
252
|
+
for (const word of queryWords) {
|
|
253
|
+
if (contentLower.includes(word)) {
|
|
254
|
+
score += 2;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return score;
|
|
258
|
+
}
|
|
259
|
+
}
|