Tự động hóa UI Facebook bằng Chrome extension: bài học xương máu về chọn selector khi DOM đổi liên tục
Mình build một Chrome extension tự động hóa thao tác trên Facebook (comment, đăng bài, inbox) chạy trực tiếp trong tab của người dùng. Nghe thì đơn giản: tìm nút, click, gõ chữ. Làm thật thì Facebook là một trong những DOM khó tự động hóa nhất mình từng gặp: class hash đổi theo build, label đổi theo ngôn ngữ người dùng, và cùng một màn hình có thể có nhiều role="dialog" chồng nhau.
Bài này tổng hợp mấy bài học chính, kèm code. Hy vọng đỡ được ai đang làm tool tương tự (không riêng Facebook, các SPA lớn như LinkedIn, X đều cùng bệnh).
1. Đừng bao giờ match nút bằng text hoặc aria-label
Bug khó chịu nhất mình từng gặp: nút "Đăng" tìm bằng text chạy ngon trên máy mình, sang máy khách thì chết. Lý do đơn giản đến mức xấu hổ: Facebook render label theo ngôn ngữ CỦA TỪNG USER. Máy mình là "Đăng", máy khách là "Post", user Đức là "Posten".
Xây list label mọi ngôn ngữ (['Đăng', 'Post', 'Posten', ...]) là con đường đau khổ: không bao giờ đủ, và vỡ im lặng khi Facebook đổi wording.
Cách bền hơn: match bằng cấu trúc + trạng thái ARIA, thứ giống nhau ở mọi locale:
function findPrimaryActionButton(dialog: HTMLElement): HTMLElement | null {
const candidates = Array.from(
dialog.querySelectorAll<HTMLElement>('div[role="button"][tabindex="0"]')
).filter((btn) => {
// 1. Nút ENABLED: Facebook XÓA HẲN aria-disabled khi nút sẵn sàng,
// chỉ set aria-disabled="true" khi disabled.
// => filter khác "true", ĐỪNG đòi attribute phải tồn tại.
if (btn.getAttribute('aria-disabled') === 'true') return false;
// 2. Có span text (loại nút icon-only như nút X đóng dialog).
// Check text TỒN TẠI, không đọc nội dung text.
const label = btn.querySelector('span[dir="auto"]');
if (!label?.textContent?.trim()) return false;
// 3. Có press-overlay đặc trưng của nút action thật.
return !!btn.querySelector('div[role="none"][data-visualcompletion="ignore"]');
});
// Nút primary luôn nằm cuối cùng (phải/dưới) trong dialog
return candidates[candidates.length - 1] ?? null;
}
Điểm hay: cùng một hàm này dùng được cho cả bước "Tiếp" lẫn bước "Đăng" của composer, vì ở mỗi bước, nút primary đang enabled chính là nó.
2. Cái bẫy aria-disabled
Đáng nhắc lại vì mình mất nguyên buổi debug: intuition thường là "nút enabled thì có aria-disabled="false"". Sai với Facebook. Khi nút sẵn sàng, attribute biến mất hoàn toàn. Nếu bạn viết btn.getAttribute('aria-disabled') === 'false' thì sẽ không bao giờ tìm thấy nút nào.
3. Nhiều dialog cùng lúc: đừng tin dialog.last()
Facebook có thói quen để lại các role="dialog" rỗng trùng tên trong DOM, cộng thêm panel của chính extension bạn nếu nó cũng dùng role dialog. Lấy dialog cuối cùng là ăn nhầm hộp rỗng. Cách đúng: scope tới dialog có form/editor bên trong (ví dụ có contenteditable hoặc textbox), rồi mới tìm nút trong đó.
4. Polling thay cho setTimeout
DOM của SPA không có "load xong". Mọi chỗ chờ đợi mình đều dùng waitFor dạng poll điều kiện + timeout, thay vì setTimeout số giây cứng:
async function waitFor<T>(
fn: () => T | null,
{ timeout = 10_000, interval = 200 } = {}
): Promise<T | null> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const result = fn();
if (result) return result;
await new Promise((r) => setTimeout(r, interval));
}
return null; // caller quyết định retry hay bỏ qua, KHÔNG fallback mù
}
Quan trọng không kém: khi waitFor trả null thì dừng và log lý do, đừng fallback sang "click đại nút cuối cùng". Fallback mù là cách nhanh nhất để tool đi click nhầm nút Xóa của người ta.
5. Log mọi quyết định skip
Tool tự động chạy trên máy khách hàng, bạn không debug trực tiếp được. Mỗi action mình đều log start/end và lý do skip ("không tìm thấy composer sau 10s", "nút Đăng còn disabled"). 80% ticket support tự trả lời được nhờ đống log này.
Toàn bộ kỹ thuật trên đang chạy thật trong GCare, extension tự động tìm khách trên Facebook cho shop online mà mình phát triển. Nếu bạn đang build tool tự động hóa cho một SPA lớn nào đó, hy vọng tiết kiệm được cho bạn vài buổi tối debug. Có kỹ thuật nào hay hơn thì comment trao đổi nhé.
All rights reserved