channel-worker 2.5.131 → 2.5.133
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/command-poller.js +0 -23
- package/lib/shopee-report.js +1 -75
- package/lib/shopee-scraper.js +58 -9
- package/lib/updater.js +47 -7
- package/package.json +1 -1
package/lib/command-poller.js
CHANGED
|
@@ -388,29 +388,8 @@ class CommandPoller {
|
|
|
388
388
|
return;
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
// Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
|
|
392
|
-
// nó dùng đúng phiên vừa mở, thêm một request chứ không thêm lần mở
|
|
393
|
-
// profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
|
|
394
|
-
// kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
|
|
395
|
-
let mediaAccounts = null;
|
|
396
|
-
let mediaError = null;
|
|
397
|
-
try {
|
|
398
|
-
const media = await report.fetchSocialMediaAccounts(conn.context, {
|
|
399
|
-
log: (message) => console.log(message),
|
|
400
|
-
});
|
|
401
|
-
if (media.status === 'ok') mediaAccounts = media.accounts;
|
|
402
|
-
else mediaError = media.reason || media.status;
|
|
403
|
-
} catch (err) {
|
|
404
|
-
mediaError = String(err.message || err).slice(0, 200);
|
|
405
|
-
}
|
|
406
|
-
if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
|
|
407
|
-
|
|
408
391
|
const saved = await this.api.affiliateReportResult(result.rows, {
|
|
409
392
|
clicks: clickResult.rows,
|
|
410
|
-
// `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
|
|
411
|
-
// liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
|
|
412
|
-
// chỉ vì một lượt đọc hỏng.
|
|
413
|
-
media_accounts: mediaAccounts,
|
|
414
393
|
last_report_date: result.last_report_date,
|
|
415
394
|
last_update_time: result.last_update_time,
|
|
416
395
|
from: result.from,
|
|
@@ -427,8 +406,6 @@ class CommandPoller {
|
|
|
427
406
|
click_saved: saved?.click_saved || 0,
|
|
428
407
|
click_matched: saved?.click_matched || 0,
|
|
429
408
|
click_unmatched: saved?.click_unmatched || 0,
|
|
430
|
-
media_accounts: mediaAccounts ? mediaAccounts.length : null,
|
|
431
|
-
media_error: mediaError || undefined,
|
|
432
409
|
last_report_date: result.last_report_date,
|
|
433
410
|
},
|
|
434
411
|
});
|
package/lib/shopee-report.js
CHANGED
|
@@ -183,78 +183,4 @@ async function fetchClickReport(ctx, {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
// thứ trang `account_setting` hiện ở tab "Media Information".
|
|
188
|
-
//
|
|
189
|
-
// KHÔNG mở account_setting và KHÔNG bấm tab: đo thật 22/09/2026 trên phiên
|
|
190
|
-
// Shopee1, endpoint này trả đủ dữ liệu khi gọi từ MỘT tab affiliate bất kỳ
|
|
191
|
-
// (thử từ /dashboard). Đi đường DOM thì phải mở thêm trang, bấm đúng nhãn
|
|
192
|
-
// tiếng Anh "Media Information", và nhãn đó đổi theo ngôn ngữ giao diện.
|
|
193
|
-
//
|
|
194
|
-
// Mỗi dòng có `user_id` là ID Facebook THẬT của page (vd 1298751909986110) —
|
|
195
|
-
// đúng con số nằm trong link `facebook.com/profile.php?id=…`. Nhờ vậy việc so
|
|
196
|
-
// khớp với kênh của mình không phải parse URL, vốn có nhiều biến thể
|
|
197
|
-
// (?referral_source=…, /<handle>/, m.facebook.com…).
|
|
198
|
-
//
|
|
199
|
-
// `platform`: 6 = Facebook, 3 = TikTok (đo được trên tài khoản thật; các mã
|
|
200
|
-
// khác giữ nguyên số để sau này đọc lại, không đoán tên).
|
|
201
|
-
const SM_PLATFORM = { 6: 'facebook', 3: 'tiktok' };
|
|
202
|
-
|
|
203
|
-
async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } = {}) {
|
|
204
|
-
let page = findAffiliatePage(ctx.pages());
|
|
205
|
-
let opened = false;
|
|
206
|
-
if (!page) {
|
|
207
|
-
page = await ctx.newPage();
|
|
208
|
-
opened = true;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
try {
|
|
212
|
-
if (!page.url().includes('affiliate.shopee.vn/')) {
|
|
213
|
-
await page.goto('https://affiliate.shopee.vn/offer/product_offer', {
|
|
214
|
-
waitUntil: 'domcontentloaded', timeout: timeoutMs,
|
|
215
|
-
}).catch(() => {});
|
|
216
|
-
await sleep(1500);
|
|
217
|
-
}
|
|
218
|
-
if (isBlockedState(page.url(), null)) {
|
|
219
|
-
return { status: 'needs_verify', accounts: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const result = await page.evaluate(async () => {
|
|
223
|
-
try {
|
|
224
|
-
const r = await fetch('/api/v3/affiliates/social_media_accounts', { credentials: 'include' });
|
|
225
|
-
return { status: r.status, json: await r.json().catch(() => null) };
|
|
226
|
-
} catch (e) {
|
|
227
|
-
return { status: 0, error: String(e?.message || e) };
|
|
228
|
-
}
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
if (isBlockedState(page.url(), result) || result.json?.code === 90309999) {
|
|
232
|
-
return { status: 'needs_verify', accounts: [], reason: `blocked while reading media info (http ${result.status}, code ${result.json?.code})` };
|
|
233
|
-
}
|
|
234
|
-
if (result.status !== 200 || result.json?.code !== 0) {
|
|
235
|
-
return { status: 'failed', accounts: [], reason: result.json?.msg || result.error || `media info HTTP ${result.status}` };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const list = Array.isArray(result.json?.data?.accounts) ? result.json.data.accounts : [];
|
|
239
|
-
const accounts = list.map((row) => ({
|
|
240
|
-
media_user_id: String(row.user_id || row.unique_id || '').trim(),
|
|
241
|
-
media_name: String(row.user_name || '').trim(),
|
|
242
|
-
website_url: String(row.website_url || '').trim(),
|
|
243
|
-
platform_code: Number(row.platform) || 0,
|
|
244
|
-
platform: SM_PLATFORM[Number(row.platform)] || 'other',
|
|
245
|
-
link_status: Number(row.link_status) || 0,
|
|
246
|
-
facebook_page_type: Number(row.facebook_page_type) || 0,
|
|
247
|
-
partner_link_status: Number(row.partner_link_status) || 0,
|
|
248
|
-
follower_number: Number(row.follower_number) || 0,
|
|
249
|
-
// Shopee trả giây, không phải mili-giây.
|
|
250
|
-
verification_time: Number(row.verification_time) || 0,
|
|
251
|
-
})).filter((a) => a.media_user_id || a.website_url);
|
|
252
|
-
|
|
253
|
-
if (log) log(`[shopee-report] media: ${accounts.length} tài khoản liên kết (${accounts.filter((a) => a.platform === 'facebook').length} Facebook)`);
|
|
254
|
-
return { status: 'ok', accounts };
|
|
255
|
-
} finally {
|
|
256
|
-
if (opened) await page.close().catch(() => {});
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, SM_PLATFORM };
|
|
186
|
+
module.exports = { fetchConversionReport, fetchClickReport, reportWindow };
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -436,6 +436,29 @@ function cleanSubId(v) {
|
|
|
436
436
|
// `/opaanlp/` — KHÁC hẳn `long_link` của offer list (universal-link + chữ ký
|
|
437
437
|
// Google Ads, không qua đường ghi nhận affiliate nên không ra hoa hồng).
|
|
438
438
|
//
|
|
439
|
+
// Vì sao Shopee không trả link — gom mọi chỗ nó có thể nhét lý do vào.
|
|
440
|
+
//
|
|
441
|
+
// Nó KHÔNG phải lúc nào cũng dùng `errors[]` của GraphQL: đã gặp lượt trả
|
|
442
|
+
// `code 599` + `data: null` (ca sub-id có gạch dưới, 09/09/2026) với
|
|
443
|
+
// `businessCode` nằm thẳng trong thân. Đọc không ra thì in nguyên văn đã cắt
|
|
444
|
+
// ngắn — còn hơn một câu rỗng nghĩa bắt người sau mở trình duyệt ra mò lại.
|
|
445
|
+
function linkFailureReason(res, wanted = 0) {
|
|
446
|
+
const j = res && res.json;
|
|
447
|
+
const bits = [];
|
|
448
|
+
if (res && res.error) bits.push(String(res.error));
|
|
449
|
+
if (res && res.status && res.status !== 200) bits.push(`http ${res.status}`);
|
|
450
|
+
const msg = j?.errors?.[0]?.message || j?.msg || j?.message || j?.error_msg;
|
|
451
|
+
if (msg) bits.push(String(msg));
|
|
452
|
+
for (const k of ['businessCode', 'business_code', 'code', 'error']) {
|
|
453
|
+
const v = j?.[k];
|
|
454
|
+
if (v !== undefined && v !== null && v !== 0 && v !== '') { bits.push(`${k} ${v}`); break; }
|
|
455
|
+
}
|
|
456
|
+
if (!bits.length) {
|
|
457
|
+
bits.push(`Shopee trả 0 link cho ${wanted} sản phẩm: ${JSON.stringify(j ?? res ?? null).slice(0, 300)}`);
|
|
458
|
+
}
|
|
459
|
+
return bits.join(' · ').slice(0, 500);
|
|
460
|
+
}
|
|
461
|
+
|
|
439
462
|
// subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
|
|
440
463
|
async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000, log = null } = {}) {
|
|
441
464
|
const list = (items || [])
|
|
@@ -483,27 +506,53 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
|
|
|
483
506
|
}
|
|
484
507
|
const err = res.json?.errors?.[0]?.message;
|
|
485
508
|
if (err) return { status: 'failed', links: [], reason: err };
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
509
|
+
|
|
510
|
+
// CHỈ nhận link của đúng những món đã hỏi, và chỉ nhận dòng CÓ link.
|
|
511
|
+
// Shopee trả `itemId` kèm từng link nên khớp được — không khớp mà vẫn lấy
|
|
512
|
+
// là có ngày ghi link của món khác vào bài, mà `workerLink` bên API khoá
|
|
513
|
+
// theo chính `item_id` này nên sai một chỗ là sai hết chuỗi.
|
|
514
|
+
const wanted = new Set(list.map((it) => it.itemId));
|
|
515
|
+
const rows = Array.isArray(res.json?.data?.productOfferLinks) ? res.json.data.productOfferLinks : [];
|
|
516
|
+
const links = rows
|
|
517
|
+
.map((r) => ({
|
|
518
|
+
item_id: String(r?.itemId ?? ''),
|
|
519
|
+
shop_id: String(r?.shopId ?? ''),
|
|
520
|
+
link: String(r?.productOfferLink || ''),
|
|
521
|
+
}))
|
|
522
|
+
.filter((r) => wanted.has(r.item_id) && r.link);
|
|
523
|
+
const missing = list.map((it) => it.itemId).filter((id) => !links.some((l) => l.item_id === id));
|
|
524
|
+
|
|
525
|
+
// KHÔNG có link nào = THẤT BẠI, không phải "xong với 0 kết quả". Bản đầu
|
|
526
|
+
// trả `status: 'ok'` cho ca này nên lệnh ghi 'done' và giao diện chỉ nói
|
|
527
|
+
// được "Worker không trả về link" — mù nguyên nhân. Mọi kiểu Shopee từ
|
|
528
|
+
// chối mà không đi qua `errors[]` (http 5xx, `data: null`, businessCode
|
|
529
|
+
// nằm trong thân) đều rơi vào đây.
|
|
530
|
+
if (!links.length) {
|
|
531
|
+
return { status: 'failed', links: [], sub_ids: subs, reason: linkFailureReason(res, list.length) };
|
|
532
|
+
}
|
|
533
|
+
if (missing.length && log) log(`[shopee] thiếu link cho ${missing.length}/${list.length} món: ${missing.join(', ')}`);
|
|
534
|
+
return { status: 'ok', sub_ids: subs, links, missing };
|
|
492
535
|
} finally {
|
|
493
536
|
if (opened) await page.close().catch(() => {});
|
|
494
537
|
}
|
|
495
538
|
}
|
|
496
539
|
|
|
540
|
+
// Dạng `-i.<shop>.<item>` neo hai đầu: mở bằng `-` hoặc `/`, đóng ở cuối
|
|
541
|
+
// đường dẫn. Cùng một luật với `api/services/ShopeeUrl.js` (ghi chú dài ở đó)
|
|
542
|
+
// — hai bên lệch nhau thì cùng một link cho ra hai mã sản phẩm khác nhau tuỳ
|
|
543
|
+
// đường đi. Đo 23/09/2026: bản cũ đọc "Mini.2.4G" ra shop 2 / item 4.
|
|
544
|
+
const I_FORM = /[-/]i\.(\d+)\.(\d+)\/?(?:[?#]|$)/;
|
|
545
|
+
|
|
497
546
|
// Parse a Shopee product URL → { shop_id, item_id }. Accepts
|
|
498
547
|
// /product/<shop>/<item> and i.<shop>.<item> forms.
|
|
499
548
|
function parseProductUrl(url = '') {
|
|
500
549
|
// `/opaanlp/` là đích của link affiliate (đo 12/09: link rút gọn s.shopee.vn
|
|
501
550
|
// đá về đúng dạng này) — vẫn là một sản phẩm, vẫn lấy được shop/item.
|
|
502
|
-
let m = url.match(/\/(?:product|opaanlp)\/(\d+)\/(\d+)/);
|
|
551
|
+
let m = String(url || '').match(/\/(?:product|opaanlp)\/(\d+)\/(\d+)/);
|
|
503
552
|
if (m) return { shop_id: m[1], item_id: m[2] };
|
|
504
|
-
m = url.match(
|
|
553
|
+
m = String(url || '').match(I_FORM);
|
|
505
554
|
if (m) return { shop_id: m[1], item_id: m[2] };
|
|
506
555
|
return null;
|
|
507
556
|
}
|
|
508
557
|
|
|
509
|
-
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, findAffiliatePage, pruneTabs, isJunkTab };
|
|
558
|
+
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, linkFailureReason, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, findAffiliatePage, pruneTabs, isJunkTab };
|
package/lib/updater.js
CHANGED
|
@@ -54,6 +54,39 @@ function planUpdate({ running, onDisk, latest, restartBlocked = '' }) {
|
|
|
54
54
|
return 'install';
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Đang chạy DƯỚI pm2 hay không. pm2 nhét `pm_id` vào env của tiến trình nó
|
|
58
|
+
// quản (đo tay trên relabs03 22/09/2026 bằng một app thử: env có `pm_id`,
|
|
59
|
+
// `pm_exec_path`, `name`, `unique_id`, `PM2_HOME`, `NODE_APP_INSTANCE`).
|
|
60
|
+
// `pm_id` là cái hẹp nhất trong số đó — `PM2_HOME` có thể do shell đặt sẵn mà
|
|
61
|
+
// chẳng có pm2 nào đang quản mình.
|
|
62
|
+
const underPm2 = () => !!process.env.pm_id;
|
|
63
|
+
|
|
64
|
+
// Sau khi cài xong thì khởi động lại KIỂU GÌ. Tách thành hàm thuần để test
|
|
65
|
+
// được: phần còn lại của checkAndUpdate đụng npm, đĩa và process.exit.
|
|
66
|
+
//
|
|
67
|
+
// 'spawn' — đẻ một daemon detached rồi tự thoát (cách chạy độc lập)
|
|
68
|
+
// 'exit' — chỉ thoát, để pm2 dựng lại, hoặc vì mình là bản thừa
|
|
69
|
+
// 'stay' — không khởi động lại gì cả
|
|
70
|
+
//
|
|
71
|
+
// ⚠️ DƯỚI pm2 TUYỆT ĐỐI KHÔNG 'spawn'. pm2 tự dựng lại tiến trình nó quản mỗi
|
|
72
|
+
// khi tiến trình đó thoát, nên spawn thêm một daemon detached rồi exit(0) là đẻ
|
|
73
|
+
// ra HAI ứng viên cho CÙNG MỘT khoá: con detached giành được khoá, con của pm2
|
|
74
|
+
// thua và chết ngay, pm2 lại dựng con mới, cứ thế mãi.
|
|
75
|
+
//
|
|
76
|
+
// Đo trên relabs03 22/09/2026: vòng lặp chạy mỗi ~30 giây, đã cộng dồn 15.242
|
|
77
|
+
// lần restart cho app `channel-worker` và 16.776 cho `shopee-worker`;
|
|
78
|
+
// `channel-worker-error.log` toàn dòng "Đã có daemon khác giữ
|
|
79
|
+
// /Users/…/.channel-worker (PID 50281)". Daemon THẬT vẫn chạy nên không ai để ý
|
|
80
|
+
// suốt mấy ngày — đó mới là chỗ nguy: bộ đếm restart thành vô nghĩa, và một lần
|
|
81
|
+
// chết thật sẽ lẫn vào giữa hàng vạn lần chết giả.
|
|
82
|
+
function restartPlan({ autoRestart, wasOwner, pm2 = underPm2() }) {
|
|
83
|
+
if (!autoRestart) return 'stay';
|
|
84
|
+
// Bản THỪA (không giữ khoá): thoát, không đẻ thêm — nhờ vậy mỗi vòng cập nhật
|
|
85
|
+
// tự rút sĩ số về đúng một daemon, thay vì N con cũ đẻ ra N con mới.
|
|
86
|
+
if (!wasOwner) return 'exit';
|
|
87
|
+
return pm2 ? 'exit' : 'spawn';
|
|
88
|
+
}
|
|
89
|
+
|
|
57
90
|
// Cảnh báo 'blocked' mỗi 6 giờ một lần thôi, không phải mỗi 5 phút.
|
|
58
91
|
const BLOCKED_WARN_EVERY_MS = 6 * 3600 * 1000;
|
|
59
92
|
let lastBlockedWarnAt = 0;
|
|
@@ -163,7 +196,6 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
|
163
196
|
|
|
164
197
|
const CONFIG_DIR = path.join(os.homedir(), '.channel-worker');
|
|
165
198
|
const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
|
|
166
|
-
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
167
199
|
|
|
168
200
|
// Nhả khoá TRƯỚC khi spawn: con mới phải giành được khoá mới chạy. Nếu
|
|
169
201
|
// tiến trình này là daemon THỪA (không giữ khoá) thì release trả false —
|
|
@@ -172,15 +204,23 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
|
172
204
|
// thay vì N con cũ đẻ ra N con mới như sự cố relabs03 04/09/2026.
|
|
173
205
|
const singleton = require('./singleton');
|
|
174
206
|
const wasOwner = singleton.release(CONFIG_DIR);
|
|
175
|
-
|
|
207
|
+
const plan = restartPlan({ autoRestart: true, wasOwner });
|
|
208
|
+
|
|
209
|
+
if (plan === 'exit' && !wasOwner) {
|
|
176
210
|
console.log('[updater] Tiến trình này không giữ khoá daemon (bản thừa) — '
|
|
177
211
|
+ 'thoát, không spawn bản thay thế.');
|
|
212
|
+
} else if (plan === 'exit') {
|
|
213
|
+
console.log('[updater] Chạy dưới pm2 — thoát để pm2 dựng lại, KHÔNG spawn bản '
|
|
214
|
+
+ 'thay thế (spawn nữa là hai ứng viên tranh một khoá → pm2 quay vòng vô tận).');
|
|
178
215
|
}
|
|
179
216
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
217
|
+
if (plan === 'spawn') {
|
|
218
|
+
// Mở log CHỈ khi thật sự spawn: fd này là để truyền cho tiến trình con.
|
|
219
|
+
// Mở nó ở nhánh pm2 là giữ khoá file thừa — trên Windows đó đúng là nguồn
|
|
220
|
+
// của EBUSY daemon.log (xem restartBlocker).
|
|
221
|
+
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
222
|
+
const isWindows = process.platform === 'win32';
|
|
223
|
+
const binName = isWindows ? 'channel-worker.cmd' : 'channel-worker';
|
|
184
224
|
const child = spawn(binName, ['start'], {
|
|
185
225
|
detached: true,
|
|
186
226
|
stdio: ['ignore', logFd, logFd],
|
|
@@ -229,4 +269,4 @@ class UpdateChecker {
|
|
|
229
269
|
}
|
|
230
270
|
}
|
|
231
271
|
|
|
232
|
-
module.exports = { checkAndUpdate, getLocalVersion, getInstalledVersion, getLatestVersion, isNewer, planUpdate, restartBlocker, UpdateChecker };
|
|
272
|
+
module.exports = { checkAndUpdate, getLocalVersion, getInstalledVersion, getLatestVersion, isNewer, planUpdate, restartPlan, underPm2, restartBlocker, UpdateChecker };
|