bulltrackers-module 1.0.474 → 1.0.475

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.
@@ -0,0 +1,282 @@
1
+ /**
2
+ * @fileoverview Social task handling for task engine
3
+ * Handles fetching social posts for instruments, PIs, and signed-in users
4
+ */
5
+
6
+ const { FieldValue, FieldPath } = require('@google-cloud/firestore');
7
+ const crypto = require('crypto');
8
+ const { getGcidForUser } = require('../../social-task-handler/helpers/handler_helpers');
9
+
10
+ const MAX_POSTS_TO_STORE = 30;
11
+
12
+ /**
13
+ * Handles social fetch task for instruments, PIs, or signed-in users
14
+ * @param {object} taskData - Contains { type, id, username, since }
15
+ * @param {object} config - Task engine configuration
16
+ * @param {object} dependencies - Contains db, logger, headerManager, proxyManager
17
+ * @param {object} batchCounterRef - Optional Firestore reference for batch counter
18
+ * @returns {Promise<void>}
19
+ */
20
+ async function handleSocialFetch(taskData, config, dependencies, batchCounterRef = null) {
21
+ const { db, logger, headerManager, proxyManager } = dependencies;
22
+ const { type, id, username, since } = taskData;
23
+ const cid = id;
24
+
25
+ let fetchUrlBase = '';
26
+ let targetCollectionPath = '';
27
+
28
+ // 1. Target URL Construction (using GCID for Users)
29
+ try {
30
+ if (type === 'INSTRUMENT') {
31
+ const socialApiBaseUrl = config.social?.socialApiBaseUrl || 'https://www.etoro.com/api/edm-streams/v1/feed/instrument/';
32
+ fetchUrlBase = `${socialApiBaseUrl}${cid}`;
33
+ const socialInsightsCollection = config.social?.socialInsightsCollectionName || 'social_insights';
34
+ targetCollectionPath = `${socialInsightsCollection}/${new Date().toISOString().slice(0, 10)}/posts`;
35
+ } else if (type === 'POPULAR_INVESTOR' || type === 'SIGNED_IN_USER') {
36
+ // REVERSE LOOKUP: Convert CID to GCID
37
+ const gcid = await getGcidForUser(dependencies, config.social || {}, cid, username);
38
+
39
+ const userFeedApiUrl = config.social?.userFeedApiUrl || 'https://www.etoro.com/api/edm-streams/v1/feed/user/top/';
40
+ fetchUrlBase = `${userFeedApiUrl}${gcid}`;
41
+ targetCollectionPath = (type === 'SIGNED_IN_USER')
42
+ ? `${config.social?.signedInUserSocialCollection || 'signed_in_users_social'}/${cid}/posts`
43
+ : `${config.social?.piSocialCollectionName || 'pi_social_posts'}/${cid}/posts`;
44
+ }
45
+ } catch (err) {
46
+ logger.log('ERROR', `[SocialFetch] Initialization failed for ${type} ${cid}.`, err);
47
+ return;
48
+ }
49
+
50
+ const taskId = `social-${cid}-${Date.now()}`;
51
+ logger.log('INFO', `[SocialFetch/${taskId}] Starting fetch for ${type} ${cid}. Limit: ${MAX_POSTS_TO_STORE} posts.`);
52
+
53
+ let offset = 0;
54
+ const take = 10;
55
+ let totalSaved = 0;
56
+ let keepFetching = true;
57
+ const processedPostsCollection = config.social?.processedPostsCollectionName || 'social_processed_registry';
58
+ const processedRef = db.collection(processedPostsCollection);
59
+
60
+ try {
61
+ while (keepFetching && totalSaved < MAX_POSTS_TO_STORE) {
62
+ const clientRequestId = crypto.randomUUID();
63
+ const url = `${fetchUrlBase}?take=${take}&offset=${offset}&reactionsPageSize=20&badgesExperimentIsEnabled=false&client_request_id=${clientRequestId}`;
64
+ const selectedHeader = await headerManager.selectHeader();
65
+
66
+ const requestHeaders = {
67
+ 'Accept': 'application/json',
68
+ 'Referer': 'https://www.etoro.com/',
69
+ ...selectedHeader.header
70
+ };
71
+
72
+ logger.log('INFO', `[SocialFetch/${taskId}] Requesting URL: ${url}`);
73
+
74
+ let response;
75
+ try {
76
+ response = await proxyManager.fetch(url, { headers: requestHeaders });
77
+ if (!response.ok) throw new Error(`Status ${response.status}`);
78
+ headerManager.updatePerformance(selectedHeader.id, true);
79
+ } catch (err) {
80
+ logger.log('WARN', `[SocialFetch/${taskId}] Fetch failed for ${url}: ${err.message}. Retrying direct.`);
81
+ try {
82
+ const directFetch = typeof fetch !== 'undefined' ? fetch : require('node-fetch');
83
+ response = await directFetch(url, { headers: requestHeaders });
84
+ if (!response.ok) throw new Error(`Direct status ${response.status}`);
85
+ headerManager.updatePerformance(selectedHeader.id, true);
86
+ } catch (directErr) {
87
+ logger.log('ERROR', `[SocialFetch/${taskId}] All fetch methods failed.`, { error: directErr.message });
88
+ headerManager.updatePerformance(selectedHeader.id, false);
89
+ break;
90
+ }
91
+ }
92
+
93
+ const page = await response.json();
94
+ const discussions = page?.discussions || [];
95
+ logger.log('INFO', `[SocialFetch/${taskId}] API returned ${discussions.length} items for ${cid} at offset ${offset}.`);
96
+
97
+ if (discussions.length === 0) {
98
+ keepFetching = false;
99
+ continue;
100
+ }
101
+
102
+ const postIds = discussions.map(d => d.post?.id).filter(Boolean);
103
+ const existingIds = new Set();
104
+ if (postIds.length > 0) {
105
+ const existingDocs = await processedRef.where(FieldPath.documentId(), 'in', postIds).get();
106
+ existingDocs.forEach(d => existingIds.add(d.id));
107
+ }
108
+
109
+ // Filter and prepare posts: only new posts in English
110
+ const newPostsWithDiscussion = discussions
111
+ .filter(d => {
112
+ const post = d.post;
113
+ if (!post || !post.id || !post.message?.text || existingIds.has(post.id)) return false;
114
+ const lang = (post.message.languageCode || 'unknown').toLowerCase();
115
+ return lang === 'en' || lang === 'english';
116
+ })
117
+ .slice(0, MAX_POSTS_TO_STORE - totalSaved);
118
+
119
+ if (newPostsWithDiscussion.length === 0) {
120
+ keepFetching = false;
121
+ continue;
122
+ }
123
+
124
+ // Process posts in batches
125
+ const batch = db.batch();
126
+ let pageBatchCount = 0;
127
+
128
+ // Process first post with AI analysis (if available)
129
+ if (newPostsWithDiscussion.length > 0 && dependencies.geminiModel) {
130
+ const discussion = newPostsWithDiscussion[0];
131
+ const post = discussion.post;
132
+ const snippet = post.message.text.substring(0, 500);
133
+
134
+ try {
135
+ const { getAdvancedAnalysisFromGemini } = require('../../social-task-handler/helpers/handler_helpers');
136
+ const aiResult = await getAdvancedAnalysisFromGemini(dependencies, snippet);
137
+
138
+ const docData = {
139
+ postId: post.id,
140
+ text: post.message.text,
141
+ ownerId: post.owner?.id,
142
+ username: post.owner?.username,
143
+ createdAt: post.created,
144
+ fetchedAt: FieldValue.serverTimestamp(),
145
+ snippet: snippet,
146
+ stats: {
147
+ likes: discussion.emotionsData?.like?.paging?.totalCount || 0,
148
+ comments: discussion.summary?.totalCommentsAndReplies || 0
149
+ },
150
+ aiAnalysis: aiResult,
151
+ tags: post.tags?.map(t => t.market?.symbolName).filter(Boolean) || []
152
+ };
153
+
154
+ batch.set(db.collection(targetCollectionPath).doc(post.id), docData);
155
+ batch.set(processedRef.doc(post.id), { processedAt: FieldValue.serverTimestamp() });
156
+ pageBatchCount++;
157
+ } catch (aiError) {
158
+ logger.log('WARN', `[SocialFetch/${taskId}] AI analysis failed, using default`, aiError);
159
+ // Fall through to default processing
160
+ }
161
+ }
162
+
163
+ // Process remaining posts without AI (or if AI failed)
164
+ const postsToProcess = newPostsWithDiscussion.slice(pageBatchCount > 0 ? 1 : 0);
165
+ for (const discussion of postsToProcess) {
166
+ if (totalSaved + pageBatchCount >= MAX_POSTS_TO_STORE) {
167
+ keepFetching = false;
168
+ break;
169
+ }
170
+
171
+ const post = discussion.post;
172
+ const docData = {
173
+ postId: post.id,
174
+ text: post.message.text,
175
+ ownerId: post.owner?.id,
176
+ username: post.owner?.username,
177
+ createdAt: post.created,
178
+ fetchedAt: FieldValue.serverTimestamp(),
179
+ snippet: post.message.text.substring(0, 500),
180
+ stats: {
181
+ likes: discussion.emotionsData?.like?.paging?.totalCount || 0,
182
+ comments: discussion.summary?.totalCommentsAndReplies || 0
183
+ },
184
+ aiAnalysis: {
185
+ overallSentiment: "Neutral",
186
+ topics: [],
187
+ isSpam: false,
188
+ qualityScore: 0.5
189
+ },
190
+ tags: post.tags?.map(t => t.market?.symbolName).filter(Boolean) || []
191
+ };
192
+
193
+ batch.set(db.collection(targetCollectionPath).doc(post.id), docData);
194
+ batch.set(processedRef.doc(post.id), { processedAt: FieldValue.serverTimestamp() });
195
+ pageBatchCount++;
196
+ }
197
+
198
+ if (pageBatchCount > 0) {
199
+ await batch.commit();
200
+ totalSaved += pageBatchCount;
201
+ }
202
+ offset += take;
203
+ }
204
+
205
+ // Write date tracking document after successful fetch
206
+ if (totalSaved > 0 && (type === 'SIGNED_IN_USER' || type === 'POPULAR_INVESTOR')) {
207
+ const today = new Date().toISOString().split('T')[0];
208
+ let trackingCollectionPath;
209
+
210
+ if (type === 'SIGNED_IN_USER') {
211
+ trackingCollectionPath = config.social?.signedInUserSocialCollection || 'signed_in_users_social';
212
+ } else {
213
+ trackingCollectionPath = config.social?.piSocialCollectionName || 'pi_social_posts';
214
+ }
215
+
216
+ // Write to a single tracking document at the root of the collection
217
+ const trackingDocRef = db.collection(trackingCollectionPath).doc('_dates');
218
+ const trackingData = {
219
+ [`fetchedDates.${today}`]: true,
220
+ lastUpdated: FieldValue.serverTimestamp()
221
+ };
222
+
223
+ await trackingDocRef.set(trackingData, { merge: true });
224
+ logger.log('INFO', `[SocialFetch/${taskId}] Updated date tracking for ${type}: ${today}`);
225
+ }
226
+
227
+ // Decrement batch counter if it exists
228
+ if (batchCounterRef && (type === 'SIGNED_IN_USER' || type === 'POPULAR_INVESTOR')) {
229
+ try {
230
+ await batchCounterRef.update({
231
+ remainingTasks: FieldValue.increment(-1),
232
+ lastUpdated: FieldValue.serverTimestamp()
233
+ });
234
+
235
+ // Check if this was the last task
236
+ const counterDoc = await batchCounterRef.get();
237
+ if (counterDoc.exists) {
238
+ const counterData = counterDoc.data();
239
+ if (counterData.remainingTasks <= 0 && !counterData.rootDataIndexed) {
240
+ // All tasks complete - trigger root data indexer
241
+ const today = new Date().toISOString().split('T')[0];
242
+ logger.log('INFO', `[SocialFetch] All social batch tasks complete. Triggering root data indexer for ${today}...`);
243
+
244
+ try {
245
+ const { runRootDataIndexer } = require('../../root-data-indexer/index');
246
+ const rootDataIndexerConfig = config.rootDataIndexer || {};
247
+ const indexerConfig = {
248
+ ...rootDataIndexerConfig,
249
+ targetDate: today
250
+ };
251
+
252
+ await runRootDataIndexer(indexerConfig, dependencies);
253
+
254
+ // Mark as indexed
255
+ await batchCounterRef.update({
256
+ rootDataIndexed: true,
257
+ rootDataIndexedAt: FieldValue.serverTimestamp()
258
+ });
259
+
260
+ logger.log('SUCCESS', `[SocialFetch] Root data indexer completed for ${today}`);
261
+ } catch (indexerError) {
262
+ logger.log('ERROR', `[SocialFetch] Failed to run root data indexer for ${today}`, indexerError);
263
+ }
264
+ }
265
+ }
266
+ } catch (counterError) {
267
+ logger.log('WARN', `[SocialFetch] Failed to update batch counter`, counterError);
268
+ }
269
+ }
270
+
271
+ logger.log('SUCCESS', `[SocialFetch/${taskId}] Process complete for ${cid}. Total stored: ${totalSaved}/${MAX_POSTS_TO_STORE}`);
272
+
273
+ } catch (error) {
274
+ logger.log('ERROR', `[SocialFetch/${taskId}] Fatal error in processing.`, error);
275
+ throw error;
276
+ } finally {
277
+ await headerManager.flushPerformanceUpdates();
278
+ }
279
+ }
280
+
281
+ module.exports = { handleSocialFetch };
282
+
@@ -14,6 +14,7 @@ const { handleDiscover } = require('../helpers/discover_helpers');
14
14
  const { handleVerify } = require('../helpers/verify_helpers');
15
15
  const { handleUpdate } = require('../helpers/update_helpers');
16
16
  const { handlePopularInvestorUpdate, handleOnDemandUserUpdate } = require('../helpers/popular_investor_helpers');
17
+ const { handleSocialFetch } = require('../helpers/social_helpers');
17
18
  const pLimit = require('p-limit');
18
19
 
19
20
  /**
@@ -29,33 +30,37 @@ function parseTaskPayload(message, logger) {
29
30
  }
30
31
 
31
32
  /**
32
- * Sorts tasks into update and other (discover/verify/PI/OnDemand).
33
+ * Sorts tasks into update, other (discover/verify/PI/OnDemand), and social.
33
34
  */
34
35
  async function prepareTaskBatches(tasks, batchManager, logger) {
35
- const tasksToRun = [], otherTasks = [];
36
+ const tasksToRun = [], otherTasks = [], socialTasks = [];
36
37
 
37
38
  for (const task of tasks) {
38
39
  if (task.type === 'update') {
39
40
  // Standard portfolio updates (Normal/Speculator ONLY)
40
41
  // NOTE: Popular Investors use type 'POPULAR_INVESTOR_UPDATE', not 'update'
41
42
  tasksToRun.push(task);
43
+ } else if (task.type && task.type.startsWith('SOCIAL_')) {
44
+ // Social fetch tasks
45
+ socialTasks.push(task);
42
46
  } else {
43
47
  // Discover, Verify, Popular Investor (POPULAR_INVESTOR_UPDATE), Signed-In User (ON_DEMAND_USER_UPDATE)
44
48
  otherTasks.push(task);
45
49
  }
46
50
  }
47
51
 
48
- logger.log('INFO', `[TaskEngine] Task sorting: ${tasksToRun.length} UPDATE (normal/speculator), ${otherTasks.length} other (PI/signed-in/discover/verify)`);
52
+ logger.log('INFO', `[TaskEngine] Task sorting: ${tasksToRun.length} UPDATE (normal/speculator), ${otherTasks.length} other (PI/signed-in/discover/verify), ${socialTasks.length} social`);
49
53
 
50
- return { tasksToRun, cidsToLookup: new Map(), otherTasks };
54
+ return { tasksToRun, cidsToLookup: new Map(), otherTasks, socialTasks };
51
55
  }
52
56
 
53
57
  /**
54
58
  * Executes all tasks.
55
59
  * (FIXED: Concurrency limit increased to 5)
60
+ * (NEW: Added batch counter support for root data indexing)
56
61
  */
57
- async function executeTasks(tasksToRun, otherTasks, dependencies, config, taskId) {
58
- const { logger, batchManager } = dependencies;
62
+ async function executeTasks(tasksToRun, otherTasks, dependencies, config, taskId, batchCounterRef = null, targetDate = null, socialTasks = [], socialCounterRef = null) {
63
+ const { logger, batchManager, db } = dependencies;
59
64
 
60
65
  // [CRITICAL FIX] Increased from 1 to 5.
61
66
  // A limit of 1 was causing timeouts on batches of 500 tasks (500s > 60s/540s timeout).
@@ -130,28 +135,103 @@ async function executeTasks(tasksToRun, otherTasks, dependencies, config, taskId
130
135
  allTaskPromises.push(
131
136
  limit(() =>
132
137
  handleUpdate(task, subTaskId, dependencies, config)
133
- .then(() => taskCounters.update++)
138
+ .then(async () => {
139
+ taskCounters.update++;
140
+
141
+ // Decrement batch counter if it exists
142
+ if (batchCounterRef) {
143
+ try {
144
+ await batchCounterRef.update({
145
+ remainingTasks: require('@google-cloud/firestore').FieldValue.increment(-1),
146
+ lastUpdated: require('@google-cloud/firestore').FieldValue.serverTimestamp()
147
+ });
148
+
149
+ // Check if this was the last task
150
+ const counterDoc = await batchCounterRef.get();
151
+ if (counterDoc.exists) {
152
+ const counterData = counterDoc.data();
153
+ if (counterData.remainingTasks <= 0 && !counterData.rootDataIndexed) {
154
+ // All tasks complete - trigger root data indexer
155
+ logger.log('INFO', `[TaskEngine/${taskId}] All batch tasks complete. Triggering root data indexer for ${targetDate}...`);
156
+
157
+ try {
158
+ const { runRootDataIndexer } = require('../../root-data-indexer/index');
159
+ const rootDataIndexerConfig = config.rootDataIndexer || {};
160
+ const indexerConfig = {
161
+ ...rootDataIndexerConfig,
162
+ targetDate: targetDate
163
+ };
164
+
165
+ await runRootDataIndexer(indexerConfig, dependencies);
166
+
167
+ // Mark as indexed
168
+ await batchCounterRef.update({
169
+ rootDataIndexed: true,
170
+ rootDataIndexedAt: require('@google-cloud/firestore').FieldValue.serverTimestamp()
171
+ });
172
+
173
+ logger.log('SUCCESS', `[TaskEngine/${taskId}] Root data indexer completed for ${targetDate}`);
174
+ } catch (indexerError) {
175
+ logger.log('ERROR', `[TaskEngine/${taskId}] Failed to run root data indexer for ${targetDate}`, indexerError);
176
+ }
177
+ }
178
+ }
179
+ } catch (counterError) {
180
+ logger.log('WARN', `[TaskEngine/${taskId}] Failed to update batch counter`, counterError);
181
+ }
182
+ }
183
+ })
134
184
  .catch(err => {
135
185
  logger.log('ERROR', `[TaskEngine/${taskId}] Error in handleUpdate for ${task.userId}`, { errorMessage: err.message });
136
186
  taskCounters.failed++;
187
+
188
+ // Still decrement counter on failure to avoid blocking
189
+ if (batchCounterRef) {
190
+ batchCounterRef.update({
191
+ remainingTasks: require('@google-cloud/firestore').FieldValue.increment(-1)
192
+ }).catch(() => {});
193
+ }
194
+ })
195
+ )
196
+ );
197
+ }
198
+
199
+ // 3. Queue social tasks
200
+ for (const task of socialTasks) {
201
+ const taskData = task.data || task;
202
+ const socialType = task.type === 'SOCIAL_INSTRUMENT_FETCH' ? 'INSTRUMENT' :
203
+ task.type === 'SOCIAL_PI_FETCH' ? 'POPULAR_INVESTOR' : 'SIGNED_IN_USER';
204
+
205
+ allTaskPromises.push(
206
+ limit(() =>
207
+ handleSocialFetch({
208
+ type: socialType,
209
+ id: taskData.id,
210
+ username: taskData.username,
211
+ since: taskData.since
212
+ }, config, dependencies, socialCounterRef)
213
+ .then(() => taskCounters.social = (taskCounters.social || 0) + 1)
214
+ .catch(err => {
215
+ logger.log('ERROR', `[TaskEngine/${taskId}] Error in social fetch for ${taskData.id}`, { errorMessage: err.message });
216
+ taskCounters.failed++;
137
217
  })
138
218
  )
139
219
  );
140
220
  }
141
221
 
142
- // 3. Wait for ALL tasks to complete
222
+ // 4. Wait for ALL tasks to complete
143
223
  await Promise.all(allTaskPromises);
144
224
 
145
- // 4. Flush any remaining data in the buffer
225
+ // 5. Flush any remaining data in the buffer
146
226
  if (batchManager) {
147
227
  logger.log('INFO', `[TaskEngine/${taskId}] Triggering final batch flush...`);
148
228
  await batchManager.flushBatches();
149
229
  }
150
230
 
151
- // 5. Log final summary
231
+ // 6. Log final summary
152
232
  logger.log(
153
233
  taskCounters.failed > 0 ? 'WARN' : 'SUCCESS',
154
- `[TaskEngine/${taskId}] Processed all tasks. Updates: ${taskCounters.update}, Discovers: ${taskCounters.discover}, Verifies: ${taskCounters.verify}, PI: ${taskCounters.popular_investor}, OnDemand: ${taskCounters.on_demand}, Unknown: ${taskCounters.unknown}, Failed: ${taskCounters.failed}.`
234
+ `[TaskEngine/${taskId}] Processed all tasks. Updates: ${taskCounters.update}, Discovers: ${taskCounters.discover}, Verifies: ${taskCounters.verify}, PI: ${taskCounters.popular_investor}, OnDemand: ${taskCounters.on_demand}, Social: ${taskCounters.social || 0}, Unknown: ${taskCounters.unknown}, Failed: ${taskCounters.failed}.`
155
235
  );
156
236
  }
157
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.474",
3
+ "version": "1.0.475",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [