Compare commits
21 Commits
f1db444853
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7af67c2056 | |||
| 6d66ecb13e | |||
| 9d2b9a1f90 | |||
| 574f0944fa | |||
| f4b1ebfe51 | |||
| 79022be20b | |||
| eeaae0b0b9 | |||
| 8df31c96e1 | |||
| e107464716 | |||
| fe22461a07 | |||
| b18cd6d79f | |||
| 85cca5a5c3 | |||
| 21fd90a069 | |||
| a6a4ae6e00 | |||
| 79bc738e1f | |||
| 1025ce9778 | |||
| 1608122288 | |||
| e506996855 | |||
| 6a7856065a | |||
| 4c1f4ccd9d | |||
| 8dbd220924 |
@@ -60,3 +60,5 @@ dist
|
|||||||
dist.zip
|
dist.zip
|
||||||
|
|
||||||
data/*
|
data/*
|
||||||
|
x_profile_data
|
||||||
|
x_profile_data/*
|
||||||
+80
-13
@@ -1,32 +1,63 @@
|
|||||||
console.log('[XAI Background] ✅ Service worker started');
|
console.log('[XAI Background] ✅ Service worker started');
|
||||||
|
|
||||||
// ===== MENU =====
|
// ===== TẠO MENU: XÓA CŨ → TẠO MỚI, KHÔNG GỘP =====
|
||||||
chrome.runtime.onInstalled.addListener(() => {
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
chrome.contextMenus.removeAll(() => {
|
||||||
|
// Menu 1: Comment — KHÔNG cần bôi đen, chỉ trên X
|
||||||
chrome.contextMenus.create({
|
chrome.contextMenus.create({
|
||||||
id: 'writeComment',
|
id: 'writeComment',
|
||||||
title: '✍️ Viết comment',
|
title: '✍️ Viết comment',
|
||||||
contexts: ['all'],
|
contexts: ['all'],
|
||||||
documentUrlPatterns: ['https://x.com/*']
|
documentUrlPatterns: ['https://x.com/*']
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Menu 2: Dịch — CHỈ hiện khi đã bôi đen text, có thể dùng trên mọi trang
|
||||||
|
chrome.contextMenus.create({
|
||||||
|
id: 'translateText',
|
||||||
|
title: '🌐 Dịch văn bản',
|
||||||
|
contexts: ['selection'],
|
||||||
|
documentUrlPatterns: ['https://x.com/*', 'http://*/*', 'https://*/*']
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// FIRE & FORGET — không đợi content trả lời
|
// ===== XỬ LÝ CLICK MENU =====
|
||||||
chrome.contextMenus.onClicked.addListener((info, tab) => {
|
chrome.contextMenus.onClicked.addListener((info, tab) => {
|
||||||
if (info.menuItemId !== 'writeComment' || !tab?.id) return;
|
if (!tab?.id) return;
|
||||||
|
|
||||||
|
if (info.menuItemId === 'writeComment') {
|
||||||
|
// Gửi xuống content: mở form comment
|
||||||
chrome.tabs.sendMessage(tab.id, { action: 'OPEN_FORM' }).catch(() => {});
|
chrome.tabs.sendMessage(tab.id, { action: 'OPEN_FORM' }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.menuItemId === 'translateText' && info.selectionText) {
|
||||||
|
// Gửi xuống content: mở tab Dịch + fill text sẵn
|
||||||
|
chrome.tabs.sendMessage(tab.id, {
|
||||||
|
action: 'OPEN_TRANSLATE_FORM',
|
||||||
|
text: info.selectionText
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// LUÔN GỌI sendResponse(), KHÔNG BAO GIỜ return true
|
// ===== NHẬN MESSAGE TỪ CONTENT =====
|
||||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||||
if (request.action === 'GENERATE_COMMENT') {
|
if (request.action === 'GENERATE_COMMENT') {
|
||||||
handleGenerate(request.data, sender.tab.id);
|
handleGenerate(request.data, sender.tab.id);
|
||||||
sendResponse({ received: true });
|
sendResponse({ received: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (request.action === 'TRANSLATE_TEXT') {
|
||||||
|
handleTranslate(request.data, sender.tab.id);
|
||||||
|
sendResponse({ received: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
sendResponse({ unknown: true });
|
sendResponse({ unknown: true });
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== GỌI API COMMENT =====
|
||||||
async function handleGenerate({ text, lang, tone, angle }, tabId) {
|
async function handleGenerate({ text, lang, tone, angle }, tabId) {
|
||||||
const cfg = await chrome.storage.local.get(['apiUrl', 'apiKey']);
|
const cfg = await chrome.storage.local.get(['apiUrl', 'apiKey']);
|
||||||
if (!cfg.apiUrl || !cfg.apiKey) {
|
if (!cfg.apiUrl || !cfg.apiKey) {
|
||||||
@@ -38,25 +69,61 @@ async function handleGenerate({ text, lang, tone, angle }, tabId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
|
||||||
originalPost: text,
|
|
||||||
language: lang,
|
|
||||||
tone: tone?tone.toLowerCase():undefined,
|
|
||||||
angle: angle?angle.toLowerCase():undefined,
|
|
||||||
};
|
|
||||||
const res = await fetch(cfg.apiUrl, {
|
const res = await fetch(cfg.apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${cfg.apiKey}`
|
'Authorization': `Bearer ${cfg.apiKey}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify({ tweet_text: text, lang, tone, angle })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const comment = data.comment || data.text || JSON.stringify(data);
|
|
||||||
await chrome.tabs.sendMessage(tabId, { action: 'SHOW_RESULT', comment }).catch(() => {});
|
await chrome.tabs.sendMessage(tabId, {
|
||||||
|
action: 'SHOW_RESULT',
|
||||||
|
comment: data.comment || data.text || JSON.stringify(data),
|
||||||
|
model: data.model || '',
|
||||||
|
commentTransVi: data.commentTransVi || '',
|
||||||
|
commentTransModel: data.commentTransModel || '',
|
||||||
|
}).catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await chrome.tabs.sendMessage(tabId, { action: 'SHOW_ERROR', error: err.message }).catch(() => {});
|
await chrome.tabs.sendMessage(tabId, { action: 'SHOW_ERROR', error: err.message }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== GỌI API DỊCH =====
|
||||||
|
async function handleTranslate({ text, target_lang, target_model }, tabId) {
|
||||||
|
const cfg = await chrome.storage.local.get(['apiUrl','apiKey','translateUrl','translateKey']);
|
||||||
|
const url = cfg.translateUrl || cfg.apiUrl;
|
||||||
|
const key = cfg.translateKey || cfg.apiKey;
|
||||||
|
|
||||||
|
if (!url || !key) {
|
||||||
|
await chrome.tabs.sendMessage(tabId, {
|
||||||
|
action: 'SHOW_TRANSLATE_ERROR',
|
||||||
|
error: '⚠️ Chưa cấu hình API.'
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${key}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text, target_lang, target_model })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
await chrome.tabs.sendMessage(tabId, {
|
||||||
|
action: 'SHOW_TRANSLATE_RESULT',
|
||||||
|
content: data.content || data.text || data.translation || JSON.stringify(data),
|
||||||
|
model: data.model || '',
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
await chrome.tabs.sendMessage(tabId, { action: 'SHOW_TRANSLATE_ERROR', error: err.message }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
+243
-42
@@ -2,21 +2,28 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
let lastTweetText = '';
|
let lastTweetText = '';
|
||||||
|
let lastSelectedText = '';
|
||||||
let sidebarHost = null;
|
let sidebarHost = null;
|
||||||
let isTyping = false; // chặn bấm paste nhiều lần
|
let isTyping = false;
|
||||||
|
|
||||||
// ===== DATA =====
|
// ===== DATA =====
|
||||||
const LANGS = [
|
const LANGS = [
|
||||||
|
{ value: 'en', label: 'English' ,selected: true },
|
||||||
{ value: 'vi', label: 'Việt' },
|
{ value: 'vi', label: 'Việt' },
|
||||||
{ value: 'en', label: 'English' },
|
|
||||||
{ value: 'ja', label: 'Nhật' },
|
{ value: 'ja', label: 'Nhật' },
|
||||||
{ value: 'ko', label: 'Han' },
|
{ value: 'ko', label: 'Han' },
|
||||||
{ value: 'cn', label: 'TQ' }
|
{ value: 'cn', label: 'TQ' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ModelAI = [
|
||||||
|
{ value: 'openai', label: 'Chat GPT' },
|
||||||
|
{ value: 'google', label: 'Gemini' },
|
||||||
|
{ value: 'deepseek', label: 'Deep Seek' },
|
||||||
|
]
|
||||||
|
|
||||||
const TONE_BASE = [
|
const TONE_BASE = [
|
||||||
|
{ value: 'CASUAL', text: 'Giản dị, thân thiện' , selected: true },
|
||||||
{ value: 'PROFESSIONAL', text: 'chuyên nghiệp, rõ ràng, đáng tin cậy' },
|
{ value: 'PROFESSIONAL', text: 'chuyên nghiệp, rõ ràng, đáng tin cậy' },
|
||||||
{ value: 'CASUAL', text: 'Giản dị, thân thiện' },
|
|
||||||
{ value: 'HYPE', text: 'Hype — Hào hứng, tràn đầy năng lượng' },
|
{ value: 'HYPE', text: 'Hype — Hào hứng, tràn đầy năng lượng' },
|
||||||
{ value: 'URGENT', text: 'urgent' },
|
{ value: 'URGENT', text: 'urgent' },
|
||||||
{ value: 'HUMOROUS', text: 'Dí dỏm, hài hước' },
|
{ value: 'HUMOROUS', text: 'Dí dỏm, hài hước' },
|
||||||
@@ -35,6 +42,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ANGLE_DEFAULT = [
|
const ANGLE_DEFAULT = [
|
||||||
|
{ value: 'NATURAL', text: 'Phản ứng tự nhiên' },
|
||||||
{ value: 'AGREE', text: 'Đồng ý' },
|
{ value: 'AGREE', text: 'Đồng ý' },
|
||||||
{ value: 'CHALLENGE', text: 'Không đồng ý' },
|
{ value: 'CHALLENGE', text: 'Không đồng ý' },
|
||||||
{ value: 'ADD_INFO', text: 'Thêm thông tin liên quan hữu ích' },
|
{ value: 'ADD_INFO', text: 'Thêm thông tin liên quan hữu ích' },
|
||||||
@@ -63,18 +71,22 @@
|
|||||||
return tone === 'EMPATHETIC' ? [...ANGLE_DEFAULT, ...ANGLE_EMPATHY] : [...ANGLE_DEFAULT];
|
return tone === 'EMPATHETIC' ? [...ANGLE_DEFAULT, ...ANGLE_EMPATHY] : [...ANGLE_DEFAULT];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== A. CAPTURE TWEET =====
|
// ===== A. CAPTURE TWEET & SELECTED TEXT =====
|
||||||
document.addEventListener('contextmenu', (e) => {
|
document.addEventListener('contextmenu', (e) => {
|
||||||
|
const sel = window.getSelection().toString().trim();
|
||||||
|
if (sel) lastSelectedText = sel;
|
||||||
|
|
||||||
const article = e.target.closest('article');
|
const article = e.target.closest('article');
|
||||||
if (!article) { lastTweetText = ''; return; }
|
if (article) {
|
||||||
const textEl =
|
const textEl =
|
||||||
article.querySelector('[data-testid="tweetText"]') ||
|
article.querySelector('[data-testid="tweetText"]') ||
|
||||||
article.querySelector('div[lang]') ||
|
article.querySelector('div[lang]') ||
|
||||||
article.querySelector('div[dir="auto"]');
|
article.querySelector('div[dir="auto"]');
|
||||||
lastTweetText = textEl ? textEl.innerText.trim() : article.innerText.trim().slice(0, 600);
|
lastTweetText = textEl ? textEl.innerText.trim() : article.innerText.trim().slice(0, 600);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== B. LISTENER — FIX LỖI CHANNEL =====
|
// ===== B. LISTENER =====
|
||||||
chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
|
||||||
if (req.action === 'OPEN_FORM') {
|
if (req.action === 'OPEN_FORM') {
|
||||||
if (!lastTweetText) {
|
if (!lastTweetText) {
|
||||||
@@ -85,8 +97,19 @@
|
|||||||
sendResponse({ ok: true });
|
sendResponse({ ok: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (req.action === 'OPEN_TRANSLATE_FORM') {
|
||||||
|
openSidebar('');
|
||||||
|
switchTab('translate');
|
||||||
|
const s = sidebarHost?.shadowRoot;
|
||||||
|
if (s) {
|
||||||
|
const input = s.querySelector('#trans-input');
|
||||||
|
if (input) input.value = req.text || '';
|
||||||
|
}
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (req.action === 'SHOW_RESULT') {
|
if (req.action === 'SHOW_RESULT') {
|
||||||
showResult(req.comment);
|
showResult(req.comment, req.model, req.commentTransVi, req.commentTransModel);
|
||||||
sendResponse({ ok: true });
|
sendResponse({ ok: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -95,6 +118,16 @@
|
|||||||
sendResponse({ ok: true });
|
sendResponse({ ok: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (req.action === 'SHOW_TRANSLATE_RESULT') {
|
||||||
|
showTranslateResult(req.content, req.model);
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (req.action === 'SHOW_TRANSLATE_ERROR') {
|
||||||
|
showTranslateError(req.error);
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
sendResponse({ ok: false, unknown: true });
|
sendResponse({ ok: false, unknown: true });
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -152,8 +185,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== D. SIDEBAR UI =====
|
// ===== D. SIDEBAR UI =====
|
||||||
|
function switchTab(name) {
|
||||||
|
if (!sidebarHost) return;
|
||||||
|
const s = sidebarHost.shadowRoot;
|
||||||
|
s.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
|
||||||
|
s.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === `tab-${name}`));
|
||||||
|
}
|
||||||
|
|
||||||
function openSidebar(tweetText) {
|
function openSidebar(tweetText) {
|
||||||
|
// ⭐ FIX: Nếu mở comment mới (có tweetText), xóa sidebar cũ để render lại từ đầu
|
||||||
|
if (tweetText && sidebarHost) {
|
||||||
removeSidebar();
|
removeSidebar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu sidebar vẫn còn (trường hợp translate hoặc đang mở), chỉ cần mở drawer
|
||||||
|
if (sidebarHost) {
|
||||||
|
const drawer = sidebarHost.shadowRoot.querySelector('.drawer');
|
||||||
|
if (drawer) drawer.classList.add('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
host.id = 'x-ai-sidebar-host';
|
host.id = 'x-ai-sidebar-host';
|
||||||
@@ -186,11 +236,12 @@
|
|||||||
.tab-btn.active { color: #1d9bf0; border-bottom-color: #1d9bf0; }
|
.tab-btn.active { color: #1d9bf0; border-bottom-color: #1d9bf0; }
|
||||||
.tab-content { padding: 16px 18px; flex: 1; overflow-y: auto; display: none; flex-direction: column; gap: 10px; }
|
.tab-content { padding: 16px 18px; flex: 1; overflow-y: auto; display: none; flex-direction: column; gap: 10px; }
|
||||||
.tab-content.active { display: flex; }
|
.tab-content.active { display: flex; }
|
||||||
.tweet-box { background: #f7f9f9; border: 1px solid #cfd9de; border-radius: 10px; padding: 10px; font-size: 13px;
|
.tweet-box { background: #f7f9f9; border: 1px solid #cfd9de; border-radius: 10px; padding: 10px; font-size: 16px;
|
||||||
line-height: 1.4; max-height: 150px; overflow-y: auto; color: #333; white-space: pre-wrap; word-break: break-word; }
|
line-height: 1.4; max-height: 150px; overflow-y: auto; color: #333; white-space: pre-wrap; word-break: break-word; }
|
||||||
label { font-size: 12px; font-weight: 700; color: #536471; text-transform: uppercase; letter-spacing: .3px; margin-top: 4px; }
|
label { font-size: 12px; font-weight: 700; color: #536471; text-transform: uppercase; letter-spacing: .3px; margin-top: 4px; }
|
||||||
select, input[type="text"], input[type="password"] { width: 100%; padding: 9px 10px; border-radius: 8px; border: 1px solid #cfd9de;
|
select, input[type="text"], input[type="password"], textarea { width: 100%; padding: 9px 10px; border-radius: 8px; border: 1px solid #cfd9de;
|
||||||
font-size: 14px; background: #fff; box-sizing: border-box; }
|
font-size: 14px; background: #fff; box-sizing: border-box; font-family: inherit; }
|
||||||
|
textarea { resize: vertical; min-height: 80px; }
|
||||||
.hint-text { font-size: 12px; color: #888; line-height: 1.4; font-style: italic; }
|
.hint-text { font-size: 12px; color: #888; line-height: 1.4; font-style: italic; }
|
||||||
button.primary { width: 100%; padding: 10px; border-radius: 9999px; border: none; background: #1d9bf0; color: #fff;
|
button.primary { width: 100%; padding: 10px; border-radius: 9999px; border: none; background: #1d9bf0; color: #fff;
|
||||||
font-weight: 700; font-size: 15px; cursor: pointer; margin-top: 6px; }
|
font-weight: 700; font-size: 15px; cursor: pointer; margin-top: 6px; }
|
||||||
@@ -204,6 +255,7 @@
|
|||||||
white-space: pre-wrap; word-break: break-word; }
|
white-space: pre-wrap; word-break: break-word; }
|
||||||
.status.ok { background: #e8f6fe; border: 1px solid #b4dffc; }
|
.status.ok { background: #e8f6fe; border: 1px solid #b4dffc; }
|
||||||
.status.err { background: #ffeaea; border: 1px solid #ffc5c5; color: #b00; }
|
.status.err { background: #ffeaea; border: 1px solid #ffc5c5; color: #b00; }
|
||||||
|
.status.trans { background: #f0fdf4; border: 1px solid #86efac; color: #14532d; }
|
||||||
.copy-hint { font-size: 12px; color: #536471; text-align: center; display: none; }
|
.copy-hint { font-size: 12px; color: #536471; text-align: center; display: none; }
|
||||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #ffad1f; color: #fff; font-size: 11px; font-weight: 700; }
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #ffad1f; color: #fff; font-size: 11px; font-weight: 700; }
|
||||||
.typing-opts { margin-top: 8px; padding-top: 10px; border-top: 1px solid #eff3f4; display: none; flex-direction: column; gap: 8px; }
|
.typing-opts { margin-top: 8px; padding-top: 10px; border-top: 1px solid #eff3f4; display: none; flex-direction: column; gap: 8px; }
|
||||||
@@ -223,6 +275,7 @@
|
|||||||
<div class="header"><span>✍️ AI Comment</span><button class="btn-x">✕</button></div>
|
<div class="header"><span>✍️ AI Comment</span><button class="btn-x">✕</button></div>
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab-btn active" data-tab="comment">Comment</button>
|
<button class="tab-btn active" data-tab="comment">Comment</button>
|
||||||
|
<button class="tab-btn" data-tab="translate">🌐 Dịch</button>
|
||||||
<button class="tab-btn" data-tab="config">⚙️ Config <span class="badge" id="cfg-missing" style="display:none">!</span></button>
|
<button class="tab-btn" data-tab="config">⚙️ Config <span class="badge" id="cfg-missing" style="display:none">!</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -243,7 +296,8 @@
|
|||||||
<button class="primary" id="ai-run">🚀 Tạo Comment</button>
|
<button class="primary" id="ai-run">🚀 Tạo Comment</button>
|
||||||
|
|
||||||
<div class="status" id="ai-status"></div>
|
<div class="status" id="ai-status"></div>
|
||||||
<div class="copy-hint" id="ai-hint">📋 Copy kết quả và dán vào ô reply!</div>
|
<div class="status trans" id="ai-trans-vi" style="display:none;"></div>
|
||||||
|
<div class="copy-hint" id="ai-hint"></div>
|
||||||
|
|
||||||
<div class="typing-opts" id="ai-typing-opts">
|
<div class="typing-opts" id="ai-typing-opts">
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
@@ -260,14 +314,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-translate">
|
||||||
|
<div style="font-size:13px;color:#536471;margin-bottom:6px;">
|
||||||
|
Nhập văn bản và chọn ngôn ngữ cần dịch. API translate lấy từ tab Config.
|
||||||
|
</div>
|
||||||
|
<label>Văn bản cần dịch</label>
|
||||||
|
<textarea id="trans-input" style="font-size:18px" placeholder="Nhập hoặc bôi đen rồi chuột phải..."></textarea>
|
||||||
|
|
||||||
|
<div style="display: flex">
|
||||||
|
<div>
|
||||||
|
<label>Ngôn ngữ đích</label>
|
||||||
|
<select id="trans-target">${LANGS.map(l => `<option value="${l.value}" ${l.value === 'ja' ? 'selected' : ''}>${l.label}</option>`).join('')}</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Model</label>
|
||||||
|
<select id="trans-model">${ModelAI.map(l => `<option value="${l.value}" ${l.value === 'openai' ? 'selected' : ''}>${l.label}</option>`).join('')}</select>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="primary" id="trans-run">🌐 Dịch</button>
|
||||||
|
<div class="status" id="trans-status"></div>
|
||||||
|
<div class="copy-hint" id="trans-hint"></div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="primary gray" id="trans-copy">📋 Copy</button>
|
||||||
|
<button class="primary green" id="trans-paste">📥 Dán vào ô reply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-content" id="tab-config">
|
<div class="tab-content" id="tab-config">
|
||||||
<div style="font-size:13px; color:#536471; margin-bottom:6px;">
|
<div style="font-size:13px; color:#536471; margin-bottom:6px;">
|
||||||
Nhập API của bạn. Dữ liệu được lưu trên trình duyệt này.
|
Cấu hình API. Nếu Translate URL để trống, sẽ dùng chung Comment URL.
|
||||||
</div>
|
</div>
|
||||||
<label>API URL</label>
|
<label>Comment API URL</label>
|
||||||
<input type="text" id="cfg-url" placeholder="https://api.yoursite.com/v1/generate">
|
<input type="text" id="cfg-url" placeholder="https://api.yoursite.com/v1/generate">
|
||||||
<label>API Key</label>
|
<label>Comment API Key</label>
|
||||||
<input type="password" id="cfg-key" placeholder="sk-...">
|
<input type="password" id="cfg-key" placeholder="sk-...">
|
||||||
|
<label>Translate API URL (tùy chọn)</label>
|
||||||
|
<input type="text" id="cfg-trans-url" placeholder="https://api.yoursite.com/v1/translate">
|
||||||
|
<label>Translate API Key (tùy chọn)</label>
|
||||||
|
<input type="password" id="cfg-trans-key" placeholder="sk-...">
|
||||||
<div style="display:flex; gap:8px;">
|
<div style="display:flex; gap:8px;">
|
||||||
<button class="primary" id="cfg-save" style="flex:1">💾 Lưu</button>
|
<button class="primary" id="cfg-save" style="flex:1">💾 Lưu</button>
|
||||||
<button class="primary green" id="cfg-test" style="flex:1">🧪 Test đọc</button>
|
<button class="primary green" id="cfg-test" style="flex:1">🧪 Test đọc</button>
|
||||||
@@ -306,6 +393,7 @@
|
|||||||
const angleHint= drawer.querySelector('#ai-angle-hint');
|
const angleHint= drawer.querySelector('#ai-angle-hint');
|
||||||
const runBtn = drawer.querySelector('#ai-run');
|
const runBtn = drawer.querySelector('#ai-run');
|
||||||
const statusEl = drawer.querySelector('#ai-status');
|
const statusEl = drawer.querySelector('#ai-status');
|
||||||
|
const transViEl= drawer.querySelector('#ai-trans-vi');
|
||||||
const copyHint = drawer.querySelector('#ai-hint');
|
const copyHint = drawer.querySelector('#ai-hint');
|
||||||
const typingOpts = drawer.querySelector('#ai-typing-opts');
|
const typingOpts = drawer.querySelector('#ai-typing-opts');
|
||||||
const typingChk = drawer.querySelector('#ai-typing');
|
const typingChk = drawer.querySelector('#ai-typing');
|
||||||
@@ -350,7 +438,9 @@
|
|||||||
const angle = angleSel.value;
|
const angle = angleSel.value;
|
||||||
runBtn.disabled = true;
|
runBtn.disabled = true;
|
||||||
statusEl.style.display = 'block'; statusEl.className = 'status ok';
|
statusEl.style.display = 'block'; statusEl.className = 'status ok';
|
||||||
statusEl.textContent = '⏳ Đang gọi API...'; copyHint.style.display = 'none';
|
statusEl.textContent = '⏳ Đang gọi API...';
|
||||||
|
transViEl.style.display = 'none'; transViEl.textContent = '';
|
||||||
|
copyHint.style.display = 'none';
|
||||||
typingOpts.classList.remove('visible');
|
typingOpts.classList.remove('visible');
|
||||||
|
|
||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(
|
||||||
@@ -365,28 +455,18 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// COPY BUTTON
|
|
||||||
copyBtn.addEventListener('click', async () => {
|
copyBtn.addEventListener('click', async () => {
|
||||||
const text = statusEl.textContent;
|
const text = statusEl.textContent;
|
||||||
if (!text || text.startsWith('⏳') || text.startsWith('Lỗi') || text.startsWith('⚠️')) {
|
if (!text || text.startsWith('⏳') || text.startsWith('Lỗi') || text.startsWith('⚠️')) {
|
||||||
const old = copyBtn.textContent;
|
flashBtn(copyBtn, '⚠️ Chưa có nội dung!');
|
||||||
copyBtn.textContent = '⚠️ Chưa có nội dung!';
|
|
||||||
setTimeout(() => copyBtn.textContent = old, 1200);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
const old = copyBtn.textContent;
|
flashBtn(copyBtn, '✅ Đã copy!');
|
||||||
copyBtn.textContent = '✅ Đã copy!';
|
} catch (e) { flashBtn(copyBtn, '❌ Lỗi copy'); }
|
||||||
setTimeout(() => copyBtn.textContent = old, 1200);
|
|
||||||
} catch (e) {
|
|
||||||
const old = copyBtn.textContent;
|
|
||||||
copyBtn.textContent = '❌ Lỗi copy';
|
|
||||||
setTimeout(() => copyBtn.textContent = old, 1200);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PASTE BUTTON
|
|
||||||
pasteBtn.addEventListener('click', async () => {
|
pasteBtn.addEventListener('click', async () => {
|
||||||
const text = statusEl.textContent;
|
const text = statusEl.textContent;
|
||||||
if (!text || text.startsWith('⏳') || text.startsWith('Lỗi') || text.startsWith('⚠️')) {
|
if (!text || text.startsWith('⏳') || text.startsWith('Lỗi') || text.startsWith('⚠️')) {
|
||||||
@@ -394,9 +474,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isTyping) return;
|
if (isTyping) return;
|
||||||
isTyping = true;
|
isTyping = true; pasteBtn.disabled = true;
|
||||||
pasteBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (typingChk.checked) {
|
if (typingChk.checked) {
|
||||||
pasteBtn.textContent = '⌨️ Đang gõ...';
|
pasteBtn.textContent = '⌨️ Đang gõ...';
|
||||||
@@ -410,18 +488,75 @@
|
|||||||
console.error('Typing error:', err);
|
console.error('Typing error:', err);
|
||||||
alert('Lỗi khi nhập: ' + err.message);
|
alert('Lỗi khi nhập: ' + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
isTyping = false;
|
isTyping = false; pasteBtn.disabled = false;
|
||||||
pasteBtn.disabled = false;
|
|
||||||
pasteBtn.textContent = '📥 Dán vào ô reply';
|
pasteBtn.textContent = '📥 Dán vào ô reply';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== TRANSLATE TAB =====
|
||||||
|
const transInput = drawer.querySelector('#trans-input');
|
||||||
|
const transTarget= drawer.querySelector('#trans-target');
|
||||||
|
const transModel= drawer.querySelector('#trans-model');
|
||||||
|
const transRun = drawer.querySelector('#trans-run');
|
||||||
|
const transStatus= drawer.querySelector('#trans-status');
|
||||||
|
const transHint = drawer.querySelector('#trans-hint');
|
||||||
|
const transCopy = drawer.querySelector('#trans-copy');
|
||||||
|
const transPaste = drawer.querySelector('#trans-paste');
|
||||||
|
|
||||||
|
transRun.addEventListener('click', () => {
|
||||||
|
const text = transInput.value.trim();
|
||||||
|
const target = transTarget.value;
|
||||||
|
const targetModel = transModel.value;
|
||||||
|
if (!text) { flashBtn(transRun, '⚠️ Nhập văn bản!'); return; }
|
||||||
|
transRun.disabled = true;
|
||||||
|
transStatus.style.display = 'block'; transStatus.className = 'status ok';
|
||||||
|
transStatus.textContent = '⏳ Đang dịch...';
|
||||||
|
transHint.style.display = 'none';
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{ action: 'TRANSLATE_TEXT', data: { text, target_lang: target, target_model: targetModel } },
|
||||||
|
() => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
transStatus.className = 'status err';
|
||||||
|
transStatus.textContent = 'Lỗi kết nối: ' + chrome.runtime.lastError.message;
|
||||||
|
transRun.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
transCopy.addEventListener('click', async () => {
|
||||||
|
const text = transStatus.textContent;
|
||||||
|
if (!text || text.startsWith('⏳')) { flashBtn(transCopy, '⚠️ Chưa có!'); return; }
|
||||||
|
try { await navigator.clipboard.writeText(text); flashBtn(transCopy, '✅ Đã copy!'); }
|
||||||
|
catch (e) { flashBtn(transCopy, '❌ Lỗi'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
transPaste.addEventListener('click', async () => {
|
||||||
|
const text = transStatus.textContent;
|
||||||
|
if (!text || text.startsWith('⏳')) { alert('Chưa có kết quả dịch!'); return; }
|
||||||
|
if (isTyping) return;
|
||||||
|
isTyping = true; transPaste.disabled = true;
|
||||||
|
try {
|
||||||
|
transPaste.textContent = '⌨️ Đang gõ...';
|
||||||
|
const ok = await simulateHumanTyping(text);
|
||||||
|
if (ok) drawer.classList.remove('open');
|
||||||
|
} catch (err) {
|
||||||
|
alert('Lỗi khi nhập: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
isTyping = false; transPaste.disabled = false;
|
||||||
|
transPaste.textContent = '📥 Dán vào ô reply';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ===== CONFIG TAB =====
|
// ===== CONFIG TAB =====
|
||||||
const urlIn = drawer.querySelector('#cfg-url');
|
const urlIn = drawer.querySelector('#cfg-url');
|
||||||
const keyIn = drawer.querySelector('#cfg-key');
|
const keyIn = drawer.querySelector('#cfg-key');
|
||||||
|
const transUrlIn= drawer.querySelector('#cfg-trans-url');
|
||||||
|
const transKeyIn= drawer.querySelector('#cfg-trans-key');
|
||||||
const saveBtn = drawer.querySelector('#cfg-save');
|
const saveBtn = drawer.querySelector('#cfg-save');
|
||||||
const testBtn = drawer.querySelector('#cfg-test');
|
const testBtn = drawer.querySelector('#cfg-test');
|
||||||
const cfgStatus= drawer.querySelector('#cfg-status');
|
const cfgStatus = drawer.querySelector('#cfg-status');
|
||||||
const cfgBadge = drawer.querySelector('#cfg-missing');
|
const cfgBadge = drawer.querySelector('#cfg-missing');
|
||||||
|
|
||||||
function showCfgStatus(msg, isErr) {
|
function showCfgStatus(msg, isErr) {
|
||||||
@@ -429,57 +564,123 @@
|
|||||||
cfgStatus.className = 'status ' + (isErr ? 'err' : 'ok');
|
cfgStatus.className = 'status ' + (isErr ? 'err' : 'ok');
|
||||||
cfgStatus.textContent = msg;
|
cfgStatus.textContent = msg;
|
||||||
}
|
}
|
||||||
chrome.storage.local.get(['apiUrl', 'apiKey'], (cfg) => {
|
|
||||||
|
chrome.storage.local.get(['apiUrl','apiKey','translateUrl','translateKey'], (cfg) => {
|
||||||
if (cfg.apiUrl) urlIn.value = cfg.apiUrl;
|
if (cfg.apiUrl) urlIn.value = cfg.apiUrl;
|
||||||
if (cfg.apiKey) keyIn.value = cfg.apiKey;
|
if (cfg.apiKey) keyIn.value = cfg.apiKey;
|
||||||
|
if (cfg.translateUrl) transUrlIn.value = cfg.translateUrl;
|
||||||
|
if (cfg.translateKey) transKeyIn.value = cfg.translateKey;
|
||||||
cfgBadge.style.display = (!cfg.apiUrl || !cfg.apiKey) ? 'inline-block' : 'none';
|
cfgBadge.style.display = (!cfg.apiUrl || !cfg.apiKey) ? 'inline-block' : 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
saveBtn.addEventListener('click', () => {
|
saveBtn.addEventListener('click', () => {
|
||||||
const url = urlIn.value.trim(), key = keyIn.value.trim();
|
const url = urlIn.value.trim(), key = keyIn.value.trim();
|
||||||
if (!url || !key) { showCfgStatus('❌ URL và Key không được để trống!', true); return; }
|
if (!url || !key) { showCfgStatus('❌ Comment URL và Key không được để trống!', true); return; }
|
||||||
try { new URL(url); } catch { showCfgStatus('❌ URL không hợp lệ', true); return; }
|
try { new URL(url); } catch { showCfgStatus('❌ URL không hợp lệ', true); return; }
|
||||||
chrome.storage.local.set({ apiUrl: url, apiKey: key }, () => {
|
const tUrl = transUrlIn.value.trim();
|
||||||
|
const tKey = transKeyIn.value.trim();
|
||||||
|
chrome.storage.local.set({
|
||||||
|
apiUrl: url, apiKey: key,
|
||||||
|
translateUrl: tUrl, translateKey: tKey
|
||||||
|
}, () => {
|
||||||
showCfgStatus('✅ Đã lưu!', false);
|
showCfgStatus('✅ Đã lưu!', false);
|
||||||
cfgBadge.style.display = 'none';
|
cfgBadge.style.display = 'none';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testBtn.addEventListener('click', () => {
|
testBtn.addEventListener('click', () => {
|
||||||
chrome.storage.local.get(['apiUrl', 'apiKey'], (cfg) => {
|
chrome.storage.local.get(['apiUrl','apiKey','translateUrl','translateKey'], (cfg) => {
|
||||||
showCfgStatus(`URL: ${cfg.apiUrl || '(trống)'}\nKey: ${cfg.apiKey ? cfg.apiKey.slice(0,8)+'...' : '(trống)'}`, !cfg.apiUrl || !cfg.apiKey);
|
const tUrl = cfg.translateUrl || cfg.apiUrl || '(trống)';
|
||||||
|
showCfgStatus(
|
||||||
|
`Comment: ${cfg.apiUrl || '(trống)'}\n` +
|
||||||
|
`Translate: ${tUrl}\n` +
|
||||||
|
`Key: ${cfg.apiKey ? cfg.apiKey.slice(0,8)+'...' : '(trống)'}`,
|
||||||
|
!cfg.apiUrl || !cfg.apiKey
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== SHOW RESULT / ERROR =====
|
// ===== SHOW RESULT / ERROR =====
|
||||||
function showResult(comment) {
|
function showResult(comment, model='', transVi='', transByModal = '') {
|
||||||
if (!sidebarHost) return;
|
if (!sidebarHost) return;
|
||||||
const s = sidebarHost.shadowRoot;
|
const s = sidebarHost.shadowRoot;
|
||||||
const statusEl = s.querySelector('#ai-status');
|
const statusEl = s.querySelector('#ai-status');
|
||||||
|
const transViEl= s.querySelector('#ai-trans-vi');
|
||||||
const copyHint = s.querySelector('#ai-hint');
|
const copyHint = s.querySelector('#ai-hint');
|
||||||
const runBtn = s.querySelector('#ai-run');
|
const runBtn = s.querySelector('#ai-run');
|
||||||
const drawer = s.querySelector('.drawer');
|
const drawer = s.querySelector('.drawer');
|
||||||
const typingOpts = s.querySelector('#ai-typing-opts');
|
const typingOpts = s.querySelector('#ai-typing-opts');
|
||||||
|
|
||||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'status ok'; statusEl.textContent = comment; }
|
console.log({model});
|
||||||
if (copyHint) copyHint.style.display = 'block';
|
if (statusEl) {
|
||||||
|
statusEl.style.display = 'block';
|
||||||
|
statusEl.className = 'status ok';
|
||||||
|
statusEl.textContent = comment;
|
||||||
|
}
|
||||||
|
if (transViEl) {
|
||||||
|
if (transVi) {
|
||||||
|
transViEl.style.display = 'block';
|
||||||
|
transViEl.textContent = '🇻🇳 ' + transVi;
|
||||||
|
} else {
|
||||||
|
transViEl.style.display = 'none';
|
||||||
|
transViEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (copyHint) { copyHint.style.display = 'block'; copyHint.textContent = `W:${model} - T:${transByModal}` || '📋 Copy kết quả và dán vào ô reply!'; }
|
||||||
if (runBtn) runBtn.disabled = false;
|
if (runBtn) runBtn.disabled = false;
|
||||||
if (drawer) drawer.classList.add('open');
|
if (drawer) drawer.classList.add('open');
|
||||||
if (typingOpts) typingOpts.classList.add('visible');
|
if (typingOpts) typingOpts.classList.add('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(msg) {
|
function showError(msg) {
|
||||||
if (!sidebarHost) return;
|
if (!sidebarHost) return;
|
||||||
const s = sidebarHost.shadowRoot;
|
const s = sidebarHost.shadowRoot;
|
||||||
const statusEl = s.querySelector('#ai-status');
|
const statusEl = s.querySelector('#ai-status');
|
||||||
|
const transViEl= s.querySelector('#ai-trans-vi');
|
||||||
const runBtn = s.querySelector('#ai-run');
|
const runBtn = s.querySelector('#ai-run');
|
||||||
const drawer = s.querySelector('.drawer');
|
const drawer = s.querySelector('.drawer');
|
||||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'status err'; statusEl.textContent = msg; }
|
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'status err'; statusEl.textContent = msg; }
|
||||||
|
if (transViEl) { transViEl.style.display = 'none'; transViEl.textContent = ''; }
|
||||||
if (runBtn) runBtn.disabled = false;
|
if (runBtn) runBtn.disabled = false;
|
||||||
if (drawer) drawer.classList.add('open');
|
if (drawer) drawer.classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showTranslateResult(content, model = '') {
|
||||||
|
if (!sidebarHost) return;
|
||||||
|
const s = sidebarHost.shadowRoot;
|
||||||
|
const status = s.querySelector('#trans-status');
|
||||||
|
const hint = s.querySelector('#trans-hint');
|
||||||
|
const runBtn = s.querySelector('#trans-run');
|
||||||
|
const drawer = s.querySelector('.drawer');
|
||||||
|
if (status) { status.style.display = 'block'; status.className = 'status ok'; status.textContent = content; }
|
||||||
|
if (hint) {
|
||||||
|
hint.style.display = 'block';hint.textContent = model
|
||||||
|
}
|
||||||
|
if (runBtn) runBtn.disabled = false;
|
||||||
|
if (drawer) drawer.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTranslateError(msg) {
|
||||||
|
if (!sidebarHost) return;
|
||||||
|
const s = sidebarHost.shadowRoot;
|
||||||
|
const status = s.querySelector('#trans-status');
|
||||||
|
const runBtn = s.querySelector('#trans-run');
|
||||||
|
if (status) { status.style.display = 'block'; status.className = 'status err'; status.textContent = msg; }
|
||||||
|
if (runBtn) runBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
function removeSidebar() {
|
function removeSidebar() {
|
||||||
if (sidebarHost) { sidebarHost.remove(); sidebarHost = null; }
|
if (sidebarHost) { sidebarHost.remove(); sidebarHost = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const d = document.createElement('div'); d.textContent = text; return d.innerHTML;
|
const d = document.createElement('div'); d.textContent = text; return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flashBtn(btn, msg) {
|
||||||
|
const old = btn.textContent;
|
||||||
|
btn.textContent = msg;
|
||||||
|
setTimeout(() => btn.textContent = old, 1200);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
+4
-1
@@ -5,6 +5,9 @@ export function rand(min, max) {
|
|||||||
export function getTweetIdFromUrl(xUrl: string) {
|
export function getTweetIdFromUrl(xUrl: string) {
|
||||||
xUrl = xUrl.replace('twitter.com', 'x.com').split('?')[0];
|
xUrl = xUrl.replace('twitter.com', 'x.com').split('?')[0];
|
||||||
const match = xUrl.match(/status\/(\d+)/);
|
const match = xUrl.match(/status\/(\d+)/);
|
||||||
if (!match) throw new Error('URL X không hợp lệ');
|
if (!match) throw new Error('getTweetIdFromUrl: URL X không hợp lệ');
|
||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
|
export const _toNumber = (value) => {
|
||||||
|
return 1 * value;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import {Injectable, Logger} from "@nestjs/common";
|
import {Injectable, Logger} from "@nestjs/common";
|
||||||
import {SqsPostService} from "./sqs.post.service";
|
import {SqsPostService} from "./sqs.post.service";
|
||||||
import {SUPPORT_SOCIAL_PROVIDERS, XPosterRouterService, XStrategy} from "../x-poster/x-poster.router.service";
|
import {SUPPORT_SOCIAL_PROVIDERS, XPosterRouterService, XStrategy} from "../x-poster/x-poster.router.service";
|
||||||
import {rand} from "../helper";
|
import {getTweetIdFromUrl, rand} from "../helper";
|
||||||
import {FacebookApi} from "../x-poster/facebook.api";
|
import {FacebookApi} from "../x-poster/facebook.api";
|
||||||
import {NotifyService} from "../notify.service";
|
import {NotifyService} from "../notify.service";
|
||||||
import {XCacheService} from "../x-cache/x-cache.service";
|
import {XCacheService} from "../x-cache/x-cache.service";
|
||||||
@@ -68,8 +68,8 @@ export class SqsPosterWorker {
|
|||||||
|
|
||||||
private async process(data: any) {
|
private async process(data: any) {
|
||||||
this.logger.log('📩 Got job:', data);
|
this.logger.log('📩 Got job:', data);
|
||||||
const {type, content, xSubmitProvider, tweetUrl, publishTo, tweetId, telegramChatId} = data;
|
const {type, job_type, content, xSubmitProvider, tweetUrl, publishTo, tweetId, telegramChatId} = data;
|
||||||
switch (type) {
|
switch (type || job_type) {
|
||||||
case 'X_POSTER_TWEET': {
|
case 'X_POSTER_TWEET': {
|
||||||
await this.doPostTweet(
|
await this.doPostTweet(
|
||||||
content,
|
content,
|
||||||
@@ -87,6 +87,15 @@ export class SqsPosterWorker {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'X_POSTER_REPLY_VIA_AUTO': {
|
||||||
|
await this.doReplyTweet(
|
||||||
|
content,
|
||||||
|
tweetUrl,
|
||||||
|
tweetId,
|
||||||
|
xSubmitProvider
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'X_POSTER_QUOTE': {
|
case 'X_POSTER_QUOTE': {
|
||||||
await this.doQuoteTweet(
|
await this.doQuoteTweet(
|
||||||
content,
|
content,
|
||||||
@@ -123,9 +132,14 @@ export class SqsPosterWorker {
|
|||||||
let sendSuccess = false;
|
let sendSuccess = false;
|
||||||
if (publishTo.includes(SUPPORT_SOCIAL_PROVIDERS.FB)) {
|
if (publishTo.includes(SUPPORT_SOCIAL_PROVIDERS.FB)) {
|
||||||
this.logger.log(`==> doPostTweet publish to fb`);
|
this.logger.log(`==> doPostTweet publish to fb`);
|
||||||
await this.facebookApi.postToPage(text, '', false);
|
await this.facebookApi.postToPage(text, '').then(async () => {
|
||||||
await this.notifyService.sendMessageToTele(`Post to FB success`);
|
await this.notifyService.sendMessageToTele(`Post to FB success`);
|
||||||
sendSuccess = true;
|
sendSuccess = true;
|
||||||
|
}).catch(async err => {
|
||||||
|
this.logger.error(err);
|
||||||
|
await this.notifyService.sendMessageToTele('FB:' + err.message);
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
if (publishTo.includes(SUPPORT_SOCIAL_PROVIDERS.X)) {
|
if (publishTo.includes(SUPPORT_SOCIAL_PROVIDERS.X)) {
|
||||||
this.logger.log(`==> doPostTweet publish to X`);
|
this.logger.log(`==> doPostTweet publish to X`);
|
||||||
@@ -154,11 +168,14 @@ export class SqsPosterWorker {
|
|||||||
private async doReplyTweet(
|
private async doReplyTweet(
|
||||||
text: string,
|
text: string,
|
||||||
tweetUrl: string,
|
tweetUrl: string,
|
||||||
tweetId: string,
|
tweetId?: string,
|
||||||
strategy: string = XStrategy.BROWSER_COOKIE
|
strategy: string = XStrategy.BROWSER_COOKIE
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
this.logger.log('doReplyTweet');
|
this.logger.log('doReplyTweet');
|
||||||
|
if (!tweetId) {
|
||||||
|
tweetId = getTweetIdFromUrl(tweetUrl)
|
||||||
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const r = await this.xRouterService.postReply({text, tweetUrl, tweetId, strategy});
|
const r = await this.xRouterService.postReply({text, tweetUrl, tweetId, strategy});
|
||||||
if (r.success) {
|
if (r.success) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Inject, Injectable} from "@nestjs/common";
|
import {Inject, Injectable} from "@nestjs/common";
|
||||||
import {Cache, CACHE_MANAGER} from "@nestjs/cache-manager";
|
import {Cache, CACHE_MANAGER} from "@nestjs/cache-manager";
|
||||||
|
import {_toNumber} from "../helper";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class XCacheService {
|
export class XCacheService {
|
||||||
@@ -22,7 +23,7 @@ export class XCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setCacheTwRefreshToken(refreshToken: string) {
|
async setCacheTwRefreshToken(refreshToken: string) {
|
||||||
await this.cacheManager.set('tw_app_refresh_token', refreshToken, 30 * 24 * 3600);
|
await this.setCachedKey('tw_app_refresh_token', refreshToken, 30 * 24 * 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCacheTwRefreshToken() {
|
async getCacheTwRefreshToken() {
|
||||||
@@ -61,20 +62,31 @@ export class XCacheService {
|
|||||||
async changeStateXCookiesIsAlive() {
|
async changeStateXCookiesIsAlive() {
|
||||||
const cacheKey = 'state_xcookie_status';
|
const cacheKey = 'state_xcookie_status';
|
||||||
const currentState = await this.isXCookiesAlive();
|
const currentState = await this.isXCookiesAlive();
|
||||||
return this.cacheManager.set(cacheKey, currentState ? 0 : 1, 365 * 24 * 3600);
|
return this.setCachedKey(cacheKey, currentState ? 0 : 1, 365 * 24 * 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setStateXCookiesIsDie() {
|
async setStateXCookiesIsDie() {
|
||||||
const cacheKey = 'state_xcookie_status';
|
const cacheKey = 'state_xcookie_status';
|
||||||
return this.cacheManager.set(cacheKey, 0, 365 * 24 * 3600);
|
return this.setCachedKey(cacheKey, 0, 3 * 24 * 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setStateXCookiesIsSillALive() {
|
async setStateXCookiesIsSillALive() {
|
||||||
const cacheKey = 'state_xcookie_status';
|
const cacheKey = 'state_xcookie_status';
|
||||||
return this.cacheManager.set(cacheKey, 1, 365 * 24 * 3600);
|
return this.setCachedKey(cacheKey, 1, 3 * 24 * 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
async isXCookiesAlive() {
|
async isXCookiesAlive() {
|
||||||
const cacheKey = 'state_xcookie_status';
|
const cacheKey = 'state_xcookie_status';
|
||||||
return 1 === Number(await this.cacheManager.get(cacheKey));
|
return 1 === Number(await this.cacheManager.get(cacheKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setDoneTweetComment(tweetId) {
|
||||||
|
await this.setCachedKey(`cmt_tweetId:${tweetId}`, 1, 30 * 24 * 3600);
|
||||||
|
return {tweetId};
|
||||||
|
}
|
||||||
|
|
||||||
|
async isDoneTweetComment(tweetId) {
|
||||||
|
const v = await this.getCachedData(`cmt_tweetId:${tweetId}`);
|
||||||
|
return 1 === _toNumber(v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// src/modules/social/facebook-api.service.ts
|
// src/modules/social/facebook-api.service.ts
|
||||||
import {HttpException, HttpStatus, Injectable, Logger} from '@nestjs/common';
|
import {HttpException, HttpStatus, Injectable, Logger} from '@nestjs/common';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import * as Http from "node:http";
|
||||||
|
import {NotifyService} from "../notify.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FacebookApi {
|
export class FacebookApi {
|
||||||
@@ -9,6 +11,11 @@ export class FacebookApi {
|
|||||||
private readonly pageAccessToken = process.env.FB_PAGE_ACCESS_TOKEN;
|
private readonly pageAccessToken = process.env.FB_PAGE_ACCESS_TOKEN;
|
||||||
private readonly pageId = process.env.FB_PAGE_ID;
|
private readonly pageId = process.env.FB_PAGE_ID;
|
||||||
|
|
||||||
|
constructor(private notifyService: NotifyService) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async postToPage(content: string, imageUrl?: string, throwEx = true) {
|
async postToPage(content: string, imageUrl?: string, throwEx = true) {
|
||||||
// console.log('postToPage==>', content, imageUrl);
|
// console.log('postToPage==>', content, imageUrl);
|
||||||
try {
|
try {
|
||||||
@@ -35,15 +42,19 @@ export class FacebookApi {
|
|||||||
this.logger.error('Lỗi khi đăng bài lên FB');
|
this.logger.error('Lỗi khi đăng bài lên FB');
|
||||||
this.logger.error(error.message);
|
this.logger.error(error.message);
|
||||||
// Kiểm tra xem Facebook có trả về response lỗi không
|
// Kiểm tra xem Facebook có trả về response lỗi không
|
||||||
let fbErrormessage = error.message;;
|
let fbErrormessage = 'FB:' + error.message;
|
||||||
|
// this.logger.error(error);
|
||||||
if (error.response && error.response.data) {
|
if (error.response && error.response.data) {
|
||||||
const fbError = error.response.data.error;
|
const fbError = error.response.data.error;
|
||||||
fbErrormessage = fbError.constructor
|
fbErrormessage = fbError.constructor
|
||||||
this.logger.error('--- LỖI FACEBOOK API ---');
|
this.logger.error('--- LỖI FACEBOOK API ---');
|
||||||
this.logger.error('Message:', fbErrormessage);
|
this.logger.error('Message:', fbErrormessage);
|
||||||
this.logger.error('Code:', fbError.code);
|
this.logger.error('Code:', fbError.code);
|
||||||
|
this.logger.error('Message:', fbError.message);
|
||||||
|
fbErrormessage += '\n\n' + fbError.message;
|
||||||
this.logger.error('Subcode:', fbError.error_subcode);
|
this.logger.error('Subcode:', fbError.error_subcode);
|
||||||
this.logger.error('FB Trace ID:', fbError.fbtrace_id);
|
this.logger.error('FB Trace ID:', fbError.fbtrace_id);
|
||||||
|
await this.notifyService.sendUrgentMessageToTele(fbErrormessage);
|
||||||
} else {
|
} else {
|
||||||
// Lỗi do mạng hoặc cấu hình Axios sai
|
// Lỗi do mạng hoặc cấu hình Axios sai
|
||||||
this.logger.error('Lỗi hệ thống/mạng:', error.message);
|
this.logger.error('Lỗi hệ thống/mạng:', error.message);
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.logger.debug('isCookieLive?');
|
this.logger.debug('isCookieLive?');
|
||||||
if (page.url().includes('/home')) {
|
if (page.url().includes('/home')) {
|
||||||
this.logger.log('Cookies live');
|
this.logger.log('Cookies live');
|
||||||
// await this.xCacheService.setStateXCookiesIsSillALive();
|
await this.xCacheService.setStateXCookiesIsSillALive();
|
||||||
if (sendNotiWhenAlive) {
|
if (sendNotiWhenAlive) {
|
||||||
// await this.notifyService.sendMessageToTele('✅Verify cookie pass')
|
// await this.notifyService.sendMessageToTele('✅Verify cookie pass')
|
||||||
}
|
}
|
||||||
@@ -198,20 +198,6 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return isLoggedIn;
|
return isLoggedIn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// async actVerifyCookie(): Promise<any> {
|
|
||||||
// this.logger.debug('==> actVerifyCookie');
|
|
||||||
// // const isAlive = await this.cookieSvc.verifyCookie();
|
|
||||||
// const isAlive = await this.verifyCookie();
|
|
||||||
// if (!isAlive) {
|
|
||||||
// await this.xCacheService.setStateXCookiesIsDie();
|
|
||||||
// await this.notifyService.sendUrgentMessageToTele('❌Cookie đã hết hạn vui lòng cập nhập để sử dụng.')
|
|
||||||
// }
|
|
||||||
// await this.xCacheService.setStateXCookiesIsSillALive();
|
|
||||||
// await this.notifyService.sendMessageToTele('✅Verify cookie pass')
|
|
||||||
//
|
|
||||||
// return isAlive;
|
|
||||||
// }
|
|
||||||
|
|
||||||
async likeTweet(tweetUrl: string) {
|
async likeTweet(tweetUrl: string) {
|
||||||
let page: Page | null = null;
|
let page: Page | null = null;
|
||||||
|
|
||||||
@@ -330,6 +316,7 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// const composer = page.locator('a[href="/compose/post"]').first();
|
// const composer = page.locator('a[href="/compose/post"]').first();
|
||||||
// await page.waitForTimeout(2000 + (Math.random()+Math.random()) * 3000);
|
// await page.waitForTimeout(2000 + (Math.random()+Math.random()) * 3000);
|
||||||
// await composer.click();
|
// await composer.click();
|
||||||
|
this.logger.debug('Bắt đầu nhập tweet ...');
|
||||||
|
|
||||||
const textarea = page.locator('div[data-testid="tweetTextarea_0"]');
|
const textarea = page.locator('div[data-testid="tweetTextarea_0"]');
|
||||||
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
@@ -594,8 +581,6 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await page?.close().catch(() => null);
|
await page?.close().catch(() => null);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupStaleContexts() {
|
private async cleanupStaleContexts() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {XCookieService} from "./x-cookie.service";
|
|||||||
import {XApiService} from "./x-api.service";
|
import {XApiService} from "./x-api.service";
|
||||||
import {XCacheService} from "../x-cache/x-cache.service";
|
import {XCacheService} from "../x-cache/x-cache.service";
|
||||||
import {XBrowserService} from "./x-browser.service";
|
import {XBrowserService} from "./x-browser.service";
|
||||||
|
import {XPwService} from "./x.pw.service";
|
||||||
|
|
||||||
@Controller('')
|
@Controller('')
|
||||||
export class XPosterController {
|
export class XPosterController {
|
||||||
@@ -14,10 +15,25 @@ export class XPosterController {
|
|||||||
private readonly xCookieService: XCookieService,
|
private readonly xCookieService: XCookieService,
|
||||||
private readonly xBrowserService: XBrowserService,
|
private readonly xBrowserService: XBrowserService,
|
||||||
private readonly xCacheService: XCacheService,
|
private readonly xCacheService: XCacheService,
|
||||||
private readonly xApiService: XApiService
|
private readonly xApiService: XApiService,
|
||||||
|
private readonly xPwService: XPwService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('x_view')
|
||||||
|
async twitterViewPage(@Query('url') url: string,) {
|
||||||
|
const targetUrl = url || 'https://x.com';
|
||||||
|
|
||||||
|
// Gọi dịch vụ chạy profile
|
||||||
|
const title = await this.xPwService.runWithProfile(targetUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Đã chạy xong kịch bản cho trang web`,
|
||||||
|
pageTitle: title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Get('tw_callback')
|
@Get('tw_callback')
|
||||||
async twitterAuthCallback(
|
async twitterAuthCallback(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {XPosterRouterService} from "./x-poster.router.service";
|
|||||||
import {XCookieService} from "./x-cookie.service";
|
import {XCookieService} from "./x-cookie.service";
|
||||||
import {XCacheService} from "../x-cache/x-cache.service";
|
import {XCacheService} from "../x-cache/x-cache.service";
|
||||||
import {NotifyService} from "../notify.service";
|
import {NotifyService} from "../notify.service";
|
||||||
|
import {XPwService} from "./x.pw.service";
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -18,6 +19,7 @@ import {NotifyService} from "../notify.service";
|
|||||||
XCacheService,
|
XCacheService,
|
||||||
XPosterRouterService,
|
XPosterRouterService,
|
||||||
NotifyService,
|
NotifyService,
|
||||||
|
XPwService
|
||||||
],
|
],
|
||||||
controllers: [XPosterController],
|
controllers: [XPosterController],
|
||||||
exports: [XApiService, XBrowserService, XPosterRouterService],
|
exports: [XApiService, XBrowserService, XPosterRouterService],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {XCookieService} from "./x-cookie.service";
|
|||||||
import {NotifyService} from "../notify.service";
|
import {NotifyService} from "../notify.service";
|
||||||
import {getAccount} from "./utils/x-headers.util";
|
import {getAccount} from "./utils/x-headers.util";
|
||||||
import {XCacheService} from "../x-cache/x-cache.service";
|
import {XCacheService} from "../x-cache/x-cache.service";
|
||||||
|
import {XPwService} from "./x.pw.service";
|
||||||
|
|
||||||
export enum SUPPORT_SOCIAL_PROVIDERS {
|
export enum SUPPORT_SOCIAL_PROVIDERS {
|
||||||
FB = 'fb',
|
FB = 'fb',
|
||||||
@@ -51,6 +52,7 @@ export class XPosterRouterService {
|
|||||||
private readonly apiSvc: XApiService,
|
private readonly apiSvc: XApiService,
|
||||||
private readonly cookieSvc: XCookieService,
|
private readonly cookieSvc: XCookieService,
|
||||||
private readonly browserSvc: XBrowserService,
|
private readonly browserSvc: XBrowserService,
|
||||||
|
private readonly browserProfileSvc: XPwService,
|
||||||
private readonly notifyService: NotifyService,
|
private readonly notifyService: NotifyService,
|
||||||
private readonly xCacheService: XCacheService,
|
private readonly xCacheService: XCacheService,
|
||||||
) {
|
) {
|
||||||
@@ -63,7 +65,7 @@ export class XPosterRouterService {
|
|||||||
async verifyCookie(): Promise<any> {
|
async verifyCookie(): Promise<any> {
|
||||||
this.logger.debug('==> Verify Cookie');
|
this.logger.debug('==> Verify Cookie');
|
||||||
// const isAlive = await this.cookieSvc.verifyCookie();
|
// const isAlive = await this.cookieSvc.verifyCookie();
|
||||||
const isAlive = await this.browserSvc.verifyCookie();
|
const isAlive = await this.browserProfileSvc.verifyCookie();
|
||||||
if (!isAlive) {
|
if (!isAlive) {
|
||||||
await this.xCacheService.setStateXCookiesIsDie();
|
await this.xCacheService.setStateXCookiesIsDie();
|
||||||
await this.notifyService.sendUrgentMessageToTele('❌Cookie đã hết hạn vui lòng cập nhập để sử dụng.')
|
await this.notifyService.sendUrgentMessageToTele('❌Cookie đã hết hạn vui lòng cập nhập để sử dụng.')
|
||||||
@@ -301,7 +303,8 @@ export class XPosterRouterService {
|
|||||||
// return await this.cookieSvc.createTweet(account.cookie, text);
|
// return await this.cookieSvc.createTweet(account.cookie, text);
|
||||||
}
|
}
|
||||||
if (method === 'browser' && account.browser) {
|
if (method === 'browser' && account.browser) {
|
||||||
return await this.browserSvc.postTweet(account.browser, text);
|
// return await this.browserSvc.postTweet(account.browser, text);
|
||||||
|
return await this.browserProfileSvc.postTweet(account.browser, text);
|
||||||
}
|
}
|
||||||
return {success: false, error: `Method ${method} not configured`};
|
return {success: false, error: `Method ${method} not configured`};
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -345,11 +348,16 @@ export class XPosterRouterService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (method === 'browser' && account.browser) {
|
if (method === 'browser' && account.browser) {
|
||||||
return await this.browserSvc.postReply(
|
return await this.browserProfileSvc.postReply(
|
||||||
account.browser,
|
account.browser,
|
||||||
params.tweetUrl,
|
params.tweetUrl,
|
||||||
params.text
|
params.text
|
||||||
);
|
);
|
||||||
|
// return await this.browserSvc.postReply(
|
||||||
|
// account.browser,
|
||||||
|
// params.tweetUrl,
|
||||||
|
// params.text
|
||||||
|
// );
|
||||||
}
|
}
|
||||||
return {success: false, error: `Method ${method} not configured`};
|
return {success: false, error: `Method ${method} not configured`};
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -390,11 +398,16 @@ export class XPosterRouterService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (method === 'browser' && account.browser) {
|
if (method === 'browser' && account.browser) {
|
||||||
return await this.browserSvc.postQuote(
|
return await this.browserProfileSvc.postQuote(
|
||||||
account.browser,
|
account.browser,
|
||||||
params.tweetUrl!,
|
params.tweetUrl!,
|
||||||
params.text
|
params.text
|
||||||
);
|
);
|
||||||
|
// return await this.browserSvc.postQuote(
|
||||||
|
// account.browser,
|
||||||
|
// params.tweetUrl!,
|
||||||
|
// params.text
|
||||||
|
// );
|
||||||
}
|
}
|
||||||
return {success: false, error: `Method ${method} not configured`};
|
return {success: false, error: `Method ${method} not configured`};
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
import {HttpException, Injectable, Logger, OnModuleDestroy} from "@nestjs/common";
|
||||||
|
import {BrowserContext, chromium, Page} from "playwright";
|
||||||
|
import * as path from 'path';
|
||||||
|
import {_toNumber, rand} from "../helper";
|
||||||
|
import {getAccount} from "./utils/x-headers.util";
|
||||||
|
import {BrowserAccount, BrowserTweetResult} from "./x-browser.service";
|
||||||
|
import {XCacheService} from "../x-cache/x-cache.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class XPwService implements OnModuleDestroy {
|
||||||
|
private context: BrowserContext | null = null;
|
||||||
|
private readonly logger = new Logger("XPwService");
|
||||||
|
constructor(
|
||||||
|
private readonly xCacheService: XCacheService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
async runWithProfile(targetUrl?: string): Promise<Page> {
|
||||||
|
// 1. Định nghĩa đường dẫn lưu trữ Profile (nằm trong thư mục dự án)
|
||||||
|
const profilePath = path.resolve(__dirname, `../../x_profile_data/${process.env.X_USERNAME}`);
|
||||||
|
const width = _toNumber(process.env.BROWSER_WP_WIDTH || 1479);
|
||||||
|
const height = _toNumber(process.env.BROWSER_WP_HEIGHT || 795);
|
||||||
|
// 2. Cấu hình các tham số ẩn danh (Antidetect)
|
||||||
|
const antidetectArgs = [
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
// '--no-sandbox',
|
||||||
|
'--disable-infobars',
|
||||||
|
'--start-maximized',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
// '--disable-setuid-sandbox'
|
||||||
|
`--user-agent=${process.env.BROWSER_USER_AGENT}`,
|
||||||
|
`--window-size=${width},${height}`
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3. Khởi chạy Persistent Context nếu chưa được khởi tạo
|
||||||
|
if (!this.context || this.context.isClosed()) {
|
||||||
|
this.context = await chromium.launchPersistentContext(profilePath, {
|
||||||
|
// channel: 'chrome', // Sử dụng Google Chrome thật
|
||||||
|
headless: false, // Để False để xem giao diện và đăng nhập lần đầu
|
||||||
|
args: antidetectArgs,
|
||||||
|
ignoreDefaultArgs: ['--enable-automation', '--disable-extensions'],
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// THÊM DÒNG NÀY ĐỂ ÉP CHROME BẬT LẠI SANDBOX
|
||||||
|
// ==========================================
|
||||||
|
chromiumSandbox: true,
|
||||||
|
viewport: {
|
||||||
|
width: width,
|
||||||
|
height: height-100,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Lấy ra Tab (Page) đầu tiên có sẵn
|
||||||
|
const pages = this.context.pages();
|
||||||
|
const page: Page = pages.length > 0 ? pages[0] : await this.context.newPage();
|
||||||
|
|
||||||
|
// 5. Giả lập User-Agent giống người dùng thật
|
||||||
|
await page.setExtraHTTPHeaders({
|
||||||
|
'User-Agent': `${process.env.BROWSER_USER_AGENT}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return page;
|
||||||
|
// // 6. Điều hướng tới trang web mục tiêu
|
||||||
|
// console.log(`Đang truy cập: ${targetUrl}`);
|
||||||
|
// await page.goto(targetUrl, {waitUntil: 'domcontentloaded'});
|
||||||
|
//
|
||||||
|
// // Lấy tiêu đề trang để kiểm tra xem đã chạy thành công chưa
|
||||||
|
// const pageTitle = await page.title();
|
||||||
|
//
|
||||||
|
// // Lưu ý: Không đóng context ở đây nếu bạn muốn giữ phiên chạy tiếp theo nhanh hơn.
|
||||||
|
// // Nếu muốn đóng ngay sau khi xong việc, dùng: await this.context.close(); this.context = null;
|
||||||
|
//
|
||||||
|
// return pageTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async newPage(): Promise<Page> {
|
||||||
|
const account = getAccount();
|
||||||
|
return this.getPage(account.browser)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPage(account: BrowserAccount): Promise<Page> {
|
||||||
|
return this.runWithProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyCookie(sendNotiWhenAlive = false): Promise<boolean> {
|
||||||
|
const page = await this.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto('https://x.com/', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
|
await page.mouse.wheel(0, rand(300, 500));
|
||||||
|
return await this.isCookieLive(page, sendNotiWhenAlive);
|
||||||
|
|
||||||
|
} catch (er) {
|
||||||
|
this.logger.error(`Browser verify cookie fail: ${er.message}`);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await page?.close().catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isCookieLive(page: Page, sendNotiWhenAlive = false): Promise<boolean> {
|
||||||
|
this.logger.debug('isCookieLive?');
|
||||||
|
if (page.url().includes('/home')) {
|
||||||
|
this.logger.log('Cookies live');
|
||||||
|
await this.xCacheService.setStateXCookiesIsSillALive();
|
||||||
|
if (sendNotiWhenAlive) {
|
||||||
|
// await this.notifyService.sendMessageToTele('✅Verify cookie pass')
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const isLoggedIn = await page
|
||||||
|
.locator('[data-testid="SideNav_AccountSwitcher_Button"], [data-testid="AppTabBar_Home_Link"]')
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
this.logger.log(`🔐 Session restore: ${isLoggedIn ? 'LOGGED IN' : 'GUEST (cookie có thể expired)'}`);
|
||||||
|
// if (isLoggedIn) {
|
||||||
|
// await this.xCacheService.setStateXCookiesIsSillALive();
|
||||||
|
// if (sendNotiWhenAlive) {
|
||||||
|
// await this.notifyService.sendMessageToTele('✅Verify cookie pass')
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// await this.xCacheService.setStateXCookiesIsDie();
|
||||||
|
// await this.notifyService.sendUrgentMessageToTele('❌Cookie đã hết hạn vui lòng cập nhập để sử dụng.')
|
||||||
|
// }
|
||||||
|
return isLoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async likeTweet(tweetUrl: string) {
|
||||||
|
let page: Page | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
page = await this.newPage();
|
||||||
|
|
||||||
|
await page.goto(tweetUrl, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await this.actLikeTweet(page);
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async actLikeTweet(page: Page, isCloseAfterEnd = false) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.debug('actLikeTweet:');
|
||||||
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
|
|
||||||
|
// 1. Cuộn xuống 1000 pixel
|
||||||
|
await page.mouse.wheel(0, rand(300, 500));
|
||||||
|
await page.waitForTimeout(rand(2000, 4000));
|
||||||
|
await page.mouse.wheel(0, rand(300, 500));
|
||||||
|
this.logger.debug('actLikeTweet:Đã cuộn xuống');
|
||||||
|
|
||||||
|
// Nghỉ 2 giây để quan sát
|
||||||
|
await page.waitForTimeout(rand(2000, 4000));
|
||||||
|
|
||||||
|
// 2. Cuộn ngược lên lại 500 pixel
|
||||||
|
await page.mouse.wheel(0, -1 * 1000);
|
||||||
|
this.logger.debug('actLikeTweet:Đã cuộn lên');
|
||||||
|
await page.waitForTimeout(rand(500, 1500));
|
||||||
|
//like
|
||||||
|
this.logger.debug('actLikeTweet:Bắt đầu nhấn like');
|
||||||
|
|
||||||
|
// Sử dụng selector cụ thể cho bài viết chính (thường có vai trò là article)
|
||||||
|
const mainTweetLike = page
|
||||||
|
.locator('article[data-testid="tweet"]').first()
|
||||||
|
.locator('button[data-testid="like"]');
|
||||||
|
await mainTweetLike.click();
|
||||||
|
|
||||||
|
this.logger.debug('actLikeTweet:Đã like xong');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug(e);
|
||||||
|
this.logger.error('actLikeTweet: Error:' + e.message);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (isCloseAfterEnd) {
|
||||||
|
page.close().catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async postTweet(
|
||||||
|
account: BrowserAccount,
|
||||||
|
text: string,
|
||||||
|
): Promise<BrowserTweetResult> {
|
||||||
|
let page: Page | null = null;
|
||||||
|
try {
|
||||||
|
page = await this.newPage();
|
||||||
|
// Intercept để lấy tweet id từ response
|
||||||
|
let capturedTweetId: string | undefined;
|
||||||
|
// page.on('response', async (resp) => {
|
||||||
|
// if (resp.url().includes('/CreateTweet')) {
|
||||||
|
// try {
|
||||||
|
// const json = await resp.json();
|
||||||
|
// capturedTweetId =
|
||||||
|
// json?.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||||
|
// } catch {
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// await page.keyboard.press('Mở trang ...');
|
||||||
|
await page.goto('https://x.com/home', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
|
await page.mouse.wheel(0, rand(300, 500));
|
||||||
|
await page.waitForTimeout(rand(1000, 4000));
|
||||||
|
|
||||||
|
// Detect login/challenge screen
|
||||||
|
if (page.url().includes('/login') || page.url().includes('/flow')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Redirected to login',
|
||||||
|
needsRelogin: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.isCookieLive(page, false);
|
||||||
|
|
||||||
|
await page.mouse.wheel(200, rand(300, 800));
|
||||||
|
await page.waitForTimeout(rand(2000, 5000));
|
||||||
|
await page.evaluate(() => window.scrollTo({top: 0, behavior: 'smooth'}));
|
||||||
|
await page.waitForTimeout(rand(1000, 2000));
|
||||||
|
// page.mouse.move()
|
||||||
|
// Mở composer
|
||||||
|
// const composer = page.locator('a[href="/compose/post"]').first();
|
||||||
|
// await page.waitForTimeout(2000 + (Math.random()+Math.random()) * 3000);
|
||||||
|
// await composer.click();
|
||||||
|
this.logger.debug('Bắt đầu nhập tweet ...');
|
||||||
|
|
||||||
|
const textarea = page.locator('div[data-testid="tweetTextarea_0"]');
|
||||||
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
|
// nhập content (fallback nếu type fail)
|
||||||
|
try {
|
||||||
|
await textarea.fill(''); // clear
|
||||||
|
await textarea.type(text, {delay: 50 + Math.random() * 200});
|
||||||
|
} catch {
|
||||||
|
await textarea.fill(text);
|
||||||
|
}
|
||||||
|
this.logger.debug(' Nhập tweet xong ...');
|
||||||
|
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
// Chờ nút enable
|
||||||
|
// const postBtn = page.locator('button[data-testid="tweetButtonInline"]');
|
||||||
|
// await postBtn.waitFor({state: 'visible', timeout: 5_000});
|
||||||
|
// await postBtn.click();
|
||||||
|
// await page.locator('button[data-testid="tweetButtonInline"]').click({ force: true });
|
||||||
|
const btn = page.locator('button[data-testid="tweetButtonInline"]');
|
||||||
|
const btnBox = await btn.boundingBox();
|
||||||
|
this.logger.debug(btnBox);
|
||||||
|
this.logger.debug('Nhấn Control+Enter ...');
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
// await page.mouse.click(btnBox?.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
|
||||||
|
await page.keyboard.press('Control+Enter');
|
||||||
|
this.logger.debug('Nhấn Control+Enter done ...');
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
|
||||||
|
// Chờ request CreateTweet hoàn tất
|
||||||
|
// await page.waitForResponse(
|
||||||
|
// (r) => r.url().includes('/CreateTweet') && r.status() === 200,
|
||||||
|
// {timeout: 15_000},
|
||||||
|
// );
|
||||||
|
|
||||||
|
return {success: true, tweetId: capturedTweetId};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Browser post failed: ${err.message}`);
|
||||||
|
// console.error(err);
|
||||||
|
return {success: false, error: err.message};
|
||||||
|
} finally {
|
||||||
|
await page?.close().catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postQuote(
|
||||||
|
account: BrowserAccount,
|
||||||
|
tweetUrl: string,
|
||||||
|
quoteText: string,
|
||||||
|
) {
|
||||||
|
const page = await this.getPage(account);
|
||||||
|
try {
|
||||||
|
// ===== SAFE GOTO =====
|
||||||
|
try {
|
||||||
|
await page.goto(tweetUrl, {waitUntil: 'domcontentloaded', timeout: 30000});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug('❌ Load fail');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(rand(2000, 4000));
|
||||||
|
|
||||||
|
await this.isCookieLive(page, false);
|
||||||
|
|
||||||
|
// ===== CHECK LOGIN =====
|
||||||
|
if (await page.locator('input[name="text"]').count()) {
|
||||||
|
this.logger.error('❌ Cookie die → bị redirect login');
|
||||||
|
|
||||||
|
throw new HttpException('❌ Không thấy nút retweet (tweet private?)', 500);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SCROLL (giống người thật) =====
|
||||||
|
await page.mouse.wheel(0, rand(300, 800));
|
||||||
|
await page.waitForTimeout(rand(1000, 5000));
|
||||||
|
await page.mouse.wheel(0, rand(300, 800));
|
||||||
|
await page.waitForTimeout(rand(4000, 8000));
|
||||||
|
await page.mouse.wheel(0, -2000);
|
||||||
|
await page.waitForTimeout(rand(1000, 2000));
|
||||||
|
|
||||||
|
await this.actLikeTweet(page);
|
||||||
|
|
||||||
|
// ===== CLICK RETWEET =====
|
||||||
|
let retweetBtn = page.locator('[data-testid="retweet"]');
|
||||||
|
|
||||||
|
if (!(await retweetBtn.count())) {
|
||||||
|
this.logger.error('❌ Không thấy nút retweet (tweet private?)');
|
||||||
|
throw new HttpException('❌ Không thấy nút retweet (tweet private?)', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await retweetBtn.first().click();
|
||||||
|
|
||||||
|
await page.waitForTimeout(rand(1000, 2000));
|
||||||
|
try {
|
||||||
|
await page.locator('a[href="/compose/post"]').click({timeout: 2000});
|
||||||
|
} catch {
|
||||||
|
this.logger.debug('fallback → click by text');
|
||||||
|
await page.locator('a[role="menuitem"]')
|
||||||
|
.filter({hasText: /Quote|Trích dẫn/i})
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
// // ===== CLICK QUOTE =====
|
||||||
|
// let quoteBtn = page.locator('[data-testid="retweetWithComment"]');
|
||||||
|
//
|
||||||
|
// if (!(await quoteBtn.count())) {
|
||||||
|
// this.logger.debug('❌ Không thấy nút quote');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// await quoteBtn.first().click();
|
||||||
|
|
||||||
|
await page.waitForTimeout(rand(2000, 3000));
|
||||||
|
|
||||||
|
// ===== TYPE LIKE HUMAN =====
|
||||||
|
// const content = pick(quoteText);
|
||||||
|
const content = quoteText;
|
||||||
|
|
||||||
|
const box = page.locator('div[role="textbox"]').first();
|
||||||
|
// chọn đúng textbox đang visible
|
||||||
|
// const box = page.locator('div[role="textbox"]:visible').first();
|
||||||
|
|
||||||
|
if (!(await box.count())) {
|
||||||
|
this.logger.error('❌ Không thấy textbox');
|
||||||
|
throw new HttpException('❌ Không thấy textbox', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// đợi nó xuất hiện thật sự
|
||||||
|
await box.waitFor({state: 'visible', timeout: 7000});
|
||||||
|
|
||||||
|
// scroll nhẹ vào view (tránh bị offscreen)
|
||||||
|
// await box.scrollIntoViewIfNeeded();
|
||||||
|
|
||||||
|
// focus trước khi gõ
|
||||||
|
this.logger.debug('focus trước khi gõ')
|
||||||
|
await box.click({delay: rand(50, 150)});
|
||||||
|
|
||||||
|
|
||||||
|
for (let char of content) {
|
||||||
|
await box.type(char, {delay: rand(50, 120)});
|
||||||
|
}
|
||||||
|
this.logger.debug('gõ quote xong ...')
|
||||||
|
await page.waitForTimeout(rand(1000, 2000));
|
||||||
|
|
||||||
|
// ===== POST =====
|
||||||
|
let postBtn = page.locator('[data-testid="tweetButton"]');
|
||||||
|
this.logger.debug('count ...')
|
||||||
|
|
||||||
|
if ((await postBtn.count())) {
|
||||||
|
this.logger.debug('click nút quote ...')
|
||||||
|
await postBtn.click({timeout: 7000}).catch(async (e) => {
|
||||||
|
this.logger.debug('❌ Nut click khong duoc, thử dùng bàn phím Control+Enter');
|
||||||
|
await page.keyboard.press('Control+Enter');
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(rand(6000, 10000));
|
||||||
|
|
||||||
|
this.logger.debug('✅ Quoted thành công');
|
||||||
|
} else {
|
||||||
|
this.logger.debug('❌ Không thấy nút post, gọi Ctr + Enter');
|
||||||
|
await page.keyboard.press('Control+Enter');
|
||||||
|
await page.waitForTimeout(rand(4000, 6000));
|
||||||
|
this.logger.debug('✅ Quoted thành công');
|
||||||
|
}
|
||||||
|
return {success: true, error: ''};
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Browser post quote failed: ${err.message}`);
|
||||||
|
// console.error(err);
|
||||||
|
return {success: false, error: `Browser post quote failed: ${err.message}`};
|
||||||
|
} finally {
|
||||||
|
await page?.close().catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postReply(account: BrowserAccount, tweetUrl, content) {
|
||||||
|
if (!content) {
|
||||||
|
this.logger.debug(`Nội dung trả lời không có`);
|
||||||
|
throw new Error('Nội dung trả lời không có');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const page = await this.getPage(account);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// limit X
|
||||||
|
// content = content.slice(0, 280);
|
||||||
|
|
||||||
|
// vào tweet
|
||||||
|
// ===== SAFE GOTO =====
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Mo trang web tweetUrl`);
|
||||||
|
await page.goto(tweetUrl, {waitUntil: 'domcontentloaded', timeout: 30000});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug('❌ Load fail');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// đợi UI ổn
|
||||||
|
this.logger.debug(`đợi UI ổn...`)
|
||||||
|
await page.waitForSelector('article', {timeout: 7000});
|
||||||
|
|
||||||
|
await this.isCookieLive(page, false);
|
||||||
|
|
||||||
|
// scroll nhẹ
|
||||||
|
this.logger.debug(`scroll nhẹ ...`)
|
||||||
|
await page.mouse.wheel(0, 300);
|
||||||
|
await page.mouse.wheel(300, 900);
|
||||||
|
await page.waitForTimeout(rand(2000, 8000));
|
||||||
|
await page.mouse.wheel(0, -2000);
|
||||||
|
|
||||||
|
|
||||||
|
await this.actLikeTweet(page);
|
||||||
|
|
||||||
|
// lấy textbox visible
|
||||||
|
const box = page.locator('div[role="textbox"]:visible').first();
|
||||||
|
|
||||||
|
await box.waitFor({state: 'visible', timeout: 7000});
|
||||||
|
|
||||||
|
// focus
|
||||||
|
this.logger.debug(`box focus ...`)
|
||||||
|
await box.click();
|
||||||
|
|
||||||
|
// nhập content (fallback nếu type fail)
|
||||||
|
try {
|
||||||
|
await box.fill(''); // clear
|
||||||
|
await box.type(content, {delay: 30 + Math.random() * 150});
|
||||||
|
} catch {
|
||||||
|
await box.fill(content);
|
||||||
|
}
|
||||||
|
this.logger.debug(`nhập nội dung xong ...`)
|
||||||
|
await page.waitForTimeout(800 + Math.random() * 1200);
|
||||||
|
|
||||||
|
// nút reply
|
||||||
|
const btn = page.locator('[data-testid="tweetButtonInline"]:visible');
|
||||||
|
|
||||||
|
if (!(await btn.count())) {
|
||||||
|
this.logger.debug('❌ Không thấy nút reply');
|
||||||
|
throw new Error('Không thấy nút reply');
|
||||||
|
// return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await btn.click();
|
||||||
|
this.logger.debug(`Đã nhấn nút gửi ...`)
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
|
||||||
|
this.logger.debug('✅ Reply OK');
|
||||||
|
|
||||||
|
// scroll nhẹ
|
||||||
|
this.logger.debug(`scroll nhẹ ...`)
|
||||||
|
await page.mouse.wheel(0, rand(300, 500));
|
||||||
|
await page.waitForTimeout(rand(2000, 7000));
|
||||||
|
await page.mouse.wheel(0, -200);
|
||||||
|
|
||||||
|
await page.goBack({waitUntil: 'domcontentloaded', timeout: 10000})
|
||||||
|
|
||||||
|
return {success: true, error: ''};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Browser reply failed: ${err.message}`);
|
||||||
|
// console.error(err);
|
||||||
|
return {success: false, error: `Browser reply failed: ${err.message}`};
|
||||||
|
} finally {
|
||||||
|
//await page?.close().catch(() => null);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Tự động đóng trình duyệt giải phóng RAM khi NestJS tắt ứng dụng
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.context) {
|
||||||
|
await this.context.close();
|
||||||
|
this.context = null;
|
||||||
|
console.log('Playwright Browser Context đã đóng an toàn.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user