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