diff --git a/config.py b/config.py index e69de29..b6b826b 100644 --- a/config.py +++ b/config.py @@ -0,0 +1,5 @@ +import os + +# 📂 Thư mục profiles gốc +PROFILES_DIR = os.path.join(os.getcwd(), "profiles") +TEMPLATE_DIR = os.path.join(os.getcwd(), "templates") diff --git a/database/db.py b/database/db.py index 12a5097..397fb1d 100644 --- a/database/db.py +++ b/database/db.py @@ -18,7 +18,8 @@ def create_tables(): id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, - is_active INTEGER DEFAULT 1 + is_active INTEGER DEFAULT 1, + login_at INTEGER DEFAULT NULL ) ''') diff --git a/facebook_marketplace.db b/facebook_marketplace.db index 2684a4a..b296e94 100644 Binary files a/facebook_marketplace.db and b/facebook_marketplace.db differ diff --git a/fb_window.py b/fb_window.py new file mode 100644 index 0000000..ad19b16 --- /dev/null +++ b/fb_window.py @@ -0,0 +1,217 @@ +import os +import sys +import cv2 +import numpy as np +from PyQt5.QtCore import Qt, QUrl, QTimer +from PyQt5.QtWidgets import ( + QApplication, + QMainWindow, + QVBoxLayout, + QWidget, + QPushButton, + QLabel, + QDialog, +) +from PyQt5.QtWebEngineWidgets import QWebEngineView +from PyQt5.QtGui import QPixmap, QImage +from services.action_service import ActionService # ✅ JS version (fake click/type) + + +class FBWindow(QMainWindow): + def __init__(self, template_dir="templates", delay=0.1): + super().__init__() + self.template_dir = os.path.abspath(template_dir) + self.delay = delay + + if not os.path.exists(self.template_dir): + raise FileNotFoundError(f"Template dir not found: {self.template_dir}") + + # --- UI --- + self.setWindowTitle("FB Auto Vision Login") + self.resize(1200, 800) + + self.web = QWebEngineView() + self.web.setUrl(QUrl("https://facebook.com")) + + self.btn_detect = QPushButton("Detect Inputs") + self.btn_detect.clicked.connect(self.detect_inputs_from_view) + + self.status = QLabel("Status: Ready") + self.status.setAlignment(Qt.AlignLeft) + + layout = QVBoxLayout() + layout.addWidget(self.web) + layout.addWidget(self.btn_detect) + layout.addWidget(self.status) + + container = QWidget() + container.setLayout(layout) + self.setCentralWidget(container) + + # --- Action service --- + self.action = ActionService(webview=self.web, delay=self.delay) + + # ---------------------------------------------------- + def capture_webview(self): + """Chụp nội dung webview → numpy array BGR""" + pixmap = self.web.grab() + if pixmap.isNull(): + return None + + qimg = pixmap.toImage().convertToFormat(QImage.Format.Format_RGBA8888) + width, height = qimg.width(), qimg.height() + ptr = qimg.bits() + ptr.setsize(height * width * 4) + arr = np.frombuffer(ptr, np.uint8).reshape((height, width, 4)) + bgr = cv2.cvtColor(arr, cv2.COLOR_RGBA2BGR) + return bgr + + # ---------------------------------------------------- + def detect_inputs_from_view(self): + """Chụp ảnh webview và dò template (lọc trùng lặp)""" + screen = self.capture_webview() + if screen is None: + self.status.setText("Status: Unable to capture webview") + return + + annotated = screen.copy() + regions = [] + print("[INFO] Bắt đầu nhận diện template...") + + # --- Duyệt toàn bộ thư mục con --- + for root, _, files in os.walk(self.template_dir): + folder_name = os.path.basename(root).lower() + + for file in files: + if not file.lower().endswith((".png", ".jpg", ".jpeg")): + continue + + template_path = os.path.join(root, file) + template = cv2.imread(template_path) + if template is None: + continue + + res = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED) + threshold = 0.75 + loc = np.where(res >= threshold) + + for pt in zip(*loc[::-1]): + top_left = (int(pt[0]), int(pt[1])) + bottom_right = ( + int(pt[0] + template.shape[1]), + int(pt[1] + template.shape[0]), + ) + score = float(res[pt[1], pt[0]]) + regions.append((folder_name, file, top_left, bottom_right, score)) + + # --- Lọc bớt trùng bằng Non-Max Suppression --- + filtered = self.non_max_suppression(regions, overlap_thresh=0.3) + + if not filtered: + self.status.setText("[WARN] Không phát hiện được gì") + print("[WARN] Không phát hiện được gì") + return + + # --- Vẽ preview --- + for folder_name, _, top_left, bottom_right, _ in filtered: + cv2.rectangle(annotated, top_left, bottom_right, (0, 255, 0), 2) + cv2.putText( + annotated, + folder_name, + (top_left[0], top_left[1] - 5), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (0, 255, 0), + 2, + ) + + self.status.setText(f"[INFO] Phát hiện {len(filtered)} vùng hợp lệ") + print(f"[INFO] Phát hiện {len(filtered)} vùng hợp lệ") + + self.show_preview(annotated) + QTimer.singleShot(800, lambda: self.autofill_by_detection(filtered)) + + # ---------------------------------------------------- + def non_max_suppression(self, regions, overlap_thresh=0.3): + """Giảm trùng vùng detect""" + if not regions: + return [] + + boxes = [] + for folder_name, file, top_left, bottom_right, score in regions: + x1, y1 = top_left + x2, y2 = bottom_right + boxes.append([x1, y1, x2, y2, score, folder_name, file]) + boxes = sorted(boxes, key=lambda x: x[4], reverse=True) + + pick = [] + while boxes: + current = boxes.pop(0) + pick.append(current) + boxes = [ + b + for b in boxes + if b[5] != current[5] # khác folder_name (chỉ giữ 1 mỗi loại) + and self.iou(b, current) < overlap_thresh + ] + return [ + (b[5], b[6], (int(b[0]), int(b[1])), (int(b[2]), int(b[3])), b[4]) + for b in pick + ] + + # ---------------------------------------------------- + def iou(self, boxA, boxB): + """Intersection-over-Union""" + xA = max(boxA[0], boxB[0]) + yA = max(boxA[1], boxB[1]) + xB = min(boxA[2], boxB[2]) + yB = min(boxA[3], boxB[3]) + interArea = max(0, xB - xA) * max(0, yB - yA) + boxAArea = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]) + boxBArea = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]) + iou = interArea / float(boxAArea + boxBArea - interArea + 1e-5) + return iou + + # ---------------------------------------------------- + def autofill_by_detection(self, regions): + """Tự động click/gõ text theo vùng detect""" + for folder_name, filename, top_left, bottom_right, score in regions: + print(f"[ACTION] {folder_name}: {filename} ({score:.2f})") + label = folder_name.lower() + + if "user" in label or "email" in label: + print("[DO] Điền email...") + self.action.write_in_region( + top_left, bottom_right, "myemail@example.com" + ) + + elif "pass" in label: + print("[DO] Điền mật khẩu...") + self.action.write_in_region(top_left, bottom_right, "mypassword123") + + elif "button" in label or "login" in label: + print("[DO] Click nút đăng nhập... (tạm comment)") + # self.action.click_center_of_region(top_left, bottom_right) + + # ---------------------------------------------------- + def show_preview(self, bgr_img): + """Hiển thị preview trong PyQt5""" + rgb = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2RGB) + h, w = rgb.shape[:2] + qimg = QImage(rgb.data, w, h, rgb.strides[0], QImage.Format.Format_RGB888) + pix = QPixmap.fromImage(qimg) + + dlg = QDialog(self) + dlg.setWindowTitle("Detection Preview") + v_layout = QVBoxLayout(dlg) + lbl = QLabel() + lbl.setPixmap(pix.scaled(800, 600, Qt.KeepAspectRatio)) + v_layout.addWidget(lbl) + dlg.exec_() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + win = FBWindow() + win.show() + sys.exit(app.exec_()) diff --git a/gui/core/login_handle_dialog.py b/gui/core/login_handle_dialog.py index 46878d0..e0eb26d 100644 --- a/gui/core/login_handle_dialog.py +++ b/gui/core/login_handle_dialog.py @@ -1,44 +1,80 @@ -# gui/dialogs/login_handle_dialog.py -from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton -from PyQt5.QtCore import QTimer, Qt +# gui/core/login_handle_dialog.py +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QPushButton, QApplication +from PyQt5.QtCore import QTimer +from services.core.log_service import log_service +from stores.shared_store import SharedStore from gui.global_signals import global_signals + class LoginHandleDialog(QDialog): - def __init__(self, account_id=None, duration=10, parent=None): - super().__init__(parent) + dialog_width = 300 + dialog_height = 100 + margin = 10 # khoảng cách giữa các dialog + + # Lưu danh sách dialog đang mở (dùng class variable để chia sẻ giữa tất cả dialog) + open_dialogs = [] + + def __init__(self, account_id: int, listed_id: int): + super().__init__() self.account_id = account_id - self.duration = duration - self.elapsed = 0 + self.listed_id = listed_id - self.setWindowTitle(f"Login Handle - Account {self.account_id}") - self.setFixedSize(300, 150) + self.setWindowTitle(f"Handle Listing {self.listed_id}") + self.setModal(False) # modeless + self.resize(self.dialog_width, self.dialog_height) + # --- UI đơn giản --- layout = QVBoxLayout() - self.label = QLabel(f"Processing account_id={self.account_id}...") - self.label.setAlignment(Qt.AlignCenter) - layout.addWidget(self.label) - - self.progress = QProgressBar() - self.progress.setRange(0, self.duration) - layout.addWidget(self.progress) - - self.btn_cancel = QPushButton("Cancel") - self.btn_cancel.clicked.connect(self.reject) - layout.addWidget(self.btn_cancel) - + self.btn_finish = QPushButton("Finish Listing") + self.btn_finish.clicked.connect(self.finish_listing) + layout.addWidget(self.btn_finish) self.setLayout(layout) - self.timer = QTimer(self) - self.timer.timeout.connect(self._update_progress) - self.timer.start(1000) + # --- Tính vị trí để xếp dialog từ góc trái trên cùng sang phải theo hàng ngang --- + self.move_to_corner() + LoginHandleDialog.open_dialogs.append(self) - def _update_progress(self): - self.elapsed += 1 - self.progress.setValue(self.elapsed) - if self.elapsed >= self.duration: - self.close() + def move_to_corner(self): + screen_geometry = QApplication.primaryScreen().availableGeometry() + start_x = screen_geometry.left() + self.margin + start_y = screen_geometry.top() + self.margin - def closeEvent(self, event): - """Emit signal khi dialog đóng""" - global_signals.dialog_finished.emit(self.account_id) - super().closeEvent(event) + row_height = 0 + current_x = start_x + current_y = start_y + + for dlg in LoginHandleDialog.open_dialogs: + # Nếu vượt chiều ngang màn hình, xuống hàng mới + if current_x + dlg.width() + self.margin > screen_geometry.right(): + current_x = start_x + current_y += row_height + self.margin + row_height = 0 + + dlg.move(current_x, current_y) + current_x += dlg.width() + self.margin + row_height = max(row_height, dlg.height()) + + # vị trí dialog mới + if current_x + self.width() + self.margin > screen_geometry.right(): + current_x = start_x + current_y += row_height + self.margin + + self.move(current_x, current_y) + + def finish_listing(self): + """Remove khỏi SharedStore, emit signal và đóng dialog""" + try: + store = SharedStore.get_instance() + store.remove(self.listed_id) + log_service.info(f"[Dialog] Removed listed_id={self.listed_id} from SharedStore") + + # Emit signal để MainWindow biết + QTimer.singleShot(0, lambda: global_signals.dialog_finished.emit(self.account_id, self.listed_id)) + + # Close dialog an toàn + self.hide() + self.deleteLater() + if self in LoginHandleDialog.open_dialogs: + LoginHandleDialog.open_dialogs.remove(self) + except Exception as e: + log_service.error(f"[Dialog] Exception in finish_listing for listed_id={self.listed_id}: {e}") diff --git a/gui/global_signals.py b/gui/global_signals.py index 973dc27..68ace94 100644 --- a/gui/global_signals.py +++ b/gui/global_signals.py @@ -3,5 +3,7 @@ from PyQt5.QtCore import QObject, pyqtSignal class GlobalSignals(QObject): listed_finished = pyqtSignal() + open_login_dialog = pyqtSignal(int, int) # account_id, listed_id + dialog_finished = pyqtSignal(int, int) # account_id, listed_id -global_signals = GlobalSignals() +global_signals = GlobalSignals() \ No newline at end of file diff --git a/gui/handle/login_fb.py b/gui/handle/login_fb.py new file mode 100644 index 0000000..3686ac7 --- /dev/null +++ b/gui/handle/login_fb.py @@ -0,0 +1,174 @@ +# gui/handle/login_fb.py +import os +import sys +import cv2 +import numpy as np +from PyQt5.QtCore import Qt, QUrl, QTimer +from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QLabel, QTextEdit, QPushButton +from PyQt5.QtWebEngineWidgets import QWebEngineView +from PyQt5.QtGui import QImage, QPixmap + +from services.action_service import ActionService +from services.detect_service import DetectService +from config import TEMPLATE_DIR + + +class LoginFB(QMainWindow): + def __init__(self, account=None, delay=0.1): + super().__init__() + self.account = account or {} + self.template_dir = os.path.abspath(TEMPLATE_DIR) + self.delay = delay + + # --- Detect services --- + self.detector = DetectService( + template_dir=TEMPLATE_DIR, + target_labels=["username", "password", "buttons/login"] + ) + + # --- Detect login fail templates --- + self.fail_detector = DetectService( + template_dir=os.path.join(TEMPLATE_DIR, "login_fail") + ) + + # --- UI --- + self.setWindowTitle("FB Auto Vision Login") + self.setFixedSize(480, 680) + + self.web = QWebEngineView() + self.web.setUrl(QUrl("https://facebook.com")) + self.web.setFixedSize(480, 480) + + self.status = QLabel("Status: Ready") + self.status.setAlignment(Qt.AlignLeft) + self.status.setFixedHeight(20) + + self.log_area = QTextEdit() + self.log_area.setReadOnly(True) + self.log_area.setFixedHeight(120) + self.log_area.setStyleSheet(""" + background-color: #1e1e1e; + color: #dcdcdc; + font-size: 12px; + font-family: Consolas, monospace; + """) + + self.btn_refresh = QPushButton("Refresh") + self.btn_refresh.setFixedHeight(30) + self.btn_refresh.clicked.connect(self.refresh_page) + + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.web) + layout.addWidget(self.status) + layout.addWidget(self.log_area) + layout.addWidget(self.btn_refresh) + + container = QWidget() + container.setLayout(layout) + self.setCentralWidget(container) + + self.action = ActionService(webview=self.web, delay=self.delay) + self.login_clicked = False + + # Giữ reference popup (nếu cần) – hiện tại không dùng + # self.login_fail_popup = None + + self.web.loadFinished.connect(self.on_web_loaded) + + # ---------------------------------------------------- + def log(self, message: str): + self.log_area.append(message) + print(message) + + # ---------------------------------------------------- + def capture_webview(self): + pixmap = self.web.grab() + if pixmap.isNull(): + return None + qimg = pixmap.toImage().convertToFormat(QImage.Format_RGBA8888) + width, height = qimg.width(), qimg.height() + ptr = qimg.bits() + ptr.setsize(height * width * 4) + arr = np.frombuffer(ptr, np.uint8).reshape((height, width, 4)) + return cv2.cvtColor(arr, cv2.COLOR_RGBA2BGR) + + # ---------------------------------------------------- + def on_web_loaded(self, ok=True): + """Called every time page load finished""" + if not ok: + self.log("[ERROR] Page failed to load") + self.status.setText("Status: Page load failed") + return + + screen = self.capture_webview() + if screen is None: + self.status.setText("Status: Unable to capture webview") + self.log("Status: Unable to capture webview") + return + + # Nếu đang sau nhấn login, check login failure + if self.login_clicked: + self.log("[INFO] Page loaded after login click. Detecting login failure...") + fail_regions = self.fail_detector.detect(screen) + + if fail_regions: + self.log(f"[FAIL] Login failed detected via template ({len(fail_regions)} regions):") + for folder_name, filename, top_left, bottom_right, score in fail_regions: + self.log(f" - {filename} @ {top_left}-{bottom_right} (score: {score:.2f})") + + self.status.setText("Status: Login failed") + else: + self.log("[SUCCESS] Login seems successful!") + self.status.setText("Status: Login successful") + + self.login_clicked = False + return + + # Nếu là initial page load + self.log("[INFO] Page loaded. Starting template detection...") + regions = self.detector.detect(screen) + + if not regions: + self.status.setText("[WARN] No regions detected") + self.log("[WARN] No regions detected") + return + + self.status.setText(f"[INFO] Detected {len(regions)} valid regions") + self.log(f"[INFO] Detected {len(regions)} valid regions") + QTimer.singleShot(500, lambda: self.autofill_by_detection(regions)) + + # ---------------------------------------------------- + def autofill_by_detection(self, regions): + email = self.account.get("email", "") + password = self.account.get("password", "") + + for folder_name, filename, top_left, bottom_right, score in regions: + self.log(f"[ACTION] {folder_name}: {filename} ({score:.2f})") + label = folder_name.lower() + + if ("user" in label or "email" in label) and email: + self.log(f"[DO] Filling email: {email}") + self.action.write_in_region(top_left, bottom_right, email) + + elif "pass" in label and password: + self.log(f"[DO] Filling password: {'*' * len(password)}") + self.action.write_in_region(top_left, bottom_right, password) + + elif "button" in label or "login" in label: + self.log("[DO] Clicking login button...") + self.login_clicked = True + self.action.click_center_of_region(top_left, bottom_right) + + # ---------------------------------------------------- + def refresh_page(self): + self.log("[INFO] Refreshing page...") + self.web.reload() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + fake_account = {"email": "test@example.com", "password": "123456"} + win = LoginFB(account=fake_account) + win.show() + sys.exit(app.exec_()) diff --git a/gui/main_window.py b/gui/main_window.py index 821a78d..ac95712 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -1,17 +1,14 @@ -# gui/main_window.py - from PyQt5.QtWidgets import QMainWindow, QTabWidget, QMessageBox from gui.tabs.accounts.account_tab import AccountTab from gui.tabs.products.product_tab import ProductTab from gui.tabs.import_tab import ImportTab from gui.tabs.listeds.listed_tab import ListedTab from gui.tabs.settings.settings_tab import SettingsTab -from gui.global_signals import global_signals from gui.core.login_handle_dialog import LoginHandleDialog +from stores.shared_store import SharedStore +from tasks.listed_tasks import start_background_listed +from gui.global_signals import global_signals -# Task runner -from tasks.task_runner import TaskRunner -from tasks.listed_tasks import process_all_listed # Hàm queue mới, 2 dialog class MainWindow(QMainWindow): def __init__(self): @@ -19,7 +16,7 @@ class MainWindow(QMainWindow): self.setWindowTitle("Facebook Marketplace Manager") self.resize(1200, 600) - # --- Tạo QTabWidget --- + # --- Tabs --- self.tabs = QTabWidget() self.account_tab = AccountTab() self.product_tab = ProductTab() @@ -33,54 +30,55 @@ class MainWindow(QMainWindow): self.tabs.addTab(self.import_tab, "Import Data") self.tabs.addTab(self.setting_tab, "Setting") - # Gắn sự kiện khi tab thay đổi self.tabs.currentChanged.connect(self.on_tab_changed) - self.setCentralWidget(self.tabs) - - # Khi mở app thì chỉ load tab đầu tiên (Accounts) self.on_tab_changed(0) - # --- Kết nối signals --- - global_signals.open_login_dialog.connect(self.show_login_dialog) - global_signals.listed_finished.connect(self.on_listed_task_finished) + # # --- Signals --- + # global_signals.listed_finished.connect(self.on_listed_task_finished) + # global_signals.dialog_finished.connect(self.on_dialog_finished) + # global_signals.open_login_dialog.connect(self.show_login_dialog) - # --- 🔥 Khởi chạy queue background khi app mở --- - self.start_background_tasks() + # # --- Start background --- + # start_background_listed() - # ---------------- Dialog handler ---------------- - def show_login_dialog(self, account_id): - """Mở dialog xử lý account_id, modeless để không block MainWindow""" - dialog = LoginHandleDialog(account_id=account_id) + # --- Store opened dialogs --- + self.login_dialogs = [] + + def show_login_dialog(self, account_id, listed_id): + print(account_id, listed_id) + dialog = LoginHandleDialog(account_id=account_id, listed_id=listed_id) dialog.show() # modeless + self.login_dialogs.append(dialog) - # ---------------- Background tasks ---------------- - def start_background_tasks(self): - """Khởi động các background task khi app mở.""" - self.task_runner = TaskRunner() - self.task_runner.start() + # Khi dialog đóng, remove khỏi list và SharedStore + def on_dialog_close(): + if dialog in self.login_dialogs: + self.login_dialogs.remove(dialog) + self.on_dialog_finished(account_id, listed_id) - # Thêm task xử lý listed pending (dùng hàm queue + 2 dialog cùng lúc) - self.task_runner.add_task(process_all_listed) - print("[App] Background task runner started") + dialog.finished.connect(on_dialog_close) + + def on_dialog_finished(self, account_id, listed_id): + """Dialog xong, remove khỏi store""" + store = SharedStore.get_instance() + store.remove(listed_id) + + def on_tab_changed(self, index): + tab = self.tabs.widget(index) + if hasattr(tab, "load_data") and not getattr(tab, "is_loaded", False): + tab.load_data() + tab.is_loaded = True - # ---------------- Listed finished ---------------- def on_listed_task_finished(self): - """Hiển thị thông báo khi listed task hoàn tất""" QMessageBox.information( self, "Auto Listing", "All pending listed items have been processed!" ) - # ---------------- Tab handling ---------------- - def on_tab_changed(self, index): - """Chỉ load nội dung tab khi được active.""" - tab = self.tabs.widget(index) - - # Mỗi tab có thể có hàm load_data() riêng - if hasattr(tab, "load_data"): - # Thêm cờ để tránh load lại nhiều lần không cần thiết - if not getattr(tab, "is_loaded", False): - tab.load_data() - tab.is_loaded = True + def closeEvent(self, event): + """Khi MainWindow đóng, đóng hết tất cả dialog đang mở""" + for dialog in self.login_dialogs[:]: # copy list để tránh lỗi khi remove + dialog.close() + event.accept() diff --git a/gui/tabs/accounts/account_tab.py b/gui/tabs/accounts/account_tab.py index 715410a..019de5f 100644 --- a/gui/tabs/accounts/account_tab.py +++ b/gui/tabs/accounts/account_tab.py @@ -1,3 +1,5 @@ +import os +from functools import partial from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, QHBoxLayout, QDialog, QLabel, QLineEdit, QComboBox, QMessageBox, @@ -8,6 +10,10 @@ from PyQt5.QtGui import QFont from PyQt5.QtWidgets import QHeaderView from database.models import Account from .forms.account_form import AccountForm +from config import PROFILES_DIR + +# 👇 import cửa sổ login FB +from gui.handle.login_fb import LoginFB # chỉnh path này theo project của bạn PAGE_SIZE = 10 @@ -17,11 +23,19 @@ class AccountTab(QWidget): self.current_page = 0 layout = QVBoxLayout() - # Add button + # --- Top bar --- + top_layout = QHBoxLayout() self.add_btn = QPushButton("Add Account") - self.add_btn.setMinimumWidth(120) self.add_btn.clicked.connect(self.add_account) - layout.addWidget(self.add_btn) + top_layout.addWidget(self.add_btn) + + # 🆕 Action menu + self.options_btn = QPushButton("Action") + self.options_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.options_btn.setMinimumWidth(80) + self.options_btn.setMaximumWidth(120) + top_layout.addWidget(self.options_btn) + layout.addLayout(top_layout) # Table self.table = QTableWidget() @@ -43,6 +57,7 @@ class AccountTab(QWidget): layout.addLayout(pag_layout) self.setLayout(layout) + self.update_options_menu() self.load_data() def load_data(self): @@ -52,43 +67,71 @@ class AccountTab(QWidget): page_items = accounts[start:end] self.table.setRowCount(len(page_items)) - self.table.setColumnCount(4) - self.table.setHorizontalHeaderLabels(["ID", "Email", "Status", "Actions"]) + self.table.setColumnCount(6) + self.table.setHorizontalHeaderLabels([ + "ID", "Email", "Status", "Profile Exists", "Login At", "Actions" + ]) for i, acc in enumerate(page_items): - # convert sqlite3.Row -> dict acc_dict = {k: acc[k] for k in acc.keys()} self.table.setItem(i, 0, QTableWidgetItem(str(acc_dict["id"]))) self.table.setItem(i, 1, QTableWidgetItem(acc_dict["email"])) self.table.setItem(i, 2, QTableWidgetItem("Active" if acc_dict["is_active"] == 1 else "Inactive")) - # nút menu Actions + # ✅ Check profile folder + folder_name = acc_dict["email"] + profile_path = os.path.join(PROFILES_DIR, folder_name) + profile_status = "True" if os.path.isdir(profile_path) else "False" + self.table.setItem(i, 3, QTableWidgetItem(profile_status)) + + # 🆕 Login At + login_at_value = acc_dict.get("login_at") or "-" + self.table.setItem(i, 4, QTableWidgetItem(str(login_at_value))) + + # Actions btn_menu = QPushButton("Actions") menu = QMenu() + + # 🆕 Login action + action_login = QAction("Login", btn_menu) + action_login.triggered.connect(lambda _, a=acc_dict: self.open_login_window(a)) + menu.addAction(action_login) + + # Edit action_edit = QAction("Edit", btn_menu) action_edit.triggered.connect(lambda _, a=acc_dict: self.edit_account(a)) menu.addAction(action_edit) + # Delete action_delete = QAction("Delete", btn_menu) action_delete.triggered.connect(lambda _, a=acc_dict: self.delete_account(a)) menu.addAction(action_delete) btn_menu.setMenu(menu) - self.table.setCellWidget(i, 3, btn_menu) + self.table.setCellWidget(i, 5, btn_menu) - # Phân bổ column width hợp lý + # Column sizing header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # ID - header.setSectionResizeMode(1, QHeaderView.Stretch) # Email stretch - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Status - header.setSectionResizeMode(3, QHeaderView.Fixed) # Actions fixed width - self.table.setColumnWidth(3, 100) # 100px cho Actions + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) + header.setSectionResizeMode(4, QHeaderView.ResizeToContents) + header.setSectionResizeMode(5, QHeaderView.Fixed) + self.table.setColumnWidth(5, 100) - # Enable/disable pagination buttons self.prev_btn.setEnabled(self.current_page > 0) self.next_btn.setEnabled(end < len(accounts)) + # 🆕 Action menu + def update_options_menu(self): + menu = QMenu() + action_reload = QAction("Reload", menu) + action_reload.triggered.connect(lambda: self.load_data()) + menu.addAction(action_reload) + self.options_btn.setMenu(menu) + def add_account(self): form = AccountForm(self) if form.exec_(): @@ -102,7 +145,7 @@ class AccountTab(QWidget): def delete_account(self, account): confirm = QMessageBox.question( - self, "Confirm", f"Delete account {account['email']}?", + self, "Confirm", f"Delete account {account['email']}?", QMessageBox.Yes | QMessageBox.No ) if confirm == QMessageBox.Yes: @@ -116,3 +159,8 @@ class AccountTab(QWidget): def prev_page(self): self.current_page -= 1 self.load_data() + + # 🆕 Mở cửa sổ login FB + def open_login_window(self, account): + self.login_window = LoginFB(account) # truyền account vào nếu class LoginFB có nhận + self.login_window.show() diff --git a/services/action_service.py b/services/action_service.py new file mode 100644 index 0000000..9dd4361 --- /dev/null +++ b/services/action_service.py @@ -0,0 +1,91 @@ +from PyQt5.QtCore import Qt, QTimer + + +class ActionService: + """ + Service mô phỏng hành động người dùng trong QWebEngineView + bằng JavaScript (click, gõ, nhấn phím), KHÔNG chiếm quyền chuột. + """ + + def __init__(self, webview=None, delay=0.05): + """ + webview: QWebEngineView để thao tác + delay: thời gian nghỉ giữa các thao tác (giây) + """ + self.webview = webview + self.delay = delay + + # ---------------------------------------------------------------------- + def _run_js(self, script): + """Chạy JavaScript trên webview""" + if not self.webview: + print("[WARN] Không có webview để chạy JS.") + return + self.webview.page().runJavaScript(script) + + # ---------------------------------------------------------------------- + def click_center_of_region(self, top_left, bottom_right): + """Click bằng JS vào vùng detect""" + if not self.webview: + print("[WARN] Không có webview để click.") + return + + x = (top_left[0] + bottom_right[0]) // 2 + y = (top_left[1] + bottom_right[1]) // 2 + + script = f""" + (function() {{ + const el = document.elementFromPoint({x}, {y}); + if (el) {{ + el.focus(); + el.click(); + console.log("Clicked element:", el.tagName); + }} else {{ + console.log("Không tìm thấy element tại {x},{y}"); + }} + }})(); + """ + self._run_js(script) + + # ---------------------------------------------------------------------- + def write_in_region(self, top_left, bottom_right, text): + """Click + gõ text vào vùng detect bằng JS""" + if not self.webview: + print("[WARN] Không có webview để gõ text.") + return + + x = (top_left[0] + bottom_right[0]) // 2 + y = (top_left[1] + bottom_right[1]) // 2 + safe_text = str(text).replace('"', '\\"') + + script = f""" + (function() {{ + const el = document.elementFromPoint({x}, {y}); + if (el) {{ + el.focus(); + el.value = "{safe_text}"; + const inputEvent = new Event('input', {{ bubbles: true }}); + el.dispatchEvent(inputEvent); + console.log("Gõ text vào:", el.tagName); + }} else {{ + console.log("Không tìm thấy element tại {x},{y}"); + }} + }})(); + """ + self._run_js(script) + + # ---------------------------------------------------------------------- + def press_key(self, key="Enter"): + """Nhấn phím bằng JS""" + if not self.webview: + print("[WARN] Không có webview để nhấn phím.") + return + + script = f""" + (function() {{ + const evt = new KeyboardEvent('keydown', {{ key: '{key}', bubbles: true }}); + document.activeElement && document.activeElement.dispatchEvent(evt); + console.log("Nhấn phím:", '{key}'); + }})(); + """ + self._run_js(script) diff --git a/services/detect_service.py b/services/detect_service.py new file mode 100644 index 0000000..f4ca673 --- /dev/null +++ b/services/detect_service.py @@ -0,0 +1,104 @@ +# services/detect_service.py +import os +import cv2 +import numpy as np +from typing import List + + +class DetectService: + def __init__( + self, + template_dir: str, + target_labels: List[str] = None, + threshold: float = 0.75, + overlap_thresh: float = 0.3, + ): + """ + template_dir: thư mục chứa các template + target_labels: danh sách folder tương ứng các field cần detect, ví dụ ["username", "password", "buttons/login"] + """ + self.template_dir = os.path.abspath(template_dir) + self.threshold = threshold + self.overlap_thresh = overlap_thresh + self.target_labels = [t.lower() for t in target_labels] if target_labels else None + + if not os.path.exists(self.template_dir): + raise FileNotFoundError(f"Template dir not found: {self.template_dir}") + + # ---------------------------------------------------- + def detect(self, screen): + """ + screen: ảnh chụp màn hình (numpy BGR) + return: list [(label, filename, top_left, bottom_right, score)] + """ + regions = [] + + for root, _, files in os.walk(self.template_dir): + rel_path = os.path.relpath(root, self.template_dir).replace("\\", "/").lower() + + # nếu target_labels được định nghĩa thì chỉ detect những folder cần thiết + if self.target_labels and rel_path not in self.target_labels: + continue + + for file in files: + if not file.lower().endswith((".png", ".jpg", ".jpeg")): + continue + + template_path = os.path.join(root, file) + template = cv2.imread(template_path) + if template is None: + continue + + res = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED) + loc = np.where(res >= self.threshold) + + for pt in zip(*loc[::-1]): + top_left = (int(pt[0]), int(pt[1])) + bottom_right = ( + int(pt[0] + template.shape[1]), + int(pt[1] + template.shape[0]) + ) + score = float(res[pt[1], pt[0]]) + regions.append((rel_path, file, top_left, bottom_right, score)) + + return self.non_max_suppression(regions) + + # ---------------------------------------------------- + def non_max_suppression(self, regions): + """Giảm trùng vùng detect""" + if not regions: + return [] + + boxes = [] + for label, file, top_left, bottom_right, score in regions: + x1, y1 = top_left + x2, y2 = bottom_right + boxes.append([x1, y1, x2, y2, score, label, file]) + boxes = sorted(boxes, key=lambda x: x[4], reverse=True) + + pick = [] + while boxes: + current = boxes.pop(0) + pick.append(current) + boxes = [ + b for b in boxes + if b[5] != current[5] and self.iou(b, current) < self.overlap_thresh + ] + + return [ + (b[5], b[6], (int(b[0]), int(b[1])), (int(b[2]), int(b[3])), b[4]) + for b in pick + ] + + # ---------------------------------------------------- + def iou(self, boxA, boxB): + """Intersection-over-Union""" + xA = max(boxA[0], boxB[0]) + yA = max(boxA[1], boxB[1]) + xB = min(boxA[2], boxB[2]) + yB = min(boxA[3], boxB[3]) + interArea = max(0, xB - xA) * max(0, yB - yA) + boxAArea = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]) + boxBArea = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]) + iou = interArea / float(boxAArea + boxBArea - interArea + 1e-5) + return iou diff --git a/services/profile_service.py b/services/profile_service.py new file mode 100644 index 0000000..80a9913 --- /dev/null +++ b/services/profile_service.py @@ -0,0 +1,142 @@ +# services/profile_service.py +import os +import re +import shutil +import logging +from typing import Optional, List + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +def _sanitize_name(name: str) -> str: + """ + Chuyển tên (email/username) thành dạng an toàn cho filesystem. + Giữ chữ, số, dấu gạch ngang và gạch dưới. + Ví dụ: "user+test@example.com" -> "user-test_example-com" + """ + if not isinstance(name, str): + raise ValueError("Profile name must be a string") + name = name.strip().lower() + # Thay các kí tự '@' và '+' thành '-' + name = name.replace("@", "-at-").replace("+", "-plus-") + # Thay các kí tự không an toàn thành '-' + name = re.sub(r"[^a-z0-9._-]", "-", name) + # Compact nhiều dấu '-' liên tiếp + name = re.sub(r"-{2,}", "-", name) + # Trim dấu '-' đầu cuối + name = name.strip("-") + return name or "profile" + + +class ProfileService: + """ + Service để quản lý thư mục profiles. + Mặc định root folder là ./profiles (tương đối với working dir). + """ + + def __init__(self, profiles_root: Optional[str] = None): + self.profiles_root = os.path.abspath(profiles_root or "profiles") + os.makedirs(self.profiles_root, exist_ok=True) + logger.info("Profiles root: %s", self.profiles_root) + + def get_profile_dirname(self, name: str) -> str: + """Tên folder đã sanitize (chỉ tên folder, không có path)""" + return _sanitize_name(name) + + def get_profile_path(self, name: str) -> str: + """Trả về path tuyệt đối tới folder profile""" + return os.path.join(self.profiles_root, self.get_profile_dirname(name)) + + def exists(self, name: str) -> bool: + """Check folder có tồn tại không""" + return os.path.isdir(self.get_profile_path(name)) + + def create(self, name: str, copy_from: Optional[str] = None, exist_ok: bool = True) -> str: + """ + Tạo folder profile. + - name: email/username + - copy_from: nếu truyền path tới folder mẫu, sẽ copy nội dung từ đó + - exist_ok: nếu True và folder đã tồn tại thì không raise + Trả về path tới folder profile. + """ + path = self.get_profile_path(name) + if os.path.isdir(path): + if exist_ok: + logger.debug("Profile already exists: %s", path) + return path + raise FileExistsError(f"Profile already exists: {path}") + + os.makedirs(path, exist_ok=True) + logger.info("Created profile dir: %s", path) + + if copy_from: + copy_from = os.path.abspath(copy_from) + if os.path.isdir(copy_from): + # copy nội dung bên trong copy_from vào path + for item in os.listdir(copy_from): + s = os.path.join(copy_from, item) + d = os.path.join(path, item) + if os.path.isdir(s): + shutil.copytree(s, d, dirs_exist_ok=True) + else: + shutil.copy2(s, d) + logger.info("Copied profile template from %s to %s", copy_from, path) + else: + logger.warning("copy_from path not found or not a dir: %s", copy_from) + + return path + + def delete(self, name: str, ignore_errors: bool = False) -> None: + """Xóa folder profile (recursive).""" + path = self.get_profile_path(name) + if not os.path.isdir(path): + raise FileNotFoundError(f"Profile not found: {path}") + shutil.rmtree(path, ignore_errors=ignore_errors) + logger.info("Deleted profile dir: %s", path) + + def list_profiles(self) -> List[str]: + """Trả về danh sách tên folder (dirnames) trong profiles_root.""" + try: + return sorted( + [ + d + for d in os.listdir(self.profiles_root) + if os.path.isdir(os.path.join(self.profiles_root, d)) + ] + ) + except FileNotFoundError: + return [] + + # ----------------- Optional: QWebEngineProfile creator ----------------- + def create_qwebengine_profile(self, name: str, parent=None, profile_id: Optional[str] = None): + """ + Tạo và trả về QWebEngineProfile đã cấu hình persistent storage (cookies, local storage, cache). + Yêu cầu PyQt5.QtWebEngineWidgets được cài. + - name: email/username để đặt thư mục lưu + - parent: parent QObject cho QWebEngineProfile (thường là self) + - profile_id: tên id cho profile (tùy chọn) + """ + try: + from PyQt5.QtWebEngineWidgets import QWebEngineProfile + except Exception as e: + raise RuntimeError( + "PyQt5.QtWebEngineWidgets không khả dụng. " + "Không thể tạo QWebEngineProfile." + ) from e + + profile_path = self.get_profile_path(name) + os.makedirs(profile_path, exist_ok=True) + + profile_name = profile_id or self.get_profile_dirname(name) + profile = QWebEngineProfile(profile_name, parent) + profile.setPersistentStoragePath(profile_path) + profile.setCachePath(profile_path) + # Force lưu cookie persist + try: + profile.setPersistentCookiesPolicy(QWebEngineProfile.ForcePersistentCookies) + except Exception: + # Một vài phiên bản PyQt có thể khác tên hằng, bọc try/except để an toàn + pass + logger.info("Created QWebEngineProfile for %s -> %s", name, profile_path) + return profile diff --git a/stores/shared_store.py b/stores/shared_store.py new file mode 100644 index 0000000..fab2488 --- /dev/null +++ b/stores/shared_store.py @@ -0,0 +1,35 @@ +from PyQt5.QtCore import QObject +import threading + +class SharedStore(QObject): + _instance = None + _lock = threading.Lock() + + def __init__(self): + super().__init__() + self._items = [] + self._items_lock = threading.Lock() + + @classmethod + def get_instance(cls): + if not cls._instance: + with cls._lock: + if not cls._instance: + cls._instance = SharedStore() + return cls._instance + + def append(self, item: dict): + with self._items_lock: + self._items.append(item) + + def remove(self, listed_id: int): + with self._items_lock: + self._items = [i for i in self._items if i["listed_id"] != listed_id] + + def size(self) -> int: + with self._items_lock: + return len(self._items) + + def get_items(self) -> list: + with self._items_lock: + return list(self._items) diff --git a/tasks/listed_tasks.py b/tasks/listed_tasks.py index 78cb325..52adac1 100644 --- a/tasks/listed_tasks.py +++ b/tasks/listed_tasks.py @@ -1,138 +1,67 @@ -# tasks/listed_tasks.py - import threading -import queue +import time from database.db import get_connection from services.core.log_service import log_service +from stores.shared_store import SharedStore +from database.models.setting import Setting from gui.global_signals import global_signals stop_event = threading.Event() -dialog_semaphore = None # sẽ khởi tạo dựa trên setting - def stop_listed_task(): - """Dừng toàn bộ task""" stop_event.set() log_service.info("[Task] Stop signal sent") - -def get_settings(): - """Lấy setting từ DB""" +def get_pending_items(limit): conn = get_connection() cursor = conn.cursor() - cursor.execute("SELECT key, value FROM settings") - rows = cursor.fetchall() - conn.close() - return {row["key"]: row["value"] for row in rows} - - -def get_pending_items(limit=1000): - """Lấy danh sách pending listed items""" - conn = get_connection() - cursor = conn.cursor() - cursor.execute(f""" - SELECT l.id, l.account_id, l.product_id, l.listed_at, l.status + cursor.execute(""" + SELECT l.id, l.account_id, l.product_id FROM listed l JOIN accounts a ON l.account_id = a.id - WHERE l.status='pending' - AND a.is_active=1 + WHERE l.status='pending' AND a.is_active=1 ORDER BY l.listed_at ASC - LIMIT {limit} - """) + LIMIT ? + """, (limit,)) rows = cursor.fetchall() conn.close() return rows +def background_loop(): + log_service.info("[Task] Background SharedStore loop started") + store = SharedStore.get_instance() -def process_item(row): - """Xử lý 1 item: mở dialog trên main thread và update DB""" - listed_id, account_id, product_id, listed_at, status = row - log_service.info(f"[Task] Processing listed_id={listed_id}") - - if stop_event.is_set(): - log_service.info("[Task] Stop signal received, skip item") - return - - # --- Đợi semaphore để giới hạn số dialog --- - dialog_semaphore.acquire() - - # --- Tạo event để chờ dialog xong --- - finished_event = threading.Event() - - # Callback khi dialog xong - def on_dialog_finished(finished_account_id): - if finished_account_id == account_id: - finished_event.set() - global_signals.dialog_finished.disconnect(on_dialog_finished) - dialog_semaphore.release() - - global_signals.dialog_finished.connect(on_dialog_finished) - - # --- Emit signal mở dialog trên main thread --- - global_signals.open_login_dialog.emit(account_id) - - # --- Chờ dialog hoàn tất --- - finished_event.wait() - - # --- Update DB --- - conn = get_connection() - cursor = conn.cursor() - cursor.execute("UPDATE listed SET status='listed' WHERE id=?", (listed_id,)) - conn.commit() - conn.close() - - log_service.info(f"[Task] Finished listed_id={listed_id}") - - -def worker_thread(q: queue.Queue): while not stop_event.is_set(): try: - row = q.get(timeout=1) - except queue.Empty: - break - try: - process_item(row) + interval = int(Setting.get("LISTING_INTERVAL_SECONDS", 10)) + max_concurrent = int(Setting.get("MAX_CONCURRENT_LISTING", 2)) + + slots = max_concurrent - store.size() + if slots > 0: + pending_items = get_pending_items(slots) + for row in pending_items: + listed_id, account_id, product_id = row + item = {"listed_id": listed_id, "account_id": account_id} + + # --- Kiểm tra unique trước khi append --- + if not any(x["listed_id"] == listed_id for x in store.get_items()): + store.append(item) + log_service.info(f"[Task] Added listed_id={listed_id} to SharedStore") + + # --- Emit signal để MainWindow mở dialog --- + global_signals.open_login_dialog.emit(account_id, listed_id) + log_service.info(f"[Task] Emitted open_login_dialog for listed_id={listed_id}") + else: + log_service.info(f"[Task] Skipped listed_id={listed_id}, already in SharedStore") + + + time.sleep(interval) except Exception as e: - log_service.error(f"[Task] Exception in worker: {e}") - finally: - q.task_done() + log_service.error(f"[Task] Exception in background_loop: {e}") + log_service.info("[Task] Background SharedStore loop stopped") -def process_all_listed(): - """ - Entry point: tạo queue và 2 worker thread (hoặc theo setting) - """ - log_service.info("[Task] Start processing all listed items") - - settings = get_settings() - max_workers = int(settings.get("MAX_CONCURRENT_LISTING", 2)) - global dialog_semaphore - dialog_semaphore = threading.Semaphore(max_workers) - - # --- Lấy pending items --- - items = get_pending_items(limit=1000) - if not items: - log_service.info("[Task] No pending items") - global_signals.listed_finished.emit() - return - - # --- Tạo queue --- - q = queue.Queue() - for row in items: - q.put(row) - - # --- Tạo worker threads --- - threads = [] - for _ in range(max_workers): - t = threading.Thread(target=worker_thread, args=(q,)) - t.start() - threads.append(t) - - # --- Chờ queue xong --- - q.join() - stop_event.set() - for t in threads: - t.join() - - log_service.info("[Task] All listed items processed") - global_signals.listed_finished.emit() +def start_background_listed(): + t = threading.Thread(target=background_loop, daemon=True) + t.start() + return t diff --git a/templates/buttons/login/Screenshot 2025-10-12 220051.png b/templates/buttons/login/Screenshot 2025-10-12 220051.png new file mode 100644 index 0000000..67c454c Binary files /dev/null and b/templates/buttons/login/Screenshot 2025-10-12 220051.png differ diff --git a/templates/login_fail/Screenshot 2025-10-14 at 16.23.16.png b/templates/login_fail/Screenshot 2025-10-14 at 16.23.16.png new file mode 100644 index 0000000..bd6f62a Binary files /dev/null and b/templates/login_fail/Screenshot 2025-10-14 at 16.23.16.png differ diff --git a/templates/password/Screenshot 2025-10-14 at 13.44.40.png b/templates/password/Screenshot 2025-10-14 at 13.44.40.png new file mode 100644 index 0000000..4811cef Binary files /dev/null and b/templates/password/Screenshot 2025-10-14 at 13.44.40.png differ diff --git a/templates/password/Screenshot 2025-10-14 at 15.53.32.png b/templates/password/Screenshot 2025-10-14 at 15.53.32.png new file mode 100644 index 0000000..1d466b9 Binary files /dev/null and b/templates/password/Screenshot 2025-10-14 at 15.53.32.png differ diff --git a/templates/password/tpl_password.png b/templates/password/tpl_password.png new file mode 100644 index 0000000..a0fc411 Binary files /dev/null and b/templates/password/tpl_password.png differ diff --git a/templates/username/Screenshot 2025-10-14 at 13.44.22.png b/templates/username/Screenshot 2025-10-14 at 13.44.22.png new file mode 100644 index 0000000..558e248 Binary files /dev/null and b/templates/username/Screenshot 2025-10-14 at 13.44.22.png differ diff --git a/templates/username/Screenshot 2025-10-14 at 13.45.45.png b/templates/username/Screenshot 2025-10-14 at 13.45.45.png new file mode 100644 index 0000000..7620a00 Binary files /dev/null and b/templates/username/Screenshot 2025-10-14 at 13.45.45.png differ diff --git a/templates/username/tpl_username.png b/templates/username/tpl_username.png new file mode 100644 index 0000000..7e8ee62 Binary files /dev/null and b/templates/username/tpl_username.png differ