channel-worker 2.5.89 → 2.5.91

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));
@@ -2,6 +2,11 @@ const { NstManager } = require('./nst-manager');
2
2
  const { checkAndUpdateExtension } = require('./extension-updater');
3
3
  const { StatsSyncer } = require('./stats-syncer');
4
4
 
5
+ // Quãng RẢNH trước khi đóng profile của luồng đăng bài. Phải đủ dài để lượt
6
+ // upload nền tảng kế tiếp (facebook → youtube → tiktok của cùng một idea) kịp
7
+ // tới và dùng lại browser, nhưng không được để mở qua đêm.
8
+ const PW_IDLE_CLOSE_MS = Number(process.env.PW_IDLE_CLOSE_MS || 5 * 60 * 1000);
9
+
5
10
  class CommandPoller {
6
11
  constructor(api, config) {
7
12
  this.api = api;
@@ -149,11 +154,20 @@ class CommandPoller {
149
154
  return true;
150
155
  }
151
156
 
152
- // 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
153
158
  // attached over CDP via its local debug port. Worker runs ON the same box as
154
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.
155
169
  async _connectNstProfileByName(name) {
156
- const puppeteer = require('puppeteer-core');
170
+ const { chromium } = require('playwright-core');
157
171
  const profileId = await this.nst.findProfile(name);
158
172
  if (!profileId) throw new Error(`NST profile "${name}" not found`);
159
173
  let running = (await this.nst.getRunningBrowsers()).find(b => b.profileId === profileId);
@@ -167,8 +181,11 @@ class CommandPoller {
167
181
  }
168
182
  const port = running?.remoteDebuggingPort;
169
183
  if (!port) throw new Error(`No debug port for profile "${name}" (launch failed?)`);
170
- const browser = await puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null });
171
- 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(() => {}) };
172
189
  }
173
190
 
174
191
  // Flow 1 — quét affiliate offer list (sort theo hoa hồng) → upsert vào kho.
@@ -180,7 +197,7 @@ class CommandPoller {
180
197
  let conn;
181
198
  try {
182
199
  conn = await this._connectNstProfileByName(profileName);
183
- const res = await scraper.scrapeOffers(conn.browser, {
200
+ const res = await scraper.scrapeOffers(conn.context, {
184
201
  category: payload.category || 'women_clothes', // Tier-1 default: quần áo nữ
185
202
  match_id: payload.match_id || null,
186
203
  sort_types: payload.sort_types || [2, 3], // commission + sales, merged
@@ -223,7 +240,7 @@ class CommandPoller {
223
240
  let conn;
224
241
  try {
225
242
  conn = await this._connectNstProfileByName(profileName);
226
- const product = await scraper.ingestProduct(conn.browser, ids);
243
+ const product = await scraper.ingestProduct(conn.context, ids);
227
244
  console.log(`[shopee] ingested: ${product.name} (${product.images.length} imgs)`);
228
245
  await this.api.ingestShopeeResult(product, { user_id: command.user_id, idea_id: payload.idea_id || null });
229
246
  await this.api.updateCommand(command._id, { status: 'done', result: { name: product.name, images: product.images.length } });
@@ -253,6 +270,11 @@ class CommandPoller {
253
270
  // publish click). Different profiles still run in parallel — this lock
254
271
  // only serializes within a profile. Wait up to 30 min with 5s polling
255
272
  // (upload typically 1-3 min); stale-cleanup picks up zombies separately.
273
+ // Lượt mới của cùng profile → huỷ hẹn đóng, giữ browser cho nó dùng lại.
274
+ if (this._pwCloseTimers && this._pwCloseTimers.has(profileId)) {
275
+ clearTimeout(this._pwCloseTimers.get(profileId));
276
+ this._pwCloseTimers.delete(profileId);
277
+ }
256
278
  if (!this._pwInFlight) this._pwInFlight = new Map();
257
279
  const MUTEX_MAX_WAIT_MS = 30 * 60 * 1000;
258
280
  const waitStart = Date.now();
@@ -341,10 +363,45 @@ class CommandPoller {
341
363
  } catch (e) {
342
364
  console.warn(`[commands/pw] close after ${scriptName} failed: ${e.message}`);
343
365
  }
366
+ } else if (this.nst) {
367
+ // ĐĂNG BÀI: giữ profile mở để lượt upload nền tảng kế tiếp dùng lại,
368
+ // nhưng phải có hạn. Trước 2026-09-07 KHÔNG AI đóng nó cả — cả ba đường
369
+ // đều hụt: không nằm trong CLOSE_AFTER, lệnh close_profile của API chỉ
370
+ // phát trong nhánh nuôi acc, còn bộ quét profile rảnh thì bỏ qua vì
371
+ // luồng _pw không ghi _profileLastActivity. Đo 24h: 78 lượt đăng, 0
372
+ // lệnh đóng, 141 tiến trình Nstbrowser còn sống trên win-worker.
373
+ //
374
+ // KHÔNG dùng bộ quét rảnh sẵn có: nó thoát sớm khi hàng đợi render còn
375
+ // việc, mà win-worker vừa đăng bài vừa render — profile đăng bài sẽ kẹt
376
+ // mở vô hạn đúng vào lúc máy bận nhất.
377
+ this._schedulePwClose(profileId, scriptName);
344
378
  }
345
379
  }
346
380
  }
347
381
 
382
+ // Hẹn đóng profile sau một quãng RẢNH. Lượt _pw kế tiếp của cùng profile huỷ
383
+ // hẹn (xem đầu handlePlaywrightCommand), nên chuỗi upload nhiều nền tảng liên
384
+ // tiếp vẫn dùng chung một browser như thiết kế cũ.
385
+ _schedulePwClose(profileId, scriptName) {
386
+ if (!this._pwCloseTimers) this._pwCloseTimers = new Map();
387
+ const prev = this._pwCloseTimers.get(profileId);
388
+ if (prev) clearTimeout(prev);
389
+ const t = setTimeout(async () => {
390
+ this._pwCloseTimers.delete(profileId);
391
+ // Có lệnh khác vừa chiếm profile này → để yên, lượt đó sẽ tự hẹn lại.
392
+ if (this._pwInFlight && this._pwInFlight.has(profileId)) return;
393
+ try {
394
+ await this.nst.stopProfile(profileId);
395
+ console.log(`[commands/pw] đóng profile ${profileId} sau ${PW_IDLE_CLOSE_MS / 1000}s rảnh (${scriptName})`);
396
+ } catch (e) {
397
+ console.warn(`[commands/pw] đóng trễ ${profileId} lỗi: ${e.message}`);
398
+ }
399
+ }, PW_IDLE_CLOSE_MS);
400
+ // Đừng giữ tiến trình sống chỉ vì một cái hẹn đóng browser.
401
+ if (typeof t.unref === 'function') t.unref();
402
+ this._pwCloseTimers.set(profileId, t);
403
+ }
404
+
348
405
  async handleLaunchProfile(command) {
349
406
  const { profile_id, channel_id } = command.payload || {};
350
407
  console.log(`[commands] Launching Nstbrowser profile: ${profile_id}`);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.89",
3
+ "version": "2.5.91",
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": {