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 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
 
@@ -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
- category: payload.category || 'women_clothes', // Tier-1 default: quần áo nữ
205
- match_id: payload.match_id || null,
206
- sort_types: payload.sort_types || [2, 3], // commission + sales, merged
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
- await this.api.updateCommand(command._id, { status: 'done', result: { count: res.products.length, raw: res.raw_count } });
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);
@@ -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
- const cat = q.listType
289
- ? `&list_type=${q.listType}`
290
- : (q.mid ? `&list_type=3&match_type=2&match_id=${q.mid}` : '&list_type=0');
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
- return { status: 'ok', products, raw_count: rawCount };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.105",
3
+ "version": "2.5.107",
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": {
@@ -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 };