commit a2f5875d1b4e3b9e4d933742e3690fd4fa650195 Author: 王鹏 Date: Thu Apr 9 14:55:54 2026 +0800 init diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..229b019 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(pyinstaller yidaima.spec --clean)", + "Bash(del /q /s dist\\\\\\\\* build\\\\\\\\*)", + "Bash(powershell -Command \"Remove-Item -Path dist\\\\\\\\* -Recurse -Force; Remove-Item -Path build\\\\\\\\* -Recurse -Force\")", + "Bash(ls -la *.py)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..495b271 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# 忽略打包后的静态资源(你的核心需求) +dist/ +build/ +dist.zip + +# 忽略依赖库 +node_modules/ +jspm_packages/ + +# 忽略日志文件 +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# 忽略本地环境变量文件(非常重要,保护隐私) +.env +.env.local +.env.*.local + +# 忽略系统生成的临时文件 +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..76a49a6 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# yidaima - Umi-OCR 桌面自动化脚本 + +## 依赖与准备 + +- 启动 **Umi-OCR** 并开启 **HTTP 服务**(默认 `http://127.0.0.1:1224/api/ocr`) +- 安装依赖: + +```bash +python -m pip install -r requirements.txt +``` + +## 快速验证 + +- 等待某个文字出现在屏幕上: + +```bash +python main.py --wait "确定" --timeout 20 +``` + +- 点击某个文字: + +```bash +python main.py --click "确定" +``` + +## 局部区域 OCR(更快) + +指定区域格式为 `left,top,width,height`(屏幕坐标): + +```bash +python main.py --wait "登录" --region 100,100,800,600 +``` + +## 配置 + +编辑 `config.yaml`: + +- `umi_ocr.url`: Umi-OCR API 地址 +- `screenshot.default_region`: 默认 OCR 区域(null 为全屏) +- `screenshot.prefer_mss`: 优先使用 mss 截图(更快),失败会自动回退 + +## Playwright 示例:百度搜索并截图 + +对应文档 `docs/baidu.md`,脚本入口:`baidu_playwright.py`。 + +安装依赖: + +```bash +python -m pip install -r requirements.txt +python -m playwright install chromium +``` + +运行(可见浏览器): + +```bash +python baidu_playwright.py --keyword "java教程" +``` + +无头模式: + +```bash +python baidu_playwright.py --keyword "java教程" --headless +``` + diff --git a/__pycache__/baidu_playwright.cpython-311.pyc b/__pycache__/baidu_playwright.cpython-311.pyc new file mode 100644 index 0000000..2dab451 Binary files /dev/null and b/__pycache__/baidu_playwright.cpython-311.pyc differ diff --git a/__pycache__/config_loader.cpython-311.pyc b/__pycache__/config_loader.cpython-311.pyc new file mode 100644 index 0000000..dbd85d8 Binary files /dev/null and b/__pycache__/config_loader.cpython-311.pyc differ diff --git a/__pycache__/db_manager.cpython-311.pyc b/__pycache__/db_manager.cpython-311.pyc new file mode 100644 index 0000000..3498183 Binary files /dev/null and b/__pycache__/db_manager.cpython-311.pyc differ diff --git a/__pycache__/editor_gui.cpython-311.pyc b/__pycache__/editor_gui.cpython-311.pyc new file mode 100644 index 0000000..c1b6658 Binary files /dev/null and b/__pycache__/editor_gui.cpython-311.pyc differ diff --git a/__pycache__/gui.cpython-311.pyc b/__pycache__/gui.cpython-311.pyc new file mode 100644 index 0000000..10df6cc Binary files /dev/null and b/__pycache__/gui.cpython-311.pyc differ diff --git a/__pycache__/image_uploader.cpython-311.pyc b/__pycache__/image_uploader.cpython-311.pyc new file mode 100644 index 0000000..5f358ad Binary files /dev/null and b/__pycache__/image_uploader.cpython-311.pyc differ diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..78714ac Binary files /dev/null and b/__pycache__/main.cpython-311.pyc differ diff --git a/__pycache__/markdown_editor.cpython-311.pyc b/__pycache__/markdown_editor.cpython-311.pyc new file mode 100644 index 0000000..c2be66c Binary files /dev/null and b/__pycache__/markdown_editor.cpython-311.pyc differ diff --git a/__pycache__/md_to_wechat.cpython-311.pyc b/__pycache__/md_to_wechat.cpython-311.pyc new file mode 100644 index 0000000..0a46f3f Binary files /dev/null and b/__pycache__/md_to_wechat.cpython-311.pyc differ diff --git a/__pycache__/step1.cpython-311.pyc b/__pycache__/step1.cpython-311.pyc new file mode 100644 index 0000000..23ac105 Binary files /dev/null and b/__pycache__/step1.cpython-311.pyc differ diff --git a/__pycache__/step2.cpython-311.pyc b/__pycache__/step2.cpython-311.pyc new file mode 100644 index 0000000..7f194ac Binary files /dev/null and b/__pycache__/step2.cpython-311.pyc differ diff --git a/__pycache__/wechat_publisher.cpython-311.pyc b/__pycache__/wechat_publisher.cpython-311.pyc new file mode 100644 index 0000000..a010efd Binary files /dev/null and b/__pycache__/wechat_publisher.cpython-311.pyc differ diff --git a/assets/.keep b/assets/.keep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/assets/.keep @@ -0,0 +1 @@ + diff --git a/assets/img/bg.jpg b/assets/img/bg.jpg new file mode 100644 index 0000000..8e8e142 Binary files /dev/null and b/assets/img/bg.jpg differ diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..b4f32cb --- /dev/null +++ b/config.yaml @@ -0,0 +1,20 @@ +# 微信公众号配置 +wechat: + appid: "wx3599fe5ceef66335" # 微信公众号 AppID + appsecret: "282d9f8be4bb10f31d95dd5878e18690" # 微信公众号 AppSecret + +# Chrome 浏览器路径 +chrome: + path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" + +# Step1 配置 +step1: + url: "https://feast.yidaima.cn/login" + username: "wangpeng" + password: "Feastcoding@123" + project_name: "【A167】基于Springboot + vue3实现的宿舍报修系统" + feast_button: "FeastCoding" + +# Step2 配置 +step2: + url: "http://yidaima.cn:6005/" diff --git a/config/.keep b/config/.keep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/.keep @@ -0,0 +1 @@ + diff --git a/config_loader.py b/config_loader.py new file mode 100644 index 0000000..3c24211 --- /dev/null +++ b/config_loader.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import yaml + + +class Config: + """配置管理类""" + + _instance = None + _config_data = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if self._config_data is None: + self._load_config() + + def _load_config(self) -> None: + """加载配置文件""" + config_path = Path(__file__).parent / "config.yaml" + + # 默认配置 + default_config = { + "wechat": { + "appid": "", + "appsecret": "" + }, + "chrome": { + "path": r"C:\Program Files\Google\Chrome\Application\chrome.exe" + }, + "step1": { + "url": "https://feast.yidaima.cn/login", + "username": "wangpeng", + "password": "Feastcoding@123", + "project_name": "【A167】基于Springboot + vue3实现的宿舍报修系统", + "feast_button": "FeastCoding" + }, + "step2": { + "url": "http://yidaima.cn:6005/" + }, + "database": { + "host": "localhost", + "port": 3306, + "database": "test", + "user": "root", + "password": "123456" + } + } + + if config_path.exists(): + try: + with open(config_path, 'r', encoding='utf-8') as f: + loaded_config = yaml.safe_load(f) or {} + # 合并配置 + self._config_data = self._merge_config(default_config, loaded_config) + except Exception as e: + print(f"[Config] 加载配置文件失败: {e},使用默认配置") + self._config_data = default_config + else: + print("[Config] 配置文件不存在,使用默认配置") + self._config_data = default_config + + # 环境变量覆盖 + self._apply_env_overrides() + + def _merge_config(self, default: dict, loaded: dict) -> dict: + """递归合并配置""" + result = default.copy() + for key, value in loaded.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = self._merge_config(result[key], value) + else: + result[key] = value + return result + + def _apply_env_overrides(self) -> None: + """应用环境变量覆盖""" + # 微信配置 + if os.environ.get("WECHAT_APPID"): + self._config_data["wechat"]["appid"] = os.environ.get("WECHAT_APPID") + if os.environ.get("WECHAT_APPSECRET"): + self._config_data["wechat"]["appsecret"] = os.environ.get("WECHAT_APPSECRET") + + # Chrome 路径 + if os.environ.get("CHROME_PATH"): + self._config_data["chrome"]["path"] = os.environ.get("CHROME_PATH") + + # Step1 配置 + if os.environ.get("FEAST_USERNAME"): + self._config_data["step1"]["username"] = os.environ.get("FEAST_USERNAME") + if os.environ.get("FEAST_PASSWORD"): + self._config_data["step1"]["password"] = os.environ.get("FEAST_PASSWORD") + + # 数据库配置 + if os.environ.get("DB_HOST"): + self._config_data["database"]["host"] = os.environ.get("DB_HOST") + if os.environ.get("DB_PORT"): + self._config_data["database"]["port"] = int(os.environ.get("DB_PORT")) + if os.environ.get("DB_NAME"): + self._config_data["database"]["database"] = os.environ.get("DB_NAME") + if os.environ.get("DB_USER"): + self._config_data["database"]["user"] = os.environ.get("DB_USER") + if os.environ.get("DB_PASSWORD"): + self._config_data["database"]["password"] = os.environ.get("DB_PASSWORD") + + def get(self, key: str, default: Any = None) -> Any: + """ + 获取配置值,支持点号分隔的路径 + 例如: get("wechat.appid") 或 get("step1.url") + """ + keys = key.split('.') + value = self._config_data + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + return value + + @property + def wechat_appid(self) -> str: + return self.get("wechat.appid", "") + + @property + def wechat_appsecret(self) -> str: + return self.get("wechat.appsecret", "") + + @property + def chrome_path(self) -> str: + return self.get("chrome.path", r"C:\Program Files\Google\Chrome\Application\chrome.exe") + + @property + def step1_config(self) -> dict: + return self.get("step1", {}) + + @property + def step2_config(self) -> dict: + return self.get("step2", {}) + + @property + def database_config(self) -> dict: + return self.get("database", {}) + + +# 全局配置实例 +_config_instance = None + + +def get_config() -> Config: + """获取配置实例(单例模式)""" + global _config_instance + if _config_instance is None: + _config_instance = Config() + return _config_instance + + +def reset_config(): + """重置配置实例(用于重新加载)""" + global _config_instance + _config_instance = None + + +if __name__ == "__main__": + # 测试配置加载 + config = get_config() + print(f"WeChat AppID: {config.wechat_appid}") + print(f"WeChat AppSecret: {'*' * len(config.wechat_appsecret) if config.wechat_appsecret else '(empty)'}") + print(f"Chrome Path: {config.chrome_path}") + print(f"Step1 URL: {config.get('step1.url')}") + print(f"Step2 URL: {config.get('step2.url')}") + print(f"Database Host: {config.get('database.host')}") + print(f"Database Port: {config.get('database.port')}") diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ + diff --git a/core/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..37d9198 Binary files /dev/null and b/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/core/__pycache__/actions.cpython-311.pyc b/core/__pycache__/actions.cpython-311.pyc new file mode 100644 index 0000000..d138093 Binary files /dev/null and b/core/__pycache__/actions.cpython-311.pyc differ diff --git a/core/__pycache__/ocr_client.cpython-311.pyc b/core/__pycache__/ocr_client.cpython-311.pyc new file mode 100644 index 0000000..d25e496 Binary files /dev/null and b/core/__pycache__/ocr_client.cpython-311.pyc differ diff --git a/core/actions.py b/core/actions.py new file mode 100644 index 0000000..f1cdc32 --- /dev/null +++ b/core/actions.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Optional + +import pyautogui + +from core.ocr_client import UmiClient +from utils.screenshot import capture_screen + + +@dataclass(frozen=True) +class MatchResult: + # 屏幕坐标(可直接点击) + x: int + y: int + # 命中的文字(当前实现为目标文字本身;若后续做模糊匹配可返回实际匹配串) + text: str + + +class ActionRunner: + def __init__(self, ocr: UmiClient, *, prefer_mss: bool = True, default_region: Optional[tuple[int, int, int, int]] = None): + self.ocr = ocr + self.prefer_mss = prefer_mss + self.default_region = default_region + + def locate_text( + self, + text: str, + *, + region: Optional[tuple[int, int, int, int]] = None, + exact: bool = True, + case_sensitive: bool = False, + ) -> Optional[MatchResult]: + cap = capture_screen(region or self.default_region, prefer_mss=self.prefer_mss) + items = self.ocr.ocr_bytes(cap.image_bytes) + pt = self.ocr.find_text(text, items, exact=exact, case_sensitive=case_sensitive) + if pt is None: + return None + + img_x, img_y = pt + left, top, _, _ = cap.region + # OCR 坐标是“截图图片像素坐标”,需先缩放到屏幕坐标,再加上截图区域偏移 + scr_x = int(left + img_x * cap.scale_x) + scr_y = int(top + img_y * cap.scale_y) + return MatchResult(x=scr_x, y=scr_y, text=text) + + def click_text( + self, + text: str, + *, + region: Optional[tuple[int, int, int, int]] = None, + exact: bool = True, + case_sensitive: bool = False, + clicks: int = 1, + interval: float = 0.05, + button: str = "left", + move_duration: float = 0.0, + pause: float = 0.05, + ) -> MatchResult: + pyautogui.PAUSE = pause + m = self.locate_text(text, region=region, exact=exact, case_sensitive=case_sensitive) + if m is None: + raise TimeoutError(f"未找到文字:{text}") + pyautogui.moveTo(m.x, m.y, duration=move_duration) + pyautogui.click(x=m.x, y=m.y, clicks=clicks, interval=interval, button=button) + return m + + def wait_for_text( + self, + text: str, + *, + timeout: float = 20.0, + interval: float = 0.5, + region: Optional[tuple[int, int, int, int]] = None, + exact: bool = True, + case_sensitive: bool = False, + ) -> MatchResult: + end = time.time() + timeout + while time.time() < end: + m = self.locate_text(text, region=region, exact=exact, case_sensitive=case_sensitive) + if m is not None: + return m + time.sleep(interval) + raise TimeoutError(f"等待超时:{timeout}s 内未出现文字:{text}") + diff --git a/core/browser.py b/core/browser.py new file mode 100644 index 0000000..a21b8a7 --- /dev/null +++ b/core/browser.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +""" +本项目的核心是“桌面 OCR + 点击”。若后续需要操作 Web 端(如 DMS/ERP),可以在此处接入 Playwright。 +当前先提供一个最小骨架,避免影响主流程。 +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class BrowserConfig: + headless: bool = False + slow_mo_ms: int = 0 + user_data_dir: Optional[str] = None + + +class Browser: + def __init__(self, config: BrowserConfig = BrowserConfig()) -> None: + self.config = config + + def __enter__(self) -> "Browser": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + diff --git a/core/ocr_client.py b/core/ocr_client.py new file mode 100644 index 0000000..6faaf97 --- /dev/null +++ b/core/ocr_client.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass +from typing import Any, Iterable, Optional + +import requests + + +@dataclass(frozen=True) +class OcrBox: + # 4 个顶点坐标(相对截图图片坐标系) + points: tuple[tuple[int, int], tuple[int, int], tuple[int, int], tuple[int, int]] + + def center(self) -> tuple[int, int]: + xs = [p[0] for p in self.points] + ys = [p[1] for p in self.points] + return (int(sum(xs) / 4), int(sum(ys) / 4)) + + +@dataclass(frozen=True) +class OcrItem: + text: str + box: OcrBox + + +class UmiClient: + """ + 调用 Umi-OCR 的 HTTP API,并将 data_format=dict 的返回解析为 text+box。 + """ + + def __init__(self, url: str = "http://127.0.0.1:1224/api/ocr", timeout_s: float = 15.0) -> None: + self.url = url + self.timeout_s = timeout_s + + def check_service(self) -> None: + """ + Umi-OCR 没有稳定的 healthz 文档接口,这里用一次轻量请求做连通性检测。 + 只要能建立连接并返回 JSON(即使是业务错误),就认为服务已启动。 + """ + try: + r = requests.post(self.url, json={"base64": "", "options": {"data_format": "dict"}}, timeout=3) + _ = r.text # 触发实际请求 + except requests.RequestException as e: + raise RuntimeError(f"无法连接 Umi-OCR 服务:{self.url}。请先在 Umi-OCR 中开启 HTTP 服务。") from e + + def ocr_bytes(self, image_bytes: bytes) -> list[OcrItem]: + img64 = base64.b64encode(image_bytes).decode("utf-8") + payload = {"base64": img64, "options": {"data_format": "dict"}} + resp = requests.post(self.url, json=payload, timeout=self.timeout_s) + resp.raise_for_status() + data = resp.json() + return self._parse_umi_dict(data) + + def _parse_umi_dict(self, data: dict[str, Any]) -> list[OcrItem]: + # 兼容:当返回不是 dict 时直接报错,方便定位 + if not isinstance(data, dict): + raise ValueError(f"Umi-OCR 返回非 JSON 对象:{type(data)}") + + items: list[OcrItem] = [] + data_list = data.get("data", []) + if not isinstance(data_list, list): + raise ValueError(f"Umi-OCR 返回 data 字段不是 list:{json.dumps(data, ensure_ascii=False)[:500]}") + + for it in data_list: + if not isinstance(it, dict): + continue + text = str(it.get("text", "")).strip() + box = it.get("box") + pts = _coerce_box_points(box) + if not text or pts is None: + continue + items.append(OcrItem(text=text, box=OcrBox(points=pts))) + return items + + def find_text( + self, + target_name: str, + items: Iterable[OcrItem], + *, + exact: bool = True, + case_sensitive: bool = False, + ) -> Optional[tuple[int, int]]: + """ + 在给定 OCR items 中查找目标文字,返回其在截图坐标系下的中心点 (x, y)。 + """ + t = target_name if case_sensitive else target_name.lower() + for item in items: + s = item.text if case_sensitive else item.text.lower() + ok = (s == t) if exact else (t in s) + if ok: + return item.box.center() + return None + + +def _coerce_box_points(box: Any) -> Optional[tuple[tuple[int, int], tuple[int, int], tuple[int, int], tuple[int, int]]]: + """ + Umi-OCR 的 box 在 data_format=dict 下通常是 4 个点: + [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] + """ + if not (isinstance(box, list) and len(box) == 4): + return None + pts: list[tuple[int, int]] = [] + for p in box: + if not (isinstance(p, (list, tuple)) and len(p) == 2): + return None + pts.append((int(p[0]), int(p[1]))) + return (pts[0], pts[1], pts[2], pts[3]) + diff --git a/data/.keep b/data/.keep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.keep @@ -0,0 +1 @@ + diff --git a/data/html/wechat.html b/data/html/wechat.html new file mode 100644 index 0000000..12c4cd5 --- /dev/null +++ b/data/html/wechat.html @@ -0,0 +1,2 @@ +

项目描述

视频演示

本系统包含管理员和用户两个角色。

管理员角色:

  1. 用户管理:管理系统中所有用户的信息,包括添加、删除和修改用户。

  2. 校规信息管理:管理校规信息,包括新增、查看、修改、删除和查看评论。

  3. 学校信息管理:管理学校信息,包括新增、查看、修改和删除。

  4. 入学须知管理:管理入学须知信息,包括新增、查看、修改、删除和查看评论。

  5. 论坛管理:管理论坛交流信息,包括查看和删除。

  6. 资料分享管理:管理资料类型和资料分享信息,包括新增、查看、修改和删除。

  7. 在线沟通管理:管理在线沟通信息,包括查看和删除。

  8. 在线交流管理:管理在线交流信息,包括查看和删除。

  9. 轮播图管理:管理轮播图信息,包括新增、查看、修改和删除。

用户角色:

  1. 在线沟通:用户可以进行在线沟通,包括新增、查看、修改和删除。

  2. 入学须知管理:用户可以查看入学须知信息。

  3. 校规信息管理:用户可以查看校规信息。

  4. 资料分享管理:用户可以查看资料分享信息。

  5. 论坛交流:用户可以新增、查看、修改和删除论坛交流信息。

  6. 学校信息管理:用户可以查看学校信息。

  7. 我的发布:用户可以查看和删除自己发布的论坛信息。

  8. 资料分享管理:用户可以新增、查看、修改和删除资料分享信息。

  9. 在线交流管理:用户可以新增、查看、修改、删除和审核在线交流信息。

  10. 我的收藏管理:用户可以查看自己的收藏信息。

技术选型

系统截图

1.首页

2.在线沟通

3.入学须知

4.资料分享

5.论坛交流

6.后台登录

7.校规信息管理

8.学校信息管理

9.入学须知管理

10.论坛交流管理

11.资料类型资料分享管理

运行步骤

准备环境

安装JDK 1.8、Maven、Node.js 14 和 MySQL 5.7以上

创建数据库

使用Navicat工具创建数据库并导入SQL脚本

配置后端

修改application.yml中的数据库连接信息(用户名/密码/库名)

启动后端服务

进入后端项目(/server_code) 执行:
mvn clean install + mvn spring-boot:run

前端依赖安装

进入前端目录(/manage_code或者/client_code),执行安装命令:
npm install

启动前端服务

执行启动命令:
npm run serve

访问系统

常见问题

端口冲突

前端安装依赖失败

推荐阅读

其他说明

  • 1、本系统源码由南音工作室精心收集整理,并经过严格测试验证,确保运行稳定可靠。
  • 2、南音工作室开始招募合伙人,有兴趣一起搞钱的小伙伴,可以在公众号后台回复关键词【合伙人】查看详细介绍。
  • 3、如需获取详细运行教程,欢迎在公众号后台回复关键词【运行教程】,即可自助领取完整指导文档。
  • 4、我们已为您整理上万套优质源码项目资源,回复【源码搜索】即可快速查找您需要的源码资源。
  • 5、为了回馈粉丝,特意整理了海量福利资源免费分享,回复【免费资源】即可领取。
  • 6、创作不易,如果觉得内容有帮助,别忘了点赞/推荐/分享/收藏支持我们,您的鼓励是我们持续更新的最大动力!

源码获取


长按小程序码,打开小程序搜索 "A173" 即可获取资源
\ No newline at end of file diff --git a/data/screenshots/2026-03-26/baidu_search_java教程.png b/data/screenshots/2026-03-26/baidu_search_java教程.png new file mode 100644 index 0000000..46a0447 Binary files /dev/null and b/data/screenshots/2026-03-26/baidu_search_java教程.png differ diff --git a/data/screenshots/2026-03-26/error_state.png b/data/screenshots/2026-03-26/error_state.png new file mode 100644 index 0000000..a4dc0dc Binary files /dev/null and b/data/screenshots/2026-03-26/error_state.png differ diff --git a/db_manager.py b/db_manager.py new file mode 100644 index 0000000..6208013 --- /dev/null +++ b/db_manager.py @@ -0,0 +1,283 @@ +""" +数据库管理模块 - 连接 MySQL 管理文章发布数据 +""" +from __future__ import annotations + +import mysql.connector +from mysql.connector import Error +from dataclasses import dataclass +from typing import List, Optional, Tuple +import os +import sys + + +@dataclass +class ProjectOrder: + """项目订单数据类""" + name: Optional[str] = None + paths: Optional[str] = None + zlzt: Optional[str] = None # 整理状态 + sfxxm: Optional[str] = None + name1: Optional[str] = None + name2: Optional[str] = None + bianhao: Optional[str] = None + desc: Optional[str] = None + sql_paths: Optional[str] = None + error_message: Optional[str] = None + + +class DatabaseManager: + """MySQL 数据库管理器""" + + def __init__(self, host: str = "localhost", database: str = "test", + user: str = "root", password: str = "123456", port: int = 3306): + self.host = host + self.database = database + self.user = user + self.password = password + self.port = port + self.connection = None + + def connect(self) -> bool: + """连接数据库""" + try: + self.connection = mysql.connector.connect( + host=self.host, + database=self.database, + user=self.user, + password=self.password, + port=self.port, + charset='utf8mb4', + collation='utf8mb4_unicode_ci', + connection_timeout=5, + use_pure=True + ) + return True + except Error as e: + print(f"[DatabaseManager] 连接数据库失败: {e}") + return False + except Exception as e: + print(f"[DatabaseManager] 连接数据库时发生未知错误: {e}") + return False + + def disconnect(self): + """断开数据库连接""" + if self.connection and self.connection.is_connected(): + self.connection.close() + self.connection = None + + def is_connected(self) -> bool: + """检查是否已连接""" + return self.connection is not None and self.connection.is_connected() + + def ensure_connected(self) -> bool: + """确保已连接数据库""" + if not self.is_connected(): + return self.connect() + return True + + def get_projects(self, page: int = 1, page_size: int = 20, + search_name: Optional[str] = None) -> Tuple[List[ProjectOrder], int]: + """ + 获取项目列表(分页) + + Args: + page: 页码(从1开始) + page_size: 每页数量 + search_name: 搜索名称(模糊匹配) + + Returns: + (项目列表, 总数量) + """ + if not self.ensure_connected(): + return [], 0 + + try: + cursor = self.connection.cursor(dictionary=True) + + # 构建查询条件 + where_clause = "" + params = [] + if search_name: + where_clause = "WHERE name LIKE %s" + params.append(f"%{search_name}%") + + # 获取总数 + count_sql = f"SELECT COUNT(*) as total FROM t_new_project_order_new {where_clause}" + cursor.execute(count_sql, params) + total = cursor.fetchone()['total'] + + # 获取分页数据 + offset = (page - 1) * page_size + sql = f""" + SELECT name, paths, zlzt, sfxxm, name1, name2, bianhao, + `desc`, sql_paths, error_message + FROM t_new_project_order_new + {where_clause} + ORDER BY name2 ASC + LIMIT %s OFFSET %s + """ + cursor.execute(sql, params + [page_size, offset]) + + rows = cursor.fetchall() + projects = [] + for row in rows: + projects.append(ProjectOrder( + name=row.get('name'), + paths=row.get('paths'), + zlzt=row.get('zlzt'), + sfxxm=row.get('sfxxm'), + name1=row.get('name1'), + name2=row.get('name2'), + bianhao=row.get('bianhao'), + desc=row.get('desc'), + sql_paths=row.get('sql_paths'), + error_message=row.get('error_message') + )) + + cursor.close() + return projects, total + + except Error as e: + print(f"[DatabaseManager] 查询失败: {e}") + return [], 0 + + def update_zlzt(self, name: str, zlzt: str) -> bool: + """ + 更新整理状态 + + Args: + name: 项目名称 + zlzt: 新的整理状态值 + + Returns: + 是否成功 + """ + if not self.ensure_connected(): + return False + + try: + cursor = self.connection.cursor() + sql = "UPDATE t_new_project_order_new SET zlzt = %s WHERE name = %s" + cursor.execute(sql, (zlzt, name)) + self.connection.commit() + affected = cursor.rowcount + cursor.close() + return affected > 0 + except Error as e: + print(f"[DatabaseManager] 更新失败: {e}") + return False + + def get_project_by_name(self, name: str) -> Optional[ProjectOrder]: + """ + 根据名称获取项目 + + Args: + name: 项目名称 + + Returns: + 项目对象,如果找不到返回 None + """ + if not self.ensure_connected(): + return None + + try: + cursor = self.connection.cursor(dictionary=True) + sql = """ + SELECT name, paths, zlzt, sfxxm, name1, name2, bianhao, + `desc`, sql_paths, error_message + FROM t_new_project_order_new + WHERE name = %s + """ + cursor.execute(sql, (name,)) + row = cursor.fetchone() + cursor.close() + + if row: + return ProjectOrder( + name=row.get('name'), + paths=row.get('paths'), + zlzt=row.get('zlzt'), + sfxxm=row.get('sfxxm'), + name1=row.get('name1'), + name2=row.get('name2'), + bianhao=row.get('bianhao'), + desc=row.get('desc'), + sql_paths=row.get('sql_paths'), + error_message=row.get('error_message') + ) + return None + except Error as e: + print(f"[DatabaseManager] 查询失败: {e}") + return None + + def delete_project(self, name: str) -> bool: + """ + 删除项目 + + Args: + name: 项目名称 + + Returns: + 是否成功 + """ + if not self.ensure_connected(): + return False + + try: + cursor = self.connection.cursor() + sql = "DELETE FROM t_new_project_order_new WHERE name = %s" + cursor.execute(sql, (name,)) + self.connection.commit() + affected = cursor.rowcount + cursor.close() + return affected > 0 + except Error as e: + print(f"[DatabaseManager] 删除失败: {e}") + return False + + def test_connection(self) -> Tuple[bool, str]: + """ + 测试数据库连接 + + Returns: + (是否成功, 错误信息) + """ + try: + conn = mysql.connector.connect( + host=self.host, + database=self.database, + user=self.user, + password=self.password, + port=self.port, + connect_timeout=5, + use_pure=True + ) + conn.close() + return True, "" + except Error as e: + return False, str(e) + except Exception as e: + return False, f"未知错误: {str(e)}" + + +# 全局数据库管理器实例 +_db_manager: Optional[DatabaseManager] = None + + +def get_db_manager(host: str = "localhost", database: str = "test", + user: str = "root", password: str = "123456", + port: int = 3306) -> DatabaseManager: + """获取数据库管理器实例""" + global _db_manager + if _db_manager is None: + _db_manager = DatabaseManager(host, database, user, password, port) + return _db_manager + + +def reset_db_manager(): + """重置数据库管理器(用于配置变更后)""" + global _db_manager + if _db_manager: + _db_manager.disconnect() + _db_manager = None diff --git a/docs/baidu.md b/docs/baidu.md new file mode 100644 index 0000000..1b89572 --- /dev/null +++ b/docs/baidu.md @@ -0,0 +1,82 @@ +这个任务非常适合用 **Playwright** 来实现,因为它对页面加载的等待机制非常智能。 + +为了保持代码的健壮性,我为你编写了一个独立的脚本。它遵循了我们之前规划的逻辑:**启动浏览器 -> 模拟输入 -> 触发搜索 -> 等待结果渲染 -> 全屏截图**。 + +### 🐍 Python 自动化脚本实现 + +```python +import os +import time +from playwright.sync_api import sync_playwright +from datetime import datetime + +def search_baidu_and_screenshot(keyword="java教程"): + # 1. 准备截图目录 + save_dir = f"./data/screenshots/{datetime.now().strftime('%Y-%m-%d')}" + if not os.path.exists(save_dir): + os.makedirs(save_dir) + + file_path = os.path.join(save_dir, f"baidu_search_{keyword}.png") + + with sync_playwright() as p: + # 启动浏览器 (headless=False 可以让你看到操作过程) + print("[*] 正在启动 Chromium 浏览器...") + browser = p.chromium.launch(headless=False) + context = browser.new_context(viewport={'width': 1280, 'height': 800}) + page = context.new_page() + + try: + # 2. 访问百度 + print(f"[*] 正在访问百度...") + page.goto("https://www.baidu.com", wait_until="networkidle") + + # 3. 定位搜索框并输入关键字 + # 百度搜索框的 ID 通常是 'kw' + print(f"[*] 输入搜索关键词: {keyword}") + page.fill("#kw", keyword) + + # 4. 点击“百度一下”按钮 + # 百度搜索按钮的 ID 通常是 'su' + page.click("#su") + + # 5. 等待搜索结果加载 + # 我们等待结果列表容器出现,确保内容不是空白 + print("[*] 等待搜索结果渲染...") + page.wait_for_selector("#content_left", timeout=10000) + + # 稍微额外等待一下,让图片或样式加载更完整 + time.sleep(2) + + # 6. 截图并保存 + print(f"[*] 正在保存截图至: {file_path}") + page.screenshot(path=file_path, full_page=True) + print("[√] 任务完成!") + + except Exception as e: + print(f"[!] 操作过程中发生错误: {e}") + # 发生错误时截取当前页面备查 + page.screenshot(path=os.path.join(save_dir, "error_state.png")) + + finally: + browser.close() + +if __name__ == "__main__": + search_baidu_and_screenshot("java教程") +``` + +### 💡 开发要点解析: + +1. **选择器 (Selectors)**: + * 百度搜索框的 ID 是 `#kw`。 + * 百度搜索按钮的 ID 是 `#su`。 + * 搜索结果的主体容器 ID 是 `#content_left`。使用 `wait_for_selector` 是为了防止在页面还没跳转完就执行截图。 +2. **全屏截图 (`full_page=True`)**: + * 这个参数非常有用,它会自动滚动页面并拼接,将整个搜索结果列表(包括滚动条下方的内容)完整保存下来。 +3. **视口设置 (`viewport`)**: + * 设置固定的分辨率可以保证不同环境下截图的一致性,方便后续交给 **Umi-OCR** 进行固定区域的文字识别。 +4. **环境准备**: + 如果你还没有安装 Playwright 运行环境,请执行以下命令: + ```bash + pip install playwright + playwright install chromium + ``` diff --git a/docs/md转公众号.md b/docs/md转公众号.md new file mode 100644 index 0000000..9ba0a4d --- /dev/null +++ b/docs/md转公众号.md @@ -0,0 +1,88 @@ +把 Markdown 转化成适合公众号发布的 HTML,其实有个“坑”:公众号的后台编辑器对 CSS 的支持比较刻薄(很多标准的 CSS 样式会被过滤)。因此,最稳妥的方法是使用**行内样式(Inline CSS)**。 + +我们可以使用 Python 的 `markdown` 库配合 `premailer`(用于将 CSS 自动注入到 HTML 标签的 `style` 属性中)来实现。 + +### 准备工作 + +你需要安装两个库: +```bash +pip install markdown premailer +``` + +--- + +### Python 实现代码 + +这段代码不仅会转换格式,还会为你预设一套适合阅读的“公众号风”样式: + +```python +import markdown +from premailer import transform + +def md_to_wechat_html(md_text): + # 1. 定义基本的 CSS 样式(根据公众号审美习惯调整) + css = """ + + """ + + # 2. 将 Markdown 转为 HTML(启用表格、代码高亮等常用扩展) + html_content = markdown.markdown( + md_text, + extensions=['extra', 'codehilite', 'toc'] + ) + + # 3. 组合成完整的 HTML 结构 + full_html = f""" + + +
+ {css} + {html_content} +
+ + + """ + + # 4. 使用 premailer 将 CSS 转化为行内样式(公众号最爱的格式) + wechat_friendly_html = transform(full_html) + + return wechat_friendly_html + +# 测试使用 +if __name__ == "__main__": + test_md = """ +## 这是一个标题 +这是一段正文,包含 **粗体** 和 `行内代码`。 + +> 这是一个引言块。 + +### 代码示例 +```python +print("Hello WeChat!") +``` + """ + + result = md_to_wechat_html(test_md) + with open("output.html", "w", encoding="utf-8") as f: + f.write(result) + print("转换成功!请打开 output.html 复制内容到公众号后台。") +``` + +--- + +### 避坑小贴士 + +1. **图片链接**:Markdown 里的本地图片链接直接转过去是没用的。你需要先手动上传图片到公众号素材库,或者使用图床,把 MD 里的地址换成 URL。 +2. **代码高亮**:公众号对 `pre` 和 `code` 标签的渲染比较特殊。如果上面的代码块样式不够完美,建议考虑使用专门的插件(如 **Markdown Here**)或者将代码块截图上传。 +3. **直接粘贴**:运行代码生成的 `output.html` 后,用浏览器打开它,**直接全选复制**,然后粘贴到公众号编辑器里即可,格式基本能保持 90% 以上的一致。 + +如果你希望样式更精致一点(比如添加特定的配色方案),需要我帮你调整上面的 `css` 变量部分吗? \ No newline at end of file diff --git a/docs/md转公众号优化.md b/docs/md转公众号优化.md new file mode 100644 index 0000000..f8d4b8a --- /dev/null +++ b/docs/md转公众号优化.md @@ -0,0 +1,78 @@ +看到你提供的 HTML 结构了。之所以在公众号后台不显示图片,是因为微信的编辑器有一套非常特殊的**图片延迟加载机制**。 + +你代码中的图片标签是这样的: +`` + +**问题核心在于:** +1. **协议问题**:你的七牛云链接是 `http`。现在微信后台基本强制要求 `https`,否则会拦截或显示破损图标。 +2. **关键属性缺失**:微信除了 `data-src`,通常还需要一个 `data-type`(指定图片格式,如 png/jpeg)或者 `style` 必须包含 `width: 100%` 以防图片被压成 0 像素。 +3. **双层嵌套限制**:你提供的代码里有两个重复的 `
` 容器层级,这可能会导致微信后台解析 CSS 时出错。 + +### 解决方案:针对公众号优化的 Python 转换逻辑 + +我们需要用 `BeautifulSoup` 来强制修正这些图片标签,并统一把 `http` 替换为 `https`。 + +```python +import markdown +import re +from bs4 import BeautifulSoup +from premailer import transform + +def fix_wechat_images(html_content): + soup = BeautifulSoup(html_content, 'html.parser') + for img in soup.find_all('img'): + src = img.get('src', '') + + # 1. 强制将 http 转换为 https + if src.startswith('http://'): + src = src.replace('http://', 'https://') + + # 2. 补全微信必须的属性 + img['src'] = src + img['data-src'] = src + + # 3. 强制给图片加行内样式,防止塌陷 + img['style'] = "display: block; margin: 20px auto; width: 100% !important; height: auto !important; visibility: visible !important;" + + # 4. 移除可能导致冲突的 class + if img.get('class'): + del img['class'] + + return str(soup) + +def md_to_wechat_final(md_text): + # 基础 Markdown 转换 + html = markdown.markdown(md_text, extensions=['extra', 'fenced_code', 'attr_list']) + + # 注入 CSS + css = """ + + """ + + full_html = f'
{css}{html}
' + + # 修正图片并转换行内样式 + fixed_html = fix_wechat_images(full_html) + return transform(fixed_html) + +# 使用方式 +# final_output = md_to_wechat_final(your_md_content) +``` + +### 操作上的“致命”细节 + +哪怕代码写对了,**操作不对图片也出不来**。请务必遵守以下步骤: + +1. **检查七牛云 HTTPS**:确保你的七牛云域名已经配置了 SSL 证书。如果 `https://img.yidaima.cn/...` 在浏览器里打不开,图片永远也进不了公众号。 +2. **不要在编辑器里“粘贴源码”**: + * 微信公众号后台**没有**“查看源码”模式。 + * 你需要用浏览器(推荐 Chrome)打开生成的 `.html` 文件。 + * 在浏览器页面上 **Ctrl+A(全选)** -> **Ctrl+C(复制)**。 + * 在公众号正文框里直接 **Ctrl+V(粘贴)**。 +3. **防盗链白名单**:如果粘贴后显示“此图片来自非官方渠道”,你需要去七牛云后台关闭“Referer 防盗链”,或者将 `servicewechat.com` 和 `mp.weixin.qq.com` 加入白名单。 diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..c1e2d97 --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,81 @@ +这是一个非常明智的选择。**Umi-OCR** 相比于直接调用 `PaddleOCR` 的库,最大的优势在于它提供了一个非常成熟的 **HTTP API 接口**(或者离线命令行接口),这使得你的 Python 自动化脚本可以保持轻量化,不需要在本地环境安装庞大且容易起冲突的深度学习库。 + +以下是针对 **Umi-OCR** 集成的更新版开发指南 MD 文档: + +--- + +# 🚀 桌面自动化脚本开发指南 (Umi-OCR 集成版) + +## 1. 系统架构说明 +本脚本采用 **Umi-OCR (HTTP 服务模式)** 作为感知核心。脚本通过发送截图到 Umi-OCR 的本地 API,获取 JSON 格式的识别结果(文字内容及坐标)。 + +--- + +## 2. 核心技术栈 +* **OCR 引擎**: [Umi-OCR](https://github.com/hiroi-sora/Umi-OCR) (需提前启动 HTTP 服务)。 +* **浏览器控制**: Playwright (用于操作 Web 端 DMS/ERP)。 +* **桌面控制**: PyAutoGUI / Mouse (用于点击 OCR 识别出的坐标)。 +* **通信**: `requests` (用于调用 Umi-OCR API)。 + +--- + +## 3. 目录结构规范 +```text +project_root/ +├── config/ # 配置文件 (yaml/json) +├── core/ +│ ├── ocr_client.py # 封装 Umi-OCR API 调用逻辑 +│ ├── browser.py # Playwright 页面操作 +│ └── actions.py # 封装“识别文字并点击”的高级动作 +├── config.yaml # 存放 Umi-OCR 地址 (默认 http://127.0.0.1:1224/api/ocr) +├── data/ # 存放临时截图、下载的文件 +├── assets/ # 存放用于识别的图标/模板图片 +├── utils/ # 日志、时间处理等工具类 +└── main.py # 业务主流程 +``` + +--- + +## 4. 关键模块开发逻辑 + +### 4.1 OCR 调用逻辑 (`ocr_client.py`) +> **AI 指令**:请实现一个 `UmiClient` 类,要求: +* 使用 `requests` 向 `http://127.0.0.1:1224/api/ocr` 发送 Base64 编码的图片。 +* 解析返回的 JSON,提取 `data` 列表中的 `text` 和 `box`(四个顶点的坐标)。 +* 提供一个 `find_text(target_name)` 方法,返回该文字在屏幕上的中心点 `(x, y)`。 + +### 4.2 自动化执行逻辑 (`actions.py`) +> **AI 指令**:结合 `PyAutoGUI` 和 `ocr_client.py`: +* 实现 `click_text(text)`:截图 -> OCR 识别 -> 匹配目标 -> 点击。 +* 实现 `wait_for_text(text, timeout=20)`:在规定时间内循环检测屏幕是否出现目标文字。 + +--- + +## 5. 开发约束 +1. **服务检测**:脚本启动时需检测 Umi-OCR 端口是否占用,若未启动则抛出错误提醒。 +2. **区域 OCR**:为了提高效率,支持“局部截图 OCR”,而不是每次都截全屏。 +3. **坐标转换**:Umi-OCR 返回的是相对图片的坐标,需确保点击时与 Windows 屏幕坐标对齐。 + +--- + +## 6. 第一步:Umi-OCR API 调用代码参考 +你可以让 AI 基于以下片段进行扩展: + +```python +import requests +import base64 + +def call_umi_ocr(image_path): + url = "http://127.0.0.1:1224/api/ocr" + with open(image_path, "rb") as f: + img64 = base64.b64encode(f.read()).decode('utf-8') + + payload = { + "base64": img64, + "options": {"data_format": "dict"} + } + response = requests.post(url, json=payload) + return response.json() +``` + +--- \ No newline at end of file diff --git a/docs/step1.md b/docs/step1.md new file mode 100644 index 0000000..a27d53a --- /dev/null +++ b/docs/step1.md @@ -0,0 +1,7 @@ +1.打开浏览器,访问网址:https://feast.yidaima.cn/login +2.输入用户名:wangpeng,密码:Feastcoding@123。 +3.点击登录 +4.点击菜单工作室运营,然后再打开源码管理页面。 +5.在源码管理页面,搜索项目名称:【A167】基于Springboot + vue3实现的宿舍报修系统。 +6.在列表上找到【A167】基于Springboot + vue3实现的宿舍报修系统这条数据,点击查看。 +7.在详情页鼠标滚动到最下面,点击FeastCoding按钮。 \ No newline at end of file diff --git a/docs/step2.md b/docs/step2.md new file mode 100644 index 0000000..d22eead --- /dev/null +++ b/docs/step2.md @@ -0,0 +1,3 @@ +1.获取剪切板内容 +2.将剪切板内容转换为公众号文章格式,调用工具类md_to_wechat.py +3.推送到公众号草稿箱 \ No newline at end of file diff --git a/docs/step3.md b/docs/step3.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/公众号.md b/docs/公众号.md new file mode 100644 index 0000000..9d72f01 --- /dev/null +++ b/docs/公众号.md @@ -0,0 +1,84 @@ +在 Python 中推送文章到微信公众号草稿箱,主要通过微信提供的 **草稿箱 API** 实现。 + +这个过程通常分为三步:**获取 Access Token**、**上传封面图(素材)**、以及 **新增草稿**。 + +--- + +### 1. 前期准备 +你需要先在 [微信公众平台](https://mp.weixin.qq.com/) 获取以下信息: +* **AppID** +* **AppSecret** +* **白名单配置**:确保你运行代码的服务器 IP 已加入公众号后台的 IP 白名单。 + +### 2. 核心流程与代码实现 + +我们可以使用 Python 的 `requests` 库来完成。 + +#### 第一步:获取 Access Token +这是调用所有 API 的“通行证”。 + +```python +import requests +import json + +def get_access_token(appid, secret): + url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}" + res = requests.get(url).json() + return res.get('access_token') +``` + +#### 第二步:上传封面图片 +草稿箱要求文章必须有封面图,且封面图需要先上传到微信服务器获取 `thumb_media_id`。 + +```python +def upload_thumb(access_token, image_path): + url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={access_token}&type=image" + files = {'media': open(image_path, 'rb')} + res = requests.post(url, files=files).json() + return res.get('media_id') # 这就是 thumb_media_id +``` + +#### 第三步:推送至草稿箱 +最后,将文章标题、正文(HTML 格式)和封面 ID 提交。 + +```python +def add_draft(access_token, title, content, thumb_id): + url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={access_token}" + + # 文章数据结构 + articles = { + "articles": [ + { + "title": title, + "author": "YourName", + "digest": "摘要内容", + "content": content, # 支持 HTML 格式 + "content_source_url": "", # 阅读原文链接 + "thumb_media_id": thumb_id, + "need_open_comment": 0 # 是否打开评论 + } + ] + } + + # 微信 API 要求发送 JSON 字符串,且处理中文需确保编码正确 + data = json.dumps(articles, ensure_ascii=False).encode('utf-8') + res = requests.post(url, data=data).json() + return res +``` + +--- + +### 3. 常见问题提示 + +* **图片问题**:如果你在 `content`(正文)里引用了外部图片链接,微信可能会屏蔽它们。建议先通过 `cgi-bin/media/uploadimg` 接口将图片上传到微信图床,获取微信内部链接后再放入 HTML 中。 +* **API 限制**:草稿箱 API 每天的调用次数有限(通常为 10,000 次),对于个人/小团队来说完全够用。 +* **HTML 标签**:微信正文支持的 HTML 比较基础,复杂的 CSS 样式可能会被过滤,建议使用标准的 `p`, `span`, `strong`, `img` 等标签。 + +--- + +### 快速总结工具 +| 步骤 | API 接口 | 目的 | +| :--- | :--- | :--- | +| 1 | `/cgi-bin/token` | 获取授权凭证 | +| 2 | `/cgi-bin/material/add_material` | 获取封面图 ID | +| 3 | `/cgi-bin/draft/add` | 写入草稿箱 | diff --git a/docs/编辑器.md b/docs/编辑器.md new file mode 100644 index 0000000..5213bcd --- /dev/null +++ b/docs/编辑器.md @@ -0,0 +1,231 @@ +我来先查看一下这个项目的具体实现,然后为你设计一个完整的Python实现方案。 +基于对 doocs/md 项目的分析,我为你设计了一个完整的 Python 实现方案。这是一个桌面端微信 Markdown 编辑器,包含你要求的所有功能。 + +## 技术选型建议 + +**GUI 框架**: 推荐使用 **PyQt6/PySide6** 或 **Tkinter + CustomTkinter** +- PyQt6: 功能强大,适合复杂应用 +- CustomTkinter: 现代化外观,开发快速 + +**Markdown 渲染**: +- `markdown` + `pygments` (代码高亮) +- 或直接嵌入 `WebEngine` 用 JavaScript 渲染 + +--- + +## 完整提示词 (Prompt) + +```markdown +# 微信 Markdown 编辑器 - Python 桌面应用开发 + +## 项目概述 +使用 Python 开发一个仿照 https://github.com/doocs/md 的微信 Markdown 编辑器桌面应用。支持实时预览、多主题切换、自定义样式,专为微信公众号排版优化。 + +## 技术栈要求 +- **GUI 框架**: PyQt6 (推荐) 或 CustomTkinter +- **Markdown 解析**: markdown + pymdownx 扩展 + pygments +- **预览引擎**: QWebEngineView (PyQt6) 或 WebView (Tkinter) +- **配置存储**: JSON 配置文件 +- **代码高亮**: Pygments (支持 100+ 主题) + +## 核心功能模块 + +### 1. 编辑器界面布局 +``` +┌─────────────────────────────────────────────────────────────┐ +│ 菜单栏: 文件 | 编辑 | 格式 | 主题 | 设置 | 帮助 │ +├──────────────────┬──────────────────┬───────────────────────┤ +│ │ │ │ +│ Markdown │ 预览/手机 │ 设置面板 │ +│ 编辑区 │ 模拟预览 │ (可折叠) │ +│ (左侧) │ (中间) │ (右侧) │ +│ │ │ │ +│ - 行号显示 │ - 微信样式渲染 │ - 主题选择 │ +│ - 语法高亮 │ - 手机框模拟 │ - 字体设置 │ +│ - 自动保存 │ - 一键复制HTML │ - 代码块主题 │ +│ │ │ - 自定义CSS │ +└──────────────────┴──────────────────┴───────────────────────┘ +``` + +### 2. 必须实现的功能清单 + +#### A. 主题系统 +- [ ] **预设主题** (至少5个): + - 默认主题 (类似微信默认) + - 优雅主题 (简约商务风) + - 科技主题 (暗色代码风格) + - 文艺主题 (衬线字体) + - 节日主题 (可切换配色) +- [ ] **主题色自定义**: 主色调、强调色、背景色选择器 +- [ ] **实时预览**: 切换主题即时生效 + +#### B. 字体设置 +- [ ] **正文字体**: 提供系统字体列表选择 +- [ ] **标题字体**: 可独立设置或跟随正文 +- [ ] **代码字体**: 等宽字体选择 (推荐: Consolas, Fira Code, JetBrains Mono) +- [ ] **字号调节**: 正文字号 (12px-20px), 标题相对比例 + +#### C. 代码块样式 +- [ ] **Mac 风格代码块开关**: 是否显示 macOS 窗口控制按钮 (红黄绿圆点) +- [ ] **代码主题选择**: + - 亮色主题: default, github, vs, xcode + - 暗色主题: monokai, dracula, one-dark, nord +- [ ] **代码行号显示**: 可选开启/关闭 +- [ ] **代码复制按钮**: 悬浮显示复制图标 + +#### D. 自定义 CSS +- [ ] **CSS 编辑器**: 内置代码编辑器输入自定义样式 +- [ ] **实时生效**: 输入 CSS 即时预览效果 +- [ ] **导入/导出**: 支持保存为 .css 文件 +- [ ] **重置功能**: 一键恢复默认样式 + +#### E. 微信专用功能 +- [ ] **微信样式重置**: 自动添加微信兼容性 CSS +- [ ] **图片处理**: 支持本地图片转 Base64 或图床上传 +- [ ] **一键复制**: 生成适合粘贴到公众号后台的 HTML +- [ ] **手机预览**: 模拟 iPhone 界面预览效果 + +### 3. 详细界面组件 + +#### 设置面板 (右侧抽屉/面板) +```python +settings_panel = { + "外观设置": { + "主题选择": ["下拉菜单", "预设主题列表"], + "主题色": ["颜色选择器", "支持十六进制输入"], + "自定义主题": ["保存当前配置为新主题", "删除自定义主题"] + }, + "字体设置": { + "正文字体": ["系统字体下拉框", "中文字体优先"], + "正文字号": ["滑块: 12-20px", "数字输入框"], + "标题字体": ["同正文/独立选择", "复选框"], + "标题缩放": ["滑块: 1.0-2.0倍"], + "代码字体": ["等宽字体列表"] + }, + "代码块设置": { + "Mac风格": ["开关按钮", "控制是否显示三个圆点"], + "代码主题": ["下拉菜单", "按亮/暗分类"], + "显示行号": ["开关"], + "代码背景": ["颜色选择", "圆角设置"] + }, + "高级设置": { + "自定义CSS": ["文本编辑区", "语法高亮"], + "导入CSS": ["文件选择按钮"], + "导出配置": ["保存当前所有设置"], + "重置默认": ["确认后恢复出厂设置"] + } +} +``` + +### 4. 数据结构设计 + +```python +# 配置文件结构 (config.json) +{ + "version": "1.0.0", + "editor": { + "font_family": "PingFang SC, Microsoft YaHei", + "font_size": 14, + "auto_save": true, + "auto_save_interval": 30 + }, + "preview": { + "theme": "default", # 当前主题ID + "custom_themes": [ + { + "id": "custom_1", + "name": "我的主题", + "primary_color": "#07C160", + "background": "#ffffff", + "text_color": "#333333", + "css_override": "..." + } + ], + "font": { + "body": "PingFang SC", + "heading": "PingFang SC", + "code": "Consolas", + "body_size": 16, + "heading_scale": 1.3 + }, + "code_block": { + "mac_style": true, + "theme": "github", + "line_numbers": false, + "background": "#f6f8fa", + "border_radius": 6 + }, + "custom_css": "/* 用户自定义CSS */" + }, + "recent_files": ["path/to/file1.md", "path/to/file2.md"] +} +``` + +### 5. 核心类设计 + +```python +# 主要类结构 +class WechatMarkdownEditor(QMainWindow): + """主窗口""" + def __init__(self): + self.editor = MarkdownEditor() # 编辑区 + self.preview = PreviewWidget() # 预览区 + self.settings = SettingsPanel() # 设置面板 + self.theme_manager = ThemeManager() # 主题管理器 + +class ThemeManager: + """主题管理""" + def load_themes(self) -> List[Theme] + def apply_theme(self, theme_id: str) + def save_custom_theme(self, theme: Theme) + def export_css(self, theme: Theme) -> str + +class PreviewGenerator: + """预览生成器""" + def markdown_to_html(self, md_content: str) -> str + def apply_wechat_style(self, html: str) -> str + def inject_custom_css(self, html: str, css: str) -> str + def generate_mac_code_block(self, code: str, lang: str) -> str +``` + +### 6. 微信样式 CSS 模板 + +```css +/* 微信文章基础样式 */ +.wechat-article { + max-width: 677px; + margin: 0 auto; + padding: 20px; + font-family: {{body_font}}; + font-size: {{body_size}}px; + line-height: 1.8; + color: #333; + background: #fff; +} + +/* Mac 风格代码块 */ +.mac-code-block { + position: relative; + background: #f6f8fa; + border-radius: 6px; + padding: 16px; + margin: 1em 0; +} + +.mac-code-block::before { + content: "● ● ●"; + color: #ff5f56 #ffbd2e #27c93f; + letter-spacing: 4px; + font-size: 12px; + position: absolute; + top: 10px; + left: 12px; +} + +/* 代码高亮主题变量 */ +.code-theme-{{theme_name}} { + --bg: {{code_bg}}; + --text: {{code_text}}; + --keyword: {{keyword_color}}; + --string: {{string_color}}; + --comment: \ No newline at end of file diff --git a/editor_gui.py b/editor_gui.py new file mode 100644 index 0000000..a5ce915 --- /dev/null +++ b/editor_gui.py @@ -0,0 +1,664 @@ +""" +微信 Markdown 编辑器 GUI 模块 +提供可视化编辑界面 +""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog, scrolledtext, colorchooser +import tempfile +import webbrowser +import os + +# 使用 tkinterweb 进行 HTML 预览(支持完整 HTML 渲染) +from tkinterweb import HtmlFrame + +from markdown_editor import ( + WechatMarkdownEditor, ThemeConfig, FontConfig, + CodeBlockConfig, EditorConfig +) + + +class MarkdownEditorWindow: + """Markdown 编辑器窗口""" + + def __init__(self, parent=None): + self.editor = WechatMarkdownEditor() + self.config = self.editor.get_config() + + # 创建窗口 + if parent: + self.window = tk.Toplevel(parent) + else: + self.window = tk.Tk() + + self.window.title("微信 Markdown 编辑器") + self.window.geometry("1400x900") + self.window.minsize(1200, 700) + + # 设置样式 + self.style = ttk.Style() + self.style.configure("Title.TLabel", font=("Microsoft YaHei", 14, "bold")) + self.style.configure("Subtitle.TLabel", font=("Microsoft YaHei", 10)) + + self.create_widgets() + self.update_preview() + + def create_widgets(self): + """创建界面组件""" + # 主框架 - 使用 PanedWindow 实现可调整的分割 + main_paned = ttk.PanedWindow(self.window, orient=tk.HORIZONTAL) + main_paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # === 左侧:编辑区 === + left_frame = ttk.Frame(main_paned) + main_paned.add(left_frame, weight=1) + + # 编辑区标题 + ttk.Label(left_frame, text="Markdown 编辑区", style="Title.TLabel").pack(anchor=tk.W, pady=(0, 5)) + + # Markdown 编辑框 + self.md_text = scrolledtext.ScrolledText( + left_frame, + wrap=tk.WORD, + font=("Consolas", 12), + undo=True, + maxundo=-1 + ) + self.md_text.pack(fill=tk.BOTH, expand=True) + self.md_text.bind("", lambda e: self.update_preview()) + + # 默认内容 + default_content = """# 欢迎使用微信 Markdown 编辑器 + +这是一段示例文本,支持 **粗体**、*斜体*、`行内代码`。 + +## 代码示例 + +```python +def hello_world(): + print("Hello, World!") + return True +``` + +## 列表 + +- 项目 1 +- 项目 2 +- 项目 3 + +## 引用 + +> 这是一段引用文本。 + +## 表格 + +| 列1 | 列2 | 列3 | +|-----|-----|-----| +| A | B | C | +| D | E | F | + +--- + +在右侧设置面板中可以调整主题、字体、代码块样式等。 +""" + self.md_text.insert("1.0", default_content) + + # === 中间:预览区 === + center_frame = ttk.Frame(main_paned) + main_paned.add(center_frame, weight=1) + + # 预览区标题和工具栏 + preview_header = ttk.Frame(center_frame) + preview_header.pack(fill=tk.X, pady=(0, 5)) + + ttk.Label(preview_header, text="实时预览", style="Title.TLabel").pack(side=tk.LEFT) + + # 刷新按钮 + ttk.Button(preview_header, text="🔄 刷新", command=self.update_preview).pack(side=tk.RIGHT, padx=5) + ttk.Button(preview_header, text="📋 复制 HTML", command=self.copy_html).pack(side=tk.RIGHT, padx=5) + ttk.Button(preview_header, text="💾 导出 HTML", command=self.export_html).pack(side=tk.RIGHT, padx=5) + + # 预览区域 + preview_notebook = ttk.Notebook(center_frame) + preview_notebook.pack(fill=tk.BOTH, expand=True) + + # HTML 渲染预览标签页 + preview_frame = ttk.Frame(preview_notebook) + preview_notebook.add(preview_frame, text="效果预览") + + # 使用 HtmlFrame 显示渲染后的 HTML + self.html_preview = HtmlFrame(preview_frame, messages_enabled=False) + self.html_preview.pack(fill=tk.BOTH, expand=True) + + # HTML 源码标签页 + html_frame = ttk.Frame(preview_notebook) + preview_notebook.add(html_frame, text="HTML 源码") + + self.html_text = scrolledtext.ScrolledText( + html_frame, + wrap=tk.NONE, + font=("Consolas", 10), + state=tk.DISABLED + ) + self.html_text.pack(fill=tk.BOTH, expand=True) + + # 浏览器预览标签页 + browser_frame = ttk.Frame(preview_notebook) + preview_notebook.add(browser_frame, text="浏览器预览") + + ttk.Label(browser_frame, text="点击按钮在浏览器中预览效果:").pack(pady=20) + ttk.Button(browser_frame, text="🌐 在浏览器中打开", command=self.open_in_browser).pack(pady=10) + + # === 右侧:设置面板 === + right_frame = ttk.Frame(main_paned, width=350) + main_paned.add(right_frame, weight=0) + right_frame.pack_propagate(False) + + # 设置面板标题 + ttk.Label(right_frame, text="设置面板", style="Title.TLabel").pack(anchor=tk.W, pady=(0, 10)) + + # 创建设置区域的 Canvas 和 Scrollbar + settings_canvas = tk.Canvas(right_frame, highlightthickness=0) + scrollbar = ttk.Scrollbar(right_frame, orient=tk.VERTICAL, command=settings_canvas.yview) + settings_canvas.configure(yscrollcommand=scrollbar.set) + + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + settings_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # 设置内容框架 + self.settings_frame = ttk.Frame(settings_canvas) + settings_canvas.create_window((0, 0), window=self.settings_frame, anchor=tk.NW, width=330) + + # 绑定滚动 + self.settings_frame.bind("", lambda e: settings_canvas.configure(scrollregion=settings_canvas.bbox("all"))) + settings_canvas.bind("", lambda e: settings_canvas.itemconfig(settings_canvas.find_all()[0], width=e.width-5)) + + # 创建设置区域 + self.create_theme_settings() + self.create_font_settings() + self.create_code_block_settings() + self.create_custom_css_settings() + + def create_theme_settings(self): + """创建主题设置区域""" + theme_frame = ttk.LabelFrame(self.settings_frame, text="主题设置", padding=10) + theme_frame.pack(fill=tk.X, pady=(0, 10)) + + # 主题选择 + ttk.Label(theme_frame, text="选择主题:").pack(anchor=tk.W) + + self.theme_var = tk.StringVar(value=self.config.theme) + themes = self.editor.theme_manager.get_all_themes() + theme_names = [f"{t.id}:{t.name}" for t in themes] + + self.theme_combo = ttk.Combobox( + theme_frame, + values=theme_names, + textvariable=self.theme_var, + state="readonly" + ) + self.theme_combo.pack(fill=tk.X, pady=(5, 10)) + self.theme_combo.bind("<>", self.on_theme_change) + + # 主题色 + color_frame = ttk.Frame(theme_frame) + color_frame.pack(fill=tk.X, pady=5) + + ttk.Label(color_frame, text="主题色:").pack(side=tk.LEFT) + + self.primary_color_var = tk.StringVar(value=self.editor.theme_manager.get_current_theme().primary_color) + self.color_btn = tk.Button( + color_frame, + text=" ", + bg=self.primary_color_var.get(), + width=3, + command=self.choose_primary_color + ) + self.color_btn.pack(side=tk.LEFT, padx=5) + + ttk.Entry(color_frame, textvariable=self.primary_color_var, width=10).pack(side=tk.LEFT) + ttk.Button(color_frame, text="应用", command=self.apply_primary_color).pack(side=tk.LEFT, padx=5) + + def create_font_settings(self): + """创建字体设置区域""" + font_frame = ttk.LabelFrame(self.settings_frame, text="字体设置", padding=10) + font_frame.pack(fill=tk.X, pady=(0, 10)) + + # 正文字体 + ttk.Label(font_frame, text="正文字体:").pack(anchor=tk.W) + self.body_font_var = tk.StringVar(value=self.config.font.body_font) + ttk.Entry(font_frame, textvariable=self.body_font_var).pack(fill=tk.X, pady=(0, 5)) + + # 正文字号 + size_frame = ttk.Frame(font_frame) + size_frame.pack(fill=tk.X, pady=5) + ttk.Label(size_frame, text="正文字号:").pack(side=tk.LEFT) + self.body_size_var = tk.IntVar(value=self.config.font.body_size) + ttk.Spinbox(size_frame, from_=12, to=24, textvariable=self.body_size_var, width=5).pack(side=tk.LEFT, padx=5) + ttk.Label(size_frame, text="px").pack(side=tk.LEFT) + + # 代码字体 + ttk.Label(font_frame, text="代码字体:").pack(anchor=tk.W, pady=(10, 0)) + self.code_font_var = tk.StringVar(value=self.config.font.code_font) + ttk.Entry(font_frame, textvariable=self.code_font_var).pack(fill=tk.X, pady=(0, 5)) + + # 行高 + lh_frame = ttk.Frame(font_frame) + lh_frame.pack(fill=tk.X, pady=5) + ttk.Label(lh_frame, text="行高:").pack(side=tk.LEFT) + self.line_height_var = tk.DoubleVar(value=self.config.font.line_height) + ttk.Spinbox(lh_frame, from_=1.0, to=2.5, increment=0.1, textvariable=self.line_height_var, width=5).pack(side=tk.LEFT, padx=5) + + # 应用按钮 + ttk.Button(font_frame, text="应用字体设置", command=self.apply_font_settings).pack(fill=tk.X, pady=(10, 0)) + + def create_code_block_settings(self): + """创建代码块设置区域""" + code_frame = ttk.LabelFrame(self.settings_frame, text="代码块设置", padding=10) + code_frame.pack(fill=tk.X, pady=(0, 10)) + + # 代码主题 + ttk.Label(code_frame, text="代码高亮主题:").pack(anchor=tk.W) + self.code_theme_var = tk.StringVar(value=self.config.code_block.theme) + code_themes = self.config.code_block.available_themes + self.code_theme_combo = ttk.Combobox( + code_frame, + values=sorted(code_themes), + textvariable=self.code_theme_var, + state="readonly" + ) + self.code_theme_combo.pack(fill=tk.X, pady=(5, 10)) + self.code_theme_combo.bind("<>", lambda e: self.apply_code_settings()) + + # 代码块背景色 + bg_frame = ttk.Frame(code_frame) + bg_frame.pack(fill=tk.X, pady=5) + ttk.Label(bg_frame, text="代码块背景:").pack(side=tk.LEFT) + self.code_bg_var = tk.StringVar(value=self.config.code_block.background) + self.code_bg_btn = tk.Button( + bg_frame, + text=" ", + bg=self.code_bg_var.get(), + width=3, + command=self.choose_code_bg + ) + self.code_bg_btn.pack(side=tk.LEFT, padx=5) + ttk.Entry(bg_frame, textvariable=self.code_bg_var, width=10).pack(side=tk.LEFT) + + def create_custom_css_settings(self): + """创建自定义 CSS 设置区域""" + css_frame = ttk.LabelFrame(self.settings_frame, text="自定义 CSS", padding=10) + css_frame.pack(fill=tk.X, pady=(0, 10)) + + # 已保存的方案选择 + scheme_frame = ttk.Frame(css_frame) + scheme_frame.pack(fill=tk.X, pady=(0, 10)) + + ttk.Label(scheme_frame, text="已保存的方案:").pack(side=tk.LEFT) + self.scheme_var = tk.StringVar() + self.scheme_combo = ttk.Combobox( + scheme_frame, + values=self._get_scheme_names(), + textvariable=self.scheme_var, + state="readonly", + width=20 + ) + self.scheme_combo.pack(side=tk.LEFT, padx=5) + ttk.Button(scheme_frame, text="加载", command=self.load_css_scheme).pack(side=tk.LEFT, padx=2) + ttk.Button(scheme_frame, text="删除", command=self.delete_css_scheme).pack(side=tk.LEFT, padx=2) + + # CSS 编辑区 + self.css_text = scrolledtext.ScrolledText( + css_frame, + wrap=tk.WORD, + font=("Consolas", 9), + height=8 + ) + self.css_text.pack(fill=tk.BOTH, expand=True) + self.css_text.insert("1.0", self.config.custom_css) + + # 保存方案区域 + save_frame = ttk.Frame(css_frame) + save_frame.pack(fill=tk.X, pady=(10, 0)) + + ttk.Label(save_frame, text="方案名称:").pack(side=tk.LEFT) + self.scheme_name_var = tk.StringVar() + ttk.Entry(save_frame, textvariable=self.scheme_name_var, width=20).pack(side=tk.LEFT, padx=5) + ttk.Button(save_frame, text="💾 保存方案", command=self.save_css_scheme).pack(side=tk.LEFT, padx=5) + + # CSS 按钮 + btn_frame = ttk.Frame(css_frame) + btn_frame.pack(fill=tk.X, pady=(10, 0)) + + ttk.Button(btn_frame, text="应用 CSS", command=self.apply_custom_css).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(btn_frame, text="导出 CSS", command=self.export_css).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="重置", command=self.reset_custom_css).pack(side=tk.LEFT, padx=5) + + def _get_scheme_names(self): + """获取方案名称列表(只显示名称)""" + schemes = self.editor.theme_manager.get_all_css_schemes() + return [s.name for s in schemes] + + def on_theme_change(self, event=None): + """主题变更处理""" + theme_id = self.theme_var.get().split(":")[0] + self.editor.theme_manager.set_theme(theme_id) + + # 更新主题色显示 + theme = self.editor.theme_manager.get_current_theme() + self.primary_color_var.set(theme.primary_color) + self.color_btn.config(bg=theme.primary_color) + + self.update_preview() + + def choose_primary_color(self): + """选择主题色""" + color = colorchooser.askcolor(initialcolor=self.primary_color_var.get()) + if color[1]: + self.primary_color_var.set(color[1]) + self.color_btn.config(bg=color[1]) + + def apply_primary_color(self): + """应用主题色""" + # 创建自定义主题 + current_theme = self.editor.theme_manager.get_current_theme() + custom_theme = ThemeConfig( + id=f"custom_{current_theme.id}", + name=f"自定义 - {current_theme.name}", + primary_color=self.primary_color_var.get(), + background=current_theme.background, + text_color=current_theme.text_color, + link_color=current_theme.link_color, + code_bg=current_theme.code_bg + ) + self.editor.theme_manager.add_custom_theme(custom_theme) + self.editor.theme_manager.set_theme(custom_theme.id) + self.update_preview() + messagebox.showinfo("成功", "主题色已应用!") + + def apply_font_settings(self): + """应用字体设置""" + self.editor.update_font_config( + body_font=self.body_font_var.get(), + code_font=self.code_font_var.get(), + body_size=self.body_size_var.get(), + line_height=self.line_height_var.get() + ) + self.update_preview() + messagebox.showinfo("成功", "字体设置已应用!") + + def choose_code_bg(self): + """选择代码块背景色""" + color = colorchooser.askcolor(initialcolor=self.code_bg_var.get()) + if color[1]: + self.code_bg_var.set(color[1]) + self.apply_code_settings() + + def apply_code_settings(self): + """应用代码块设置""" + self.editor.update_code_block_config( + theme=self.code_theme_var.get(), + background=self.code_bg_var.get() + ) + self.update_preview() + + def apply_custom_css(self): + """应用自定义 CSS""" + css = self.css_text.get("1.0", tk.END).strip() + self.editor.update_custom_css(css) + self.update_preview() + messagebox.showinfo("成功", "自定义 CSS 已应用!") + + def reset_custom_css(self): + """重置自定义 CSS""" + if messagebox.askyesno("确认", "确定要清空自定义 CSS 吗?"): + self.css_text.delete("1.0", tk.END) + self.editor.update_custom_css("") + self.update_preview() + + def export_css(self): + """导出 CSS 文件""" + file_path = filedialog.asksaveasfilename( + defaultextension=".css", + filetypes=[("CSS 文件", "*.css"), ("所有文件", "*.*")] + ) + if file_path: + self.editor.export_css(file_path) + messagebox.showinfo("成功", f"CSS 已导出到:\n{file_path}") + + def save_css_scheme(self): + """保存 CSS 方案(同名更新,新名添加)""" + name = self.scheme_name_var.get().strip() + if not name: + messagebox.showwarning("提示", "请输入方案名称") + return + + css = self.css_text.get("1.0", tk.END).strip() + + # 收集当前字体和代码块配置 + font_config = { + "body_font": self.body_font_var.get(), + "code_font": self.code_font_var.get(), + "body_size": self.body_size_var.get(), + "line_height": self.line_height_var.get() + } + + code_block_config = { + "theme": self.code_theme_var.get(), + "background": self.code_bg_var.get() + } + + # 获取当前主题和主题色 + theme_id = self.theme_var.get().split(":")[0] if ":" in self.theme_var.get() else self.theme_var.get() + primary_color = self.primary_color_var.get() + + # 检查是否已存在同名方案 + existing_scheme = None + for scheme in self.editor.theme_manager.get_all_css_schemes(): + if scheme.name == name: + existing_scheme = scheme + break + + from markdown_editor import CssScheme + + if existing_scheme: + # 更新现有方案 + if messagebox.askyesno("确认", f"方案 '{name}' 已存在,是否更新?"): + # 删除旧方案,添加新方案(保留原 ID) + self.editor.theme_manager.remove_css_scheme(existing_scheme.id) + updated_scheme = CssScheme( + id=existing_scheme.id, + name=name, + css=css, + font_config=font_config, + code_block_config=code_block_config, + theme_id=theme_id, + primary_color=primary_color + ) + self.editor.theme_manager.add_css_scheme(updated_scheme) + messagebox.showinfo("成功", f"方案 '{name}' 已更新!") + else: + return + else: + # 添加新方案 + import uuid + scheme_id = str(uuid.uuid4())[:8] + scheme = CssScheme( + id=scheme_id, + name=name, + css=css, + font_config=font_config, + code_block_config=code_block_config, + theme_id=theme_id, + primary_color=primary_color + ) + self.editor.theme_manager.add_css_scheme(scheme) + messagebox.showinfo("成功", f"方案 '{name}' 已保存!") + + # 更新下拉列表 + self.scheme_combo['values'] = self._get_scheme_names() + self.scheme_name_var.set("") + + def load_css_scheme(self): + """加载 CSS 方案""" + scheme_name = self.scheme_var.get() + if not scheme_name: + messagebox.showwarning("提示", "请选择一个方案") + return + + # 根据名称查找方案 + scheme = None + for s in self.editor.theme_manager.get_all_css_schemes(): + if s.name == scheme_name: + scheme = s + break + + if not scheme: + messagebox.showerror("错误", "方案不存在") + return + + # 应用 CSS + self.css_text.delete("1.0", tk.END) + self.css_text.insert("1.0", scheme.css) + + # 应用字体配置 + if scheme.font_config: + if "body_font" in scheme.font_config: + self.body_font_var.set(scheme.font_config["body_font"]) + if "code_font" in scheme.font_config: + self.code_font_var.set(scheme.font_config["code_font"]) + if "body_size" in scheme.font_config: + self.body_size_var.set(scheme.font_config["body_size"]) + if "line_height" in scheme.font_config: + self.line_height_var.set(scheme.font_config["line_height"]) + + # 应用代码块配置 + if scheme.code_block_config: + if "theme" in scheme.code_block_config: + self.code_theme_var.set(scheme.code_block_config["theme"]) + if "background" in scheme.code_block_config: + self.code_bg_var.set(scheme.code_block_config["background"]) + self.code_bg_btn.config(bg=scheme.code_block_config["background"]) + + # 应用主题和主题色 + if scheme.theme_id: + self.theme_var.set(scheme.theme_id) + self.editor.theme_manager.set_theme(scheme.theme_id) + if scheme.primary_color: + self.primary_color_var.set(scheme.primary_color) + self.color_btn.config(bg=scheme.primary_color) + + # 应用所有配置 + self.apply_custom_css() + self.apply_font_settings() + self.apply_code_settings() + + # 将方案名称写入方案名称框 + self.scheme_name_var.set(scheme.name) + + messagebox.showinfo("成功", f"方案 '{scheme.name}' 已加载!") + + def delete_css_scheme(self): + """删除 CSS 方案""" + scheme_name = self.scheme_var.get() + if not scheme_name: + messagebox.showwarning("提示", "请选择一个方案") + return + + # 根据名称查找方案 + scheme = None + for s in self.editor.theme_manager.get_all_css_schemes(): + if s.name == scheme_name: + scheme = s + break + + if not scheme: + messagebox.showerror("错误", "方案不存在") + return + + if messagebox.askyesno("确认", f"确定要删除方案 '{scheme.name}' 吗?"): + self.editor.theme_manager.remove_css_scheme(scheme.id) + self.scheme_combo['values'] = self._get_scheme_names() + self.scheme_var.set("") + messagebox.showinfo("成功", "方案已删除!") + + def update_preview(self): + """更新预览""" + md_content = self.md_text.get("1.0", tk.END) + html = self.editor.render(md_content) + + # 更新 HTML 源码显示 + self.html_text.config(state=tk.NORMAL) + self.html_text.delete("1.0", tk.END) + self.html_text.insert("1.0", html) + self.html_text.config(state=tk.DISABLED) + + # 更新 HTML 渲染预览 + try: + # 清理 HTML 中的图片 URL,移除反引号和多余空格 + import re + cleaned_html = html + # 移除 src 属性中的反引号 + cleaned_html = re.sub(r'src="\s*`\s*([^"`]+)\s*`\s*"', r'src="\1"', cleaned_html) + cleaned_html = re.sub(r"src='\s*`\s*([^'`]+)\s*`'", r'src="\1"', cleaned_html) + # 移除 src 属性值前后的空格 + cleaned_html = re.sub(r'src="\s*([^"]+)\s*"', r'src="\1"', cleaned_html) + + # 使用 tkinterweb 的 HtmlFrame 显示预览 + # 创建临时文件并加载,以便图片能正确显示 + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as f: + f.write(cleaned_html) + temp_path = f.name + self.html_preview.load_file(temp_path) + except Exception as e: + print(f"[Editor] 预览更新失败: {e}") + + def copy_html(self): + """复制 HTML 到剪贴板""" + html = self.html_text.get("1.0", tk.END) + self.window.clipboard_clear() + self.window.clipboard_append(html) + messagebox.showinfo("成功", "HTML 已复制到剪贴板!") + + def export_html(self): + """导出 HTML 文件""" + file_path = filedialog.asksaveasfilename( + defaultextension=".html", + filetypes=[("HTML 文件", "*.html"), ("所有文件", "*.*")] + ) + if file_path: + md_content = self.md_text.get("1.0", tk.END) + self.editor.export_html(md_content, file_path) + messagebox.showinfo("成功", f"HTML 已导出到:\n{file_path}") + + def open_in_browser(self): + """在浏览器中打开预览""" + md_content = self.md_text.get("1.0", tk.END) + html = self.editor.render(md_content) + + # 创建临时文件 + with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as f: + f.write(html) + temp_path = f.name + + # 在浏览器中打开 + webbrowser.open(f"file://{temp_path}") + + def run(self): + """运行编辑器""" + if not hasattr(self.window, '_is_main'): + self.window.mainloop() + + +def open_editor(parent=None): + """打开 Markdown 编辑器窗口""" + editor = MarkdownEditorWindow(parent) + return editor + + +if __name__ == "__main__": + editor = MarkdownEditorWindow() + editor.run() diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..8b01de5 --- /dev/null +++ b/gui.py @@ -0,0 +1,768 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk, messagebox, scrolledtext +import threading +import sys +import os + +# 添加当前目录到路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from config_loader import get_config, Config +from step1 import Step1FeastCoding +from step2 import Step2Converter +from editor_gui import open_editor +from markdown_editor import ThemeManager +from db_manager import get_db_manager, reset_db_manager, ProjectOrder + + +class YidaimaGUI: + def __init__(self, root): + self.root = root + self.root.title("自动化工具") + self.root.geometry("1200x900") + self.root.resizable(True, True) + + # 设置样式 + self.style = ttk.Style() + self.style.configure("TButton", padding=6, relief="flat", background="#007aff") + self.style.configure("TLabel", padding=6, font=("Microsoft YaHei", 10)) + self.style.configure("TEntry", padding=6) + + # 设置 Treeview 样式,增加行高 + self.style.configure("Treeview", rowheight=30) + + # 加载配置 + self.config = get_config() + + # 初始化管理器 + self.theme_manager = ThemeManager() + self._init_db_manager() + + # 运行状态 + self.is_running = False + + # 文章管理分页状态 + self.current_page = 1 + self.page_size = 20 + self.total_items = 0 + self.search_keyword = "" + + # 创建界面 + self.create_widgets() + + def _init_db_manager(self): + """初始化数据库管理器""" + try: + db_config = self.config.get("database", {}) + self.db_manager = get_db_manager( + host=db_config.get("host", "localhost"), + database=db_config.get("database", "test"), + user=db_config.get("user", "root"), + password=db_config.get("password", "123456"), + port=db_config.get("port", 3306) + ) + except Exception as e: + print(f"[GUI] 初始化数据库管理器失败: {e}") + self.db_manager = None + + def create_widgets(self): + """创建主界面""" + # 主框架 + main_frame = ttk.Frame(self.root, padding="10") + main_frame.pack(fill=tk.BOTH, expand=True) + + # 创建 Notebook (Tab 控件) + self.notebook = ttk.Notebook(main_frame) + self.notebook.pack(fill=tk.BOTH, expand=True) + + # === Tab 1: 发布微信公众号 === + self.tab_publish = ttk.Frame(self.notebook, padding="10") + self.notebook.add(self.tab_publish, text="发布微信公众号") + self._create_publish_tab() + + # === Tab 2: 文章发布管理 === + self.tab_manage = ttk.Frame(self.notebook, padding="10") + self.notebook.add(self.tab_manage, text="文章发布管理") + self._create_manage_tab() + + # === Tab 4: 参数设置 === + self.tab_settings = ttk.Frame(self.notebook, padding="10") + self.notebook.add(self.tab_settings, text="参数设置") + self._create_settings_tab() + + def _create_publish_tab(self): + """创建发布微信公众号 Tab""" + # 配置网格 + self.tab_publish.columnconfigure(0, weight=1) + self.tab_publish.rowconfigure(2, weight=1) + + # === 配置区域 === + config_frame = ttk.LabelFrame(self.tab_publish, text="微信公众号配置", padding="10") + config_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + config_frame.columnconfigure(1, weight=1) + + # 项目名称 + ttk.Label(config_frame, text="项目名称:").grid(row=0, column=0, sticky=tk.W) + self.project_name_var = tk.StringVar( + value=self.config.get("step1.project_name", "【A173】基于Springboot + vue3实现的学生交流互助平台") + ) + self.project_name_entry = ttk.Entry(config_frame, textvariable=self.project_name_var, width=50) + self.project_name_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(10, 0)) + + # CSS 样式方案 + ttk.Label(config_frame, text="CSS 样式方案:").grid(row=1, column=0, sticky=tk.W, pady=(10, 0)) + self.css_scheme_var = tk.StringVar() + scheme_frame = ttk.Frame(config_frame) + scheme_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(10, 0), pady=(10, 0)) + scheme_frame.columnconfigure(0, weight=1) + + self.css_scheme_combo = ttk.Combobox( + scheme_frame, + values=self._get_css_scheme_names(), + textvariable=self.css_scheme_var, + state="readonly", + width=50 + ) + self.css_scheme_combo.grid(row=0, column=0, sticky=(tk.W, tk.E)) + self.css_scheme_combo.set("默认") + + # 刷新按钮 + ttk.Button(scheme_frame, text="刷新", command=self.refresh_css_schemes, width=8).grid(row=0, column=1, padx=(5, 0)) + + # === 操作按钮区域 === + button_frame = ttk.Frame(self.tab_publish) + button_frame.grid(row=1, column=0, pady=(10, 10)) + + # 发布微信公众号按钮 + self.publish_btn = ttk.Button( + button_frame, + text="发布微信公众号", + command=self.run_full_flow, + width=25 + ) + self.publish_btn.pack(side=tk.LEFT) + + # Markdown 编辑器按钮 + ttk.Button( + button_frame, + text="Markdown 编辑器", + command=self._open_markdown_editor, + width=15 + ).pack(side=tk.LEFT, padx=(10, 0)) + + + + # === 日志输出区域 === + log_frame = ttk.LabelFrame(self.tab_publish, text="运行日志", padding="10") + log_frame.grid(row=2, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(10, 0)) + log_frame.columnconfigure(0, weight=1) + log_frame.rowconfigure(0, weight=1) + + self.log_text = scrolledtext.ScrolledText( + log_frame, + wrap=tk.WORD, + width=80, + height=15, + font=("Consolas", 9) + ) + self.log_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # 进度条 + self.progress = ttk.Progressbar( + self.tab_publish, + mode='indeterminate', + length=400 + ) + self.progress.grid(row=3, column=0, pady=(10, 0), sticky=(tk.W, tk.E)) + + # 状态标签 + self.status_var = tk.StringVar(value="就绪") + self.status_label = ttk.Label( + self.tab_publish, + textvariable=self.status_var, + foreground="#666" + ) + self.status_label.grid(row=4, column=0, pady=(10, 0)) + + def _create_manage_tab(self): + """创建文章发布管理 Tab""" + # 配置网格 + self.tab_manage.columnconfigure(0, weight=1) + self.tab_manage.rowconfigure(2, weight=1) + + # === 搜索区域 === + search_frame = ttk.Frame(self.tab_manage) + search_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + search_frame.columnconfigure(1, weight=1) + + ttk.Label(search_frame, text="搜索名称:").grid(row=0, column=0, sticky=tk.W) + self.search_var = tk.StringVar() + self.search_entry = ttk.Entry(search_frame, textvariable=self.search_var, width=40) + self.search_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(10, 0)) + + ttk.Button(search_frame, text="搜索", command=self._search_projects, width=10).grid(row=0, column=2, padx=(10, 0)) + ttk.Button(search_frame, text="刷新", command=self._refresh_projects, width=10).grid(row=0, column=3, padx=(10, 0)) + + # === 数据表格区域 === + table_frame = ttk.LabelFrame(self.tab_manage, text="项目列表", padding="5") + table_frame.grid(row=1, column=0, rowspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 10)) + table_frame.columnconfigure(0, weight=1) + table_frame.rowconfigure(0, weight=1) + + # 创建 Treeview + columns = ("name", "name2", "paths", "zlzt", "sfxxm", "bianhao", "actions") + self.tree = ttk.Treeview( + table_frame, + columns=columns, + show="headings", + height=35 + ) + + # 设置列标题 + self.tree.heading("name", text="项目名称") + self.tree.heading("name2", text="名称2") + self.tree.heading("paths", text="路径") + self.tree.heading("zlzt", text="整理状态") + self.tree.heading("sfxxm", text="是否新项目") + self.tree.heading("bianhao", text="编号") + self.tree.heading("actions", text="操作") + + # 设置列宽 + self.tree.column("name", width=150, anchor=tk.W) + self.tree.column("name2", width=100, anchor=tk.W) + self.tree.column("paths", width=120, anchor=tk.W) + self.tree.column("zlzt", width=80, anchor=tk.CENTER) + self.tree.column("sfxxm", width=80, anchor=tk.CENTER) + self.tree.column("bianhao", width=80, anchor=tk.CENTER) + self.tree.column("actions", width=200, anchor=tk.CENTER) + + # 添加滚动条 + scrollbar_y = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.tree.yview) + scrollbar_x = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.tree.xview) + self.tree.configure(yscrollcommand=scrollbar_y.set, xscrollcommand=scrollbar_x.set) + + self.tree.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + scrollbar_y.grid(row=0, column=1, sticky=(tk.N, tk.S)) + scrollbar_x.grid(row=1, column=0, sticky=(tk.W, tk.E)) + + # 绑定右键点击事件 + self.tree.bind("", self._on_tree_right_click) + + # === 分页区域 === + page_frame = ttk.Frame(self.tab_manage) + page_frame.grid(row=3, column=0, sticky=(tk.W, tk.E), pady=(5, 0)) + + self.page_info_var = tk.StringVar(value="第 1 页,共 0 页,共 0 条") + ttk.Label(page_frame, textvariable=self.page_info_var).pack(side=tk.LEFT) + + ttk.Button(page_frame, text="上一页", command=self._prev_page).pack(side=tk.LEFT, padx=(20, 5)) + ttk.Button(page_frame, text="下一页", command=self._next_page).pack(side=tk.LEFT, padx=(5, 0)) + + # 加载数据(延迟加载,避免初始化时出错) + # self._load_projects() + + def _create_settings_tab(self): + """创建参数设置 Tab""" + # 创建 Canvas 和 Scrollbar 实现滚动 + canvas = tk.Canvas(self.tab_settings) + scrollbar = ttk.Scrollbar(self.tab_settings, orient="vertical", command=canvas.yview) + self.settings_frame = ttk.Frame(canvas, padding="10") + + self.settings_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + canvas.create_window((0, 0), window=self.settings_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # 配置网格 + self.settings_frame.columnconfigure(1, weight=1) + + row = 0 + + # === 网站账号配置 === + website_frame = ttk.LabelFrame(self.settings_frame, text="网站账号配置", padding="10") + website_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 15)) + website_frame.columnconfigure(1, weight=1) + + ttk.Label(website_frame, text="网站用户名:").grid(row=0, column=0, sticky=tk.W) + self.setting_username_var = tk.StringVar(value=self.config.get("step1.username", "wangpeng")) + ttk.Entry(website_frame, textvariable=self.setting_username_var, width=40).grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(10, 0)) + + ttk.Label(website_frame, text="网站密码:").grid(row=1, column=0, sticky=tk.W, pady=(10, 0)) + self.setting_password_var = tk.StringVar(value=self.config.get("step1.password", "")) + ttk.Entry(website_frame, textvariable=self.setting_password_var, show="*", width=40).grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(10, 0), pady=(10, 0)) + + row += 1 + + # === Chrome 配置 === + chrome_frame = ttk.LabelFrame(self.settings_frame, text="Chrome 配置", padding="10") + chrome_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 15)) + chrome_frame.columnconfigure(1, weight=1) + + ttk.Label(chrome_frame, text="Chrome 路径:").grid(row=0, column=0, sticky=tk.W) + self.setting_chrome_var = tk.StringVar(value=self.config.get("chrome.path", r"C:\Program Files\Google\Chrome\Application\chrome.exe")) + ttk.Entry(chrome_frame, textvariable=self.setting_chrome_var, width=50).grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(10, 0)) + + row += 1 + + # === 微信公众号配置 === + wechat_frame = ttk.LabelFrame(self.settings_frame, text="微信公众号配置", padding="10") + wechat_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 15)) + wechat_frame.columnconfigure(1, weight=1) + + ttk.Label(wechat_frame, text="AppID:").grid(row=0, column=0, sticky=tk.W) + self.setting_appid_var = tk.StringVar(value=self.config.get("wechat.appid", "")) + ttk.Entry(wechat_frame, textvariable=self.setting_appid_var, width=40).grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(10, 0)) + + ttk.Label(wechat_frame, text="AppSecret:").grid(row=1, column=0, sticky=tk.W, pady=(10, 0)) + self.setting_appsecret_var = tk.StringVar(value=self.config.get("wechat.appsecret", "")) + ttk.Entry(wechat_frame, textvariable=self.setting_appsecret_var, show="*", width=40).grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(10, 0), pady=(10, 0)) + + row += 1 + + # === 数据库配置 === + db_frame = ttk.LabelFrame(self.settings_frame, text="数据库配置", padding="10") + db_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 15)) + db_frame.columnconfigure(1, weight=1) + + ttk.Label(db_frame, text="主机地址:").grid(row=0, column=0, sticky=tk.W) + self.setting_db_host_var = tk.StringVar(value=self.config.get("database.host", "localhost")) + ttk.Entry(db_frame, textvariable=self.setting_db_host_var, width=30).grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(10, 0)) + + ttk.Label(db_frame, text="端口:").grid(row=1, column=0, sticky=tk.W, pady=(10, 0)) + self.setting_db_port_var = tk.StringVar(value=str(self.config.get("database.port", "3306"))) + ttk.Entry(db_frame, textvariable=self.setting_db_port_var, width=30).grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(10, 0), pady=(10, 0)) + + ttk.Label(db_frame, text="数据库名:").grid(row=2, column=0, sticky=tk.W, pady=(10, 0)) + self.setting_db_name_var = tk.StringVar(value=self.config.get("database.database", "test")) + ttk.Entry(db_frame, textvariable=self.setting_db_name_var, width=30).grid(row=2, column=1, sticky=(tk.W, tk.E), padx=(10, 0), pady=(10, 0)) + + ttk.Label(db_frame, text="用户名:").grid(row=3, column=0, sticky=tk.W, pady=(10, 0)) + self.setting_db_user_var = tk.StringVar(value=self.config.get("database.user", "root")) + ttk.Entry(db_frame, textvariable=self.setting_db_user_var, width=30).grid(row=3, column=1, sticky=(tk.W, tk.E), padx=(10, 0), pady=(10, 0)) + + ttk.Label(db_frame, text="密码:").grid(row=4, column=0, sticky=tk.W, pady=(10, 0)) + self.setting_db_pass_var = tk.StringVar(value=self.config.get("database.password", "123456")) + ttk.Entry(db_frame, textvariable=self.setting_db_pass_var, show="*", width=30).grid(row=4, column=1, sticky=(tk.W, tk.E), padx=(10, 0), pady=(10, 0)) + + # 测试连接按钮 + ttk.Button(db_frame, text="🧪 测试连接", command=self._test_db_connection).grid(row=5, column=1, sticky=tk.W, pady=(10, 0)) + + row += 1 + + # === 保存按钮 === + btn_frame = ttk.Frame(self.settings_frame) + btn_frame.grid(row=row, column=0, columnspan=2, pady=(20, 0)) + + ttk.Button(btn_frame, text="💾 保存配置", command=self._save_settings, width=20).pack(side=tk.LEFT, padx=(0, 10)) + ttk.Button(btn_frame, text="🔄 重置", command=self._reset_settings, width=15).pack(side=tk.LEFT) + + # ==================== 发布公众号 Tab 方法 ==================== + + def _open_markdown_editor(self): + """打开 Markdown 编辑器""" + try: + open_editor(parent=self.root) + except Exception as e: + messagebox.showerror("错误", f"打开 Markdown 编辑器失败: {e}") + + def log(self, message: str): + """添加日志""" + self.log_text.insert(tk.END, f"{message}\n") + self.log_text.see(tk.END) + self.root.update_idletasks() + + def set_running(self, running: bool): + """设置运行状态""" + self.is_running = running + state = tk.DISABLED if running else tk.NORMAL + self.publish_btn.config(state=state) + + if running: + self.progress.start() + self.status_var.set("运行中...") + else: + self.progress.stop() + self.status_var.set("就绪") + + def run_full_flow(self): + """运行完整流程""" + if self.is_running: + return + + thread = threading.Thread(target=self._run_full_flow_thread) + thread.daemon = True + thread.start() + + def _run_full_flow_thread(self): + """在后台线程中运行完整流程""" + try: + self.set_running(True) + self.root.after(0, lambda: self.log("=" * 50)) + self.root.after(0, lambda: self.log("开始运行完整流程...")) + self.root.after(0, lambda: self.log("=" * 50)) + + # 获取配置(从配置文件读取) + chrome_path = self.config.get("chrome.path", "") + username = self.config.get("step1.username", "") + password = self.config.get("step1.password", "") + project_name = self.project_name_var.get() + + # Step 1 + self.root.after(0, lambda: self.log("\n[Step 1] 开始执行...")) + + # 定义日志回调函数,将 step1 的日志输出到 GUI + def step1_log_callback(message: str): + self.root.after(0, lambda msg=message: self.log(msg)) + + step1 = Step1FeastCoding(chrome_path, username, password, log_callback=step1_log_callback) + step1.project_name = project_name # 设置项目名称 + step1.run() + self.root.after(0, lambda: self.log("[Step 1] 执行完成!")) + + # Step 2 + self.root.after(0, lambda: self.log("\n[Step 2] 开始执行...")) + + # 定义日志回调函数,将 step2 的日志输出到 GUI + def log_callback(message: str): + self.root.after(0, lambda msg=message: self.log(msg)) + + # 获取 CSS 方案 ID(根据名称查找) + scheme_name = self.css_scheme_var.get() + css_scheme_id = self._get_scheme_id_by_name(scheme_name) + + step2 = Step2Converter(log_callback=log_callback, css_scheme_id=css_scheme_id) + step2.run() + self.root.after(0, lambda: self.log("[Step 2] 执行完成!")) + + self.root.after(0, lambda: self.log("\n" + "=" * 50)) + self.root.after(0, lambda: self.log("完整流程执行成功!")) + self.root.after(0, lambda: self.log("=" * 50)) + self.root.after(0, lambda: messagebox.showinfo("成功", "完整流程执行成功!")) + + except Exception as e: + error_msg = str(e) + self.root.after(0, lambda: self.log(f"\n[ERROR] {error_msg}")) + self.root.after(0, lambda: messagebox.showerror("错误", f"执行失败: {error_msg}")) + finally: + self.set_running(False) + + def _get_css_scheme_names(self): + """获取 CSS 方案名称列表(只显示名称)""" + schemes = self.theme_manager.get_all_css_schemes() + return ["默认"] + [s.name for s in schemes] + + def _get_scheme_id_by_name(self, name: str): + """根据名称获取方案 ID""" + if name == "默认": + return None + schemes = self.theme_manager.get_all_css_schemes() + for scheme in schemes: + if scheme.name == name: + return scheme.id + return None + + def refresh_css_schemes(self): + """刷新 CSS 方案列表""" + # 重新加载配置 + self.theme_manager = ThemeManager() + # 更新下拉框 + self.css_scheme_combo['values'] = self._get_css_scheme_names() + messagebox.showinfo("提示", "CSS 方案列表已刷新!") + + # ==================== 文章管理 Tab 方法 ==================== + + def _load_projects(self): + """加载项目列表""" + if not self.db_manager: + messagebox.showwarning("警告", "数据库未配置或连接失败") + return + + try: + projects, total = self.db_manager.get_projects( + page=self.current_page, + page_size=self.page_size, + search_name=self.search_keyword if self.search_keyword else None + ) + + self.total_items = total + + # 清空表格 + for item in self.tree.get_children(): + self.tree.delete(item) + + # 插入数据 + for project in projects: + self.tree.insert("", tk.END, values=( + project.name or "", + project.name2 or "", + project.paths or "", + project.zlzt or "", + project.sfxxm or "", + project.bianhao or "", + "右键操作" + )) + + # 更新分页信息 + total_pages = (total + self.page_size - 1) // self.page_size if total > 0 else 1 + self.page_info_var.set(f"第 {self.current_page} 页,共 {total_pages} 页,共 {total} 条") + + except Exception as e: + messagebox.showerror("错误", f"加载数据失败: {e}") + + def _search_projects(self): + """搜索项目""" + try: + self.search_keyword = self.search_var.get().strip() + self.current_page = 1 + self._load_projects() + except Exception as e: + messagebox.showerror("错误", f"搜索失败: {e}") + + def _refresh_projects(self): + """刷新项目列表""" + self.search_keyword = "" + self.search_var.set("") + self.current_page = 1 + self._load_projects() + + def _prev_page(self): + """上一页""" + if self.current_page > 1: + self.current_page -= 1 + self._load_projects() + + def _next_page(self): + """下一页""" + total_pages = (self.total_items + self.page_size - 1) // self.page_size if self.total_items > 0 else 1 + if self.current_page < total_pages: + self.current_page += 1 + self._load_projects() + + def _on_tree_right_click(self, event): + """处理表格右键点击事件""" + # 获取点击位置对应的项 + item = self.tree.identify_row(event.y) + if not item: + return + + # 选中当前项 + self.tree.selection_set(item) + + # 获取选中项的值 + values = self.tree.item(item, "values") + if not values: + return + + name = values[0] + name2 = values[1] + current_zlzt = values[3] # 整理状态现在在第4列(索引3) + + # 创建操作菜单 + menu = tk.Menu(self.root, tearoff=0) + + # 变更已整理状态选项 + new_zlzt = "已整理" if current_zlzt != "已整理" else "未整理" + menu.add_command( + label=f"变更状态: {new_zlzt}", + command=lambda: self._update_project_zlzt(name, new_zlzt) + ) + + menu.add_separator() + + # 删除选项 + menu.add_command( + label="删除", + command=lambda: self._delete_project(name) + ) + + menu.add_separator() + + # 复制路径选项 + menu.add_command( + label="复制路径", + command=lambda: self._copy_path(name) + ) + + menu.add_separator() + + # 复制name2名称选项 + menu.add_command( + label="复制名称2", + command=lambda: self._copy_name2(name2) + ) + + # 显示菜单 + menu.post(event.x_root, event.y_root) + + def _update_project_zlzt(self, name: str, zlzt: str): + """更新项目整理状态""" + if not self.db_manager: + return + + if messagebox.askyesno("确认", f"确定将 '{name}' 标记为 {zlzt} 吗?"): + if self.db_manager.update_zlzt(name, zlzt): + messagebox.showinfo("成功", f"已更新为 {zlzt}") + self._load_projects() + else: + messagebox.showerror("错误", "更新失败") + + def _delete_project(self, name: str): + """删除项目""" + if not self.db_manager: + return + + if messagebox.askyesno("确认", f"确定删除 '{name}' 吗?\n此操作不可恢复!"): + if self.db_manager.delete_project(name): + messagebox.showinfo("成功", "删除成功") + self._load_projects() + else: + messagebox.showerror("错误", "删除失败") + + def _copy_name2(self, name2: str): + """复制name2名称到剪贴板""" + if not name2: + messagebox.showwarning("提示", "名称2为空,无法复制") + return + + self.root.clipboard_clear() + self.root.clipboard_append(name2) + messagebox.showinfo("成功", f"已复制: {name2}") + + def _copy_path(self, name: str): + """复制项目路径到剪贴板""" + if not self.db_manager: + messagebox.showwarning("提示", "数据库未连接,无法获取路径") + return + + try: + # 获取项目路径 + project = self.db_manager.get_project_by_name(name) + if project and project.paths: + self.root.clipboard_clear() + self.root.clipboard_append(project.paths) + messagebox.showinfo("成功", f"已复制路径: {project.paths}") + else: + messagebox.showwarning("提示", "项目路径为空,无法复制") + except Exception as e: + messagebox.showerror("错误", f"复制路径失败: {e}") + + # ==================== 参数设置 Tab 方法 ==================== + + def _test_db_connection(self): + """测试数据库连接""" + def test_in_thread(): + try: + from db_manager import DatabaseManager + + host = self.setting_db_host_var.get() + port_str = self.setting_db_port_var.get() + port = int(port_str) if port_str else 3306 + database = self.setting_db_name_var.get() + user = self.setting_db_user_var.get() + password = self.setting_db_pass_var.get() + + print(f"[TestDB] 测试连接: {host}:{port}/{database} as {user}") + db = DatabaseManager(host, database, user, password, port) + success, error = db.test_connection() + + if success: + self.root.after(0, lambda: messagebox.showinfo("成功", "数据库连接成功!")) + else: + self.root.after(0, lambda: messagebox.showerror("失败", f"连接失败: {error}")) + except Exception as e: + print(f"[TestDB] 测试连接异常: {e}") + self.root.after(0, lambda msg=str(e): messagebox.showerror("错误", f"测试连接出错: {msg}")) + + # 在新线程中运行,避免阻塞 GUI + thread = threading.Thread(target=test_in_thread) + thread.daemon = True + thread.start() + + def _save_settings(self): + """保存配置""" + try: + # 构建新配置 + new_config = { + "step1": { + "url": "https://feast.yidaima.cn/login", + "username": self.setting_username_var.get(), + "password": self.setting_password_var.get(), + "project_name": self.project_name_var.get(), + "feast_button": "FeastCoding" + }, + "step2": { + "url": "http://yidaima.cn:6005/" + }, + "chrome": { + "path": self.setting_chrome_var.get() + }, + "wechat": { + "appid": self.setting_appid_var.get(), + "appsecret": self.setting_appsecret_var.get() + }, + "database": { + "host": self.setting_db_host_var.get(), + "port": int(self.setting_db_port_var.get() or 3306), + "database": self.setting_db_name_var.get(), + "user": self.setting_db_user_var.get(), + "password": self.setting_db_pass_var.get() + } + } + + # 保存到文件 + import yaml + config_path = os.path.join(os.path.dirname(__file__), "config.yaml") + with open(config_path, 'w', encoding='utf-8') as f: + yaml.dump(new_config, f, allow_unicode=True, sort_keys=False) + + # 重新加载配置 + global _config_instance + _config_instance = None + self.config = get_config() + + # 重置数据库管理器 + reset_db_manager() + self._init_db_manager() + + # 更新发布页面的值 + self.username_var.set(self.config.get("step1.username", "")) + self.password_var.set(self.config.get("step1.password", "")) + self.chrome_path_var.set(self.config.get("chrome.path", "")) + + messagebox.showinfo("成功", "配置已保存!") + + except Exception as e: + messagebox.showerror("错误", f"保存失败: {e}") + + def _reset_settings(self): + """重置设置""" + if messagebox.askyesno("确认", "确定要重置所有设置吗?"): + self.setting_username_var.set(self.config.get("step1.username", "wangpeng")) + self.setting_password_var.set(self.config.get("step1.password", "")) + self.setting_chrome_var.set(self.config.get("chrome.path", r"C:\Program Files\Google\Chrome\Application\chrome.exe")) + self.setting_appid_var.set(self.config.get("wechat.appid", "")) + self.setting_appsecret_var.set(self.config.get("wechat.appsecret", "")) + self.setting_db_host_var.set(self.config.get("database.host", "localhost")) + self.setting_db_port_var.set(str(self.config.get("database.port", "3306"))) + self.setting_db_name_var.set(self.config.get("database.database", "test")) + self.setting_db_user_var.set(self.config.get("database.user", "root")) + self.setting_db_pass_var.set(self.config.get("database.password", "123456")) + + +def main(): + """启动 GUI""" + root = tk.Tk() + app = YidaimaGUI(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/image_uploader.py b/image_uploader.py new file mode 100644 index 0000000..708517a --- /dev/null +++ b/image_uploader.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +import requests + +from config_loader import get_config + + +class WeChatImageUploader: + """微信图片上传器 - 将外部图片上传到微信服务器获取永久链接""" + + def __init__(self, access_token: str): + self.access_token = access_token + self.upload_url = "https://api.weixin.qq.com/cgi-bin/media/uploadimg" + + def upload_image(self, image_url: str) -> Optional[str]: + """ + 下载图片并上传到微信服务器 + + Args: + image_url: 图片的原始 URL(七牛云等) + + Returns: + 微信图片 URL,上传失败返回 None + """ + try: + # 1. 下载图片 + print(f"[ImageUploader] 正在下载图片: {image_url}") + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + response = requests.get(image_url, headers=headers, timeout=30) + response.raise_for_status() + + # 2. 获取文件扩展名和 MIME 类型 + content_type = response.headers.get('Content-Type', '') + if 'image/' in content_type: + ext = content_type.split('/')[-1].split(';')[0].strip() + if ext == 'jpeg': + ext = 'jpg' + else: + # 从 URL 推断 + parsed = urlparse(image_url) + path = parsed.path.lower() + if path.endswith('.jpg') or path.endswith('.jpeg'): + ext = 'jpg' + elif path.endswith('.png'): + ext = 'png' + elif path.endswith('.gif'): + ext = 'gif' + else: + ext = 'jpg' + + # 3. 上传到微信 + print(f"[ImageUploader] 正在上传到微信服务器...") + files = { + 'media': (f'image.{ext}', response.content, f'image/{ext}') + } + params = { + 'access_token': self.access_token + } + + upload_response = requests.post( + self.upload_url, + params=params, + files=files, + timeout=30 + ) + upload_response.raise_for_status() + + result = upload_response.json() + print(f"[ImageUploader] 微信返回: {result}") + + # 检查错误 + if 'errcode' in result and result['errcode'] != 0: + print(f"[ImageUploader] 上传失败: {result}") + return None + + # 获取微信图片 URL + wechat_url = result.get('url') + if wechat_url: + print(f"[ImageUploader] 上传成功: {wechat_url}") + return wechat_url + else: + print(f"[ImageUploader] 上传失败,未返回 URL: {result}") + return None + + except requests.exceptions.RequestException as e: + print(f"[ImageUploader] 网络请求出错: {e}") + return None + except Exception as e: + print(f"[ImageUploader] 上传出错: {e}") + import traceback + traceback.print_exc() + return None + + +def process_html_images(html_content: str, access_token: str, external_domain: Optional[str] = None) -> tuple[str, Optional[str]]: + """ + 处理 HTML 中的图片,将外部图片上传到微信并替换 URL + + Args: + html_content: HTML 内容 + access_token: 微信 access_token + external_domain: 外部图片域名,用于识别需要上传的图片(如 yidaima.cn) + + Returns: + (处理后的 HTML 内容, 第二张图片的微信 URL 作为封面图) + """ + uploader = WeChatImageUploader(access_token) + + # 匹配所有 img 标签的 src 属性 + img_pattern = r']+src="([^"]+)"' + + # 收集所有图片 URL(包括微信图片) + all_img_urls = [] + for match in re.finditer(img_pattern, html_content): + url = match.group(1) + all_img_urls.append(url) + + print(f"[ImageUploader] HTML 中共有 {len(all_img_urls)} 张图片") + + # 获取第二张图片作为封面(如果存在) + cover_image_url: Optional[str] = None + if len(all_img_urls) >= 2: + second_img = all_img_urls[1] + print(f"[ImageUploader] 第二张图片: {second_img}") + + # 如果第二张是外部图片,需要上传 + if 'mmbiz.qpic.cn' not in second_img: + print(f"[ImageUploader] 上传第二张图片作为封面...") + cover_image_url = uploader.upload_image(second_img) + if cover_image_url: + print(f"[ImageUploader] 封面上传成功: {cover_image_url}") + else: + print(f"[ImageUploader] 封面上传失败") + else: + # 已经是微信图片 + cover_image_url = second_img + print(f"[ImageUploader] 第二张已是微信图片,直接用作封面") + else: + print(f"[ImageUploader] 图片数量不足2张,无法获取封面") + + # 收集所有需要处理的外部图片 URL + urls_to_process = [] + for url in all_img_urls: + # 跳过已经是微信图片的 + if 'mmbiz.qpic.cn' in url: + continue + + # 检查是否是外部图片 + is_external = False + if external_domain and external_domain in url: + is_external = True + elif url.startswith(('http://', 'https://')) and not url.startswith(('http://localhost', 'https://localhost')): + # 所有外部 HTTP/HTTPS 图片(除了本地) + is_external = True + + if is_external: + urls_to_process.append(url) + + if not urls_to_process: + print("[ImageUploader] 没有需要上传的外部图片") + return html_content, cover_image_url + + print(f"[ImageUploader] 发现 {len(urls_to_process)} 张外部图片需要上传") + + # 上传图片并替换 URL + url_mapping = {} + for url in urls_to_process: + print(f"[ImageUploader] 处理图片: {url}") + wechat_url = uploader.upload_image(url) + if wechat_url: + url_mapping[url] = wechat_url + else: + print(f"[ImageUploader] 上传失败,保留原 URL: {url}") + + # 替换 HTML 中的 URL + for old_url, new_url in url_mapping.items(): + # 同时替换 src 和 data-src + html_content = html_content.replace(f'src="{old_url}"', f'src="{new_url}"') + html_content = html_content.replace(f'data-src="{old_url}"', f'data-src="{new_url}"') + print(f"[ImageUploader] 已替换: {old_url} -> {new_url}") + + # 如果第二张图片被替换了,更新封面 URL + if len(all_img_urls) >= 2 and all_img_urls[1] in url_mapping: + cover_image_url = url_mapping[all_img_urls[1]] + + return html_content, cover_image_url + + +def get_wechat_access_token(appid: str, appsecret: str) -> Optional[str]: + """ + 获取微信 access_token + + Args: + appid: 微信公众号 AppID + appsecret: 微信公众号 AppSecret + + Returns: + access_token,失败返回 None + """ + try: + url = "https://api.weixin.qq.com/cgi-bin/token" + params = { + "grant_type": "client_credential", + "appid": appid, + "secret": appsecret + } + + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + + data = response.json() + + if "access_token" in data: + return data["access_token"] + else: + print(f"[ImageUploader] 获取 access_token 失败: {data}") + return None + + except Exception as e: + print(f"[ImageUploader] 获取 access_token 出错: {e}") + return None + + +def main(): + """测试图片上传功能""" + config = get_config() + + # 获取 access_token + access_token = get_wechat_access_token(config.wechat_appid, config.wechat_appsecret) + if not access_token: + print("[ImageUploader] 无法获取 access_token,请检查配置") + return + + # 测试 HTML + test_html = """ +
+ 测试 + 微信图片 + 本地图片 +
+""" + + result = process_html_images(test_html, access_token, external_domain="yidaima.cn") + print("\n处理后的 HTML:") + print(result) + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py new file mode 100644 index 0000000..4221845 --- /dev/null +++ b/main.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import sys +import argparse +import ctypes +import os + +# 添加当前目录到 Python 路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Windows DPI 感知设置 - 必须在导入 tkinter 之前设置 +if sys.platform == 'win32': + try: + # 设置 DPI 感知模式(支持 Windows 8.1 及以上) + ctypes.windll.shcore.SetProcessDpiAwareness(1) # 系统 DPI 感知 + except Exception: + try: + # 兼容旧版本 Windows + ctypes.windll.user32.SetProcessDPIAware() + except Exception: + pass + +from config_loader import get_config +from step1 import Step1FeastCoding +from step2 import Step2Converter + + +def run_business_flow(project_name: str | None = None) -> int: + """运行完整的业务流程(Step1 + Step2)""" + config = get_config() + + chrome_path = config.chrome_path + step1_config = config.step1_config + + username = step1_config.get("username", "wangpeng") + password = step1_config.get("password", "Feastcoding@123") + + try: + # Step 1 + step1 = Step1FeastCoding(chrome_path, username, password) + if project_name: + step1.project_name = project_name + step1.run() + + # Step 2 + step2 = Step2Converter() + step2.run() + + print("[INFO] 完整流程执行成功!") + return 0 + except Exception as e: + print(f"[ERROR] 流程执行失败: {e}") + return 1 + + +def main() -> int: + # 解析命令行参数 + parser = argparse.ArgumentParser(description="自动化工具") + parser.add_argument("--cli", action="store_true", help="使用命令行模式(默认启动 GUI)") + parser.add_argument("--project", type=str, help="设置项目名称") + args = parser.parse_args() + + # 如果没有指定 --cli 参数,默认启动 GUI + if not args.cli: + from gui import main as gui_main + gui_main() + return 0 + + # 否则运行命令行模式 + return run_business_flow(project_name=args.project) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/markdown_editor.py b/markdown_editor.py new file mode 100644 index 0000000..777f39f --- /dev/null +++ b/markdown_editor.py @@ -0,0 +1,815 @@ +""" +微信 Markdown 编辑器模块 +支持主题、字体、代码块样式、自定义CSS等功能 +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Dict, List, Optional, Callable +from dataclasses import dataclass, asdict +from pathlib import Path + +import markdown +from markdown.extensions import fenced_code, tables, nl2br +from pygments import highlight +from pygments.lexers import get_lexer_by_name, guess_lexer +from pygments.formatters import HtmlFormatter +from pygments.styles import get_all_styles +from premailer import Premailer + + +@dataclass +class ThemeConfig: + """主题配置""" + id: str + name: str + primary_color: str = "#07C160" + background: str = "#ffffff" + text_color: str = "#333333" + link_color: str = "#576b95" + code_bg: str = "#f6f8fa" + blockquote_bg: str = "#f9f9f9" + blockquote_border: str = "#dfe2e5" + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict) -> "ThemeConfig": + return cls(**data) + + +@dataclass +class FontConfig: + """字体配置""" + body_font: str = "-apple-system-font, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', Arial, sans-serif" + heading_font: str = "" + code_font: str = "Consolas, Monaco, 'Courier New', monospace" + body_size: int = 16 + heading_scale: float = 1.3 + line_height: float = 1.8 + + def __post_init__(self): + if not self.heading_font: + self.heading_font = self.body_font + + +@dataclass +class CodeBlockConfig: + """代码块配置""" + theme: str = "default" + line_numbers: bool = False + background: str = "#f6f8fa" + border_radius: int = 6 + + # 常用代码高亮主题列表 + COMMON_THEMES = [ + "default", "emacs", "friendly", "colorful", "autumn", "murphy", "manni", + "monokai", "perldoc", "pastie", "borland", "trac", "native", "fruity", + "bw", "vim", "vs", "tango", "rrt", "xcode", "igor", "paraiso-light", + "paraiso-dark", "lovelace", "algol", "algol_nu", "arduino", "rainbow_dash", + "abap", "solarized-dark", "solarized-light", "sas", "stata", "stata-light", + "stata-dark", "inkpot", "zenburn", "gruvbox-dark", "gruvbox-light", + "dracula", "one-dark", "material", "nord", "github-dark" + ] + + @property + def available_themes(self) -> List[str]: + """获取所有可用的代码高亮主题""" + try: + all_themes = list(get_all_styles()) + return all_themes if all_themes else self.COMMON_THEMES + except Exception: + return self.COMMON_THEMES + + +@dataclass +class CssScheme: + """CSS 样式方案""" + id: str + name: str + css: str + font_config: dict = None + code_block_config: dict = None + theme_id: str = "default" # 主题 ID + primary_color: str = "#07C160" # 主题色 + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "css": self.css, + "font_config": self.font_config or {}, + "code_block_config": self.code_block_config or {}, + "theme_id": self.theme_id, + "primary_color": self.primary_color + } + + @classmethod + def from_dict(cls, data: dict) -> "CssScheme": + return cls( + id=data.get("id", ""), + name=data.get("name", ""), + css=data.get("css", ""), + font_config=data.get("font_config", {}), + code_block_config=data.get("code_block_config", {}), + theme_id=data.get("theme_id", "default"), + primary_color=data.get("primary_color", "#07C160") + ) + + +@dataclass +class EditorConfig: + """编辑器完整配置""" + version: str = "1.0.0" + theme: str = "default" + custom_themes: List[dict] = None + font: FontConfig = None + code_block: CodeBlockConfig = None + custom_css: str = "" + css_schemes: List[dict] = None # CSS 样式方案列表 + + def __post_init__(self): + if self.custom_themes is None: + self.custom_themes = [] + if self.font is None: + self.font = FontConfig() + if self.code_block is None: + self.code_block = CodeBlockConfig() + if self.css_schemes is None: + self.css_schemes = [] + + +class ThemeManager: + """主题管理器""" + + # 预设主题 + PRESET_THEMES = { + "default": ThemeConfig( + id="default", + name="默认主题", + primary_color="#07C160", + background="#ffffff", + text_color="#333333" + ), + "elegant": ThemeConfig( + id="elegant", + name="优雅主题", + primary_color="#2c3e50", + background="#fafafa", + text_color="#2c3e50" + ), + "tech": ThemeConfig( + id="tech", + name="科技主题", + primary_color="#3498db", + background="#1e1e1e", + text_color="#d4d4d4", + code_bg="#2d2d2d" + ), + "literary": ThemeConfig( + id="literary", + name="文艺主题", + primary_color="#8b4513", + background="#fefcf8", + text_color="#4a4a4a" + ), + "festive": ThemeConfig( + id="festive", + name="节日主题", + primary_color="#e74c3c", + background="#fff5f5", + text_color="#c0392b" + ) + } + + def __init__(self, config_dir: str = None): + if config_dir: + self.config_dir = config_dir + else: + # 获取程序运行目录(兼容 exe 和普通 Python 运行) + if getattr(sys, 'frozen', False): + # 如果是打包后的 exe,使用 exe 所在目录 + app_dir = os.path.dirname(sys.executable) + else: + # 如果是普通 Python 运行,使用脚本所在目录 + app_dir = os.path.dirname(os.path.abspath(__file__)) + self.config_dir = os.path.join(app_dir, "data", "editor") + + os.makedirs(self.config_dir, exist_ok=True) + self.config_file = os.path.join(self.config_dir, "editor_config.json") + print(f"[ThemeManager] 配置目录: {self.config_dir}") + print(f"[ThemeManager] 配置文件: {self.config_file}") + self.config = self._load_config() + self._custom_themes: Dict[str, ThemeConfig] = {} + self._css_schemes: Dict[str, CssScheme] = {} + self._load_custom_themes() + self._load_css_schemes() + + def _load_config(self) -> EditorConfig: + """加载配置""" + if os.path.exists(self.config_file): + try: + with open(self.config_file, 'r', encoding='utf-8') as f: + data = json.load(f) + return self._parse_config(data) + except Exception as e: + print(f"[ThemeManager] 加载配置失败: {e}") + return EditorConfig() + + def _parse_config(self, data: dict) -> EditorConfig: + """解析配置数据""" + config = EditorConfig( + version=data.get("version", "1.0.0"), + theme=data.get("theme", "default"), + custom_themes=data.get("custom_themes", []), + custom_css=data.get("custom_css", "") + ) + + # 解析字体配置 + if "font" in data: + font_data = data["font"] + config.font = FontConfig( + body_font=font_data.get("body_font", config.font.body_font), + heading_font=font_data.get("heading_font", config.font.heading_font), + code_font=font_data.get("code_font", config.font.code_font), + body_size=font_data.get("body_size", config.font.body_size), + heading_scale=font_data.get("heading_scale", config.font.heading_scale), + line_height=font_data.get("line_height", config.font.line_height) + ) + + # 解析代码块配置 + if "code_block" in data: + code_data = data["code_block"] + config.code_block = CodeBlockConfig( + theme=code_data.get("theme", config.code_block.theme), + line_numbers=code_data.get("line_numbers", config.code_block.line_numbers), + background=code_data.get("background", config.code_block.background), + border_radius=code_data.get("border_radius", config.code_block.border_radius) + ) + + # 解析 CSS 方案 + if "css_schemes" in data: + config.css_schemes = data["css_schemes"] + + return config + + def _load_custom_themes(self): + """加载自定义主题""" + for theme_data in self.config.custom_themes: + try: + theme = ThemeConfig.from_dict(theme_data) + self._custom_themes[theme.id] = theme + except Exception as e: + print(f"[ThemeManager] 加载自定义主题失败: {e}") + + def _load_css_schemes(self): + """加载 CSS 样式方案""" + for scheme_data in self.config.css_schemes: + try: + scheme = CssScheme.from_dict(scheme_data) + self._css_schemes[scheme.id] = scheme + except Exception as e: + print(f"[ThemeManager] 加载 CSS 方案失败: {e}") + + def save_config(self): + """保存配置""" + data = { + "version": self.config.version, + "theme": self.config.theme, + "custom_themes": [t.to_dict() for t in self._custom_themes.values()], + "font": { + "body_font": self.config.font.body_font, + "heading_font": self.config.font.heading_font, + "code_font": self.config.font.code_font, + "body_size": self.config.font.body_size, + "heading_scale": self.config.font.heading_scale, + "line_height": self.config.font.line_height + }, + "code_block": { + "theme": self.config.code_block.theme, + "line_numbers": self.config.code_block.line_numbers, + "background": self.config.code_block.background, + "border_radius": self.config.code_block.border_radius + }, + "custom_css": self.config.custom_css, + "css_schemes": [s.to_dict() for s in self._css_schemes.values()] + } + + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def get_all_themes(self) -> List[ThemeConfig]: + """获取所有主题(预设+自定义)""" + themes = list(self.PRESET_THEMES.values()) + themes.extend(self._custom_themes.values()) + return themes + + def get_theme(self, theme_id: str) -> Optional[ThemeConfig]: + """获取指定主题""" + if theme_id in self.PRESET_THEMES: + return self.PRESET_THEMES[theme_id] + return self._custom_themes.get(theme_id) + + def get_current_theme(self) -> ThemeConfig: + """获取当前主题""" + return self.get_theme(self.config.theme) or self.PRESET_THEMES["default"] + + def set_theme(self, theme_id: str): + """设置当前主题""" + if theme_id in self.PRESET_THEMES or theme_id in self._custom_themes: + self.config.theme = theme_id + self.save_config() + + def add_custom_theme(self, theme: ThemeConfig): + """添加自定义主题""" + self._custom_themes[theme.id] = theme + self.save_config() + + def remove_custom_theme(self, theme_id: str): + """删除自定义主题""" + if theme_id in self._custom_themes: + del self._custom_themes[theme_id] + if self.config.theme == theme_id: + self.config.theme = "default" + self.save_config() + + # ========== CSS 样式方案管理 ========== + + def get_all_css_schemes(self) -> List[CssScheme]: + """获取所有 CSS 样式方案""" + return list(self._css_schemes.values()) + + def get_css_scheme(self, scheme_id: str) -> Optional[CssScheme]: + """获取指定 CSS 方案""" + return self._css_schemes.get(scheme_id) + + def add_css_scheme(self, scheme: CssScheme): + """添加 CSS 样式方案""" + self._css_schemes[scheme.id] = scheme + self.save_config() + + def remove_css_scheme(self, scheme_id: str): + """删除 CSS 样式方案""" + if scheme_id in self._css_schemes: + del self._css_schemes[scheme_id] + self.save_config() + + def apply_css_scheme(self, scheme_id: str) -> bool: + """应用 CSS 样式方案""" + scheme = self._css_schemes.get(scheme_id) + if not scheme: + return False + + # 应用主题 + if scheme.theme_id: + self.config.theme = scheme.theme_id + + # 应用主题色(更新当前主题的主色调) + if scheme.primary_color and scheme.theme_id: + # 先从预设主题中查找,再从自定义主题中查找 + theme = self.PRESET_THEMES.get(scheme.theme_id) + if not theme: + theme = self._custom_themes.get(scheme.theme_id) + if theme: + theme.primary_color = scheme.primary_color + + # 应用 CSS + self.config.custom_css = scheme.css + + # 应用字体配置 + if scheme.font_config: + for key, value in scheme.font_config.items(): + if hasattr(self.config.font, key): + setattr(self.config.font, key, value) + + # 应用代码块配置 + if scheme.code_block_config: + for key, value in scheme.code_block_config.items(): + if hasattr(self.config.code_block, key): + setattr(self.config.code_block, key, value) + + self.save_config() + return True + + +class MarkdownToWechatConverter: + """Markdown 转微信公众号 HTML 转换器""" + + def __init__(self, theme_manager: ThemeManager = None): + self.theme_manager = theme_manager or ThemeManager() + + def convert(self, md_content: str) -> str: + """转换 Markdown 为微信公众号 HTML""" + config = self.theme_manager.config + theme = self.theme_manager.get_current_theme() + + # 1. 转换 Markdown 为 HTML + html_body = self._markdown_to_html(md_content, config.code_block.theme) + + # 2. 生成完整 HTML + html = self._generate_full_html(html_body, theme, config) + + return html + + def _markdown_to_html(self, md_content: str, code_theme: str) -> str: + """Markdown 转 HTML""" + # 配置 Markdown 扩展 + extensions = [ + 'markdown.extensions.fenced_code', + 'markdown.extensions.tables', + 'markdown.extensions.nl2br', + ] + + # 预处理:确保空行正确(Markdown 需要空行来分隔段落) + # 将多个连续换行符转换为两个换行符(段落分隔) + import re + md_content = re.sub(r'\n{3,}', '\n\n', md_content) + + # 使用 Pygments 进行代码高亮 + md = markdown.Markdown(extensions=extensions) + html = md.convert(md_content) + + # 处理代码块高亮 + html = self._highlight_code_blocks(html, code_theme) + + # 后处理:为段落之间添加额外的空行 + # 在段落之间插入空行(使用

 


) + # 将

替换为

 

来添加空行 + html = re.sub(r'

\s*

', '

 

', html) + + return html + + def _highlight_code_blocks(self, html: str, theme: str) -> str: + """高亮代码块""" + import re + from pygments.styles import get_style_by_name + + # 验证主题是否可用,如果不可用则使用默认主题 + try: + get_style_by_name(theme) + except: + theme = "default" + + # 查找代码块 + pattern = r'

([^<]*)
' + + def replace_code_block(match): + lang = match.group(1) or 'text' + code = match.group(2) + + # 解码 HTML 实体 + code = code.replace('<', '<').replace('>', '>').replace('&', '&') + + try: + lexer = get_lexer_by_name(lang) + except: + lexer = guess_lexer(code) + + try: + formatter = HtmlFormatter( + style=theme, + noclasses=True, + cssclass='highlight' + ) + highlighted = highlight(code, lexer, formatter) + except Exception: + # 如果高亮失败,返回原始代码 + highlighted = f'
{code}
' + + return f'
{highlighted}
' + + return re.sub(pattern, replace_code_block, html, flags=re.DOTALL) + + def _generate_full_html(self, html_body: str, theme: ThemeConfig, config: EditorConfig) -> str: + """生成完整 HTML 文档""" + + # 基础样式 + base_css = f""" + + """ + + full_html = f""" + + + + + 微信公众号文章 + {base_css} + + +
+ {html_body} +
+ +""" + + # 使用 premailer 将 CSS 内联到 HTML 元素中(微信公众号需要内联样式) + try: + premailer = Premailer(full_html, remove_classes=False) + inlined_html = premailer.transform() + return inlined_html + except Exception as e: + print(f"[MarkdownToWechatConverter] CSS 内联失败: {e}") + return full_html + + def export_css(self) -> str: + """导出当前主题 CSS""" + theme = self.theme_manager.get_current_theme() + config = self.theme_manager.config + + css = f"""/* 微信公众号文章样式 - {theme.name} */ +.wechat-article {{ + max-width: 677px; + margin: 0 auto; + padding: 20px; + font-family: {config.font.body_font}; + font-size: {config.font.body_size}px; + line-height: {config.font.line_height}; + color: {theme.text_color}; + background: {theme.background}; +}} + +.wechat-article h1 {{ + font-size: {config.font.body_size * config.font.heading_scale * 1.5:.0f}px; + font-weight: bold; + margin: 1.5em 0 0.8em; + padding-bottom: 0.3em; + border-bottom: 2px solid {theme.primary_color}; + color: {theme.primary_color}; +}} + +.wechat-article h2 {{ + font-size: {config.font.body_size * config.font.heading_scale * 1.3:.0f}px; + font-weight: bold; + margin: 1.3em 0 0.6em; + padding-left: 12px; + border-left: 4px solid {theme.primary_color}; +}} + +.wechat-article h3 {{ + font-size: {config.font.body_size * config.font.heading_scale * 1.1:.0f}px; + font-weight: bold; + margin: 1em 0 0.5em; + color: {theme.primary_color}; +}} + +.wechat-article p {{ + margin: 1em 0; + text-align: justify; +}} + +.wechat-article a {{ + color: {theme.link_color}; + text-decoration: none; +}} + +.wechat-article img {{ + max-width: 100%; + height: auto; + display: block; + margin: 1em auto; +}} + +.wechat-article blockquote {{ + margin: 1em 0; + padding: 12px 16px; + background: {theme.blockquote_bg}; + border-left: 4px solid {theme.blockquote_border}; + color: #6a737d; +}} + +.wechat-article ul, .wechat-article ol {{ + margin: 1em 0; + padding-left: 2em; +}} + +.wechat-article li {{ + margin: 0.3em 0; +}} + +.wechat-article code {{ + font-family: {config.font.code_font}; + background: {theme.code_bg}; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.9em; +}} + +.wechat-article pre {{ + font-family: {config.font.code_font}; + background: {config.code_block.background}; + padding: 16px; + border-radius: {config.code_block.border_radius}px; + overflow-x: auto; + margin: 1em 0; +}} + +.wechat-article pre code {{ + background: transparent; + padding: 0; +}} + +.wechat-article table {{ + width: 100%; + border-collapse: collapse; + margin: 1em 0; +}} + +.wechat-article th, .wechat-article td {{ + border: 1px solid #dfe2e5; + padding: 8px 12px; + text-align: left; +}} + +.wechat-article th {{ + background: #f6f8fa; + font-weight: bold; +}} + +.wechat-article tr:nth-child(even) {{ + background: #fafafa; +}} +""" + return css + + +class WechatMarkdownEditor: + """微信 Markdown 编辑器主类""" + + def __init__(self): + self.theme_manager = ThemeManager() + self.converter = MarkdownToWechatConverter(self.theme_manager) + + def render(self, md_content: str) -> str: + """渲染 Markdown 为 HTML""" + return self.converter.convert(md_content) + + def get_config(self) -> EditorConfig: + """获取编辑器配置""" + return self.theme_manager.config + + def save_config(self): + """保存配置""" + self.theme_manager.save_config() + + def update_font_config(self, **kwargs): + """更新字体配置""" + for key, value in kwargs.items(): + if hasattr(self.theme_manager.config.font, key): + setattr(self.theme_manager.config.font, key, value) + self.save_config() + + def update_code_block_config(self, **kwargs): + """更新代码块配置""" + for key, value in kwargs.items(): + if hasattr(self.theme_manager.config.code_block, key): + setattr(self.theme_manager.config.code_block, key, value) + self.save_config() + + def update_custom_css(self, css: str): + """更新自定义 CSS""" + self.theme_manager.config.custom_css = css + self.save_config() + + def export_html(self, md_content: str, output_path: str): + """导出 HTML 文件""" + html = self.render(md_content) + with open(output_path, 'w', encoding='utf-8') as f: + f.write(html) + + def export_css(self, output_path: str): + """导出 CSS 文件""" + css = self.converter.export_css() + with open(output_path, 'w', encoding='utf-8') as f: + f.write(css) + + +# 便捷函数 +def create_editor() -> WechatMarkdownEditor: + """创建编辑器实例""" + return WechatMarkdownEditor() + + +def markdown_to_wechat(md_content: str, theme_id: str = "default") -> str: + """快速转换 Markdown 为微信公众号 HTML""" + editor = create_editor() + editor.theme_manager.set_theme(theme_id) + return editor.render(md_content) diff --git a/md_to_wechat.py b/md_to_wechat.py new file mode 100644 index 0000000..59a57e2 --- /dev/null +++ b/md_to_wechat.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import re +import markdown +from markdown.extensions.codehilite import CodeHiliteExtension +from markdown.extensions.fenced_code import FencedCodeExtension +from premailer import transform + + +def convert_markdown_to_wechat(md_content: str) -> tuple[str, str]: + """ + 将 Markdown 文本转换为微信公众号 HTML + + Args: + md_content: Markdown 格式的文本 + + Returns: + (标题, HTML内容) 元组 + """ + if not md_content or not md_content.strip(): + return "", "

" + + # 提取标题 + title = extract_title(md_content) + + # 1. 定义微信风格的基础 CSS 样式 + # 微信对这些属性支持较好:color, font-size, margin, padding, line-height + custom_css = """ + .wechat-body { + font-family: -apple-system-font, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif; + font-size: 16px; + color: #353535; + line-height: 1.75; + padding: 10px; + } + h1 { + font-size: 24px; + color: #007aff; + border-bottom: 2px solid #007aff; + padding-bottom: 10px; + margin-top: 30px; + margin-bottom: 15px; + } + h2 { + font-size: 20px; + color: #007aff; + margin-top: 25px; + margin-bottom: 10px; + border-left: 4px solid #007aff; + padding-left: 10px; + } + h3 { + font-size: 18px; + color: #007aff; + margin-top: 20px; + margin-bottom: 10px; + } + h4 { + font-size: 16px; + font-weight: bold; + color: #007aff; + margin-top: 15px; + margin-bottom: 8px; + } + h5 { + font-size: 14px; + font-weight: bold; + color: #007aff; + margin-top: 12px; + margin-bottom: 6px; + } + p { + margin: 15px 0; + text-align: justify; + } + code { + background-color: #f8f8f8; + color: #ff502c; + padding: 2px 4px; + border-radius: 3px; + font-family: Consolas, Monaco, 'Andale Mono', monospace; + font-size: 14px; + } + pre { + background-color: #282c34; + color: #abb2bf; + padding: 15px; + border-radius: 5px; + overflow-x: auto; + line-height: 1.4; + font-family: Consolas, Monaco, 'Andale Mono', monospace; + font-size: 13px; + } + pre code { + background-color: transparent; + color: inherit; + padding: 0; + border-radius: 0; + font-size: inherit; + } + ul, ol { + padding-left: 30px; + color: #555; + margin: 15px 0; + } + li { + margin: 8px 0; + } + blockquote { + border-left: 4px solid #007aff; + color: #666; + padding-left: 15px; + margin: 20px 0; + background-color: #f8f9fa; + font-style: italic; + } + img { + max-width: 100%; + border-radius: 4px; + display: block; + margin: 20px auto; + } + a { + color: #007aff; + text-decoration: none; + } + table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; + font-size: 14px; + } + th, td { + border: 1px solid #ddd; + padding: 10px; + text-align: left; + } + th { + background-color: #f8f9fa; + font-weight: bold; + } + hr { + border: none; + border-top: 1px solid #e0e0e0; + margin: 30px 0; + } + """ + + # 2. 将 Markdown 转为 HTML + # 使用 fenced_code 处理代码块,codehilite 处理高亮 + html_body = markdown.markdown(md_content, extensions=[ + FencedCodeExtension(), + CodeHiliteExtension(css_class='highlight', linenums=False, guess_lang=False), + 'tables', + 'nl2br' + ]) + + # 3. 包装在外层容器中 + full_html = f""" + + + + + + +
+ {html_body} +
+ +""" + + # 4. 关键步骤:使用 premailer 将 CSS 内联化 + # 它会扫描