From 2ae720ae097973dd389074865b5d25949c9a11c2 Mon Sep 17 00:00:00 2001 From: supernijia <2903208875a@gmail.com> Date: Sat, 18 Apr 2026 16:23:13 +0000 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=96=87=E4=BB=B6=20desktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/README.md | 74 -- desktop/__init__.py | 1 - desktop/__pycache__/__init__.cpython-312.pyc | Bin 142 -> 0 bytes desktop/__pycache__/__init__.cpython-314.pyc | Bin 144 -> 0 bytes desktop/__pycache__/app.cpython-312.pyc | Bin 51068 -> 0 bytes desktop/__pycache__/app.cpython-314.pyc | Bin 22571 -> 0 bytes desktop/app.py | 1017 ------------------ desktop/requirements.txt | 3 - desktop/run_desktop_dev.ps1 | 67 -- desktop/run_desktop_dist.ps1 | 67 -- desktop/txt_templates.json | 140 --- 11 files changed, 1369 deletions(-) delete mode 100644 desktop/README.md delete mode 100644 desktop/__init__.py delete mode 100644 desktop/__pycache__/__init__.cpython-312.pyc delete mode 100644 desktop/__pycache__/__init__.cpython-314.pyc delete mode 100644 desktop/__pycache__/app.cpython-312.pyc delete mode 100644 desktop/__pycache__/app.cpython-314.pyc delete mode 100644 desktop/app.py delete mode 100644 desktop/requirements.txt delete mode 100644 desktop/run_desktop_dev.ps1 delete mode 100644 desktop/run_desktop_dist.ps1 delete mode 100644 desktop/txt_templates.json diff --git a/desktop/README.md b/desktop/README.md deleted file mode 100644 index f2ca34e..0000000 --- a/desktop/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# 桌面端启动 - -## Python 要求 - -桌面端当前依赖 `pywebview`,在 Windows 下建议使用: - -- `Python 3.12` -- `Python 3.11` - -不要直接用 `Python 3.14`。你当前遇到的 `pythonnet` 构建失败就是这个兼容性问题。 - -## 开发模式 - -前置条件: - -- `frontend-vue` 的 Vite 开发服务已启动在 `http://127.0.0.1:5173` -- 后端 API 已启动在 `http://127.0.0.1:8000` -- 本机已安装 `Python 3.12` 或 `Python 3.11` - -启动命令: - -```powershell -cd F:\project\crawler-plugin -.\desktop\run_desktop_dev.ps1 -``` - -脚本会自动: - -- 选择兼容的 Python 解释器 -- 安装 `desktop/requirements.txt` -- 启动 `pywebview` 桌面窗口 - -如果脚本检测不到兼容 Python,会直接提示并退出,不再继续错误安装。 - -## 发布模式 - -先构建前端: - -```powershell -cd F:\project\crawler-plugin\frontend-vue -npm run build -``` - -再启动桌面端: - -```powershell -cd F:\project\crawler-plugin -.\desktop\run_desktop_dist.ps1 -``` - -## 手动安装依赖 - -如果要手动安装,请显式使用兼容版本,例如: - -```powershell -py -3.12 -m pip install -r .\desktop\requirements.txt -``` - -## 当前桌面能力 - -- 选择品牌 Excel 文件 -- 选择文件夹 -- 选择保存目录 -- 另存模板文件 -- 按 URL 下载结果文件到本地 - -## 说明 - -发布模式下,桌面宿主会: - -- 本地托管 `frontend-vue/dist` -- 将 `/api`、`/login`、`/logout`、`/static` 代理到 `--backend-url` - -所以后端仍然需要运行。 diff --git a/desktop/__init__.py b/desktop/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/desktop/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/desktop/__pycache__/__init__.cpython-312.pyc b/desktop/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 74a00d43d8d163b6dddb7233512a3adcdb5aa1ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 142 zcmX@j%ge<81g~>lIYq;;_lhPbtkwwJTx; T>IRu#3}Sp@W@Kb6Vg|ARb%h~9 diff --git a/desktop/__pycache__/app.cpython-312.pyc b/desktop/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 6ec347af519426930e271e6cf7b65bb61a2d5284..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51068 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 Ti?vNU^;;{9O+3_Bii9M@d>}Ie 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 diff --git a/desktop/app.py b/desktop/app.py deleted file mode 100644 index b05d9d8..0000000 --- a/desktop/app.py +++ /dev/null @@ -1,1017 +0,0 @@ -from __future__ import annotations - -import argparse -import contextlib -import json -import mimetypes -import os -import re -import socket -import subprocess -import threading -import time -from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from tkinter import Tk, filedialog -from typing import Iterable -from urllib.parse import urljoin -from uuid import uuid4 - -try: - import requests -except ModuleNotFoundError as exc: - raise SystemExit( - "缺少依赖 requests。\n" - "请先执行:python -m pip install -r .\\desktop\\requirements.txt" - ) from exc - -try: - import webview -except ModuleNotFoundError as exc: - raise SystemExit( - "缺少依赖 pywebview。\n" - "请先执行:python -m pip install -r .\\desktop\\requirements.txt" - ) from exc - -try: - from openpyxl import load_workbook -except ModuleNotFoundError as exc: - raise SystemExit( - "缺少依赖 openpyxl。\n" - "请先执行:python -m pip install -r .\\desktop\\requirements.txt" - ) from exc - - -ROOT_DIR = Path(__file__).resolve().parents[1] -FRONTEND_DIR = ROOT_DIR / "frontend-vue" -DIST_DIR = FRONTEND_DIR / "dist" -INDEX_FILE = DIST_DIR / "index.html" -PROXY_PREFIXES = ("/api", "/login", "/logout", "/static") -TEMPLATE_STORE_PATH = ROOT_DIR / "desktop" / "txt_templates.json" -TXT_TEMPLATES = { - "uk_offer": { - "id": "uk_offer", - "label": "英国 Offer 模板", - "output_filename": "英国.txt", - "is_default": True, - "built_in": True, - "preamble_lines": ["TemplateType=Offer\tVersion=1.4"], - "header_columns": [ - "sku", - "price", - "quantity", - "product-id", - "product-id-type", - "condition-type", - "condition-note", - "ASIN-hint", - "title", - "product-tax-code", - "operation-type", - "sale-price", - "sale-start-date", - "sale-end-date", - "leadtime-to-ship", - "launch-date", - "is-giftwrap-available", - "is-gift-message-available", - ], - "blank_header_rows_after_schema": 1, - "required_source_columns": ["ASIN", "价格"], - "defaults": { - "quantity": "100", - "product-id-type": "ASIN", - "condition-type": "new", - "leadtime-to-ship": "4", - }, - "field_mapping": { - "price": "价格", - "product-id": "ASIN", - }, - } -} - - -def find_free_port() -> int: - with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) - - -def ensure_dist_exists() -> None: - if not INDEX_FILE.exists(): - raise FileNotFoundError( - f"未找到前端构建产物:{INDEX_FILE}\n" - "请先进入 frontend-vue 执行 npm run build。" - ) - - -def resolve_url(base_url: str, maybe_relative_url: str) -> str: - if maybe_relative_url.startswith(("http://", "https://")): - return maybe_relative_url - return urljoin(base_url.rstrip("/") + "/", maybe_relative_url.lstrip("/")) - - -def run_file_dialog(fn): - root = Tk() - root.withdraw() - root.attributes("-topmost", True) - try: - return fn(root) - finally: - root.destroy() - - -def normalize_cell_text(value: object) -> str: - if value is None: - return "" - text = str(value).replace("\ufeff", "").replace("\u3000", " ") - text = text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ").replace("\t", " ") - text = re.sub(r"\s+", " ", text) - return text.strip() - - -def build_output_path(output_dir: str, source_path: str) -> str: - source = Path(source_path) - candidate = Path(output_dir) / f"{source.stem}_cleaned{source.suffix}" - index = 2 - while candidate.exists(): - candidate = Path(output_dir) / f"{source.stem}_cleaned_{index}{source.suffix}" - index += 1 - return str(candidate) - - -def build_named_output_path(output_dir: str, filename: str) -> str: - candidate = Path(output_dir) / filename - if not candidate.exists(): - return str(candidate) - stem = candidate.stem - suffix = candidate.suffix - index = 2 - while True: - next_candidate = Path(output_dir) / f"{stem}_{index}{suffix}" - if not next_candidate.exists(): - return str(next_candidate) - index += 1 - - -def should_keep_id(value: object, keep_integer_ids: bool, keep_underscore_ids: bool) -> bool: - text = normalize_cell_text(value) - if not text: - return False - if keep_integer_ids and text.isdigit(): - return True - if keep_underscore_ids and re.fullmatch(r"\d+_\d+", text): - return True - return False - - -def generate_snowflake_like_id() -> int: - return int(time.time() * 1000) - - -def load_user_templates() -> tuple[dict[str, dict], str | None]: - if not TEMPLATE_STORE_PATH.exists(): - return {}, None - try: - payload = json.loads(TEMPLATE_STORE_PATH.read_text(encoding="utf-8")) - templates = payload.get("templates") or {} - default_template_id = payload.get("default_template_id") - return templates, default_template_id - except Exception: - return {}, None - - -def save_user_templates(templates: dict[str, dict], default_template_id: str | None) -> None: - payload = { - "default_template_id": default_template_id, - "templates": templates, - } - TEMPLATE_STORE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - - -def build_effective_templates() -> tuple[dict[str, dict], str]: - builtins = {key: dict(value) for key, value in TXT_TEMPLATES.items()} - user_templates, persisted_default = load_user_templates() - templates = {**builtins, **user_templates} - default_template_id = persisted_default or next((key for key, value in builtins.items() if value.get("is_default")), "uk_offer") - for key, value in templates.items(): - value["is_default"] = key == default_template_id - return templates, default_template_id - - -def recognize_txt_template(template_text: str, custom_name: str | None = None) -> dict | None: - lines = [line.rstrip("\n") for line in template_text.splitlines()] - non_empty_lines = [line for line in lines if line.strip()] - if len(non_empty_lines) < 2: - return None - - first_line = non_empty_lines[0] - header_line = non_empty_lines[1] - header_columns = header_line.split("\t") - uk_template = TXT_TEMPLATES["uk_offer"] - - if not first_line.startswith("TemplateType=Offer"): - return None - if header_columns != uk_template["header_columns"]: - return None - - template_id = f"user_{int(time.time() * 1000)}" - output_filename = custom_name or Path("英国.txt").name - if not output_filename.endswith(".txt"): - output_filename = f"{output_filename}.txt" - - return { - "id": template_id, - "label": custom_name or "自定义 Offer 模板", - "output_filename": output_filename, - "is_default": False, - "built_in": False, - "preamble_lines": [first_line], - "header_columns": header_columns, - "blank_header_rows_after_schema": 1, - "required_source_columns": ["ASIN", "价格"], - "defaults": dict(uk_template["defaults"]), - "field_mapping": dict(uk_template["field_mapping"]), - } - - -@dataclass -class DesktopConfig: - mode: str - frontend_url: str - backend_url: str - host: str - port: int - width: int - height: int - title: str - - -class DesktopApi: - def __init__(self, backend_base_url: str) -> None: - self.app_base_url = backend_base_url.rstrip("/") - self.session = requests.Session() - - def select_brand_xlsx_files(self) -> list[str]: - files = run_file_dialog( - lambda _root: filedialog.askopenfilenames( - title="选择 Excel 文件", - filetypes=[("Excel files", "*.xlsx")], - ) - ) - return list(files) - - def select_clean_xlsx_files(self) -> list[str]: - files = run_file_dialog( - lambda _root: filedialog.askopenfilenames( - title="选择待清洗 Excel 文件", - filetypes=[("Excel files", "*.xlsx")], - ) - ) - return list(files) - - def select_brand_folder(self) -> str | None: - folder = run_file_dialog( - lambda _root: filedialog.askdirectory(title="选择文件夹") - ) - return folder or None - - def select_clean_folder(self) -> str | None: - folder = run_file_dialog( - lambda _root: filedialog.askdirectory(title="选择待清洗文件夹") - ) - return folder or None - - def get_excel_headers(self, file_path: str) -> dict: - try: - workbook = load_workbook(file_path, read_only=True, data_only=True) - sheet = workbook[workbook.sheetnames[0]] - first_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None) - workbook.close() - if not first_row: - return {"success": False, "error": "Excel 表头为空"} - - headers: list[str] = [] - seen: set[str] = set() - for cell in first_row: - if cell is None: - continue - raw_value = str(cell).replace("\ufeff", "").strip() - if not raw_value: - continue - value = raw_value.split("idASIN国家状态价格变体数量", 1)[0].strip() or raw_value - if value in seen: - continue - seen.add(value) - headers.append(value) - if value == "缩略图地址8": - break - - if not headers: - return {"success": False, "error": "未读取到有效表头"} - return {"success": True, "headers": headers} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def get_excel_info(self, file_path: str) -> dict: - try: - workbook = load_workbook(file_path, read_only=True, data_only=True) - sheet = workbook[workbook.sheetnames[0]] - first_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None) - if not first_row: - workbook.close() - return {"success": False, "error": "Excel 表头为空"} - - headers: list[str] = [] - seen: set[str] = set() - for cell in first_row: - if cell is None: - continue - raw_value = str(cell).replace("\ufeff", "").strip() - if not raw_value: - continue - value = raw_value.split("idASIN国家状态价格变体数量", 1)[0].strip() or raw_value - if value in seen: - continue - seen.add(value) - headers.append(value) - if value == "缩略图地址8": - break - - total_rows = max(sheet.max_row - 1, 0) - workbook.close() - return {"success": True, "headers": headers, "total_rows": total_rows} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def list_txt_templates(self) -> list[dict]: - try: - response = self.session.get( - resolve_url(self.app_base_url, "/api/convert/templates"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - if not payload.get("success"): - return [] - items = payload.get("data") or [] - return [ - { - "id": str(item.get("templateCode") or item.get("id") or ""), - "label": item.get("templateName") or "", - "output_filename": item.get("outputFilename") or "", - "is_default": bool(item.get("isDefault")), - "built_in": bool(item.get("builtIn")), - } - for item in items - ] - except Exception: - templates, _default_template_id = build_effective_templates() - return [ - { - "id": template["id"], - "label": template["label"], - "output_filename": template["output_filename"], - "is_default": template.get("is_default", False), - "built_in": template.get("built_in", False), - } - for template in templates.values() - ] - - def import_txt_template(self, name: str | None = None) -> dict: - file_path = run_file_dialog( - lambda _root: filedialog.askopenfilename( - title="选择 txt 模板文件", - filetypes=[("TXT files", "*.txt")], - ) - ) - if not file_path: - return {"success": False, "error": "用户取消"} - - try: - try: - template_text = Path(file_path).read_text(encoding="utf-8") - except UnicodeDecodeError: - template_text = Path(file_path).read_text(encoding="utf-8-sig") - - response = self.session.post( - resolve_url(self.app_base_url, "/api/convert/templates/import"), - json={ - "templateName": (name or "").strip() or Path(file_path).stem, - "templateContent": template_text, - }, - timeout=120, - ) - response.raise_for_status() - payload = response.json() - if not payload.get("success") or not payload.get("data"): - return {"success": False, "error": payload.get("message") or "导入失败"} - item = payload.get("data") or {} - return { - "success": True, - "template": { - "id": str(item.get("templateCode") or item.get("id") or ""), - "label": item.get("templateName") or "", - "output_filename": item.get("outputFilename") or "", - "is_default": bool(item.get("isDefault")), - "built_in": bool(item.get("builtIn")), - }, - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def set_default_txt_template(self, template_id: str) -> dict: - try: - response = self.session.post( - resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}/default"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def delete_txt_template(self, template_id: str) -> dict: - try: - response = self.session.delete( - resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def split_excel_files(self, payload: dict) -> dict: - paths = payload.get("paths") or [] - selected_columns = payload.get("selected_columns") or [] - split_mode = payload.get("split_mode") or "rows_per_file" - rows_per_file = payload.get("rows_per_file") - parts = payload.get("parts") - - if not isinstance(paths, list) or not paths: - return {"success": False, "error": "未选择待拆分文件"} - if not isinstance(selected_columns, list) or not selected_columns: - return {"success": False, "error": "请至少选择一列"} - if split_mode not in {"rows_per_file", "parts"}: - return {"success": False, "error": "拆分模式不合法"} - if split_mode == "rows_per_file" and (not isinstance(rows_per_file, int) or rows_per_file <= 0): - return {"success": False, "error": "每份条数不合法"} - if split_mode == "parts" and (not isinstance(parts, int) or parts <= 0): - return {"success": False, "error": "拆分份数不合法"} - - try: - uploaded_files = [] - for path in paths: - with open(path, "rb") as file_obj: - response = self.session.post( - resolve_url(self.app_base_url, "/api/files/upload"), - files={"file": (Path(path).name, file_obj)}, - timeout=180, - ) - response.raise_for_status() - upload_payload = response.json() - if not upload_payload.get("success") or not upload_payload.get("data"): - return {"success": False, "error": upload_payload.get("message") or "上传文件失败"} - upload_data = upload_payload.get("data") or {} - uploaded_files.append({ - "fileKey": upload_data.get("fileKey"), - "originalFilename": upload_data.get("originalFilename") or Path(path).name, - }) - - run_response = self.session.post( - resolve_url(self.app_base_url, "/api/split/run"), - json={ - "files": uploaded_files, - "selectedColumns": selected_columns, - "splitMode": split_mode, - "rowsPerFile": rows_per_file, - "parts": parts, - }, - timeout=600, - ) - run_response.raise_for_status() - run_payload = run_response.json() - if not run_payload.get("success") or not run_payload.get("data"): - return {"success": False, "error": run_payload.get("message") or "数据拆分失败"} - - data = run_payload.get("data") or {} - result_items = [] - for item in data.get("items") or []: - if item.get("success") and item.get("downloadUrl"): - result_items.append({ - "result_id": item.get("resultId"), - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": True, - "row_count": item.get("rowCount"), - }) - else: - result_items.append({ - "source_path": item.get("sourceFilename") or "", - "success": False, - "error": item.get("error") or "数据拆分失败", - }) - - return { - "success": True, - "summary": { - "total": data.get("total") or len(paths), - "success_count": data.get("successCount") or 0, - "failed_count": data.get("failedCount") or 0, - }, - "items": result_items, - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def delete_history_item(self, module_type: str, result_id: int) -> dict: - try: - response = self.session.delete( - resolve_url(self.app_base_url, f"/api/{module_type}/history/{result_id}"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def get_convert_history(self) -> dict: - try: - response = self.session.get( - resolve_url(self.app_base_url, "/api/convert/history"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - if not payload.get("success") or not payload.get("data"): - return {"success": False, "error": payload.get("message") or "获取历史失败"} - - items = payload.get("data", {}).get("items") or [] - return { - "success": True, - "items": [ - { - "result_id": item.get("resultId"), - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": bool(item.get("success", True)), - } - for item in items - ], - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def get_split_history(self) -> dict: - try: - response = self.session.get( - resolve_url(self.app_base_url, "/api/split/history"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - if not payload.get("success") or not payload.get("data"): - return {"success": False, "error": payload.get("message") or "获取历史失败"} - - items = payload.get("data", {}).get("items") or [] - return { - "success": True, - "items": [ - { - "result_id": item.get("resultId"), - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": bool(item.get("success", True)), - "row_count": item.get("rowCount"), - } - for item in items - ], - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def get_dedupe_history(self) -> dict: - try: - response = self.session.get( - resolve_url(self.app_base_url, "/api/dedupe/history"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - if not payload.get("success") or not payload.get("data"): - return {"success": False, "error": payload.get("message") or "获取历史失败"} - - items = payload.get("data", {}).get("items") or [] - return { - "success": True, - "items": [ - { - "result_id": item.get("resultId"), - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": bool(item.get("success", True)), - } - for item in items - ], - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def clean_excel_files(self, payload: dict) -> dict: - paths = payload.get("paths") or [] - selected_columns = payload.get("selected_columns") or [] - keep_integer_ids = bool(payload.get("keep_integer_ids", True)) - keep_underscore_ids = bool(payload.get("keep_underscore_ids", True)) - - if not isinstance(paths, list) or not paths: - return {"success": False, "error": "未选择待处理文件"} - if not isinstance(selected_columns, list) or not selected_columns: - return {"success": False, "error": "请至少选择一列"} - if not keep_integer_ids and not keep_underscore_ids: - return {"success": False, "error": "请至少选择一种 ID 保留规则"} - - try: - uploaded_files = [] - for path in paths: - with open(path, "rb") as file_obj: - response = self.session.post( - resolve_url(self.app_base_url, "/api/files/upload"), - files={"file": (Path(path).name, file_obj)}, - timeout=180, - ) - response.raise_for_status() - upload_payload = response.json() - if not upload_payload.get("success") or not upload_payload.get("data"): - return {"success": False, "error": upload_payload.get("message") or "上传文件失败"} - upload_data = upload_payload.get("data") or {} - uploaded_files.append({ - "fileKey": upload_data.get("fileKey"), - "originalFilename": upload_data.get("originalFilename") or Path(path).name, - }) - - run_response = self.session.post( - resolve_url(self.app_base_url, "/api/dedupe/run"), - json={ - "files": uploaded_files, - "selectedColumns": selected_columns, - "keepIntegerIds": keep_integer_ids, - "keepUnderscoreIds": keep_underscore_ids, - }, - timeout=600, - ) - run_response.raise_for_status() - run_payload = run_response.json() - if not run_payload.get("success") or not run_payload.get("data"): - return {"success": False, "error": run_payload.get("message") or "去重失败"} - - data = run_payload.get("data") or {} - result_items = [] - for item in data.get("items") or []: - if item.get("success") and item.get("downloadUrl"): - result_items.append({ - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": True, - }) - else: - result_items.append({ - "source_path": item.get("sourceFilename") or "", - "success": False, - "error": item.get("error") or "去重失败", - }) - - return { - "success": True, - "summary": { - "total": data.get("total") or len(paths), - "success_count": data.get("successCount") or 0, - "failed_count": data.get("failedCount") or 0, - }, - "items": result_items, - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def convert_excel_to_uk_txt(self, payload: dict) -> dict: - paths = payload.get("paths") or [] - template_id = payload.get("template_id") or "uk_offer" - - if not isinstance(paths, list) or not paths: - return {"success": False, "error": "未选择待转换文件"} - - try: - templates = self.list_txt_templates() - selected_template = next((item for item in templates if item.get("id") == template_id), None) - if not selected_template: - return {"success": False, "error": "模板不存在"} - - uploaded_files = [] - for path in paths: - with open(path, "rb") as file_obj: - response = self.session.post( - resolve_url(self.app_base_url, "/api/files/upload"), - files={"file": (Path(path).name, file_obj)}, - timeout=180, - ) - response.raise_for_status() - upload_payload = response.json() - if not upload_payload.get("success") or not upload_payload.get("data"): - return {"success": False, "error": upload_payload.get("message") or "上传文件失败"} - upload_data = upload_payload.get("data") or {} - uploaded_files.append({ - "fileKey": upload_data.get("fileKey"), - "originalFilename": upload_data.get("originalFilename") or Path(path).name, - }) - - run_response = self.session.post( - resolve_url(self.app_base_url, "/api/convert/run"), - json={ - "files": uploaded_files, - "templateId": str(template_id), - }, - timeout=600, - ) - run_response.raise_for_status() - run_payload = run_response.json() - if not run_payload.get("success") or not run_payload.get("data"): - return {"success": False, "error": run_payload.get("message") or "格式转换失败"} - - data = run_payload.get("data") or {} - result_items = [] - for item in data.get("items") or []: - if item.get("success") and item.get("downloadUrl"): - result_items.append({ - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": True, - }) - else: - result_items.append({ - "source_path": item.get("sourceFilename") or "", - "success": False, - "error": item.get("error") or "格式转换失败", - }) - - return { - "success": True, - "summary": { - "total": data.get("total") or len(paths), - "success_count": data.get("successCount") or 0, - "failed_count": data.get("failedCount") or 0, - }, - "items": result_items, - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def open_path(self, path: str) -> dict: - try: - if not path: - return {"success": False, "error": "路径为空"} - target = Path(path) - if not target.exists(): - return {"success": False, "error": "路径不存在"} - os.startfile(str(target)) - return {"success": True} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def select_folder(self) -> str | None: - folder = run_file_dialog( - lambda _root: filedialog.askdirectory(title="选择文件夹") - ) - return folder or None - - def save_template_xlsx(self) -> dict: - return self._download_to_selected_path( - "/static/品牌文档格式_模板.xlsx", - "品牌文档格式_模板.xlsx", - ) - - def save_template_zip(self) -> dict: - return self._download_to_selected_path( - "/static/模板2-以文件夹方式上传.zip", - "模板2-以文件夹方式上传.zip", - ) - - def save_file_from_url(self, url: str, filename: str) -> dict: - return self._download_to_selected_path(url, filename) - - def _download_to_selected_path(self, source_url: str, default_filename: str) -> dict: - path = run_file_dialog( - lambda _root: filedialog.asksaveasfilename( - title="选择保存位置", - initialfile=default_filename, - defaultextension=Path(default_filename).suffix or None, - ) - ) - if not path: - return {"success": False, "error": "用户取消"} - - try: - resolved_url = resolve_url(self.app_base_url, source_url) - with self.session.get(resolved_url, timeout=120, stream=True) as response: - response.raise_for_status() - with open(path, "wb") as file: - for chunk in response.iter_content(chunk_size=1024 * 128): - if chunk: - file.write(chunk) - return {"success": True, "path": path} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - -class DistRequestHandler(BaseHTTPRequestHandler): - backend_base_url = "" - - def do_GET(self) -> None: # noqa: N802 - self._dispatch() - - def do_POST(self) -> None: # noqa: N802 - self._dispatch() - - def do_PUT(self) -> None: # noqa: N802 - self._dispatch() - - def do_DELETE(self) -> None: # noqa: N802 - self._dispatch() - - def do_OPTIONS(self) -> None: # noqa: N802 - self._dispatch() - - def _dispatch(self) -> None: - if self.path.startswith(PROXY_PREFIXES): - self._proxy_to_backend() - return - self._serve_dist_file() - - def _proxy_to_backend(self) -> None: - target_url = resolve_url(self.backend_base_url, self.path) - body = self._read_request_body() - headers = {k: v for k, v in self.headers.items() if k.lower() != "host"} - - try: - response = requests.request( - method=self.command, - url=target_url, - headers=headers, - data=body, - stream=True, - timeout=120, - allow_redirects=False, - ) - except requests.RequestException as exc: - payload = f"后端代理请求失败:{exc}".encode("utf-8") - self.send_response(502) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - return - - self.send_response(response.status_code) - excluded_headers = {"content-encoding", "transfer-encoding", "connection"} - for key, value in response.headers.items(): - if key.lower() in excluded_headers: - continue - self.send_header(key, value) - self.end_headers() - for chunk in response.iter_content(chunk_size=1024 * 128): - if chunk: - self.wfile.write(chunk) - - def _serve_dist_file(self) -> None: - request_path = self.path.split("?", 1)[0].lstrip("/") - if not request_path: - file_path = INDEX_FILE - else: - file_path = (DIST_DIR / request_path).resolve() - if not str(file_path).startswith(str(DIST_DIR.resolve())): - self.send_error(403) - return - if not file_path.exists() or file_path.is_dir(): - file_path = INDEX_FILE - - try: - content = file_path.read_bytes() - except FileNotFoundError: - self.send_error(404) - return - - mime_type, _ = mimetypes.guess_type(str(file_path)) - self.send_response(200) - self.send_header("Content-Type", mime_type or "application/octet-stream") - self.send_header("Content-Length", str(len(content))) - self.end_headers() - self.wfile.write(content) - - def _read_request_body(self) -> bytes | None: - content_length = self.headers.get("Content-Length") - if not content_length: - return None - return self.rfile.read(int(content_length)) - - def log_message(self, format: str, *args) -> None: # noqa: A003 - return - - -class DistServer: - def __init__(self, host: str, port: int, backend_url: str) -> None: - handler = type( - "ConfiguredDistRequestHandler", - (DistRequestHandler,), - {"backend_base_url": backend_url.rstrip("/")}, - ) - self.server = ThreadingHTTPServer((host, port), handler) - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - self.base_url = f"http://{host}:{port}" - - def start(self) -> None: - self.thread.start() - - def stop(self) -> None: - self.server.shutdown() - self.server.server_close() - self.thread.join(timeout=2) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Crawler Plugin 桌面端启动器") - parser.add_argument("--mode", choices=("dev", "dist"), default="dev") - parser.add_argument("--frontend-url", default="http://127.0.0.1:5173") - parser.add_argument("--backend-url", default="http://127.0.0.1:8000") - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=0) - parser.add_argument("--width", type=int, default=1440) - parser.add_argument("--height", type=int, default=960) - parser.add_argument("--title", default="数富AI") - return parser - - -def parse_args() -> DesktopConfig: - args = build_parser().parse_args() - port = args.port or find_free_port() - return DesktopConfig( - mode=args.mode, - frontend_url=args.frontend_url.rstrip("/"), - backend_url=args.backend_url.rstrip("/"), - host=args.host, - port=port, - width=args.width, - height=args.height, - title=args.title, - ) - - -def ensure_dev_server_ready(frontend_url: str) -> None: - try: - response = requests.get(frontend_url, timeout=5) - response.raise_for_status() - except requests.RequestException as exc: - raise SystemExit( - "Dev server is not reachable.\n" - f"Expected frontend URL: {frontend_url}\n" - "Start Vite first with:\n" - " cd frontend-vue\n" - " npm run dev" - ) from exc - - -def main() -> None: - config = parse_args() - dist_server = None - - if config.mode == "dist": - ensure_dist_exists() - dist_server = DistServer(config.host, config.port, config.backend_url) - dist_server.start() - app_url = dist_server.base_url - else: - ensure_dev_server_ready(config.frontend_url) - app_url = config.frontend_url - - api = DesktopApi(config.backend_url) - window = webview.create_window( - title=config.title, - url=app_url, - js_api=api, - width=config.width, - height=config.height, - min_size=(1200, 760), - ) - - try: - webview.start() - finally: - if dist_server is not None: - dist_server.stop() - - -if __name__ == "__main__": - main() diff --git a/desktop/requirements.txt b/desktop/requirements.txt deleted file mode 100644 index ce8b9a8..0000000 --- a/desktop/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pywebview>=5.1 -requests>=2.32.0 -openpyxl>=3.1.5 diff --git a/desktop/run_desktop_dev.ps1 b/desktop/run_desktop_dev.ps1 deleted file mode 100644 index eda8ff8..0000000 --- a/desktop/run_desktop_dev.ps1 +++ /dev/null @@ -1,67 +0,0 @@ -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$root = Split-Path -Parent $PSScriptRoot -Set-Location $root - -function Get-CompatiblePythonCommand { - $pyLauncher = Get-Command py -ErrorAction SilentlyContinue - if ($pyLauncher) { - foreach ($version in @("3.12", "3.11")) { - try { - & py "-$version" --version *> $null - if ($LASTEXITCODE -eq 0) { - return @("py", "-$version") - } - } catch { - } - } - } - - $pythonCommand = Get-Command python -ErrorAction SilentlyContinue - if ($pythonCommand) { - try { - $pythonVersion = (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null).Trim() - if ($pythonVersion -in @("3.12", "3.11")) { - return @("python") - } - } catch { - } - } - - return $null -} - -function Invoke-Python { - param( - [string[]]$PythonCommand, - [string[]]$Arguments - ) - - $exe = $PythonCommand[0] - $prefix = @() - if ($PythonCommand.Length -gt 1) { - $prefix = $PythonCommand[1..($PythonCommand.Length - 1)] - } - - & $exe @prefix @Arguments -} - -$pythonCmd = Get-CompatiblePythonCommand -if (-not $pythonCmd) { - Write-Host "" - Write-Host "No compatible Python version was found." - Write-Host "Desktop mode requires Python 3.12 or 3.11 for pywebview on Windows." - Write-Host "Only Python 3.14 was detected, which will fail when installing pythonnet." - Write-Host "" - Write-Host "Install Python 3.12 first, then run:" - Write-Host " .\desktop\run_desktop_dev.ps1" - exit 1 -} - -Write-Host "Using Python: $($pythonCmd -join ' ')" -Write-Host "Ensuring desktop dependencies..." -Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt") - -Write-Host "Starting desktop app..." -Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:18080") diff --git a/desktop/run_desktop_dist.ps1 b/desktop/run_desktop_dist.ps1 deleted file mode 100644 index 39025d2..0000000 --- a/desktop/run_desktop_dist.ps1 +++ /dev/null @@ -1,67 +0,0 @@ -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$root = Split-Path -Parent $PSScriptRoot -Set-Location $root - -function Get-CompatiblePythonCommand { - $pyLauncher = Get-Command py -ErrorAction SilentlyContinue - if ($pyLauncher) { - foreach ($version in @("3.12", "3.11")) { - try { - & py "-$version" --version *> $null - if ($LASTEXITCODE -eq 0) { - return @("py", "-$version") - } - } catch { - } - } - } - - $pythonCommand = Get-Command python -ErrorAction SilentlyContinue - if ($pythonCommand) { - try { - $pythonVersion = (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null).Trim() - if ($pythonVersion -in @("3.12", "3.11")) { - return @("python") - } - } catch { - } - } - - return $null -} - -function Invoke-Python { - param( - [string[]]$PythonCommand, - [string[]]$Arguments - ) - - $exe = $PythonCommand[0] - $prefix = @() - if ($PythonCommand.Length -gt 1) { - $prefix = $PythonCommand[1..($PythonCommand.Length - 1)] - } - - & $exe @prefix @Arguments -} - -$pythonCmd = Get-CompatiblePythonCommand -if (-not $pythonCmd) { - Write-Host "" - Write-Host "No compatible Python version was found." - Write-Host "Desktop mode requires Python 3.12 or 3.11 for pywebview on Windows." - Write-Host "Only Python 3.14 was detected, which will fail when installing pythonnet." - Write-Host "" - Write-Host "Install Python 3.12 first, then run:" - Write-Host " .\desktop\run_desktop_dist.ps1" - exit 1 -} - -Write-Host "Using Python: $($pythonCmd -join ' ')" -Write-Host "Ensuring desktop dependencies..." -Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt") - -Write-Host "Starting desktop app..." -Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dist", "--backend-url", "http://127.0.0.1:8000") diff --git a/desktop/txt_templates.json b/desktop/txt_templates.json deleted file mode 100644 index da5deed..0000000 --- a/desktop/txt_templates.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "default_template_id": null, - "templates": { - "user_1773929220357": { - "id": "user_1773929220357", - "label": "自定义 Offer 模板", - "output_filename": "英国.txt", - "is_default": false, - "built_in": false, - "preamble_lines": [ - "TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" - ], - "header_columns": [ - "sku", - "price", - "quantity", - "product-id", - "product-id-type", - "condition-type", - "condition-note", - "ASIN-hint", - "title", - "product-tax-code", - "operation-type", - "sale-price", - "sale-start-date", - "sale-end-date", - "leadtime-to-ship", - "launch-date", - "is-giftwrap-available", - "is-gift-message-available" - ], - "blank_header_rows_after_schema": 1, - "required_source_columns": [ - "ASIN", - "价格" - ], - "defaults": { - "quantity": "100", - "product-id-type": "ASIN", - "condition-type": "new", - "leadtime-to-ship": "4" - }, - "field_mapping": { - "price": "价格", - "product-id": "ASIN" - } - }, - "user_1773929734779": { - "id": "user_1773929734779", - "label": "测试", - "output_filename": "测试.txt", - "is_default": false, - "built_in": false, - "preamble_lines": [ - "TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" - ], - "header_columns": [ - "sku", - "price", - "quantity", - "product-id", - "product-id-type", - "condition-type", - "condition-note", - "ASIN-hint", - "title", - "product-tax-code", - "operation-type", - "sale-price", - "sale-start-date", - "sale-end-date", - "leadtime-to-ship", - "launch-date", - "is-giftwrap-available", - "is-gift-message-available" - ], - "blank_header_rows_after_schema": 1, - "required_source_columns": [ - "ASIN", - "价格" - ], - "defaults": { - "quantity": "100", - "product-id-type": "ASIN", - "condition-type": "new", - "leadtime-to-ship": "4" - }, - "field_mapping": { - "price": "价格", - "product-id": "ASIN" - } - }, - "user_1773982097249": { - "id": "user_1773982097249", - "label": "默认", - "output_filename": "默认.txt", - "is_default": false, - "built_in": false, - "preamble_lines": [ - "TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" - ], - "header_columns": [ - "sku", - "price", - "quantity", - "product-id", - "product-id-type", - "condition-type", - "condition-note", - "ASIN-hint", - "title", - "product-tax-code", - "operation-type", - "sale-price", - "sale-start-date", - "sale-end-date", - "leadtime-to-ship", - "launch-date", - "is-giftwrap-available", - "is-gift-message-available" - ], - "blank_header_rows_after_schema": 1, - "required_source_columns": [ - "ASIN", - "价格" - ], - "defaults": { - "quantity": "100", - "product-id-type": "ASIN", - "condition-type": "new", - "leadtime-to-ship": "4" - }, - "field_mapping": { - "price": "价格", - "product-id": "ASIN" - } - } - } -} \ No newline at end of file