Compare commits

...

35 Commits

Author SHA1 Message Date
NAME 7af67c2056 Update 2026-05-24 01:40:08 +00:00
NAME 6d66ecb13e Update 2026-05-23 16:36:37 +00:00
NAME 9d2b9a1f90 Update 2026-05-23 16:24:11 +00:00
NAME 574f0944fa Update 2026-05-23 16:11:12 +00:00
NAME f4b1ebfe51 Update 2026-05-23 06:26:01 +00:00
NAME 79022be20b Update 2026-05-22 03:14:03 +00:00
NAME eeaae0b0b9 Update 2026-05-21 05:39:28 +00:00
NAME 8df31c96e1 Update 2026-05-21 05:33:42 +00:00
NAME e107464716 Update 2026-05-21 05:27:30 +00:00
NAME fe22461a07 Update 2026-05-20 03:10:28 +00:00
NAME b18cd6d79f Update 2026-05-20 03:00:33 +00:00
NAME 85cca5a5c3 Merge branch 'master' of https://git.realflashkaze.duckdns.org/realflashkaze/x-poster-client 2026-05-20 02:57:08 +00:00
Your Name 21fd90a069 D 2026-05-20 09:55:59 +07:00
NAME a6a4ae6e00 Update 2026-05-18 02:42:09 +00:00
NAME 79bc738e1f Update 2026-05-17 08:24:17 +00:00
NAME 1025ce9778 Update 2026-05-16 16:47:17 +00:00
NAME 1608122288 Update 2026-05-16 16:15:01 +00:00
NAME e506996855 Update 2026-05-16 14:02:06 +00:00
NAME 6a7856065a Update 2026-05-16 11:58:13 +00:00
NAME 4c1f4ccd9d Merge branch 'master' of https://git.realflashkaze.duckdns.org/realflashkaze/x-poster-client 2026-05-16 11:46:02 +00:00
NAME 8dbd220924 Update 2026-05-16 11:45:33 +00:00
Your Name f1db444853 Update 2026-05-16 18:44:40 +07:00
NAME 7811e1f144 Update 2026-05-15 15:27:25 +00:00
NAME 3700309c06 Update 2026-05-15 14:42:31 +00:00
NAME 8feb07344e Update 2026-05-15 14:25:34 +00:00
NAME 977d02ee2e Update 2026-05-15 14:22:04 +00:00
NAME c9cdb9c069 Update 2026-05-15 14:12:21 +00:00
NAME 6dac81dd27 U 2026-05-15 14:06:58 +00:00
NAME 3fb16a4d97 Update 2026-05-15 11:33:22 +00:00
NAME 2208bfc062 Update 2026-05-15 11:27:36 +00:00
NAME a00f220940 Update 2026-05-15 10:02:53 +00:00
NAME 65768643e0 Update 2026-05-15 00:22:30 +00:00
NAME b57c9aa0bd U 2026-05-13 13:22:54 +00:00
NAME 702a0d1ab5 Update 2026-05-13 12:05:55 +00:00
NAME 31129e23f6 Udate 2026-05-13 11:49:16 +00:00
22 changed files with 2879 additions and 1386 deletions
+4
View File
@@ -58,3 +58,7 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
dist dist
.idea .idea
dist.zip dist.zip
data/*
x_profile_data
x_profile_data/*
+129
View File
@@ -0,0 +1,129 @@
console.log('[XAI Background] ✅ Service worker started');
// ===== TẠO MENU: XÓA CŨ → TẠO MỚI, KHÔNG GỘP =====
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.removeAll(() => {
// Menu 1: Comment — KHÔNG cần bôi đen, chỉ trên X
chrome.contextMenus.create({
id: 'writeComment',
title: '✍️ Viết comment',
contexts: ['all'],
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://*/*']
});
});
});
// ===== XỬ LÝ CLICK MENU =====
chrome.contextMenus.onClicked.addListener((info, tab) => {
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(() => {});
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;
}
});
// ===== NHẬN MESSAGE TỪ CONTENT =====
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'GENERATE_COMMENT') {
handleGenerate(request.data, sender.tab.id);
sendResponse({ received: true });
return false;
}
if (request.action === 'TRANSLATE_TEXT') {
handleTranslate(request.data, sender.tab.id);
sendResponse({ received: true });
return false;
}
sendResponse({ unknown: true });
return false;
});
// ===== GỌI API COMMENT =====
async function handleGenerate({ text, lang, tone, angle }, tabId) {
const cfg = await chrome.storage.local.get(['apiUrl', 'apiKey']);
if (!cfg.apiUrl || !cfg.apiKey) {
await chrome.tabs.sendMessage(tabId, {
action: 'SHOW_ERROR',
error: '⚠️ Chưa cấu hình API.\n\n👉 Bấm tab ⚙️ Config để nhập URL và Key.'
}).catch(() => {});
return;
}
try {
const res = await fetch(cfg.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${cfg.apiKey}`
},
body: JSON.stringify({ tweet_text: text, lang, tone, angle })
});
const data = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
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) {
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(() => {});
}
}
+353
View File
@@ -0,0 +1,353 @@
(() => {
'use strict';
let lastTweetText = '';
let sidebarHost = null;
// ===== DATA =====
const LANGS = [
{ value: 'ja', label: 'Nhật' },
{ value: 'vi', label: 'Việt' },
{ value: 'en', label: 'English' },
{ value: 'ko', label: 'Hàn' },
{ value: 'cn', label: 'Trung' }
];
const TONE_BASE = [
{ 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: 'URGENT', text: 'urgent' },
{ value: 'HUMOROUS', text: 'Dí dỏm, hài hước' },
{ value: 'INFORMATIVE', text: 'Thông tin, chính xác' },
{ value: 'EMPATHETIC', text: 'empathetic — Đồng cảm, thấu hiểu cảm xúc' },
{ value: 'PROVOCATIVE', text: 'provocative — Gợi mở suy nghĩ, thách thức giả định' },
{ value: 'AUTHORITATIVE', text: 'authoritative — Tự tin, uy quyền' },
{ value: 'SPICY', text: 'spicy — Tự tin, hơi đối đầu, chỉ thẳng' }
];
const TONE_JA_ONLY = [
{ value: 'AGGRESSIVE', text: 'aggressive — Cục súc, attack ideas mạnh' },
{ value: 'PROFANE', text: 'profane — Nói tục thoải mái, raw' },
{ value: 'INFLAMMATORY', text: 'inflammatory — Kích động cao' },
{ value: 'SAVAGE', text: 'savage — Chửi tục OK, sass tối đa' }
];
const ANGLE_DEFAULT = [
{ value: 'AGREE', text: 'Đồng ý' },
{ value: 'CHALLENGE', text: 'Không đồng ý' },
{ value: 'ADD_INFO', text: 'Thêm thông tin liên quan hữu ích' },
{ value: 'FUNNY', text: 'Hóm hỉnh, hài hước nhẹ nhàng' },
{ value: 'QUESTION', text: 'Đặt câu hỏi tiếp theo thông minh' },
{ value: 'RELATE', text: 'Chia sẻ trải nghiệm cá nhân tương tự' },
{ value: 'DEVIL_ADVOCATE', text: 'Phản biện công bằng, không thù địch' },
{ value: 'EXPAND', text: 'Phân tích sâu 1 điểm' },
{ value: 'VALIDATE', text: 'Khẳng định bằng chứng mạnh mẽ' },
{ value: 'CTA', text: 'Kêu gọi hành động nhẹ nhàng' }
];
const ANGLE_EMPATHY = [
{ value: 'WISH_RECOVERY', text: 'Chúc hồi phục' },
{ value: 'TRIBUTE', text: 'Tưởng nhớ / RIP' },
{ value: 'SOLIDARITY', text: 'Đồng lòng / Đứng cùng' },
{ value: 'PERSONAL_SUPPORT', text: 'Hỗ trợ cá nhân' },
{ value: 'SHARED_GRIEF', text: 'Cùng nỗi buồn' }
];
function getTones(lang) {
if (lang === 'ja') return [...TONE_BASE, ...TONE_JA_ONLY];
return [...TONE_BASE];
}
function getAngles(tone) {
return tone === 'EMPATHETIC' ? [...ANGLE_EMPATHY] : [...ANGLE_DEFAULT];
}
// ===== DOM =====
document.addEventListener('contextmenu', (e) => {
const article = e.target.closest('article');
if (!article) { lastTweetText = ''; return; }
const textEl =
article.querySelector('[data-testid="tweetText"]') ||
article.querySelector('div[lang]') ||
article.querySelector('div[dir="auto"]');
lastTweetText = textEl
? textEl.innerText.trim()
: article.innerText.trim().slice(0, 600);
});
chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
if (req.action === 'OPEN_FORM') {
if (!lastTweetText) {
alert('Không tìm thấy tweet. Hãy chuột phải vào phần chữ của tweet.');
return true;
}
openSidebar(lastTweetText);
return true;
}
if (req.action === 'SHOW_RESULT') { showResult(req.comment); return true; }
if (req.action === 'SHOW_ERROR') { showError(req.error); return true; }
});
// ===== SIDEBAR =====
function openSidebar(tweetText) {
removeSidebar();
const host = document.createElement('div');
host.id = 'x-ai-sidebar-host';
Object.assign(host.style, {
position: 'fixed', top: '0', left: '0', width: '0', height: '0',
zIndex: '2147483647', overflow: 'visible', pointerEvents: 'none'
});
document.body.appendChild(host);
sidebarHost = host;
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
.fab { position: fixed; right: 24px; bottom: 24px; width: 56px; height: 56px; border-radius: 50%;
background: #1d9bf0; color: #fff; font-size: 24px; display: flex; align-items: center; justify-content: center;
cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: none; pointer-events: auto; z-index: 1;
transition: transform .15s ease; user-select: none; }
.fab:hover { transform: scale(1.08); }
.drawer { position: fixed; right: 0; top: 0; width: 400px; max-width: 100vw; height: 100vh; background: #fff; color: #0f1419;
box-shadow: -5px 0 25px rgba(0,0,0,0.15); transform: translateX(100%); transition: transform .25s ease;
display: flex; flex-direction: column; pointer-events: auto; z-index: 2;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
.drawer.open { transform: translateX(0); }
.header { padding: 14px 18px; border-bottom: 1px solid #eff3f4; display: flex; justify-content: space-between; align-items: center;
font-weight: 700; font-size: 16px; }
.btn-x { background: none; border: none; font-size: 20px; cursor: pointer; color: #536471; padding: 4px; line-height: 1; }
.tabs { display: flex; border-bottom: 1px solid #eff3f4; }
.tab-btn { flex: 1; padding: 10px; border: none; background: none; cursor: pointer; font-size: 14px; color: #536471;
border-bottom: 2px solid transparent; font-weight: 600; }
.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.active { display: flex; }
.tweet-box { background: #f7f9f9; border: 1px solid #cfd9de; border-radius: 10px; padding: 10px; font-size: 13px;
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; }
select, input[type="text"], input[type="password"] { width: 100%; padding: 9px 10px; border-radius: 8px; border: 1px solid #cfd9de;
font-size: 14px; background: #fff; box-sizing: border-box; }
.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;
font-weight: 700; font-size: 15px; cursor: pointer; margin-top: 6px; }
button.primary:hover { background: #1a8cd8; }
button.primary:disabled { background: #8ecdf7; cursor: default; }
.status { display: none; padding: 12px; border-radius: 10px; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.status.ok { background: #e8f6fe; border: 1px solid #b4dffc; }
.status.err { background: #ffeaea; border: 1px solid #ffc5c5; color: #b00; }
.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; }
`;
const fab = document.createElement('button');
fab.className = 'fab'; fab.textContent = '🤖'; fab.title = 'AI Comment';
const drawer = document.createElement('div');
drawer.className = 'drawer';
drawer.innerHTML = `
<div class="header"><span>✍️ AI Comment</span><button class="btn-x">✕</button></div>
<div class="tabs">
<button class="tab-btn active" data-tab="comment">Comment</button>
<button class="tab-btn" data-tab="config">⚙️ Config <span class="badge" id="cfg-missing" style="display:none">!</span></button>
</div>
<!-- TAB COMMENT -->
<div class="tab-content active" id="tab-comment">
<div class="tweet-box">${escapeHtml(tweetText)}</div>
<label>Ngôn ngữ đầu ra</label>
<select id="ai-lang">${LANGS.map(l => `<option value="${l.value}">${l.label}</option>`).join('')}</select>
<label>Tone</label>
<select id="ai-tone"></select>
<div class="hint-text" id="ai-tone-hint"></div>
<label>Angle</label>
<select id="ai-angle"></select>
<div class="hint-text" id="ai-angle-hint"></div>
<button class="primary" id="ai-run">🚀 Tạo Comment</button>
<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>
<!-- TAB CONFIG -->
<div class="tab-content" id="tab-config">
<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.
</div>
<label>API URL</label>
<input type="text" id="cfg-url" placeholder="https://api.yoursite.com/v1/generate">
<label>API Key</label>
<input type="password" id="cfg-key" placeholder="sk-...">
<div style="display:flex; gap:8px;">
<button class="primary" id="cfg-save" style="flex:1">💾 Lưu</button>
<button class="primary" id="cfg-test" style="flex:1;background:#17bf63;">🧪 Test đọc</button>
</div>
<div class="status" id="cfg-status"></div>
</div>
`;
shadow.appendChild(style);
shadow.appendChild(fab);
shadow.appendChild(drawer);
// Toggle drawer
const toggleDrawer = () => drawer.classList.toggle('open');
fab.addEventListener('click', toggleDrawer);
drawer.querySelector('.btn-x').addEventListener('click', () => drawer.classList.remove('open'));
requestAnimationFrame(() => drawer.classList.add('open'));
// Tab switching
const tabBtns = drawer.querySelectorAll('.tab-btn');
const tabContents = drawer.querySelectorAll('.tab-content');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
tabBtns.forEach(b => b.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
btn.classList.add('active');
drawer.querySelector(`#tab-${btn.dataset.tab}`).classList.add('active');
});
});
// ===== COMMENT TAB LOGIC =====
const langSel = drawer.querySelector('#ai-lang');
const toneSel = drawer.querySelector('#ai-tone');
const toneHint = drawer.querySelector('#ai-tone-hint');
const angleSel = drawer.querySelector('#ai-angle');
const angleHint= drawer.querySelector('#ai-angle-hint');
const runBtn = drawer.querySelector('#ai-run');
const statusEl = drawer.querySelector('#ai-status');
const copyHint = drawer.querySelector('#ai-hint');
function populateSelect(sel, items, selectedValue) {
sel.innerHTML = items.map(it => `<option value="${it.value}">${it.text}</option>`).join('');
if (selectedValue && items.find(i => i.value === selectedValue)) sel.value = selectedValue;
}
function updateTone(lang, keepValueIfValid) {
const items = getTones(lang);
const old = toneSel.value;
populateSelect(toneSel, items, keepValueIfValid && items.find(i => i.value === old) ? old : null);
onToneChange();
}
function onToneChange() {
const lang = langSel.value;
const tone = toneSel.value;
const tItem = getTones(lang).find(i => i.value === tone);
toneHint.textContent = tItem ? tItem.text : '';
const oldAngle = angleSel.value;
const aItems = getAngles(tone);
populateSelect(angleSel, aItems, aItems.find(i => i.value === oldAngle) ? oldAngle : null);
onAngleChange();
}
function onAngleChange() {
const tone = toneSel.value;
const angle = angleSel.value;
const aItem = getAngles(tone).find(i => i.value === angle);
angleHint.textContent = aItem ? aItem.text : '';
}
langSel.addEventListener('change', () => updateTone(langSel.value, true));
toneSel.addEventListener('change', onToneChange);
angleSel.addEventListener('change', onAngleChange);
updateTone(langSel.value, false);
runBtn.addEventListener('click', () => {
const lang = langSel.value;
const tone = toneSel.value;
const angle = angleSel.value;
runBtn.disabled = true;
statusEl.style.display = 'block'; statusEl.className = 'status ok'; statusEl.textContent = '⏳ Đang gọi API...'; copyHint.style.display = 'none';
chrome.runtime.sendMessage(
{ action: 'GENERATE_COMMENT', data: { text: tweetText, lang, tone, angle } },
() => {
if (chrome.runtime.lastError) {
statusEl.className = 'status err';
statusEl.textContent = 'Lỗi kết nối: ' + chrome.runtime.lastError.message;
runBtn.disabled = false;
}
}
);
});
// ===== CONFIG TAB LOGIC =====
const urlIn = drawer.querySelector('#cfg-url');
const keyIn = drawer.querySelector('#cfg-key');
const saveBtn = drawer.querySelector('#cfg-save');
const testBtn = drawer.querySelector('#cfg-test');
const cfgStatus= drawer.querySelector('#cfg-status');
const cfgBadge = drawer.querySelector('#cfg-missing');
function showCfgStatus(msg, isErr) {
cfgStatus.style.display = 'block';
cfgStatus.className = 'status ' + (isErr ? 'err' : 'ok');
cfgStatus.textContent = msg;
}
// Đọc config hiện tại
chrome.storage.local.get(['apiUrl', 'apiKey'], (cfg) => {
if (cfg.apiUrl) urlIn.value = cfg.apiUrl;
if (cfg.apiKey) keyIn.value = cfg.apiKey;
cfgBadge.style.display = (!cfg.apiUrl || !cfg.apiKey) ? 'inline-block' : 'none';
});
saveBtn.addEventListener('click', () => {
const url = urlIn.value.trim();
const key = keyIn.value.trim();
if (!url || !key) {
showCfgStatus('❌ URL và Key không được để trống!', true);
return;
}
try { new URL(url); } catch {
showCfgStatus('❌ URL không hợp lệ (cần có https://)', true);
return;
}
chrome.storage.local.set({ apiUrl: url, apiKey: key }, () => {
showCfgStatus('✅ Đã lưu! Bạn có thể đóng tab này và bấm Tạo Comment.', false);
cfgBadge.style.display = 'none';
});
});
testBtn.addEventListener('click', () => {
chrome.storage.local.get(['apiUrl', 'apiKey'], (cfg) => {
showCfgStatus(`URL: ${cfg.apiUrl || '(trống)'}\nKey: ${cfg.apiKey ? cfg.apiKey.slice(0,8)+'...' : '(trống)'}`, !cfg.apiUrl || !cfg.apiKey);
});
});
}
// ===== RESULT / ERROR =====
function showResult(comment) {
if (!sidebarHost) return;
const s = sidebarHost.shadowRoot;
const statusEl = s.querySelector('#ai-status');
const copyHint = s.querySelector('#ai-hint');
const runBtn = s.querySelector('#ai-run');
const drawer = s.querySelector('.drawer');
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'status ok'; statusEl.textContent = comment; }
if (copyHint) copyHint.style.display = 'block';
if (runBtn) runBtn.disabled = false;
if (drawer) drawer.classList.add('open');
}
function showError(msg) {
if (!sidebarHost) return;
const s = sidebarHost.shadowRoot;
const statusEl = s.querySelector('#ai-status');
const runBtn = s.querySelector('#ai-run');
const drawer = s.querySelector('.drawer');
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'status err'; statusEl.textContent = msg; }
if (runBtn) runBtn.disabled = false;
if (drawer) drawer.classList.add('open');
}
function removeSidebar() {
if (sidebarHost) { sidebarHost.remove(); sidebarHost = null; }
}
function escapeHtml(text) {
const d = document.createElement('div'); d.textContent = text; return d.innerHTML;
}
})();
+686
View File
@@ -0,0 +1,686 @@
(() => {
'use strict';
let lastTweetText = '';
let lastSelectedText = '';
let sidebarHost = null;
let isTyping = false;
// ===== DATA =====
const LANGS = [
{ value: 'en', label: 'English' ,selected: true },
{ value: 'vi', label: 'Việt' },
{ value: 'ja', label: 'Nhật' },
{ value: 'ko', label: 'Han' },
{ value: 'cn', label: 'TQ' }
];
const ModelAI = [
{ value: 'openai', label: 'Chat GPT' },
{ value: 'google', label: 'Gemini' },
{ value: 'deepseek', label: 'Deep Seek' },
]
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: 'HYPE', text: 'Hype — Hào hứng, tràn đầy năng lượng' },
{ value: 'URGENT', text: 'urgent' },
{ value: 'HUMOROUS', text: 'Dí dỏm, hài hước' },
{ value: 'INFORMATIVE', text: 'Thông tin, chính xác' },
{ value: 'EMPATHETIC', text: 'empathetic — Đồng cảm, thấu hiểu cảm xúc' },
{ value: 'PROVOCATIVE', text: 'provocative — Gợi mở suy nghĩ, thách thức giả định' },
{ value: 'AUTHORITATIVE', text: 'authoritative — Tự tin, uy quyền' },
{ value: 'SPICY', text: 'spicy — Tự tin, hơi đối đầu, chỉ thẳng' }
];
const TONE_JA_ONLY = [
{ value: 'AGGRESSIVE', text: 'aggressive — Cục súc, attack ideas mạnh' },
{ value: 'PROFANE', text: 'profane — Nói tục thoải mái, raw' },
{ value: 'INFLAMMATORY', text: 'inflammatory — Kích động cao' },
{ value: 'SAVAGE', text: 'savage — Chửi tục OK, sass tối đa' }
];
const ANGLE_DEFAULT = [
{ value: 'NATURAL', text: 'Phản ứng tự nhiên' },
{ value: 'AGREE', text: 'Đồng ý' },
{ value: 'CHALLENGE', text: 'Không đồng ý' },
{ value: 'ADD_INFO', text: 'Thêm thông tin liên quan hữu ích' },
{ value: 'FUNNY', text: 'Hóm hỉnh, hài hước nhẹ nhàng' },
{ value: 'QUESTION', text: 'Đặt câu hỏi tiếp theo thông minh' },
{ value: 'RELATE', text: 'Chia sẻ trải nghiệm cá nhân tương tự' },
{ value: 'DEVIL_ADVOCATE', text: 'Phản biện công bằng, không thù địch' },
{ value: 'EXPAND', text: 'Phân tích sâu 1 điểm' },
{ value: 'VALIDATE', text: 'Khẳng định bằng chứng mạnh mẽ' },
{ value: 'CTA', text: 'Kêu gọi hành động nhẹ nhàng' }
];
const ANGLE_EMPATHY = [
{ value: 'WISH_RECOVERY', text: 'Chúc hồi phục' },
{ value: 'TRIBUTE', text: 'Tưởng nhớ / RIP' },
{ value: 'SOLIDARITY', text: 'Đồng lòng / Đứng cùng' },
{ value: 'PERSONAL_SUPPORT', text: 'Hỗ trợ cá nhân' },
{ value: 'SHARED_GRIEF', text: 'Cùng nỗi buồn' }
];
function getTones(lang) {
if (lang === 'ja') return [...TONE_BASE, ...TONE_JA_ONLY];
return [...TONE_BASE];
}
function getAngles(tone) {
return tone === 'EMPATHETIC' ? [...ANGLE_DEFAULT, ...ANGLE_EMPATHY] : [...ANGLE_DEFAULT];
}
// ===== A. CAPTURE TWEET & SELECTED TEXT =====
document.addEventListener('contextmenu', (e) => {
const sel = window.getSelection().toString().trim();
if (sel) lastSelectedText = sel;
const article = e.target.closest('article');
if (article) {
const textEl =
article.querySelector('[data-testid="tweetText"]') ||
article.querySelector('div[lang]') ||
article.querySelector('div[dir="auto"]');
lastTweetText = textEl ? textEl.innerText.trim() : article.innerText.trim().slice(0, 600);
}
});
// ===== B. LISTENER =====
chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
if (req.action === 'OPEN_FORM') {
if (!lastTweetText) {
sendResponse({ ok: false, reason: 'no_text' });
return false;
}
openSidebar(lastTweetText);
sendResponse({ ok: true });
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') {
showResult(req.comment, req.model, req.commentTransVi, req.commentTransModel);
sendResponse({ ok: true });
return false;
}
if (req.action === 'SHOW_ERROR') {
showError(req.error);
sendResponse({ ok: true });
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 });
return false;
});
// ===== C. TYPING ENGINE =====
async function simulateHumanTyping(fullText) {
let editor =
document.querySelector('[data-testid="tweetTextarea_0"]') ||
document.querySelector('div[contenteditable="true"][role="textbox"]');
if (!editor) {
alert('🔍 Không tìm thấy ô reply.\n\n👉 Hãy bấm nút "Reply" của tweet trước để ô nhập hiện ra, rồi thử lại!');
return false;
}
editor.focus();
editor.click();
editor.textContent = '';
const textNode = document.createTextNode('');
editor.appendChild(textNode);
const sel = window.getSelection();
const range = document.createRange();
range.setStart(textNode, 0);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
for (let i = 0; i < fullText.length; i++) {
const char = fullText[i];
textNode.nodeValue += char;
range.setEnd(textNode, textNode.nodeValue.length);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
cancelable: true,
inputType: 'insertText',
data: char
}));
let delay = 30 + Math.random() * 50;
if (char === ' ') delay += 40 + Math.random() * 40;
if ('.!?,'.includes(char)) delay += 120 + Math.random() * 200;
await new Promise(r => setTimeout(r, delay));
}
editor.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
// ===== 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) {
// ⭐ FIX: Nếu mở comment mới (có tweetText), xóa sidebar cũ để render lại từ đầu
if (tweetText && sidebarHost) {
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');
host.id = 'x-ai-sidebar-host';
Object.assign(host.style, {
position: 'fixed', top: '0', left: '0', width: '0', height: '0',
zIndex: '2147483647', overflow: 'visible', pointerEvents: 'none'
});
document.body.appendChild(host);
sidebarHost = host;
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
.fab { position: fixed; right: 24px; bottom: 24px; width: 56px; height: 56px; border-radius: 50%;
background: #1d9bf0; color: #fff; font-size: 24px; display: flex; align-items: center; justify-content: center;
cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: none; pointer-events: auto; z-index: 1;
transition: transform .15s ease; user-select: none; }
.fab:hover { transform: scale(1.08); }
.drawer { position: fixed; right: 0; top: 0; width: 420px; max-width: 100vw; height: 100vh; background: #fff; color: #0f1419;
box-shadow: -5px 0 25px rgba(0,0,0,0.15); transform: translateX(100%); transition: transform .25s ease;
display: flex; flex-direction: column; pointer-events: auto; z-index: 2;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
.drawer.open { transform: translateX(0); }
.header { padding: 14px 18px; border-bottom: 1px solid #eff3f4; display: flex; justify-content: space-between; align-items: center;
font-weight: 700; font-size: 16px; }
.btn-x { background: none; border: none; font-size: 20px; cursor: pointer; color: #536471; padding: 4px; line-height: 1; }
.tabs { display: flex; border-bottom: 1px solid #eff3f4; }
.tab-btn { flex: 1; padding: 10px; border: none; background: none; cursor: pointer; font-size: 14px; color: #536471;
border-bottom: 2px solid transparent; font-weight: 600; }
.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.active { display: flex; }
.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; }
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"], textarea { width: 100%; padding: 9px 10px; border-radius: 8px; border: 1px solid #cfd9de;
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; }
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; }
button.primary:hover { background: #1a8cd8; }
button.primary:disabled { background: #8ecdf7; cursor: default; }
button.green { background: #17bf63 !important; }
button.green:hover { background: #15a857 !important; }
button.gray { background: #536471 !important; }
button.gray:hover { background: #3e4e56 !important; }
.status { display: none; padding: 12px; border-radius: 10px; font-size: 14px; line-height: 1.5;
white-space: pre-wrap; word-break: break-word; }
.status.ok { background: #e8f6fe; border: 1px solid #b4dffc; }
.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; }
.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.visible { display: flex; }
.check-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #0f1419; cursor: pointer; }
.check-row input { cursor: pointer; }
.btn-row { display: flex; gap: 8px; }
.btn-row .primary { flex: 1; margin-top: 0; }
`;
const fab = document.createElement('button');
fab.className = 'fab'; fab.textContent = '🤖'; fab.title = 'AI Comment';
const drawer = document.createElement('div');
drawer.className = 'drawer';
drawer.innerHTML = `
<div class="header"><span>✍️ AI Comment</span><button class="btn-x">✕</button></div>
<div class="tabs">
<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>
</div>
<div class="tab-content active" id="tab-comment">
<div class="tweet-box">${escapeHtml(tweetText)}</div>
<label>Ngôn ngữ đầu ra</label>
<select id="ai-lang">${LANGS.map(l => `<option value="${l.value}">${l.label}</option>`).join('')}</select>
<label>Tone</label>
<select id="ai-tone"></select>
<div class="hint-text" id="ai-tone-hint"></div>
<label>Angle</label>
<select id="ai-angle"></select>
<div class="hint-text" id="ai-angle-hint"></div>
<button class="primary" id="ai-run">🚀 Tạo Comment</button>
<div class="status" id="ai-status"></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">
<label class="check-row">
<input type="checkbox" id="ai-typing" checked>
<span>Giả lập gõ như người thật (từ từ)</span>
</label>
<div class="btn-row">
<button class="primary gray" id="ai-copy">📋 Copy</button>
<button class="primary green" id="ai-paste">📥 Dán vào ô reply</button>
</div>
<div style="font-size:12px;color:#536471;text-align:center;">
💡 Nếu chưa mở ô reply, hãy bấm Reply trước!
</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 style="font-size:13px; color:#536471; margin-bottom:6px;">
Cấu hình API. Nếu Translate URL để trống, sẽ dùng chung Comment URL.
</div>
<label>Comment API URL</label>
<input type="text" id="cfg-url" placeholder="https://api.yoursite.com/v1/generate">
<label>Comment API Key</label>
<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;">
<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>
</div>
<div class="status" id="cfg-status"></div>
</div>
`;
shadow.appendChild(style);
shadow.appendChild(fab);
shadow.appendChild(drawer);
// Toggle
const toggleDrawer = () => drawer.classList.toggle('open');
fab.addEventListener('click', toggleDrawer);
drawer.querySelector('.btn-x').addEventListener('click', () => drawer.classList.remove('open'));
requestAnimationFrame(() => drawer.classList.add('open'));
// Tabs
const tabBtns = drawer.querySelectorAll('.tab-btn');
const tabContents = drawer.querySelectorAll('.tab-content');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
tabBtns.forEach(b => b.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
btn.classList.add('active');
drawer.querySelector(`#tab-${btn.dataset.tab}`).classList.add('active');
});
});
// ===== COMMENT TAB =====
const langSel = drawer.querySelector('#ai-lang');
const toneSel = drawer.querySelector('#ai-tone');
const toneHint = drawer.querySelector('#ai-tone-hint');
const angleSel = drawer.querySelector('#ai-angle');
const angleHint= drawer.querySelector('#ai-angle-hint');
const runBtn = drawer.querySelector('#ai-run');
const statusEl = drawer.querySelector('#ai-status');
const transViEl= drawer.querySelector('#ai-trans-vi');
const copyHint = drawer.querySelector('#ai-hint');
const typingOpts = drawer.querySelector('#ai-typing-opts');
const typingChk = drawer.querySelector('#ai-typing');
const pasteBtn = drawer.querySelector('#ai-paste');
const copyBtn = drawer.querySelector('#ai-copy');
function populateSelect(sel, items, selectedValue) {
sel.innerHTML = items.map(it => `<option value="${it.value}">${it.text}</option>`).join('');
if (selectedValue && items.find(i => i.value === selectedValue)) sel.value = selectedValue;
}
function updateTone(lang, keepValueIfValid) {
const items = getTones(lang);
const old = toneSel.value;
populateSelect(toneSel, items, keepValueIfValid && items.find(i => i.value === old) ? old : null);
onToneChange();
}
function onToneChange() {
const lang = langSel.value;
const tone = toneSel.value;
const tItem = getTones(lang).find(i => i.value === tone);
toneHint.textContent = tItem ? tItem.text : '';
const oldAngle = angleSel.value;
const aItems = getAngles(tone);
populateSelect(angleSel, aItems, aItems.find(i => i.value === oldAngle) ? oldAngle : null);
onAngleChange();
}
function onAngleChange() {
const tone = toneSel.value;
const angle = angleSel.value;
const aItem = getAngles(tone).find(i => i.value === angle);
angleHint.textContent = aItem ? aItem.text : '';
}
langSel.addEventListener('change', () => updateTone(langSel.value, true));
toneSel.addEventListener('change', onToneChange);
angleSel.addEventListener('change', onAngleChange);
updateTone(langSel.value, false);
runBtn.addEventListener('click', () => {
const lang = langSel.value;
const tone = toneSel.value;
const angle = angleSel.value;
runBtn.disabled = true;
statusEl.style.display = 'block'; statusEl.className = 'status ok';
statusEl.textContent = '⏳ Đang gọi API...';
transViEl.style.display = 'none'; transViEl.textContent = '';
copyHint.style.display = 'none';
typingOpts.classList.remove('visible');
chrome.runtime.sendMessage(
{ action: 'GENERATE_COMMENT', data: { text: tweetText, lang, tone, angle } },
() => {
if (chrome.runtime.lastError) {
statusEl.className = 'status err';
statusEl.textContent = 'Lỗi kết nối: ' + chrome.runtime.lastError.message;
runBtn.disabled = false;
}
}
);
});
copyBtn.addEventListener('click', async () => {
const text = statusEl.textContent;
if (!text || text.startsWith('⏳') || text.startsWith('Lỗi') || text.startsWith('⚠️')) {
flashBtn(copyBtn, '⚠️ Chưa có nội dung!');
return;
}
try {
await navigator.clipboard.writeText(text);
flashBtn(copyBtn, '✅ Đã copy!');
} catch (e) { flashBtn(copyBtn, '❌ Lỗi copy'); }
});
pasteBtn.addEventListener('click', async () => {
const text = statusEl.textContent;
if (!text || text.startsWith('⏳') || text.startsWith('Lỗi') || text.startsWith('⚠️')) {
alert('Chưa có nội dung hợp lệ để dán. Hãy tạo comment trước!');
return;
}
if (isTyping) return;
isTyping = true; pasteBtn.disabled = true;
try {
if (typingChk.checked) {
pasteBtn.textContent = '⌨️ Đang gõ...';
const ok = await simulateHumanTyping(text);
if (ok) drawer.classList.remove('open');
} else {
await navigator.clipboard.writeText(text);
alert('✅ Đã copy vào clipboard!\n\nBạn tự dán vào ô reply nhé.');
}
} catch (err) {
console.error('Typing error:', err);
alert('Lỗi khi nhập: ' + err.message);
} finally {
isTyping = false; pasteBtn.disabled = false;
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 =====
const urlIn = drawer.querySelector('#cfg-url');
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 testBtn = drawer.querySelector('#cfg-test');
const cfgStatus = drawer.querySelector('#cfg-status');
const cfgBadge = drawer.querySelector('#cfg-missing');
function showCfgStatus(msg, isErr) {
cfgStatus.style.display = 'block';
cfgStatus.className = 'status ' + (isErr ? 'err' : 'ok');
cfgStatus.textContent = msg;
}
chrome.storage.local.get(['apiUrl','apiKey','translateUrl','translateKey'], (cfg) => {
if (cfg.apiUrl) urlIn.value = cfg.apiUrl;
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';
});
saveBtn.addEventListener('click', () => {
const url = urlIn.value.trim(), key = keyIn.value.trim();
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; }
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);
cfgBadge.style.display = 'none';
});
});
testBtn.addEventListener('click', () => {
chrome.storage.local.get(['apiUrl','apiKey','translateUrl','translateKey'], (cfg) => {
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 =====
function showResult(comment, model='', transVi='', transByModal = '') {
if (!sidebarHost) return;
const s = sidebarHost.shadowRoot;
const statusEl = s.querySelector('#ai-status');
const transViEl= s.querySelector('#ai-trans-vi');
const copyHint = s.querySelector('#ai-hint');
const runBtn = s.querySelector('#ai-run');
const drawer = s.querySelector('.drawer');
const typingOpts = s.querySelector('#ai-typing-opts');
console.log({model});
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 (drawer) drawer.classList.add('open');
if (typingOpts) typingOpts.classList.add('visible');
}
function showError(msg) {
if (!sidebarHost) return;
const s = sidebarHost.shadowRoot;
const statusEl = s.querySelector('#ai-status');
const transViEl= s.querySelector('#ai-trans-vi');
const runBtn = s.querySelector('#ai-run');
const drawer = s.querySelector('.drawer');
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 (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() {
if (sidebarHost) { sidebarHost.remove(); sidebarHost = null; }
}
function escapeHtml(text) {
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);
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+32
View File
@@ -0,0 +1,32 @@
{
"manifest_version": 3,
"name": "X Comment AI",
"version": "1.2.0",
"description": "AI Comment cho X.com — có cấu hình",
"permissions": [
"contextMenus",
"storage"
],
"host_permissions": [
"https://x.com/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": [
"https://x.com/*"
],
"js": [
"content.js"
],
"run_at": "document_idle"
}
],
"icons": {
"16": "icons/icon.png",
"48": "icons/icon.png",
"128": "icons/icon.png"
}
}
+1
View File
@@ -34,6 +34,7 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie": "^1.1.1", "cookie": "^1.1.1",
"https-proxy-agent": "^9.0.0", "https-proxy-agent": "^9.0.0",
"keyv": "^5.6.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"playwright": "^1.59.1", "playwright": "^1.59.1",
"playwright-extra": "^4.3.6", "playwright-extra": "^4.3.6",
+943 -1298
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -2,12 +2,15 @@ import {Controller, Get, Post} from '@nestjs/common';
import {AppService} from './app.service'; import {AppService} from './app.service';
import {XCookieAccountDto} from "./x-poster/dto/x-cookie-account.dto"; import {XCookieAccountDto} from "./x-poster/dto/x-cookie-account.dto";
import {XPosterRouterService} from "./x-poster/x-poster.router.service"; import {XPosterRouterService} from "./x-poster/x-poster.router.service";
import {Context} from "node:vm";
import {XCacheService} from "./x-cache/x-cache.service";
@Controller() @Controller()
export class AppController { export class AppController {
constructor( constructor(
private readonly appService: AppService, private readonly appService: AppService,
private readonly xPosterRouterService: XPosterRouterService, private readonly xPosterRouterService: XPosterRouterService,
private readonly cache: XCacheService
) { ) {
} }
@@ -16,8 +19,8 @@ export class AppController {
return this.xPosterRouterService.verifyCookie(); return this.xPosterRouterService.verifyCookie();
} }
@Post('/set-x-cookies') @Get('/x')
setXCookies(dto: XCookieAccountDto) { setXCookies(dto: XCookieAccountDto) {
return this.cache.getCacheTwRefreshToken();
} }
} }
+7 -3
View File
@@ -5,8 +5,12 @@ import {SqsModule} from "./sqs-module/sqs.module";
import {XPosterModule} from "./x-poster/x-poster.module"; import {XPosterModule} from "./x-poster/x-poster.module";
import {ConfigModule} from "@nestjs/config"; import {ConfigModule} from "@nestjs/config";
import {CacheModule} from "@nestjs/cache-manager"; import {CacheModule} from "@nestjs/cache-manager";
import KeyvRedis from "@keyv/redis";
import {XbotFollowModule} from "./xbot-follow/xbot-follow.module"; import {XbotFollowModule} from "./xbot-follow/xbot-follow.module";
import * as path from 'path';
import {XCacheService} from "./x-cache/x-cache.service";
import KeyvRedis from "@keyv/redis";
console.log(`sqlite://${path.join(process.cwd(), 'cache.sqlite')}`)
@Module({ @Module({
imports: [ imports: [
@@ -20,7 +24,7 @@ import {XbotFollowModule} from "./xbot-follow/xbot-follow.module";
isGlobal: true, isGlobal: true,
useFactory: () => ({ useFactory: () => ({
stores: [ stores: [
new KeyvRedis(`redis://127.0.0.1:6379/1`) new KeyvRedis(process.env.REDIS_URL)
], ],
}), }),
}), }),
@@ -29,7 +33,7 @@ import {XbotFollowModule} from "./xbot-follow/xbot-follow.module";
XbotFollowModule, XbotFollowModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService, XCacheService,],
}) })
export class AppModule { export class AppModule {
} }
+4 -1
View File
@@ -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;
}
+4 -1
View File
@@ -2,8 +2,12 @@ import {NestFactory} from '@nestjs/core';
import {AppModule} from './app.module'; import {AppModule} from './app.module';
import {SqsPosterWorker} from "./sqs-module/sqs.poster.worker"; import {SqsPosterWorker} from "./sqs-module/sqs.poster.worker";
import {DocumentBuilder, SwaggerModule} from "@nestjs/swagger"; import {DocumentBuilder, SwaggerModule} from "@nestjs/swagger";
import fs from 'fs';
async function bootstrap() { async function bootstrap() {
fs.mkdirSync('./data', { recursive: true });
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
// Cấu hình Swagger // Cấu hình Swagger
@@ -28,7 +32,6 @@ async function bootstrap() {
`) `)
); );
await app.get(SqsPosterWorker).start(); await app.get(SqsPosterWorker).start();
} }
+4 -3
View File
@@ -1,12 +1,12 @@
// sqs.module.ts // sqs.module.ts
import { Module, Global } from '@nestjs/common'; import {Global, Module} from '@nestjs/common';
import { SqsService } from './sqs.service'; import {SqsService} from './sqs.service';
import {SqsPostService} from "./sqs.post.service"; import {SqsPostService} from "./sqs.post.service";
import {SqsPosterWorker} from "./sqs.poster.worker"; import {SqsPosterWorker} from "./sqs.poster.worker";
import {XPosterRouterService} from "../x-poster/x-poster.router.service";
import {XPosterModule} from "../x-poster/x-poster.module"; import {XPosterModule} from "../x-poster/x-poster.module";
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";
@Global() @Global()
@Module({ @Module({
@@ -17,6 +17,7 @@ import {NotifyService} from "../notify.service";
SqsPosterWorker, SqsPosterWorker,
FacebookApi, FacebookApi,
NotifyService, NotifyService,
XCacheService,
], ],
exports: [SqsService], exports: [SqsService],
}) })
+35 -11
View File
@@ -2,9 +2,10 @@
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";
@Injectable() @Injectable()
export class SqsPosterWorker { export class SqsPosterWorker {
@@ -15,6 +16,7 @@ export class SqsPosterWorker {
private readonly xRouterService: XPosterRouterService, private readonly xRouterService: XPosterRouterService,
private readonly facebookApi: FacebookApi, private readonly facebookApi: FacebookApi,
private readonly notifyService: NotifyService, private readonly notifyService: NotifyService,
private readonly xCacheService: XCacheService,
) { ) {
} }
@@ -23,9 +25,11 @@ export class SqsPosterWorker {
await this.notifyService.sendMessageToTele(`🚀 Worker started for ${await this.sqs.getQueueName()}`) await this.notifyService.sendMessageToTele(`🚀 Worker started for ${await this.sqs.getQueueName()}`)
//check cookie //check cookie
this.xRouterService.verifyCookie().catch((err) => { this.xCacheService.isXCookiesAlive().then(isLive => {
console.error(`SqsPosterWorker_verifyCookie`); this.logger.log(`cache cookie is ${isLive ? 'LIVE' : 'DIE'}`);
console.error(err); if (!isLive) {
this.xRouterService.verifyCookie();
}
}); });
let ReceiptHandle = ''; let ReceiptHandle = '';
@@ -64,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,
@@ -83,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,
@@ -99,7 +112,10 @@ export class SqsPosterWorker {
// await postToX(data.content); // await postToX(data.content);
// giả lập delay // giả lập delay
await this.sleep(rand(7, 10) * 1000); //nghỉ 10s const sleepS = rand(12, 21) * 1000;
this.logger.log(`Nghỉ ${sleepS} second`);
await this.sleep(sleepS);
} }
private sleep(ms: number) { private sleep(ms: number) {
@@ -116,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); 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`);
@@ -147,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) {
+16 -4
View File
@@ -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);
}
} }
+46 -9
View File
@@ -1,14 +1,22 @@
// src/modules/social/facebook-api.service.ts // src/modules/social/facebook-api.service.ts
import {HttpException, HttpStatus, Injectable} 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 {
private logger = new Logger('FacebookApi');
private readonly fbBaseUrl = 'https://graph.facebook.com/v19.0'; private readonly fbBaseUrl = 'https://graph.facebook.com/v19.0';
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;
async postToPage(content: string, imageUrl?: string) { constructor(private notifyService: NotifyService) {
}
async postToPage(content: string, imageUrl?: string, throwEx = true) {
// console.log('postToPage==>', content, imageUrl); // console.log('postToPage==>', content, imageUrl);
try { try {
let url = `${this.fbBaseUrl}/${this.pageId}/feed`; let url = `${this.fbBaseUrl}/${this.pageId}/feed`;
@@ -25,14 +33,43 @@ export class FacebookApi {
const response = await axios.post(url, params); const response = await axios.post(url, params);
//response.data= { id: '1010286162176053_122107818902775551' } //response.data= { id: '1010286162176053_122107818902775551' }
return response.data; // Trả về ID bài viết nếu thành công // return response.data; // Trả về ID bài viết nếu thành công
return {
success: true,
postId: response.data,
}
} catch (error) { } catch (error) {
console.log('Lỗi khi đăng bài lên FB'); this.logger.error('Lỗi khi đăng bài lên FB');
console.log(error.message); this.logger.error(error.message);
throw new HttpException( // Kiểm tra xem Facebook có trả về response lỗi không
error.response?.data || 'Lỗi khi đăng bài lên FB', let fbErrormessage = 'FB:' + error.message;
HttpStatus.BAD_REQUEST, // this.logger.error(error);
); if (error.response && error.response.data) {
const fbError = error.response.data.error;
fbErrormessage = fbError.constructor
this.logger.error('--- LỖI FACEBOOK API ---');
this.logger.error('Message:', fbErrormessage);
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('FB Trace ID:', fbError.fbtrace_id);
await this.notifyService.sendUrgentMessageToTele(fbErrormessage);
} else {
// 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);
}
if (throwEx) {
throw new HttpException(
fbErrormessage || 'Fb Lỗi khi đăng bài lên FB',
HttpStatus.BAD_REQUEST,
);
}
return {
success: false,
postId: 0,
error: error.message,
}
} }
} }
} }
+47 -44
View File
@@ -3,6 +3,8 @@ import {HttpException, Injectable, Logger, OnModuleDestroy, OnModuleInit,} from
import {Browser, BrowserContext, chromium, Page} from 'playwright'; import {Browser, BrowserContext, chromium, Page} from 'playwright';
import {rand} from "../helper"; import {rand} from "../helper";
import {getAccount} from "./utils/x-headers.util"; import {getAccount} from "./utils/x-headers.util";
import {XCacheService} from "../x-cache/x-cache.service";
import {NotifyService} from "../notify.service";
export interface BrowserAccount { export interface BrowserAccount {
accountId: string; accountId: string;
@@ -35,6 +37,12 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
private readonly MAX_CONTEXTS = 5; private readonly MAX_CONTEXTS = 5;
private readonly CONTEXT_TTL_MS = 15 * 60 * 1000; // 15 phút private readonly CONTEXT_TTL_MS = 15 * 60 * 1000; // 15 phút
constructor(
private readonly xCacheService: XCacheService,
private readonly notifyService: NotifyService,
) {
}
async onModuleInit() { async onModuleInit() {
// Lazy launch chỉ mở khi cần // Lazy launch chỉ mở khi cần
setInterval(() => this.cleanupStaleContexts(), 60_000); setInterval(() => this.cleanupStaleContexts(), 60_000);
@@ -141,7 +149,7 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
return ctx.newPage(); return ctx.newPage();
} }
async verifyCookie() { async verifyCookie(sendNotiWhenAlive = false): Promise<boolean> {
const page = await this.newPage(); const page = await this.newPage();
try { try {
@@ -151,25 +159,8 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
}); });
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000); await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
await page.mouse.wheel(0, rand(300, 500)); await page.mouse.wheel(0, rand(300, 500));
// Detect login/challenge screen return await this.isCookieLive(page, sendNotiWhenAlive);
if (page.url().includes('/home')) {
this.logger.log('Cookies live');
return true;
// return {
// success: false,
// error: 'Redirected to login',
// needsRelogin: 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)'}`);
// await page.close();
return isLoggedIn;
} catch (er) { } catch (er) {
this.logger.error(`Browser verify cookie fail: ${er.message}`); this.logger.error(`Browser verify cookie fail: ${er.message}`);
return false; return false;
@@ -178,6 +169,35 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
} }
} }
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) { async likeTweet(tweetUrl: string) {
let page: Page | null = null; let page: Page | null = null;
@@ -285,12 +305,7 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
}; };
} }
const isLoggedIn = await page await this.isCookieLive(page, false);
.locator('[data-testid="SideNav_AccountSwitcher_Button"], [data-testid="AppTabBar_Home_Link"]')
.first()
.isVisible()
.catch(() => false);
this.logger.debug(`postTweet: ${isLoggedIn ? 'LOGGED IN' : 'LOGGED OUT'}`);
await page.mouse.wheel(200, rand(300, 800)); await page.mouse.wheel(200, rand(300, 800));
await page.waitForTimeout(rand(2000, 5000)); await page.waitForTimeout(rand(2000, 5000));
@@ -301,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);
@@ -329,7 +345,7 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
// await page.mouse.click(btnBox?.x + btnBox.width / 2, btnBox.y + btnBox.height / 2); // await page.mouse.click(btnBox?.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
await page.keyboard.press('Control+Enter'); await page.keyboard.press('Control+Enter');
this.logger.debug('Nhấn Control+Enter done ...'); this.logger.debug('Nhấn Control+Enter done ...');
await page.waitForTimeout(5000); await page.waitForTimeout(10000);
// Chờ request CreateTweet hoàn tất // Chờ request CreateTweet hoàn tất
// await page.waitForResponse( // await page.waitForResponse(
@@ -364,13 +380,7 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
await page.waitForTimeout(rand(2000, 4000)); await page.waitForTimeout(rand(2000, 4000));
const isLoggedIn = await page await this.isCookieLive(page, false);
.locator('[data-testid="SideNav_AccountSwitcher_Button"], [data-testid="AppTabBar_Home_Link"]')
.first()
.isVisible()
.catch(() => false);
this.logger.debug(`postQuote: ${isLoggedIn ? 'LOGGED IN' : 'LOGGED OUT'}`);
// ===== CHECK LOGIN ===== // ===== CHECK LOGIN =====
if (await page.locator('input[name="text"]').count()) { if (await page.locator('input[name="text"]').count()) {
@@ -461,7 +471,7 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
this.logger.debug('❌ Nut click khong duoc, thử dùng bàn phím Control+Enter'); this.logger.debug('❌ Nut click khong duoc, thử dùng bàn phím Control+Enter');
await page.keyboard.press('Control+Enter'); await page.keyboard.press('Control+Enter');
}); });
await page.waitForTimeout(rand(4000, 6000)); await page.waitForTimeout(rand(6000, 10000));
this.logger.debug('✅ Quoted thành công'); this.logger.debug('✅ Quoted thành công');
} else { } else {
@@ -520,12 +530,7 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
this.logger.debug(`đợi UI ổn...`) this.logger.debug(`đợi UI ổn...`)
await page.waitForSelector('article', {timeout: 7000}); await page.waitForSelector('article', {timeout: 7000});
const isLoggedIn = await page await this.isCookieLive(page, false);
.locator('[data-testid="SideNav_AccountSwitcher_Button"], [data-testid="AppTabBar_Home_Link"]')
.first()
.isVisible()
.catch(() => false);
this.logger.debug(`postReply: ${isLoggedIn ? 'LOGGED IN' : 'LOGGED OUT'}`);
// scroll nhẹ // scroll nhẹ
this.logger.debug(`scroll nhẹ ...`) this.logger.debug(`scroll nhẹ ...`)
@@ -563,8 +568,8 @@ export class XBrowserService implements OnModuleInit, OnModuleDestroy {
} }
await btn.click(); await btn.click();
this.logger.debug(`nhấn nút gửi ...`) this.logger.debug(`Đã nhấn nút gửi ...`)
await page.waitForTimeout(3000); await page.waitForTimeout(10000);
this.logger.debug('✅ Reply OK'); this.logger.debug('✅ Reply OK');
return {success: true, error: ''}; return {success: true, error: ''};
@@ -576,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() {
+17 -1
View File
@@ -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(
+2
View File
@@ -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],
+22 -9
View File
@@ -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.')
@@ -93,8 +95,8 @@ export class XPosterRouterService {
if (['cookie', 'browser'].includes(method)) { if (['cookie', 'browser'].includes(method)) {
if (!canUseCookie) { if (!canUseCookie) {
await this.notifyService.sendUrgentMessageToTele('❌ Vui lòng cập nhập cookie để sử dụng ');
this.logger.error('Cookie đã hết hạn, vui lòng cập nhập'); this.logger.error('Cookie đã hết hạn, vui lòng cập nhập');
await this.notifyService.sendUrgentMessageToTele('❌ Vui lòng cập nhập cookie để sử dụng ');
continue; continue;
} }
} }
@@ -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) {
@@ -339,17 +342,22 @@ export class XPosterRouterService {
// success: true, // success: true,
// } // }
this.logger.error(`cookie not supported`); this.logger.error(`cookie not supported`);
// return { return {
// success: false, success: false,
// error: 'Cookie not supported', error: 'Cookie not supported',
// } }
} }
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) {
+522
View File
@@ -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.');
}
}
}