@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,211 @@
|
|
|
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 { Scraper, SearchMode } from '@the-convocation/twitter-scraper';
|
|
9
|
+
import { BaseAdapter } from '../../core/base/BaseAdapter.js';
|
|
10
|
+
export class TwitterAdapter extends BaseAdapter {
|
|
11
|
+
scraper;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
super(config);
|
|
14
|
+
this.scraper = new Scraper();
|
|
15
|
+
this.maxRequestsPerWindow = 20;
|
|
16
|
+
}
|
|
17
|
+
async initialize() {
|
|
18
|
+
try {
|
|
19
|
+
const username = process.env.TWITTER_USERNAME;
|
|
20
|
+
const password = process.env.TWITTER_PASSWORD;
|
|
21
|
+
if (username && password) {
|
|
22
|
+
await this.scraper.login(username, password);
|
|
23
|
+
}
|
|
24
|
+
const loggedIn = await this.scraper.isLoggedIn();
|
|
25
|
+
if (!loggedIn) {
|
|
26
|
+
this.log('Twitter scraper not logged in — some features may be limited', 'warn');
|
|
27
|
+
// Still usable for public content without login
|
|
28
|
+
}
|
|
29
|
+
this.isInitialized = true;
|
|
30
|
+
this.log(`Twitter adapter initialized (logged in: ${loggedIn})`);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
this.log(`Failed to initialize Twitter adapter: ${error.message}`, 'error');
|
|
35
|
+
// Initialize anyway — scraper can still fetch some public content
|
|
36
|
+
this.isInitialized = true;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async searchContent(query, limit = 10) {
|
|
41
|
+
this.ensureInitialized();
|
|
42
|
+
this.validateLimit(limit);
|
|
43
|
+
if (!query || query.trim().length === 0) {
|
|
44
|
+
throw new Error('Search query cannot be empty');
|
|
45
|
+
}
|
|
46
|
+
await this.enforceRateLimit();
|
|
47
|
+
const posts = [];
|
|
48
|
+
const tweets = this.scraper.searchTweets(query, limit, SearchMode.Latest);
|
|
49
|
+
for await (const tweet of tweets) {
|
|
50
|
+
if (posts.length >= limit)
|
|
51
|
+
break;
|
|
52
|
+
const post = this.normalizeTweet(tweet);
|
|
53
|
+
if (post)
|
|
54
|
+
posts.push(post);
|
|
55
|
+
}
|
|
56
|
+
this.log(`Found ${posts.length} tweets for query: ${query}`);
|
|
57
|
+
return posts;
|
|
58
|
+
}
|
|
59
|
+
async getTrendingContent(limit = 10) {
|
|
60
|
+
this.ensureInitialized();
|
|
61
|
+
this.validateLimit(limit);
|
|
62
|
+
await this.enforceRateLimit();
|
|
63
|
+
try {
|
|
64
|
+
const trends = await this.scraper.getTrends();
|
|
65
|
+
if (!trends || trends.length === 0) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
// Search for the top trend to get actual posts
|
|
69
|
+
const topTrend = trends[0];
|
|
70
|
+
return this.searchContent(topTrend, limit);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
this.handleError(error, 'getTrendingContent');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async getUserContent(userId, limit = 10) {
|
|
77
|
+
this.ensureInitialized();
|
|
78
|
+
this.validateUserId(userId);
|
|
79
|
+
this.validateLimit(limit);
|
|
80
|
+
await this.enforceRateLimit();
|
|
81
|
+
const username = userId.replace(/^@/, '');
|
|
82
|
+
const posts = [];
|
|
83
|
+
const tweets = this.scraper.getTweets(username, limit);
|
|
84
|
+
for await (const tweet of tweets) {
|
|
85
|
+
if (posts.length >= limit)
|
|
86
|
+
break;
|
|
87
|
+
const post = this.normalizeTweet(tweet);
|
|
88
|
+
if (post)
|
|
89
|
+
posts.push(post);
|
|
90
|
+
}
|
|
91
|
+
this.log(`Retrieved ${posts.length} tweets from user @${username}`);
|
|
92
|
+
return posts;
|
|
93
|
+
}
|
|
94
|
+
async getContentComments(contentId, limit = 20) {
|
|
95
|
+
this.ensureInitialized();
|
|
96
|
+
this.validateContentId(contentId);
|
|
97
|
+
this.validateLimit(limit);
|
|
98
|
+
await this.enforceRateLimit();
|
|
99
|
+
// Extract tweet ID from URL if needed
|
|
100
|
+
let tweetId = contentId;
|
|
101
|
+
const idMatch = contentId.match(/\/status\/(\d+)/);
|
|
102
|
+
if (idMatch)
|
|
103
|
+
tweetId = idMatch[1];
|
|
104
|
+
try {
|
|
105
|
+
const tweet = await this.scraper.getTweet(tweetId);
|
|
106
|
+
if (!tweet) {
|
|
107
|
+
this.log(`Tweet ${tweetId} not found`, 'warn');
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
// Get replies by searching for replies to the tweet
|
|
111
|
+
const comments = [];
|
|
112
|
+
const username = tweet.username || '';
|
|
113
|
+
const replyQuery = `to:${username} conversation_id:${tweetId}`;
|
|
114
|
+
const replies = this.scraper.searchTweets(replyQuery, limit, SearchMode.Latest);
|
|
115
|
+
for await (const reply of replies) {
|
|
116
|
+
if (comments.length >= limit)
|
|
117
|
+
break;
|
|
118
|
+
const comment = this.normalizeTweetAsComment(reply);
|
|
119
|
+
if (comment)
|
|
120
|
+
comments.push(comment);
|
|
121
|
+
}
|
|
122
|
+
this.log(`Retrieved ${comments.length} replies for tweet ${tweetId}`);
|
|
123
|
+
return comments;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
this.handleError(error, `getContentComments(${tweetId})`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// ── Normalization ──────────────────────────────────────────────────────
|
|
130
|
+
normalizeTweet(tweet) {
|
|
131
|
+
try {
|
|
132
|
+
const id = tweet.id || '';
|
|
133
|
+
if (!id)
|
|
134
|
+
return null;
|
|
135
|
+
return {
|
|
136
|
+
id,
|
|
137
|
+
platform: 'twitter',
|
|
138
|
+
author: {
|
|
139
|
+
id: tweet.userId || tweet.username || '',
|
|
140
|
+
username: tweet.username || '',
|
|
141
|
+
displayName: tweet.name || tweet.username || '',
|
|
142
|
+
followerCount: tweet.followersCount,
|
|
143
|
+
verified: tweet.isVerified || tweet.isBlueVerified,
|
|
144
|
+
profileImageUrl: tweet.profileImageUrl,
|
|
145
|
+
},
|
|
146
|
+
content: tweet.text || '',
|
|
147
|
+
mediaUrl: tweet.photos?.[0]?.url || tweet.videos?.[0]?.preview || '',
|
|
148
|
+
engagement: {
|
|
149
|
+
likes: tweet.likes || 0,
|
|
150
|
+
comments: tweet.replies || 0,
|
|
151
|
+
shares: tweet.retweets || 0,
|
|
152
|
+
views: tweet.views || 0,
|
|
153
|
+
},
|
|
154
|
+
timestamp: tweet.timeParsed ? new Date(tweet.timeParsed) : new Date(),
|
|
155
|
+
url: tweet.permanentUrl || `https://x.com/${tweet.username || 'user'}/status/${id}`,
|
|
156
|
+
hashtags: tweet.hashtags || [],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
normalizeTweetAsComment(tweet) {
|
|
164
|
+
try {
|
|
165
|
+
const id = tweet.id || '';
|
|
166
|
+
if (!id)
|
|
167
|
+
return null;
|
|
168
|
+
return {
|
|
169
|
+
id,
|
|
170
|
+
author: {
|
|
171
|
+
id: tweet.userId || tweet.username || '',
|
|
172
|
+
username: tweet.username || '',
|
|
173
|
+
displayName: tweet.name || tweet.username || '',
|
|
174
|
+
verified: tweet.isVerified || tweet.isBlueVerified,
|
|
175
|
+
},
|
|
176
|
+
text: tweet.text || '',
|
|
177
|
+
timestamp: tweet.timeParsed ? new Date(tweet.timeParsed) : new Date(),
|
|
178
|
+
likes: tweet.likes || 0,
|
|
179
|
+
engagement: {
|
|
180
|
+
shares: tweet.retweets || 0,
|
|
181
|
+
views: tweet.views || 0,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// ── Platform identity ──────────────────────────────────────────────────
|
|
190
|
+
getPlatformName() {
|
|
191
|
+
return 'twitter';
|
|
192
|
+
}
|
|
193
|
+
getSupportedFeatures() {
|
|
194
|
+
return {
|
|
195
|
+
supportsTrending: true,
|
|
196
|
+
supportsUserContent: true,
|
|
197
|
+
supportsSearch: true,
|
|
198
|
+
supportsComments: true,
|
|
199
|
+
supportsAnalysis: true,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async cleanup() {
|
|
203
|
+
try {
|
|
204
|
+
await this.scraper.logout();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Ignore logout errors
|
|
208
|
+
}
|
|
209
|
+
await super.cleanup();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaohongshu (RedNote) Platform Adapter — flat browser adapter using API interception.
|
|
3
|
+
*
|
|
4
|
+
* Uses BrowserPool + RequestInterceptor to capture XHS's internal API responses.
|
|
5
|
+
* Preserves mobile viewport, zh-CN locale, and conservative anti-detection delays.
|
|
6
|
+
* No tiers, no fallback chains.
|
|
7
|
+
*
|
|
8
|
+
* API targets:
|
|
9
|
+
* - /api/sns/web/v1/search/notes — search
|
|
10
|
+
* - /api/sns/web/v2/comment/page — comments
|
|
11
|
+
* - /api/sns/web/v1/feed — explore/trending
|
|
12
|
+
* - /api/sns/web/v1/user_posted — user posts
|
|
13
|
+
*/
|
|
14
|
+
import { BaseAdapter } from '../../core/base/BaseAdapter.js';
|
|
15
|
+
import { Post, Comment, PlatformCapabilities, PlatformType, PlatformConfig } from '../../core/interfaces/SocialMediaPlatform.js';
|
|
16
|
+
export declare class XiaohongshuAdapter extends BaseAdapter {
|
|
17
|
+
constructor(config: PlatformConfig);
|
|
18
|
+
initialize(): Promise<boolean>;
|
|
19
|
+
searchContent(query: string, limit?: number): Promise<Post[]>;
|
|
20
|
+
getTrendingContent(limit?: number): Promise<Post[]>;
|
|
21
|
+
getUserContent(userId: string, limit?: number): Promise<Post[]>;
|
|
22
|
+
getContentComments(contentId: string, limit?: number): Promise<Comment[]>;
|
|
23
|
+
private interceptPosts;
|
|
24
|
+
/**
|
|
25
|
+
* XHS needs longer waits and slower scrolling due to anti-bot measures.
|
|
26
|
+
*/
|
|
27
|
+
private waitAndScrollSlow;
|
|
28
|
+
private humanDelay;
|
|
29
|
+
private structurePosts;
|
|
30
|
+
private structureComments;
|
|
31
|
+
private normalizeNote;
|
|
32
|
+
private normalizeXHSComment;
|
|
33
|
+
getPlatformName(): PlatformType;
|
|
34
|
+
getSupportedFeatures(): PlatformCapabilities;
|
|
35
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaohongshu (RedNote) Platform Adapter — flat browser adapter using API interception.
|
|
3
|
+
*
|
|
4
|
+
* Uses BrowserPool + RequestInterceptor to capture XHS's internal API responses.
|
|
5
|
+
* Preserves mobile viewport, zh-CN locale, and conservative anti-detection delays.
|
|
6
|
+
* No tiers, no fallback chains.
|
|
7
|
+
*
|
|
8
|
+
* API targets:
|
|
9
|
+
* - /api/sns/web/v1/search/notes — search
|
|
10
|
+
* - /api/sns/web/v2/comment/page — comments
|
|
11
|
+
* - /api/sns/web/v1/feed — explore/trending
|
|
12
|
+
* - /api/sns/web/v1/user_posted — user posts
|
|
13
|
+
*/
|
|
14
|
+
import { BaseAdapter } from '../../core/base/BaseAdapter.js';
|
|
15
|
+
import { getBrowserPool } from '../../browser/BrowserPool.js';
|
|
16
|
+
import { RequestInterceptor } from '../../browser/RequestInterceptor.js';
|
|
17
|
+
const API_PATTERNS = [
|
|
18
|
+
'/api/sns/web/v1/search/notes',
|
|
19
|
+
'/api/sns/web/v2/comment/page',
|
|
20
|
+
'/api/sns/web/v1/feed',
|
|
21
|
+
'/api/sns/web/v1/user_posted',
|
|
22
|
+
'/api/sns/web/v1/note/',
|
|
23
|
+
'/api/sns/web/v2/note/',
|
|
24
|
+
];
|
|
25
|
+
export class XiaohongshuAdapter extends BaseAdapter {
|
|
26
|
+
constructor(config) {
|
|
27
|
+
super(config);
|
|
28
|
+
// Browser platforms risk IP blocks above ~1/min sustained; 3/min allows interactive bursts
|
|
29
|
+
this.maxRequestsPerWindow = 3;
|
|
30
|
+
}
|
|
31
|
+
async initialize() {
|
|
32
|
+
this.isInitialized = true;
|
|
33
|
+
this.log('Xiaohongshu adapter initialized (API interception mode, mobile profile)');
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
async searchContent(query, limit = 10) {
|
|
37
|
+
this.ensureInitialized();
|
|
38
|
+
await this.enforceRateLimit();
|
|
39
|
+
const url = `https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(query)}&type=1`;
|
|
40
|
+
return this.interceptPosts(url, limit);
|
|
41
|
+
}
|
|
42
|
+
async getTrendingContent(limit = 10) {
|
|
43
|
+
this.ensureInitialized();
|
|
44
|
+
await this.enforceRateLimit();
|
|
45
|
+
return this.interceptPosts('https://www.xiaohongshu.com/explore', limit);
|
|
46
|
+
}
|
|
47
|
+
async getUserContent(userId, limit = 10) {
|
|
48
|
+
this.ensureInitialized();
|
|
49
|
+
this.validateUserId(userId);
|
|
50
|
+
await this.enforceRateLimit();
|
|
51
|
+
return this.interceptPosts(`https://www.xiaohongshu.com/user/profile/${userId}`, limit);
|
|
52
|
+
}
|
|
53
|
+
async getContentComments(contentId, limit = 20) {
|
|
54
|
+
this.ensureInitialized();
|
|
55
|
+
this.validateContentId(contentId);
|
|
56
|
+
await this.enforceRateLimit();
|
|
57
|
+
const url = contentId.includes('xiaohongshu.com/')
|
|
58
|
+
? contentId
|
|
59
|
+
: `https://www.xiaohongshu.com/explore/${contentId}`;
|
|
60
|
+
const pool = getBrowserPool();
|
|
61
|
+
const page = await pool.acquire('xiaohongshu');
|
|
62
|
+
const interceptor = new RequestInterceptor();
|
|
63
|
+
try {
|
|
64
|
+
await interceptor.setup(page, API_PATTERNS);
|
|
65
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
66
|
+
await this.waitAndScrollSlow(page);
|
|
67
|
+
const apiData = interceptor.getAllData();
|
|
68
|
+
if (apiData.length === 0)
|
|
69
|
+
return [];
|
|
70
|
+
return this.structureComments(apiData).slice(0, limit);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
this.handleError(error, `getContentComments(${contentId})`);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
interceptor.stop();
|
|
77
|
+
await pool.release(page);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// ── Interception pipeline ──────────────────────────────────────────────
|
|
81
|
+
async interceptPosts(url, limit) {
|
|
82
|
+
const pool = getBrowserPool();
|
|
83
|
+
const page = await pool.acquire('xiaohongshu');
|
|
84
|
+
const interceptor = new RequestInterceptor();
|
|
85
|
+
try {
|
|
86
|
+
await interceptor.setup(page, API_PATTERNS);
|
|
87
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
88
|
+
await this.waitAndScrollSlow(page);
|
|
89
|
+
const apiData = interceptor.getAllData();
|
|
90
|
+
if (apiData.length === 0)
|
|
91
|
+
return [];
|
|
92
|
+
return this.structurePosts(apiData).slice(0, limit);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
this.handleError(error, `interceptPosts(${url})`);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
interceptor.stop();
|
|
99
|
+
await pool.release(page);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* XHS needs longer waits and slower scrolling due to anti-bot measures.
|
|
104
|
+
*/
|
|
105
|
+
async waitAndScrollSlow(page) {
|
|
106
|
+
// Human-like initial wait
|
|
107
|
+
await this.humanDelay(page, 2000, 4000);
|
|
108
|
+
try {
|
|
109
|
+
await page.waitForLoadState('networkidle', { timeout: 20000 });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Non-fatal
|
|
113
|
+
}
|
|
114
|
+
// Slow scrolling with random delays (70% viewport height)
|
|
115
|
+
for (let i = 0; i < 3; i++) {
|
|
116
|
+
await page.evaluate(() => window.scrollBy(0, window.innerHeight * 0.7));
|
|
117
|
+
await this.humanDelay(page, 2000, 5000);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async humanDelay(page, minMs, maxMs) {
|
|
121
|
+
const delay = minMs + Math.random() * (maxMs - minMs);
|
|
122
|
+
await page.waitForTimeout(delay);
|
|
123
|
+
}
|
|
124
|
+
// ── Data normalization (preserved from VisualXiaohongshuAdapter) ───────
|
|
125
|
+
structurePosts(interceptedData) {
|
|
126
|
+
const posts = [];
|
|
127
|
+
const seenIds = new Set();
|
|
128
|
+
for (const data of interceptedData) {
|
|
129
|
+
try {
|
|
130
|
+
// Search results shape
|
|
131
|
+
const items = data?.data?.items || data?.data?.notes || [];
|
|
132
|
+
if (Array.isArray(items)) {
|
|
133
|
+
for (const item of items) {
|
|
134
|
+
const noteCard = item.note_card || item;
|
|
135
|
+
const post = this.normalizeNote(noteCard, item.id || noteCard.note_id);
|
|
136
|
+
if (post && !seenIds.has(post.id)) {
|
|
137
|
+
seenIds.add(post.id);
|
|
138
|
+
posts.push(post);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Single note detail
|
|
143
|
+
if (data?.data?.note_id || data?.data?.id) {
|
|
144
|
+
const post = this.normalizeNote(data.data, data.data.note_id || data.data.id);
|
|
145
|
+
if (post && !seenIds.has(post.id)) {
|
|
146
|
+
seenIds.add(post.id);
|
|
147
|
+
posts.push(post);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Skip malformed response
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return posts;
|
|
156
|
+
}
|
|
157
|
+
structureComments(interceptedData) {
|
|
158
|
+
const comments = [];
|
|
159
|
+
const seenIds = new Set();
|
|
160
|
+
for (const data of interceptedData) {
|
|
161
|
+
try {
|
|
162
|
+
const commentList = data?.data?.comments || [];
|
|
163
|
+
if (Array.isArray(commentList)) {
|
|
164
|
+
for (const item of commentList) {
|
|
165
|
+
const comment = this.normalizeXHSComment(item);
|
|
166
|
+
if (comment && !seenIds.has(comment.id)) {
|
|
167
|
+
seenIds.add(comment.id);
|
|
168
|
+
comments.push(comment);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Skip malformed response
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return comments;
|
|
178
|
+
}
|
|
179
|
+
normalizeNote(note, noteId) {
|
|
180
|
+
try {
|
|
181
|
+
const id = noteId || note.note_id || note.id || '';
|
|
182
|
+
if (!id)
|
|
183
|
+
return null;
|
|
184
|
+
const user = note.user || note.author || {};
|
|
185
|
+
const interactInfo = note.interact_info || {};
|
|
186
|
+
return {
|
|
187
|
+
id: String(id),
|
|
188
|
+
platform: 'xiaohongshu',
|
|
189
|
+
author: {
|
|
190
|
+
id: user.user_id || user.uid || '',
|
|
191
|
+
username: user.nickname || user.nick_name || '',
|
|
192
|
+
displayName: user.nickname || user.nick_name || '',
|
|
193
|
+
profileImageUrl: user.avatar || user.images,
|
|
194
|
+
},
|
|
195
|
+
content: note.title || note.desc || note.display_title || '',
|
|
196
|
+
mediaUrl: note.cover?.url || note.cover?.url_default || note.image_list?.[0]?.url || '',
|
|
197
|
+
engagement: {
|
|
198
|
+
likes: interactInfo.liked_count || note.liked_count || 0,
|
|
199
|
+
comments: interactInfo.comment_count || note.comment_count || 0,
|
|
200
|
+
shares: interactInfo.share_count || note.share_count || 0,
|
|
201
|
+
views: interactInfo.view_count || 0,
|
|
202
|
+
},
|
|
203
|
+
timestamp: note.time
|
|
204
|
+
? new Date(note.time)
|
|
205
|
+
: note.create_time
|
|
206
|
+
? new Date(note.create_time * 1000)
|
|
207
|
+
: new Date(),
|
|
208
|
+
url: `https://www.xiaohongshu.com/explore/${id}`,
|
|
209
|
+
hashtags: (note.tag_list || []).map((t) => t.name || t),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
normalizeXHSComment(item) {
|
|
217
|
+
try {
|
|
218
|
+
const id = item.id || item.comment_id || '';
|
|
219
|
+
if (!id)
|
|
220
|
+
return null;
|
|
221
|
+
const user = item.user_info || item.user || {};
|
|
222
|
+
const subComments = (item.sub_comments || [])
|
|
223
|
+
.map((s) => this.normalizeXHSComment(s))
|
|
224
|
+
.filter(Boolean);
|
|
225
|
+
return {
|
|
226
|
+
id: String(id),
|
|
227
|
+
author: {
|
|
228
|
+
id: user.user_id || '',
|
|
229
|
+
username: user.nickname || '',
|
|
230
|
+
displayName: user.nickname || '',
|
|
231
|
+
profileImageUrl: user.image || user.avatar,
|
|
232
|
+
},
|
|
233
|
+
text: item.content || '',
|
|
234
|
+
timestamp: item.create_time
|
|
235
|
+
? new Date(item.create_time * 1000)
|
|
236
|
+
: new Date(),
|
|
237
|
+
likes: item.like_count || 0,
|
|
238
|
+
replies: subComments.length > 0 ? subComments : undefined,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// ── Platform identity ──────────────────────────────────────────────────
|
|
246
|
+
getPlatformName() {
|
|
247
|
+
return 'xiaohongshu';
|
|
248
|
+
}
|
|
249
|
+
getSupportedFeatures() {
|
|
250
|
+
return {
|
|
251
|
+
supportsTrending: true,
|
|
252
|
+
supportsUserContent: true,
|
|
253
|
+
supportsSearch: true,
|
|
254
|
+
supportsComments: true,
|
|
255
|
+
supportsAnalysis: true,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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 { Post, Comment, PlatformCapabilities, PlatformType, PlatformConfig } from '../../core/interfaces/SocialMediaPlatform.js';
|
|
7
|
+
export declare class YouTubeAdapter extends BaseAdapter {
|
|
8
|
+
private client;
|
|
9
|
+
private apiKey;
|
|
10
|
+
constructor(config: PlatformConfig);
|
|
11
|
+
initialize(): Promise<boolean>;
|
|
12
|
+
getTrendingContent(limit?: number): Promise<Post[]>;
|
|
13
|
+
getUserContent(userId: string, limit?: number): Promise<Post[]>;
|
|
14
|
+
searchContent(query: string, limit?: number): Promise<Post[]>;
|
|
15
|
+
getContentComments(contentId: string, limit?: number): Promise<Comment[]>;
|
|
16
|
+
getPlatformName(): PlatformType;
|
|
17
|
+
getSupportedFeatures(): PlatformCapabilities;
|
|
18
|
+
protected isRateLimitError(error: any): boolean;
|
|
19
|
+
protected isAuthError(error: any): boolean;
|
|
20
|
+
protected isNotFoundError(error: any): boolean;
|
|
21
|
+
cleanup(): Promise<void>;
|
|
22
|
+
}
|