This commit is contained in:
王鹏
2026-04-09 14:55:54 +08:00
commit a2f5875d1b
60 changed files with 5210 additions and 0 deletions

View 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
View 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
View 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
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
assets/.keep Normal file
View File

@@ -0,0 +1 @@

BIN
assets/img/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

20
config.yaml Normal file
View 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
View File

@@ -0,0 +1 @@

179
config_loader.py Normal file
View 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
View File

@@ -0,0 +1 @@

Binary file not shown.

Binary file not shown.

Binary file not shown.

87
core/actions.py Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@

2
data/html/wechat.html Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

283
db_manager.py Normal file
View 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
View 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
View 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` 变量部分吗?

View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
1.获取剪切板内容
2.将剪切板内容转换为公众号文章格式调用工具类md_to_wechat.py
3.推送到公众号草稿箱

0
docs/step3.md Normal file
View File

84
docs/公众号.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>&nbsp;</p> 或 <br>
# 将 </p><p> 替换为 </p><p>&nbsp;</p><p> 来添加空行
html = re.sub(r'</p>\s*<p>', '</p><p>&nbsp;</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('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&')
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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

41
utils/config.py Normal file
View 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
View 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
View 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
View 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
View 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,
)