@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,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple Reddit Platform Adapter
|
|
3
|
+
* Uses Reddit's public JSON API for basic functionality
|
|
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 RedditAdapter extends BaseAdapter {
|
|
10
|
+
client = null;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
super(config);
|
|
13
|
+
this.maxRequestsPerWindow = 60;
|
|
14
|
+
}
|
|
15
|
+
async initialize() {
|
|
16
|
+
try {
|
|
17
|
+
this.client = axios.create({
|
|
18
|
+
baseURL: 'https://www.reddit.com',
|
|
19
|
+
headers: {
|
|
20
|
+
'User-Agent': 'crowdlisten-mcp/1.0.0'
|
|
21
|
+
},
|
|
22
|
+
timeout: 10000
|
|
23
|
+
});
|
|
24
|
+
this.isInitialized = true;
|
|
25
|
+
this.log('Reddit adapter initialized successfully (HTTP access)', 'info');
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
this.log('Failed to initialize Reddit adapter', 'error');
|
|
30
|
+
this.isInitialized = false;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async getTrendingContent(limit = 10) {
|
|
35
|
+
this.ensureInitialized();
|
|
36
|
+
this.validateLimit(limit);
|
|
37
|
+
try {
|
|
38
|
+
await this.enforceRateLimit();
|
|
39
|
+
const response = await this.client.get('/r/popular.json', {
|
|
40
|
+
params: { limit }
|
|
41
|
+
});
|
|
42
|
+
const posts = [];
|
|
43
|
+
const items = response.data?.data?.children || [];
|
|
44
|
+
for (const item of items.slice(0, limit)) {
|
|
45
|
+
const postData = item.data;
|
|
46
|
+
const post = DataNormalizer.normalizePost({
|
|
47
|
+
id: postData.id,
|
|
48
|
+
title: postData.title,
|
|
49
|
+
selftext: postData.selftext || '',
|
|
50
|
+
author: postData.author,
|
|
51
|
+
score: postData.score,
|
|
52
|
+
num_comments: postData.num_comments,
|
|
53
|
+
created_utc: postData.created_utc,
|
|
54
|
+
permalink: postData.permalink,
|
|
55
|
+
url: postData.url || `https://reddit.com${postData.permalink}`,
|
|
56
|
+
subreddit: postData.subreddit
|
|
57
|
+
}, 'reddit');
|
|
58
|
+
posts.push(post);
|
|
59
|
+
}
|
|
60
|
+
this.log(`Retrieved ${posts.length} trending Reddit posts`, 'info');
|
|
61
|
+
return posts;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
this.handleError(error, 'getTrendingContent');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async getUserContent(userId, limit = 10) {
|
|
68
|
+
this.ensureInitialized();
|
|
69
|
+
this.validateUserId(userId);
|
|
70
|
+
this.validateLimit(limit);
|
|
71
|
+
try {
|
|
72
|
+
await this.enforceRateLimit();
|
|
73
|
+
const response = await this.client.get(`/user/${userId}.json`, {
|
|
74
|
+
params: { limit }
|
|
75
|
+
});
|
|
76
|
+
const posts = [];
|
|
77
|
+
const items = response.data?.data?.children || [];
|
|
78
|
+
for (const item of items.slice(0, limit)) {
|
|
79
|
+
const postData = item.data;
|
|
80
|
+
if (postData.title) { // Only posts, not comments
|
|
81
|
+
const post = DataNormalizer.normalizePost({
|
|
82
|
+
id: postData.id,
|
|
83
|
+
title: postData.title,
|
|
84
|
+
selftext: postData.selftext || '',
|
|
85
|
+
author: postData.author || userId,
|
|
86
|
+
score: postData.score,
|
|
87
|
+
num_comments: postData.num_comments,
|
|
88
|
+
created_utc: postData.created_utc,
|
|
89
|
+
permalink: postData.permalink,
|
|
90
|
+
url: postData.url || `https://reddit.com${postData.permalink}`,
|
|
91
|
+
subreddit: postData.subreddit
|
|
92
|
+
}, 'reddit');
|
|
93
|
+
posts.push(post);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.log(`Retrieved ${posts.length} posts from Reddit user ${userId}`, 'info');
|
|
97
|
+
return posts;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error.response?.status === 404) {
|
|
101
|
+
throw new NotFoundError('reddit', `User ${userId}`, error);
|
|
102
|
+
}
|
|
103
|
+
this.handleError(error, 'getUserContent');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async searchContent(query, limit = 10) {
|
|
107
|
+
this.ensureInitialized();
|
|
108
|
+
this.validateLimit(limit);
|
|
109
|
+
if (!query || query.trim().length === 0) {
|
|
110
|
+
throw new Error('Search query cannot be empty');
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await this.enforceRateLimit();
|
|
114
|
+
const response = await this.client.get('/search.json', {
|
|
115
|
+
params: {
|
|
116
|
+
q: query.trim(),
|
|
117
|
+
limit,
|
|
118
|
+
sort: 'relevance',
|
|
119
|
+
type: 'link'
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const posts = [];
|
|
123
|
+
const items = response.data?.data?.children || [];
|
|
124
|
+
for (const item of items.slice(0, limit)) {
|
|
125
|
+
const postData = item.data;
|
|
126
|
+
const post = DataNormalizer.normalizePost({
|
|
127
|
+
id: postData.id,
|
|
128
|
+
title: postData.title,
|
|
129
|
+
selftext: postData.selftext || '',
|
|
130
|
+
author: postData.author,
|
|
131
|
+
score: postData.score,
|
|
132
|
+
num_comments: postData.num_comments,
|
|
133
|
+
created_utc: postData.created_utc,
|
|
134
|
+
permalink: postData.permalink,
|
|
135
|
+
url: postData.url || `https://reddit.com${postData.permalink}`,
|
|
136
|
+
subreddit: postData.subreddit
|
|
137
|
+
}, 'reddit');
|
|
138
|
+
posts.push(post);
|
|
139
|
+
}
|
|
140
|
+
this.log(`Found ${posts.length} Reddit posts for query: ${query}`, 'info');
|
|
141
|
+
return posts;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
this.handleError(error, 'searchContent');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async getContentComments(contentId, limit = 20) {
|
|
148
|
+
this.ensureInitialized();
|
|
149
|
+
this.validateContentId(contentId);
|
|
150
|
+
this.validateLimit(limit);
|
|
151
|
+
try {
|
|
152
|
+
await this.enforceRateLimit();
|
|
153
|
+
// Reddit /comments/{postId}.json returns [postListing, commentListing]
|
|
154
|
+
const response = await this.client.get(`/comments/${contentId}.json`, {
|
|
155
|
+
params: {
|
|
156
|
+
limit,
|
|
157
|
+
depth: 5,
|
|
158
|
+
sort: 'top'
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
const commentListing = response.data?.[1];
|
|
162
|
+
const children = commentListing?.data?.children || [];
|
|
163
|
+
const comments = [];
|
|
164
|
+
for (const child of children) {
|
|
165
|
+
// Skip "more" stubs (load-more placeholders)
|
|
166
|
+
if (child.kind !== 't1' || !child.data)
|
|
167
|
+
continue;
|
|
168
|
+
comments.push(DataNormalizer.normalizeComment(child.data, 'reddit'));
|
|
169
|
+
}
|
|
170
|
+
this.log(`Retrieved ${comments.length} comments for Reddit post ${contentId}`);
|
|
171
|
+
return comments.slice(0, limit);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error.response?.status === 404) {
|
|
175
|
+
throw new NotFoundError('reddit', `Post ${contentId}`, error);
|
|
176
|
+
}
|
|
177
|
+
this.handleError(error, 'getContentComments');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
getPlatformName() {
|
|
181
|
+
return 'reddit';
|
|
182
|
+
}
|
|
183
|
+
getSupportedFeatures() {
|
|
184
|
+
return {
|
|
185
|
+
supportsTrending: true,
|
|
186
|
+
supportsUserContent: true,
|
|
187
|
+
supportsSearch: true,
|
|
188
|
+
supportsComments: true,
|
|
189
|
+
supportsAnalysis: true
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
isRateLimitError(error) {
|
|
193
|
+
return error.response?.status === 429 ||
|
|
194
|
+
error.message?.includes('rate limit');
|
|
195
|
+
}
|
|
196
|
+
isAuthError(error) {
|
|
197
|
+
return error.response?.status === 401 ||
|
|
198
|
+
error.response?.status === 403;
|
|
199
|
+
}
|
|
200
|
+
isNotFoundError(error) {
|
|
201
|
+
return error.response?.status === 404;
|
|
202
|
+
}
|
|
203
|
+
async cleanup() {
|
|
204
|
+
try {
|
|
205
|
+
this.client = null;
|
|
206
|
+
await super.cleanup();
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
this.log('Error during Reddit cleanup', 'warn');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TikTok Platform Adapter — flat browser adapter using API interception.
|
|
3
|
+
*
|
|
4
|
+
* Uses BrowserPool + RequestInterceptor to capture TikTok's internal API
|
|
5
|
+
* responses. No tiers, no fallback chains.
|
|
6
|
+
*
|
|
7
|
+
* API targets:
|
|
8
|
+
* - /api/search/item/ — search results
|
|
9
|
+
* - /api/comment/list/ — comment threads
|
|
10
|
+
* - /api/recommend/item_list/ — trending/FYP
|
|
11
|
+
* - /api/post/item_list/ — user posts
|
|
12
|
+
*/
|
|
13
|
+
import { BaseAdapter } from '../../core/base/BaseAdapter.js';
|
|
14
|
+
import { Post, Comment, PlatformCapabilities, PlatformType, PlatformConfig } from '../../core/interfaces/SocialMediaPlatform.js';
|
|
15
|
+
export declare class TikTokAdapter extends BaseAdapter {
|
|
16
|
+
constructor(config: PlatformConfig);
|
|
17
|
+
initialize(): Promise<boolean>;
|
|
18
|
+
searchContent(query: string, limit?: number): Promise<Post[]>;
|
|
19
|
+
getTrendingContent(limit?: number): Promise<Post[]>;
|
|
20
|
+
getUserContent(userId: string, limit?: number): Promise<Post[]>;
|
|
21
|
+
getContentComments(contentId: string, limit?: number): Promise<Comment[]>;
|
|
22
|
+
private interceptPosts;
|
|
23
|
+
/**
|
|
24
|
+
* TikTok uses IntersectionObserver for lazy loading — stub it before navigation.
|
|
25
|
+
*/
|
|
26
|
+
private setupPage;
|
|
27
|
+
private waitAndScroll;
|
|
28
|
+
private structurePosts;
|
|
29
|
+
private structureComments;
|
|
30
|
+
private normalizeItem;
|
|
31
|
+
private normalizeComment;
|
|
32
|
+
getPlatformName(): PlatformType;
|
|
33
|
+
getSupportedFeatures(): PlatformCapabilities;
|
|
34
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TikTok Platform Adapter — flat browser adapter using API interception.
|
|
3
|
+
*
|
|
4
|
+
* Uses BrowserPool + RequestInterceptor to capture TikTok's internal API
|
|
5
|
+
* responses. No tiers, no fallback chains.
|
|
6
|
+
*
|
|
7
|
+
* API targets:
|
|
8
|
+
* - /api/search/item/ — search results
|
|
9
|
+
* - /api/comment/list/ — comment threads
|
|
10
|
+
* - /api/recommend/item_list/ — trending/FYP
|
|
11
|
+
* - /api/post/item_list/ — user posts
|
|
12
|
+
*/
|
|
13
|
+
import { BaseAdapter } from '../../core/base/BaseAdapter.js';
|
|
14
|
+
import { getBrowserPool } from '../../browser/BrowserPool.js';
|
|
15
|
+
import { RequestInterceptor } from '../../browser/RequestInterceptor.js';
|
|
16
|
+
const API_PATTERNS = [
|
|
17
|
+
'/api/search/item/',
|
|
18
|
+
'/api/search/general/',
|
|
19
|
+
'/api/comment/list/',
|
|
20
|
+
'/api/recommend/item_list/',
|
|
21
|
+
'/api/post/item_list/',
|
|
22
|
+
'/v1/search/',
|
|
23
|
+
];
|
|
24
|
+
export class TikTokAdapter extends BaseAdapter {
|
|
25
|
+
constructor(config) {
|
|
26
|
+
super(config);
|
|
27
|
+
// Browser platforms risk IP blocks above ~2/min sustained; 5/min allows interactive bursts
|
|
28
|
+
this.maxRequestsPerWindow = 5;
|
|
29
|
+
}
|
|
30
|
+
async initialize() {
|
|
31
|
+
this.isInitialized = true;
|
|
32
|
+
this.log('TikTok adapter initialized (API interception mode)');
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
async searchContent(query, limit = 10) {
|
|
36
|
+
this.ensureInitialized();
|
|
37
|
+
await this.enforceRateLimit();
|
|
38
|
+
const url = `https://www.tiktok.com/search?q=${encodeURIComponent(query)}`;
|
|
39
|
+
return this.interceptPosts(url, limit);
|
|
40
|
+
}
|
|
41
|
+
async getTrendingContent(limit = 10) {
|
|
42
|
+
this.ensureInitialized();
|
|
43
|
+
await this.enforceRateLimit();
|
|
44
|
+
return this.interceptPosts('https://www.tiktok.com/explore', limit);
|
|
45
|
+
}
|
|
46
|
+
async getUserContent(userId, limit = 10) {
|
|
47
|
+
this.ensureInitialized();
|
|
48
|
+
this.validateUserId(userId);
|
|
49
|
+
await this.enforceRateLimit();
|
|
50
|
+
const username = userId.startsWith('@') ? userId : `@${userId}`;
|
|
51
|
+
return this.interceptPosts(`https://www.tiktok.com/${username}`, limit);
|
|
52
|
+
}
|
|
53
|
+
async getContentComments(contentId, limit = 20) {
|
|
54
|
+
this.ensureInitialized();
|
|
55
|
+
this.validateContentId(contentId);
|
|
56
|
+
await this.enforceRateLimit();
|
|
57
|
+
const url = contentId.includes('tiktok.com/')
|
|
58
|
+
? contentId
|
|
59
|
+
: `https://www.tiktok.com/video/${contentId}`;
|
|
60
|
+
const pool = getBrowserPool();
|
|
61
|
+
const page = await pool.acquire('tiktok');
|
|
62
|
+
const interceptor = new RequestInterceptor();
|
|
63
|
+
try {
|
|
64
|
+
await this.setupPage(page);
|
|
65
|
+
await interceptor.setup(page, API_PATTERNS);
|
|
66
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
67
|
+
await this.waitAndScroll(page);
|
|
68
|
+
const apiData = interceptor.getAllData();
|
|
69
|
+
if (apiData.length === 0)
|
|
70
|
+
return [];
|
|
71
|
+
return this.structureComments(apiData).slice(0, limit);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.handleError(error, `getContentComments(${contentId})`);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
interceptor.stop();
|
|
78
|
+
await pool.release(page);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// ── Interception pipeline ──────────────────────────────────────────────
|
|
82
|
+
async interceptPosts(url, limit) {
|
|
83
|
+
const pool = getBrowserPool();
|
|
84
|
+
const page = await pool.acquire('tiktok');
|
|
85
|
+
const interceptor = new RequestInterceptor();
|
|
86
|
+
try {
|
|
87
|
+
await this.setupPage(page);
|
|
88
|
+
await interceptor.setup(page, API_PATTERNS);
|
|
89
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
90
|
+
await this.waitAndScroll(page);
|
|
91
|
+
const apiData = interceptor.getAllData();
|
|
92
|
+
if (apiData.length === 0)
|
|
93
|
+
return [];
|
|
94
|
+
return this.structurePosts(apiData).slice(0, limit);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
this.handleError(error, `interceptPosts(${url})`);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
interceptor.stop();
|
|
101
|
+
await pool.release(page);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* TikTok uses IntersectionObserver for lazy loading — stub it before navigation.
|
|
106
|
+
*/
|
|
107
|
+
async setupPage(page) {
|
|
108
|
+
await page.addInitScript(() => {
|
|
109
|
+
if (typeof globalThis.IntersectionObserver === 'undefined') {
|
|
110
|
+
globalThis.IntersectionObserver = class {
|
|
111
|
+
_cb;
|
|
112
|
+
constructor(cb) { this._cb = cb; }
|
|
113
|
+
observe(target) {
|
|
114
|
+
setTimeout(() => this._cb([{
|
|
115
|
+
isIntersecting: true,
|
|
116
|
+
intersectionRatio: 1,
|
|
117
|
+
target,
|
|
118
|
+
}]), 50);
|
|
119
|
+
}
|
|
120
|
+
unobserve() { }
|
|
121
|
+
disconnect() { }
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async waitAndScroll(page) {
|
|
127
|
+
try {
|
|
128
|
+
await page.waitForLoadState('networkidle', { timeout: 15000 });
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Non-fatal
|
|
132
|
+
}
|
|
133
|
+
for (let i = 0; i < 5; i++) {
|
|
134
|
+
await page.evaluate(() => window.scrollBy(0, window.innerHeight));
|
|
135
|
+
await page.waitForTimeout(2000);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ── Data normalization (preserved from VisualTikTokAdapter) ────────────
|
|
139
|
+
structurePosts(interceptedData) {
|
|
140
|
+
const posts = [];
|
|
141
|
+
const seenIds = new Set();
|
|
142
|
+
for (const data of interceptedData) {
|
|
143
|
+
try {
|
|
144
|
+
const items = data?.data || data?.item_list || data?.itemList || [];
|
|
145
|
+
if (Array.isArray(items)) {
|
|
146
|
+
for (const item of items) {
|
|
147
|
+
const post = this.normalizeItem(item);
|
|
148
|
+
if (post && !seenIds.has(post.id)) {
|
|
149
|
+
seenIds.add(post.id);
|
|
150
|
+
posts.push(post);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const searchItems = data?.data?.item_list || data?.data?.items || [];
|
|
155
|
+
if (Array.isArray(searchItems) && searchItems !== items) {
|
|
156
|
+
for (const item of searchItems) {
|
|
157
|
+
const post = this.normalizeItem(item);
|
|
158
|
+
if (post && !seenIds.has(post.id)) {
|
|
159
|
+
seenIds.add(post.id);
|
|
160
|
+
posts.push(post);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Skip malformed response
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return posts;
|
|
170
|
+
}
|
|
171
|
+
structureComments(interceptedData) {
|
|
172
|
+
const comments = [];
|
|
173
|
+
const seenIds = new Set();
|
|
174
|
+
for (const data of interceptedData) {
|
|
175
|
+
try {
|
|
176
|
+
const commentList = data?.comments || data?.data?.comments || [];
|
|
177
|
+
if (Array.isArray(commentList)) {
|
|
178
|
+
for (const item of commentList) {
|
|
179
|
+
const comment = this.normalizeComment(item);
|
|
180
|
+
if (comment && !seenIds.has(comment.id)) {
|
|
181
|
+
seenIds.add(comment.id);
|
|
182
|
+
comments.push(comment);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Skip malformed response
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return comments;
|
|
192
|
+
}
|
|
193
|
+
normalizeItem(item) {
|
|
194
|
+
try {
|
|
195
|
+
const id = item.id || item.aweme_id || item.video?.id || '';
|
|
196
|
+
if (!id)
|
|
197
|
+
return null;
|
|
198
|
+
const author = item.author || {};
|
|
199
|
+
const stats = item.stats || item.statistics || {};
|
|
200
|
+
const desc = item.desc || item.title || item.video?.title || '';
|
|
201
|
+
const createTime = item.createTime || item.create_time;
|
|
202
|
+
return {
|
|
203
|
+
id: String(id),
|
|
204
|
+
platform: 'tiktok',
|
|
205
|
+
author: {
|
|
206
|
+
id: author.id || author.uid || '',
|
|
207
|
+
username: author.uniqueId || author.unique_id || author.nickname || '',
|
|
208
|
+
displayName: author.nickname || '',
|
|
209
|
+
followerCount: author.followerCount || author.follower_count,
|
|
210
|
+
verified: author.verified,
|
|
211
|
+
profileImageUrl: author.avatarThumb || author.avatar_thumb,
|
|
212
|
+
},
|
|
213
|
+
content: desc,
|
|
214
|
+
mediaUrl: item.video?.cover || item.video?.dynamicCover || '',
|
|
215
|
+
engagement: {
|
|
216
|
+
likes: stats.diggCount || stats.digg_count || stats.likeCount || 0,
|
|
217
|
+
comments: stats.commentCount || stats.comment_count || 0,
|
|
218
|
+
shares: stats.shareCount || stats.share_count || 0,
|
|
219
|
+
views: stats.playCount || stats.play_count || 0,
|
|
220
|
+
},
|
|
221
|
+
timestamp: createTime ? new Date(createTime * 1000) : new Date(),
|
|
222
|
+
url: `https://www.tiktok.com/@${author.uniqueId || author.unique_id || 'user'}/video/${id}`,
|
|
223
|
+
hashtags: (item.textExtra || [])
|
|
224
|
+
.filter((t) => t.hashtagName || t.hashtag_name)
|
|
225
|
+
.map((t) => t.hashtagName || t.hashtag_name),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
normalizeComment(item) {
|
|
233
|
+
try {
|
|
234
|
+
const id = item.cid || item.id || '';
|
|
235
|
+
if (!id)
|
|
236
|
+
return null;
|
|
237
|
+
const user = item.user || {};
|
|
238
|
+
const createTime = item.create_time || item.createTime;
|
|
239
|
+
return {
|
|
240
|
+
id: String(id),
|
|
241
|
+
author: {
|
|
242
|
+
id: user.uid || user.id || '',
|
|
243
|
+
username: user.unique_id || user.uniqueId || user.nickname || '',
|
|
244
|
+
displayName: user.nickname || '',
|
|
245
|
+
},
|
|
246
|
+
text: item.text || '',
|
|
247
|
+
timestamp: createTime ? new Date(createTime * 1000) : new Date(),
|
|
248
|
+
likes: item.digg_count || item.diggCount || item.likes || 0,
|
|
249
|
+
replies: (item.reply_comment || []).map((r) => this.normalizeComment(r)).filter(Boolean),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// ── Platform identity ──────────────────────────────────────────────────
|
|
257
|
+
getPlatformName() {
|
|
258
|
+
return 'tiktok';
|
|
259
|
+
}
|
|
260
|
+
getSupportedFeatures() {
|
|
261
|
+
return {
|
|
262
|
+
supportsTrending: true,
|
|
263
|
+
supportsUserContent: true,
|
|
264
|
+
supportsSearch: true,
|
|
265
|
+
supportsComments: true,
|
|
266
|
+
supportsAnalysis: true,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitter/X Platform Adapter
|
|
3
|
+
* Uses @the-convocation/twitter-scraper for HTTP-only access to Twitter's
|
|
4
|
+
* internal API via cookie auth. No browser needed.
|
|
5
|
+
*
|
|
6
|
+
* Auth: TWITTER_USERNAME + TWITTER_PASSWORD env vars, or stored cookies.
|
|
7
|
+
*/
|
|
8
|
+
import { BaseAdapter } from '../../core/base/BaseAdapter.js';
|
|
9
|
+
import { Post, Comment, PlatformCapabilities, PlatformType, PlatformConfig } from '../../core/interfaces/SocialMediaPlatform.js';
|
|
10
|
+
export declare class TwitterAdapter extends BaseAdapter {
|
|
11
|
+
private scraper;
|
|
12
|
+
constructor(config: PlatformConfig);
|
|
13
|
+
initialize(): Promise<boolean>;
|
|
14
|
+
searchContent(query: string, limit?: number): Promise<Post[]>;
|
|
15
|
+
getTrendingContent(limit?: number): Promise<Post[]>;
|
|
16
|
+
getUserContent(userId: string, limit?: number): Promise<Post[]>;
|
|
17
|
+
getContentComments(contentId: string, limit?: number): Promise<Comment[]>;
|
|
18
|
+
private normalizeTweet;
|
|
19
|
+
private normalizeTweetAsComment;
|
|
20
|
+
getPlatformName(): PlatformType;
|
|
21
|
+
getSupportedFeatures(): PlatformCapabilities;
|
|
22
|
+
cleanup(): Promise<void>;
|
|
23
|
+
}
|