init
This commit is contained in:
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -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
|
||||
64
README.md
Normal file
64
README.md
Normal file
@@ -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
|
||||
```
|
||||
|
||||
BIN
__pycache__/baidu_playwright.cpython-311.pyc
Normal file
BIN
__pycache__/baidu_playwright.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/config_loader.cpython-311.pyc
Normal file
BIN
__pycache__/config_loader.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/db_manager.cpython-311.pyc
Normal file
BIN
__pycache__/db_manager.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/editor_gui.cpython-311.pyc
Normal file
BIN
__pycache__/editor_gui.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/gui.cpython-311.pyc
Normal file
BIN
__pycache__/gui.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/image_uploader.cpython-311.pyc
Normal file
BIN
__pycache__/image_uploader.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/main.cpython-311.pyc
Normal file
BIN
__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/markdown_editor.cpython-311.pyc
Normal file
BIN
__pycache__/markdown_editor.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/md_to_wechat.cpython-311.pyc
Normal file
BIN
__pycache__/md_to_wechat.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/step1.cpython-311.pyc
Normal file
BIN
__pycache__/step1.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/step2.cpython-311.pyc
Normal file
BIN
__pycache__/step2.cpython-311.pyc
Normal file
Binary file not shown.
BIN
__pycache__/wechat_publisher.cpython-311.pyc
Normal file
BIN
__pycache__/wechat_publisher.cpython-311.pyc
Normal file
Binary file not shown.
1
assets/.keep
Normal file
1
assets/.keep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
assets/img/bg.jpg
Normal file
BIN
assets/img/bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
20
config.yaml
Normal file
20
config.yaml
Normal file
@@ -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/"
|
||||
1
config/.keep
Normal file
1
config/.keep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
179
config_loader.py
Normal file
179
config_loader.py
Normal file
@@ -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')}")
|
||||
1
core/__init__.py
Normal file
1
core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/actions.cpython-311.pyc
Normal file
BIN
core/__pycache__/actions.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/ocr_client.cpython-311.pyc
Normal file
BIN
core/__pycache__/ocr_client.cpython-311.pyc
Normal file
Binary file not shown.
87
core/actions.py
Normal file
87
core/actions.py
Normal file
@@ -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}")
|
||||
|
||||
28
core/browser.py
Normal file
28
core/browser.py
Normal file
@@ -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
|
||||
|
||||
110
core/ocr_client.py
Normal file
110
core/ocr_client.py
Normal file
@@ -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])
|
||||
|
||||
1
data/.keep
Normal file
1
data/.keep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
2
data/html/wechat.html
Normal file
2
data/html/wechat.html
Normal file
File diff suppressed because one or more lines are too long
BIN
data/screenshots/2026-03-26/baidu_search_java教程.png
Normal file
BIN
data/screenshots/2026-03-26/baidu_search_java教程.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
data/screenshots/2026-03-26/error_state.png
Normal file
BIN
data/screenshots/2026-03-26/error_state.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
283
db_manager.py
Normal file
283
db_manager.py
Normal file
@@ -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
|
||||
82
docs/baidu.md
Normal file
82
docs/baidu.md
Normal file
@@ -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
|
||||
```
|
||||
88
docs/md转公众号.md
Normal file
88
docs/md转公众号.md
Normal file
@@ -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 = """
|
||||
<style>
|
||||
.rich_media_content { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
h2 { border-bottom: 2px solid #007aff; padding-bottom: 5px; margin-top: 30px; color: #007aff; }
|
||||
h3 { color: #007aff; margin-top: 20px; }
|
||||
p { margin: 15px 0; }
|
||||
blockquote { border-left: 4px solid #ddd; padding-left: 15px; color: #666; font-style: italic; margin: 20px 0; }
|
||||
code { background-color: #f8f8f8; color: #c7254e; padding: 2px 4px; border-radius: 3px; }
|
||||
pre { background-color: #282c34; color: #abb2bf; padding: 15px; border-radius: 5px; overflow-x: auto; }
|
||||
img { max-width: 100%; display: block; margin: 20px auto; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
# 2. 将 Markdown 转为 HTML(启用表格、代码高亮等常用扩展)
|
||||
html_content = markdown.markdown(
|
||||
md_text,
|
||||
extensions=['extra', 'codehilite', 'toc']
|
||||
)
|
||||
|
||||
# 3. 组合成完整的 HTML 结构
|
||||
full_html = f"""
|
||||
<html>
|
||||
<body>
|
||||
<div class="rich_media_content">
|
||||
{css}
|
||||
{html_content}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# 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` 变量部分吗?
|
||||
78
docs/md转公众号优化.md
Normal file
78
docs/md转公众号优化.md
Normal file
@@ -0,0 +1,78 @@
|
||||
看到你提供的 HTML 结构了。之所以在公众号后台不显示图片,是因为微信的编辑器有一套非常特殊的**图片延迟加载机制**。
|
||||
|
||||
你代码中的图片标签是这样的:
|
||||
`<img src="http://..." data-src="http://..." alt="">`
|
||||
|
||||
**问题核心在于:**
|
||||
1. **协议问题**:你的七牛云链接是 `http`。现在微信后台基本强制要求 `https`,否则会拦截或显示破损图标。
|
||||
2. **关键属性缺失**:微信除了 `data-src`,通常还需要一个 `data-type`(指定图片格式,如 png/jpeg)或者 `style` 必须包含 `width: 100%` 以防图片被压成 0 像素。
|
||||
3. **双层嵌套限制**:你提供的代码里有两个重复的 `<div class="rich_media_content">` 容器层级,这可能会导致微信后台解析 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 = """
|
||||
<style>
|
||||
.rich_media_content { font-size: 16px; line-height: 1.75; color: #333; }
|
||||
h1 { font-size: 24px; font-weight: bold; color: #007aff; border-bottom: 2px solid #007aff; padding-bottom: 10px; margin: 1.5em 0 0.5em; }
|
||||
h4 { font-size: 16px; font-weight: bold; border-left: 4px solid #007aff; padding-left: 10px; margin: 1em 0; }
|
||||
blockquote { background: #f8f9fa; border-left: 4px solid #007aff; padding: 15px; color: #666; }
|
||||
pre { background: #2b2b2b; color: #f8f8f2; padding: 15px; border-radius: 6px; overflow-x: auto; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
full_html = f'<div class="rich_media_content">{css}{html}</div>'
|
||||
|
||||
# 修正图片并转换行内样式
|
||||
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` 加入白名单。
|
||||
81
docs/plan.md
Normal file
81
docs/plan.md
Normal file
@@ -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()
|
||||
```
|
||||
|
||||
---
|
||||
7
docs/step1.md
Normal file
7
docs/step1.md
Normal file
@@ -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按钮。
|
||||
3
docs/step2.md
Normal file
3
docs/step2.md
Normal file
@@ -0,0 +1,3 @@
|
||||
1.获取剪切板内容
|
||||
2.将剪切板内容转换为公众号文章格式,调用工具类md_to_wechat.py
|
||||
3.推送到公众号草稿箱
|
||||
0
docs/step3.md
Normal file
0
docs/step3.md
Normal file
84
docs/公众号.md
Normal file
84
docs/公众号.md
Normal file
@@ -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` | 写入草稿箱 |
|
||||
231
docs/编辑器.md
Normal file
231
docs/编辑器.md
Normal file
@@ -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:
|
||||
664
editor_gui.py
Normal file
664
editor_gui.py
Normal file
@@ -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("<KeyRelease>", 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("<Configure>", lambda e: settings_canvas.configure(scrollregion=settings_canvas.bbox("all")))
|
||||
settings_canvas.bind("<Configure>", 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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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()
|
||||
768
gui.py
Normal file
768
gui.py
Normal file
@@ -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("<Button-3>", 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(
|
||||
"<Configure>",
|
||||
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()
|
||||
256
image_uploader.py
Normal file
256
image_uploader.py
Normal file
@@ -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'<img[^>]+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 = """
|
||||
<div>
|
||||
<img src="http://img.yidaima.cn/test.png" alt="测试">
|
||||
<img src="http://mmbiz.qpic.cn/some/path" alt="微信图片">
|
||||
<img src="./local.png" alt="本地图片">
|
||||
</div>
|
||||
"""
|
||||
|
||||
result = process_html_images(test_html, access_token, external_domain="yidaima.cn")
|
||||
print("\n处理后的 HTML:")
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
74
main.py
Normal file
74
main.py
Normal file
@@ -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())
|
||||
815
markdown_editor.py
Normal file
815
markdown_editor.py
Normal file
@@ -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)
|
||||
|
||||
# 后处理:为段落之间添加额外的空行
|
||||
# 在段落之间插入空行(使用 <p> </p> 或 <br>)
|
||||
# 将 </p><p> 替换为 </p><p> </p><p> 来添加空行
|
||||
html = re.sub(r'</p>\s*<p>', '</p><p> </p><p>', 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'<pre><code(?:\s+class="language-([^"]*)")?>([^<]*)</code></pre>'
|
||||
|
||||
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'<pre><code>{code}</code></pre>'
|
||||
|
||||
return f'<div class="code-block-wrapper">{highlighted}</div>'
|
||||
|
||||
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"""
|
||||
<style>
|
||||
.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;
|
||||
}}
|
||||
|
||||
{config.custom_css}
|
||||
</style>
|
||||
"""
|
||||
|
||||
full_html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>微信公众号文章</title>
|
||||
{base_css}
|
||||
</head>
|
||||
<body>
|
||||
<div class="wechat-article">
|
||||
{html_body}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# 使用 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)
|
||||
292
md_to_wechat.py
Normal file
292
md_to_wechat.py
Normal file
@@ -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 "", "<p></p>"
|
||||
|
||||
# 提取标题
|
||||
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"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>{custom_css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wechat-body">
|
||||
{html_body}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# 4. 关键步骤:使用 premailer 将 CSS 内联化
|
||||
# 它会扫描 <style> 里的选择器,然后转换成 <p style="margin: 15px 0; ...">
|
||||
inline_html = transform(full_html)
|
||||
|
||||
# 5. 处理图片标签,优化微信兼容性
|
||||
inline_html = process_images(inline_html)
|
||||
|
||||
# 6. 清理多余的空行和换行
|
||||
inline_html = re.sub(r'\n\s*\n+', '\n', inline_html)
|
||||
inline_html = re.sub(r'>\s*\n\s*<', '><', inline_html)
|
||||
|
||||
return title, inline_html
|
||||
|
||||
|
||||
def extract_title(md_text: str) -> str:
|
||||
"""从 Markdown 文本中提取标题"""
|
||||
# 尝试提取一级标题
|
||||
h1_match = re.search(r'^# (.+)$', md_text, re.MULTILINE)
|
||||
if h1_match:
|
||||
return h1_match.group(1).strip()
|
||||
|
||||
# 尝试提取二级标题
|
||||
h2_match = re.search(r'^## (.+)$', md_text, re.MULTILINE)
|
||||
if h2_match:
|
||||
return h2_match.group(1).strip()
|
||||
|
||||
# 尝试提取三级标题
|
||||
h3_match = re.search(r'^### (.+)$', md_text, re.MULTILINE)
|
||||
if h3_match:
|
||||
return h3_match.group(1).strip()
|
||||
|
||||
# 默认标题
|
||||
return "公众号文章"
|
||||
|
||||
|
||||
def process_images(html: str) -> str:
|
||||
"""处理图片标签,优化微信兼容性"""
|
||||
def fix_img_tag(match):
|
||||
img_tag = match.group(0)
|
||||
|
||||
# 提取 src 属性
|
||||
src_match = re.search(r'src="([^"]+)"', img_tag)
|
||||
if src_match:
|
||||
src = src_match.group(1)
|
||||
|
||||
# 1. 强制将 http 转换为 https
|
||||
if src.startswith('http://'):
|
||||
src = src.replace('http://', 'https://')
|
||||
|
||||
# 2. 补全微信必须的属性
|
||||
img_tag = img_tag.replace(f'src="{src_match.group(1)}"', f'src="{src}"')
|
||||
|
||||
# 3. 添加 data-src(如果没有)
|
||||
if 'data-src=' not in img_tag:
|
||||
img_tag = img_tag.replace(f'src="{src}"', f'src="{src}" data-src="{src}"')
|
||||
|
||||
# 4. 强制给图片加行内样式,防止塌陷
|
||||
img_tag = re.sub(r'\s*style="[^"]*"', '', img_tag)
|
||||
img_tag = img_tag.replace('>', ' style="display: block; margin: 20px auto; width: 100% !important; height: auto !important; visibility: visible !important;" data-type="png">')
|
||||
|
||||
# 5. 移除可能导致冲突的 class
|
||||
img_tag = re.sub(r'\s*class="[^"]*"', '', img_tag)
|
||||
|
||||
return img_tag
|
||||
|
||||
# 匹配所有 img 标签
|
||||
return re.sub(r'<img[^>]+>', fix_img_tag, html)
|
||||
|
||||
|
||||
def main():
|
||||
"""测试转换功能"""
|
||||
test_md = """
|
||||
# 这是一个标题
|
||||
|
||||
这是一段正文,包含 **粗体** 和 *斜体* 文字。
|
||||
|
||||
## 二级标题
|
||||
|
||||
> 这是一个引言块,用于强调重要内容。
|
||||
|
||||
### 列表示例
|
||||
|
||||
- 第一项
|
||||
- 第二项
|
||||
- 第三项
|
||||
|
||||
#### 四级标题
|
||||
|
||||
### 代码示例
|
||||
|
||||
```python
|
||||
print("Hello WeChat!")
|
||||
```
|
||||
|
||||
行内代码:`print("hello")`
|
||||
|
||||
### 链接和图片
|
||||
|
||||
[访问百度](https://www.baidu.com)
|
||||
|
||||
---
|
||||
|
||||
**注意**:以上内容仅供测试使用。
|
||||
"""
|
||||
|
||||
title, html = convert_markdown_to_wechat(test_md)
|
||||
|
||||
print(f"标题: {title}")
|
||||
print(f"HTML 长度: {len(html)} 字符")
|
||||
print("\nHTML 预览:")
|
||||
print(html[:800] + "..." if len(html) > 800 else html)
|
||||
|
||||
# 保存到文件
|
||||
output_file = "output_wechat.html"
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"\n已保存到: {output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
requests>=2.31.0
|
||||
PyAutoGUI>=0.9.54
|
||||
PyYAML>=6.0.1
|
||||
mss>=9.0.1
|
||||
playwright>=1.42.0
|
||||
210
step1.py
Normal file
210
step1.py
Normal file
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, Callable
|
||||
|
||||
from config_loader import get_config
|
||||
|
||||
|
||||
def _now_datestr() -> str:
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _safe_screenshot(page, save_path: str) -> None:
|
||||
try:
|
||||
page.screenshot(path=save_path, full_page=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Step1FeastCoding:
|
||||
"""
|
||||
Step 1: 访问 feast.yidaima.cn,登录并点击 FeastCoding 按钮
|
||||
参考: docs/step1.md
|
||||
"""
|
||||
|
||||
def __init__(self, chrome_path: str, username: str, password: str, log_callback: Optional[Callable[[str], None]] = None):
|
||||
self.chrome_path = chrome_path
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.url = "https://feast.yidaima.cn/login"
|
||||
# self.url = "http://localhost/login"
|
||||
self.project_name = "【A173】基于Springboot + vue3实现的学生交流互助平台"
|
||||
self.feast_button = "FeastCoding"
|
||||
self.browser = None
|
||||
self.page = None
|
||||
self.log_callback = log_callback
|
||||
|
||||
def _log(self, message: str):
|
||||
"""输出日志,支持回调到 GUI"""
|
||||
print(message)
|
||||
if self.log_callback:
|
||||
self.log_callback(message)
|
||||
|
||||
def run(self) -> bool:
|
||||
"""执行 Step 1 流程,成功返回 True"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
save_dir = os.path.join(".", "data", "screenshots", _now_datestr())
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
err_path = os.path.join(save_dir, "step1_error.png")
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
self.browser = p.chromium.launch(headless=False, executable_path=self.chrome_path)
|
||||
context = self.browser.new_context(viewport={"width": 1280, "height": 800})
|
||||
self.page = context.new_page()
|
||||
|
||||
# 1) 登录页
|
||||
self._log("[Step1] 正在打开登录页...")
|
||||
self.page.goto(self.url, wait_until="networkidle")
|
||||
self.page.wait_for_timeout(2000)
|
||||
|
||||
# 等待输入框出现
|
||||
self.page.wait_for_selector("input[type='text'], input[name*='user' i], input[placeholder*='用' i]", timeout=15000)
|
||||
|
||||
# 查找用户名和密码输入框
|
||||
user_candidates = [
|
||||
self.page.locator("input[type='text']").first,
|
||||
self.page.locator("input[name*='user' i]").first,
|
||||
self.page.locator("input[name*='username' i]").first,
|
||||
self.page.locator("input[placeholder*='用' i]").first,
|
||||
self.page.get_by_placeholder(re.compile(r"用户名|账号|登录名", re.I)).first,
|
||||
]
|
||||
pass_candidates = [
|
||||
self.page.locator("input[type='password']").first,
|
||||
self.page.locator("input[name*='pass' i]").first,
|
||||
self.page.locator("input[placeholder*='密' i]").first,
|
||||
self.page.get_by_placeholder(re.compile(r"密码", re.I)).first,
|
||||
]
|
||||
|
||||
user_loc = next((c for c in user_candidates if c.count() > 0 and c.is_visible()), None)
|
||||
pass_loc = next((c for c in pass_candidates if c.count() > 0 and c.is_visible()), None)
|
||||
if user_loc is None or pass_loc is None:
|
||||
raise RuntimeError("未能定位到用户名/密码输入框。")
|
||||
|
||||
# 输入用户名密码
|
||||
self._log("[Step1] 正在输入用户名密码...")
|
||||
user_loc.click(timeout=5000, force=True)
|
||||
user_loc.fill(self.username)
|
||||
pass_loc.click(timeout=5000, force=True)
|
||||
pass_loc.fill(self.password)
|
||||
|
||||
# 登录按钮
|
||||
login_btn = self.page.get_by_role("button", name=re.compile(r"(登录|Log in|Sign in|立即登录)", re.I)).first
|
||||
try:
|
||||
if login_btn.count() > 0:
|
||||
login_btn.click(timeout=10000, force=True)
|
||||
else:
|
||||
self.page.locator("button[type='submit']").first.click(timeout=10000, force=True)
|
||||
except Exception:
|
||||
self.page.keyboard.press("Enter")
|
||||
|
||||
# 等待菜单
|
||||
self._log("[Step1] 等待登录完成...")
|
||||
self.page.wait_for_selector("text=工作室运营", timeout=30000)
|
||||
time.sleep(1)
|
||||
|
||||
# 2) 进入源码管理
|
||||
self._log("[Step1] 进入源码管理...")
|
||||
self.page.get_by_text("工作室运营", exact=False).first.click(timeout=10000, force=True)
|
||||
time.sleep(0.8)
|
||||
self.page.get_by_text("源码管理", exact=False).first.click(timeout=10000, force=True)
|
||||
time.sleep(1)
|
||||
|
||||
# 3) 搜索项目
|
||||
self._log("[Step1] 搜索项目...")
|
||||
self.page.wait_for_timeout(1500)
|
||||
|
||||
search_input_candidates = [
|
||||
self.page.locator("input[type='search']").first,
|
||||
self.page.locator("input[placeholder*='搜索' i]").first,
|
||||
self.page.get_by_placeholder("搜索").first,
|
||||
self.page.locator(".el-input__inner").filter(has_text=re.compile(r"搜索", re.I)).first,
|
||||
]
|
||||
search_input = next((c for c in search_input_candidates if c.count() > 0 and c.is_visible()), None)
|
||||
if search_input is None:
|
||||
all_inputs = self.page.locator("input[type='text']").all()
|
||||
for inp in all_inputs:
|
||||
if inp.is_visible():
|
||||
search_input = inp
|
||||
break
|
||||
|
||||
if search_input is None:
|
||||
raise RuntimeError("未能定位到搜索输入框。")
|
||||
|
||||
search_input.click(timeout=5000, force=True)
|
||||
search_input.fill(self.project_name)
|
||||
search_input.press("Enter")
|
||||
|
||||
# 从 project_name 提取项目编号(前6个字符)
|
||||
project_code = self.project_name[:6] if len(self.project_name) >= 6 else self.project_name
|
||||
|
||||
# 等待列表渲染
|
||||
self.page.wait_for_selector(f"text={project_code}", timeout=20000)
|
||||
self.page.wait_for_timeout(1000)
|
||||
|
||||
# 查找项目行
|
||||
row = self.page.locator("tr", has_text=self.project_name).first
|
||||
if row.count() == 0:
|
||||
row = self.page.locator("tr", has_text=project_code).first
|
||||
|
||||
# 点击"查看"按钮
|
||||
view_btn = row.get_by_role("button", name=re.compile(r"(查看|View)", re.I)).first
|
||||
if view_btn.count() == 0 or not view_btn.is_visible():
|
||||
raise RuntimeError("未能定位到'查看'按钮。")
|
||||
|
||||
view_btn.click(timeout=10000)
|
||||
|
||||
# 4) 滚动到底并点击 FeastCoding
|
||||
self._log("[Step1] 查找 FeastCoding 按钮...")
|
||||
self.page.wait_for_timeout(1500)
|
||||
|
||||
feast_loc = self.page.get_by_text(self.feast_button, exact=False).first
|
||||
feast_loc.wait_for(state="visible", timeout=15000)
|
||||
self._log("[Step1] 已找到 FeastCoding 按钮")
|
||||
|
||||
feast_loc.scroll_into_view_if_needed(timeout=10000)
|
||||
self.page.wait_for_timeout(800)
|
||||
|
||||
self._log("[Step1] 点击 FeastCoding 按钮...")
|
||||
feast_loc.click(timeout=10000)
|
||||
self.page.wait_for_timeout(3000)
|
||||
self._log("[Step1] 已完成,内容已复制到剪贴板")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
if self.page is not None:
|
||||
_safe_screenshot(self.page, err_path)
|
||||
raise e
|
||||
finally:
|
||||
if self.browser is not None:
|
||||
try:
|
||||
self.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
"""单独运行 Step1"""
|
||||
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")
|
||||
|
||||
step1 = Step1FeastCoding(chrome_path, username, password)
|
||||
try:
|
||||
step1.run()
|
||||
print("[INFO] Step1 执行成功!")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Step1 执行失败: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
178
step2.py
Normal file
178
step2.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Optional, Callable
|
||||
|
||||
from config_loader import get_config
|
||||
from image_uploader import get_wechat_access_token, process_html_images
|
||||
from md_to_wechat import convert_markdown_to_wechat
|
||||
from wechat_publisher import publish_to_wechat
|
||||
from markdown_editor import WechatMarkdownEditor, ThemeManager
|
||||
|
||||
|
||||
def _now_datestr() -> str:
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def get_clipboard_text() -> str:
|
||||
"""获取剪贴板文本内容"""
|
||||
result = subprocess.run(
|
||||
["powershell", "-command", "Get-Clipboard"],
|
||||
capture_output=True
|
||||
)
|
||||
try:
|
||||
return result.stdout.decode('utf-8').strip()
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
return result.stdout.decode('gbk').strip()
|
||||
except UnicodeDecodeError:
|
||||
return result.stdout.decode('gb2312', errors='ignore').strip()
|
||||
|
||||
|
||||
class Step2Converter:
|
||||
"""
|
||||
Step 2: 获取剪贴板内容,转换为公众号格式,推送到草稿箱
|
||||
参考: docs/step2.md
|
||||
"""
|
||||
|
||||
def __init__(self, log_callback: Optional[Callable[[str], None]] = None, css_scheme_id: Optional[str] = None):
|
||||
"""
|
||||
初始化 Step2Converter
|
||||
|
||||
Args:
|
||||
log_callback: 日志回调函数,用于将日志输出到 GUI
|
||||
css_scheme_id: CSS 样式方案 ID,可选
|
||||
"""
|
||||
self.log_callback = log_callback
|
||||
self.css_scheme_id = css_scheme_id
|
||||
self.theme_manager = ThemeManager()
|
||||
|
||||
def _log(self, message: str):
|
||||
"""输出日志,支持回调到 GUI"""
|
||||
print(message)
|
||||
if self.log_callback:
|
||||
self.log_callback(message)
|
||||
|
||||
def run(self) -> bool:
|
||||
"""执行 Step 2 流程,成功返回 True"""
|
||||
|
||||
# 1) 获取剪贴板内容
|
||||
self._log("[Step2] 正在获取剪贴板内容...")
|
||||
md_content = get_clipboard_text()
|
||||
|
||||
if not md_content:
|
||||
self._log("[Step2] 警告:剪贴板为空,无法继续")
|
||||
return False
|
||||
|
||||
self._log(f"[Step2] 获取到 Markdown 内容,长度: {len(md_content)} 字符")
|
||||
|
||||
# 2) 转换为公众号文章格式
|
||||
self._log("[Step2] 正在转换为公众号 HTML 格式...")
|
||||
|
||||
# 如果指定了 CSS 方案,先应用
|
||||
if self.css_scheme_id:
|
||||
scheme = self.theme_manager.get_css_scheme(self.css_scheme_id)
|
||||
if scheme:
|
||||
self._log(f"[Step2] 应用 CSS 方案: {scheme.name}")
|
||||
self.theme_manager.apply_css_scheme(self.css_scheme_id)
|
||||
else:
|
||||
self._log(f"[Step2] 警告:CSS 方案 {self.css_scheme_id} 不存在,使用默认设置")
|
||||
|
||||
# 使用 Markdown 编辑器进行转换(与编辑器中的方式一致)
|
||||
# 创建编辑器实例,它会自动加载当前配置(包括已应用的 CSS 方案)
|
||||
editor = WechatMarkdownEditor()
|
||||
html_content = editor.render(md_content)
|
||||
|
||||
# 提取标题(从 HTML 内容中提取 h1 标签)
|
||||
import re
|
||||
title_match = re.search(r'<h1[^>]*>(.*?)</h1>', html_content)
|
||||
title = title_match.group(1) if title_match else "无标题"
|
||||
|
||||
html_content = re.sub(r'\n\s*\n+', '\n', html_content)
|
||||
html_content = re.sub(r'>\s*\n\s*<', '><', html_content)
|
||||
|
||||
self._log(f"[Step2] 转换完成,标题: {title}")
|
||||
self._log(f"[Step2] HTML 内容长度: {len(html_content)} 字符")
|
||||
|
||||
# 3) 处理图片 - 将外部图片上传到微信并替换 URL
|
||||
cover_image_url: Optional[str] = None
|
||||
config = get_config()
|
||||
if config.wechat_appid and config.wechat_appsecret:
|
||||
self._log("[Step2] 正在处理图片,将外部图片上传到微信服务器...")
|
||||
access_token = get_wechat_access_token(config.wechat_appid, config.wechat_appsecret)
|
||||
if access_token:
|
||||
html_content, cover_image_url = process_html_images(html_content, access_token, external_domain="yidaima.cn")
|
||||
self._log("[Step2] 图片处理完成")
|
||||
if cover_image_url:
|
||||
self._log(f"[Step2] 封面图: {cover_image_url}")
|
||||
else:
|
||||
self._log("[Step2] 警告:无法获取微信 access_token,跳过图片上传")
|
||||
|
||||
# 4) 保存 HTML 到文件
|
||||
html_dir = os.path.join(".", "data", "html")
|
||||
os.makedirs(html_dir, exist_ok=True)
|
||||
html_file = os.path.join(html_dir, "wechat.html")
|
||||
with open(html_file, "w", encoding="utf-8") as f:
|
||||
f.write(html_content)
|
||||
self._log(f"[Step2] HTML 已保存到: {html_file}")
|
||||
|
||||
# 5) 推送到公众号草稿箱
|
||||
self._log("[Step2] 准备推送到公众号草稿箱...")
|
||||
wechat_appid = config.wechat_appid
|
||||
wechat_appsecret = config.wechat_appsecret
|
||||
|
||||
if wechat_appid and wechat_appsecret:
|
||||
self._log("[Step2] 正在推送到公众号草稿箱...")
|
||||
|
||||
# 优先使用第二张图片作为封面,否则使用默认封面
|
||||
if cover_image_url:
|
||||
self._log(f"[Step2] 使用第二张图片作为封面: {cover_image_url}")
|
||||
success = publish_to_wechat(
|
||||
title=title,
|
||||
content=html_content,
|
||||
appid=wechat_appid,
|
||||
appsecret=wechat_appsecret,
|
||||
thumb_image_url=cover_image_url
|
||||
)
|
||||
else:
|
||||
# 使用默认封面图片
|
||||
thumb_path = r"c:\Users\南音\Desktop\yidaima\assets\img\bg.jpg"
|
||||
self._log(f"[Step2] 使用默认封面图片: {thumb_path}")
|
||||
success = publish_to_wechat(
|
||||
title=title,
|
||||
content=html_content,
|
||||
appid=wechat_appid,
|
||||
appsecret=wechat_appsecret,
|
||||
thumb_image_path=thumb_path
|
||||
)
|
||||
if success:
|
||||
self._log("[Step2] 公众号草稿推送成功!")
|
||||
else:
|
||||
self._log("[Step2] 公众号草稿推送失败!")
|
||||
else:
|
||||
self._log("[Step2] 提示:未配置微信公众号,跳过公众号推送")
|
||||
self._log("[Step2] 如需推送,请编辑 config.yaml 文件:")
|
||||
self._log("[Step2] wechat:")
|
||||
self._log("[Step2] appid: \"your_appid\"")
|
||||
self._log("[Step2] appsecret: \"your_appsecret\"")
|
||||
self._log("[Step2] 或使用环境变量覆盖:")
|
||||
self._log("[Step2] set WECHAT_APPID=your_appid")
|
||||
self._log("[Step2] set WECHAT_APPSECRET=your_appsecret")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""单独运行 Step2"""
|
||||
step2 = Step2Converter()
|
||||
try:
|
||||
step2.run()
|
||||
print("[INFO] Step2 执行成功!")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Step2 执行失败: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
utils/__init__.py
Normal file
1
utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
utils/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
utils/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/config.cpython-311.pyc
Normal file
BIN
utils/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/logger.cpython-311.pyc
Normal file
BIN
utils/__pycache__/logger.cpython-311.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/screenshot.cpython-311.pyc
Normal file
BIN
utils/__pycache__/screenshot.cpython-311.pyc
Normal file
Binary file not shown.
41
utils/config.py
Normal file
41
utils/config.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppConfig:
|
||||
umi_ocr_url: str
|
||||
prefer_mss: bool = True
|
||||
default_region: Optional[tuple[int, int, int, int]] = None # left, top, width, height
|
||||
click_pause: float = 0.05
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> AppConfig:
|
||||
p = Path(path)
|
||||
data: dict[str, Any] = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
|
||||
umi_ocr = data.get("umi_ocr", {}) or {}
|
||||
screenshot = data.get("screenshot", {}) or {}
|
||||
click = data.get("click", {}) or {}
|
||||
|
||||
region = screenshot.get("default_region")
|
||||
default_region: Optional[tuple[int, int, int, int]]
|
||||
if region is None:
|
||||
default_region = None
|
||||
else:
|
||||
if not (isinstance(region, (list, tuple)) and len(region) == 4):
|
||||
raise ValueError("config.yaml screenshot.default_region 必须是 null 或 [left, top, width, height]")
|
||||
default_region = (int(region[0]), int(region[1]), int(region[2]), int(region[3]))
|
||||
|
||||
return AppConfig(
|
||||
umi_ocr_url=str(umi_ocr.get("url", "http://127.0.0.1:1224/api/ocr")),
|
||||
prefer_mss=bool(screenshot.get("prefer_mss", True)),
|
||||
default_region=default_region,
|
||||
click_pause=float(click.get("pause", 0.05)),
|
||||
)
|
||||
|
||||
18
utils/logger.py
Normal file
18
utils/logger.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO, log_file: Optional[str] = None) -> logging.Logger:
|
||||
handlers: list[logging.Handler] = [logging.StreamHandler()]
|
||||
if log_file:
|
||||
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
|
||||
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
handlers=handlers,
|
||||
)
|
||||
return logging.getLogger("yidaima")
|
||||
|
||||
79
utils/screenshot.py
Normal file
79
utils/screenshot.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptureResult:
|
||||
image_bytes: bytes
|
||||
# 截图区域在“屏幕坐标系”中的位置(left, top, width, height)
|
||||
region: tuple[int, int, int, int]
|
||||
# 截图图片像素坐标 -> 屏幕坐标 的缩放系数
|
||||
scale_x: float
|
||||
scale_y: float
|
||||
|
||||
|
||||
def capture_screen(region: Optional[tuple[int, int, int, int]] = None, *, prefer_mss: bool = True) -> CaptureResult:
|
||||
"""
|
||||
截取屏幕(全屏或局部),返回 PNG bytes + region + 像素到屏幕坐标的缩放。
|
||||
|
||||
- region: (left, top, width, height) 使用屏幕坐标
|
||||
- scale_x/scale_y: 用于处理高 DPI 下 mss 取到的物理像素与 pyautogui 屏幕坐标不一致的问题
|
||||
"""
|
||||
if prefer_mss:
|
||||
try:
|
||||
return _capture_with_mss(region)
|
||||
except Exception:
|
||||
# 回退到 pyautogui(更贴近“屏幕坐标系”)
|
||||
return _capture_with_pyautogui(region)
|
||||
return _capture_with_pyautogui(region)
|
||||
|
||||
|
||||
def _capture_with_pyautogui(region: Optional[tuple[int, int, int, int]]) -> CaptureResult:
|
||||
import pyautogui
|
||||
|
||||
img = pyautogui.screenshot(region=region) # type: ignore[arg-type]
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
|
||||
if region is None:
|
||||
w, h = img.size
|
||||
region2 = (0, 0, int(w), int(h))
|
||||
else:
|
||||
region2 = (int(region[0]), int(region[1]), int(region[2]), int(region[3]))
|
||||
|
||||
return CaptureResult(image_bytes=buf.getvalue(), region=region2, scale_x=1.0, scale_y=1.0)
|
||||
|
||||
|
||||
def _capture_with_mss(region: Optional[tuple[int, int, int, int]]) -> CaptureResult:
|
||||
import mss
|
||||
import mss.tools
|
||||
import pyautogui
|
||||
|
||||
with mss.mss() as sct:
|
||||
if region is None:
|
||||
mon = sct.monitors[1] # 主屏
|
||||
left, top, width, height = int(mon["left"]), int(mon["top"]), int(mon["width"]), int(mon["height"])
|
||||
else:
|
||||
left, top, width, height = (int(region[0]), int(region[1]), int(region[2]), int(region[3]))
|
||||
|
||||
shot = sct.grab({"left": left, "top": top, "width": width, "height": height})
|
||||
png_bytes = mss.tools.to_png(shot.rgb, shot.size)
|
||||
|
||||
# DPI 缩放处理:mss 的像素尺寸可能与 pyautogui 的屏幕坐标尺寸不同
|
||||
screen_w, screen_h = pyautogui.size()
|
||||
mon = sct.monitors[1]
|
||||
mon_w, mon_h = int(mon["width"]), int(mon["height"])
|
||||
# 若 mss 返回的是物理像素,mon_w/mon_h 往往大于 pyautogui.size()
|
||||
scale_x = float(screen_w) / float(mon_w) if mon_w else 1.0
|
||||
scale_y = float(screen_h) / float(mon_h) if mon_h else 1.0
|
||||
|
||||
return CaptureResult(
|
||||
image_bytes=png_bytes,
|
||||
region=(left, top, width, height),
|
||||
scale_x=scale_x,
|
||||
scale_y=scale_y,
|
||||
)
|
||||
|
||||
270
wechat_publisher.py
Normal file
270
wechat_publisher.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class WeChatPublisher:
|
||||
"""微信公众号草稿箱发布器"""
|
||||
|
||||
def __init__(self, appid: str, appsecret: str):
|
||||
self.appid = appid
|
||||
self.appsecret = appsecret
|
||||
self.access_token: Optional[str] = None
|
||||
self.token_expires_at: float = 0
|
||||
|
||||
def _get_access_token(self) -> str:
|
||||
"""获取或刷新 access_token"""
|
||||
if self.access_token and time.time() < self.token_expires_at - 300:
|
||||
return self.access_token
|
||||
|
||||
url = "https://api.weixin.qq.com/cgi-bin/token"
|
||||
params = {
|
||||
"grant_type": "client_credential",
|
||||
"appid": self.appid,
|
||||
"secret": self.appsecret
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=30)
|
||||
data = response.json()
|
||||
|
||||
if "access_token" not in data:
|
||||
errcode = data.get("errcode")
|
||||
errmsg = data.get("errmsg", "")
|
||||
|
||||
if errcode == 40164 or "not in whitelist" in errmsg.lower() or "ip" in errmsg.lower():
|
||||
import re
|
||||
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', errmsg)
|
||||
current_ip = ip_match.group(1) if ip_match else "当前IP"
|
||||
|
||||
raise RuntimeError(
|
||||
f"获取 access_token 失败: IP 不在白名单中\n"
|
||||
f"当前 IP: {current_ip}\n"
|
||||
f"解决方法: 登录微信公众平台 → 开发 → 基本配置 → IP白名单 → 添加以上IP地址"
|
||||
)
|
||||
elif errcode == 40013:
|
||||
raise RuntimeError(f"获取 access_token 失败: AppID 无效")
|
||||
elif errcode == 40125:
|
||||
raise RuntimeError(f"获取 access_token 失败: AppSecret 无效")
|
||||
else:
|
||||
raise RuntimeError(f"获取 access_token 失败: {data}")
|
||||
|
||||
self.access_token = data["access_token"]
|
||||
self.token_expires_at = time.time() + data.get("expires_in", 7200)
|
||||
|
||||
return self.access_token
|
||||
|
||||
def upload_thumb(self, image_path: str) -> str:
|
||||
"""
|
||||
上传封面图片获取 thumb_media_id
|
||||
|
||||
Args:
|
||||
image_path: 图片文件路径
|
||||
|
||||
Returns:
|
||||
thumb_media_id
|
||||
"""
|
||||
token = self._get_access_token()
|
||||
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image"
|
||||
|
||||
with open(image_path, 'rb') as f:
|
||||
files = {'media': f}
|
||||
response = requests.post(url, files=files, timeout=30)
|
||||
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode"):
|
||||
raise RuntimeError(f"上传封面图片失败: {result}")
|
||||
|
||||
media_id = result.get("media_id")
|
||||
if not media_id:
|
||||
raise RuntimeError(f"上传封面图片失败,未返回 media_id: {result}")
|
||||
|
||||
return media_id
|
||||
|
||||
def upload_thumb_from_url(self, image_url: str) -> str:
|
||||
"""
|
||||
从URL下载图片并上传获取 thumb_media_id
|
||||
|
||||
Args:
|
||||
image_url: 图片URL
|
||||
|
||||
Returns:
|
||||
thumb_media_id
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
# 下载图片
|
||||
response = requests.get(image_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# 保存到临时文件
|
||||
suffix = Path(image_url).suffix or '.jpg'
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
||||
f.write(response.content)
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
return self.upload_thumb(temp_path)
|
||||
finally:
|
||||
# 清理临时文件
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
|
||||
def add_draft(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
thumb_media_id: str,
|
||||
author: str = "",
|
||||
digest: str = "",
|
||||
content_source_url: str = ""
|
||||
) -> dict:
|
||||
"""
|
||||
添加草稿到公众号草稿箱
|
||||
|
||||
Args:
|
||||
title: 文章标题
|
||||
content: 文章内容(HTML格式)
|
||||
thumb_media_id: 封面图片的 media_id(必需)
|
||||
author: 作者
|
||||
digest: 摘要
|
||||
content_source_url: 阅读原文链接
|
||||
|
||||
Returns:
|
||||
API 返回的原始数据
|
||||
"""
|
||||
token = self._get_access_token()
|
||||
url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
|
||||
|
||||
# 构建图文消息
|
||||
article = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"thumb_media_id": thumb_media_id,
|
||||
"show_cover_pic": 1,
|
||||
"need_open_comment": 0,
|
||||
"only_fans_can_comment": 0
|
||||
}
|
||||
|
||||
if author:
|
||||
article["author"] = author
|
||||
if digest:
|
||||
article["digest"] = digest
|
||||
if content_source_url:
|
||||
article["content_source_url"] = content_source_url
|
||||
|
||||
payload = {"articles": [article]}
|
||||
|
||||
# 调试信息
|
||||
payload_str = json.dumps(payload, ensure_ascii=False)
|
||||
print(f"[WeChat] 发送内容长度: {len(payload_str)} 字符")
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
data=payload_str.encode('utf-8'),
|
||||
headers={"Content-Type": "application/json; charset=utf-8"},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
raise RuntimeError(f"添加草稿失败: {result}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def publish_to_wechat(
|
||||
title: str,
|
||||
content: str,
|
||||
appid: str,
|
||||
appsecret: str,
|
||||
thumb_image_path: Optional[str] = None,
|
||||
thumb_image_url: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
便捷函数:发布文章到微信公众号草稿箱
|
||||
|
||||
Args:
|
||||
title: 文章标题
|
||||
content: 文章内容(HTML格式)
|
||||
appid: 微信公众号 AppID
|
||||
appsecret: 微信公众号 AppSecret
|
||||
thumb_image_path: 封面图片本地路径(可选,与 thumb_image_url 二选一)
|
||||
thumb_image_url: 封面图片URL(可选,与 thumb_image_path 二选一)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
publisher = WeChatPublisher(appid, appsecret)
|
||||
|
||||
# 获取封面图片的 media_id
|
||||
if thumb_image_path:
|
||||
print(f"[WeChat] 正在上传封面图片: {thumb_image_path}")
|
||||
thumb_media_id = publisher.upload_thumb(thumb_image_path)
|
||||
elif thumb_image_url:
|
||||
print(f"[WeChat] 正在下载并上传封面图片: {thumb_image_url}")
|
||||
thumb_media_id = publisher.upload_thumb_from_url(thumb_image_url)
|
||||
else:
|
||||
raise RuntimeError("必须提供封面图片路径或URL")
|
||||
|
||||
print(f"[WeChat] 封面图片上传成功,media_id: {thumb_media_id}")
|
||||
|
||||
# 添加草稿
|
||||
result = publisher.add_draft(
|
||||
title=title,
|
||||
content=content,
|
||||
thumb_media_id=thumb_media_id
|
||||
)
|
||||
|
||||
print(f"[WeChat] 草稿添加成功,media_id: {result.get('media_id')}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WeChat] 发布失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""测试发布功能"""
|
||||
from config_loader import get_config
|
||||
|
||||
config = get_config()
|
||||
appid = config.wechat_appid
|
||||
appsecret = config.wechat_appsecret
|
||||
|
||||
if not appid or not appsecret:
|
||||
print("[ERROR] 未配置微信公众号,请编辑 config.yaml 文件:")
|
||||
print(" wechat:")
|
||||
print(" appid: \"your_appid\"")
|
||||
print(" appsecret: \"your_appsecret\"")
|
||||
return
|
||||
|
||||
# 测试内容
|
||||
title = "测试文章"
|
||||
content = "<p>这是一篇测试文章</p>"
|
||||
|
||||
# 使用默认封面图片
|
||||
default_thumb_path = r"c:\Users\南音\Desktop\yidaima\assets\img\bg.jpg"
|
||||
|
||||
success = publish_to_wechat(
|
||||
title=title,
|
||||
content=content,
|
||||
appid=appid,
|
||||
appsecret=appsecret,
|
||||
thumb_image_path=default_thumb_path
|
||||
)
|
||||
|
||||
if success:
|
||||
print("[INFO] 发布成功!")
|
||||
else:
|
||||
print("[ERROR] 发布失败!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
75
yidaima.spec
Normal file
75
yidaima.spec
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
import sys
|
||||
sys.setrecursionlimit(5000)
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[
|
||||
('config.yaml', '.'),
|
||||
('data', 'data'),
|
||||
],
|
||||
hiddenimports=[
|
||||
'markdown.extensions.codehilite',
|
||||
'markdown.extensions.fenced_code',
|
||||
'markdown.extensions.tables',
|
||||
'markdown.extensions.nl2br',
|
||||
'premailer',
|
||||
'playwright',
|
||||
'playwright.sync_api',
|
||||
'yaml',
|
||||
'requests',
|
||||
'pygments',
|
||||
'pygments.lexers',
|
||||
'pygments.formatters',
|
||||
'pygments.styles',
|
||||
'tkinter',
|
||||
'tkinter.ttk',
|
||||
'tkinter.scrolledtext',
|
||||
'tkinter.colorchooser',
|
||||
'tkhtmlview',
|
||||
'tkinterweb',
|
||||
'tkinterweb_tkhtml',
|
||||
'PIL',
|
||||
'PIL.Image',
|
||||
'PIL.ImageTk',
|
||||
'mysql',
|
||||
'mysql.connector',
|
||||
'mysql.connector.locales',
|
||||
'mysql.connector.plugins',
|
||||
'mysql.connector.plugins.mysql_native_password',
|
||||
'mysql.connector.plugins.caching_sha2_password',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='yidaima',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False, # 改为 False,不显示控制台窗口(GUI 模式)
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=None,
|
||||
)
|
||||
Reference in New Issue
Block a user