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,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
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
|
||||
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}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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 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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 queue
|
||||
import time
|
||||
from database.db import get_connection
|
||||
from services.core.log_service import log_service
|
||||
from stores.shared_store import SharedStore
|
||||
from database.models.setting import Setting
|
||||
from gui.global_signals import global_signals
|
||||
|
||||
stop_event = threading.Event()
|
||||
dialog_semaphore = None # sẽ khởi tạo dựa trên setting
|
||||
|
||||
|
||||
def stop_listed_task():
|
||||
"""Dừng toàn bộ task"""
|
||||
stop_event.set()
|
||||
log_service.info("[Task] Stop signal sent")
|
||||
|
||||
|
||||
def get_settings():
|
||||
"""Lấy setting từ DB"""
|
||||
def get_pending_items(limit):
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT key, value FROM settings")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return {row["key"]: row["value"] for row in rows}
|
||||
|
||||
|
||||
def get_pending_items(limit=1000):
|
||||
"""Lấy danh sách pending listed items"""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"""
|
||||
SELECT l.id, l.account_id, l.product_id, l.listed_at, l.status
|
||||
cursor.execute("""
|
||||
SELECT l.id, l.account_id, l.product_id
|
||||
FROM listed l
|
||||
JOIN accounts a ON l.account_id = a.id
|
||||
WHERE l.status='pending'
|
||||
AND a.is_active=1
|
||||
WHERE l.status='pending' AND a.is_active=1
|
||||
ORDER BY l.listed_at ASC
|
||||
LIMIT {limit}
|
||||
""")
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
def background_loop():
|
||||
log_service.info("[Task] Background SharedStore loop started")
|
||||
store = SharedStore.get_instance()
|
||||
|
||||
def process_item(row):
|
||||
"""Xử lý 1 item: mở dialog trên main thread và update DB"""
|
||||
listed_id, account_id, product_id, listed_at, status = row
|
||||
log_service.info(f"[Task] Processing listed_id={listed_id}")
|
||||
|
||||
if stop_event.is_set():
|
||||
log_service.info("[Task] Stop signal received, skip item")
|
||||
return
|
||||
|
||||
# --- Đợi semaphore để giới hạn số dialog ---
|
||||
dialog_semaphore.acquire()
|
||||
|
||||
# --- Tạo event để chờ dialog xong ---
|
||||
finished_event = threading.Event()
|
||||
|
||||
# Callback khi dialog xong
|
||||
def on_dialog_finished(finished_account_id):
|
||||
if finished_account_id == account_id:
|
||||
finished_event.set()
|
||||
global_signals.dialog_finished.disconnect(on_dialog_finished)
|
||||
dialog_semaphore.release()
|
||||
|
||||
global_signals.dialog_finished.connect(on_dialog_finished)
|
||||
|
||||
# --- Emit signal mở dialog trên main thread ---
|
||||
global_signals.open_login_dialog.emit(account_id)
|
||||
|
||||
# --- Chờ dialog hoàn tất ---
|
||||
finished_event.wait()
|
||||
|
||||
# --- Update DB ---
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE listed SET status='listed' WHERE id=?", (listed_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
log_service.info(f"[Task] Finished listed_id={listed_id}")
|
||||
|
||||
|
||||
def worker_thread(q: queue.Queue):
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
row = q.get(timeout=1)
|
||||
except queue.Empty:
|
||||
break
|
||||
try:
|
||||
process_item(row)
|
||||
interval = int(Setting.get("LISTING_INTERVAL_SECONDS", 10))
|
||||
max_concurrent = int(Setting.get("MAX_CONCURRENT_LISTING", 2))
|
||||
|
||||
slots = max_concurrent - store.size()
|
||||
if slots > 0:
|
||||
pending_items = get_pending_items(slots)
|
||||
for row in pending_items:
|
||||
listed_id, account_id, product_id = row
|
||||
item = {"listed_id": listed_id, "account_id": account_id}
|
||||
|
||||
# --- Kiểm tra unique trước khi append ---
|
||||
if not any(x["listed_id"] == listed_id for x in store.get_items()):
|
||||
store.append(item)
|
||||
log_service.info(f"[Task] Added listed_id={listed_id} to SharedStore")
|
||||
|
||||
# --- Emit signal để MainWindow mở dialog ---
|
||||
global_signals.open_login_dialog.emit(account_id, listed_id)
|
||||
log_service.info(f"[Task] Emitted open_login_dialog for listed_id={listed_id}")
|
||||
else:
|
||||
log_service.info(f"[Task] Skipped listed_id={listed_id}, already in SharedStore")
|
||||
|
||||
|
||||
time.sleep(interval)
|
||||
except Exception as e:
|
||||
log_service.error(f"[Task] Exception in worker: {e}")
|
||||
finally:
|
||||
q.task_done()
|
||||
log_service.error(f"[Task] Exception in background_loop: {e}")
|
||||
|
||||
log_service.info("[Task] Background SharedStore loop stopped")
|
||||
|
||||
def process_all_listed():
|
||||
"""
|
||||
Entry point: tạo queue và 2 worker thread (hoặc theo setting)
|
||||
"""
|
||||
log_service.info("[Task] Start processing all listed items")
|
||||
|
||||
settings = get_settings()
|
||||
max_workers = int(settings.get("MAX_CONCURRENT_LISTING", 2))
|
||||
global dialog_semaphore
|
||||
dialog_semaphore = threading.Semaphore(max_workers)
|
||||
|
||||
# --- Lấy pending items ---
|
||||
items = get_pending_items(limit=1000)
|
||||
if not items:
|
||||
log_service.info("[Task] No pending items")
|
||||
global_signals.listed_finished.emit()
|
||||
return
|
||||
|
||||
# --- Tạo queue ---
|
||||
q = queue.Queue()
|
||||
for row in items:
|
||||
q.put(row)
|
||||
|
||||
# --- Tạo worker threads ---
|
||||
threads = []
|
||||
for _ in range(max_workers):
|
||||
t = threading.Thread(target=worker_thread, args=(q,))
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
# --- Chờ queue xong ---
|
||||
q.join()
|
||||
stop_event.set()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
log_service.info("[Task] All listed items processed")
|
||||
global_signals.listed_finished.emit()
|
||||
def start_background_listed():
|
||||
t = threading.Thread(target=background_loop, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
|
|
|||
|
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 |