channel-worker 2.5.145 → 2.5.147
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 +11 -1
- package/lib/cdp-client.js +113 -0
- package/lib/command-poller.js +99 -4
- package/lib/shopee-scraper.js +71 -3
- package/lib/sigv4-s3.js +65 -0
- package/lib/tripo-studio.js +449 -0
- package/package.json +1 -1
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 };
|
package/lib/command-poller.js
CHANGED
|
@@ -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
|
-
|
|
226
|
-
|
|
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
|
|
@@ -397,6 +412,9 @@ class CommandPoller {
|
|
|
397
412
|
const res = await scraper.generateOfferLink(context, {
|
|
398
413
|
items,
|
|
399
414
|
subIds: Array.isArray(payload.sub_ids) ? payload.sub_ids : [],
|
|
415
|
+
// API bật `need_product` khi món CHƯA có tên ở đâu cả (món dán link
|
|
416
|
+
// tay, chưa nhập kho). Món đã biết tên thì không tốn thêm request.
|
|
417
|
+
productIds: payload.need_product ? items.map((it) => String(it.item_id || '')) : [],
|
|
400
418
|
log: (m) => console.log(m), // để thấy số tab đã dọn
|
|
401
419
|
});
|
|
402
420
|
if (res.status === 'needs_verify') {
|
|
@@ -411,12 +429,24 @@ class CommandPoller {
|
|
|
411
429
|
return { status: 'failed', error: String(res.reason || 'không lấy được link').slice(0, 500) };
|
|
412
430
|
}
|
|
413
431
|
console.log(`[shopee] lấy được ${res.links.length} link (sub: ${JSON.stringify(res.sub_ids)})`);
|
|
414
|
-
|
|
432
|
+
// Món vừa đọc được thông tin → vào kho TRƯỚC khi báo link: `workerLink`
|
|
433
|
+
// bên API đọc tên món từ kho để AI viết câu bình luận, và bảng "Món đã
|
|
434
|
+
// dùng" bù giá/lãi/ảnh cũng từ kho. Bỏ `affiliate_link` (parseOfferRow
|
|
435
|
+
// để chuỗi rỗng) — gửi nguyên là xoá trắng link cũ của món trong kho.
|
|
436
|
+
const products = (res.products || []).map(({ affiliate_link: _link, ...p }) => p);
|
|
437
|
+
if (products.length) {
|
|
438
|
+
await this.api.upsertAffiliateProducts(products, { user_id: command.user_id })
|
|
439
|
+
.catch((e) => console.warn(`[shopee] ghi kho món vừa đọc lỗi: ${e.message}`));
|
|
440
|
+
console.log(`[shopee] đọc được tên ${products.length} món: ${products.map((p) => p.name.slice(0, 40)).join(' | ')}`);
|
|
441
|
+
}
|
|
442
|
+
const nameOf = new Map(products.map((p) => [p.item_id, p.name]));
|
|
443
|
+
const links = res.links.map((l) => (nameOf.get(l.item_id) ? { ...l, name: nameOf.get(l.item_id) } : l));
|
|
444
|
+
await this.api.affiliateLinkResult(links, {
|
|
415
445
|
user_id: command.user_id,
|
|
416
446
|
sub_ids: res.sub_ids,
|
|
417
447
|
idea_id: payload.idea_id || null,
|
|
418
448
|
});
|
|
419
|
-
return { status: 'done', result: { links
|
|
449
|
+
return { status: 'done', result: { links, sub_ids: res.sub_ids } };
|
|
420
450
|
},
|
|
421
451
|
});
|
|
422
452
|
}
|
|
@@ -436,6 +466,71 @@ class CommandPoller {
|
|
|
436
466
|
});
|
|
437
467
|
}
|
|
438
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
|
+
|
|
439
534
|
// Đọc đơn + click + liên kết trên một phiên đã nối, trả về bản vá cho lệnh.
|
|
440
535
|
async _readAffiliateReport(context, payload) {
|
|
441
536
|
const report = require('./shopee-report');
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -146,6 +146,27 @@ function parseOfferRow(row) {
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// `data` của /api/v3/offer/product?item_id=<id> — trang chi tiết MỘT món trên
|
|
150
|
+
// affiliate.shopee.vn. Thẻ món (`batch_item_for_item_card_full`) cùng dạng với
|
|
151
|
+
// một dòng của offer list, chỉ khác chỗ để tỉ lệ hoa hồng: ở đây
|
|
152
|
+
// `commission_rate` là một OBJECT gom mọi loại tỉ lệ, còn offer list để
|
|
153
|
+
// `default_commission_rate` nằm thẳng trên dòng. Đưa nguyên `data` vào
|
|
154
|
+
// parseOfferRow là `pctToNumber(object)` ra 0 — món có tên mà lãi 0đ.
|
|
155
|
+
// Đo 30/09/2026 trên Shopee1: 25451550752 giá 154.999đ, default 9%, Shopee
|
|
156
|
+
// ghi "commission":"₫13.950" — khớp đúng phép tính của parseOfferRow.
|
|
157
|
+
function parseOfferDetail(data) {
|
|
158
|
+
if (!data || typeof data !== 'object') return null;
|
|
159
|
+
const cr = data.commission_rate && typeof data.commission_rate === 'object' ? data.commission_rate : {};
|
|
160
|
+
const row = parseOfferRow({
|
|
161
|
+
...data,
|
|
162
|
+
commission_rate: undefined,
|
|
163
|
+
default_commission_rate: cr.default_commission_rate,
|
|
164
|
+
max_commission_rate: cr.max_commission_rate,
|
|
165
|
+
seller_commission_rate: cr.seller_commission_rate,
|
|
166
|
+
});
|
|
167
|
+
return row.item_id && row.shop_id && row.name ? row : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
149
170
|
// Affiliate offer-page category tabs → match_id (read from rc-tabs node keys,
|
|
150
171
|
// confirmed live). Tier-1 fashion focus first; the offer page has NO dedicated
|
|
151
172
|
// Shoes/Bags/Accessories tab → those come later via keyword search.
|
|
@@ -517,8 +538,52 @@ function linkFailureReason(res, wanted = 0) {
|
|
|
517
538
|
return bits.join(' · ').slice(0, 500);
|
|
518
539
|
}
|
|
519
540
|
|
|
541
|
+
// Tên + giá + hoa hồng + ảnh của từng món, đọc trên CHÍNH tab affiliate vừa
|
|
542
|
+
// lấy link.
|
|
543
|
+
//
|
|
544
|
+
// Vì sao cần: GQL lấy link chỉ trả itemId/shopId/link, không có tên. Món dán
|
|
545
|
+
// link rút gọn s.shopee.vn (đường gắn chính từ 25/09) lại chưa nằm trong kho,
|
|
546
|
+
// nên cứ ~1/3 món mang tên rỗng mãi — bảng "Món đã dùng" chỉ hiện được URL,
|
|
547
|
+
// giá/lãi 0đ, không ảnh (đo prod 29/09: 40/130 món).
|
|
548
|
+
//
|
|
549
|
+
// Đây đúng là request trang chi tiết `affiliate.shopee.vn/offer/product_offer/
|
|
550
|
+
// <item_id>` tự gọi khi người mở món đó ra bấm Get Link — không phải đường lạ.
|
|
551
|
+
// Đo 30/09/2026 trên Shopee1: fetch tay trong tab /offer trả 200 code 0 cho
|
|
552
|
+
// 2/2 món, không bị đá sang captcha.
|
|
553
|
+
//
|
|
554
|
+
// LỖI MỀM toàn phần: không bao giờ ném, hỏng món nào thì bỏ món đó. Chỗ gọi
|
|
555
|
+
// đã cầm link thật trong tay — mất tên là chuyện nhỏ, mất link là mất lượt bán.
|
|
556
|
+
// Gặp mã chặn chống bot thì DỪNG luôn, không gõ tiếp vào cửa đang đóng.
|
|
557
|
+
async function readOfferProducts(page, itemIds = [], { evalTimeoutMs = EVAL_TIMEOUT_MS, pauseMs = 1200, log = null } = {}) {
|
|
558
|
+
const ids = [...new Set((itemIds || []).map((x) => String(x || '')).filter((x) => /^\d+$/.test(x)))];
|
|
559
|
+
const products = [];
|
|
560
|
+
for (const id of ids) {
|
|
561
|
+
try {
|
|
562
|
+
if (pauseMs) await sleep(pauseMs);
|
|
563
|
+
const res = await evaluateWithin(page, async (itemId) => {
|
|
564
|
+
try {
|
|
565
|
+
const r = await fetch(`/api/v3/offer/product?item_id=${encodeURIComponent(itemId)}`, { credentials: 'include' });
|
|
566
|
+
return { status: r.status, json: await r.json().catch(() => null) };
|
|
567
|
+
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
568
|
+
}, id, evalTimeoutMs);
|
|
569
|
+
if (isAntiBotBody(res?.json) || isBlockedState(page.url(), res)) {
|
|
570
|
+
if (log) log(`[shopee] đọc tên món ${id}: bị chặn (http ${res?.status}) — dừng, link vẫn giữ`);
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
const p = res?.status === 200 && res.json?.code === 0 ? parseOfferDetail(res.json.data) : null;
|
|
574
|
+
if (p && p.item_id === id) products.push(p);
|
|
575
|
+
else if (log) log(`[shopee] đọc tên món ${id}: không ra (http ${res?.status}, code ${res?.json?.code})`);
|
|
576
|
+
} catch (e) {
|
|
577
|
+
if (log) log(`[shopee] đọc tên món ${id} lỗi: ${e.message}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return products;
|
|
581
|
+
}
|
|
582
|
+
|
|
520
583
|
// subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
|
|
521
|
-
|
|
584
|
+
// productIds: món nào cần đọc thêm tên/giá/hoa hồng (xem readOfferProducts) —
|
|
585
|
+
// để trống thì không tốn thêm request nào, đúng như trước.
|
|
586
|
+
async function generateOfferLink(ctx, { items = [], subIds = [], productIds = [], timeoutMs = 45000, evalTimeoutMs = EVAL_TIMEOUT_MS, pauseMs = 1200, log = null } = {}) {
|
|
522
587
|
const list = (items || [])
|
|
523
588
|
.map((it) => ({ itemId: String(it.item_id || ''), shopId: Number(it.shop_id) || 0, trace: '' }))
|
|
524
589
|
.filter((it) => it.itemId && it.shopId);
|
|
@@ -592,7 +657,10 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
|
|
|
592
657
|
return { status: 'failed', links: [], sub_ids: subs, reason: linkFailureReason(res, list.length) };
|
|
593
658
|
}
|
|
594
659
|
if (missing.length && log) log(`[shopee] thiếu link cho ${missing.length}/${list.length} món: ${missing.join(', ')}`);
|
|
595
|
-
|
|
660
|
+
// Chỉ đọc món ĐÃ có link, và làm SAU khi link đã nằm trong tay.
|
|
661
|
+
const want = (productIds || []).map(String).filter((id) => links.some((l) => l.item_id === id));
|
|
662
|
+
const products = want.length ? await readOfferProducts(page, want, { evalTimeoutMs, pauseMs, log }) : [];
|
|
663
|
+
return { status: 'ok', sub_ids: subs, links, missing, products };
|
|
596
664
|
} finally {
|
|
597
665
|
if (opened) await page.close().catch(() => {});
|
|
598
666
|
}
|
|
@@ -616,4 +684,4 @@ function parseProductUrl(url = '') {
|
|
|
616
684
|
return null;
|
|
617
685
|
}
|
|
618
686
|
|
|
619
|
-
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, linkFailureReason, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, findAffiliatePage, pruneTabs, isJunkTab, evaluateWithin, EVAL_TIMEOUT_MS };
|
|
687
|
+
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, readOfferProducts, linkFailureReason, cleanSubId, parseProductUrl, parseOfferRow, parseOfferDetail, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, findAffiliatePage, pruneTabs, isJunkTab, evaluateWithin, EVAL_TIMEOUT_MS };
|
package/lib/sigv4-s3.js
ADDED
|
@@ -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
|
+
};
|