This commit is contained in:
Admin 2025-10-14 16:45:19 +07:00
parent 2355a7de55
commit f4c8185ed0
22 changed files with 986 additions and 204 deletions

View File

@ -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")

View File

@ -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
)
''')

Binary file not shown.

217
fb_window.py Normal file
View File

@ -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_())

View File

@ -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}")

View File

@ -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()

174
gui/handle/login_fb.py Normal file
View File

@ -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_())

View File

@ -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()

View File

@ -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()

View File

@ -0,0 +1,91 @@
from PyQt5.QtCore import Qt, QTimer
class ActionService:
"""
Service phỏng hành động người dùng trong QWebEngineView
bằng JavaScript (click, , 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)

104
services/detect_service.py Normal file
View File

@ -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, 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

142
services/profile_service.py Normal file
View File

@ -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 gạch dưới.
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 thư mục profiles.
Mặc định root folder ./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 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 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 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

35
stores/shared_store.py Normal file
View File

@ -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)

View File

@ -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 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB