channel-worker 2.5.90 → 2.5.92

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/bin/cli.js CHANGED
@@ -159,6 +159,10 @@ if (cmd === 'pair') {
159
159
  console.log(`[channel-worker] Tiếp quản khoá của PID ${res.stolen.pid} `
160
160
  + `(${args.force ? 'ép bằng --force' : 'đã chết hoặc treo quá lâu'}).`);
161
161
  }
162
+ if (res.corrupted) {
163
+ console.log('[channel-worker] daemon.lock cũ hỏng (không đọc ra nội dung — '
164
+ + 'dấu hiệu điển hình của mất điện giữa chừng) — đã ghi đè và chạy tiếp.');
165
+ }
162
166
  // Ghi ĐÚNG pid của daemon thật — updater/restart trước đây ghi pid của
163
167
  // tiến trình bọc, nên "stop" bắn trượt và để lại mồ côi.
164
168
  fs.writeFileSync(path.join(CONFIG_DIR, 'daemon.pid'), String(process.pid));
@@ -197,6 +201,42 @@ if (cmd === 'pair') {
197
201
  process.exit(0);
198
202
  }
199
203
 
204
+ } else if (cmd === 'selftest') {
205
+ // Nạp thử MỌI module trong lib/ rồi thoát 0. Bộ tự-cập-nhật chạy lệnh này
206
+ // sau khi cài và chỉ khởi động lại khi nó xanh.
207
+ //
208
+ // Vì sao cần: 08/09/2026, một bản npm publish làm mọi daemon tự lao vào
209
+ // `npm install -g` ĐÈ LÊN CHÍNH MODULE ĐANG CHẠY. Trên Windows npm xoá cây
210
+ // cũ trước khi ghi cây mới, và lượt đó dừng giữa chừng: lib/daemon.js và
211
+ // lib/command-poller.js biến mất, package.json vẫn ghi version mới nên nhìn
212
+ // vào đó thấy "cài xong". Daemon thay thế chết ngay MODULE_NOT_FOUND, hai
213
+ // máy Windows tắt lịm ĐÚNG LÚC đang đăng 9 video — 9 lượt đăng thành "mập
214
+ // mờ" phải kiểm tay. Đọc version là không đủ; phải nạp thử thật.
215
+ const fs = require('fs');
216
+ const libDir = path.join(__dirname, '..', 'lib');
217
+ const bad = [];
218
+ let files = [];
219
+ try {
220
+ files = fs.readdirSync(libDir).filter((f) => f.endsWith('.js')).sort();
221
+ } catch (e) {
222
+ console.error(`[channel-worker] selftest FAILED — không đọc nổi ${libDir}: ${e.code || e.message}`);
223
+ process.exit(1);
224
+ }
225
+ if (files.length < 10) {
226
+ // Cây đủ có 15 file; ít hơn 10 là chắc chắn cụt, không cần require từng cái.
227
+ console.error(`[channel-worker] selftest FAILED — lib/ chỉ còn ${files.length} file (cây cài dở).`);
228
+ process.exit(1);
229
+ }
230
+ for (const f of files) {
231
+ try { require(path.join(libDir, f)); } catch (e) { bad.push(`${f}: ${e.code || e.message}`); }
232
+ }
233
+ if (bad.length) {
234
+ console.error(`[channel-worker] selftest FAILED — ${bad.length}/${files.length} module không nạp được:\n ${bad.join('\n ')}`);
235
+ process.exit(1);
236
+ }
237
+ console.log(`[channel-worker] selftest OK — v${require('../package.json').version}, ${files.length} module nạp được.`);
238
+ process.exit(0);
239
+
200
240
  } else if (cmd === 'update') {
201
241
  const { checkAndUpdate, getLocalVersion } = require('../lib/updater');
202
242
  console.log(`[channel-worker] Current version: ${getLocalVersion()}`);
@@ -154,11 +154,20 @@ class CommandPoller {
154
154
  return true;
155
155
  }
156
156
 
157
- // Ensure a profile (by name) is running and return a puppeteer-core browser
157
+ // Ensure a profile (by name) is running and return a playwright BrowserContext
158
158
  // attached over CDP via its local debug port. Worker runs ON the same box as
159
159
  // Nstbrowser, so localhost:<remoteDebuggingPort> is reachable directly.
160
+ //
161
+ // Dùng playwright-core, KHÔNG puppeteer-core: gói này chưa bao giờ nằm trong
162
+ // dependencies của worker (07/09: chạy thật lần đầu → MODULE_NOT_FOUND, hai
163
+ // lệnh Shopee chết ngay tại đây). Cả worker đã chạy playwright rồi.
164
+ //
165
+ // Trả CONTEXT chứ không phải browser, và người gọi phải dùng context đó:
166
+ // `browser.newPage()` của playwright đẻ ra một context MỚI — trắng cookie,
167
+ // nghĩa là mất sạch phiên đăng nhập Shopee của profile. Chỉ context sẵn có
168
+ // (contexts()[0]) mới mang cookie thật.
160
169
  async _connectNstProfileByName(name) {
161
- const puppeteer = require('puppeteer-core');
170
+ const { chromium } = require('playwright-core');
162
171
  const profileId = await this.nst.findProfile(name);
163
172
  if (!profileId) throw new Error(`NST profile "${name}" not found`);
164
173
  let running = (await this.nst.getRunningBrowsers()).find(b => b.profileId === profileId);
@@ -172,8 +181,11 @@ class CommandPoller {
172
181
  }
173
182
  const port = running?.remoteDebuggingPort;
174
183
  if (!port) throw new Error(`No debug port for profile "${name}" (launch failed?)`);
175
- const browser = await puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null });
176
- return { browser, disconnect: () => browser.disconnect() };
184
+ const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
185
+ const context = browser.contexts()[0] || await browser.newContext();
186
+ // browser.close() trên kết nối CDP chỉ CẮT kết nối, không tắt Chrome của
187
+ // Nstbrowser (đo thật trên relabs03 07/09: profile vẫn chạy sau khi đóng).
188
+ return { browser, context, disconnect: () => browser.close().catch(() => {}) };
177
189
  }
178
190
 
179
191
  // Flow 1 — quét affiliate offer list (sort theo hoa hồng) → upsert vào kho.
@@ -185,7 +197,7 @@ class CommandPoller {
185
197
  let conn;
186
198
  try {
187
199
  conn = await this._connectNstProfileByName(profileName);
188
- const res = await scraper.scrapeOffers(conn.browser, {
200
+ const res = await scraper.scrapeOffers(conn.context, {
189
201
  category: payload.category || 'women_clothes', // Tier-1 default: quần áo nữ
190
202
  match_id: payload.match_id || null,
191
203
  sort_types: payload.sort_types || [2, 3], // commission + sales, merged
@@ -228,7 +240,7 @@ class CommandPoller {
228
240
  let conn;
229
241
  try {
230
242
  conn = await this._connectNstProfileByName(profileName);
231
- const product = await scraper.ingestProduct(conn.browser, ids);
243
+ const product = await scraper.ingestProduct(conn.context, ids);
232
244
  console.log(`[shopee] ingested: ${product.name} (${product.images.length} imgs)`);
233
245
  await this.api.ingestShopeeResult(product, { user_id: command.user_id, idea_id: payload.idea_id || null });
234
246
  await this.api.updateCommand(command._id, { status: 'done', result: { name: product.name, images: product.images.length } });
@@ -65,9 +65,13 @@ function parsePdpItem(item) {
65
65
 
66
66
  // Open the PDP and resolve with the get_pc payload the page fires. We listen at
67
67
  // the response layer because a hand-rolled fetch gets error 90309999 (no sig).
68
- async function ingestProduct(browser, { shop_id, item_id, timeoutMs = 60000 } = {}) {
68
+ //
69
+ // `ctx` là một playwright BrowserContext (KHÔNG phải Browser): trang phải mở
70
+ // trong đúng context của profile thì mới mang cookie đăng nhập Shopee — mở từ
71
+ // Browser là playwright đẻ context mới, trắng phiên.
72
+ async function ingestProduct(ctx, { shop_id, item_id, timeoutMs = 60000 } = {}) {
69
73
  if (!shop_id || !item_id) throw new Error('ingestProduct needs shop_id + item_id');
70
- const page = await browser.newPage();
74
+ const page = await ctx.newPage();
71
75
  try {
72
76
  const captured = new Promise((resolve) => {
73
77
  page.on('response', async (res) => {
@@ -93,6 +97,13 @@ function parseOfferRow(row) {
93
97
  const card = row.batch_item_for_item_card_full || row.item_card_displayed_asset || {};
94
98
  const shopId = String(card.shopid || card.shop_id || '');
95
99
  const itemId = String(row.item_id || card.itemid || card.item_id || '');
100
+ // Đo thật 08/09/2026 trên cả tab XTRA lẫn tab ngành hàng:
101
+ // default_commission_rate = TỔNG hoa hồng affiliate (đã gồm Xtra)
102
+ // seller_commission_rate = phần NGƯỜI BÁN tài trợ, tức chính là Xtra
103
+ // default − seller = hoa hồng nền Shopee theo ngành (thời trang 7%,
104
+ // hoá phẩm / điện tử nhỏ 2,5%)
105
+ // max_commission_rate = 0% ở MỌI dòng đo được → đừng tin field này,
106
+ // nó chỉ đứng làm đường lui khi thiếu default.
96
107
  const rate = pctToNumber(row.default_commission_rate ?? row.max_commission_rate ?? row.commission_rate);
97
108
  const sellerRate = pctToNumber(row.seller_commission_rate);
98
109
  const price = (Number(card.price || card.price_min) || 0) / 100000;
@@ -123,11 +134,26 @@ function parseOfferRow(row) {
123
134
  // Affiliate offer-page category tabs → match_id (read from rc-tabs node keys,
124
135
  // confirmed live). Tier-1 fashion focus first; the offer page has NO dedicated
125
136
  // Shoes/Bags/Accessories tab → those come later via keyword search.
137
+ // Đọc thẳng từ dãy tab trên affiliate.shopee.vn/offer/product_offer, đối chiếu
138
+ // lại ngày 08/09/2026 — dãy này ĐỔI theo thời gian: 'grocery' (100629) và
139
+ // Moms/Kids có hồi 06/2026 nay không còn tab, đổi lại có thêm Đồ gia dụng và
140
+ // Máy tính & phụ kiện. Thấy quét ra rỗng thì việc đầu tiên là mở trang xem tab
141
+ // còn không, đừng đổ tại phiên đăng nhập.
142
+ //
143
+ // Hai kiểu danh sách KHÁC NHAU, đừng trộn (đo thật 08/09: truyền XTRA như một
144
+ // ngành hàng thì trả về 0 dòng, http 200 code 0 — hỏng im lặng):
145
+ // • ngành hàng → list_type=3&match_type=2&match_id=<id>
146
+ // • Commissions XTRA → list_type=7, KHÔNG có match_id
126
147
  const AFFILIATE_CATEGORIES = {
127
- women_clothes: { match_id: 100017, label: 'Quần áo nữ', group: 'fashion' },
128
- beauty: { match_id: 100630, label: 'Làm đẹp', group: 'beauty' },
129
- home_living: { match_id: 100636, label: 'Nhà cửa', group: 'other' },
130
- grocery: { match_id: 100629, label: 'Tạp hoá', group: 'other' },
148
+ women_clothes: { match_id: 100017, label: 'Quần áo nữ', group: 'fashion' },
149
+ beauty: { match_id: 100630, label: 'Làm đẹp', group: 'beauty' },
150
+ home_living: { match_id: 100636, label: 'Nhà cửa & đời sống', group: 'other' },
151
+ home_appliances: { match_id: 100010, label: 'Đồ gia dụng', group: 'other' },
152
+ computer: { match_id: 100644, label: 'Máy tính & phụ kiện', group: 'other' },
153
+ // Không phải ngành hàng mà là "sản phẩm có hoa hồng Xtra" — cắt ngang mọi
154
+ // ngành. Không đặt group để khỏi tự bật lọc nhóm và vứt mất hàng ngoài
155
+ // fashion, vì chính cái tab này mới là chỗ hoa hồng cao nhất.
156
+ xtra: { list_type: 7, label: 'Hoa hồng XTRA' },
131
157
  };
132
158
 
133
159
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -147,10 +173,24 @@ function applyFilters(rows, f = {}) {
147
173
  return rows.filter((p) => {
148
174
  if (f.category_groups?.length && !f.category_groups.includes(p.category_group)) return false;
149
175
  if (f.min_commission != null && (p.commission_rate || 0) < f.min_commission) return false;
176
+ // Lọc theo phần NGƯỜI BÁN tài trợ (Xtra). Cần cửa riêng vì quét tab XTRA
177
+ // không đồng nghĩa với Xtra cao: đo 08/09 thấy tab ngành hàng thường cũng
178
+ // có dòng tài trợ 10%, còn trong chính tab XTRA vẫn có dòng chỉ 8%.
179
+ if (f.min_seller_commission != null && (p.commission_seller_rate || 0) < f.min_seller_commission) return false;
180
+ // Cổng theo TIỀN mỗi đơn. Ngưỡng phần trăm không so được giữa các ngành:
181
+ // hoa hồng nền của thời trang là 7% còn gia dụng/điện tử chỉ 2,5%, nên bộ
182
+ // Tier-1 (≥12%) vốn chỉnh cho thời trang đem áp lên gia dụng thì quét gần
183
+ // như trắng — đo 08/09/2026 trên tab Đồ gia dụng: 19/20 món rớt ĐÚNG ở
184
+ // cổng 12%, trong khi không món nào rớt vì doanh số, sao hay ảnh.
185
+ if (f.min_commission_value != null && (p.commission_value || 0) < f.min_commission_value) return false;
150
186
  if (f.price_min != null && (p.price || 0) < f.price_min) return false;
151
187
  if (f.price_max != null && (p.price || 0) > f.price_max) return false;
152
188
  if (f.min_sold != null && (p.sold_count || 0) < f.min_sold) return false;
153
- if (f.min_rating != null && p.rating != null && p.rating < f.min_rating) return false;
189
+ // Hàng CHƯA đánh giá cũng rớt, không được đi cửa sau. Điều kiện cũ là
190
+ // "có rating VÀ rating < ngưỡng thì loại" — nghĩa là sản phẩm rating rỗng
191
+ // lọt thẳng qua cổng chất lượng, đúng loại hàng mới đăng chưa ai mua mà
192
+ // mình không muốn đem đi làm video.
193
+ if (f.min_rating != null && !(Number(p.rating) >= f.min_rating)) return false;
154
194
  if (f.min_images != null && (p.images?.length || 0) < f.min_images) return false;
155
195
  return true;
156
196
  });
@@ -162,7 +202,9 @@ function applyFilters(rows, f = {}) {
162
202
  // { status: 'ok'|'needs_verify', products: [...], raw_count }
163
203
  // Never throws on a blocked session — returns status:'needs_verify' so the
164
204
  // worker reports it cleanly instead of failing + retrying into more captchas.
165
- async function scrapeOffers(browser, {
205
+ // `ctx` = playwright BrowserContext của profile affiliate đã đăng nhập — xem
206
+ // ghi chú ở ingestProduct về việc vì sao không nhận Browser.
207
+ async function scrapeOffers(ctx, {
166
208
  category = null, // key in AFFILIATE_CATEGORIES (e.g. 'women_clothes')
167
209
  match_id = null, // explicit category id override
168
210
  sort_types = [2], // [2]=commission; pass [2,3] to merge commission+sales
@@ -180,15 +222,18 @@ async function scrapeOffers(browser, {
180
222
  // Default the category_groups filter to the category's own group (so a
181
223
  // Women-Clothes scrape keeps only fashion rows even though the tab mixes in
182
224
  // a few accessories), unless the caller overrides.
183
- if (cat && !filters.category_groups) filters = { ...filters, category_groups: [cat.group] };
225
+ if (cat?.group && !filters.category_groups) filters = { ...filters, category_groups: [cat.group] };
226
+ const listType = cat?.list_type || null;
184
227
 
185
- const pagesOpen = await browser.pages();
228
+ const pagesOpen = ctx.pages();
186
229
  let page = pagesOpen.find((p) => p.url().includes('affiliate.shopee.vn'));
187
230
  let opened = false;
188
- if (!page) { page = await browser.newPage(); opened = true; }
231
+ if (!page) { page = await ctx.newPage(); opened = true; }
189
232
  try {
190
233
  if (!page.url().includes('affiliate.shopee.vn/offer')) {
191
- await page.goto('https://affiliate.shopee.vn/offer/product_offer', { waitUntil: 'networkidle2', timeout: timeoutMs }).catch(() => {});
234
+ // 'networkidle' (playwright), KHÔNG 'networkidle2' (tên của puppeteer
235
+ // playwright coi là giá trị lạ và ném lỗi).
236
+ await page.goto('https://affiliate.shopee.vn/offer/product_offer', { waitUntil: 'networkidle', timeout: timeoutMs }).catch(() => {});
192
237
  await sleep(1500);
193
238
  }
194
239
  if (isBlockedState(page.url(), null)) {
@@ -201,13 +246,15 @@ async function scrapeOffers(browser, {
201
246
  for (let i = 0; i < pages; i++) {
202
247
  const offset = i * page_limit;
203
248
  const res = await page.evaluate(async (q) => {
204
- const cat = q.mid ? `&list_type=3&match_type=2&match_id=${q.mid}` : '&list_type=0';
249
+ const cat = q.listType
250
+ ? `&list_type=${q.listType}`
251
+ : (q.mid ? `&list_type=3&match_type=2&match_id=${q.mid}` : '&list_type=0');
205
252
  const url = `/api/v3/offer/product/list?sort_type=${q.st}&page_offset=${q.offset}&page_limit=${q.page_limit}&client_type=1${cat}`;
206
253
  try {
207
254
  const r = await fetch(url, { credentials: 'include' });
208
255
  return { status: r.status, json: await r.json().catch(() => null) };
209
256
  } catch (e) { return { status: 0, error: String(e.message) }; }
210
- }, { st, offset, page_limit, mid });
257
+ }, { st, offset, page_limit, mid, listType });
211
258
 
212
259
  if (isBlockedState(page.url(), res) || res.json?.code === 90309999) {
213
260
  return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: `blocked mid-scrape (http ${res.status}, code ${res.json?.code})` };
package/lib/singleton.js CHANGED
@@ -84,7 +84,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
84
 
85
85
  /**
86
86
  * Giành khoá cho tiến trình hiện tại.
87
- * @returns {{ok: true, stolen?: object} | {ok: false, holder: object}}
87
+ * @returns {{ok: true, stolen?: object, corrupted?: boolean} | {ok: false, holder: object}}
88
88
  */
89
89
  async function acquire(configDir, { waitMs = 0, force = false, info = {}, pollMs = 1000 } = {}) {
90
90
  const started_at = new Date().toISOString();
@@ -97,7 +97,19 @@ async function acquire(configDir, { waitMs = 0, force = false, info = {}, pollMs
97
97
  return { ok: true };
98
98
  } catch (err) {
99
99
  if (err.code !== 'EEXIST') throw err;
100
- continue; // đứa khác chen vào giữa hai nhịp đọc lại rồi xử tiếp
100
+ // File thật readLock không đọc ra. Hai ca khác hẳn nhau:
101
+ // a) đứa khác vừa chen vào giữa hai nhịp → đọc lại ra khoá hợp lệ,
102
+ // quay vòng xử như bình thường (chờ / cướp nếu đã rác);
103
+ // b) file HỎNG, đọc lại vẫn không ra gì → không có chủ nào để chờ.
104
+ // Ca (b) mới là cái giết máy: mất điện 07/09/2026 để lại daemon.lock
105
+ // dài 139 byte TOÀN NUL (NTFS đã cấp cỡ, chưa kịp ghi nội dung).
106
+ // `continue` trần khi ấy thành vòng lặp KHÔNG sleep, không hạn giờ:
107
+ // 3 daemon trên win-worker quay tít 100% CPU suốt 4 giờ, không ghi nổi
108
+ // một dòng log, không bao giờ tới Daemon.start() — cả đàn video ngừng
109
+ // đăng mà nhìn từ ngoài y như "daemon không tự chạy sau khi bật máy".
110
+ if (readLock(configDir)) continue;
111
+ writeLock(configDir, { ...info, started_at }, { overwrite: true });
112
+ return { ok: true, corrupted: true };
101
113
  }
102
114
  }
103
115
  if (lock.pid === process.pid) return { ok: true }; // đã là của mình
package/lib/updater.js CHANGED
@@ -31,6 +31,28 @@ function installUpdate(version) {
31
31
  console.log(`[updater] Installed ${PKG_NAME}@${version}`);
32
32
  }
33
33
 
34
+ /**
35
+ * Cây vừa cài có nạp được không? `channel-worker selftest` require từng file
36
+ * trong lib/ và thoát 0 nếu đủ.
37
+ *
38
+ * npm cài ĐÈ LÊN module đang chạy, và trên Windows nó xoá cây cũ trước khi ghi
39
+ * cây mới. Lượt cài dừng giữa chừng để lại package.json version MỚI cạnh một
40
+ * lib/ THIẾU FILE — nhìn version thì thấy "cài xong". 08/09/2026 daemon thay
41
+ * thế chết ngay MODULE_NOT_FOUND (lib/daemon.js + lib/command-poller.js biến
42
+ * mất), hai máy Windows tắt giữa lúc đăng 9 video.
43
+ */
44
+ function verifyInstall() {
45
+ const { spawnSync } = require('child_process');
46
+ const isWindows = process.platform === 'win32';
47
+ const bin = isWindows ? 'channel-worker.cmd' : 'channel-worker';
48
+ const out = spawnSync(bin, ['selftest'], {
49
+ shell: isWindows, windowsHide: true, encoding: 'utf8', timeout: 120000,
50
+ });
51
+ // status null = không chạy nổi (timeout, binary không thấy) → coi như hỏng.
52
+ const detail = `${out.stdout || ''}${out.stderr || ''}`.trim().split('\n').slice(-6).join(' | ');
53
+ return { ok: out.status === 0, detail: detail.slice(-600) };
54
+ }
55
+
34
56
  async function checkAndUpdate({ autoRestart = false } = {}) {
35
57
  const local = getLocalVersion();
36
58
  const latest = await getLatestVersion();
@@ -42,6 +64,26 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
42
64
  console.log(`[updater] Update available: ${local} → ${latest}`);
43
65
  installUpdate(latest);
44
66
 
67
+ // Cài xong CHƯA CHẮC chạy được — xem verifyInstall(). Hỏng thì thử cài lại
68
+ // đúng một lần, vẫn hỏng thì Ở YÊN: tiến trình này vẫn đang chạy code cũ đã
69
+ // nạp trong bộ nhớ, nên không restart là còn worker; restart vào cây cụt là
70
+ // mất worker. Bản mới sẽ vào ở lượt kiểm kế tiếp, hoặc do người cài tay.
71
+ let check = verifyInstall();
72
+ if (!check.ok) {
73
+ console.warn(`[updater] Bản vừa cài KHÔNG nạp được (${check.detail}) — cài lại một lần.`);
74
+ try {
75
+ installUpdate(latest);
76
+ check = verifyInstall();
77
+ } catch (e) {
78
+ check = { ok: false, detail: `cài lại lỗi: ${e.message}` };
79
+ }
80
+ }
81
+ if (!check.ok) {
82
+ console.error('[updater] BỎ khởi động lại: cây cài dở, daemon cũ chạy tiếp. '
83
+ + `Cần cài tay: npm i -g ${PKG_NAME}@${latest} --prefer-online (dừng daemon trước). Chi tiết: ${check.detail}`);
84
+ return { updated: false, local, latest, verifyFailed: true, detail: check.detail };
85
+ }
86
+
45
87
  if (autoRestart) {
46
88
  console.log('[updater] Restarting daemon with new version...');
47
89
  const { spawn } = require('child_process');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.90",
3
+ "version": "2.5.92",
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": {