channel-worker 2.5.93 → 2.5.95
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 +7 -1
- package/lib/command-poller.js +83 -0
- package/lib/shopee-scraper.js +82 -1
- package/package.json +1 -1
- package/scripts/upload_facebook.js +51 -31
package/lib/api-client.js
CHANGED
|
@@ -68,7 +68,7 @@ class ApiClient {
|
|
|
68
68
|
async getNextCommand(workerId) {
|
|
69
69
|
// Daemon-handled types. `_pw` variants route to the Playwright pipeline
|
|
70
70
|
// (lib/playwright-runner → scripts/<base>.js) instead of the extension.
|
|
71
|
-
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,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,scrape_affiliate_products,ingest_shopee_product';
|
|
71
|
+
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,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link';
|
|
72
72
|
return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -94,6 +94,12 @@ class ApiClient {
|
|
|
94
94
|
return this.request('POST', '/products/worker-ingest', { product, ...meta });
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Trả LINK AFFILIATE thật về kho. `sub_ids` đi kèm để sau này biết link này
|
|
98
|
+
// gắn cho kênh/video nào — đó là cả lý do link phải sinh riêng từng bài.
|
|
99
|
+
async affiliateLinkResult(links, { user_id, sub_ids, idea_id } = {}) {
|
|
100
|
+
return this.request('POST', '/products/worker-link', { links, user_id, sub_ids, idea_id });
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
// Return the calling daemon's own Worker doc — primarily for reading
|
|
98
104
|
// parallel_limit (the per-daemon scene-generation concurrency cap that
|
|
99
105
|
// replaced the legacy global flowkit_max_concurrent setting).
|
package/lib/command-poller.js
CHANGED
|
@@ -115,6 +115,9 @@ class CommandPoller {
|
|
|
115
115
|
case 'ingest_shopee_product':
|
|
116
116
|
await this.handleIngestShopeeProduct(command);
|
|
117
117
|
break;
|
|
118
|
+
case 'get_affiliate_link':
|
|
119
|
+
await this.handleGetAffiliateLink(command);
|
|
120
|
+
break;
|
|
118
121
|
default:
|
|
119
122
|
// Playwright-based pipeline: any command whose type ends in '_pw'
|
|
120
123
|
// is routed to scripts/<base>.js (BrowserClaw-style automation
|
|
@@ -252,6 +255,59 @@ class CommandPoller {
|
|
|
252
255
|
}
|
|
253
256
|
}
|
|
254
257
|
|
|
258
|
+
// Sinh LINK AFFILIATE THẬT (s.shopee.vn/...) cho một hoặc nhiều sản phẩm,
|
|
259
|
+
// kèm sub-id để đo click/đơn về tới từng kênh và từng video.
|
|
260
|
+
// Open API chính thức đã bị Shopee từ chối, nên phải đi qua phiên đăng nhập
|
|
261
|
+
// như mọi việc Shopee khác — xem ghi chú ở shopee-scraper.generateOfferLink.
|
|
262
|
+
async handleGetAffiliateLink(command) {
|
|
263
|
+
const payload = command.payload || {};
|
|
264
|
+
if (!(await this._ensureNst(command))) return;
|
|
265
|
+
const scraper = require('./shopee-scraper');
|
|
266
|
+
const profileName = payload.profile_name || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null)) || 'Shopee1';
|
|
267
|
+
|
|
268
|
+
let items = Array.isArray(payload.items) ? payload.items : [];
|
|
269
|
+
if (!items.length && payload.url) {
|
|
270
|
+
const ids = scraper.parseProductUrl(payload.url);
|
|
271
|
+
if (ids) items = [ids];
|
|
272
|
+
}
|
|
273
|
+
if (!items.length && payload.shop_id && payload.item_id) items = [{ shop_id: payload.shop_id, item_id: payload.item_id }];
|
|
274
|
+
if (!items.length) {
|
|
275
|
+
await this.api.updateCommand(command._id, { status: 'failed', error: 'cần items[] hoặc url hoặc {shop_id,item_id}' });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let conn;
|
|
280
|
+
try {
|
|
281
|
+
conn = await this._connectNstProfileByName(profileName);
|
|
282
|
+
const res = await scraper.generateOfferLink(conn.context, {
|
|
283
|
+
items,
|
|
284
|
+
subIds: Array.isArray(payload.sub_ids) ? payload.sub_ids : [],
|
|
285
|
+
});
|
|
286
|
+
if (res.status === 'needs_verify') {
|
|
287
|
+
// Anti-bot bounce — báo sạch, KHÔNG thử lại để khỏi đâm thêm captcha.
|
|
288
|
+
console.warn(`[shopee] lấy link: needs_verify — ${res.reason}`);
|
|
289
|
+
await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason: res.reason } });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (res.status !== 'ok') {
|
|
293
|
+
await this.api.updateCommand(command._id, { status: 'failed', error: String(res.reason || 'không lấy được link').slice(0, 500) });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
console.log(`[shopee] lấy được ${res.links.length} link (sub: ${JSON.stringify(res.sub_ids)})`);
|
|
297
|
+
await this.api.affiliateLinkResult(res.links, {
|
|
298
|
+
user_id: command.user_id,
|
|
299
|
+
sub_ids: res.sub_ids,
|
|
300
|
+
idea_id: payload.idea_id || null,
|
|
301
|
+
});
|
|
302
|
+
await this.api.updateCommand(command._id, { status: 'done', result: { links: res.links, sub_ids: res.sub_ids } });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error(`[shopee] lấy link thất bại: ${err.message}`);
|
|
305
|
+
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
306
|
+
} finally {
|
|
307
|
+
if (conn) conn.disconnect();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
255
311
|
async handlePlaywrightCommand(command) {
|
|
256
312
|
const { runPlaywrightScript } = require('./playwright-runner');
|
|
257
313
|
const payload = command.payload || {};
|
|
@@ -277,13 +333,29 @@ class CommandPoller {
|
|
|
277
333
|
}
|
|
278
334
|
if (!this._pwInFlight) this._pwInFlight = new Map();
|
|
279
335
|
const MUTEX_MAX_WAIT_MS = 30 * 60 * 1000;
|
|
336
|
+
// Nhịp báo còn sống trong lúc XẾP HÀNG. Bộ dọn của API coi lệnh đăng
|
|
337
|
+
// 'running' quá 20 phút là "worker crash giữa chừng": nó đánh failed +
|
|
338
|
+
// ambiguous, idea bị RÚT khỏi kho tự động và bắt người vào Facebook kiểm —
|
|
339
|
+
// trong khi bài còn chưa hề được thử đăng. Hai ngưỡng đá nhau (chờ ở đây
|
|
340
|
+
// tối đa 30 phút > 20 phút của bộ dọn), đo prod 2026-09-09 tối: 6 bài bị
|
|
341
|
+
// khai tử oan trong 7 phút (19:14→19:21) khi lệnh dồn cục vào một profile.
|
|
342
|
+
// Chạm vào lệnh mỗi 2 phút là đủ giữ `updatedAt` tươi mà không nện API.
|
|
343
|
+
const MUTEX_BEAT_MS = 2 * 60 * 1000;
|
|
280
344
|
const waitStart = Date.now();
|
|
345
|
+
let lastBeat = Date.now();
|
|
281
346
|
while (this._pwInFlight.has(profileId)) {
|
|
282
347
|
if (Date.now() - waitStart > MUTEX_MAX_WAIT_MS) {
|
|
283
348
|
await this.api.updateCommand(command._id, { status: 'failed', error: `pw mutex timeout — profile ${profileId} busy with ${this._pwInFlight.get(profileId)} for >30min` });
|
|
284
349
|
return;
|
|
285
350
|
}
|
|
286
351
|
console.log(`[commands/pw] ${command.type} waiting — profile ${profileId} busy with ${this._pwInFlight.get(profileId)}`);
|
|
352
|
+
if (Date.now() - lastBeat > MUTEX_BEAT_MS) {
|
|
353
|
+
lastBeat = Date.now();
|
|
354
|
+
const waitedMin = Math.round((Date.now() - waitStart) / 60000);
|
|
355
|
+
await this.api.updateCommand(command._id, { status: 'running' })
|
|
356
|
+
.then(() => console.log(`[commands/pw] ${command.type} vẫn đang xếp hàng ${waitedMin} phút — báo còn sống để bộ dọn không khai tử`))
|
|
357
|
+
.catch(() => {});
|
|
358
|
+
}
|
|
287
359
|
await new Promise(r => setTimeout(r, 5000));
|
|
288
360
|
}
|
|
289
361
|
this._pwInFlight.set(profileId, command.type);
|
|
@@ -325,6 +397,16 @@ class CommandPoller {
|
|
|
325
397
|
|
|
326
398
|
const keepOpen = await this._keepOpenCfg();
|
|
327
399
|
|
|
400
|
+
// 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
|
|
401
|
+
// API khai tử lệnh đăng 'running' quá 20 phút không cập nhật; một lượt
|
|
402
|
+
// upload FB thật có thể đứng chờ nút Đăng tới 600s cộng các bước trước đó
|
|
403
|
+
// là vượt 20 phút — ca Chuyện Nhà Nông 09/09 21:46 chạy 17 phút, updatedAt
|
|
404
|
+
// vẫn là 21:46. Chạm vào lệnh mỗi 2 phút là đủ, thưa hơn nhiều so với API.
|
|
405
|
+
const RUN_BEAT_MS = 2 * 60 * 1000;
|
|
406
|
+
const beat = setInterval(() => {
|
|
407
|
+
this.api.updateCommand(command._id, { status: 'running' }).catch(() => {});
|
|
408
|
+
}, RUN_BEAT_MS);
|
|
409
|
+
|
|
328
410
|
try {
|
|
329
411
|
const result = await runPlaywrightScript({
|
|
330
412
|
nst: this.nst,
|
|
@@ -343,6 +425,7 @@ class CommandPoller {
|
|
|
343
425
|
console.error(`[commands/pw] ${command.type} failed: ${err.message}`);
|
|
344
426
|
await this.api.postCommandResult(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
345
427
|
} finally {
|
|
428
|
+
clearInterval(beat);
|
|
346
429
|
// Always release the per-profile mutex — even on throw — or sibling
|
|
347
430
|
// pw cmds for the same profile would hang forever.
|
|
348
431
|
if (this._pwInFlight) this._pwInFlight.delete(profileId);
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -292,6 +292,87 @@ async function scrapeOffers(ctx, {
|
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
// Chuẩn hoá một sub-id: CHỈ CHỮ VÀ SỐ. Đo 09/09/2026 — gửi `kenh_C` (có gạch
|
|
296
|
+
// dưới) thì Shopee trả `businessCode:282800002` và data null, còn `kenhB` thì
|
|
297
|
+
// 200 ra link. Placeholder của chính Shopee cũng là SportShoes / InstagramFeed
|
|
298
|
+
// / 1212BirthdaySale. Cắt sạch dấu thay vì để lệnh chết giữa chừng.
|
|
299
|
+
function cleanSubId(v) {
|
|
300
|
+
return String(v == null ? '' : v)
|
|
301
|
+
.replace(/đ/g, 'd').replace(/Đ/g, 'D') // 'đ' KHÔNG tách được bằng NFD (ký tự riêng, không phải chữ + dấu tổ hợp) — bỏ qua là "đợt" thành "ot", hai mã khác nhau có thể trùng nhau
|
|
302
|
+
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // bỏ dấu tiếng Việt
|
|
303
|
+
.replace(/[^A-Za-z0-9]/g, '')
|
|
304
|
+
.slice(0, 50);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Sinh LINK AFFILIATE THẬT cho một hoặc nhiều sản phẩm.
|
|
308
|
+
//
|
|
309
|
+
// Đây là đường DUY NHẤT còn dùng được: Open API chính thức đã bị Shopee từ chối
|
|
310
|
+
// ("chỉ hỗ trợ một số nhóm KOL/KOC có nhân viên hỗ trợ trực tiếp", trả lời
|
|
311
|
+
// 09/09/2026), nên phải gọi trong phiên đăng nhập như mọi thứ khác ở file này.
|
|
312
|
+
//
|
|
313
|
+
// Bắt được payload bằng cách bấm thật nút Get Link → tick Advanced → điền 5 ô
|
|
314
|
+
// subId → "Add to Link". Link trả về dạng `s.shopee.vn/<mã>`, redirect qua
|
|
315
|
+
// `/opaanlp/` — KHÁC hẳn `long_link` của offer list (universal-link + chữ ký
|
|
316
|
+
// Google Ads, không qua đường ghi nhận affiliate nên không ra hoa hồng).
|
|
317
|
+
//
|
|
318
|
+
// subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
|
|
319
|
+
async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000 } = {}) {
|
|
320
|
+
const list = (items || [])
|
|
321
|
+
.map((it) => ({ itemId: String(it.item_id || ''), shopId: Number(it.shop_id) || 0, trace: '' }))
|
|
322
|
+
.filter((it) => it.itemId && it.shopId);
|
|
323
|
+
if (!list.length) throw new Error('generateOfferLink cần ít nhất một {shop_id,item_id}');
|
|
324
|
+
|
|
325
|
+
const subs = {};
|
|
326
|
+
for (let i = 0; i < 5; i++) subs[`subId${i + 1}`] = cleanSubId(subIds[i]);
|
|
327
|
+
|
|
328
|
+
const pagesOpen = ctx.pages();
|
|
329
|
+
let page = pagesOpen.find((p) => p.url().includes('affiliate.shopee.vn'));
|
|
330
|
+
let opened = false;
|
|
331
|
+
if (!page) { page = await ctx.newPage(); opened = true; }
|
|
332
|
+
try {
|
|
333
|
+
if (!page.url().includes('affiliate.shopee.vn')) {
|
|
334
|
+
await page.goto('https://affiliate.shopee.vn/offer/product_offer', { waitUntil: 'networkidle', timeout: timeoutMs }).catch(() => {});
|
|
335
|
+
await sleep(1500);
|
|
336
|
+
}
|
|
337
|
+
if (isBlockedState(page.url(), null)) {
|
|
338
|
+
return { status: 'needs_verify', links: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
339
|
+
}
|
|
340
|
+
const res = await page.evaluate(async (q) => {
|
|
341
|
+
const body = {
|
|
342
|
+
operationName: 'batchGetProductOfferLink',
|
|
343
|
+
query: `query batchGetProductOfferLink($sourceCaller: SourceCaller!, $productOfferLinkParams: [ProductOfferLinkParam!]!, $advancedLinkParams: AdvancedLinkParams){ productOfferLinks(productOfferLinkParams: $productOfferLinkParams, sourceCaller: $sourceCaller, advancedLinkParams: $advancedLinkParams) { itemId shopId productOfferLink } }`,
|
|
344
|
+
variables: {
|
|
345
|
+
productOfferLinkParams: q.list,
|
|
346
|
+
sourceCaller: 'WEB_SITE_CALLER',
|
|
347
|
+
advancedLinkParams: q.subs,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
try {
|
|
351
|
+
const r = await fetch('/api/v3/gql?q=productOfferLinks', {
|
|
352
|
+
method: 'POST', credentials: 'include',
|
|
353
|
+
headers: { 'Content-Type': 'application/json' },
|
|
354
|
+
body: JSON.stringify(body),
|
|
355
|
+
});
|
|
356
|
+
return { status: r.status, json: await r.json().catch(() => null) };
|
|
357
|
+
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
358
|
+
}, { list, subs });
|
|
359
|
+
|
|
360
|
+
if (isBlockedState(page.url(), res)) {
|
|
361
|
+
return { status: 'needs_verify', links: [], reason: `blocked (http ${res.status})` };
|
|
362
|
+
}
|
|
363
|
+
const err = res.json?.errors?.[0]?.message;
|
|
364
|
+
if (err) return { status: 'failed', links: [], reason: err };
|
|
365
|
+
const rows = res.json?.data?.productOfferLinks || [];
|
|
366
|
+
return {
|
|
367
|
+
status: 'ok',
|
|
368
|
+
sub_ids: subs,
|
|
369
|
+
links: rows.map((r) => ({ item_id: String(r.itemId), shop_id: String(r.shopId), link: r.productOfferLink })),
|
|
370
|
+
};
|
|
371
|
+
} finally {
|
|
372
|
+
if (opened) await page.close().catch(() => {});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
295
376
|
// Parse a Shopee product URL → { shop_id, item_id }. Accepts
|
|
296
377
|
// /product/<shop>/<item> and i.<shop>.<item> forms.
|
|
297
378
|
function parseProductUrl(url = '') {
|
|
@@ -302,4 +383,4 @@ function parseProductUrl(url = '') {
|
|
|
302
383
|
return null;
|
|
303
384
|
}
|
|
304
385
|
|
|
305
|
-
module.exports = { ingestProduct, scrapeOffers, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters };
|
|
386
|
+
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters };
|
package/package.json
CHANGED
|
@@ -510,7 +510,7 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw',
|
|
|
510
510
|
const dlgs = document.querySelectorAll("[role='dialog']");
|
|
511
511
|
const roots = dlgs.length ? Array.from(dlgs) : [document];
|
|
512
512
|
const vh = window.innerHeight;
|
|
513
|
-
let present = false, enabled = false;
|
|
513
|
+
let present = false, enabled = false, rejected = '';
|
|
514
514
|
for (const root of roots) {
|
|
515
515
|
for (const el of root.querySelectorAll("button, [role='button']")) {
|
|
516
516
|
const t = (el.innerText || el.textContent || '').trim();
|
|
@@ -523,9 +523,22 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw',
|
|
|
523
523
|
present = true;
|
|
524
524
|
if (!(el.getAttribute('aria-disabled') === 'true' || el.disabled)) enabled = true;
|
|
525
525
|
}
|
|
526
|
+
// FB TỪ CHỐI FILE: dòng đỏ ngay trên nút Lưu/Đăng, khung xem trước
|
|
527
|
+
// trống. Ảnh dump 09/09 21:49 (Góc Trại Gà, file 58 MB, lành, decode
|
|
528
|
+
// sạch): "Không thể tải file của bạn lên: fb-….mp4". Nút Đăng sẽ KHÔNG
|
|
529
|
+
// BAO GIỜ bật — chờ đủ 600s rồi mới báo "remained disabled" là đốt 10
|
|
530
|
+
// phút profile và dễ bị bộ dọn 20 phút khai tử. Đo 24h: 9 ca, 8 ca là
|
|
531
|
+
// file >50 MB (đi đường CDP setFileInputFiles).
|
|
532
|
+
const txt = (root.innerText || '').slice(0, 4000);
|
|
533
|
+
const m = /Không thể tải (?:file|tệp)[^\n]{0,120}|couldn['’]t upload your (?:file|video)[^\n]{0,80}|Unable to upload[^\n]{0,80}/i.exec(txt);
|
|
534
|
+
if (m) rejected = m[0].trim();
|
|
526
535
|
}
|
|
527
|
-
return { present, enabled };
|
|
528
|
-
}, verbs).catch(() => ({ present: false, enabled: false }));
|
|
536
|
+
return { present, enabled, rejected };
|
|
537
|
+
}, verbs).catch(() => ({ present: false, enabled: false, rejected: '' }));
|
|
538
|
+
if (st.rejected) {
|
|
539
|
+
log('warn', `[${tag}] Facebook TỪ CHỐI file: "${st.rejected.slice(0, 120)}" — nút "Đăng" sẽ không bật, dừng chờ ngay`);
|
|
540
|
+
return { enabled: false, sawPresent: true, rejected: st.rejected };
|
|
541
|
+
}
|
|
529
542
|
if (st.enabled) {
|
|
530
543
|
if (announced) log('info', `[${tag}] "Đăng" is now enabled — video finished processing`);
|
|
531
544
|
return { enabled: true, sawPresent: true };
|
|
@@ -534,6 +547,19 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw',
|
|
|
534
547
|
sawPresent = true;
|
|
535
548
|
absentSince = 0;
|
|
536
549
|
if (!announced) { log('info', `[${tag}] final step: "Đăng" disabled (video still processing) — waiting up to ${Math.round(timeoutMs / 1000)}s…`); announced = true; }
|
|
550
|
+
} else if (sawPresent) {
|
|
551
|
+
// ĐÃ THẤY nút rồi mà giờ biến mất = composer đã đóng (FB nhảy về trang
|
|
552
|
+
// chủ / toast cướp click / người mở profile bằng tay). Trước đây nhánh này
|
|
553
|
+
// không có: worker đứng đếm đủ 600s trên một hộp thoại không còn tồn tại
|
|
554
|
+
// rồi mới báo "composer vanished — retrying" (09/09 22:20, chủ tịch nhìn
|
|
555
|
+
// profile Chuyện Nhà Nông thấy đang ở trang chủ trong khi log vẫn ghi
|
|
556
|
+
// "waiting up to 600s"). Cho một khoảng ân hạn ngắn vì FB có lúc dựng lại
|
|
557
|
+
// dialog, rồi trả về để call site chạy lại lượt upload ngay.
|
|
558
|
+
if (!absentSince) absentSince = Date.now();
|
|
559
|
+
if (Date.now() - absentSince >= absentGraceMs) {
|
|
560
|
+
log('warn', `[${tag}] nút "Đăng" đã thấy rồi mà biến mất ${Math.round(absentGraceMs / 1000)}s — composer đã đóng, không chờ hết ${Math.round(timeoutMs / 1000)}s nữa`);
|
|
561
|
+
return { enabled: false, sawPresent: true, gone: true };
|
|
562
|
+
}
|
|
537
563
|
} else if (!sawPresent) {
|
|
538
564
|
if (!absentSince) absentSince = Date.now();
|
|
539
565
|
// The final settings form mounts lazily after the thumbnail editor closes.
|
|
@@ -2454,6 +2480,11 @@ async function runOnce({ page, payload, log }) {
|
|
|
2454
2480
|
if (waitResult.enabled) { step--; continue; }
|
|
2455
2481
|
await dumpInventory(page, log, `no-advance-${step + 1}`);
|
|
2456
2482
|
await dumpFailure(page, `no-advance-${step + 1}`, log);
|
|
2483
|
+
if (waitResult.rejected) {
|
|
2484
|
+
// Mã FB_PUBLISH_ để run() không đi dò tài khoản (không phải lỗi
|
|
2485
|
+
// đăng nhập), và PublishRetry phân loại đúng "file bị từ chối".
|
|
2486
|
+
throw new Error(`FB_PUBLISH_REJECTED: Facebook từ chối file video ("${waitResult.rejected.slice(0, 100)}") — thường gặp với file >50 MB đi đường CDP; nén/render lại nhỏ hơn rồi đăng`);
|
|
2487
|
+
}
|
|
2457
2488
|
// Composer biến mất (URL nhảy sang /reel/… vì toast cướp click, hoặc
|
|
2458
2489
|
// FB tự đóng) → CHẠY LẠI cả lượt upload thay vì báo lỗi khó hiểu.
|
|
2459
2490
|
// An toàn vì chưa hề bấm Đăng ở nhánh này.
|
|
@@ -2869,35 +2900,24 @@ async function runOnce({ page, payload, log }) {
|
|
|
2869
2900
|
}
|
|
2870
2901
|
}
|
|
2871
2902
|
|
|
2872
|
-
// (a.3) LAST-RESORT
|
|
2873
|
-
//
|
|
2874
|
-
//
|
|
2875
|
-
//
|
|
2876
|
-
//
|
|
2877
|
-
//
|
|
2878
|
-
//
|
|
2879
|
-
//
|
|
2880
|
-
//
|
|
2903
|
+
// (a.3) ĐÃ GỠ nhánh LAST-RESORT "bốc tile đầu tab Reels" — 2026-09-09.
|
|
2904
|
+
// Nó là nguồn của mọi link sai: đo prod 30 ngày, 97 video mang link
|
|
2905
|
+
// của BÀI KHÁC (~10% số link), riêng 09/09 là 15 ca; log win-worker
|
|
2906
|
+
// cho thấy nhánh này chạy 250 lần mà bộ lọc "chắc chắn reel cũ" chỉ
|
|
2907
|
+
// chặn được 6, relabs03 chạy 107 lần chặn được 0. Bộ lọc vô dụng vì
|
|
2908
|
+
// nó chỉ biết những reel ID đã đi qua GÓI MẠNG trước lúc bấm Đăng,
|
|
2909
|
+
// mà luồng page-wall không hề mở tab Reels trước đó — tile đầu tiên
|
|
2910
|
+
// của tab hầu như không nằm trong danh sách ấy. Nhánh "tile có mốc
|
|
2911
|
+
// thời gian mới" phía trên (a.2/a.2b) thì chưa từng khớp lần nào
|
|
2912
|
+
// (0/250) vì tile trên tab Reels chỉ hiện lượt xem, không hiện giờ
|
|
2913
|
+
// đăng — nên "last-resort" thực chất là "bốc đại cái đầu tiên".
|
|
2914
|
+
// Ca chủ tịch bắt 09/09 (kênh Bí Quyết Nhà Nông): bài 12:53 báo
|
|
2915
|
+
// thành công, link trỏ vào reel của bài 08/09; bài thật CHƯA lên.
|
|
2916
|
+
// Không có link thì API hạ xuống "mập mờ, chờ người kiểm"
|
|
2917
|
+
// (markUnverifiedPublish) — thà rỗng còn hơn sai, và giờ rỗng cũng
|
|
2918
|
+
// không còn hoá thành "đăng thành công" nữa.
|
|
2881
2919
|
if (!postUrl) {
|
|
2882
|
-
|
|
2883
|
-
const tileHref = await page.evaluate((staleIds) => {
|
|
2884
|
-
const staleSet = new Set(staleIds);
|
|
2885
|
-
const anchors = document.querySelectorAll("a[role='link']");
|
|
2886
|
-
for (const a of anchors) {
|
|
2887
|
-
const aria = (a.getAttribute('aria-label') || '').toLowerCase();
|
|
2888
|
-
if (!/bản xem trước ô thước phim|reel tile preview|reel preview/.test(aria)) continue;
|
|
2889
|
-
const href = a.getAttribute('href') || '';
|
|
2890
|
-
const m = href.match(/\/reel\/(\d{8,20})/);
|
|
2891
|
-
if (m) return { href, aria: aria.slice(0, 60), id: m[1], provablyStale: staleSet.has(m[1]) };
|
|
2892
|
-
}
|
|
2893
|
-
return null;
|
|
2894
|
-
}, preIds).catch(() => null);
|
|
2895
|
-
if (tileHref && tileHref.provablyStale) {
|
|
2896
|
-
log('warn', `[fb-pw] LAST-RESORT tile ${tileHref.id} was already captured PRE-publish — provably an OLD reel; leaving post_url empty instead of recording a wrong one`);
|
|
2897
|
-
} else if (tileHref) {
|
|
2898
|
-
postUrl = tileHref.href.startsWith('http') ? tileHref.href : `https://www.facebook.com${tileHref.href}`;
|
|
2899
|
-
log('warn', `[fb-pw] post URL from FIRST reel tile on reels_tab (LAST-RESORT, may be STALE): ${postUrl}`);
|
|
2900
|
-
}
|
|
2920
|
+
log('warn', '[fb-pw] không nguồn nào xác nhận được reel mới (mạng/tile mới/tiêu đề đều trượt) — KHÔNG bốc tile đầu tab Reels nữa, để post_url rỗng cho API đánh mập mờ');
|
|
2901
2921
|
}
|
|
2902
2922
|
|
|
2903
2923
|
// (b) URL change — FB sometimes navigates to /reel/<id>/ after publish.
|