channel-worker 2.5.105 → 2.5.106
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 +1 -1
- 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
|
@@ -441,7 +441,7 @@ class CommandPoller {
|
|
|
441
441
|
//
|
|
442
442
|
// Scoped to the browse-only scripts. Publish keeps its profile open on
|
|
443
443
|
// 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']);
|
|
444
|
+
const CLOSE_AFTER = new Set(['nurture_facebook', 'warmup_facebook', 'warmup_youtube', 'warmup_tiktok', 'fetch_facebook_reel_stats', 'comment_facebook']);
|
|
445
445
|
// Cờ vừa bị TẮT trên web → bỏ profile khỏi sổ giữ mở, để nó quay về vòng
|
|
446
446
|
// đời đóng-mở bình thường ngay từ lượt này.
|
|
447
447
|
if (!keepOpen.enabled && this._keptOpenProfiles) this._keptOpenProfiles.delete(profileId);
|
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 };
|