channel-worker 2.5.115 → 2.5.118

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/lib/api-client.js CHANGED
@@ -75,7 +75,7 @@ class ApiClient {
75
75
  // nào — xảy ra 14/09 với verify_facebook_reels_pw (cổng thứ nhất là enum
76
76
  // của app/models/WorkerCommand.js, cũng đã quên một lần cùng ngày).
77
77
  // api/tests/publish-verify-wiring.test.js canh cả hai cổng.
78
- const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,upload_facebook_post_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,verify_facebook_reels_pw,comment_facebook_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link';
78
+ const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,upload_facebook_post_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,verify_facebook_reels_pw,comment_facebook_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link,fetch_affiliate_report';
79
79
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
80
80
  }
81
81
 
@@ -107,6 +107,10 @@ class ApiClient {
107
107
  return this.request('POST', '/products/worker-link', { links, user_id, sub_ids, idea_id });
108
108
  }
109
109
 
110
+ async affiliateReportResult(rows, meta = {}) {
111
+ return this.request('POST', '/affiliate-report/worker-ingest', { rows, ...meta });
112
+ }
113
+
110
114
  // Return the calling daemon's own Worker doc — primarily for reading
111
115
  // parallel_limit (the per-daemon scene-generation concurrency cap that
112
116
  // replaced the legacy global flowkit_max_concurrent setting).
@@ -125,6 +125,9 @@ class CommandPoller {
125
125
  case 'get_affiliate_link':
126
126
  await this.handleGetAffiliateLink(command);
127
127
  break;
128
+ case 'fetch_affiliate_report':
129
+ await this.handleFetchAffiliateReport(command);
130
+ break;
128
131
  default:
129
132
  // Playwright-based pipeline: any command whose type ends in '_pw'
130
133
  // is routed to scripts/<base>.js (BrowserClaw-style automation
@@ -339,6 +342,59 @@ class CommandPoller {
339
342
  }
340
343
  }
341
344
 
345
+ async handleFetchAffiliateReport(command) {
346
+ const payload = command.payload || {};
347
+ if (!(await this._ensureNst(command))) return;
348
+ const report = require('./shopee-report');
349
+ const profileName = payload.profile_name
350
+ || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null))
351
+ || 'Shopee1';
352
+ let conn;
353
+ try {
354
+ conn = await this._connectNstProfileByName(profileName);
355
+ const result = await report.fetchConversionReport(conn.context, {
356
+ days: payload.days || 90,
357
+ log: (message) => console.log(message),
358
+ });
359
+ if (result.status === 'needs_verify') {
360
+ await this.api.updateCommand(command._id, {
361
+ status: 'done',
362
+ result: { needs_verify: true, reason: result.reason, count: result.rows?.length || 0 },
363
+ });
364
+ return;
365
+ }
366
+ if (result.status !== 'ok') {
367
+ await this.api.updateCommand(command._id, {
368
+ status: 'failed', error: String(result.reason || 'Không đọc được báo cáo Shopee').slice(0, 500),
369
+ });
370
+ return;
371
+ }
372
+
373
+ const saved = await this.api.affiliateReportResult(result.rows, {
374
+ last_report_date: result.last_report_date,
375
+ last_update_time: result.last_update_time,
376
+ from: result.from,
377
+ to: result.to,
378
+ });
379
+ await this.api.updateCommand(command._id, {
380
+ status: 'done',
381
+ result: {
382
+ count: result.rows.length,
383
+ saved: saved?.saved || 0,
384
+ matched: saved?.matched || 0,
385
+ unmatched: saved?.unmatched || 0,
386
+ last_report_date: result.last_report_date,
387
+ },
388
+ });
389
+ console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} dòng; khớp kênh ${saved?.matched || 0}`);
390
+ } catch (err) {
391
+ console.error(`[shopee-report] đồng bộ thất bại: ${err.message}`);
392
+ await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
393
+ } finally {
394
+ if (conn) conn.disconnect();
395
+ }
396
+ }
397
+
342
398
  async handlePlaywrightCommand(command) {
343
399
  const { runPlaywrightScript } = require('./playwright-runner');
344
400
  const payload = command.payload || {};
@@ -0,0 +1,107 @@
1
+ const { findAffiliatePage, isBlockedState } = require('./shopee-scraper');
2
+
3
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+
5
+ function reportWindow(days = 90, nowMs = Date.now()) {
6
+ const safeDays = Math.min(180, Math.max(1, Number(days || 90)));
7
+ const vnNow = new Date(nowMs + 7 * 3600e3);
8
+ const vnDayStartAsUtc = Date.parse(`${vnNow.toISOString().slice(0, 10)}T00:00:00.000Z`);
9
+ const end = Math.floor(nowMs / 1000);
10
+ const start = Math.floor((vnDayStartAsUtc - 7 * 3600e3 - (safeDays - 1) * 86400e3) / 1000);
11
+ return { start, end, days: safeDays };
12
+ }
13
+
14
+ async function fetchConversionReport(ctx, {
15
+ days = 90,
16
+ pageSize = 100,
17
+ maxPages = 200,
18
+ delayMs = 750,
19
+ timeoutMs = 60000,
20
+ log = null,
21
+ } = {}) {
22
+ let page = findAffiliatePage(ctx.pages());
23
+ let opened = false;
24
+ if (!page) {
25
+ page = await ctx.newPage();
26
+ opened = true;
27
+ }
28
+
29
+ try {
30
+ if (!page.url().includes('/report/conversion_report')) {
31
+ await page.goto('https://affiliate.shopee.vn/report/conversion_report', {
32
+ waitUntil: 'domcontentloaded', timeout: timeoutMs,
33
+ }).catch(() => {});
34
+ await sleep(1500);
35
+ }
36
+ if (isBlockedState(page.url(), null)) {
37
+ return { status: 'needs_verify', rows: [], reason: 'session at captcha/login — re-verify via VNC' };
38
+ }
39
+
40
+ const window = reportWindow(days);
41
+ const rows = [];
42
+ let total = null;
43
+ let lastReportDate = null;
44
+ let lastUpdateTime = null;
45
+
46
+ const updated = await page.evaluate(async () => {
47
+ try {
48
+ const r = await fetch('/api/v3/gql?q=conversionReportUpdateTime', {
49
+ method: 'POST', credentials: 'include',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({
52
+ operationName: 'conversionReportUpdateTime',
53
+ query: 'query conversionReportUpdateTime { conversionReportUpdateTime { lastReportDate lastUpdateTime } }',
54
+ variables: {},
55
+ }),
56
+ });
57
+ return await r.json().catch(() => null);
58
+ } catch { return null; }
59
+ });
60
+ lastReportDate = updated?.data?.conversionReportUpdateTime?.lastReportDate || null;
61
+ lastUpdateTime = updated?.data?.conversionReportUpdateTime?.lastUpdateTime || null;
62
+
63
+ for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
64
+ const result = await page.evaluate(async (q) => {
65
+ const url = `/api/v3/report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
66
+ + `&purchase_time_s=${q.start}&purchase_time_e=${q.end}&version=1`;
67
+ try {
68
+ const r = await fetch(url, { credentials: 'include' });
69
+ return { status: r.status, json: await r.json().catch(() => null) };
70
+ } catch (e) {
71
+ return { status: 0, error: String(e?.message || e) };
72
+ }
73
+ }, { pageSize, pageNum, start: window.start, end: window.end });
74
+
75
+ if (isBlockedState(page.url(), result) || result.json?.code === 90309999) {
76
+ return { status: 'needs_verify', rows, reason: `blocked while reading report (http ${result.status}, code ${result.json?.code})` };
77
+ }
78
+ if (result.status !== 200 || result.json?.code !== 0) {
79
+ return { status: 'failed', rows, reason: result.json?.msg || result.error || `report HTTP ${result.status}` };
80
+ }
81
+
82
+ const data = result.json?.data || {};
83
+ const batch = Array.isArray(data.list) ? data.list : [];
84
+ total = Number(data.total_count || 0);
85
+ rows.push(...batch);
86
+ if (log) log(`[shopee-report] trang ${pageNum}: ${batch.length} dòng (${rows.length}/${total})`);
87
+ if (!batch.length || rows.length >= total || batch.length < pageSize) break;
88
+ await sleep(delayMs);
89
+ }
90
+
91
+ if (total != null && rows.length < total) {
92
+ return { status: 'failed', rows, reason: `report pagination stopped at ${rows.length}/${total} rows` };
93
+ }
94
+
95
+ return {
96
+ status: 'ok', rows, total: total ?? rows.length,
97
+ from: new Date(window.start * 1000).toISOString(),
98
+ to: new Date(window.end * 1000).toISOString(),
99
+ last_report_date: lastReportDate,
100
+ last_update_time: lastUpdateTime,
101
+ };
102
+ } finally {
103
+ if (opened) await page.close().catch(() => {});
104
+ }
105
+ }
106
+
107
+ module.exports = { fetchConversionReport, reportWindow };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.115",
3
+ "version": "2.5.118",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -21,6 +21,8 @@ const CODES = {
21
21
  banned: 'FB_ACCOUNT_BANNED',
22
22
  };
23
23
 
24
+ const PAGE_NOT_CONFIGURED_CODE = 'FB_PAGE_NOT_CONFIGURED';
25
+
24
26
  const PUBLISH_BLOCK_PHRASES = [
25
27
  'giới hạn tần suất',
26
28
  'để bảo vệ cộng đồng khỏi spam',
@@ -108,6 +110,81 @@ async function assertAccountUsable(page, log) {
108
110
  throw err;
109
111
  }
110
112
 
113
+ // Is this NST session browsing Facebook as the person or as a Page?
114
+ //
115
+ // This is shared by nurture and publish. Publishing through a personal profile
116
+ // is a configuration error for this product: every channel is expected to own
117
+ // a Facebook Page. Without this guard, the personal "Tạo bài viết" widget
118
+ // (Video trực tiếp / Ảnh-video / Cảm xúc-hoạt động) falls through to
119
+ // /reels/create/. Facebook can still open a PERSONAL Reel composer there, so
120
+ // the upload fails later with a completely misleading "composer closed" error.
121
+ function classifyProfileModeSignals(signals = {}) {
122
+ // Personal-composer evidence wins over the navigation shortcuts. Facebook's
123
+ // Professional mode gives a PERSON `professional_dashboard` too; that exact
124
+ // combination made "thời trang 03" persist `profile_mode: page` even though
125
+ // its composer still showed "Cảm xúc/hoạt động" and no Reel control.
126
+ if (signals.hasFriends || (signals.hasFeelingActivity && !signals.hasReelControl)) return 'personal';
127
+ const clearPage = (signals.proLink || signals.proText) && !signals.hasFriends;
128
+ if (clearPage) return 'page';
129
+ return 'unknown';
130
+ }
131
+
132
+ async function readProfileMode(page) {
133
+ const signals = await page.evaluate(() => {
134
+ const navs = [...document.querySelectorAll("[role='navigation']")];
135
+ const navText = navs.map((n) => n.innerText || '').join(' | ');
136
+ const hrefs = navs.flatMap((n) => [...n.querySelectorAll('a[href]')].map((a) => a.getAttribute('href') || ''));
137
+ const proLink = hrefs.some((h) => /professional_dashboard|adsmanager|ad_center|ads\/manage|business\.facebook/i.test(h));
138
+ const proText = /Công cụ chuyên nghiệp|Professional dashboard|Trung tâm quảng cáo|Ad Center|Trình quản lý quảng cáo|Ads Manager/i.test(navText);
139
+ const hasFriends = /\bBạn bè\b|\bFriends\b/i.test(navText);
140
+
141
+ let hasFeelingActivity = false;
142
+ let hasReelControl = false;
143
+ for (const region of document.querySelectorAll("[role='region'], [aria-label='Tạo bài viết'], [aria-label='Create post'], [aria-label='Create a post']")) {
144
+ const aria = (region.getAttribute('aria-label') || '').trim();
145
+ if (!/tạo bài viết|create (?:a )?post/i.test(aria)) continue;
146
+ for (const el of region.querySelectorAll("[role='button'], button, a")) {
147
+ const label = `${el.innerText || el.textContent || ''}\n${el.getAttribute('aria-label') || ''}`.trim();
148
+ if (/Cảm xúc\s*\/\s*hoạt động|Feeling\s*\/\s*activity|Feeling or activity/i.test(label)) hasFeelingActivity = true;
149
+ if (/^\s*(?:Thước phim|Reels?|Create (?:a )?reel|Tạo thước phim)\s*$/im.test(label)) hasReelControl = true;
150
+ }
151
+ }
152
+
153
+ let timelineLabel = '';
154
+ for (const el of document.querySelectorAll("a[aria-label]")) {
155
+ const label = (el.getAttribute('aria-label') || '').trim();
156
+ if (/^Dòng thời gian của\s+|(?:'s|’s) Timeline$/i.test(label)) {
157
+ timelineLabel = label.slice(0, 120);
158
+ break;
159
+ }
160
+ }
161
+ return {
162
+ proLink, proText, hasFriends, hasFeelingActivity, hasReelControl, timelineLabel,
163
+ navSample: navText.replace(/\s+/g, ' ').slice(0, 140),
164
+ };
165
+ }).catch(() => null);
166
+
167
+ if (!signals) return { mode: 'unknown', isPage: false, why: 'detect failed', navSample: '', timelineLabel: '' };
168
+ const mode = classifyProfileModeSignals(signals);
169
+ return {
170
+ mode,
171
+ isPage: mode === 'page',
172
+ why: `mode=${mode} proLink=${signals.proLink} proText=${signals.proText} friends=${signals.hasFriends} feeling=${signals.hasFeelingActivity} reel=${signals.hasReelControl}`,
173
+ navSample: signals.navSample || '',
174
+ timelineLabel: signals.timelineLabel || '',
175
+ };
176
+ }
177
+
178
+ function pageNotConfiguredError(profileMode = {}) {
179
+ const who = profileMode.timelineLabel ? ` (${profileMode.timelineLabel})` : '';
180
+ const err = new Error(
181
+ `${PAGE_NOT_CONFIGURED_CODE}: Kênh chưa cấu hình Facebook Page — NST profile đang dùng Facebook dưới dạng trang cá nhân${who}. `
182
+ + 'Hãy tạo Page, chuyển profile sang Page đó rồi đăng lại.',
183
+ );
184
+ err.code = PAGE_NOT_CONFIGURED_CODE;
185
+ return err;
186
+ }
187
+
111
188
  async function readPublishBlock(page) {
112
189
  return page.evaluate((phrases) => {
113
190
  const text = (document.body?.innerText || '').toLowerCase();
@@ -120,5 +197,13 @@ function publishRateLimitError(blockHit) {
120
197
  }
121
198
 
122
199
  module.exports = {
123
- readAccountState, assertAccountUsable, readPublishBlock, publishRateLimitError, CODES,
200
+ readAccountState,
201
+ assertAccountUsable,
202
+ readProfileMode,
203
+ classifyProfileModeSignals,
204
+ pageNotConfiguredError,
205
+ readPublishBlock,
206
+ publishRateLimitError,
207
+ CODES,
208
+ PAGE_NOT_CONFIGURED_CODE,
124
209
  };
@@ -19,7 +19,7 @@
19
19
  // A dead session (logged out / checkpoint / disabled) throws a CODED error via
20
20
  // lib/fb-guard so the API can stop the schedule and raise a notification.
21
21
 
22
- const { assertAccountUsable } = require('./lib/fb-guard');
22
+ const { assertAccountUsable, readProfileMode } = require('./lib/fb-guard');
23
23
  const { pick, clickHandle } = require('./lib/dom-pick');
24
24
  const { humanMove, humanWheel, humanClick } = require('./lib/human');
25
25
 
@@ -435,32 +435,6 @@ async function sideTrip(page, log) {
435
435
  return true;
436
436
  }
437
437
 
438
- // Is this profile browsing AS A PAGE rather than as the person?
439
- //
440
- // Why it matters: a Page's feed contains only the Page's own posts, so there is
441
- // nothing to nurture — measured live: 1 post, feed bottoms out after ~1500px.
442
- // And a profile already switched into a Page has, by definition, got its Page —
443
- // which is the whole goal of nurturing. So this ends the session immediately and
444
- // takes the channel off the schedule.
445
- //
446
- // Signal: the left nav carries Page-only entries (Công cụ chuyên nghiệp / Trung
447
- // tâm quảng cáo / Ads Manager) and lacks the personal "Bạn bè" entry.
448
- async function detectProfileMode(page) {
449
- return page.evaluate(() => {
450
- const navs = [...document.querySelectorAll("[role='navigation']")];
451
- const navText = navs.map((n) => n.innerText || '').join(' | ');
452
- const hrefs = navs.flatMap((n) => [...n.querySelectorAll('a[href]')].map((a) => a.getAttribute('href') || ''));
453
- const proLink = hrefs.some((h) => /professional_dashboard|adsmanager|ad_center|ads\/manage|business\.facebook/i.test(h));
454
- const proText = /Công cụ chuyên nghiệp|Professional dashboard|Trung tâm quảng cáo|Ad Center|Trình quản lý quảng cáo|Ads Manager/i.test(navText);
455
- const hasFriends = /\bBạn bè\b|\bFriends\b/i.test(navText);
456
- return {
457
- isPage: (proLink || proText) && !hasFriends,
458
- why: `proLink=${proLink} proText=${proText} friends=${hasFriends}`,
459
- navSample: navText.replace(/\s+/g, ' ').slice(0, 140),
460
- };
461
- }).catch(() => ({ isPage: false, why: 'detect failed', navSample: '' }));
462
- }
463
-
464
438
  // ONE tab, always.
465
439
  //
466
440
  // Two things pile tabs up behind a session, and neither is ours: NSTBrowser
@@ -568,7 +542,7 @@ async function run({ page, context, payload, log }) {
568
542
  await dismissDialogs(page, log);
569
543
 
570
544
  // Already a Page? Then the goal is met — stop here, don't fake a session.
571
- const mode = await detectProfileMode(page);
545
+ const mode = await readProfileMode(page);
572
546
  if (payload.debug) log('info', `[nurture-fb][dbg] mode ${mode.why} nav="${mode.navSample}"`);
573
547
  if (mode.isPage) {
574
548
  log('info', '[nurture-fb] profile đang dùng dưới dạng TRANG (đã có Page) — bỏ qua, không cần nuôi nữa');
@@ -17,7 +17,13 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const { humanMove, humanClick, humanType, humanWheel, pause, randInt: hRandInt, chance: hChance } = require('./lib/human');
20
- const { assertAccountUsable, readPublishBlock, publishRateLimitError } = require('./lib/fb-guard');
20
+ const {
21
+ assertAccountUsable,
22
+ readProfileMode,
23
+ pageNotConfiguredError,
24
+ readPublishBlock,
25
+ publishRateLimitError,
26
+ } = require('./lib/fb-guard');
21
27
  const path = require('path');
22
28
  const { downloadToTemp, safeUnlink } = require('./lib/download');
23
29
 
@@ -1123,6 +1129,16 @@ async function runOnce({ page, payload, log }) {
1123
1129
  // code and opens the Facebook auto-publish circuit breaker.
1124
1130
  await assertAccountUsable(page, log);
1125
1131
 
1132
+ // This uploader publishes to a Facebook PAGE. A personal account can also
1133
+ // open /reels/create/, which used to let an unconfigured channel get deep
1134
+ // into the wizard and finally surface as the unrelated "composer closed"
1135
+ // retry error. Stop before touching the video and tell the operator what is
1136
+ // actually missing. Unknown layouts continue to the existing DOM probes so
1137
+ // a Facebook A/B change does not falsely block a valid Page.
1138
+ const profileMode = await readProfileMode(page);
1139
+ log('info', `[fb-pw] Facebook identity check: ${profileMode.why}${profileMode.timelineLabel ? ` timeline="${profileMode.timelineLabel}"` : ''}`);
1140
+ if (profileMode.mode === 'personal') throw pageNotConfiguredError(profileMode);
1141
+
1126
1142
  // Look around before posting. The old flow was goto → 4s → straight into
1127
1143
  // "Thước phim", every single run: a session whose only actions are
1128
1144
  // open→post→leave. A person glances at the wall first. 2-5 wheel bursts
@@ -3465,7 +3481,7 @@ async function run(args) {
3465
3481
  } catch (e) {
3466
3482
  // A checkpoint/logout can appear mid-composer. Prefer the coded account
3467
3483
  // error over the selector symptom so the server stops unattended retries.
3468
- if (!/^FB_(?:ACCOUNT|PUBLISH)_/.test(String(e?.message || ''))) {
3484
+ if (!/^FB_(?:ACCOUNT|PUBLISH|PAGE)_/.test(String(e?.message || ''))) {
3469
3485
  try { await assertAccountUsable(page, log); } catch (accountErr) { throw accountErr; }
3470
3486
  }
3471
3487
  const safeRetry = e?.code === SAFE_RETRY_COMPOSER_CLOSED;