用 Tkinter 实现一个功能完整的微信公众号文章自动发布工具,包含配置管理、图文编辑、Markdown 转 HTML、图片上传、立即发布和定时发布等核心功能,并且界面要符合运营人员的使用习惯。


下面我会为你提供完整的可运行代码,代码包含所有你提到的核心功能,并做好了详细的注释和异常处理。
import tkinter as tk
from tkinter import ttk, scrolledtext, filedialog, messagebox
import json
import os
import requests
import markdown
from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler
classWeChatPublisher:
def__init__(self, root):
self.root = root
self.root.title("微信公众号文章自动发布工具")
self.root.geometry("900x950")
self.root.resizable(True, True)
# 解决Windows中文乱码问题
self.default_font = ('SimHei', 10)
self.root.option_add("*Font", self.default_font)
# 配置相关变量
self.appid = tk.StringVar()
self.appsecret = tk.StringVar()
# 文章相关变量
self.title = tk.StringVar()
self.digest = tk.StringVar()
self.author = tk.StringVar()
self.original_url = tk.StringVar()
# 图片相关变量
self.image_path = tk.StringVar()
self.media_id = tk.StringVar()
# 定时发布相关变量
self.is_scheduled = tk.BooleanVar()
self.scheduled_time = tk.StringVar(value=datetime.now().strftime("%Y-%m-%d %H:%M"))
# 初始化定时任务调度器
self.scheduler = BackgroundScheduler()
self.scheduler.start()
# 创建界面组件
self.create_widgets()
# 加载配置文件
self.load_config()
defcreate_widgets(self):
"""创建所有界面组件"""
# 主容器
main_frame = ttk.Frame(self.root, padding="10")
main_frame.pack(fill=tk.BOTH, expand=True)
# 1. 配置区域 (AppID & AppSecret)
config_frame = ttk.LabelFrame(main_frame, text="账号配置", padding="10")
config_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=5)
ttk.Label(config_frame, text="AppID:").grid(row=0, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(config_frame, textvariable=self.appid, width=40).grid(row=0, column=1, padx=5, pady=3)
ttk.Label(config_frame, text="AppSecret:").grid(row=1, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(config_frame, textvariable=self.appsecret, width=40, show="*").grid(row=1, column=1, padx=5, pady=3)
ttk.Button(config_frame, text="保存配置", command=self.save_config).grid(row=0, column=2, padx=5)
ttk.Button(config_frame, text="测试连接", command=self.test_connection).grid(row=1, column=2, padx=5)
# 2. 文章基本信息区域
article_frame = ttk.LabelFrame(main_frame, text="文章信息", padding="10")
article_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
ttk.Label(article_frame, text="标题:").grid(row=0, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(article_frame, textvariable=self.title, width=60).grid(row=0, column=1, padx=5, pady=3)
ttk.Label(article_frame, text="摘要:").grid(row=1, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(article_frame, textvariable=self.digest, width=60).grid(row=1, column=1, padx=5, pady=3)
ttk.Label(article_frame, text="作者:").grid(row=2, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(article_frame, textvariable=self.author, width=30).grid(row=2, column=1, padx=5, pady=3, sticky="w")
ttk.Label(article_frame, text="原文链接:").grid(row=2, column=2, sticky="w", padx=5, pady=3)
ttk.Entry(article_frame, textvariable=self.original_url, width=30).grid(row=2, column=3, padx=5, pady=3, sticky="w")
# 3. 图片上传区域
image_frame = ttk.LabelFrame(main_frame, text="封面图片", padding="10")
image_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=5)
ttk.Button(image_frame, text="选择图片", command=self.select_image).grid(row=0, column=0, padx=5, pady=3)
ttk.Entry(image_frame, textvariable=self.image_path, width=50, state="readonly").grid(row=0, column=1, padx=5, pady=3)
ttk.Button(image_frame, text="上传图片", command=self.upload_image).grid(row=0, column=2, padx=5, pady=3)
ttk.Label(image_frame, text="Media ID:").grid(row=1, column=0, sticky="w", padx=5, pady=3)
self.media_id_entry = ttk.Entry(image_frame, textvariable=self.media_id, width=60, state="readonly")
self.media_id_entry.grid(row=1, column=1, columnspan=2, padx=5, pady=3, sticky="w")
# 4. 内容编辑区域
content_frame = ttk.LabelFrame(main_frame, text="文章内容 (Markdown)", padding="10")
content_frame.grid(row=3, column=0, columnspan=2, sticky="nsew", pady=5)
main_frame.rowconfigure(3, weight=1)
# Markdown编辑框
self.content_text = scrolledtext.ScrolledText(content_frame, wrap=tk.WORD, height=15)
self.content_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5)
# 操作按钮
btn_frame = ttk.Frame(content_frame)
btn_frame.pack(side=tk.RIGHT, fill=tk.Y, padx=5, pady=5)
ttk.Button(btn_frame, text="转换HTML", command=self.convert_markdown).pack(fill=tk.X, pady=5)
ttk.Button(btn_frame, text="预览内容", command=self.preview_content).pack(fill=tk.X, pady=5)
# 5. 发布设置区域
publish_frame = ttk.LabelFrame(main_frame, text="发布设置", padding="10")
publish_frame.grid(row=4, column=0, columnspan=2, sticky="ew", pady=5)
# 定时发布选项
ttk.Checkbutton(publish_frame, text="定时发布", variable=self.is_scheduled,
command=self.toggle_scheduled).grid(row=0, column=0, padx=5, pady=3, sticky="w")
ttk.Label(publish_frame, text="发布时间:").grid(row=0, column=1, sticky="w", padx=5, pady=3)
self.time_entry = ttk.Entry(publish_frame, textvariable=self.scheduled_time, state="disabled")
self.time_entry.grid(row=0, column=2, padx=5, pady=3)
# 发布按钮
btn_publish_frame = ttk.Frame(publish_frame)
btn_publish_frame.grid(row=0, column=3, padx=20, pady=3)
ttk.Button(btn_publish_frame, text="立即发布", command=self.publish_now,
style="Accent.TButton").pack(side=tk.LEFT, padx=5)
ttk.Button(btn_publish_frame, text="保存草稿", command=self.save_draft).pack(side=tk.LEFT, padx=5)
# 6. 状态栏
self.status_var = tk.StringVar(value="就绪")
self.status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN)
self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)
deftoggle_scheduled(self):
"""切换定时发布状态"""
if self.is_scheduled.get():
self.time_entry.config(state="normal")
else:
self.time_entry.config(state="disabled")
defload_config(self):
"""加载配置文件"""
try:
if os.path.exists("wechat_config.json"):
with open("wechat_config.json", "r", encoding="utf-8") as f:
config = json.load(f)
self.appid.set(config.get("appid", ""))
self.appsecret.set(config.get("appsecret", ""))
self.status_var.set("配置文件加载成功")
else:
self.status_var.set("未找到配置文件,将使用新配置")
except Exception as e:
messagebox.showerror("错误", f"加载配置失败:{str(e)}")
self.status_var.set("配置文件加载失败")
defsave_config(self):
"""保存配置文件"""
try:
config = {
"appid": self.appid.get(),
"appsecret": self.appsecret.get()
}
with open("wechat_config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
messagebox.showinfo("成功", "配置保存成功!")
self.status_var.set("配置已保存")
except Exception as e:
messagebox.showerror("错误", f"保存配置失败:{str(e)}")
self.status_var.set("配置保存失败")
defget_access_token(self):
"""获取微信Access Token"""
try:
appid = self.appid.get()
appsecret = self.appsecret.get()
ifnot appid ornot appsecret:
messagebox.showwarning("警告", "请先填写AppID和AppSecret!")
returnNone
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=10)
result = response.json()
if"access_token"in result:
self.status_var.set("Access Token获取成功")
return result["access_token"]
else:
messagebox.showerror("错误", f"获取Token失败:{result}")
self.status_var.set("Access Token获取失败")
returnNone
except requests.exceptions.Timeout:
messagebox.showerror("错误", "网络超时,请检查网络连接!")
returnNone
except Exception as e:
messagebox.showerror("错误", f"获取Token异常:{str(e)}")
returnNone
deftest_connection(self):
"""测试微信接口连接"""
token = self.get_access_token()
if token:
messagebox.showinfo("成功", "接口连接测试成功!")
defselect_image(self):
"""选择本地图片"""
file_path = filedialog.askopenfilename(
title="选择封面图片",
filetypes=[("图片文件", "*.png *.jpg *.jpeg *.gif")]
)
if file_path:
# 检查文件大小(微信限制10M)
file_size = os.path.getsize(file_path) / 1024 / 1024# 转MB
if file_size > 10:
messagebox.showwarning("警告", f"图片大小{file_size:.2f}MB,超过微信10M限制!")
return
self.image_path.set(file_path)
self.status_var.set(f"已选择图片:{os.path.basename(file_path)}")
defupload_image(self):
"""上传图片到微信素材库"""
ifnot self.image_path.get():
messagebox.showwarning("警告", "请先选择图片!")
return
token = self.get_access_token()
ifnot token:
return
try:
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image"
with open(self.image_path.get(), "rb") as f:
files = {"media": f}
response = requests.post(url, files=files, timeout=20)
result = response.json()
if"media_id"in result:
self.media_id.set(result["media_id"])
self.media_id_entry.config(state="normal")
self.media_id_entry.config(state="readonly")
messagebox.showinfo("成功", "图片上传成功!")
self.status_var.set("图片上传成功,Media ID已更新")
else:
messagebox.showerror("错误", f"图片上传失败:{result}")
self.status_var.set("图片上传失败")
except Exception as e:
messagebox.showerror("错误", f"上传图片异常:{str(e)}")
self.status_var.set("图片上传异常")
defconvert_markdown(self):
"""将Markdown转换为HTML"""
content = self.content_text.get("1.0", tk.END).strip()
ifnot content:
messagebox.showwarning("警告", "请先输入Markdown内容!")
return
try:
# 转换Markdown到HTML(启用常用扩展)
html = markdown.markdown(content, extensions=['markdown.extensions.extra'])
self.status_var.set("Markdown转换HTML成功")
return html
except Exception as e:
messagebox.showerror("错误", f"转换失败:{str(e)}")
self.status_var.set("Markdown转换HTML失败")
return""
defpreview_content(self):
"""预览转换后的HTML内容"""
html = self.convert_markdown()
ifnot html:
return
# 创建预览窗口
preview_window = tk.Toplevel(self.root)
preview_window.title("内容预览 (HTML)")
preview_window.geometry("800x600")
preview_window.resizable(True, True)
# HTML预览框
preview_text = scrolledtext.ScrolledText(preview_window, wrap=tk.WORD, font=self.default_font)
preview_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
preview_text.insert(tk.END, html)
preview_text.configure(state='disabled') # 设为只读
defsave_draft(self):
"""保存本地草稿"""
try:
draft_data = {
"title": self.title.get(),
"digest": self.digest.get(),
"author": self.author.get(),
"original_url": self.original_url.get(),
"content": self.content_text.get("1.0", tk.END),
"media_id": self.media_id.get(),
"create_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
with open("article_draft.json", "w", encoding="utf-8") as f:
json.dump(draft_data, f, ensure_ascii=False, indent=2)
messagebox.showinfo("成功", "草稿保存成功!")
self.status_var.set("草稿已保存到本地")
except Exception as e:
messagebox.showerror("错误", f"保存草稿失败:{str(e)}")
self.status_var.set("草稿保存失败")
defpublish_now(self):
"""立即发布文章"""
# 基础参数检查
ifnot self.title.get():
messagebox.showwarning("警告", "请填写文章标题!")
return
ifnot self.media_id.get():
messagebox.showwarning("警告", "请先上传封面图片获取Media ID!")
return
# 获取HTML内容
html_content = self.convert_markdown()
ifnot html_content:
return
# 获取Access Token
token = self.get_access_token()
ifnot token:
return
try:
# 1. 创建草稿
draft_url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
draft_data = {
"articles": [{
"title": self.title.get(),
"author": self.author.get() or"未知作者",
"digest": self.digest.get() or self.title.get(),
"content": html_content,
"content_source_url": self.original_url.get(),
"thumb_media_id": self.media_id.get(),
"show_cover_pic": 1,
"need_open_comment": 1,
"only_fans_can_comment": 0
}]
}
# 重要:手动编码为UTF-8,避免中文乱码
headers = {"Content-Type": "application/json; charset=utf-8"}
draft_json = json.dumps(draft_data, ensure_ascii=False).encode('utf-8')
draft_response = requests.post(draft_url, data=draft_json, headers=headers, timeout=10)
draft_result = draft_response.json()
if"draft_id"notin draft_result:
messagebox.showerror("错误", f"创建草稿失败:{draft_result}")
self.status_var.set("创建草稿失败")
return
draft_id = draft_result["draft_id"]
self.status_var.set(f"草稿创建成功,ID:{draft_id}")
# 2. 发布草稿
publish_url = f"https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token={token}"
publish_data = {"draft_id": draft_id}
publish_json = json.dumps(publish_data, ensure_ascii=False).encode('utf-8')
publish_response = requests.post(publish_url, data=publish_json, headers=headers, timeout=10)
publish_result = publish_response.json()
if"publish_id"in publish_result:
messagebox.showinfo("成功", f"文章发布成功!发布ID:{publish_result['publish_id']}")
self.status_var.set(f"文章发布成功,ID:{publish_result['publish_id']}")
else:
messagebox.showerror("错误", f"发布失败:{publish_result}")
self.status_var.set("文章发布失败")
except Exception as e:
messagebox.showerror("错误", f"发布异常:{str(e)}")
self.status_var.set("发布过程出现异常")
defschedule_publish(self):
"""设置定时发布"""
ifnot self.is_scheduled.get():
return
time_str = self.scheduled_time.get()
ifnot time_str:
messagebox.showwarning("警告", "请填写定时发布时间!")
return
try:
# 解析时间
run_date = datetime.strptime(time_str, "%Y-%m-%d %H:%M")
if run_date < datetime.now():
messagebox.showwarning("警告", "发布时间不能早于当前时间!")
return
# 移除已存在的定时任务
if self.scheduler.get_job('publish_job'):
self.scheduler.remove_job('publish_job')
# 添加新的定时任务
self.scheduler.add_job(
self.publish_now,
'date',
run_date=run_date,
id='publish_job'
)
messagebox.showinfo("成功", f"定时发布已设置:{time_str}")
self.status_var.set(f"定时任务已创建,发布时间:{time_str}")
except ValueError:
messagebox.showerror("错误", "时间格式错误!请使用:YYYY-MM-DD HH:MM")
except Exception as e:
messagebox.showerror("错误", f"设置定时任务失败:{str(e)}")
defon_closing(self):
"""窗口关闭时的清理工作"""
# 关闭调度器
self.scheduler.shutdown()
self.root.destroy()
if __name__ == "__main__":
# 安装依赖提示(首次运行前需要执行)
"""
请先安装依赖:
pip install tkinter requests markdown apscheduler
"""
root = tk.Tk()
app = WeChatPublisher(root)
# 设置关闭窗口的回调
root.protocol("WM_DELETE_WINDOW", app.on_closing)
# 启动主循环
root.mainloop()
环境准备:
pip install requests markdown apscheduler
(tkinter通常随Python自带,若缺失需单独安装)运行方式:
wechat_publisher.pypython wechat_publisher.py核心功能使用流程:
配置管理:
ensure_ascii=False)show="*")Markdown转换:
markdown库转换Markdown到HTML图片上传:
发布逻辑:
定时任务:
你可以直接运行这段代码,根据实际的公众号AppID和AppSecret进行配置,即可体验完整的发布流程。如果需要打包成EXE文件,可使用pyinstaller -F -w -i logo.ico wechat_publisher.py命令。
修复草稿创建失败问题后的完整可运行代码,我已经针对微信接口参数格式、media_id 校验、请求头配置等核心问题进行了全面修复,并保留了所有原有功能。
import tkinter as tk
from tkinter import ttk, scrolledtext, filedialog, messagebox
import json
import os
import requests
import markdown
from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler
# 关闭requests的SSL警告(调试用,生产环境建议配置证书)
requests.packages.urllib3.disable_warnings()
classWeChatPublisher:
def__init__(self, root):
self.root = root
self.root.title("微信公众号文章自动发布工具")
self.root.geometry("900x950")
self.root.resizable(True, True)
# 解决Windows中文乱码问题
self.default_font = ('SimHei', 10)
self.root.option_add("*Font", self.default_font)
# 配置相关变量
self.appid = tk.StringVar()
self.appsecret = tk.StringVar()
# 文章相关变量
self.title = tk.StringVar()
self.digest = tk.StringVar()
self.author = tk.StringVar()
self.original_url = tk.StringVar()
# 图片相关变量
self.image_path = tk.StringVar()
self.media_id = tk.StringVar()
# 定时发布相关变量
self.is_scheduled = tk.BooleanVar()
self.scheduled_time = tk.StringVar(value=datetime.now().strftime("%Y-%m-%d %H:%M"))
# 初始化定时任务调度器
self.scheduler = BackgroundScheduler()
self.scheduler.start()
# 创建界面组件
self.create_widgets()
# 加载配置文件
self.load_config()
defcreate_widgets(self):
"""创建所有界面组件"""
# 主容器
main_frame = ttk.Frame(self.root, padding="10")
main_frame.pack(fill=tk.BOTH, expand=True)
# 1. 配置区域 (AppID & AppSecret)
config_frame = ttk.LabelFrame(main_frame, text="账号配置", padding="10")
config_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=5)
ttk.Label(config_frame, text="AppID:").grid(row=0, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(config_frame, textvariable=self.appid, width=40).grid(row=0, column=1, padx=5, pady=3)
ttk.Label(config_frame, text="AppSecret:").grid(row=1, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(config_frame, textvariable=self.appsecret, width=40, show="*").grid(row=1, column=1, padx=5, pady=3)
ttk.Button(config_frame, text="保存配置", command=self.save_config).grid(row=0, column=2, padx=5)
ttk.Button(config_frame, text="测试连接", command=self.test_connection).grid(row=1, column=2, padx=5)
# 2. 文章基本信息区域
article_frame = ttk.LabelFrame(main_frame, text="文章信息", padding="10")
article_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
ttk.Label(article_frame, text="标题:").grid(row=0, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(article_frame, textvariable=self.title, width=60).grid(row=0, column=1, padx=5, pady=3)
ttk.Label(article_frame, text="摘要:").grid(row=1, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(article_frame, textvariable=self.digest, width=60).grid(row=1, column=1, padx=5, pady=3)
ttk.Label(article_frame, text="作者:").grid(row=2, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(article_frame, textvariable=self.author, width=30).grid(row=2, column=1, padx=5, pady=3, sticky="w")
ttk.Label(article_frame, text="原文链接:").grid(row=2, column=2, sticky="w", padx=5, pady=3)
ttk.Entry(article_frame, textvariable=self.original_url, width=30).grid(row=2, column=3, padx=5, pady=3, sticky="w")
# 3. 图片上传区域
image_frame = ttk.LabelFrame(main_frame, text="封面图片", padding="10")
image_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=5)
ttk.Button(image_frame, text="选择图片", command=self.select_image).grid(row=0, column=0, padx=5, pady=3)
ttk.Entry(image_frame, textvariable=self.image_path, width=50, state="readonly").grid(row=0, column=1, padx=5, pady=3)
ttk.Button(image_frame, text="上传图片", command=self.upload_image).grid(row=0, column=2, padx=5, pady=3)
ttk.Label(image_frame, text="Media ID:").grid(row=1, column=0, sticky="w", padx=5, pady=3)
self.media_id_entry = ttk.Entry(image_frame, textvariable=self.media_id, width=60, state="readonly")
self.media_id_entry.grid(row=1, column=1, columnspan=2, padx=5, pady=3, sticky="w")
# 4. 内容编辑区域
content_frame = ttk.LabelFrame(main_frame, text="文章内容 (Markdown)", padding="10")
content_frame.grid(row=3, column=0, columnspan=2, sticky="nsew", pady=5)
main_frame.rowconfigure(3, weight=1)
# Markdown编辑框
self.content_text = scrolledtext.ScrolledText(content_frame, wrap=tk.WORD, height=15)
self.content_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5)
# 操作按钮
btn_frame = ttk.Frame(content_frame)
btn_frame.pack(side=tk.RIGHT, fill=tk.Y, padx=5, pady=5)
ttk.Button(btn_frame, text="转换HTML", command=self.convert_markdown).pack(fill=tk.X, pady=5)
ttk.Button(btn_frame, text="预览内容", command=self.preview_content).pack(fill=tk.X, pady=5)
# 5. 发布设置区域
publish_frame = ttk.LabelFrame(main_frame, text="发布设置", padding="10")
publish_frame.grid(row=4, column=0, columnspan=2, sticky="ew", pady=5)
# 定时发布选项
ttk.Checkbutton(publish_frame, text="定时发布", variable=self.is_scheduled,
command=self.toggle_scheduled).grid(row=0, column=0, padx=5, pady=3, sticky="w")
ttk.Label(publish_frame, text="发布时间:").grid(row=0, column=1, sticky="w", padx=5, pady=3)
self.time_entry = ttk.Entry(publish_frame, textvariable=self.scheduled_time, state="disabled")
self.time_entry.grid(row=0, column=2, padx=5, pady=3)
# 发布按钮
btn_publish_frame = ttk.Frame(publish_frame)
btn_publish_frame.grid(row=0, column=3, padx=20, pady=3)
ttk.Button(btn_publish_frame, text="立即发布", command=self.publish_now,
style="Accent.TButton").pack(side=tk.LEFT, padx=5)
ttk.Button(btn_publish_frame, text="保存草稿", command=self.save_draft).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_publish_frame, text="设置定时", command=self.schedule_publish).pack(side=tk.LEFT, padx=5)
# 6. 状态栏
self.status_var = tk.StringVar(value="就绪")
self.status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN)
self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)
deftoggle_scheduled(self):
"""切换定时发布状态"""
if self.is_scheduled.get():
self.time_entry.config(state="normal")
else:
self.time_entry.config(state="disabled")
defload_config(self):
"""加载配置文件"""
try:
if os.path.exists("wechat_config.json"):
with open("wechat_config.json", "r", encoding="utf-8") as f:
config = json.load(f)
self.appid.set(config.get("appid", ""))
self.appsecret.set(config.get("appsecret", ""))
self.status_var.set("配置文件加载成功")
else:
self.status_var.set("未找到配置文件,将使用新配置")
except Exception as e:
messagebox.showerror("错误", f"加载配置失败:{str(e)}")
self.status_var.set("配置文件加载失败")
defsave_config(self):
"""保存配置文件"""
try:
config = {
"appid": self.appid.get().strip(),
"appsecret": self.appsecret.get().strip()
}
with open("wechat_config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
messagebox.showinfo("成功", "配置保存成功!")
self.status_var.set("配置已保存")
except Exception as e:
messagebox.showerror("错误", f"保存配置失败:{str(e)}")
self.status_var.set("配置保存失败")
defget_access_token(self):
"""获取微信Access Token"""
try:
appid = self.appid.get().strip()
appsecret = self.appsecret.get().strip()
ifnot appid ornot appsecret:
messagebox.showwarning("警告", "请先填写AppID和AppSecret!")
returnNone
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=10, verify=False)
result = response.json()
if"access_token"in result:
self.status_var.set("Access Token获取成功")
return result["access_token"]
else:
messagebox.showerror("错误", f"获取Token失败:{result}")
self.status_var.set("Access Token获取失败")
returnNone
except requests.exceptions.Timeout:
messagebox.showerror("错误", "网络超时,请检查网络连接!")
returnNone
except Exception as e:
messagebox.showerror("错误", f"获取Token异常:{str(e)}")
returnNone
deftest_connection(self):
"""测试微信接口连接"""
token = self.get_access_token()
if token:
messagebox.showinfo("成功", "接口连接测试成功!")
defselect_image(self):
"""选择本地图片"""
file_path = filedialog.askopenfilename(
title="选择封面图片",
filetypes=[("图片文件", "*.png *.jpg *.jpeg *.gif")]
)
if file_path:
# 检查文件大小(微信限制10M)
file_size = os.path.getsize(file_path) / 1024 / 1024# 转MB
if file_size > 10:
messagebox.showwarning("警告", f"图片大小{file_size:.2f}MB,超过微信10M限制!")
return
self.image_path.set(file_path)
self.status_var.set(f"已选择图片:{os.path.basename(file_path)}")
defupload_image(self):
"""上传图片到微信素材库(永久素材)"""
ifnot self.image_path.get():
messagebox.showwarning("警告", "请先选择图片!")
return
token = self.get_access_token()
ifnot token:
return
try:
# 上传永久图片素材(关键修复:使用add_material接口)
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image"
with open(self.image_path.get(), "rb") as f:
files = {"media": f}
# 添加素材描述(可选,但规范)
data = {
"description": json.dumps({
"title": "封面图片",
"introduction": "公众号文章封面"
}, ensure_ascii=False)
}
response = requests.post(url, files=files, data=data, timeout=20, verify=False)
result = response.json()
print("图片上传响应:", result) # 调试用
if"media_id"in result:
self.media_id.set(result["media_id"])
self.media_id_entry.config(state="normal")
self.media_id_entry.config(state="readonly")
messagebox.showinfo("成功", "图片上传成功!")
self.status_var.set("图片上传成功,Media ID已更新")
else:
messagebox.showerror("错误", f"图片上传失败:{result}")
self.status_var.set("图片上传失败")
except Exception as e:
messagebox.showerror("错误", f"上传图片异常:{str(e)}")
self.status_var.set("图片上传异常")
defconvert_markdown(self):
"""将Markdown转换为HTML"""
content = self.content_text.get("1.0", tk.END).strip()
ifnot content:
messagebox.showwarning("警告", "请先输入Markdown内容!")
return""
try:
# 转换Markdown到HTML(启用常用扩展,禁用toc避免多余标签)
html = markdown.markdown(
content,
extensions=[
'markdown.extensions.extra',
'markdown.extensions.codehilite',
'markdown.extensions.tables'
],
extension_configs={
'markdown.extensions.codehilite': {
'noclasses': True
}
}
)
self.status_var.set("Markdown转换HTML成功")
return html
except Exception as e:
messagebox.showerror("错误", f"转换失败:{str(e)}")
self.status_var.set("Markdown转换HTML失败")
return""
defpreview_content(self):
"""预览转换后的HTML内容"""
html = self.convert_markdown()
ifnot html:
return
# 创建预览窗口
preview_window = tk.Toplevel(self.root)
preview_window.title("内容预览 (HTML)")
preview_window.geometry("800x600")
preview_window.resizable(True, True)
# HTML预览框
preview_text = scrolledtext.ScrolledText(preview_window, wrap=tk.WORD, font=self.default_font)
preview_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
preview_text.insert(tk.END, html)
preview_text.configure(state='disabled') # 设为只读
defsave_draft(self):
"""保存本地草稿"""
try:
draft_data = {
"title": self.title.get().strip(),
"digest": self.digest.get().strip(),
"author": self.author.get().strip(),
"original_url": self.original_url.get().strip(),
"content": self.content_text.get("1.0", tk.END),
"media_id": self.media_id.get().strip(),
"create_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
with open("article_draft.json", "w", encoding="utf-8") as f:
json.dump(draft_data, f, ensure_ascii=False, indent=2)
messagebox.showinfo("成功", "草稿保存成功!")
self.status_var.set("草稿已保存到本地")
except Exception as e:
messagebox.showerror("错误", f"保存草稿失败:{str(e)}")
self.status_var.set("草稿保存失败")
defpublish_now(self):
"""立即发布文章(修复版,解决创建草稿失败问题)"""
# 1. 严格的参数校验
title = self.title.get().strip()
ifnot title:
messagebox.showwarning("警告", "请填写文章标题!")
return
media_id = self.media_id.get().strip()
ifnot media_id or len(media_id) < 10:
messagebox.showwarning("警告", "Media ID无效,请重新上传封面图片!")
return
# 2. 获取并校验HTML内容
html_content = self.convert_markdown()
ifnot html_content:
return
# 3. 获取有效的Access Token
token = self.get_access_token()
ifnot token:
return
try:
# 4. 构建符合微信规范的草稿数据(核心修复)
draft_data = {
"articles": [
{
"title": title,
"author": self.author.get().strip() or"",
"digest": self.digest.get().strip() or title[:120], # 摘要为空则用标题前120字
"content": html_content,
"content_source_url": self.original_url.get().strip() or"",
"thumb_media_id": media_id,
"show_cover_pic": 1, # 必须是数字,不能是布尔值
"need_open_comment": 1,
"only_fans_can_comment": 0,
# 兼容新版接口字段
"is_open_comment": 1,
"is_only_fans_can_comment": 0
}
]
}
# 5. 标准的请求配置(核心修复)
headers = {
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
# 确保JSON序列化格式正确
draft_json = json.dumps(
draft_data,
ensure_ascii=False,
separators=(',', ':') # 压缩JSON,避免多余空格
).encode('utf-8')
# 6. 调用创建草稿接口
draft_url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
draft_response = requests.post(
draft_url,
data=draft_json,
headers=headers,
timeout=15,
verify=False
)
# 打印原始响应(调试用)
print("创建草稿响应:", draft_response.text)
draft_result = draft_response.json()
if"draft_id"in draft_result:
draft_id = draft_result["draft_id"]
self.status_var.set(f"草稿创建成功,ID:{draft_id}")
# 7. 调用发布接口
publish_url = f"https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token={token}"
publish_data = {"draft_id": draft_id}
publish_json = json.dumps(publish_data, ensure_ascii=False).encode('utf-8')
publish_response = requests.post(
publish_url,
data=publish_json,
headers=headers,
timeout=15,
verify=False
)
print("发布响应:", publish_response.text)
publish_result = publish_response.json()
if"publish_id"in publish_result:
messagebox.showinfo("成功", f"文章发布成功!发布ID:{publish_result['publish_id']}")
self.status_var.set(f"文章发布成功,ID:{publish_result['publish_id']}")
else:
messagebox.showerror("错误", f"发布失败:{publish_result}")
self.status_var.set("文章发布失败")
else:
# 详细的错误提示
error_msg = f"创建草稿失败:{draft_result}"
if"errcode"in draft_result:
error_msg += f"\n错误码:{draft_result['errcode']},错误信息:{draft_result.get('errmsg', '未知错误')}"
messagebox.showerror("错误", error_msg)
self.status_var.set("创建草稿失败")
except Exception as e:
messagebox.showerror("错误", f"发布异常:{str(e)}\n请检查参数格式和网络连接")
self.status_var.set("发布过程出现异常")
defschedule_publish(self):
"""设置定时发布"""
ifnot self.is_scheduled.get():
messagebox.showwarning("警告", "请先勾选定时发布!")
return
time_str = self.scheduled_time.get().strip()
ifnot time_str:
messagebox.showwarning("警告", "请填写定时发布时间!")
return
try:
# 解析时间
run_date = datetime.strptime(time_str, "%Y-%m-%d %H:%M")
if run_date < datetime.now():
messagebox.showwarning("警告", "发布时间不能早于当前时间!")
return
# 移除已存在的定时任务
if self.scheduler.get_job('publish_job'):
self.scheduler.remove_job('publish_job')
# 添加新的定时任务
self.scheduler.add_job(
self.publish_now,
'date',
run_date=run_date,
id='publish_job',
misfire_grace_time=300# 允许5分钟的执行误差
)
messagebox.showinfo("成功", f"定时发布已设置:{time_str}")
self.status_var.set(f"定时任务已创建,发布时间:{time_str}")
except ValueError:
messagebox.showerror("错误", "时间格式错误!请使用:YYYY-MM-DD HH:MM")
except Exception as e:
messagebox.showerror("错误", f"设置定时任务失败:{str(e)}")
defon_closing(self):
"""窗口关闭时的清理工作"""
# 关闭调度器
if self.scheduler.running:
self.scheduler.shutdown(wait=False)
self.root.destroy()
if __name__ == "__main__":
"""
依赖安装命令:
pip install requests markdown apscheduler
"""
root = tk.Tk()
app = WeChatPublisher(root)
# 设置关闭窗口的回调
root.protocol("WM_DELETE_WINDOW", app.on_closing)
# 启动主循环
root.mainloop()
参数校验强化:
media_id 增加长度校验(至少10位),过滤无效值strip() 去空格,避免空格导致的参数错误请求配置修复:
separators=(',', ':')),避免多余空格导致的解析错误素材上传修复:
media_id 有效且不过期错误调试优化:
定时任务优化:
misfire_grace_time 参数,允许5分钟执行误差运行代码前请先执行 pip install requests markdown apscheduler 安装依赖,填写正确的公众号AppID和AppSecret后即可正常使用。
彻底解决Markdown转换后的样式问题(代码背景不全、排序换行异常),将输入的Markdown内容转换为美观规范的H5页面,并完善上传保存功能。我会重新设计核心转换逻辑,采用更成熟的方案确保样式完美展示,同时优化上传保存流程。
mistune替代原生markdown库,配合专业的CSS框架import tkinter as tk
from tkinter import ttk, scrolledtext, filedialog, messagebox
import json
import os
import requests
import mistune
from mistune.renderers import HTMLRenderer
from mistune.plugins import plugin_table, plugin_strikethrough, plugin_footnotes, plugin_task_lists
from pygments import highlight
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.formatters import HtmlFormatter
from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler
import re
import uuid
# 关闭requests的SSL警告
requests.packages.urllib3.disable_warnings()
# ===================== 自定义Markdown渲染器 =====================
classHighlightRenderer(HTMLRenderer):
"""自定义渲染器,实现代码高亮和美观样式"""
def__init__(self):
super().__init__()
self.formatter = HtmlFormatter(
style='monokai',
noclasses=False,
linenos=False,
lineanchors='code-line',
anchorlinenos=True
)
defblock_code(self, code, lang=None):
"""渲染代码块,确保背景完整、语法高亮"""
ifnot lang:
lang = 'text'
try:
lexer = get_lexer_by_name(lang, stripall=True)
except:
lexer = guess_lexer(code)
# 渲染高亮代码
highlighted = highlight(code, lexer, self.formatter)
# 包装代码块,确保背景完整
returnf'''
<div class="code-block-wrapper my-6 rounded-lg overflow-hidden shadow-lg">
<div class="code-header bg-gray-800 text-gray-200 px-4 py-2 flex items-center justify-between">
<span class="font-mono text-sm">{lang or'code'}</span>
<div class="flex space-x-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
</div>
</div>
<div class="bg-gray-900 overflow-x-auto">
<pre class="p-4 text-sm leading-relaxed">{highlighted}</pre>
</div>
</div>
'''
defheading(self, text, level):
"""渲染标题,添加美观样式"""
returnf'''
<h{level} class="text-heading font-bold mt-8 mb-4 pb-2 border-b border-gray-200">
{text}
</h{level}>
'''
deflist_item(self, text, level, checked=False):
"""渲染列表项,修复换行问题"""
returnf'<li class="mb-2 pl-2">{text}</li>\n'
deflist(self, text, ordered, level):
"""渲染列表容器"""
if ordered:
returnf'<ol class="pl-8 mb-6 list-decimal">{text}</ol>\n'
else:
returnf'<ul class="pl-8 mb-6 list-disc">{text}</ul>\n'
defparagraph(self, text):
"""渲染段落"""
returnf'<p class="mb-6 leading-relaxed text-gray-700">{text}</p>\n'
deflink(self, link, text=None, title=None):
"""渲染链接"""
if text isNone:
text = link
returnf'<a href="{link}" class="text-blue-600 hover:text-blue-800 underline" title="{title or""}">{text}</a>'
defimage(self, src, alt="", title=None):
"""渲染图片"""
returnf'''
<div class="my-8 text-center">
<img src="{src}" alt="{alt}" title="{title or""}" class="max-w-full h-auto rounded-lg shadow-md mx-auto" />
{f'<p class="text-sm text-gray-500 mt-2">{alt}</p>'if alt else''}
</div>
'''
# ===================== 主应用类 =====================
classWeChatPublisher:
def__init__(self, root):
self.root = root
self.root.title("微信公众号文章发布工具 - 美化版")
self.root.geometry("1000x800")
self.root.minsize(800, 600)
# 解决Windows中文乱码
self.default_font = ('Microsoft YaHei UI', 9)
self.root.option_add("*Font", self.default_font)
# 初始化Markdown解析器
self.renderer = HighlightRenderer()
self.md = mistune.create_markdown(
renderer=self.renderer,
plugins=[
plugin_table,
plugin_strikethrough,
plugin_footnotes,
plugin_task_lists
],
escape=False,
hard_wrap=True
)
# 配置变量
self.appid = tk.StringVar()
self.appsecret = tk.StringVar()
# 文章变量
self.title = tk.StringVar()
self.digest = tk.StringVar()
self.author = tk.StringVar()
self.original_url = tk.StringVar()
# 图片变量
self.image_path = tk.StringVar()
self.media_id = tk.StringVar()
# 定时发布变量
self.is_scheduled = tk.BooleanVar()
self.scheduled_time = tk.StringVar(value=datetime.now().strftime("%Y-%m-%d %H:%M"))
# 初始化调度器
self.scheduler = BackgroundScheduler()
self.scheduler.start()
# 创建UI
self.create_widgets()
# 加载配置
self.load_config()
defcreate_widgets(self):
"""创建界面组件"""
# 主容器
main_container = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 左侧编辑区
left_frame = ttk.Frame(main_container, width=400)
main_container.add(left_frame, weight=1)
# 右侧预览区
right_frame = ttk.Frame(main_container, width=600)
main_container.add(right_frame, weight=1)
# ========== 左侧编辑区 ==========
# 配置区域
config_frame = ttk.LabelFrame(left_frame, text="账号配置", padding=(10, 5))
config_frame.pack(fill=tk.X, padx=5, pady=5)
ttk.Label(config_frame, text="AppID:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
ttk.Entry(config_frame, textvariable=self.appid, width=30).grid(row=0, column=1, padx=5, pady=3)
ttk.Button(config_frame, text="保存配置", command=self.save_config).grid(row=0, column=2, padx=5)
ttk.Label(config_frame, text="AppSecret:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
ttk.Entry(config_frame, textvariable=self.appsecret, width=30, show="*").grid(row=1, column=1, padx=5, pady=3)
ttk.Button(config_frame, text="测试连接", command=self.test_connection).grid(row=1, column=2, padx=5)
# 文章信息区域
article_info_frame = ttk.LabelFrame(left_frame, text="文章信息", padding=(10, 5))
article_info_frame.pack(fill=tk.X, padx=5, pady=5)
ttk.Label(article_info_frame, text="标题:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
ttk.Entry(article_info_frame, textvariable=self.title, width=40).grid(row=0, column=1, columnspan=2, padx=5, pady=3)
ttk.Label(article_info_frame, text="摘要:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
ttk.Entry(article_info_frame, textvariable=self.digest, width=40).grid(row=1, column=1, columnspan=2, padx=5, pady=3)
ttk.Label(article_info_frame, text="作者:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=3)
ttk.Entry(article_info_frame, textvariable=self.author, width=20).grid(row=2, column=1, padx=5, pady=3)
ttk.Label(article_info_frame, text="原文链接:").grid(row=2, column=2, sticky=tk.W, padx=5, pady=3)
ttk.Entry(article_info_frame, textvariable=self.original_url, width=20).grid(row=2, column=3, padx=5, pady=3)
# 封面图片区域
image_frame = ttk.LabelFrame(left_frame, text="封面图片", padding=(10, 5))
image_frame.pack(fill=tk.X, padx=5, pady=5)
ttk.Button(image_frame, text="选择图片", command=self.select_image).grid(row=0, column=0, padx=5, pady=3)
ttk.Entry(image_frame, textvariable=self.image_path, width=40, state="readonly").grid(row=0, column=1, padx=5, pady=3)
ttk.Button(image_frame, text="上传图片", command=self.upload_image).grid(row=0, column=2, padx=5, pady=3)
ttk.Label(image_frame, text="Media ID:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
self.media_id_entry = ttk.Entry(image_frame, textvariable=self.media_id, width=50, state="readonly")
self.media_id_entry.grid(row=1, column=1, columnspan=2, padx=5, pady=3, sticky=tk.W)
# Markdown编辑区域
edit_frame = ttk.LabelFrame(left_frame, text="Markdown编辑", padding=(10, 5))
edit_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# 编辑区按钮
btn_frame = ttk.Frame(edit_frame)
btn_frame.pack(fill=tk.X, pady=5)
ttk.Button(btn_frame, text="导入MD文件", command=self.import_md_file).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="清空内容", command=self.clear_content).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="预览H5", command=self.preview_h5).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="导出HTML", command=self.export_html).pack(side=tk.LEFT, padx=2)
# 编辑框
self.content_text = scrolledtext.ScrolledText(edit_frame, wrap=tk.WORD, font=('Consolas', 10))
self.content_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# 发布设置区域
publish_frame = ttk.LabelFrame(left_frame, text="发布设置", padding=(10, 5))
publish_frame.pack(fill=tk.X, padx=5, pady=5)
ttk.Checkbutton(publish_frame, text="定时发布", variable=self.is_scheduled,
command=self.toggle_scheduled).grid(row=0, column=0, padx=5, pady=3, sticky=tk.W)
ttk.Label(publish_frame, text="发布时间:").grid(row=0, column=1, sticky=tk.W, padx=5, pady=3)
self.time_entry = ttk.Entry(publish_frame, textvariable=self.scheduled_time, state="disabled")
self.time_entry.grid(row=0, column=2, padx=5, pady=3)
# 发布按钮
btn_publish_frame = ttk.Frame(publish_frame)
btn_publish_frame.grid(row=0, column=3, padx=10, pady=3)
ttk.Button(btn_publish_frame, text="保存草稿", command=self.save_draft).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_publish_frame, text="立即发布", command=self.publish_now).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_publish_frame, text="设置定时", command=self.schedule_publish).pack(side=tk.LEFT, padx=2)
# ========== 右侧预览区 ==========
preview_frame = ttk.LabelFrame(right_frame, text="H5预览", padding=(10, 5))
preview_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.preview_text = scrolledtext.ScrolledText(preview_frame, wrap=tk.WORD, font=('Consolas', 9))
self.preview_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# 状态栏
self.status_var = tk.StringVar(value="就绪 - 请输入Markdown内容")
self.status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN)
self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)
# ========== 基础功能 ==========
deftoggle_scheduled(self):
"""切换定时发布状态"""
self.time_entry.config(state="normal"if self.is_scheduled.get() else"disabled")
defload_config(self):
"""加载配置文件"""
try:
if os.path.exists("wechat_config.json"):
with open("wechat_config.json", "r", encoding="utf-8") as f:
config = json.load(f)
self.appid.set(config.get("appid", ""))
self.appsecret.set(config.get("appsecret", ""))
self.status_var.set("配置文件加载成功")
else:
self.status_var.set("未找到配置文件,将使用新配置")
except Exception as e:
messagebox.showerror("错误", f"加载配置失败:{str(e)}")
self.status_var.set("配置加载失败")
defsave_config(self):
"""保存配置文件"""
try:
config = {
"appid": self.appid.get().strip(),
"appsecret": self.appsecret.get().strip()
}
with open("wechat_config.json", "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
messagebox.showinfo("成功", "配置保存成功!")
self.status_var.set("配置已保存")
except Exception as e:
messagebox.showerror("错误", f"保存配置失败:{str(e)}")
self.status_var.set("配置保存失败")
# ========== 图片处理 ==========
defselect_image(self):
"""选择封面图片"""
file_path = filedialog.askopenfilename(
title="选择封面图片",
filetypes=[("图片文件", "*.png *.jpg *.jpeg *.gif *.webp"), ("所有文件", "*.*")]
)
if file_path:
# 检查文件大小(微信限制10M)
file_size = os.path.getsize(file_path) / 1024 / 1024
if file_size > 10:
messagebox.showwarning("警告", f"图片大小{file_size:.2f}MB,超过微信10M限制!")
return
self.image_path.set(file_path)
self.status_var.set(f"已选择图片:{os.path.basename(file_path)}")
defupload_image(self):
"""上传图片到微信素材库"""
ifnot self.image_path.get():
messagebox.showwarning("警告", "请先选择图片!")
return
token = self.get_access_token()
ifnot token:
return
try:
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image"
with open(self.image_path.get(), "rb") as f:
files = {"media": f}
data = {
"description": json.dumps({
"title": self.title.get() or"封面图片",
"introduction": "公众号文章封面图片"
}, ensure_ascii=False)
}
response = requests.post(url, files=files, data=data, timeout=30, verify=False)
result = response.json()
if"media_id"in result:
self.media_id.set(result["media_id"])
messagebox.showinfo("成功", "图片上传成功!")
self.status_var.set("图片上传成功,Media ID已更新")
else:
messagebox.showerror("错误", f"图片上传失败:{result.get('errmsg', '未知错误')}")
self.status_var.set("图片上传失败")
except Exception as e:
messagebox.showerror("错误", f"上传图片异常:{str(e)}")
self.status_var.set("图片上传异常")
# ========== Markdown处理 ==========
defimport_md_file(self):
"""导入Markdown文件"""
try:
file_path = filedialog.askopenfilename(
title="选择Markdown文件",
filetypes=[("Markdown文件", "*.md *.markdown *.mdx"), ("所有文件", "*.*")]
)
if file_path:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
self.content_text.delete("1.0", tk.END)
self.content_text.insert(tk.END, content)
self.status_var.set(f"已导入文件:{os.path.basename(file_path)}")
# 自动预览
self.preview_h5()
messagebox.showinfo("成功", "Markdown文件导入成功!")
except Exception as e:
messagebox.showerror("错误", f"导入失败:{str(e)}")
self.status_var.set("文件导入失败")
defclear_content(self):
"""清空编辑内容"""
if messagebox.askyesno("确认", "确定要清空所有内容吗?"):
self.content_text.delete("1.0", tk.END)
self.status_var.set("内容已清空")
defconvert_markdown_to_html(self):
"""将Markdown转换为美观的H5 HTML"""
content = self.content_text.get("1.0", tk.END).strip()
ifnot content:
return""
# 预处理:修复换行和格式问题
content = self.preprocess_markdown(content)
# 转换Markdown到HTML
body_html = self.md(content)
# 构建完整的H5页面
full_html = f'''
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{self.title.get() or"文章标题"}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<script>
tailwind.config = {{
theme: {{
extend: {{
colors: {{
primary: '#165DFF',
}},
fontFamily: {{
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['Consolas', 'Monaco', 'monospace'],
}},
}}
}}
}}
</script>
<style type="text/tailwindcss">
@layer utilities {{
.text-heading {{
color: #1f2937;
}}
.leading-relaxed {{
line-height: 1.8;
}}
.code-block-wrapper {{
font-family: 'Consolas', 'Monaco', monospace;
}}
}}
/* 代码高亮样式 */
{self.renderer.formatter.get_style_defs('.highlight')}
/* 基础样式 */
body {{
font-family: 'Microsoft YaHei UI', 'Inter', sans-serif;
line-height: 1.6;
color: #374151;
background-color: #f9fafb;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}}
.article-container {{
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin: 20px auto;
}}
/* 表格样式 */
table {{
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}}
th, td {{
padding: 0.75rem;
border: 1px solid #e5e7eb;
text-align: left;
}}
th {{
background-color: #f9fafb;
font-weight: 600;
}}
tr:nth-child(even) {{
background-color: #f9fafb;
}}
</style>
</head>
<body>
<div class="article-container">
<header class="mb-10 text-center">
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 mb-4">{self.title.get() or"未命名文章"}</h1>
{f'<p class="text-gray-500 mb-2">作者:{self.author.get()}</p>'if self.author.get() else''}
{f'<p class="text-gray-400 text-sm">摘要:{self.digest.get()}</p>'if self.digest.get() else''}
</header>
<main class="prose max-w-none">
{body_html}
</main>
<footer class="mt-12 pt-6 border-t border-gray-200 text-center text-gray-500">
<p>© {datetime.now().year}{self.author.get() or"未知作者"}</p>
{f'<p class="mt-2"><a href="{self.original_url.get()}" class="text-primary hover:underline">原文链接</a></p>'if self.original_url.get() else''}
</footer>
</div>
</body>
</html>
'''
return full_html
defpreprocess_markdown(self, content):
"""预处理Markdown,修复格式问题"""
# 统一换行符
content = content.replace('\r\n', '\n').replace('\r', '\n')
# 修复列表项后的换行
content = re.sub(r'(\n\s*[-*+]\s+.*?)(?=\n\s*[-*+]\s+)', r'\1\n', content, flags=re.DOTALL)
content = re.sub(r'(\n\s*\d+\.\s+.*?)(?=\n\s*\d+\.\s+)', r'\1\n', content, flags=re.DOTALL)
# 确保代码块前后有空白行
content = re.sub(r'([^\n])```(\w+)?\n', r'\1\n```\2\n', content)
content = re.sub(r'\n```([^\n])', r'\n```\n\1', content)
# 移除多余空行
content = re.sub(r'\n{3,}', '\n\n', content)
return content
defpreview_h5(self):
"""预览H5效果"""
try:
html = self.convert_markdown_to_html()
ifnot html:
messagebox.showwarning("警告", "请先输入Markdown内容!")
return
self.preview_text.delete("1.0", tk.END)
self.preview_text.insert(tk.END, html)
self.preview_text.configure(state='disabled')
self.status_var.set("H5预览已更新 - 样式已优化")
except Exception as e:
messagebox.showerror("错误", f"预览失败:{str(e)}")
self.status_var.set(f"预览失败:{str(e)}")
defexport_html(self):
"""导出美化的HTML文件"""
html = self.convert_markdown_to_html()
ifnot html:
return
try:
file_path = filedialog.asksaveasfilename(
title="保存H5文件",
defaultextension=".html",
filetypes=[("HTML文件", "*.html *.htm"), ("所有文件", "*.*")],
initialfile=self.title.get() or"article_" + datetime.now().strftime("%Y%m%d")
)
if file_path:
with open(file_path, "w", encoding="utf-8") as f:
f.write(html)
self.status_var.set(f"HTML已导出:{os.path.basename(file_path)}")
messagebox.showinfo("成功", f"HTML文件已保存到:\n{file_path}")
# 询问是否打开文件
if messagebox.askyesno("提示", "是否立即打开文件?"):
os.startfile(file_path)
except Exception as e:
messagebox.showerror("错误", f"导出失败:{str(e)}")
self.status_var.set(f"导出失败:{str(e)}")
# ========== 微信接口 ==========
defget_access_token(self):
"""获取微信Access Token"""
appid = self.appid.get().strip()
appsecret = self.appsecret.get().strip()
ifnot appid ornot appsecret:
messagebox.showwarning("警告", "请先填写AppID和AppSecret!")
returnNone
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=15, verify=False)
result = response.json()
if"access_token"in result:
return result["access_token"]
else:
messagebox.showerror("错误", f"获取Token失败:{result.get('errmsg', '未知错误')}")
returnNone
except Exception as e:
messagebox.showerror("错误", f"获取Token异常:{str(e)}")
returnNone
deftest_connection(self):
"""测试微信接口连接"""
token = self.get_access_token()
if token:
messagebox.showinfo("成功", "微信接口连接测试成功!")
self.status_var.set("接口连接正常")
defsave_draft(self):
"""保存本地草稿(增强版)"""
try:
draft_id = str(uuid.uuid4())[:8]
draft_data = {
"draft_id": draft_id,
"title": self.title.get().strip(),
"digest": self.digest.get().strip(),
"author": self.author.get().strip(),
"original_url": self.original_url.get().strip(),
"content": self.content_text.get("1.0", tk.END),
"media_id": self.media_id.get().strip(),
"create_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"html_content": self.convert_markdown_to_html()
}
# 确保草稿目录存在
ifnot os.path.exists("drafts"):
os.makedirs("drafts")
file_path = f"drafts/draft_{draft_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
with open(file_path, "w", encoding="utf-8") as f:
json.dump(draft_data, f, ensure_ascii=False, indent=2)
messagebox.showinfo("成功", f"草稿保存成功!\n草稿ID:{draft_id}")
self.status_var.set(f"草稿已保存 - ID: {draft_id}")
except Exception as e:
messagebox.showerror("错误", f"保存草稿失败:{str(e)}")
self.status_var.set(f"草稿保存失败:{str(e)}")
defpublish_now(self):
"""立即发布文章"""
# 参数校验
title = self.title.get().strip()
ifnot title:
messagebox.showwarning("警告", "请填写文章标题!")
return
media_id = self.media_id.get().strip()
ifnot media_id:
messagebox.showwarning("警告", "请先上传封面图片获取Media ID!")
return
html_content = self.convert_markdown_to_html()
ifnot html_content:
return
token = self.get_access_token()
ifnot token:
return
try:
# 构建草稿数据
draft_data = {
"articles": [{
"title": title,
"author": self.author.get().strip() or"",
"digest": self.digest.get().strip() or title[:120],
"content": html_content,
"content_source_url": self.original_url.get().strip() or"",
"thumb_media_id": media_id,
"show_cover_pic": 1,
"need_open_comment": 1,
"only_fans_can_comment": 0
}]
}
# 创建草稿
draft_url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
headers = {"Content-Type": "application/json; charset=utf-8"}
draft_response = requests.post(
draft_url,
data=json.dumps(draft_data, ensure_ascii=False, separators=(',', ':')).encode('utf-8'),
headers=headers,
timeout=20,
verify=False
)
draft_result = draft_response.json()
if"draft_id"in draft_result:
# 发布草稿
publish_url = f"https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token={token}"
publish_data = {"draft_id": draft_result["draft_id"]}
publish_response = requests.post(
publish_url,
data=json.dumps(publish_data, ensure_ascii=False).encode('utf-8'),
headers=headers,
timeout=20,
verify=False
)
publish_result = publish_response.json()
if"publish_id"in publish_result:
messagebox.showinfo("成功", f"文章发布成功!\n发布ID:{publish_result['publish_id']}")
self.status_var.set(f"发布成功 - ID: {publish_result['publish_id']}")
else:
messagebox.showerror("错误", f"发布失败:{publish_result.get('errmsg', '未知错误')}")
else:
messagebox.showerror("错误", f"创建草稿失败:{draft_result.get('errmsg', '未知错误')}")
except Exception as e:
messagebox.showerror("错误", f"发布异常:{str(e)}")
self.status_var.set(f"发布异常:{str(e)}")
defschedule_publish(self):
"""设置定时发布"""
ifnot self.is_scheduled.get():
messagebox.showwarning("警告", "请先勾选定时发布!")
return
time_str = self.scheduled_time.get().strip()
ifnot time_str:
messagebox.showwarning("警告", "请填写发布时间!")
return
try:
run_date = datetime.strptime(time_str, "%Y-%m-%d %H:%M")
if run_date < datetime.now():
messagebox.showwarning("警告", "发布时间不能早于当前时间!")
return
# 移除现有任务
if self.scheduler.get_job('publish_job'):
self.scheduler.remove_job('publish_job')
# 添加新任务
self.scheduler.add_job(
self.publish_now,
'date',
run_date=run_date,
id='publish_job',
misfire_grace_time=300
)
messagebox.showinfo("成功", f"定时发布已设置:\n{time_str}")
self.status_var.set(f"定时任务已创建 - {time_str}")
except ValueError:
messagebox.showerror("错误", "时间格式错误!请使用:YYYY-MM-DD HH:MM")
except Exception as e:
messagebox.showerror("错误", f"设置定时失败:{str(e)}")
defon_closing(self):
"""窗口关闭处理"""
if self.scheduler.running:
self.scheduler.shutdown(wait=False)
self.root.destroy()
# ===================== 主程序 =====================
if __name__ == "__main__":
# 安装依赖提示
print("""
所需依赖安装命令:
pip install requests mistune pygments apscheduler python-dotenv uuid
""")
root = tk.Tk()
app = WeChatPublisher(root)
root.protocol("WM_DELETE_WINDOW", app.on_closing)
# 设置图标(可选)
try:
root.iconbitmap(default="icon.ico")
except:
pass
root.mainloop()
pygments实现专业语法高亮,代码块添加完整背景、标题栏、阴影效果,解决背景不全问题markdown库为更强大的mistune使用方法:
pip install requests mistune pygments apscheduler