channel-worker 2.5.105 → 2.5.107
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 +28 -6
- package/lib/shopee-scraper.js +33 -5
- package/package.json +1 -1
- package/scripts/comment_facebook.js +271 -0
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,get_affiliate_link';
|
|
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,comment_facebook_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
|
|
package/lib/command-poller.js
CHANGED
|
@@ -200,10 +200,17 @@ class CommandPoller {
|
|
|
200
200
|
let conn;
|
|
201
201
|
try {
|
|
202
202
|
conn = await this._connectNstProfileByName(profileName);
|
|
203
|
+
// Từ khoá và ngành hàng loại trừ nhau: có keyword thì KHÔNG được để
|
|
204
|
+
// `category` rơi về mặc định 'women_clothes', không thì scrapeOffers nhận
|
|
205
|
+
// cả hai và lượt tìm im lặng biến thành lượt duyệt tab quần áo nữ.
|
|
206
|
+
const keyword = String(payload.keyword || '').trim();
|
|
203
207
|
const res = await scraper.scrapeOffers(conn.context, {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
208
|
+
keyword: keyword || null,
|
|
209
|
+
category: keyword ? null : (payload.category || 'women_clothes'), // Tier-1 default: quần áo nữ
|
|
210
|
+
match_id: keyword ? null : (payload.match_id || null),
|
|
211
|
+
// Ở chế độ từ khoá, sort_type 3 là GIÁ GIẢM DẦN chứ không phải doanh số
|
|
212
|
+
// (đo 09/09) — scrapeOffers tự cắt, đây chỉ là không đưa vào cho đỡ rối.
|
|
213
|
+
sort_types: payload.sort_types || (keyword ? [2] : [2, 3]), // commission + sales, merged
|
|
207
214
|
pages: payload.pages ?? 2,
|
|
208
215
|
page_limit: payload.page_limit ?? 20,
|
|
209
216
|
delay_ms: payload.delay_ms ?? 2500, // human-like pacing (anti-bot)
|
|
@@ -217,9 +224,24 @@ class CommandPoller {
|
|
|
217
224
|
await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason: res.reason, count: res.products?.length || 0 } });
|
|
218
225
|
return;
|
|
219
226
|
}
|
|
220
|
-
console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}`);
|
|
227
|
+
console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}${keyword ? ` (từ khoá "${keyword}")` : ''}`);
|
|
221
228
|
await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
222
|
-
|
|
229
|
+
// Lượt tìm theo từ khoá trả kèm CHÍNH danh sách tìm được, không chỉ đếm:
|
|
230
|
+
// 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
|
|
231
|
+
// thì lẫn hàng của các lượt trước và mất luôn thứ tự Shopee xếp.
|
|
232
|
+
// Cắt gọn để kết quả lệnh không phình (ảnh/biến thể ở lại trong kho).
|
|
233
|
+
const slim = keyword
|
|
234
|
+
? res.products.slice(0, 20).map((r) => ({
|
|
235
|
+
shop_id: r.shop_id, item_id: r.item_id, name: r.name, price: r.price,
|
|
236
|
+
commission_rate: r.commission_rate, commission_value: r.commission_value,
|
|
237
|
+
sold_count: r.sold_count, rating: r.rating, cover_image: r.cover_image,
|
|
238
|
+
product_url: r.product_url, category_group: r.category_group,
|
|
239
|
+
}))
|
|
240
|
+
: undefined;
|
|
241
|
+
await this.api.updateCommand(command._id, {
|
|
242
|
+
status: 'done',
|
|
243
|
+
result: { count: res.products.length, raw: res.raw_count, keyword: res.keyword || null, ...(slim ? { products: slim } : {}) },
|
|
244
|
+
});
|
|
223
245
|
} catch (err) {
|
|
224
246
|
console.error(`[shopee] scrape failed: ${err.message}`);
|
|
225
247
|
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
@@ -441,7 +463,7 @@ class CommandPoller {
|
|
|
441
463
|
//
|
|
442
464
|
// Scoped to the browse-only scripts. Publish keeps its profile open on
|
|
443
465
|
// purpose: one profile serves several platform uploads back-to-back.
|
|
444
|
-
const CLOSE_AFTER = new Set(['nurture_facebook', 'warmup_facebook', 'warmup_youtube', 'warmup_tiktok', 'fetch_facebook_reel_stats']);
|
|
466
|
+
const CLOSE_AFTER = new Set(['nurture_facebook', 'warmup_facebook', 'warmup_youtube', 'warmup_tiktok', 'fetch_facebook_reel_stats', 'comment_facebook']);
|
|
445
467
|
// Cờ vừa bị TẮT trên web → bỏ profile khỏi sổ giữ mở, để nó quay về vòng
|
|
446
468
|
// đời đóng-mở bình thường ngay từ lượt này.
|
|
447
469
|
if (!keepOpen.enabled && this._keptOpenProfiles) this._keptOpenProfiles.delete(profileId);
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -246,6 +246,14 @@ function applyFilters(rows, f = {}) {
|
|
|
246
246
|
async function scrapeOffers(ctx, {
|
|
247
247
|
category = null, // key in AFFILIATE_CATEGORIES (e.g. 'women_clothes')
|
|
248
248
|
match_id = null, // explicit category id override
|
|
249
|
+
// Tìm theo TỪ KHOÁ thay vì duyệt tab ngành hàng. Cùng endpoint, khác tham số:
|
|
250
|
+
// `list_type=0&keyword=<kw>&filter_types=2` (đo 09/09/2026 — ghi chú cũ bảo
|
|
251
|
+
// "cần signed header" là sai/lạc hậu).
|
|
252
|
+
//
|
|
253
|
+
// Đây là thứ gỡ bế tắc kho bão hoà: 6 tab ngành hàng luôn trả về top N giống
|
|
254
|
+
// nhau nên kho đứng yên ở 34 sản phẩm. Từ khoá thì mở ra đúng món hợp với
|
|
255
|
+
// từng video.
|
|
256
|
+
keyword = null,
|
|
249
257
|
sort_types = [2], // [2]=commission; pass [2,3] to merge commission+sales
|
|
250
258
|
pages = 2,
|
|
251
259
|
page_limit = 20,
|
|
@@ -256,6 +264,18 @@ async function scrapeOffers(ctx, {
|
|
|
256
264
|
sort_type = null,
|
|
257
265
|
} = {}) {
|
|
258
266
|
if (sort_type != null) sort_types = [sort_type];
|
|
267
|
+
const kw = String(keyword || '').trim();
|
|
268
|
+
// ⚠️ Ý NGHĨA CỦA sort_type ĐỔI KHI TÌM THEO TỪ KHOÁ (đo 09/09/2026):
|
|
269
|
+
// 2 = bán chạy ← thứ mình muốn
|
|
270
|
+
// 3 = GIÁ GIẢM DẦN, không phải doanh số (khác hẳn chế độ duyệt tab)
|
|
271
|
+
// 1 = liên quan, trả về toàn hàng bán 0 / 0 sao
|
|
272
|
+
// Mặc định của hàm là [2] nên chế độ từ khoá đã đúng sẵn; nhưng bên gọi quen
|
|
273
|
+
// tay truyền [2,3] như lúc quét tab thì một nửa kết quả là hàng đắt nhất chứ
|
|
274
|
+
// không phải hàng bán chạy. Cắt 3 đi và nói ra, đừng lặng lẽ quét rác.
|
|
275
|
+
if (kw && sort_types.includes(3)) {
|
|
276
|
+
sort_types = sort_types.filter((x) => x !== 3);
|
|
277
|
+
if (!sort_types.length) sort_types = [2];
|
|
278
|
+
}
|
|
259
279
|
const cat = category && AFFILIATE_CATEGORIES[category] ? AFFILIATE_CATEGORIES[category] : null;
|
|
260
280
|
const mid = match_id || cat?.match_id || null;
|
|
261
281
|
// Default the category_groups filter to the category's own group (so a
|
|
@@ -285,15 +305,20 @@ async function scrapeOffers(ctx, {
|
|
|
285
305
|
for (let i = 0; i < pages; i++) {
|
|
286
306
|
const offset = i * page_limit;
|
|
287
307
|
const res = await page.evaluate(async (q) => {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
308
|
+
// Từ khoá đứng TRƯỚC ngành hàng: hai chế độ loại trừ nhau, gửi cả hai
|
|
309
|
+
// thì Shopee bỏ qua keyword và mình tưởng đang tìm mà thật ra vẫn
|
|
310
|
+
// đang duyệt tab cũ.
|
|
311
|
+
const cat = q.kw
|
|
312
|
+
? `&list_type=0&keyword=${encodeURIComponent(q.kw)}&filter_types=2`
|
|
313
|
+
: (q.listType
|
|
314
|
+
? `&list_type=${q.listType}`
|
|
315
|
+
: (q.mid ? `&list_type=3&match_type=2&match_id=${q.mid}` : '&list_type=0'));
|
|
291
316
|
const url = `/api/v3/offer/product/list?sort_type=${q.st}&page_offset=${q.offset}&page_limit=${q.page_limit}&client_type=1${cat}`;
|
|
292
317
|
try {
|
|
293
318
|
const r = await fetch(url, { credentials: 'include' });
|
|
294
319
|
return { status: r.status, json: await r.json().catch(() => null) };
|
|
295
320
|
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
296
|
-
}, { st, offset, page_limit, mid, listType });
|
|
321
|
+
}, { st, offset, page_limit, mid, listType, kw });
|
|
297
322
|
|
|
298
323
|
if (isBlockedState(page.url(), res) || res.json?.code === 90309999) {
|
|
299
324
|
return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: `blocked mid-scrape (http ${res.status}, code ${res.json?.code})` };
|
|
@@ -314,7 +339,10 @@ async function scrapeOffers(ctx, {
|
|
|
314
339
|
}
|
|
315
340
|
|
|
316
341
|
const products = applyFilters([...byId.values()], filters);
|
|
317
|
-
|
|
342
|
+
// `keyword` đi kèm kết quả để bên gọi ghi lại sản phẩm tìm được theo từ
|
|
343
|
+
// khoá nào — không có nó thì kho chỉ biết "có món này", không biết vì sao
|
|
344
|
+
// nó được nhặt về, và lượt chấm điểm hợp-video sau này mất căn cứ.
|
|
345
|
+
return { status: 'ok', products, raw_count: rawCount, keyword: kw || null };
|
|
318
346
|
} finally {
|
|
319
347
|
if (opened) await page.close().catch(() => {});
|
|
320
348
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Thả link affiliate xuống BÌNH LUẬN của một bài Facebook đã đăng, rồi ghim.
|
|
2
|
+
//
|
|
3
|
+
// Vì sao là bình luận chứ không phải caption: bài đang lên view thì không ai đi
|
|
4
|
+
// sửa caption của nó — sửa caption là bài quay lại hàng đợi xét duyệt của
|
|
5
|
+
// Facebook giữa lúc nó đang được phân phối. Bình luận thì ghim được, sửa được,
|
|
6
|
+
// xoá được, không đụng vào bài.
|
|
7
|
+
//
|
|
8
|
+
// Chạy trên chính profile NST của kênh (máy đăng bài) chứ không phải máy
|
|
9
|
+
// Shopee: bình luận phải mang danh Page thì mới ghim được, và một link lạ dưới
|
|
10
|
+
// bài của mình do tài khoản khác thả thì người xem đọc ra là spam.
|
|
11
|
+
//
|
|
12
|
+
// Payload: { post_url, text, link, pin?, idea_id?, channel_id? }
|
|
13
|
+
// post_url: link bài đã XÁC MINH (API chặn sẵn ô rỗng — services/AffComment)
|
|
14
|
+
// text: nguyên câu bình luận sẽ gõ, đã gồm link
|
|
15
|
+
// link: riêng chuỗi link, dùng để ĐỌC LẠI xác minh — xem `verified`
|
|
16
|
+
//
|
|
17
|
+
// Trả về { verified, comment_url, commented_as, pinned, post_url }.
|
|
18
|
+
//
|
|
19
|
+
// ⚠️ LUẬT LÕI CỦA SCRIPT NÀY: `verified` chỉ được true khi đã đọc lại thấy một
|
|
20
|
+
// bình luận THẬT trên trang mang đúng chuỗi link. Gõ xong + bấm Enter KHÔNG
|
|
21
|
+
// phải bằng chứng — đó đúng là cách 39 ca "đăng xong mà post_url rỗng" ra đời:
|
|
22
|
+
// script khai thành công theo thao tác của chính nó, không theo thứ trang web
|
|
23
|
+
// hiện ra. API hạ mọi kết quả thiếu `verified` xuống 'failed'.
|
|
24
|
+
|
|
25
|
+
const { assertAccountUsable, readPublishBlock } = require('./lib/fb-guard');
|
|
26
|
+
const { humanClick, humanType, pause, randInt } = require('./lib/human');
|
|
27
|
+
|
|
28
|
+
const NAV_TIMEOUT_MS = 45_000;
|
|
29
|
+
// Bình luận hiện ra gần như tức thì, nhưng lượt đầu sau khi mở bài thì Facebook
|
|
30
|
+
// còn đang dựng khung bình luận. 20 giây là dư cho trường hợp chậm nhất đo được
|
|
31
|
+
// mà vẫn không treo lệnh.
|
|
32
|
+
const VERIFY_TIMEOUT_MS = 20_000;
|
|
33
|
+
|
|
34
|
+
// Ô soạn bình luận. Facebook thay nhãn theo phiên bản giao diện và theo việc
|
|
35
|
+
// đang bình luận với tư cách ai, nên dò theo NHIỀU nhãn thay vì một selector
|
|
36
|
+
// "đúng nhất" — bản trước chỉ có 'Viết bình luận' và trượt sạch trên giao diện
|
|
37
|
+
// tiếng Anh.
|
|
38
|
+
const BOX_SELECTORS = [
|
|
39
|
+
"[contenteditable='true'][aria-label*='Bình luận']",
|
|
40
|
+
"[contenteditable='true'][aria-label*='bình luận']",
|
|
41
|
+
"[contenteditable='true'][aria-label*='Comment']",
|
|
42
|
+
"[contenteditable='true'][aria-label*='comment']",
|
|
43
|
+
"[contenteditable='true'][aria-placeholder*='bình luận']",
|
|
44
|
+
"[contenteditable='true'][aria-placeholder*='comment']",
|
|
45
|
+
"[role='textbox'][contenteditable='true']",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// Nút mở khung bình luận, khi bài mở ra mà khung chưa dựng.
|
|
49
|
+
const OPEN_COMMENTS_SELECTORS = [
|
|
50
|
+
"[aria-label='Bình luận'], [aria-label='Comment']",
|
|
51
|
+
"[aria-label*='Để lại bình luận'], [aria-label*='Leave a comment']",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
function sel(list) { return list.join(', '); }
|
|
55
|
+
|
|
56
|
+
// ─── tìm ô soạn ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async function findCommentBox(page) {
|
|
59
|
+
for (const s of BOX_SELECTORS) {
|
|
60
|
+
const loc = page.locator(s);
|
|
61
|
+
const n = Math.min(await loc.count().catch(() => 0), 6);
|
|
62
|
+
for (let i = 0; i < n; i++) {
|
|
63
|
+
const el = loc.nth(i);
|
|
64
|
+
if (!(await el.isVisible().catch(() => false))) continue;
|
|
65
|
+
// Ô soạn BÀI (tường Page) cũng là contenteditable và cũng khớp
|
|
66
|
+
// `role=textbox` — gõ nhầm vào đó là đăng một bài mới toàn link. Ô bình
|
|
67
|
+
// luận luôn nằm bên trong khối bài; ô soạn bài thì không.
|
|
68
|
+
const inArticle = await el.evaluate((node) => !!node.closest("[role='article'], [role='dialog'], [role='complementary']"))
|
|
69
|
+
.catch(() => false);
|
|
70
|
+
if (!inArticle && !/comment|bình luận/i.test(s)) continue;
|
|
71
|
+
return el;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function openCommentsIfNeeded(page, log) {
|
|
78
|
+
for (const s of OPEN_COMMENTS_SELECTORS) {
|
|
79
|
+
const loc = page.locator(s).first();
|
|
80
|
+
if (!(await loc.isVisible().catch(() => false))) continue;
|
|
81
|
+
log('info', `[fb-cmt] chưa thấy ô soạn — bấm mở khung bình luận ("${s}")`);
|
|
82
|
+
await humanClick(page, loc).catch(() => {});
|
|
83
|
+
await pause(page, 1800);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── đọc lại để xác minh ────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
// Tìm bình luận THẬT mang chuỗi `link`. Bỏ qua mọi thứ nằm trong ô soạn: chữ
|
|
92
|
+
// mình vừa gõ cũng khớp chuỗi tìm kiếm, mà nó chưa phải bình luận.
|
|
93
|
+
//
|
|
94
|
+
// Facebook đặt tên tác giả ngay trong aria-label của khối bình luận
|
|
95
|
+
// ("Bình luận của <Tên>" / "Comment by <Name>") — đọc thẳng ở đó chắc hơn dò
|
|
96
|
+
// anchor đầu tiên, vì trong khối còn có link tới bài, tới trang được nhắc tên.
|
|
97
|
+
async function findPostedComment(page, link) {
|
|
98
|
+
return page.evaluate((needle) => {
|
|
99
|
+
const out = { found: false, author: '', comment_url: '' };
|
|
100
|
+
const nodes = [...document.querySelectorAll("[role='article']")];
|
|
101
|
+
for (const node of nodes) {
|
|
102
|
+
const txt = node.innerText || '';
|
|
103
|
+
if (!txt.includes(needle)) continue;
|
|
104
|
+
// Chính ô soạn (hoặc khối bài) — không tính.
|
|
105
|
+
if (node.querySelector("[contenteditable='true']") && !node.getAttribute('aria-label')) continue;
|
|
106
|
+
const label = node.getAttribute('aria-label') || '';
|
|
107
|
+
const m = /(?:Bình luận của|Comment by)\s+(.+)$/i.exec(label.trim());
|
|
108
|
+
// Không có aria-label dạng bình luận = nhiều khả năng đây là khối BÀI
|
|
109
|
+
// chứa chuỗi link trong caption, không phải bình luận.
|
|
110
|
+
if (!m) continue;
|
|
111
|
+
out.found = true;
|
|
112
|
+
out.author = m[1].trim().slice(0, 120);
|
|
113
|
+
const a = node.querySelector("a[href*='comment_id=']");
|
|
114
|
+
if (a) {
|
|
115
|
+
const href = a.getAttribute('href') || '';
|
|
116
|
+
out.comment_url = href.startsWith('http') ? href : `https://www.facebook.com${href}`;
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}, link).catch(() => ({ found: false, author: '', comment_url: '' }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function waitForPostedComment(page, link, log, timeoutMs = VERIFY_TIMEOUT_MS) {
|
|
125
|
+
const deadline = Date.now() + timeoutMs;
|
|
126
|
+
let last = { found: false, author: '', comment_url: '' };
|
|
127
|
+
while (Date.now() < deadline) {
|
|
128
|
+
last = await findPostedComment(page, link);
|
|
129
|
+
if (last.found) return last;
|
|
130
|
+
await page.waitForTimeout(1200);
|
|
131
|
+
}
|
|
132
|
+
log('warn', `[fb-cmt] hết ${Math.round(timeoutMs / 1000)}s vẫn chưa đọc lại thấy bình luận mang link`);
|
|
133
|
+
return last;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── ghim ───────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
// Ghim là việc CÓ CŨNG TỐT, không có cũng xong: link đã nằm dưới bài rồi. Nên
|
|
139
|
+
// mọi lỗi ở đây chỉ ghi log và trả false, KHÔNG ném — hỏng bước ghim mà đánh
|
|
140
|
+
// cả lệnh thành thất bại thì lượt chạy lại sẽ thả bình luận thứ hai.
|
|
141
|
+
async function pinComment(page, link, log) {
|
|
142
|
+
try {
|
|
143
|
+
const menuBtn = await page.evaluateHandle((needle) => {
|
|
144
|
+
const nodes = [...document.querySelectorAll("[role='article']")];
|
|
145
|
+
for (const node of nodes) {
|
|
146
|
+
if (!(node.innerText || '').includes(needle)) continue;
|
|
147
|
+
if (!/(Bình luận của|Comment by)/i.test(node.getAttribute('aria-label') || '')) continue;
|
|
148
|
+
const btn = [...node.querySelectorAll("[role='button'], [aria-haspopup='menu']")]
|
|
149
|
+
.find((b) => /hành động|tùy chọn|tuỳ chọn|options|actions/i.test(b.getAttribute('aria-label') || ''));
|
|
150
|
+
if (btn) { btn.scrollIntoView({ block: 'center' }); return btn; }
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}, link);
|
|
154
|
+
const el = menuBtn.asElement();
|
|
155
|
+
if (!el) { log('info', '[fb-cmt] không thấy nút ⋯ của bình luận — bỏ qua bước ghim'); return false; }
|
|
156
|
+
await el.click({ timeout: 5000 }).catch(() => {});
|
|
157
|
+
await pause(page, 1200);
|
|
158
|
+
|
|
159
|
+
const item = page.locator("[role='menuitem'], [role='menu'] [role='button']")
|
|
160
|
+
.filter({ hasText: /Ghim bình luận|Ghim|Pin comment|Pin/i }).first();
|
|
161
|
+
if (!(await item.isVisible().catch(() => false))) {
|
|
162
|
+
log('info', '[fb-cmt] menu không có mục Ghim (thường là chưa bình luận bằng danh Page) — bỏ qua');
|
|
163
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
await item.click({ timeout: 5000 }).catch(() => {});
|
|
167
|
+
await pause(page, 1500);
|
|
168
|
+
log('info', '[fb-cmt] đã bấm Ghim bình luận');
|
|
169
|
+
return true;
|
|
170
|
+
} catch (e) {
|
|
171
|
+
log('warn', `[fb-cmt] ghim hỏng (bỏ qua, link vẫn nằm dưới bài): ${e.message.slice(0, 120)}`);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── thân ───────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
async function run({ page, payload = {}, log }) {
|
|
179
|
+
const postUrl = String(payload.post_url || '').trim();
|
|
180
|
+
const text = String(payload.text || '').trim();
|
|
181
|
+
const link = String(payload.link || '').trim();
|
|
182
|
+
if (!postUrl) throw new Error('thiếu post_url — không biết thả bình luận vào bài nào');
|
|
183
|
+
if (!text) throw new Error('thiếu text — không có gì để bình luận');
|
|
184
|
+
if (!link) throw new Error('thiếu link — không có gì để đọc lại xác minh');
|
|
185
|
+
if (!text.includes(link)) throw new Error('text không chứa link — API dựng sai câu bình luận');
|
|
186
|
+
|
|
187
|
+
// Ghé trang chủ TRƯỚC rồi mới sang bài, dù biết thừa link bài.
|
|
188
|
+
//
|
|
189
|
+
// Lý do không kiểm tra phiên ngay trên trang bài: lib/fb-guard đọc chữ trong
|
|
190
|
+
// body để đoán checkpoint/khoá tài khoản, mà trang bài thì đầy BÌNH LUẬN của
|
|
191
|
+
// người lạ — một người bình luận "tài khoản của bạn đã bị vô hiệu hoá" là đủ
|
|
192
|
+
// để script tự kết luận kênh mình chết. Trang chủ có feed thật, fb-guard
|
|
193
|
+
// thấy feed là trả 'ok' ngay, không cần đọc chữ.
|
|
194
|
+
await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS }).catch(() => {});
|
|
195
|
+
await pause(page, 2200);
|
|
196
|
+
await assertAccountUsable(page, log);
|
|
197
|
+
|
|
198
|
+
log('info', `[fb-cmt] mở bài ${postUrl}`);
|
|
199
|
+
await page.goto(postUrl, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
|
|
200
|
+
await pause(page, 2600);
|
|
201
|
+
|
|
202
|
+
// Trang bài mở ra là trang LỖI / bài đã xoá thì dừng ngay, đừng đi tìm ô soạn
|
|
203
|
+
// rồi báo "không thấy ô bình luận" — hai chuyện khác hẳn nhau khi đọc log.
|
|
204
|
+
const gone = await page.evaluate(() => {
|
|
205
|
+
const t = (document.body?.innerText || '').slice(0, 1500);
|
|
206
|
+
return /(Không có sẵn|nội dung này hiện không có|This content isn't available|Liên kết bị hỏng|Page Not Found)/i.test(t);
|
|
207
|
+
}).catch(() => false);
|
|
208
|
+
if (gone) throw new Error(`Bài không mở được (đã xoá, đổi quyền riêng tư, hoặc link sai): ${postUrl}`);
|
|
209
|
+
|
|
210
|
+
let box = await findCommentBox(page);
|
|
211
|
+
if (!box) {
|
|
212
|
+
await openCommentsIfNeeded(page, log);
|
|
213
|
+
box = await findCommentBox(page);
|
|
214
|
+
}
|
|
215
|
+
if (!box) {
|
|
216
|
+
await assertAccountUsable(page, log);
|
|
217
|
+
throw new Error(`Không tìm thấy ô soạn bình luận trên ${postUrl} (đã thử ${BOX_SELECTORS.length} nhãn) — nhiều khả năng bài tắt bình luận hoặc Facebook đổi giao diện`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await box.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
|
|
221
|
+
await humanClick(page, box).catch(async () => { await box.click({ timeout: 5000 }); });
|
|
222
|
+
await pause(page, 900);
|
|
223
|
+
|
|
224
|
+
// typo: 0 — humanType vốn gõ sai rồi tự Backspace cho giống người, nhưng
|
|
225
|
+
// trong câu này có một URL: một ký tự sai giữa mã link làm Facebook dựng thẻ
|
|
226
|
+
// xem trước của link KHÁC ngay lúc gõ, và thẻ đó không rút lại bằng Backspace.
|
|
227
|
+
// Nhịp gõ thì vẫn giữ nguyên chất người.
|
|
228
|
+
await humanType(page, text, { typo: 0 });
|
|
229
|
+
await pause(page, randInt(900, 1800));
|
|
230
|
+
|
|
231
|
+
await page.keyboard.press('Enter');
|
|
232
|
+
log('info', '[fb-cmt] đã gửi bình luận — đang đọc lại để xác minh');
|
|
233
|
+
await pause(page, 2500);
|
|
234
|
+
|
|
235
|
+
// Facebook chặn theo tần suất thì nó nuốt bình luận và hiện chữ đỏ. Không
|
|
236
|
+
// đọc chỗ này là lệnh sẽ báo "không xác minh được" chung chung, và người đọc
|
|
237
|
+
// log đi tìm lỗi giao diện trong khi thủ phạm là cái phanh chống spam.
|
|
238
|
+
const block = await readPublishBlock(page);
|
|
239
|
+
if (block) {
|
|
240
|
+
throw new Error(`FB_COMMENT_RATE_LIMITED: Facebook chặn bình luận vì tần suất/nghi spam — bình luận KHÔNG được nhận. Chuỗi bắt được: "${block}". Dừng thả link trên kênh này cho tới khi người kiểm tra.`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const found = await waitForPostedComment(page, link, log);
|
|
244
|
+
if (!found.found) {
|
|
245
|
+
await assertAccountUsable(page, log);
|
|
246
|
+
// KHÔNG ném lỗi kiểu "có thể đã thả": trả về verified=false để API ghi
|
|
247
|
+
// 'failed' kèm nguyên văn. Ném lỗi ở đây thì lượt chạy lại sẽ gõ thêm một
|
|
248
|
+
// bình luận nữa, mà bình luận trước có thể đã nằm đó rồi.
|
|
249
|
+
log('warn', '[fb-cmt] gõ và gửi xong nhưng KHÔNG đọc lại thấy bình luận — coi như chưa thả, người phải vào kiểm');
|
|
250
|
+
return { verified: false, post_url: postUrl, comment_url: '', commented_as: '', pinned: false };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
log('info', `[fb-cmt] xác minh được bình luận — danh nghĩa "${found.author}"${found.comment_url ? ` · ${found.comment_url}` : ''}`);
|
|
254
|
+
|
|
255
|
+
let pinned = false;
|
|
256
|
+
if (payload.pin !== false) pinned = await pinComment(page, link, log);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
verified: true,
|
|
260
|
+
post_url: postUrl,
|
|
261
|
+
comment_url: found.comment_url || '',
|
|
262
|
+
// Tên hiện trên bình luận. Phải là tên Page — ra tên người thì profile đang
|
|
263
|
+
// bình luận bằng tài khoản cá nhân, link nằm dưới bài mà không ghim được và
|
|
264
|
+
// người xem đọc ra là spam của người lạ.
|
|
265
|
+
commented_as: found.author || '',
|
|
266
|
+
pinned,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = { run };
|
|
271
|
+
module.exports.__testables = { findPostedComment, findCommentBox, BOX_SELECTORS, sel };
|