channel-worker 2.5.90 → 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));
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.90",
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": {