channel-worker 2.5.143 → 2.5.145
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 +1 -1
- package/lib/command-poller.js +161 -133
- package/lib/playwright-runner.js +33 -15
- package/lib/shopee-report.js +3 -25
- package/lib/shopee-scraper.js +42 -7
- package/package.json +1 -1
- package/scripts/facebook_page_check.js +74 -0
- package/scripts/upload_facebook.js +4 -0
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,fetch_affiliate_report';
|
|
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,facebook_page_check_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
|
|
package/lib/command-poller.js
CHANGED
|
@@ -14,12 +14,19 @@ const PW_IDLE_CLOSE_MS = Number(process.env.PW_IDLE_CLOSE_MS || 5 * 60 * 1000);
|
|
|
14
14
|
// trước mũi chủ tịch (đo thật: kenh-21, mở 11:42 → bị đóng vài phút sau).
|
|
15
15
|
const HUMAN_HOLD_MS = Number(process.env.HUMAN_HOLD_MS || 15 * 60 * 1000);
|
|
16
16
|
|
|
17
|
-
// Hạn chót cho CẢ
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
17
|
+
// Hạn chót cho CẢ một lệnh Shopee (nối profile + mọi lượt gọi của lệnh). Không
|
|
18
|
+
// có hạn thì một bước kẹt giữ lệnh `running` vô hạn: 28/09/2026 lượt đọc báo
|
|
19
|
+
// cáo của Shopee2 treo 12 tiếng, khoá nút Đồng bộ, lịch của máy và cả lượt xả
|
|
20
|
+
// daemon (drain chờ `_handling` rỗng). Bốn loại dùng CHUNG một phiên và chung
|
|
21
|
+
// kiểu gọi nên chung một cửa treo. Số đo lượt thật: báo cáo 90 ngày ~1 phút
|
|
22
|
+
// (26/09: 808 đơn + 9.268 click trong 54s); lấy link 3-4 giây; quét từ khoá
|
|
23
|
+
// ~10-16 giây. API có bộ dọn riêng ở ngưỡng dài hơn giá trị lớn nhất ở đây.
|
|
24
|
+
const SHOPEE_DEADLINE_MS = {
|
|
25
|
+
fetch_affiliate_report: 10 * 60 * 1000,
|
|
26
|
+
scrape_affiliate_products: 10 * 60 * 1000,
|
|
27
|
+
get_affiliate_link: 3 * 60 * 1000,
|
|
28
|
+
ingest_shopee_product: 3 * 60 * 1000,
|
|
29
|
+
};
|
|
23
30
|
|
|
24
31
|
class CommandPoller {
|
|
25
32
|
constructor(api, config) {
|
|
@@ -39,7 +46,7 @@ class CommandPoller {
|
|
|
39
46
|
// pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
|
|
40
47
|
// "rảnh rồi, tắt" sẽ mang về một lệnh đã bị nhận — tắt lúc đó là giết nó.
|
|
41
48
|
this._pollsInFlight = 0;
|
|
42
|
-
this.
|
|
49
|
+
this.shopeeDeadlineMs = { ...SHOPEE_DEADLINE_MS };
|
|
43
50
|
|
|
44
51
|
// Init Nstbrowser if API key available
|
|
45
52
|
if (config.nst_api_key) {
|
|
@@ -238,65 +245,102 @@ class CommandPoller {
|
|
|
238
245
|
}
|
|
239
246
|
|
|
240
247
|
// Flow 1 — quét affiliate offer list (sort theo hoa hồng) → upsert vào kho.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
248
|
+
// Chạy một lệnh Shopee trên profile `profileName`, chạy đua với hạn chót
|
|
249
|
+
// theo loại lệnh `type` (SHOPEE_DEADLINE_MS). `work(context)` làm việc thật — kể cả
|
|
250
|
+
// gửi dữ liệu về API — và trả bản vá cho lệnh ({status, result|error}).
|
|
251
|
+
// Hết hạn thì chốt `failed` ngay và cắt kết nối CDP (evaluate đang chờ sẽ
|
|
252
|
+
// văng ra); kết nối nào về SAU lúc đó cũng bị nhả luôn, không để rơi.
|
|
253
|
+
async _runShopeeCommand(command, { type, profileName, label, failLog, work }) {
|
|
254
|
+
const ms = this.shopeeDeadlineMs[type];
|
|
255
|
+
let conn = null;
|
|
256
|
+
let abandoned = false;
|
|
257
|
+
const job = (async () => {
|
|
258
|
+
const c = await this._connectNstProfileByName(profileName);
|
|
259
|
+
if (abandoned) { c.disconnect(); return null; }
|
|
260
|
+
conn = c;
|
|
261
|
+
return work(c.context);
|
|
262
|
+
})();
|
|
263
|
+
let timer;
|
|
264
|
+
const deadline = new Promise((_, reject) => {
|
|
265
|
+
timer = setTimeout(() => reject(new Error(
|
|
266
|
+
`quá ${Math.round(ms / 60000)} phút chưa ${label} [${profileName}] — huỷ lượt này`,
|
|
267
|
+
)), ms);
|
|
268
|
+
});
|
|
247
269
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
// `category` rơi về mặc định 'women_clothes', không thì scrapeOffers nhận
|
|
251
|
-
// cả hai và lượt tìm im lặng biến thành lượt duyệt tab quần áo nữ.
|
|
252
|
-
const keyword = String(payload.keyword || '').trim();
|
|
253
|
-
const res = await scraper.scrapeOffers(conn.context, {
|
|
254
|
-
keyword: keyword || null,
|
|
255
|
-
category: keyword ? null : (payload.category || 'women_clothes'), // Tier-1 default: quần áo nữ
|
|
256
|
-
match_id: keyword ? null : (payload.match_id || null),
|
|
257
|
-
// Ở chế độ từ khoá, sort_type 3 là GIÁ GIẢM DẦN chứ không phải doanh số
|
|
258
|
-
// (đo 09/09) — scrapeOffers tự cắt, đây chỉ là không đưa vào cho đỡ rối.
|
|
259
|
-
sort_types: payload.sort_types || (keyword ? [2] : [2, 3]), // commission + sales, merged
|
|
260
|
-
pages: payload.pages ?? 2,
|
|
261
|
-
page_limit: payload.page_limit ?? 20,
|
|
262
|
-
delay_ms: payload.delay_ms ?? 2500, // human-like pacing (anti-bot)
|
|
263
|
-
filters: payload.filters || {},
|
|
264
|
-
log: (m) => console.log(m), // để thấy số tab đã dọn
|
|
265
|
-
});
|
|
266
|
-
if (res.status === 'needs_verify') {
|
|
267
|
-
// Anti-bot bounce — DON'T fail/retry into more captchas. Report cleanly
|
|
268
|
-
// so the dashboard can prompt the operator to re-verify via VNC.
|
|
269
|
-
console.warn(`[shopee] needs_verify: ${res.reason}`);
|
|
270
|
-
if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
271
|
-
await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason: res.reason, count: res.products?.length || 0 } });
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}${keyword ? ` (từ khoá "${keyword}")` : ''}`);
|
|
275
|
-
await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
276
|
-
// Lượt tìm theo từ khoá trả kèm CHÍNH danh sách tìm được, không chỉ đếm:
|
|
277
|
-
// bên gọi phải chấm chọn đúng trên những món của lượt này. Đọc lại từ kho
|
|
278
|
-
// thì lẫn hàng của các lượt trước và mất luôn thứ tự Shopee xếp.
|
|
279
|
-
// Cắt gọn để kết quả lệnh không phình (ảnh/biến thể ở lại trong kho).
|
|
280
|
-
const slim = keyword
|
|
281
|
-
? res.products.slice(0, 20).map((r) => ({
|
|
282
|
-
shop_id: r.shop_id, item_id: r.item_id, name: r.name, price: r.price,
|
|
283
|
-
commission_rate: r.commission_rate, commission_value: r.commission_value,
|
|
284
|
-
sold_count: r.sold_count, rating: r.rating, cover_image: r.cover_image,
|
|
285
|
-
product_url: r.product_url, category_group: r.category_group,
|
|
286
|
-
}))
|
|
287
|
-
: undefined;
|
|
288
|
-
await this.api.updateCommand(command._id, {
|
|
289
|
-
status: 'done',
|
|
290
|
-
result: { count: res.products.length, raw: res.raw_count, keyword: res.keyword || null, ...(slim ? { products: slim } : {}) },
|
|
291
|
-
});
|
|
270
|
+
const patch = await Promise.race([job, deadline]);
|
|
271
|
+
await this.api.updateCommand(command._id, patch);
|
|
292
272
|
} catch (err) {
|
|
293
|
-
console.error(
|
|
273
|
+
console.error(`${failLog}: ${err.message}`);
|
|
294
274
|
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
295
275
|
} finally {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
abandoned = true;
|
|
296
278
|
if (conn) conn.disconnect();
|
|
297
279
|
}
|
|
298
280
|
}
|
|
299
281
|
|
|
282
|
+
async handleScrapeAffiliateProducts(command) {
|
|
283
|
+
const payload = command.payload || {};
|
|
284
|
+
if (!(await this._ensureNst(command))) return;
|
|
285
|
+
const scraper = require('./shopee-scraper');
|
|
286
|
+
const profileName = payload.profile_name || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null)) || 'Shopee1';
|
|
287
|
+
await this._runShopeeCommand(command, {
|
|
288
|
+
type: 'scrape_affiliate_products',
|
|
289
|
+
profileName,
|
|
290
|
+
label: 'quét xong sản phẩm Shopee',
|
|
291
|
+
failLog: '[shopee] scrape failed',
|
|
292
|
+
work: async (context) => {
|
|
293
|
+
// Từ khoá và ngành hàng loại trừ nhau: có keyword thì KHÔNG được để
|
|
294
|
+
// `category` rơi về mặc định 'women_clothes', không thì scrapeOffers nhận
|
|
295
|
+
// cả hai và lượt tìm im lặng biến thành lượt duyệt tab quần áo nữ.
|
|
296
|
+
const keyword = String(payload.keyword || '').trim();
|
|
297
|
+
const res = await scraper.scrapeOffers(context, {
|
|
298
|
+
keyword: keyword || null,
|
|
299
|
+
category: keyword ? null : (payload.category || 'women_clothes'), // Tier-1 default: quần áo nữ
|
|
300
|
+
match_id: keyword ? null : (payload.match_id || null),
|
|
301
|
+
// Ở chế độ từ khoá, sort_type 3 là GIÁ GIẢM DẦN chứ không phải doanh số
|
|
302
|
+
// (đo 09/09) — scrapeOffers tự cắt, đây chỉ là không đưa vào cho đỡ rối.
|
|
303
|
+
sort_types: payload.sort_types || (keyword ? [2] : [2, 3]), // commission + sales, merged
|
|
304
|
+
pages: payload.pages ?? 2,
|
|
305
|
+
page_limit: payload.page_limit ?? 20,
|
|
306
|
+
delay_ms: payload.delay_ms ?? 2500, // human-like pacing (anti-bot)
|
|
307
|
+
filters: payload.filters || {},
|
|
308
|
+
log: (m) => console.log(m), // để thấy số tab đã dọn
|
|
309
|
+
});
|
|
310
|
+
if (res.status === 'needs_verify') {
|
|
311
|
+
// Anti-bot bounce — DON'T fail/retry into more captchas. Report cleanly
|
|
312
|
+
// so the dashboard can prompt the operator to re-verify via VNC.
|
|
313
|
+
console.warn(`[shopee] needs_verify: ${res.reason}`);
|
|
314
|
+
if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
315
|
+
return { status: 'done', result: { needs_verify: true, reason: res.reason, count: res.products?.length || 0 } };
|
|
316
|
+
}
|
|
317
|
+
if (res.status !== 'ok') {
|
|
318
|
+
// Shopee ngừng trả lời giữa chừng: món đã quét được vẫn vào kho.
|
|
319
|
+
if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
320
|
+
return { status: 'failed', error: String(res.reason || 'Không quét được sản phẩm Shopee').slice(0, 500) };
|
|
321
|
+
}
|
|
322
|
+
console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}${keyword ? ` (từ khoá "${keyword}")` : ''}`);
|
|
323
|
+
await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
324
|
+
// Lượt tìm theo từ khoá trả kèm CHÍNH danh sách tìm được, không chỉ đếm:
|
|
325
|
+
// bên gọi phải chấm chọn đúng trên những món của lượt này. Đọc lại từ kho
|
|
326
|
+
// thì lẫn hàng của các lượt trước và mất luôn thứ tự Shopee xếp.
|
|
327
|
+
// Cắt gọn để kết quả lệnh không phình (ảnh/biến thể ở lại trong kho).
|
|
328
|
+
const slim = keyword
|
|
329
|
+
? res.products.slice(0, 20).map((r) => ({
|
|
330
|
+
shop_id: r.shop_id, item_id: r.item_id, name: r.name, price: r.price,
|
|
331
|
+
commission_rate: r.commission_rate, commission_value: r.commission_value,
|
|
332
|
+
sold_count: r.sold_count, rating: r.rating, cover_image: r.cover_image,
|
|
333
|
+
product_url: r.product_url, category_group: r.category_group,
|
|
334
|
+
}))
|
|
335
|
+
: undefined;
|
|
336
|
+
return {
|
|
337
|
+
status: 'done',
|
|
338
|
+
result: { count: res.products.length, raw: res.raw_count, keyword: res.keyword || null, ...(slim ? { products: slim } : {}) },
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
300
344
|
// Flow 2 — ingest 1 sản phẩm từ link bất kỳ (bắt PDP get_pc) → full product.
|
|
301
345
|
async handleIngestShopeeProduct(command) {
|
|
302
346
|
const payload = command.payload || {};
|
|
@@ -309,19 +353,18 @@ class CommandPoller {
|
|
|
309
353
|
await this.api.updateCommand(command._id, { status: 'failed', error: 'need {shop_id,item_id} or a parseable {url}' });
|
|
310
354
|
return;
|
|
311
355
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
356
|
+
await this._runShopeeCommand(command, {
|
|
357
|
+
type: 'ingest_shopee_product',
|
|
358
|
+
profileName,
|
|
359
|
+
label: 'nạp xong sản phẩm Shopee',
|
|
360
|
+
failLog: '[shopee] ingest failed',
|
|
361
|
+
work: async (context) => {
|
|
362
|
+
const product = await scraper.ingestProduct(context, ids);
|
|
363
|
+
console.log(`[shopee] ingested: ${product.name} (${product.images.length} imgs)`);
|
|
364
|
+
await this.api.ingestShopeeResult(product, { user_id: command.user_id, idea_id: payload.idea_id || null });
|
|
365
|
+
return { status: 'done', result: { name: product.name, images: product.images.length } };
|
|
366
|
+
},
|
|
367
|
+
});
|
|
325
368
|
}
|
|
326
369
|
|
|
327
370
|
// Sinh LINK AFFILIATE THẬT (s.shopee.vn/...) cho một hoặc nhiều sản phẩm,
|
|
@@ -345,40 +388,37 @@ class CommandPoller {
|
|
|
345
388
|
return;
|
|
346
389
|
}
|
|
347
390
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
} finally {
|
|
380
|
-
if (conn) conn.disconnect();
|
|
381
|
-
}
|
|
391
|
+
await this._runShopeeCommand(command, {
|
|
392
|
+
type: 'get_affiliate_link',
|
|
393
|
+
profileName,
|
|
394
|
+
label: 'lấy được link Shopee',
|
|
395
|
+
failLog: '[shopee] lấy link thất bại',
|
|
396
|
+
work: async (context) => {
|
|
397
|
+
const res = await scraper.generateOfferLink(context, {
|
|
398
|
+
items,
|
|
399
|
+
subIds: Array.isArray(payload.sub_ids) ? payload.sub_ids : [],
|
|
400
|
+
log: (m) => console.log(m), // để thấy số tab đã dọn
|
|
401
|
+
});
|
|
402
|
+
if (res.status === 'needs_verify') {
|
|
403
|
+
// Anti-bot bounce — báo sạch, KHÔNG thử lại để khỏi đâm thêm captcha.
|
|
404
|
+
// Ghi kèm tên profile: có hai phiên (Shopee1, Shopee2) và người xử lý
|
|
405
|
+
// phải biết mở đúng cái nào để kéo captcha.
|
|
406
|
+
const reason = `${res.reason} [${profileName}]`;
|
|
407
|
+
console.warn(`[shopee] lấy link: needs_verify — ${reason}`);
|
|
408
|
+
return { status: 'done', result: { needs_verify: true, reason } };
|
|
409
|
+
}
|
|
410
|
+
if (res.status !== 'ok') {
|
|
411
|
+
return { status: 'failed', error: String(res.reason || 'không lấy được link').slice(0, 500) };
|
|
412
|
+
}
|
|
413
|
+
console.log(`[shopee] lấy được ${res.links.length} link (sub: ${JSON.stringify(res.sub_ids)})`);
|
|
414
|
+
await this.api.affiliateLinkResult(res.links, {
|
|
415
|
+
user_id: command.user_id,
|
|
416
|
+
sub_ids: res.sub_ids,
|
|
417
|
+
idea_id: payload.idea_id || null,
|
|
418
|
+
});
|
|
419
|
+
return { status: 'done', result: { links: res.links, sub_ids: res.sub_ids } };
|
|
420
|
+
},
|
|
421
|
+
});
|
|
382
422
|
}
|
|
383
423
|
|
|
384
424
|
async handleFetchAffiliateReport(command) {
|
|
@@ -387,35 +427,13 @@ class CommandPoller {
|
|
|
387
427
|
const profileName = payload.profile_name
|
|
388
428
|
|| (await this.api.getSetting('shopee_affiliate_profile').catch(() => null))
|
|
389
429
|
|| 'Shopee1';
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const work = (async () => {
|
|
397
|
-
const c = await this._connectNstProfileByName(profileName);
|
|
398
|
-
if (abandoned) { c.disconnect(); return null; }
|
|
399
|
-
conn = c;
|
|
400
|
-
return this._readAffiliateReport(c.context, payload);
|
|
401
|
-
})();
|
|
402
|
-
let timer;
|
|
403
|
-
const deadline = new Promise((_, reject) => {
|
|
404
|
-
timer = setTimeout(() => reject(new Error(
|
|
405
|
-
`quá ${Math.round(this.affReportDeadlineMs / 60000)} phút chưa đọc xong báo cáo Shopee [${profileName}] — huỷ lượt này`,
|
|
406
|
-
)), this.affReportDeadlineMs);
|
|
430
|
+
await this._runShopeeCommand(command, {
|
|
431
|
+
type: 'fetch_affiliate_report',
|
|
432
|
+
profileName,
|
|
433
|
+
label: 'đọc xong báo cáo Shopee',
|
|
434
|
+
failLog: '[shopee-report] đồng bộ thất bại',
|
|
435
|
+
work: (context) => this._readAffiliateReport(context, payload),
|
|
407
436
|
});
|
|
408
|
-
try {
|
|
409
|
-
const patch = await Promise.race([work, deadline]);
|
|
410
|
-
await this.api.updateCommand(command._id, patch);
|
|
411
|
-
} catch (err) {
|
|
412
|
-
console.error(`[shopee-report] đồng bộ thất bại: ${err.message}`);
|
|
413
|
-
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
414
|
-
} finally {
|
|
415
|
-
clearTimeout(timer);
|
|
416
|
-
abandoned = true;
|
|
417
|
-
if (conn) conn.disconnect();
|
|
418
|
-
}
|
|
419
437
|
}
|
|
420
438
|
|
|
421
439
|
// Đọc đơn + click + liên kết trên một phiên đã nối, trả về bản vá cho lệnh.
|
|
@@ -512,6 +530,9 @@ class CommandPoller {
|
|
|
512
530
|
// only serializes within a profile. Wait up to 30 min with 5s polling
|
|
513
531
|
// (upload typically 1-3 min); stale-cleanup picks up zombies separately.
|
|
514
532
|
// Lượt mới của cùng profile → huỷ hẹn đóng, giữ browser cho nó dùng lại.
|
|
533
|
+
// Nhớ lại là CÓ hẹn: browser đó do automation mở, lượt này xong phải hẹn
|
|
534
|
+
// đóng lại (xem nhánh runInfo.attached ở cuối hàm).
|
|
535
|
+
const hadCloseTimer = !!(this._pwCloseTimers && this._pwCloseTimers.has(profileId));
|
|
515
536
|
if (this._pwCloseTimers && this._pwCloseTimers.has(profileId)) {
|
|
516
537
|
clearTimeout(this._pwCloseTimers.get(profileId));
|
|
517
538
|
this._pwCloseTimers.delete(profileId);
|
|
@@ -581,6 +602,7 @@ class CommandPoller {
|
|
|
581
602
|
});
|
|
582
603
|
|
|
583
604
|
const keepOpen = await this._keepOpenCfg();
|
|
605
|
+
const runInfo = {};
|
|
584
606
|
|
|
585
607
|
// Nhịp còn-sống SUỐT lúc kịch bản chạy (không chỉ lúc xếp hàng). Bộ dọn của
|
|
586
608
|
// API khai tử lệnh đăng 'running' quá 20 phút không cập nhật; một lượt
|
|
@@ -600,6 +622,7 @@ class CommandPoller {
|
|
|
600
622
|
payload,
|
|
601
623
|
log,
|
|
602
624
|
keepOpen: keepOpen.enabled,
|
|
625
|
+
info: runInfo,
|
|
603
626
|
});
|
|
604
627
|
// postCommandResult (not updateCommand) so the existing extension-side
|
|
605
628
|
// logic mirrors status into ContentJob + ContentIdea.publish_results +
|
|
@@ -637,6 +660,11 @@ class CommandPoller {
|
|
|
637
660
|
this._keptOpenProfiles.set(profileId, Date.now());
|
|
638
661
|
console.log(`[keep-open] giữ profile ${profileId} mở sau ${scriptName} (${this._keptOpenProfiles.size}/${keepOpen.max})`);
|
|
639
662
|
await this._trimKeptProfiles(keepOpen.max).catch((e) => console.warn(`[keep-open] trim lỗi: ${e.message}`));
|
|
663
|
+
} else if (runInfo.attached && !hadCloseTimer) {
|
|
664
|
+
// Script nối vào browser ĐANG MỞ SẴN mà không có hẹn đóng nào của
|
|
665
|
+
// automation (vd "Kiểm tra Page ngay" lúc người vừa tạo Page còn ngồi
|
|
666
|
+
// trong cửa sổ) → không phải browser của mình, để nguyên cho người đó.
|
|
667
|
+
console.log(`[commands/pw] ${scriptName}: nối vào profile ${profileId} đang mở sẵn — để nguyên, không hẹn đóng`);
|
|
640
668
|
} else if (CLOSE_AFTER.has(scriptName) && this.nst) {
|
|
641
669
|
try {
|
|
642
670
|
await this.nst.stopProfile(profileId);
|
package/lib/playwright-runner.js
CHANGED
|
@@ -52,33 +52,35 @@ async function enableFocusEmulation(page, log) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
// Lấy cổng CDP của profile. Mở mới nếu chưa chạy; đang chạy thì mặc định
|
|
56
|
+
// tắt-mở lại để có endpoint tươi.
|
|
57
|
+
//
|
|
58
|
+
// Hai trường hợp NỐI THẲNG vào browser đang chạy thay vì tắt-mở lại:
|
|
59
|
+
// - keepOpen: máy bị chặn số lượt mở/ngày — relaunch là đốt 1 lượt cho không.
|
|
60
|
+
// - attachIfRunning (script tự khai, vd facebook_page_check): người vừa tạo
|
|
61
|
+
// Page rất có thể còn ngồi trong đúng cửa sổ đó — relaunch là đóng ngang
|
|
62
|
+
// trước mặt họ. Script loại này tự mở tab riêng và chỉ đóng tab của nó.
|
|
63
|
+
// Trả { endpoint, attached } — attached = đã nối vào browser đang chạy sẵn
|
|
64
|
+
// (daemon dựa vào đó để không hẹn đóng một cửa sổ nó không mở).
|
|
65
|
+
async function resolveEndpoint({ nst, profileId, log = () => {}, keepOpen = false, attachIfRunning = false }) {
|
|
63
66
|
// Don't load any Chromium extension — Playwright is the automation, no need
|
|
64
67
|
// for the channel-manager-ext on this path. (The chagpt cgpt path still
|
|
65
68
|
// launches with content-creator-ext via a different code path.)
|
|
66
69
|
const launchRes = await nst.launchProfile(profileId, { /* extensionPath omitted */ });
|
|
67
70
|
let endpoint = launchRes?.wsEndpoint;
|
|
71
|
+
let attached = false;
|
|
68
72
|
|
|
69
73
|
// If the profile was already running, launchProfile returns { alreadyRunning:
|
|
70
74
|
// true } without a wsEndpoint.
|
|
71
75
|
if (!endpoint && launchRes?.alreadyRunning) {
|
|
72
|
-
|
|
73
|
-
// sẵn chỉ để lấy endpoint mới là đốt 1 lượt cho không — nối thẳng vào cổng
|
|
74
|
-
// debug của nó thay vì relaunch.
|
|
75
|
-
if (keepOpen) {
|
|
76
|
+
if (keepOpen || attachIfRunning) {
|
|
76
77
|
const live = await nst.resolveRunningEndpoint(profileId).catch(() => null);
|
|
77
78
|
if (live?.httpEndpoint) {
|
|
78
79
|
endpoint = live.httpEndpoint;
|
|
79
|
-
|
|
80
|
+
attached = true;
|
|
81
|
+
log('info', `[pw] profile đang chạy — nối vào cổng debug ${live.port} (không mở lại${keepOpen ? ', không tốn lượt mở' : ''})`);
|
|
80
82
|
} else {
|
|
81
|
-
log('warn', `[pw] profile đang chạy nhưng không đọc được cổng debug — buộc phải mở lại (tốn 1 lượt)`);
|
|
83
|
+
log('warn', `[pw] profile đang chạy nhưng không đọc được cổng debug — buộc phải mở lại${keepOpen ? ' (tốn 1 lượt)' : ''}`);
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
if (!endpoint) {
|
|
@@ -88,6 +90,22 @@ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, l
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
if (!endpoint) throw new Error(`No webSocketDebuggerUrl from NST for profile ${profileId}`);
|
|
93
|
+
return { endpoint, attached };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// `info` (tuỳ chọn) được điền { attached } cho daemon đọc sau lượt chạy.
|
|
97
|
+
async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, log = () => {}, keepOpen = false, info = null }) {
|
|
98
|
+
let playwright;
|
|
99
|
+
try { playwright = require('playwright-core'); }
|
|
100
|
+
catch (e) { throw new Error(`playwright-core not installed in channel-worker (npm i playwright-core in the worker dir): ${e.message}`); }
|
|
101
|
+
|
|
102
|
+
const script = loadScript(scriptName);
|
|
103
|
+
|
|
104
|
+
log('info', `[pw] launching NST profile ${profileId}…`);
|
|
105
|
+
const { endpoint, attached } = await resolveEndpoint({
|
|
106
|
+
nst, profileId, log, keepOpen, attachIfRunning: !!script.attachIfRunning,
|
|
107
|
+
});
|
|
108
|
+
if (info) info.attached = attached;
|
|
91
109
|
|
|
92
110
|
log('info', `[pw] connectOverCDP…`);
|
|
93
111
|
const browser = await playwright.chromium.connectOverCDP(endpoint);
|
|
@@ -107,4 +125,4 @@ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, l
|
|
|
107
125
|
}
|
|
108
126
|
}
|
|
109
127
|
|
|
110
|
-
module.exports = { runPlaywrightScript, enableFocusEmulation };
|
|
128
|
+
module.exports = { runPlaywrightScript, enableFocusEmulation, resolveEndpoint };
|
package/lib/shopee-report.js
CHANGED
|
@@ -1,31 +1,9 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
findAffiliatePage, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, evaluateWithin, EVAL_TIMEOUT_MS,
|
|
3
|
+
} = require('./shopee-scraper');
|
|
2
4
|
|
|
3
5
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
6
|
|
|
5
|
-
// Một lượt evaluate phải có hạn. Playwright không có timeout cho evaluate, và
|
|
6
|
-
// fetch chạy trong trang chống bot của Shopee có thể không bao giờ về: 28/09/2026
|
|
7
|
-
// (Shopee2, relabs03) một lượt nằm im 12 tiếng, chỉ nhả khi người gỡ captcha
|
|
8
|
-
// làm trang chuyển hướng — suốt lúc đó lệnh `running` khoá nút Đồng bộ và lịch
|
|
9
|
-
// của máy. Hết hạn thì trả `{ status: 0, timedOut }` để bên gọi xử như một
|
|
10
|
-
// lượt hỏng; trang lúc đó đã sang captcha thì isBlockedState đọc ra needs_verify.
|
|
11
|
-
const EVAL_TIMEOUT_MS = 60000;
|
|
12
|
-
|
|
13
|
-
async function evaluateWithin(page, fn, arg, ms = EVAL_TIMEOUT_MS) {
|
|
14
|
-
let timer;
|
|
15
|
-
const expired = new Promise((resolve) => {
|
|
16
|
-
timer = setTimeout(() => resolve({
|
|
17
|
-
status: 0,
|
|
18
|
-
timedOut: true,
|
|
19
|
-
error: `Shopee không trả lời sau ${Math.round(ms / 1000)} giây (trang ${page.url()})`,
|
|
20
|
-
}), ms);
|
|
21
|
-
});
|
|
22
|
-
try {
|
|
23
|
-
return await Promise.race([page.evaluate(fn, arg), expired]);
|
|
24
|
-
} finally {
|
|
25
|
-
clearTimeout(timer);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
7
|
function reportWindow(days = 90, nowMs = Date.now()) {
|
|
30
8
|
const safeDays = Math.min(180, Math.max(1, Number(days || 90)));
|
|
31
9
|
const vnNow = new Date(nowMs + 7 * 3600e3);
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -72,13 +72,16 @@ function parsePdpItem(item) {
|
|
|
72
72
|
async function ingestProduct(ctx, { shop_id, item_id, timeoutMs = 60000 } = {}) {
|
|
73
73
|
if (!shop_id || !item_id) throw new Error('ingestProduct needs shop_id + item_id');
|
|
74
74
|
const page = await ctx.newPage();
|
|
75
|
+
let timer;
|
|
75
76
|
try {
|
|
76
77
|
const captured = new Promise((resolve) => {
|
|
77
78
|
page.on('response', async (res) => {
|
|
78
79
|
if (!res.url().includes('/api/v4/pdp/get_pc')) return;
|
|
79
80
|
try { const j = await res.json(); if (j?.data?.item) resolve(j.data.item); } catch { /* keep waiting */ }
|
|
80
81
|
});
|
|
81
|
-
|
|
82
|
+
// Trang bị đóng (lệnh hết hạn chót → cắt kết nối) thì thôi chờ ngay.
|
|
83
|
+
page.once('close', () => resolve(null));
|
|
84
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
82
85
|
});
|
|
83
86
|
await page.goto(`https://shopee.vn/product/${shop_id}/${item_id}`, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
|
84
87
|
const item = await captured;
|
|
@@ -87,6 +90,7 @@ async function ingestProduct(ctx, { shop_id, item_id, timeoutMs = 60000 } = {})
|
|
|
87
90
|
parsed.product_url = `https://shopee.vn/product/${shop_id}/${item_id}`;
|
|
88
91
|
return parsed;
|
|
89
92
|
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
90
94
|
await page.close().catch(() => {});
|
|
91
95
|
}
|
|
92
96
|
}
|
|
@@ -169,6 +173,31 @@ const AFFILIATE_CATEGORIES = {
|
|
|
169
173
|
|
|
170
174
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
171
175
|
|
|
176
|
+
// Một lượt evaluate phải có hạn. Playwright không có timeout cho evaluate, và
|
|
177
|
+
// fetch chạy trong trang chống bot của Shopee có thể không bao giờ về: 28/09/2026
|
|
178
|
+
// (Shopee2, relabs03) một lượt đọc báo cáo nằm im 12 tiếng, chỉ nhả khi người
|
|
179
|
+
// gỡ captcha làm trang chuyển hướng. Mọi luồng Shopee (báo cáo, lấy link, quét
|
|
180
|
+
// món) dùng chung phiên và chung kiểu gọi nên dùng chung chốt này. Hết hạn thì
|
|
181
|
+
// trả `{ status: 0, timedOut }` để bên gọi xử như một lượt hỏng; trang lúc đó
|
|
182
|
+
// đã sang captcha thì isBlockedState đọc ra needs_verify.
|
|
183
|
+
const EVAL_TIMEOUT_MS = 60000;
|
|
184
|
+
|
|
185
|
+
async function evaluateWithin(page, fn, arg, ms = EVAL_TIMEOUT_MS) {
|
|
186
|
+
let timer;
|
|
187
|
+
const expired = new Promise((resolve) => {
|
|
188
|
+
timer = setTimeout(() => resolve({
|
|
189
|
+
status: 0,
|
|
190
|
+
timedOut: true,
|
|
191
|
+
error: `Shopee không trả lời sau ${Math.round(ms / 1000)} giây (trang ${page.url()})`,
|
|
192
|
+
}), ms);
|
|
193
|
+
});
|
|
194
|
+
try {
|
|
195
|
+
return await Promise.race([page.evaluate(fn, arg), expired]);
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
172
201
|
// True when the session got bounced to the anti-bot captcha (scene=crawler_item)
|
|
173
202
|
// or logged out — in either case the offer API returns 404/empty and we must
|
|
174
203
|
// STOP (not keep hammering) and ask the operator to re-verify via VNC.
|
|
@@ -342,6 +371,7 @@ async function scrapeOffers(ctx, {
|
|
|
342
371
|
delay_ms = 2500, // human-like pause between calls (anti-bot)
|
|
343
372
|
filters = {},
|
|
344
373
|
timeoutMs = 45000,
|
|
374
|
+
evalTimeoutMs = EVAL_TIMEOUT_MS,
|
|
345
375
|
log = null,
|
|
346
376
|
// legacy single-sort param (back-compat with the first build)
|
|
347
377
|
sort_type = null,
|
|
@@ -389,7 +419,7 @@ async function scrapeOffers(ctx, {
|
|
|
389
419
|
for (const st of sort_types) {
|
|
390
420
|
for (let i = 0; i < pages; i++) {
|
|
391
421
|
const offset = i * page_limit;
|
|
392
|
-
const res = await page
|
|
422
|
+
const res = await evaluateWithin(page, async (q) => {
|
|
393
423
|
// Từ khoá đứng TRƯỚC ngành hàng: hai chế độ loại trừ nhau, gửi cả hai
|
|
394
424
|
// thì Shopee bỏ qua keyword và mình tưởng đang tìm mà thật ra vẫn
|
|
395
425
|
// đang duyệt tab cũ.
|
|
@@ -403,7 +433,7 @@ async function scrapeOffers(ctx, {
|
|
|
403
433
|
const r = await fetch(url, { credentials: 'include' });
|
|
404
434
|
return { status: r.status, json: await r.json().catch(() => null) };
|
|
405
435
|
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
406
|
-
}, { st, offset, page_limit, mid, listType, kw, ft: filter_types });
|
|
436
|
+
}, { st, offset, page_limit, mid, listType, kw, ft: filter_types }, evalTimeoutMs);
|
|
407
437
|
|
|
408
438
|
if (isAntiBotBody(res.json)) {
|
|
409
439
|
return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: ANTI_BOT_REASON };
|
|
@@ -411,6 +441,11 @@ async function scrapeOffers(ctx, {
|
|
|
411
441
|
if (isBlockedState(page.url(), res)) {
|
|
412
442
|
return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: `blocked mid-scrape (http ${res.status}, code ${res.json?.code})` };
|
|
413
443
|
}
|
|
444
|
+
// Hết hạn KHÔNG phải hết dữ liệu: đi tiếp là thêm một lượt chờ nữa trên
|
|
445
|
+
// đúng phiên đang kẹt. Dừng hẳn, giữ những món đã quét được.
|
|
446
|
+
if (res.timedOut) {
|
|
447
|
+
return { status: 'failed', products: [...byId.values()], raw_count: rawCount, reason: res.error };
|
|
448
|
+
}
|
|
414
449
|
if (res.status !== 200 || res.json?.code !== 0) {
|
|
415
450
|
// soft stop — treat as end-of-data for this sort, move on
|
|
416
451
|
break;
|
|
@@ -483,7 +518,7 @@ function linkFailureReason(res, wanted = 0) {
|
|
|
483
518
|
}
|
|
484
519
|
|
|
485
520
|
// subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
|
|
486
|
-
async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000, log = null } = {}) {
|
|
521
|
+
async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000, evalTimeoutMs = EVAL_TIMEOUT_MS, log = null } = {}) {
|
|
487
522
|
const list = (items || [])
|
|
488
523
|
.map((it) => ({ itemId: String(it.item_id || ''), shopId: Number(it.shop_id) || 0, trace: '' }))
|
|
489
524
|
.filter((it) => it.itemId && it.shopId);
|
|
@@ -504,7 +539,7 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
|
|
|
504
539
|
if (isBlockedState(page.url(), null)) {
|
|
505
540
|
return { status: 'needs_verify', links: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
506
541
|
}
|
|
507
|
-
const res = await page
|
|
542
|
+
const res = await evaluateWithin(page, async (q) => {
|
|
508
543
|
const body = {
|
|
509
544
|
operationName: 'batchGetProductOfferLink',
|
|
510
545
|
query: `query batchGetProductOfferLink($sourceCaller: SourceCaller!, $productOfferLinkParams: [ProductOfferLinkParam!]!, $advancedLinkParams: AdvancedLinkParams){ productOfferLinks(productOfferLinkParams: $productOfferLinkParams, sourceCaller: $sourceCaller, advancedLinkParams: $advancedLinkParams) { itemId shopId productOfferLink } }`,
|
|
@@ -522,7 +557,7 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
|
|
|
522
557
|
});
|
|
523
558
|
return { status: r.status, json: await r.json().catch(() => null) };
|
|
524
559
|
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
525
|
-
}, { list, subs });
|
|
560
|
+
}, { list, subs }, evalTimeoutMs);
|
|
526
561
|
|
|
527
562
|
if (isAntiBotBody(res.json)) {
|
|
528
563
|
return { status: 'needs_verify', links: [], reason: ANTI_BOT_REASON };
|
|
@@ -581,4 +616,4 @@ function parseProductUrl(url = '') {
|
|
|
581
616
|
return null;
|
|
582
617
|
}
|
|
583
618
|
|
|
584
|
-
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, linkFailureReason, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, findAffiliatePage, pruneTabs, isJunkTab };
|
|
619
|
+
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, linkFailureReason, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, findAffiliatePage, pruneTabs, isJunkTab, evaluateWithin, EVAL_TIMEOUT_MS };
|
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// "Kiểm tra Page ngay" — profile NST của kênh đang dùng Facebook dưới danh
|
|
2
|
+
// nghĩa PAGE hay vẫn là trang CÁ NHÂN?
|
|
3
|
+
//
|
|
4
|
+
// Sinh ra 29/09/2026: người vừa tạo Page vẫn bị API chặn đăng vì cờ
|
|
5
|
+
// `nurture.profile_mode='personal'` do phiên nuôi hôm trước ghi, và cờ đó chỉ
|
|
6
|
+
// phiên nuôi làm mới được (hàng nuôi chạy lần lượt cả đội). Script này là lượt
|
|
7
|
+
// đọc riêng, ~30 giây: mở facebook.com, kiểm tài khoản còn sống, đọc danh tính
|
|
8
|
+
// bằng ĐÚNG bộ đọc mà lượt đăng dùng (lib/fb-guard readProfileMode), rồi thôi.
|
|
9
|
+
// Không lướt, không bấm, không mở web ngoài như phiên nuôi.
|
|
10
|
+
//
|
|
11
|
+
// Người bấm nút này thường VỪA tạo Page trong chính profile đó và cửa sổ có
|
|
12
|
+
// thể còn mở. Vì vậy:
|
|
13
|
+
// - attachIfRunning: runner nối vào browser đang chạy thay vì tắt-mở lại;
|
|
14
|
+
// - làm trong TAB RIÊNG và chỉ đóng tab của mình — không đóng tab nào khác
|
|
15
|
+
// (phiên nuôi thì có dọn tab, ở đây thì không).
|
|
16
|
+
//
|
|
17
|
+
// Contract: run({ page, context, payload, log }) → { profile_mode:
|
|
18
|
+
// 'page'|'personal'|'unknown', why, timeline_label, nav_sample, duration_sec }.
|
|
19
|
+
// Tài khoản chết (đăng xuất / checkpoint / khoá) ném lỗi có mã FB_ACCOUNT_*
|
|
20
|
+
// như mọi script Facebook khác — API ghi trạng thái tài khoản từ đó.
|
|
21
|
+
|
|
22
|
+
const { assertAccountUsable, readProfileMode } = require('./lib/fb-guard');
|
|
23
|
+
const { enableFocusEmulation } = require('../lib/playwright-runner');
|
|
24
|
+
|
|
25
|
+
// Facebook vẽ thanh điều hướng + ô "Tạo bài viết" sau khi tải xong khung
|
|
26
|
+
// trang; đọc quá sớm thì mọi tín hiệu đều rỗng → 'unknown'. Đọc lại vài lượt
|
|
27
|
+
// cách nhau vài giây trước khi chịu là không đọc ra.
|
|
28
|
+
const READ_ATTEMPTS = 3;
|
|
29
|
+
const READ_GAP_MS = 3000;
|
|
30
|
+
|
|
31
|
+
async function openOwnTab(page, context, log) {
|
|
32
|
+
if (!context || typeof context.newPage !== 'function') return { tab: page, own: false };
|
|
33
|
+
try {
|
|
34
|
+
return { tab: await context.newPage(), own: true };
|
|
35
|
+
} catch (e) {
|
|
36
|
+
log('warn', `[fb-page-check] không mở được tab riêng, dùng tab sẵn có (${String(e?.message || e).slice(0, 80)})`);
|
|
37
|
+
return { tab: page, own: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function run({ page, context, payload = {}, log = () => {} }) {
|
|
42
|
+
const t0 = Date.now();
|
|
43
|
+
const { tab, own } = await openOwnTab(page, context, log);
|
|
44
|
+
// Tab mới mở trong một browser người đang xem là tab NỀN — Chrome bóp nhịp
|
|
45
|
+
// vẽ của nó, Facebook có thể chưa dựng xong nav khi ta đọc.
|
|
46
|
+
const focus = own ? await enableFocusEmulation(tab, log) : null;
|
|
47
|
+
try {
|
|
48
|
+
await tab.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
49
|
+
await tab.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
|
|
50
|
+
await tab.waitForTimeout(3000);
|
|
51
|
+
await assertAccountUsable(tab, log);
|
|
52
|
+
|
|
53
|
+
let mode = await readProfileMode(tab);
|
|
54
|
+
for (let i = 1; i < READ_ATTEMPTS && mode.mode === 'unknown'; i++) {
|
|
55
|
+
await tab.waitForTimeout(READ_GAP_MS);
|
|
56
|
+
mode = await readProfileMode(tab);
|
|
57
|
+
}
|
|
58
|
+
const durationSec = Math.round((Date.now() - t0) / 1000);
|
|
59
|
+
log('info', `[fb-page-check] ${mode.why}${mode.timelineLabel ? ` timeline="${mode.timelineLabel}"` : ''}`
|
|
60
|
+
+ ` nav="${mode.navSample}" (${durationSec}s, kênh ${payload.channel_id || '?'})`);
|
|
61
|
+
return {
|
|
62
|
+
profile_mode: mode.mode,
|
|
63
|
+
why: mode.why,
|
|
64
|
+
timeline_label: mode.timelineLabel || '',
|
|
65
|
+
nav_sample: mode.navSample || '',
|
|
66
|
+
duration_sec: durationSec,
|
|
67
|
+
};
|
|
68
|
+
} finally {
|
|
69
|
+
if (focus) await focus.detach().catch(() => {});
|
|
70
|
+
if (own) await tab.close().catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { run, attachIfRunning: true };
|
|
@@ -4691,6 +4691,10 @@ async function runOnce({ page, payload, log }) {
|
|
|
4691
4691
|
reels_before: reelIdsBefore ? reelIdsBefore.slice(0, 300) : undefined,
|
|
4692
4692
|
reels_tab_url: reelsTabUrlBefore || reelsTabUrl || undefined,
|
|
4693
4693
|
video_path_local: videoPath,
|
|
4694
|
+
// Danh tính đọc lúc mở trang (bước identity check ở trên). 'page' → API
|
|
4695
|
+
// ghi cờ Page cho kênh, cờ cũ `personal`/`unknown` tự lành mà không phải
|
|
4696
|
+
// chờ phiên nuôi (xem api services/FacebookPublishGuard).
|
|
4697
|
+
profile_mode: profileMode.mode,
|
|
4694
4698
|
};
|
|
4695
4699
|
} finally {
|
|
4696
4700
|
safeUnlink(videoPath);
|