login
|
|
@ -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")
|
||||||
|
|
@ -18,7 +18,8 @@ def create_tables():
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
password TEXT NOT NULL,
|
password TEXT NOT NULL,
|
||||||
is_active INTEGER DEFAULT 1
|
is_active INTEGER DEFAULT 1,
|
||||||
|
login_at INTEGER DEFAULT NULL
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_())
|
||||||
|
|
@ -1,44 +1,80 @@
|
||||||
# gui/dialogs/login_handle_dialog.py
|
# gui/core/login_handle_dialog.py
|
||||||
from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton
|
from PyQt5.QtWidgets import QDialog, QVBoxLayout, QPushButton, QApplication
|
||||||
from PyQt5.QtCore import QTimer, Qt
|
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
|
from gui.global_signals import global_signals
|
||||||
|
|
||||||
|
|
||||||
class LoginHandleDialog(QDialog):
|
class LoginHandleDialog(QDialog):
|
||||||
def __init__(self, account_id=None, duration=10, parent=None):
|
dialog_width = 300
|
||||||
super().__init__(parent)
|
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.account_id = account_id
|
||||||
self.duration = duration
|
self.listed_id = listed_id
|
||||||
self.elapsed = 0
|
|
||||||
|
|
||||||
self.setWindowTitle(f"Login Handle - Account {self.account_id}")
|
self.setWindowTitle(f"Handle Listing {self.listed_id}")
|
||||||
self.setFixedSize(300, 150)
|
self.setModal(False) # modeless
|
||||||
|
self.resize(self.dialog_width, self.dialog_height)
|
||||||
|
|
||||||
|
# --- UI đơn giản ---
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self.label = QLabel(f"Processing account_id={self.account_id}...")
|
self.btn_finish = QPushButton("Finish Listing")
|
||||||
self.label.setAlignment(Qt.AlignCenter)
|
self.btn_finish.clicked.connect(self.finish_listing)
|
||||||
layout.addWidget(self.label)
|
layout.addWidget(self.btn_finish)
|
||||||
|
|
||||||
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.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
self.timer = QTimer(self)
|
# --- Tính vị trí để xếp dialog từ góc trái trên cùng sang phải theo hàng ngang ---
|
||||||
self.timer.timeout.connect(self._update_progress)
|
self.move_to_corner()
|
||||||
self.timer.start(1000)
|
LoginHandleDialog.open_dialogs.append(self)
|
||||||
|
|
||||||
def _update_progress(self):
|
def move_to_corner(self):
|
||||||
self.elapsed += 1
|
screen_geometry = QApplication.primaryScreen().availableGeometry()
|
||||||
self.progress.setValue(self.elapsed)
|
start_x = screen_geometry.left() + self.margin
|
||||||
if self.elapsed >= self.duration:
|
start_y = screen_geometry.top() + self.margin
|
||||||
self.close()
|
|
||||||
|
|
||||||
def closeEvent(self, event):
|
row_height = 0
|
||||||
"""Emit signal khi dialog đóng"""
|
current_x = start_x
|
||||||
global_signals.dialog_finished.emit(self.account_id)
|
current_y = start_y
|
||||||
super().closeEvent(event)
|
|
||||||
|
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}")
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,7 @@ from PyQt5.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
class GlobalSignals(QObject):
|
class GlobalSignals(QObject):
|
||||||
listed_finished = pyqtSignal()
|
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()
|
||||||
|
|
@ -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_())
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
# gui/main_window.py
|
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QMainWindow, QTabWidget, QMessageBox
|
from PyQt5.QtWidgets import QMainWindow, QTabWidget, QMessageBox
|
||||||
from gui.tabs.accounts.account_tab import AccountTab
|
from gui.tabs.accounts.account_tab import AccountTab
|
||||||
from gui.tabs.products.product_tab import ProductTab
|
from gui.tabs.products.product_tab import ProductTab
|
||||||
from gui.tabs.import_tab import ImportTab
|
from gui.tabs.import_tab import ImportTab
|
||||||
from gui.tabs.listeds.listed_tab import ListedTab
|
from gui.tabs.listeds.listed_tab import ListedTab
|
||||||
from gui.tabs.settings.settings_tab import SettingsTab
|
from gui.tabs.settings.settings_tab import SettingsTab
|
||||||
from gui.global_signals import global_signals
|
|
||||||
from gui.core.login_handle_dialog import LoginHandleDialog
|
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):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
@ -19,7 +16,7 @@ class MainWindow(QMainWindow):
|
||||||
self.setWindowTitle("Facebook Marketplace Manager")
|
self.setWindowTitle("Facebook Marketplace Manager")
|
||||||
self.resize(1200, 600)
|
self.resize(1200, 600)
|
||||||
|
|
||||||
# --- Tạo QTabWidget ---
|
# --- Tabs ---
|
||||||
self.tabs = QTabWidget()
|
self.tabs = QTabWidget()
|
||||||
self.account_tab = AccountTab()
|
self.account_tab = AccountTab()
|
||||||
self.product_tab = ProductTab()
|
self.product_tab = ProductTab()
|
||||||
|
|
@ -33,54 +30,55 @@ class MainWindow(QMainWindow):
|
||||||
self.tabs.addTab(self.import_tab, "Import Data")
|
self.tabs.addTab(self.import_tab, "Import Data")
|
||||||
self.tabs.addTab(self.setting_tab, "Setting")
|
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.tabs.currentChanged.connect(self.on_tab_changed)
|
||||||
|
|
||||||
self.setCentralWidget(self.tabs)
|
self.setCentralWidget(self.tabs)
|
||||||
|
|
||||||
# Khi mở app thì chỉ load tab đầu tiên (Accounts)
|
|
||||||
self.on_tab_changed(0)
|
self.on_tab_changed(0)
|
||||||
|
|
||||||
# --- Kết nối signals ---
|
# # --- Signals ---
|
||||||
global_signals.open_login_dialog.connect(self.show_login_dialog)
|
# global_signals.listed_finished.connect(self.on_listed_task_finished)
|
||||||
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ở ---
|
# # --- Start background ---
|
||||||
self.start_background_tasks()
|
# start_background_listed()
|
||||||
|
|
||||||
# ---------------- Dialog handler ----------------
|
# --- Store opened dialogs ---
|
||||||
def show_login_dialog(self, account_id):
|
self.login_dialogs = []
|
||||||
"""Mở dialog xử lý account_id, modeless để không block MainWindow"""
|
|
||||||
dialog = LoginHandleDialog(account_id=account_id)
|
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
|
dialog.show() # modeless
|
||||||
|
self.login_dialogs.append(dialog)
|
||||||
|
|
||||||
# ---------------- Background tasks ----------------
|
# Khi dialog đóng, remove khỏi list và SharedStore
|
||||||
def start_background_tasks(self):
|
def on_dialog_close():
|
||||||
"""Khởi động các background task khi app mở."""
|
if dialog in self.login_dialogs:
|
||||||
self.task_runner = TaskRunner()
|
self.login_dialogs.remove(dialog)
|
||||||
self.task_runner.start()
|
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)
|
dialog.finished.connect(on_dialog_close)
|
||||||
self.task_runner.add_task(process_all_listed)
|
|
||||||
print("[App] Background task runner started")
|
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):
|
def on_listed_task_finished(self):
|
||||||
"""Hiển thị thông báo khi listed task hoàn tất"""
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"Auto Listing",
|
"Auto Listing",
|
||||||
"All pending listed items have been processed!"
|
"All pending listed items have been processed!"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------- Tab handling ----------------
|
def closeEvent(self, event):
|
||||||
def on_tab_changed(self, index):
|
"""Khi MainWindow đóng, đóng hết tất cả dialog đang mở"""
|
||||||
"""Chỉ load nội dung tab khi được active."""
|
for dialog in self.login_dialogs[:]: # copy list để tránh lỗi khi remove
|
||||||
tab = self.tabs.widget(index)
|
dialog.close()
|
||||||
|
event.accept()
|
||||||
# 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import os
|
||||||
|
from functools import partial
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
|
QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
|
||||||
QPushButton, QHBoxLayout, QDialog, QLabel, QLineEdit, QComboBox, QMessageBox,
|
QPushButton, QHBoxLayout, QDialog, QLabel, QLineEdit, QComboBox, QMessageBox,
|
||||||
|
|
@ -8,6 +10,10 @@ from PyQt5.QtGui import QFont
|
||||||
from PyQt5.QtWidgets import QHeaderView
|
from PyQt5.QtWidgets import QHeaderView
|
||||||
from database.models import Account
|
from database.models import Account
|
||||||
from .forms.account_form import AccountForm
|
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
|
PAGE_SIZE = 10
|
||||||
|
|
||||||
|
|
@ -17,11 +23,19 @@ class AccountTab(QWidget):
|
||||||
self.current_page = 0
|
self.current_page = 0
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# Add button
|
# --- Top bar ---
|
||||||
|
top_layout = QHBoxLayout()
|
||||||
self.add_btn = QPushButton("Add Account")
|
self.add_btn = QPushButton("Add Account")
|
||||||
self.add_btn.setMinimumWidth(120)
|
|
||||||
self.add_btn.clicked.connect(self.add_account)
|
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
|
# Table
|
||||||
self.table = QTableWidget()
|
self.table = QTableWidget()
|
||||||
|
|
@ -43,6 +57,7 @@ class AccountTab(QWidget):
|
||||||
layout.addLayout(pag_layout)
|
layout.addLayout(pag_layout)
|
||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
self.update_options_menu()
|
||||||
self.load_data()
|
self.load_data()
|
||||||
|
|
||||||
def load_data(self):
|
def load_data(self):
|
||||||
|
|
@ -52,43 +67,71 @@ class AccountTab(QWidget):
|
||||||
page_items = accounts[start:end]
|
page_items = accounts[start:end]
|
||||||
|
|
||||||
self.table.setRowCount(len(page_items))
|
self.table.setRowCount(len(page_items))
|
||||||
self.table.setColumnCount(4)
|
self.table.setColumnCount(6)
|
||||||
self.table.setHorizontalHeaderLabels(["ID", "Email", "Status", "Actions"])
|
self.table.setHorizontalHeaderLabels([
|
||||||
|
"ID", "Email", "Status", "Profile Exists", "Login At", "Actions"
|
||||||
|
])
|
||||||
|
|
||||||
for i, acc in enumerate(page_items):
|
for i, acc in enumerate(page_items):
|
||||||
# convert sqlite3.Row -> dict
|
|
||||||
acc_dict = {k: acc[k] for k in acc.keys()}
|
acc_dict = {k: acc[k] for k in acc.keys()}
|
||||||
|
|
||||||
self.table.setItem(i, 0, QTableWidgetItem(str(acc_dict["id"])))
|
self.table.setItem(i, 0, QTableWidgetItem(str(acc_dict["id"])))
|
||||||
self.table.setItem(i, 1, QTableWidgetItem(acc_dict["email"]))
|
self.table.setItem(i, 1, QTableWidgetItem(acc_dict["email"]))
|
||||||
self.table.setItem(i, 2, QTableWidgetItem("Active" if acc_dict["is_active"] == 1 else "Inactive"))
|
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")
|
btn_menu = QPushButton("Actions")
|
||||||
menu = QMenu()
|
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 = QAction("Edit", btn_menu)
|
||||||
action_edit.triggered.connect(lambda _, a=acc_dict: self.edit_account(a))
|
action_edit.triggered.connect(lambda _, a=acc_dict: self.edit_account(a))
|
||||||
menu.addAction(action_edit)
|
menu.addAction(action_edit)
|
||||||
|
|
||||||
|
# Delete
|
||||||
action_delete = QAction("Delete", btn_menu)
|
action_delete = QAction("Delete", btn_menu)
|
||||||
action_delete.triggered.connect(lambda _, a=acc_dict: self.delete_account(a))
|
action_delete.triggered.connect(lambda _, a=acc_dict: self.delete_account(a))
|
||||||
menu.addAction(action_delete)
|
menu.addAction(action_delete)
|
||||||
|
|
||||||
btn_menu.setMenu(menu)
|
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 = self.table.horizontalHeader()
|
||||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # ID
|
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||||
header.setSectionResizeMode(1, QHeaderView.Stretch) # Email stretch
|
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Status
|
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||||
header.setSectionResizeMode(3, QHeaderView.Fixed) # Actions fixed width
|
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||||
self.table.setColumnWidth(3, 100) # 100px cho Actions
|
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.prev_btn.setEnabled(self.current_page > 0)
|
||||||
self.next_btn.setEnabled(end < len(accounts))
|
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):
|
def add_account(self):
|
||||||
form = AccountForm(self)
|
form = AccountForm(self)
|
||||||
if form.exec_():
|
if form.exec_():
|
||||||
|
|
@ -102,7 +145,7 @@ class AccountTab(QWidget):
|
||||||
|
|
||||||
def delete_account(self, account):
|
def delete_account(self, account):
|
||||||
confirm = QMessageBox.question(
|
confirm = QMessageBox.question(
|
||||||
self, "Confirm", f"Delete account {account['email']}?",
|
self, "Confirm", f"Delete account {account['email']}?",
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
if confirm == QMessageBox.Yes:
|
if confirm == QMessageBox.Yes:
|
||||||
|
|
@ -116,3 +159,8 @@ class AccountTab(QWidget):
|
||||||
def prev_page(self):
|
def prev_page(self):
|
||||||
self.current_page -= 1
|
self.current_page -= 1
|
||||||
self.load_data()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -1,138 +1,67 @@
|
||||||
# tasks/listed_tasks.py
|
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import queue
|
import time
|
||||||
from database.db import get_connection
|
from database.db import get_connection
|
||||||
from services.core.log_service import log_service
|
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
|
from gui.global_signals import global_signals
|
||||||
|
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
dialog_semaphore = None # sẽ khởi tạo dựa trên setting
|
|
||||||
|
|
||||||
|
|
||||||
def stop_listed_task():
|
def stop_listed_task():
|
||||||
"""Dừng toàn bộ task"""
|
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
log_service.info("[Task] Stop signal sent")
|
log_service.info("[Task] Stop signal sent")
|
||||||
|
|
||||||
|
def get_pending_items(limit):
|
||||||
def get_settings():
|
|
||||||
"""Lấy setting từ DB"""
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("SELECT key, value FROM settings")
|
cursor.execute("""
|
||||||
rows = cursor.fetchall()
|
SELECT l.id, l.account_id, l.product_id
|
||||||
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
|
|
||||||
FROM listed l
|
FROM listed l
|
||||||
JOIN accounts a ON l.account_id = a.id
|
JOIN accounts a ON l.account_id = a.id
|
||||||
WHERE l.status='pending'
|
WHERE l.status='pending' AND a.is_active=1
|
||||||
AND a.is_active=1
|
|
||||||
ORDER BY l.listed_at ASC
|
ORDER BY l.listed_at ASC
|
||||||
LIMIT {limit}
|
LIMIT ?
|
||||||
""")
|
""", (limit,))
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return rows
|
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():
|
while not stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
row = q.get(timeout=1)
|
interval = int(Setting.get("LISTING_INTERVAL_SECONDS", 10))
|
||||||
except queue.Empty:
|
max_concurrent = int(Setting.get("MAX_CONCURRENT_LISTING", 2))
|
||||||
break
|
|
||||||
try:
|
slots = max_concurrent - store.size()
|
||||||
process_item(row)
|
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:
|
except Exception as e:
|
||||||
log_service.error(f"[Task] Exception in worker: {e}")
|
log_service.error(f"[Task] Exception in background_loop: {e}")
|
||||||
finally:
|
|
||||||
q.task_done()
|
|
||||||
|
|
||||||
|
log_service.info("[Task] Background SharedStore loop stopped")
|
||||||
|
|
||||||
def process_all_listed():
|
def start_background_listed():
|
||||||
"""
|
t = threading.Thread(target=background_loop, daemon=True)
|
||||||
Entry point: tạo queue và 2 worker thread (hoặc theo setting)
|
t.start()
|
||||||
"""
|
return t
|
||||||
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()
|
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |