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 74a00d4..0000000 Binary files a/desktop/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/desktop/__pycache__/__init__.cpython-314.pyc b/desktop/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index bae2de3..0000000 Binary files a/desktop/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/desktop/__pycache__/app.cpython-312.pyc b/desktop/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 6ec347a..0000000 Binary files a/desktop/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/desktop/__pycache__/app.cpython-314.pyc b/desktop/__pycache__/app.cpython-314.pyc deleted file mode 100644 index bb0d42d..0000000 Binary files a/desktop/__pycache__/app.cpython-314.pyc and /dev/null differ 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