channel-worker 2.5.146 → 2.5.148

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 CHANGED
@@ -75,7 +75,7 @@ class ApiClient {
75
75
  // nào — xảy ra 14/09 với verify_facebook_reels_pw (cổng thứ nhất là enum
76
76
  // của app/models/WorkerCommand.js, cũng đã quên một lần cùng ngày).
77
77
  // api/tests/publish-verify-wiring.test.js canh cả hai cổng.
78
- 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,upload_facebook_post_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,verify_facebook_reels_pw,comment_facebook_pw,facebook_page_check_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link,fetch_affiliate_report';
78
+ 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,upload_facebook_post_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,verify_facebook_reels_pw,comment_facebook_pw,facebook_page_check_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link,fetch_affiliate_report,tripo_multiview_to_model';
79
79
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
80
80
  }
81
81
 
@@ -111,6 +111,16 @@ class ApiClient {
111
111
  return this.request('POST', '/affiliate-report/worker-ingest', { rows, ...meta });
112
112
  }
113
113
 
114
+ // Tripo Studio (lib/tripo-studio.js) — tiến độ từng bước và kết quả cuối.
115
+ // Hai đường này nằm trong workerPaths của api/app/middleware/auth.js.
116
+ async tripoProgress(body) {
117
+ return this.request('POST', '/tripo/worker-progress', body);
118
+ }
119
+
120
+ async tripoResult(body) {
121
+ return this.request('POST', '/tripo/worker-result', body);
122
+ }
123
+
114
124
  // Return the calling daemon's own Worker doc — primarily for reading
115
125
  // parallel_limit (the per-daemon scene-generation concurrency cap that
116
126
  // replaced the legacy global flowkit_max_concurrent setting).
@@ -0,0 +1,113 @@
1
+ // CDP "trần" qua ws — KHÔNG đi qua playwright.
2
+ //
3
+ // Vì sao phải có file này khi worker đã có playwright-core: studio.tripo3d.ai
4
+ // đứng sau Cloudflare managed challenge. Đo thật 02/10/2026 trên profile
5
+ // "tripo" (win-worker-2): tab mở bằng playwright `context.newPage()` kẹt ở
6
+ // trang "Just a moment…" quá 35s và không bao giờ qua; trang chặn còn gắn CSP
7
+ // `connect-src 'self' challenges.cloudflare.com` nên fetch từ tab đó tới
8
+ // api.tripo3d.ai cũng chết ("Failed to fetch"). Cùng profile, cùng cookie, tab
9
+ // tạo bằng `Target.createTarget` trần (không Runtime.enable, không auto-attach)
10
+ // qua challenge sau ~10s và gọi API bình thường. Khác biệt duy nhất là dấu vết
11
+ // automation playwright để lại khi attach — nên luồng Tripo đi bằng ws thuần.
12
+ //
13
+ // Chỉ gói đúng vài lệnh CDP cần: liệt kê/tạo/đóng target, attach phẳng, và
14
+ // Runtime.evaluate (không cần Runtime.enable).
15
+ const WebSocket = require('ws');
16
+
17
+ class CdpClient {
18
+ constructor(ws) {
19
+ this.ws = ws;
20
+ this._id = 0;
21
+ this._pending = new Map();
22
+ this.closed = false;
23
+ ws.on('message', (raw) => {
24
+ let m;
25
+ try { m = JSON.parse(raw); } catch { return; }
26
+ if (!m.id || !this._pending.has(m.id)) return;
27
+ const { resolve, reject } = this._pending.get(m.id);
28
+ this._pending.delete(m.id);
29
+ if (m.error) reject(new Error(`CDP ${m.error.message}${m.error.data ? ` — ${m.error.data}` : ''}`));
30
+ else resolve(m.result);
31
+ });
32
+ const fail = (why) => {
33
+ this.closed = true;
34
+ for (const { reject } of this._pending.values()) reject(new Error(`CDP connection ${why}`));
35
+ this._pending.clear();
36
+ };
37
+ ws.on('close', () => fail('closed'));
38
+ ws.on('error', (e) => fail(`error: ${e.message}`));
39
+ }
40
+
41
+ // Nối vào Chrome của một profile NST qua cổng debug cục bộ.
42
+ static async connect(port, { timeoutMs = 15000 } = {}) {
43
+ const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(timeoutMs) })).json();
44
+ const url = ver.webSocketDebuggerUrl;
45
+ if (!url) throw new Error(`CDP /json/version không có webSocketDebuggerUrl (port ${port})`);
46
+ const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
47
+ await new Promise((resolve, reject) => {
48
+ const t = setTimeout(() => reject(new Error(`CDP ws timeout (port ${port})`)), timeoutMs);
49
+ ws.once('open', () => { clearTimeout(t); resolve(); });
50
+ ws.once('error', (e) => { clearTimeout(t); reject(e); });
51
+ });
52
+ return new CdpClient(ws);
53
+ }
54
+
55
+ send(method, params = {}, sessionId = undefined, { timeoutMs = 120000 } = {}) {
56
+ if (this.closed) return Promise.reject(new Error('CDP connection closed'));
57
+ const id = ++this._id;
58
+ return new Promise((resolve, reject) => {
59
+ const t = setTimeout(() => {
60
+ if (this._pending.delete(id)) reject(new Error(`CDP ${method} timeout sau ${Math.round(timeoutMs / 1000)}s`));
61
+ }, timeoutMs);
62
+ this._pending.set(id, {
63
+ resolve: (v) => { clearTimeout(t); resolve(v); },
64
+ reject: (e) => { clearTimeout(t); reject(e); },
65
+ });
66
+ this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
67
+ });
68
+ }
69
+
70
+ async getTargets() {
71
+ const { targetInfos } = await this.send('Target.getTargets');
72
+ return (targetInfos || []).filter((t) => t.type === 'page');
73
+ }
74
+
75
+ async createTarget(url, { background = true } = {}) {
76
+ const { targetId } = await this.send('Target.createTarget', { url, newWindow: false, background });
77
+ return targetId;
78
+ }
79
+
80
+ async attach(targetId) {
81
+ const { sessionId } = await this.send('Target.attachToTarget', { targetId, flatten: true });
82
+ return sessionId;
83
+ }
84
+
85
+ async detach(sessionId) {
86
+ await this.send('Target.detachFromTarget', { sessionId }).catch(() => {});
87
+ }
88
+
89
+ async closeTarget(targetId) {
90
+ await this.send('Target.closeTarget', { targetId }).catch(() => {});
91
+ }
92
+
93
+ // Chạy một biểu thức trong trang, chờ promise, trả giá trị JSON. Ném lỗi
94
+ // với message của exception trong trang (không nuốt).
95
+ async evaluate(sessionId, expression, { timeoutMs = 120000 } = {}) {
96
+ const r = await this.send('Runtime.evaluate', {
97
+ expression, awaitPromise: true, returnByValue: true, userGesture: false,
98
+ }, sessionId, { timeoutMs });
99
+ if (r.exceptionDetails) {
100
+ const d = r.exceptionDetails;
101
+ const msg = d.exception?.description || d.exception?.value || d.text || 'exception';
102
+ throw new Error(`page: ${String(msg).split('\n')[0].slice(0, 300)}`);
103
+ }
104
+ return r.result ? r.result.value : undefined;
105
+ }
106
+
107
+ close() {
108
+ this.closed = true;
109
+ try { this.ws.close(); } catch { /* ignore */ }
110
+ }
111
+ }
112
+
113
+ module.exports = { CdpClient };
@@ -27,6 +27,10 @@ const SHOPEE_DEADLINE_MS = {
27
27
  get_affiliate_link: 3 * 60 * 1000,
28
28
  ingest_shopee_product: 3 * 60 * 1000,
29
29
  };
30
+ // Tripo Studio: một lượt HD multiview gồm tải ảnh + tạo (vài phút tới hơn
31
+ // chục phút tuỳ hàng chờ của Tripo) + xuất GLB. Hết hạn thì bỏ lượt, Studio
32
+ // vẫn chạy nốt trong tab — người mở profile vẫn thấy model.
33
+ const TRIPO_DEADLINE_MS = Number(process.env.TRIPO_DEADLINE_MS || 45 * 60 * 1000);
30
34
 
31
35
  class CommandPoller {
32
36
  constructor(api, config) {
@@ -169,6 +173,9 @@ class CommandPoller {
169
173
  case 'fetch_affiliate_report':
170
174
  await this.handleFetchAffiliateReport(command);
171
175
  break;
176
+ case 'tripo_multiview_to_model':
177
+ await this.handleTripoMultiviewToModel(command);
178
+ break;
172
179
  default:
173
180
  // Playwright-based pipeline: any command whose type ends in '_pw'
174
181
  // is routed to scripts/<base>.js (BrowserClaw-style automation
@@ -222,8 +229,10 @@ class CommandPoller {
222
229
  // `browser.newPage()` của playwright đẻ ra một context MỚI — trắng cookie,
223
230
  // nghĩa là mất sạch phiên đăng nhập Shopee của profile. Chỉ context sẵn có
224
231
  // (contexts()[0]) mới mang cookie thật.
225
- async _connectNstProfileByName(name) {
226
- const { chromium } = require('playwright-core');
232
+ // Bảo đảm profile (theo TÊN) đang chạy, trả {profileId, port} của cổng debug
233
+ // cục bộ. Tách khỏi _connectNstProfileByName để luồng Tripo nối CDP trần
234
+ // (lib/cdp-client) thay vì playwright — xem lý do ở lib/cdp-client.js.
235
+ async _ensureNstProfileRunning(name) {
227
236
  const profileId = await this.nst.findProfile(name);
228
237
  if (!profileId) throw new Error(`NST profile "${name}" not found`);
229
238
  let running = (await this.nst.getRunningBrowsers()).find(b => b.profileId === profileId);
@@ -237,6 +246,12 @@ class CommandPoller {
237
246
  }
238
247
  const port = running?.remoteDebuggingPort;
239
248
  if (!port) throw new Error(`No debug port for profile "${name}" (launch failed?)`);
249
+ return { profileId, port };
250
+ }
251
+
252
+ async _connectNstProfileByName(name) {
253
+ const { chromium } = require('playwright-core');
254
+ const { port } = await this._ensureNstProfileRunning(name);
240
255
  const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
241
256
  const context = browser.contexts()[0] || await browser.newContext();
242
257
  // browser.close() trên kết nối CDP chỉ CẮT kết nối, không tắt Chrome của
@@ -451,6 +466,71 @@ class CommandPoller {
451
466
  });
452
467
  }
453
468
 
469
+ // Tạo model 3D HD từ ảnh nhiều góc qua Tripo STUDIO trong profile NST
470
+ // (mặc định tên "tripo", trên máy này — win-worker-2). Toàn bộ việc với
471
+ // Studio nằm ở lib/tripo-studio.js; ở đây chỉ: mở profile → nối CDP trần →
472
+ // chạy → báo tiến độ/kết quả về API (/tripo/worker-progress, /tripo/worker-result)
473
+ // → chốt lệnh. Deadline TRIPO_DEADLINE_MS; hết hạn thì cắt kết nối, lệnh failed.
474
+ async handleTripoMultiviewToModel(command) {
475
+ const payload = command.payload || {};
476
+ const jobId = payload.job_id || null;
477
+ if (!(await this._ensureNst(command))) return;
478
+ const profileName = payload.profile_name
479
+ || (await this.api.getSetting('tripo_profile_name').catch(() => null))
480
+ || 'tripo';
481
+ const log = (m) => console.log(m);
482
+ const report = (patch) => (jobId
483
+ ? this.api.tripoProgress({ job_id: jobId, command_id: String(command._id), ...patch }).catch((e) => { if (this.config.verbose) console.warn(`[tripo] báo tiến độ lỗi: ${e.message}`); })
484
+ : Promise.resolve());
485
+ let cdp = null;
486
+ let timer;
487
+ const deadline = new Promise((_, reject) => {
488
+ timer = setTimeout(() => reject(new Error(`quá ${Math.round(TRIPO_DEADLINE_MS / 60000)} phút chưa xong lượt Tripo [${profileName}] — huỷ lượt này`)), TRIPO_DEADLINE_MS);
489
+ });
490
+ try {
491
+ const { CdpClient } = require('./cdp-client');
492
+ const tripo = require('./tripo-studio');
493
+ const { port } = await this._ensureNstProfileRunning(profileName);
494
+ cdp = await CdpClient.connect(port);
495
+ log(`[tripo] profile "${profileName}" đang chạy (debug port ${port}) — bắt đầu job ${jobId || '(không id)'}`);
496
+ const result = await Promise.race([
497
+ tripo.runMultiviewJob({
498
+ cdp,
499
+ images: payload.images || [],
500
+ options: payload.options || {},
501
+ name: payload.name || 'model',
502
+ log,
503
+ onProgress: report,
504
+ deadlineMs: TRIPO_DEADLINE_MS,
505
+ }),
506
+ deadline,
507
+ ]);
508
+ await this.api.tripoResult({ job_id: jobId, command_id: String(command._id), status: 'done', result });
509
+ await this.api.updateCommand(command._id, {
510
+ status: 'done',
511
+ result: {
512
+ project_id: result.project_id, operator_id: result.operator_id,
513
+ export_model_url: result.export?.model_url || '', tripo_model_url: result.tripo_model_url || '',
514
+ credits_used: result.credits?.used ?? null,
515
+ },
516
+ });
517
+ log(`[tripo] xong: project ${result.project_id}${result.credits?.used != null ? ` · ${result.credits.used} credit` : ''}`);
518
+ } catch (err) {
519
+ const needsVerify = err && err.code === 'NEEDS_VERIFY';
520
+ const msg = String(err && err.message || err).slice(0, 500);
521
+ console.error(`[tripo] ${needsVerify ? 'cần người xác minh' : 'thất bại'}: ${msg}`);
522
+ await this.api.tripoResult({ job_id: jobId, command_id: String(command._id), status: needsVerify ? 'needs_verify' : 'failed', error: msg }).catch(() => {});
523
+ // needs_verify là lệnh ĐÃ chạy xong theo nghĩa của worker (không retry
524
+ // được bằng máy) — ghi 'done' kèm cờ, giống Shopee; lỗi thật mới 'failed'.
525
+ await this.api.updateCommand(command._id, needsVerify
526
+ ? { status: 'done', result: { needs_verify: true, reason: msg } }
527
+ : { status: 'failed', error: msg });
528
+ } finally {
529
+ clearTimeout(timer);
530
+ if (cdp) cdp.close();
531
+ }
532
+ }
533
+
454
534
  // Đọc đơn + click + liên kết trên một phiên đã nối, trả về bản vá cho lệnh.
455
535
  async _readAffiliateReport(context, payload) {
456
536
  const report = require('./shopee-report');
@@ -0,0 +1,65 @@
1
+ // S3 PutObject ký AWS SigV4 bằng crypto thuần — worker không có AWS SDK (deps
2
+ // chỉ node-fetch / playwright-core / ws) và Studio của Tripo đưa ảnh lên S3
3
+ // bằng chính STS tạm (`/v2/studio/storage/temporary_token` → sts_ak/sts_sk/
4
+ // session_token + bucket/key). Một lần PUT là đủ: ảnh tham chiếu < 20 MB.
5
+ //
6
+ // Đã chạy thật 02/10/2026: PUT lên `tripo-data.s3-accelerate.amazonaws.com`
7
+ // (endpoint accelerate — Studio cũng bật) → 200, audit ảnh sau đó `pass`.
8
+ const crypto = require('crypto');
9
+
10
+ function hmac(key, data, enc) { return crypto.createHmac('sha256', key).update(data, 'utf8').digest(enc); }
11
+ function sha256Hex(data) { return crypto.createHash('sha256').update(data).digest('hex'); }
12
+ function encodeRfc3986(str) {
13
+ return encodeURIComponent(str).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
14
+ }
15
+
16
+ /**
17
+ * Dựng URL + header đã ký cho PUT s3://bucket/key (virtual-hosted style).
18
+ * Tách riêng để test được mà không gọi mạng.
19
+ */
20
+ function signPutObject({ accessKeyId, secretAccessKey, sessionToken, region, bucket, key, host, accelerate, contentType, now }, body) {
21
+ const h = host || (accelerate ? `${bucket}.s3-accelerate.amazonaws.com` : `${bucket}.s3.${region}.amazonaws.com`);
22
+ const service = 's3';
23
+ const d = now || new Date();
24
+ const amzDate = d.toISOString().replace(/[:-]|\.\d{3}/g, '');
25
+ const dateStamp = amzDate.slice(0, 8);
26
+ const payloadHash = sha256Hex(body);
27
+ const canonicalUri = '/' + String(key).split('/').map(encodeRfc3986).join('/');
28
+ const headers = {
29
+ 'content-type': contentType || 'application/octet-stream',
30
+ host: h,
31
+ 'x-amz-content-sha256': payloadHash,
32
+ 'x-amz-date': amzDate,
33
+ };
34
+ if (sessionToken) headers['x-amz-security-token'] = sessionToken;
35
+ const signedKeys = Object.keys(headers).sort();
36
+ const canonicalHeaders = signedKeys.map((k) => `${k}:${String(headers[k]).trim()}\n`).join('');
37
+ const signedHeaders = signedKeys.join(';');
38
+ const canonicalRequest = ['PUT', canonicalUri, '', canonicalHeaders, signedHeaders, payloadHash].join('\n');
39
+ const scope = `${dateStamp}/${region}/${service}/aws4_request`;
40
+ const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n');
41
+ const kDate = hmac('AWS4' + secretAccessKey, dateStamp);
42
+ const kRegion = hmac(kDate, region);
43
+ const kService = hmac(kRegion, service);
44
+ const kSigning = hmac(kService, 'aws4_request');
45
+ const signature = hmac(kSigning, stringToSign, 'hex');
46
+ headers.authorization = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
47
+ return { url: `https://${h}${canonicalUri}`, headers };
48
+ }
49
+
50
+ async function s3PutObject(opts, body, { fetchImpl = fetch, timeoutMs = 120000 } = {}) {
51
+ const { url, headers } = signPutObject(opts, body);
52
+ const res = await fetchImpl(url, {
53
+ method: 'PUT',
54
+ headers: { ...headers, 'content-length': String(body.length) },
55
+ body,
56
+ signal: AbortSignal.timeout(timeoutMs),
57
+ });
58
+ if (!res.ok) {
59
+ const txt = await res.text().catch(() => '');
60
+ throw new Error(`S3 PUT ${res.status}: ${txt.replace(/\s+/g, ' ').slice(0, 300)}`);
61
+ }
62
+ return { url, etag: res.headers.get('etag') || '' };
63
+ }
64
+
65
+ module.exports = { s3PutObject, signPutObject };
@@ -0,0 +1,449 @@
1
+ // Tripo Studio (studio.tripo3d.ai) — tạo model 3D HD từ ảnh nhiều góc, đi
2
+ // bằng CHÍNH phiên đăng nhập của profile NST và credit của gói Studio.
3
+ //
4
+ // KHÔNG dùng developer API (platform.tripo3d.ai) — chủ tịch chốt 02/10/2026:
5
+ // cổng đó tính tiền riêng, còn Studio đã có credit sẵn. Mọi endpoint dưới đây
6
+ // là API nội bộ của web Studio, đọc ra từ bundle Nuxt
7
+ // (tripo-webapp-assets.tripo3d.ai/studio-prod/_nuxt/*.js) và đo thật 02/10/2026
8
+ // trên profile "tripo" ở win-worker-2:
9
+ //
10
+ // • JWT: GET /v2/studio/studio/whoami?tokenizeAs=default_jwt (cookie
11
+ // ory_kratos_session, credentials:include) → data.tokenized — JWT ES256 của
12
+ // auth.tripo3d.ai, sống 15 phút. Mọi call khác: Authorization Bearer +
13
+ // x-tripo-device-id (localStorage tripo:device_id) + x-tripo-start
14
+ // (performance.now()), credentials:omit.
15
+ // • Ảnh lên: POST /v2/studio/storage/temporary_token {client:'aws', format}
16
+ // → STS (sts_ak/sts_sk/session_token, resource_bucket, resource_uri, host)
17
+ // → PUT S3 SigV4 (lib/sigv4-s3) → POST /v2/studio/audit/image {image:{bucket,key}}
18
+ // → data.result 'pass'|'sensitive'|'reject'|'nsfw'.
19
+ // • Tạo model: POST /v2/studio/operation/multiview_to_model
20
+ // { ...tuỳ chọn HD, image:[front,left,back,right] (null cho ô trống) }
21
+ // → data.{project_id, operator_id} (hoặc data.variations[]).
22
+ // • Tiến độ: POST /v2/studio/progress {ids:[operator_id]} → [{operator_id,
23
+ // status prepare|queued|running|success|failed|expired|cancelled|banned,
24
+ // progress, left_time, reason}].
25
+ // • Kết quả: GET /v2/studio/project/detail/v3/{project_id} → model_url (file
26
+ // Studio dùng để xem, có thể nén meshopt). Xuất GLB chuẩn: POST
27
+ // /v2/studio/operation/export → model_url ngay hoặc operator_id → chờ
28
+ // progress → POST /v2/studio/operation/download_with_name → model_url.
29
+ // • Ví: GET /v2/studio/user/profile/payment → wallet.total_credit.
30
+ //
31
+ // Giao tiếp với trang qua lib/cdp-client (CDP trần) — xem ghi chú ở đó vì sao
32
+ // không dùng playwright (Cloudflare giữ tab playwright ở "Just a moment").
33
+
34
+ const { s3PutObject } = require('./sigv4-s3');
35
+
36
+ const API_BASE = 'https://api.tripo3d.ai';
37
+ const STUDIO_URL = 'https://studio.tripo3d.ai/';
38
+ const STUDIO_HOST = 'studio.tripo3d.ai';
39
+ // Phiên bản "HD" Studio đang gắn cho nút Generate HD Model (hdVersion = v3_1,
40
+ // đọc từ bundle B00y9j9b.js 02/10/2026). Đổi được qua options.model_version.
41
+ const HD_MODEL_VERSION = 'v3.1-20260211';
42
+ const VIEW_ORDER = ['front', 'left', 'back', 'right'];
43
+ const TERMINAL_OK = new Set(['success']);
44
+ const TERMINAL_BAD = new Set(['failed', 'expired', 'cancelled', 'banned']);
45
+ const IMAGE_FORMATS = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/webp': 'webp' };
46
+ // Trần cỡ phản hồi JSON đưa từ trang về qua CDP (cdp-client mở maxPayload 256 MB).
47
+ const MAX_PAGE_TEXT = 32 * 1024 * 1024;
48
+
49
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
50
+
51
+ class NeedsVerifyError extends Error {
52
+ constructor(msg) { super(msg); this.name = 'NeedsVerifyError'; this.code = 'NEEDS_VERIFY'; }
53
+ }
54
+ class TripoApiError extends Error {
55
+ constructor(msg, { status, code, path } = {}) { super(msg); this.name = 'TripoApiError'; this.status = status; this.tripoCode = code; this.path = path; }
56
+ }
57
+
58
+ // ── thuần (test được, không mạng) ───────────────────────────────────────────
59
+
60
+ function isStudioUrl(u) { try { return new URL(u).hostname === STUDIO_HOST; } catch { return false; } }
61
+
62
+ // Tiêu đề trang chặn của Cloudflare theo locale (profile NST này đặt tiếng
63
+ // Thái: "รอสักครู่..."). Tiêu đề rỗng cũng coi là CHƯA qua — trang đang nạp.
64
+ function isChallengeTitle(title) {
65
+ const t = String(title || '').trim();
66
+ if (!t) return true;
67
+ return /just a moment|รอสักครู่|attention required|checking your browser|verify you are human|请稍候|请稍等/i.test(t);
68
+ }
69
+
70
+ // Chọn tab Studio đã qua Cloudflare. Không có → null (bên gọi tự mở).
71
+ function pickStudioTarget(targets) {
72
+ const live = (targets || []).filter((t) => t && isStudioUrl(t.url) && !isChallengeTitle(t.title));
73
+ return live[0] || null;
74
+ }
75
+
76
+ // Tuỳ chọn tạo model theo đúng khuôn Studio gửi (BRpGFHL4.js g() nhánh
77
+ // BestQuality). Mặc định khớp lượt HD multi-view THẬT của tài khoản này (đọc
78
+ // lại từ project "eagle head 3d model" 30/09/2026 và "pastel airplane"):
79
+ // v3.1, geometry detailed, 2 triệu mặt, tam giác, KHÔNG texture, visibility
80
+ // shareable. `private` bị Studio khoá sau gói cao hơn (dy_pricing_private) —
81
+ // gói starter gửi private là tự rước lỗi. Quad mặc định 10k mặt như Studio V().
82
+ function hdOptions(o = {}) {
83
+ const geometry = o.geometry_quality === 'standard' ? 'standard' : 'detailed';
84
+ const quad = o.quad === true || o.quad === 'quad';
85
+ let faceLimit = Number(o.face_limit);
86
+ if (!Number.isFinite(faceLimit) || faceLimit <= 0) faceLimit = quad ? 10000 : (geometry === 'detailed' ? 2000000 : 1000000);
87
+ const texQ = ['standard', 'detailed', 'extreme'].includes(o.texture_quality) ? o.texture_quality : 'detailed';
88
+ const texture = o.texture === true;
89
+ return {
90
+ enable_image_autofix: o.enable_image_autofix === true,
91
+ face_limit: Math.round(faceLimit),
92
+ generate_parts: o.generate_parts === true,
93
+ geometry_quality: geometry,
94
+ model_version: o.model_version || HD_MODEL_VERSION,
95
+ pbr: texture && o.pbr !== false,
96
+ quad,
97
+ smart_poly: o.smart_poly === true,
98
+ texture,
99
+ texture_alignment: o.texture_alignment === 'geometry' ? 'geometry' : 'original_image',
100
+ texture_quality: texQ,
101
+ visibility: ['private', 'public', 'shareable'].includes(o.visibility) ? o.visibility : 'shareable',
102
+ };
103
+ }
104
+
105
+ // `images`: { front: {bucket,key,image_audit_result,image_source}, left?, back?, right? }
106
+ // Studio bắt buộc ô front và ≥2 ảnh (d0-kdlow.js: `!multiViewImages[0]?.key || r.length<2`).
107
+ function buildMultiviewBody(images, options) {
108
+ const img = images || {};
109
+ if (!img.front || !img.front.key) throw new Error('multiview cần ảnh FRONT (ô 0) — Studio không nhận thiếu mặt trước');
110
+ const count = VIEW_ORDER.filter((v) => img[v] && img[v].key).length;
111
+ if (count < 2) throw new Error(`multiview cần ít nhất 2 góc (đang có ${count})`);
112
+ return {
113
+ ...hdOptions(options),
114
+ image: VIEW_ORDER.map((v) => (img[v] && img[v].key
115
+ ? { bucket: img[v].bucket, image_audit_result: img[v].image_audit_result || 'pass', image_source: img[v].image_source || 'upload', key: img[v].key }
116
+ : null)),
117
+ };
118
+ }
119
+
120
+ // Body xuất file theo khuôn gallery/export-popover của Studio (_bwxceEO.js).
121
+ function buildExportBody({ project_id, name, format = 'glb', texture_size = 4096, texture_packaging = 'embedded' } = {}) {
122
+ if (!project_id) throw new Error('export cần project_id');
123
+ const fmt = ['glb', 'fbx', 'obj', 'stl', 'usdz', '3mf'].includes(format) ? format : 'glb';
124
+ return {
125
+ animate_in_place: false,
126
+ animations: [],
127
+ bake_animation_frame: 0,
128
+ enable_bake_animation: false,
129
+ export_orientation: '-y',
130
+ export_vertex_colors: false,
131
+ fbx_preset: 'blender',
132
+ format: fmt,
133
+ model_version: 'default',
134
+ name: String(name || 'model').replace(/[^\w\-. ]+/g, '_').slice(0, 80) || 'model',
135
+ pack_uv: false,
136
+ project_id,
137
+ texture_packaging: texture_packaging === 'zip' ? 'zip' : 'embedded',
138
+ texture_size: [512, 1024, 2048, 4096, 8192].includes(Number(texture_size)) ? Number(texture_size) : 4096,
139
+ with_animation: false,
140
+ };
141
+ }
142
+
143
+ // Trả về {project_id, operator_id} từ data của multiview_to_model, kể cả dạng
144
+ // variations (B00y9j9b.js ye()).
145
+ function parseSubmitResponse(data) {
146
+ if (!data) throw new Error('multiview_to_model trả về rỗng');
147
+ if (data.project_id && data.operator_id) return { project_id: data.project_id, operator_id: data.operator_id, is_nsfw: !!data.is_nsfw };
148
+ if (Array.isArray(data.variations)) {
149
+ const ok = data.variations.find((v) => v && v.accepted && v.project_id);
150
+ if (ok) return { project_id: ok.project_id, operator_id: ok.operator_id || '', is_nsfw: !!data.is_nsfw };
151
+ const why = data.variations.map((v) => v && (v.reason || v.message || v.info)).filter(Boolean).join('; ');
152
+ throw new Error(`Studio không nhận lượt tạo nào${why ? `: ${why}` : ''}`);
153
+ }
154
+ throw new Error(`multiview_to_model trả về lạ: ${JSON.stringify(data).slice(0, 200)}`);
155
+ }
156
+
157
+ function progressOf(list, operatorId) {
158
+ const arr = Array.isArray(list) ? list : (list && (list.list || list.items || list.data)) || [];
159
+ return arr.find((x) => x && x.operator_id === operatorId) || null;
160
+ }
161
+
162
+ // Đoán định dạng ảnh Studio chấp nhận (webp/png/jpg/jpeg) từ content-type rồi
163
+ // tới đuôi URL. Không đoán được → null (bên gọi báo lỗi rõ, không gửi bừa).
164
+ function imageFormat(contentType, url) {
165
+ const ct = String(contentType || '').split(';')[0].trim().toLowerCase();
166
+ if (IMAGE_FORMATS[ct]) return IMAGE_FORMATS[ct];
167
+ const m = String(url || '').split(/[?#]/)[0].match(/\.(png|jpe?g|webp)$/i);
168
+ if (m) return m[1].toLowerCase() === 'jpeg' ? 'jpg' : m[1].toLowerCase();
169
+ return null;
170
+ }
171
+
172
+ function decodeJwtExp(token) {
173
+ try {
174
+ const p = JSON.parse(Buffer.from(String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString());
175
+ return Number(p.exp) || 0;
176
+ } catch { return 0; }
177
+ }
178
+
179
+ // ── phiên Studio trong tab ──────────────────────────────────────────────────
180
+
181
+ // Biểu thức chạy trong trang: fetch + trả {status, text}. Tham số đi qua
182
+ // JSON.stringify nên không có chuyện nối chuỗi lọt ký tự lạ.
183
+ function fetchExpr({ url, method = 'GET', headers = {}, body, credentials = 'omit', withTripoHeaders = true }) {
184
+ const init = { method, credentials, headers };
185
+ const hasBody = body !== undefined && body !== null;
186
+ return `(async () => {
187
+ const init = ${JSON.stringify(init)};
188
+ ${withTripoHeaders ? `init.headers['x-tripo-device-id'] = localStorage.getItem('tripo:device_id') || '';
189
+ init.headers['x-tripo-start'] = String(performance.now());` : ''}
190
+ ${hasBody ? `init.body = ${JSON.stringify(typeof body === 'string' ? body : JSON.stringify(body))};` : ''}
191
+ const r = await fetch(${JSON.stringify(url)}, init);
192
+ const text = await r.text();
193
+ // Trần rộng: danh sách 20 project đã vượt 400 KB (đo 02/10/2026) — cắt
194
+ // ngắn là JSON cụt và call báo lỗi oan dù HTTP 200.
195
+ return JSON.stringify({ status: r.status, text: text.length > ${MAX_PAGE_TEXT} ? '' : text, oversize: text.length > ${MAX_PAGE_TEXT} ? text.length : 0 });
196
+ })()`;
197
+ }
198
+
199
+ class TripoStudioSession {
200
+ constructor(cdp, sessionId, { log = () => {} } = {}) {
201
+ this.cdp = cdp;
202
+ this.sessionId = sessionId;
203
+ this.log = log;
204
+ this._token = '';
205
+ this._tokenExp = 0;
206
+ }
207
+
208
+ async _raw(opts, { timeoutMs = 90000 } = {}) {
209
+ const out = await this.cdp.evaluate(this.sessionId, fetchExpr(opts), { timeoutMs });
210
+ let parsed;
211
+ try { parsed = JSON.parse(out); } catch { throw new Error(`trang trả về không phải JSON wrapper: ${String(out).slice(0, 120)}`); }
212
+ return parsed;
213
+ }
214
+
215
+ // JWT 15 phút — lấy lại khi còn < 60s. Cookie Ory nằm trong trình duyệt, nên
216
+ // call này PHẢI credentials:include và KHÔNG kèm Bearer.
217
+ async token() {
218
+ const now = Math.floor(Date.now() / 1000);
219
+ if (this._token && this._tokenExp - now > 60) return this._token;
220
+ const r = await this._raw({ url: `${API_BASE}/v2/studio/studio/whoami?tokenizeAs=default_jwt`, credentials: 'include', withTripoHeaders: false });
221
+ if (r.status === 401 || r.status === 403) throw new NeedsVerifyError(`Studio báo ${r.status} ở whoami — phiên đăng nhập Tripo trong profile đã hết, cần đăng nhập lại bằng tay`);
222
+ if (r.status !== 200) throw new TripoApiError(`whoami HTTP ${r.status}: ${r.text.slice(0, 160)}`, { status: r.status, path: 'whoami' });
223
+ let j; try { j = JSON.parse(r.text); } catch { j = null; }
224
+ if (!j || !j.tokenized) throw new NeedsVerifyError('whoami không trả tokenized — chưa đăng nhập Tripo trong profile này');
225
+ this._token = j.tokenized;
226
+ this._tokenExp = decodeJwtExp(j.tokenized) || (now + 14 * 60);
227
+ this.identity = j.identity || null;
228
+ return this._token;
229
+ }
230
+
231
+ // Gọi API Studio, bóc {code,message,data}. code≠0 → ném TripoApiError kèm
232
+ // message của Tripo (đó là lý do thật, ví dụ hết credit).
233
+ async call(path, { method = 'GET', body, timeoutMs = 90000 } = {}) {
234
+ const tok = await this.token();
235
+ const r = await this._raw({
236
+ url: `${API_BASE}${path}`, method,
237
+ headers: { authorization: `Bearer ${tok}`, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
238
+ body,
239
+ }, { timeoutMs });
240
+ if (r.oversize) throw new TripoApiError(`Tripo ${path} trả ${Math.round(r.oversize / 1048576)}MB — vượt trần đọc qua CDP`, { status: r.status, path });
241
+ let j = null;
242
+ try { j = JSON.parse(r.text); } catch { /* không phải JSON */ }
243
+ if (r.status === 401) { this._token = ''; throw new NeedsVerifyError(`Studio 401 ở ${path} — phiên hết hạn, cần đăng nhập lại trong profile`); }
244
+ if (r.status === 200 && !j) throw new TripoApiError(`Tripo ${path} → HTTP 200 nhưng thân không phải JSON: ${r.text.slice(0, 120)}`, { status: r.status, path });
245
+ if (r.status >= 400 || !j || (j.code !== undefined && j.code !== 0)) {
246
+ const msg = (j && (j.message || j.suggestion)) || r.text.slice(0, 200) || `HTTP ${r.status}`;
247
+ throw new TripoApiError(`Tripo ${path} → HTTP ${r.status}${j && j.code !== undefined ? ` code ${j.code}` : ''}: ${msg}`, { status: r.status, code: j && j.code, path });
248
+ }
249
+ return j.data !== undefined ? j.data : j;
250
+ }
251
+
252
+ payment() { return this.call('/v2/studio/user/profile/payment'); }
253
+ stsToken(format) { return this.call('/v2/studio/storage/temporary_token', { method: 'POST', body: { client: 'aws', format } }); }
254
+ auditImage(image) { return this.call('/v2/studio/audit/image', { method: 'POST', body: { image: { bucket: image.bucket, key: image.key } } }); }
255
+ submitMultiview(body) { return this.call('/v2/studio/operation/multiview_to_model', { method: 'POST', body, timeoutMs: 120000 }); }
256
+ progress(ids) { return this.call('/v2/studio/progress', { method: 'POST', body: { ids } }); }
257
+ projectDetail(projectId) { return this.call(`/v2/studio/project/detail/v3/${encodeURIComponent(projectId)}`); }
258
+ exportModel(body) { return this.call('/v2/studio/operation/export', { method: 'POST', body, timeoutMs: 120000 }); }
259
+ downloadWithName(operatorId, fileName) { return this.call('/v2/studio/operation/download_with_name', { method: 'POST', body: { file_name: fileName, operator_id: operatorId } }); }
260
+ cancelOperation(operatorId, operationType) { return this.call('/v2/studio/operation/cancel', { method: 'POST', body: { operator_id: operatorId, operation_type: operationType, scene: 'workspace' } }); }
261
+ }
262
+
263
+ // Tìm tab Studio đã qua Cloudflare, không có thì tạo tab trần rồi chờ nó qua.
264
+ // Trả {targetId, sessionId, created}. Hết `waitMs` mà vẫn ở trang chặn → ném
265
+ // NeedsVerifyError (người mở Studio một lần trong profile là xong).
266
+ async function openStudio(cdp, { log = () => {}, waitMs = 90000, pollMs = 3000 } = {}) {
267
+ let targets = await cdp.getTargets();
268
+ let target = pickStudioTarget(targets);
269
+ let created = false;
270
+ if (!target) {
271
+ const stuck = targets.filter((t) => isStudioUrl(t.url));
272
+ log(`[tripo] chưa có tab Studio sống (${stuck.length} tab đang kẹt chặn) — mở tab mới`);
273
+ const targetId = await cdp.createTarget(STUDIO_URL);
274
+ created = true;
275
+ const deadline = Date.now() + waitMs;
276
+ while (Date.now() < deadline) {
277
+ await sleep(pollMs);
278
+ targets = await cdp.getTargets();
279
+ const t = targets.find((x) => x.targetId === targetId);
280
+ if (t && isStudioUrl(t.url) && !isChallengeTitle(t.title)) { target = t; break; }
281
+ }
282
+ if (!target) {
283
+ await cdp.closeTarget(targetId);
284
+ throw new NeedsVerifyError(`Cloudflare không cho tab mới vào studio.tripo3d.ai sau ${Math.round(waitMs / 1000)}s — mở Studio một lần bằng tay trong profile rồi chạy lại`);
285
+ }
286
+ log(`[tripo] tab Studio mới đã qua Cloudflare: ${target.title}`);
287
+ } else {
288
+ log(`[tripo] dùng lại tab Studio đang mở: ${target.url.slice(0, 60)}`);
289
+ }
290
+ const sessionId = await cdp.attach(target.targetId);
291
+ return { targetId: target.targetId, sessionId, created };
292
+ }
293
+
294
+ async function fetchBytes(url, { timeoutMs = 120000 } = {}) {
295
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
296
+ if (!res.ok) throw new Error(`tải ảnh ${res.status} ${url.slice(0, 100)}`);
297
+ const buf = Buffer.from(await res.arrayBuffer());
298
+ return { buffer: buf, contentType: res.headers.get('content-type') || '' };
299
+ }
300
+
301
+ // Đưa một ảnh vào kho Studio: STS → S3 PUT → audit. Trả object để nhét vào
302
+ // body multiview. Ảnh bị audit 'reject'/'nsfw' → ném lỗi rõ (Studio cũng chặn
303
+ // trước khi gửi, 'nsfw' đòi xác nhận tuổi tay — không tự bấm thay người).
304
+ async function uploadReferenceImage(session, { view, url, buffer, contentType }, { log = () => {} } = {}) {
305
+ let bytes = buffer; let ct = contentType;
306
+ if (!bytes) ({ buffer: bytes, contentType: ct } = await fetchBytes(url));
307
+ const format = imageFormat(ct, url);
308
+ if (!format) throw new Error(`ảnh ${view}: định dạng không hỗ trợ (${ct || 'không rõ'}) — Studio chỉ nhận png/jpg/webp`);
309
+ if (bytes.length > 20 * 1024 * 1024) throw new Error(`ảnh ${view}: ${Math.round(bytes.length / 1048576)}MB > 20MB`);
310
+ const sts = await session.stsToken(format);
311
+ if (!sts || !sts.sts_ak || !sts.resource_uri) throw new Error(`temporary_token trả thiếu trường: ${Object.keys(sts || {}).join(',')}`);
312
+ const creds = { accessKeyId: sts.sts_ak, secretAccessKey: sts.sts_sk, sessionToken: sts.session_token, region: sts.region || 'us-west-2', bucket: sts.resource_bucket, key: sts.resource_uri, contentType: `image/${format === 'jpg' ? 'jpeg' : format}` };
313
+ try {
314
+ await s3PutObject({ ...creds, accelerate: true }, bytes);
315
+ } catch (e) {
316
+ log(`[tripo] S3 accelerate lỗi (${e.message.slice(0, 80)}) — thử endpoint thường ${sts.host}`);
317
+ await s3PutObject({ ...creds, host: sts.host }, bytes);
318
+ }
319
+ const audit = await session.auditImage({ bucket: sts.resource_bucket, key: sts.resource_uri });
320
+ const result = (audit && audit.result) || 'pass';
321
+ if (result === 'reject') throw new Error(`ảnh ${view} bị Tripo từ chối (audit: reject)`);
322
+ if (result === 'nsfw') throw new Error(`ảnh ${view} bị Tripo gắn nsfw — cần xác nhận tuổi bằng tay trong Studio, không tự làm`);
323
+ log(`[tripo] ảnh ${view} lên kho Studio (${format}, ${Math.round(bytes.length / 1024)}KB, audit ${result})`);
324
+ return { bucket: sts.resource_bucket, key: sts.resource_uri, image_audit_result: result, image_source: 'upload', bytes: bytes.length };
325
+ }
326
+
327
+ // Chờ một operator xong. onProgress({status, progress, left_time}) mỗi vòng.
328
+ async function waitOperator(session, operatorId, { label = 'operator', onProgress = () => {}, intervalMs = 6000, deadlineAt = Date.now() + 40 * 60000, log = () => {} } = {}) {
329
+ let missing = 0;
330
+ let lastStatus = '';
331
+ for (;;) {
332
+ if (Date.now() > deadlineAt) throw new Error(`${label} ${operatorId} chưa xong trước hạn — bỏ lượt này (Studio vẫn chạy tiếp, xem trong tab)`);
333
+ const list = await session.progress([operatorId]);
334
+ const p = progressOf(list, operatorId);
335
+ if (!p) {
336
+ missing += 1;
337
+ if (missing >= 8) throw new Error(`/progress không còn thấy ${label} ${operatorId} (8 lượt liền)`);
338
+ } else {
339
+ missing = 0;
340
+ const status = String(p.status || 'unknown');
341
+ if (status !== lastStatus) { log(`[tripo] ${label} ${status}${p.left_time > 0 ? ` (~${p.left_time}s)` : ''}`); lastStatus = status; }
342
+ try { await onProgress({ status, progress: Number(p.progress) || 0, left_time: Number(p.left_time) || -1, point_cloud_model_url: p.point_cloud_model_url || '' }); } catch { /* báo tiến độ không được chặn việc */ }
343
+ if (TERMINAL_OK.has(status)) return p;
344
+ if (TERMINAL_BAD.has(status)) {
345
+ const why = p.reason && (p.reason.message || p.reason.code) ? `${p.reason.message || ''}${p.reason.code ? ` (code ${p.reason.code})` : ''}` : status;
346
+ throw new Error(`${label} ${status}: ${why}`);
347
+ }
348
+ }
349
+ await sleep(intervalMs);
350
+ }
351
+ }
352
+
353
+ // Xuất GLB chuẩn qua Studio. Trả {model_url, operator_id|null}.
354
+ async function exportProject(session, { project_id, name, format, texture_size, texture_packaging, onProgress, deadlineAt, log = () => {} }) {
355
+ const body = buildExportBody({ project_id, name, format, texture_size, texture_packaging });
356
+ const r = await session.exportModel(body);
357
+ if (r && r.model_url) { log('[tripo] export trả file ngay'); return { model_url: r.model_url, operator_id: null, body }; }
358
+ const opId = r && r.operator_id;
359
+ if (!opId) throw new Error(`export không trả model_url lẫn operator_id: ${JSON.stringify(r).slice(0, 160)}`);
360
+ await waitOperator(session, opId, { label: 'export', onProgress, deadlineAt, intervalMs: 4000, log });
361
+ const d = await session.downloadWithName(opId, `${body.name}.${body.format}`);
362
+ if (!d || !d.model_url) throw new Error(`download_with_name không trả model_url: ${JSON.stringify(d).slice(0, 160)}`);
363
+ return { model_url: d.model_url, operator_id: opId, body };
364
+ }
365
+
366
+ /**
367
+ * Chạy trọn một lượt: ảnh → Studio → model → (xuất GLB). Không đóng tab Studio
368
+ * khi xong để lượt sau dùng lại (và khỏi đi qua Cloudflare lần nữa).
369
+ *
370
+ * @param {object} p
371
+ * @param {import('./cdp-client').CdpClient} p.cdp
372
+ * @param {Array<{view:string,url:string}>} p.images front bắt buộc, ≥2 góc
373
+ * @param {object} p.options tuỳ chọn tạo (xem hdOptions) + export:{enabled,format,texture_size,texture_packaging}
374
+ * @param {string} p.name tên project/file
375
+ * @param {(e:object)=>any} p.onProgress {stage, status, progress, left_time, tripo}
376
+ */
377
+ async function runMultiviewJob({ cdp, images, options = {}, name = 'model', log = () => {}, onProgress = () => {}, deadlineMs = 45 * 60000 }) {
378
+ const deadlineAt = Date.now() + deadlineMs;
379
+ const emit = async (e) => { try { await onProgress(e); } catch { /* ignore */ } };
380
+ const { sessionId } = await openStudio(cdp, { log });
381
+ const session = new TripoStudioSession(cdp, sessionId, { log });
382
+ try {
383
+ await session.token();
384
+ const payBefore = await session.payment().catch(() => null);
385
+ const creditsBefore = payBefore && payBefore.wallet ? Number(payBefore.wallet.total_credit) : null;
386
+ if (creditsBefore !== null) log(`[tripo] ví trước khi tạo: ${creditsBefore} credit (gói ${payBefore.member && payBefore.member.type})`);
387
+ await emit({ stage: 'uploading', progress: 0, tripo: { credits_before: creditsBefore } });
388
+
389
+ const byView = {};
390
+ const audits = {};
391
+ for (const im of images || []) {
392
+ if (!im || !VIEW_ORDER.includes(im.view) || !im.url) continue;
393
+ const up = await uploadReferenceImage(session, im, { log });
394
+ byView[im.view] = up;
395
+ audits[im.view] = up.image_audit_result;
396
+ }
397
+ const body = buildMultiviewBody(byView, options);
398
+ log(`[tripo] gửi multiview_to_model: ${Object.keys(byView).join('+')} · ${body.model_version} · geometry ${body.geometry_quality} · texture ${body.texture ? body.texture_quality : 'off'} · pbr ${body.pbr} · ${body.quad ? 'quad' : 'tri'} ${body.face_limit}`);
399
+ const submitted = parseSubmitResponse(await session.submitMultiview(body));
400
+ log(`[tripo] Studio nhận: project ${submitted.project_id} · operator ${submitted.operator_id}`);
401
+ await emit({ stage: 'generating', status: 'queued', progress: 0, tripo: { project_id: submitted.project_id, operator_id: submitted.operator_id, request: body, image_audits: audits } });
402
+
403
+ await waitOperator(session, submitted.operator_id, {
404
+ label: 'generate', deadlineAt, log,
405
+ onProgress: (p) => emit({ stage: 'generating', ...p, tripo: { project_id: submitted.project_id, operator_id: submitted.operator_id } }),
406
+ });
407
+
408
+ const detail = await session.projectDetail(submitted.project_id).catch((e) => { log(`[tripo] project detail lỗi: ${e.message}`); return null; });
409
+ const tripoModelUrl = (detail && detail.model_url) || '';
410
+
411
+ let exported = null;
412
+ const ex = options.export || {};
413
+ if (ex.enabled !== false) {
414
+ await emit({ stage: 'exporting', progress: 0, tripo: { project_id: submitted.project_id, operator_id: submitted.operator_id } });
415
+ exported = await exportProject(session, {
416
+ project_id: submitted.project_id, name, format: ex.format || 'glb', texture_size: ex.texture_size || 4096, texture_packaging: ex.texture_packaging || 'embedded',
417
+ deadlineAt, log,
418
+ onProgress: (p) => emit({ stage: 'exporting', ...p, tripo: { project_id: submitted.project_id, operator_id: submitted.operator_id } }),
419
+ });
420
+ }
421
+
422
+ const payAfter = await session.payment().catch(() => null);
423
+ const creditsAfter = payAfter && payAfter.wallet ? Number(payAfter.wallet.total_credit) : null;
424
+ const used = creditsBefore !== null && creditsAfter !== null ? creditsBefore - creditsAfter : null;
425
+ if (used !== null) log(`[tripo] credit dùng: ${used} (còn ${creditsAfter})`);
426
+
427
+ return {
428
+ project_id: submitted.project_id,
429
+ operator_id: submitted.operator_id,
430
+ is_nsfw: submitted.is_nsfw,
431
+ project_name: (detail && detail.project_name) || '',
432
+ tripo_model_url: tripoModelUrl,
433
+ export: exported ? { model_url: exported.model_url, operator_id: exported.operator_id, format: exported.body.format, texture_size: exported.body.texture_size, texture_packaging: exported.body.texture_packaging } : null,
434
+ request: body,
435
+ image_audits: audits,
436
+ credits: { before: creditsBefore, after: creditsAfter, used },
437
+ };
438
+ } finally {
439
+ await cdp.detach(sessionId);
440
+ }
441
+ }
442
+
443
+ module.exports = {
444
+ API_BASE, STUDIO_URL, HD_MODEL_VERSION, VIEW_ORDER,
445
+ NeedsVerifyError, TripoApiError, TripoStudioSession,
446
+ isStudioUrl, isChallengeTitle, pickStudioTarget, hdOptions, buildMultiviewBody, buildExportBody,
447
+ parseSubmitResponse, progressOf, imageFormat, decodeJwtExp, fetchExpr,
448
+ openStudio, uploadReferenceImage, waitOperator, exportProject, runMultiviewJob,
449
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.146",
3
+ "version": "2.5.148",
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": {
@@ -839,10 +839,13 @@ async function run({ page, payload, log }) {
839
839
  // Không có nó thì API không kết luận được kênh đã liên kết hay chưa, và cố
840
840
  // tình để ở "chưa rõ" còn hơn báo sai. Xem readDelegatePageId.
841
841
  if (pageRef) {
842
- const dg = await readDelegatePageId(page, pageRef.id).catch(() => ({ id: '', checked: false }));
842
+ const dg = await readDelegatePageId(page, pageRef.id).catch(() => ({ id: '', checked: false, reason: 'lỗi khi đọc' }));
843
843
  pageRef.delegate_id = dg.id;
844
844
  pageRef.delegate_checked = dg.checked;
845
- log('info', `[fb-stats] mã Trang (Shopee dùng): ${dg.id || (dg.checked ? 'không có — page kiểu cổ điển' : 'CHƯA rõ')}`);
845
+ // Giữ nguyên tiền tố "mã Trang (Shopee dùng): <số>" — grep log dựa vào nó.
846
+ // Lý do đi sau, để ca hụt nói được vì 0 mã hay vì nhiều mã.
847
+ const what = dg.id || (dg.checked ? 'không có — page kiểu cổ điển' : 'CHƯA rõ');
848
+ log('info', `[fb-stats] mã Trang (Shopee dùng): ${what}${dg.reason ? ` (${dg.reason})` : ''}`);
846
849
  }
847
850
  if (pageRef) log('info', `[fb-stats] page: ${pageRef.id ? `id ${pageRef.id}` : `handle ${pageRef.handle}`}`);
848
851
 
@@ -2153,34 +2153,76 @@ async function readPageIdFromDom(page) {
2153
2153
  // Khác `profile_id`/`actorID` (là của NGƯỜI ĐANG XEM), `delegate_page_id` nói
2154
2154
  // về TRANG ĐANG MỞ: đo trên 3 page lạ, nó trả đúng delegate của trang đó.
2155
2155
  //
2156
- // Trả { id, checked }:
2157
- // - đúng MỘT giá trị → id = nó, checked = true
2158
- // - KHÔNG có giá trị nào → page kiểu CỔ ĐIỂN (profile id chính là khoá
2159
- // Shopee) → id rỗng, checked = true. Chỉ dám kết luận vậy khi profile id
2160
- // KHÔNG mang hình dạng page kiểu mới; nếu nó là `61…` mà không đọc được
2161
- // delegate thì đó là đọc HỤT, không phải page cổ điển.
2162
- // - NHIỀU giá trị → profile quản nhiều page (đo thật ở
2163
- // `duong-nhan-cung-anh`: 2 delegate). Không đoán → checked = false, để bên
2164
- // API giữ trạng thái "chưa rõ" thay vì báo sai.
2165
- const NEW_STYLE_PROFILE_ID = /^61\d{12}$/;
2156
+ // ⚠️ MENU CHUYỂN TÀI KHOẢN cũng mang `delegate_page_id` (đo 02/10/2026, ca
2157
+ // "Bí Quyết Nhà Nông", profile NST khoe-khong-kho). Phiên đang đứng dưới danh
2158
+ // nghĩa page, nên trang nhúng sẵn mục để chuyển về TÀI KHOẢN CÁ NHÂN đã tạo
2159
+ // page (`ProfileSwitcherEligibleProfile`). Tài khoản cá nhân id dạng cũ `100…`
2160
+ // thì mục đó ghi `"delegate_page_id":null` — 5 profile đọc tốt đều vậy. Tài
2161
+ // khoản cá nhân "Lan Linh" (id dạng mới 61590196273118) lại mang một SỐ
2162
+ // (1193846817137262). Đếm chung mọi giá trị thì thành 2 mã → "chưa rõ" ở MỌI
2163
+ // lượt (47/47), và page không bao giờ được đối chiếu với Shopee. Tệ hơn: page
2164
+ // nào không lộ mã của chính nó thì bản cũ nhận nhầm mã của tài khoản cá nhân.
2165
+ //
2166
+ // Nên mỗi lần xuất hiện được phân loại theo VỊ TRÍ, không chỉ theo giá trị:
2167
+ // own — nằm cùng object phẳng với `"id":"<profile id của page>"`
2168
+ // (vd `"user":{"id":"6159…","name":"…","delegate_page_id":"…"}`
2169
+ // ở thanh điều hướng trái). Chắc nhất: gắn thẳng với page đang mở.
2170
+ // switcher — mục menu chuyển tài khoản. Không bao giờ là mã của page.
2171
+ // other — còn lại (vd bài viết trong lưới Reels).
2172
+ //
2173
+ // Hàm THUẦN và TỰ CHỨA (không dùng biến ngoài): `readDelegatePageId` gửi
2174
+ // nguyên văn mã nguồn của nó vào trang, nên test chạy đúng đoạn chạy trên
2175
+ // Facebook.
2176
+ function scanDelegatesInHtml(html, profileId) {
2177
+ const re = /"delegate_page_id"\s*:\s*"?(\d{5,25})"?/g;
2178
+ const pid = String(profileId || '');
2179
+ const own = new Set(); const switcher = new Set(); const other = new Set();
2180
+ let m; let n = 0;
2181
+ while ((m = re.exec(html)) && n < 60) {
2182
+ n++;
2183
+ const before = html.slice(Math.max(0, m.index - 600), m.index);
2184
+ const owner = before.match(/"id"\s*:\s*"(\d{5,25})"[^{}]*$/);
2185
+ if (pid && owner && owner[1] === pid) { own.add(m[1]); continue; }
2186
+ const after = html.slice(m.index + m[0].length, m.index + m[0].length + 400);
2187
+ const sw = after.indexOf('ProfileSwitcherEligibleProfile');
2188
+ const next = after.indexOf('"delegate_page_id"');
2189
+ if (sw !== -1 && (next === -1 || sw < next)) { switcher.add(m[1]); continue; }
2190
+ other.add(m[1]);
2191
+ }
2192
+ return { own: [...own], switcher: [...switcher], other: [...other] };
2193
+ }
2166
2194
 
2167
- async function readDelegatePageId(page, profileId = '') {
2168
- const found = await page.evaluate(() => {
2169
- const out = new Set();
2170
- const re = /"delegate_page_id"\s*:\s*"?(\d{5,25})"?/g;
2171
- const html = document.documentElement.innerHTML;
2172
- let m; let n = 0;
2173
- while ((m = re.exec(html)) && n < 20) { out.add(m[1]); n++; }
2174
- return [...out];
2175
- }).catch(() => null);
2195
+ // Trả { id, checked, reason }:
2196
+ // - đúng MỘT mã gắn với profile id của page → nhận.
2197
+ // - không có mã nào gắn được → luật cũ trên các mã còn lại, ĐÃ BỎ mục menu
2198
+ // chuyển tài khoản: đúng một mã thì nhận; NHIỀU mã thì không đoán
2199
+ // (`duong-nhan-cung-anh` 22/09: 2 mã), checked = false để API giữ "chưa rõ".
2200
+ // - KHÔNG có mã nào → page kiểu CỔ ĐIỂN (profile id chính là khoá Shopee) →
2201
+ // id rỗng, checked = true. Chỉ dám kết luận vậy khi profile id KHÔNG mang
2202
+ // hình dạng page kiểu mới; `61…` mà không có mã là đọc HỤT.
2203
+ // `reason` chỉ để ghi log: trước đây mọi ca hụt đều in "CHƯA rõ" nên không
2204
+ // ai biết hụt vì 0 mã hay vì nhiều mã.
2205
+ const NEW_STYLE_PROFILE_ID = /^61\d{12}$/;
2176
2206
 
2177
- if (found === null) return { id: '', checked: false };
2178
- if (found.length === 1) return { id: found[0], checked: true };
2179
- if (found.length === 0) {
2180
- // Không có delegate. Chỉ coi là page cổ điển khi profile id không phải dạng mới.
2181
- return { id: '', checked: !NEW_STYLE_PROFILE_ID.test(String(profileId || '')) };
2207
+ function pickDelegate(scan, profileId = '') {
2208
+ if (!scan) return { id: '', checked: false, reason: 'không đọc được trang' };
2209
+ const own = scan.own || [];
2210
+ const rest = scan.other || [];
2211
+ const sw = scan.switcher || [];
2212
+ if (own.length === 1) return { id: own[0], checked: true, reason: 'gắn với profile id của page' };
2213
+ if (own.length > 1) return { id: '', checked: false, reason: `${own.length} mã cùng gắn với profile id của page` };
2214
+ if (rest.length === 1) return { id: rest[0], checked: true, reason: 'mã duy nhất trên trang' };
2215
+ if (rest.length > 1) return { id: '', checked: false, reason: `${rest.length} mã khác nhau trên trang, không đoán` };
2216
+ if (NEW_STYLE_PROFILE_ID.test(String(profileId || ''))) {
2217
+ return { id: '', checked: false, reason: sw.length ? 'chỉ thấy mã của mục chuyển tài khoản' : 'trang không có mã nào' };
2182
2218
  }
2183
- return { id: '', checked: false }; // nhiều page trong cùng profile — không đoán
2219
+ return { id: '', checked: true, reason: 'page kiểu cổ điển' };
2220
+ }
2221
+
2222
+ async function readDelegatePageId(page, profileId = '') {
2223
+ const expr = `(${scanDelegatesInHtml.toString()})(document.documentElement.innerHTML, ${JSON.stringify(String(profileId || ''))})`;
2224
+ const scan = await page.evaluate(expr).catch(() => null);
2225
+ return pickDelegate(scan, profileId);
2184
2226
  }
2185
2227
 
2186
2228
  async function hasVisibleReelComposer(page) {
@@ -4772,6 +4814,8 @@ module.exports.__testables = {
4772
4814
  parsePageRef,
4773
4815
  readPageIdFromDom,
4774
4816
  readDelegatePageId,
4817
+ scanDelegatesInHtml,
4818
+ pickDelegate,
4775
4819
  discoverReelsTabUrls,
4776
4820
  openReelsTabWithTiles,
4777
4821
  networkIdVerdict,