第一次提交
This commit is contained in:
commit
02172caf18
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
*.xml
|
||||||
|
|
||||||
|
*.sh
|
||||||
|
*.bat
|
||||||
16
.idea/.gitignore
vendored
Normal file
16
.idea/.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# 已忽略包含查询文件的默认文件夹
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
|
||||||
|
../build
|
||||||
|
../build_unix.sh
|
||||||
|
../build_win.bat
|
||||||
|
|
||||||
|
*.exe
|
||||||
12
.idea/article_tool_project.iml
Normal file
12
.idea/article_tool_project.iml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.10 (article_tool_project)" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
<component name="PyDocumentationSettings">
|
||||||
|
<option name="format" value="PLAIN" />
|
||||||
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
833
JiangAIP.py
Normal file
833
JiangAIP.py
Normal file
@ -0,0 +1,833 @@
|
|||||||
|
"""
|
||||||
|
文章处理工具 v2
|
||||||
|
单篇处理 + 批量处理(选文件夹 → 处理所有 txt → 替换源文件 / 另存)
|
||||||
|
GUI: CustomTkinter
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import random
|
||||||
|
import threading
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 示例文本
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
EXAMPLE_TEXT = (
|
||||||
|
'他说:"时间会证明一切"。*这是一个星号标记*,还有下划线_test_。'
|
||||||
|
'实际上,这段话里包含引号、星号、下划线等复杂符号。'
|
||||||
|
'我们的工具应该能自动删除这些符号,只保留常规标点,比如逗号,句号。'
|
||||||
|
'感叹号!问号?冒号:以及书名号《西游记》。'
|
||||||
|
'长句子超过十五个字符的时候,我们会在逗号处强制拆分成短句。'
|
||||||
|
'另外,段落会随机分成2-4句一段,并且相邻段落的句子数量不会相同。'
|
||||||
|
'每个段落内部,除了最后一个句子末尾是句号,其他句子末尾都会变成逗号。'
|
||||||
|
'这是一个特别长的句子,它里面有很多内容,比如我们故意写很多字,'
|
||||||
|
'让它超过十五个字符,并且中间有逗号,那么它就会被拆开。'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 核心处理逻辑
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def convert_quotes_to_corner(text: str) -> str:
|
||||||
|
"""全角/半角双引号 → 直角引号「」"""
|
||||||
|
text = text.replace('\u201c', '「').replace('\u201d', '」')
|
||||||
|
text = text.replace('\uff02', '"')
|
||||||
|
out, left = [], True
|
||||||
|
for ch in text:
|
||||||
|
if ch == '"':
|
||||||
|
out.append('「' if left else '」')
|
||||||
|
left = not left
|
||||||
|
else:
|
||||||
|
out.append(ch)
|
||||||
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_symbols(text: str, keep_corner: bool = False) -> str:
|
||||||
|
"""过滤复杂符号,只保留汉字/数字/字母/常规标点"""
|
||||||
|
pat = (r'[^\u4e00-\u9fff0-9a-zA-Z\s,。!?:《》,.!?:「」]'
|
||||||
|
if keep_corner else
|
||||||
|
r'[^\u4e00-\u9fff0-9a-zA-Z\s,。!?:《》,.!?:]')
|
||||||
|
return re.sub(pat, '', text)
|
||||||
|
|
||||||
|
|
||||||
|
def split_long_sentence(sentence: str) -> list:
|
||||||
|
"""长句强制递归拆分(支持中英文逗号)"""
|
||||||
|
s = sentence.strip()
|
||||||
|
|
||||||
|
if len(s) <= 15:
|
||||||
|
return [s]
|
||||||
|
|
||||||
|
comma_pos = next(
|
||||||
|
(
|
||||||
|
i for i, ch in enumerate(s)
|
||||||
|
if ch in ",," and 2 < i < len(s) - 2
|
||||||
|
),
|
||||||
|
-1
|
||||||
|
)
|
||||||
|
|
||||||
|
if comma_pos == -1:
|
||||||
|
return [s]
|
||||||
|
|
||||||
|
chars = list(s)
|
||||||
|
chars[comma_pos] = '。'
|
||||||
|
|
||||||
|
new_text = ''.join(chars)
|
||||||
|
|
||||||
|
parts = new_text.split('。')
|
||||||
|
|
||||||
|
if len(parts) < 2:
|
||||||
|
return [s]
|
||||||
|
|
||||||
|
first = parts[0] + '。'
|
||||||
|
|
||||||
|
rest = '。'.join(parts[1:])
|
||||||
|
|
||||||
|
second = rest if new_text.endswith('。') else rest + '。'
|
||||||
|
|
||||||
|
return split_long_sentence(first) + split_long_sentence(second)
|
||||||
|
|
||||||
|
def apply_split(text: str) -> str:
|
||||||
|
raws = re.findall(r'[^。!?!?]+[。!?!?]', text)
|
||||||
|
if not raws:
|
||||||
|
return text
|
||||||
|
result = []
|
||||||
|
for sent in raws:
|
||||||
|
result.extend(split_long_sentence(sent))
|
||||||
|
return ''.join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sentences(text: str) -> list:
|
||||||
|
matches = re.findall(r'[^。!?!?]+[。!?!?]', text)
|
||||||
|
return [s.strip() for s in matches if s.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def group_paragraphs(sentences: list, rnd=None) -> list:
|
||||||
|
"""每段 2~4 句,相邻段句数不同"""
|
||||||
|
if not sentences:
|
||||||
|
return []
|
||||||
|
paras, i, total = [], 0, len(sentences)
|
||||||
|
while i < total:
|
||||||
|
if rnd is None:
|
||||||
|
rnd = random
|
||||||
|
size = random.randint(2, 4)
|
||||||
|
if total - i == 1 and paras:
|
||||||
|
paras[-1] += sentences[i]
|
||||||
|
break
|
||||||
|
end = min(i + size, total)
|
||||||
|
if total - end == 1 and end < total:
|
||||||
|
end -= 1
|
||||||
|
if end <= i:
|
||||||
|
end = i + 1
|
||||||
|
paras.append(''.join(sentences[i:end]))
|
||||||
|
i = end
|
||||||
|
|
||||||
|
# 保证相邻段句数不同
|
||||||
|
idx = 1
|
||||||
|
while idx < len(paras):
|
||||||
|
pc = len(get_sentences(paras[idx - 1]))
|
||||||
|
cc = len(get_sentences(paras[idx]))
|
||||||
|
if pc == cc:
|
||||||
|
cs = get_sentences(paras[idx])
|
||||||
|
if len(cs) > 2:
|
||||||
|
paras[idx - 1] += cs.pop(0)
|
||||||
|
paras[idx] = ''.join(cs)
|
||||||
|
elif len(get_sentences(paras[idx - 1])) > 2:
|
||||||
|
ps = get_sentences(paras[idx - 1])
|
||||||
|
last = ps.pop()
|
||||||
|
paras[idx - 1] = ''.join(ps)
|
||||||
|
paras[idx] = last + paras[idx]
|
||||||
|
else:
|
||||||
|
paras[idx - 1] += paras.pop(idx)
|
||||||
|
idx -= 1
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
# 保证每段至少 2 句
|
||||||
|
idx2 = 0
|
||||||
|
while idx2 < len(paras):
|
||||||
|
if len(get_sentences(paras[idx2])) == 1 and len(paras) > 1:
|
||||||
|
if idx2 > 0:
|
||||||
|
paras[idx2 - 1] += paras.pop(idx2)
|
||||||
|
idx2 -= 1
|
||||||
|
elif idx2 + 1 < len(paras):
|
||||||
|
paras[idx2 + 1] = paras.pop(idx2) + paras[idx2]
|
||||||
|
idx2 -= 1
|
||||||
|
idx2 += 1
|
||||||
|
return paras
|
||||||
|
|
||||||
|
|
||||||
|
def unify_punctuation(paragraph: str) -> str:
|
||||||
|
"""末句句号,其余句子改逗号"""
|
||||||
|
sents = re.findall(r'[^。!?!?]+[。!?!?]', paragraph)
|
||||||
|
if not sents:
|
||||||
|
return paragraph
|
||||||
|
sents = [s.strip() for s in sents]
|
||||||
|
result = []
|
||||||
|
for i, sent in enumerate(sents):
|
||||||
|
body = sent
|
||||||
|
for j in range(len(sent) - 1, -1, -1):
|
||||||
|
if sent[j] in '。!?':
|
||||||
|
body = sent[:j]
|
||||||
|
break
|
||||||
|
result.append(body + ('。' if i == len(sents) - 1 else ','))
|
||||||
|
return ''.join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def transform(text: str, mode: str = 'remove') -> str:
|
||||||
|
"""
|
||||||
|
mode='remove' → 去AI排版
|
||||||
|
mode='corner' → 转直角引号
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
return ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 统一预处理
|
||||||
|
text = normalize_text(text)
|
||||||
|
|
||||||
|
if mode == 'corner':
|
||||||
|
text = convert_quotes_to_corner(text)
|
||||||
|
cleaned = clean_symbols(text, keep_corner=True)
|
||||||
|
else:
|
||||||
|
cleaned = clean_symbols(text, keep_corner=False)
|
||||||
|
|
||||||
|
split_text = apply_split(cleaned)
|
||||||
|
|
||||||
|
sentences = get_sentences(split_text)
|
||||||
|
|
||||||
|
if not sentences:
|
||||||
|
return split_text
|
||||||
|
|
||||||
|
# 稳定随机(同一文件结果固定)
|
||||||
|
rnd = random.Random(hash(text))
|
||||||
|
|
||||||
|
paragraphs = group_paragraphs(sentences, rnd)
|
||||||
|
|
||||||
|
return '\n\n'.join(
|
||||||
|
unify_punctuation(p)
|
||||||
|
for p in paragraphs
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f'处理出错:{e}'
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 配色
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
C_GREEN = "#1f6e43"
|
||||||
|
C_GREEN_HOVER = "#155a38"
|
||||||
|
C_BLUE = "#2c6e8f"
|
||||||
|
C_BLUE_HOVER = "#1f5c7a"
|
||||||
|
C_SLATE = "#4a6f88"
|
||||||
|
C_SLATE_HOVER = "#375a70"
|
||||||
|
C_RED = "#a93226"
|
||||||
|
C_RED_HOVER = "#7b241c"
|
||||||
|
C_ORANGE = "#b7670a"
|
||||||
|
|
||||||
|
ctk.set_appearance_mode("System")
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# Toast 弹窗
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
class Toast(ctk.CTkToplevel):
|
||||||
|
def __init__(self, parent, msg: str, duration: int = 1600):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.title("")
|
||||||
|
self.geometry("320x72")
|
||||||
|
self.resizable(False, False)
|
||||||
|
self.grab_set()
|
||||||
|
px = parent.winfo_rootx() + parent.winfo_width() // 2 - 160
|
||||||
|
py = parent.winfo_rooty() + parent.winfo_height() // 2 - 36
|
||||||
|
self.geometry(f"+{px}+{py}")
|
||||||
|
ctk.CTkLabel(self, text=msg, font=ctk.CTkFont(size=13),
|
||||||
|
wraplength=280).pack(expand=True, padx=16)
|
||||||
|
self.after(duration, self.destroy)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 单篇处理 Tab
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
class SingleTab(ctk.CTkFrame):
|
||||||
|
def __init__(self, master, get_mode_fn, **kw):
|
||||||
|
super().__init__(master, fg_color="transparent", **kw)
|
||||||
|
self._get_mode = get_mode_fn
|
||||||
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_columnconfigure(1, weight=1)
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
# ── 左列 ──────────────────────────────────────────────
|
||||||
|
left = ctk.CTkFrame(self, corner_radius=18)
|
||||||
|
left.grid(row=0, column=0, sticky="nsew", padx=(0, 10), pady=0)
|
||||||
|
left.grid_rowconfigure(1, weight=1)
|
||||||
|
left.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
lh = ctk.CTkFrame(left, fg_color="transparent")
|
||||||
|
lh.grid(row=0, column=0, sticky="ew", padx=14, pady=(12, 0))
|
||||||
|
ctk.CTkLabel(lh, text="📄 原始文章",
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold")).pack(side="left")
|
||||||
|
for label, cmd, color, hov in [
|
||||||
|
("加载示例", self._load_example, C_BLUE, C_BLUE_HOVER),
|
||||||
|
("清 空", self._clear_left, C_SLATE, C_SLATE_HOVER),
|
||||||
|
("复制原文", self._copy_source, C_SLATE, C_SLATE_HOVER),
|
||||||
|
]:
|
||||||
|
ctk.CTkButton(lh, text=label, width=72, height=30, corner_radius=20,
|
||||||
|
fg_color=color, hover_color=hov,
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
command=cmd).pack(side="right", padx=3)
|
||||||
|
|
||||||
|
self._src = ctk.CTkTextbox(left, font=ctk.CTkFont(size=14),
|
||||||
|
wrap="word", corner_radius=12)
|
||||||
|
self._src.grid(row=1, column=0, sticky="nsew", padx=12, pady=10)
|
||||||
|
|
||||||
|
# ── 右列 ──────────────────────────────────────────────
|
||||||
|
right = ctk.CTkFrame(self, corner_radius=18)
|
||||||
|
right.grid(row=0, column=1, sticky="nsew", padx=(10, 0), pady=0)
|
||||||
|
right.grid_rowconfigure(1, weight=1)
|
||||||
|
right.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
rh = ctk.CTkFrame(right, fg_color="transparent")
|
||||||
|
rh.grid(row=0, column=0, sticky="ew", padx=14, pady=(12, 0))
|
||||||
|
ctk.CTkLabel(rh, text="✨ 调整后",
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold")).pack(side="left")
|
||||||
|
ctk.CTkButton(rh, text="复制结果", width=72, height=30, corner_radius=20,
|
||||||
|
fg_color=C_SLATE, hover_color=C_SLATE_HOVER,
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
command=self._copy_result).pack(side="right", padx=3)
|
||||||
|
|
||||||
|
self._res = ctk.CTkTextbox(right, font=ctk.CTkFont(size=14),
|
||||||
|
wrap="word", corner_radius=12)
|
||||||
|
self._res.grid(row=1, column=0, sticky="nsew", padx=12, pady=10)
|
||||||
|
self._set_result("等待调整… 请粘贴文章或点击「加载示例」后点击「一键调整」")
|
||||||
|
|
||||||
|
# ── 底部按钮 ───────────────────────────────────────────
|
||||||
|
bar = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
bar.grid(row=1, column=0, columnspan=2, pady=(8, 4))
|
||||||
|
ctk.CTkButton(bar, text="✨ 一键调整", width=180, height=46,
|
||||||
|
corner_radius=30, font=ctk.CTkFont(size=15, weight="bold"),
|
||||||
|
fg_color=C_GREEN, hover_color=C_GREEN_HOVER,
|
||||||
|
command=self._do_transform).pack(side="left", padx=12)
|
||||||
|
ctk.CTkButton(bar, text="🔄 同步并调整", width=180, height=46,
|
||||||
|
corner_radius=30, font=ctk.CTkFont(size=15),
|
||||||
|
fg_color=C_SLATE, hover_color=C_SLATE_HOVER,
|
||||||
|
command=self._do_transform).pack(side="left", padx=12)
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────
|
||||||
|
def _set_result(self, text: str):
|
||||||
|
self._res.configure(state="normal")
|
||||||
|
self._res.delete("1.0", "end")
|
||||||
|
self._res.insert("1.0", text)
|
||||||
|
|
||||||
|
def _toast(self, msg):
|
||||||
|
Toast(self.winfo_toplevel(), msg)
|
||||||
|
|
||||||
|
def _load_example(self):
|
||||||
|
self._src.delete("1.0", "end")
|
||||||
|
self._src.insert("1.0", EXAMPLE_TEXT)
|
||||||
|
self._set_result("示例已加载,点击「一键调整」查看效果。")
|
||||||
|
|
||||||
|
def _clear_left(self):
|
||||||
|
self._src.delete("1.0", "end")
|
||||||
|
self._set_result("左侧已清空,请粘贴新文章或加载示例。")
|
||||||
|
|
||||||
|
def _copy_source(self):
|
||||||
|
content = self._src.get("1.0", "end-1c")
|
||||||
|
if not content.strip():
|
||||||
|
self._toast("原文为空,无可复制内容。")
|
||||||
|
return
|
||||||
|
root = self.winfo_toplevel()
|
||||||
|
root.clipboard_clear(); root.clipboard_append(content)
|
||||||
|
self._toast("✅ 原文已复制")
|
||||||
|
|
||||||
|
def _copy_result(self):
|
||||||
|
content = self._res.get("1.0", "end-1c")
|
||||||
|
skip = ("等待调整", "左侧无内容", "左侧已清空", "示例已加载", "⚠️")
|
||||||
|
if not content.strip() or any(k in content for k in skip):
|
||||||
|
self._toast("没有可复制的结果,请先生成。")
|
||||||
|
return
|
||||||
|
root = self.winfo_toplevel()
|
||||||
|
root.clipboard_clear(); root.clipboard_append(content)
|
||||||
|
self._toast("✅ 结果已复制")
|
||||||
|
|
||||||
|
def _do_transform(self):
|
||||||
|
src = self._src.get("1.0", "end-1c")
|
||||||
|
if not src.strip():
|
||||||
|
self._set_result("⚠️ 左侧无内容,请粘贴文章或点击「加载示例」。")
|
||||||
|
return
|
||||||
|
self._set_result(transform(src, self._get_mode()))
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 批量处理 Tab
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
class BatchTab(ctk.CTkFrame):
|
||||||
|
def __init__(self, master, get_mode_fn, **kw):
|
||||||
|
super().__init__(master, fg_color="transparent", **kw)
|
||||||
|
self._get_mode = get_mode_fn
|
||||||
|
self._src_dir = ctk.StringVar()
|
||||||
|
self._dst_dir = ctk.StringVar()
|
||||||
|
self._export_var = ctk.StringVar(value="overwrite")
|
||||||
|
self._file_vars : list[tuple[Path, ctk.BooleanVar]] = []
|
||||||
|
self._cancel_evt = threading.Event()
|
||||||
|
self._running = False
|
||||||
|
# 左列撑满,右列固定宽度
|
||||||
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_columnconfigure(1, minsize=340, weight=0)
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
# ── 构建界面(左右双栏)────────────────────────────────────
|
||||||
|
def _build(self):
|
||||||
|
# ════════════════ 左栏:文件夹 + 文件列表 ════════════════
|
||||||
|
left = ctk.CTkFrame(self, corner_radius=16)
|
||||||
|
left.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
|
||||||
|
left.grid_rowconfigure(2, weight=1)
|
||||||
|
left.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# 源文件夹行
|
||||||
|
src_row = ctk.CTkFrame(left, fg_color="transparent")
|
||||||
|
src_row.grid(row=0, column=0, sticky="ew", padx=12, pady=(12, 6))
|
||||||
|
src_row.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(src_row, text="源文件夹",
|
||||||
|
font=ctk.CTkFont(size=12, weight="bold"),
|
||||||
|
width=64).grid(row=0, column=0, padx=(0, 6))
|
||||||
|
|
||||||
|
self._src_entry = ctk.CTkEntry(src_row, textvariable=self._src_dir,
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
placeholder_text="选择含 txt 文件的文件夹")
|
||||||
|
self._src_entry.grid(row=0, column=1, sticky="ew", padx=(0, 6))
|
||||||
|
|
||||||
|
ctk.CTkButton(src_row, text="选择", width=54, height=30, corner_radius=16,
|
||||||
|
fg_color=C_BLUE, hover_color=C_BLUE_HOVER,
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
command=self._pick_src).grid(row=0, column=2, padx=(0, 4))
|
||||||
|
|
||||||
|
ctk.CTkButton(src_row, text="扫描", width=54, height=30, corner_radius=16,
|
||||||
|
fg_color=C_SLATE, hover_color=C_SLATE_HOVER,
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
command=self._scan).grid(row=0, column=3)
|
||||||
|
|
||||||
|
# 列表栏头
|
||||||
|
lh = ctk.CTkFrame(left, fg_color="transparent")
|
||||||
|
lh.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 4))
|
||||||
|
|
||||||
|
self._file_count_label = ctk.CTkLabel(
|
||||||
|
lh, text="📂 尚未扫描",
|
||||||
|
font=ctk.CTkFont(size=12), anchor="w")
|
||||||
|
self._file_count_label.pack(side="left")
|
||||||
|
|
||||||
|
for label, cmd in [("全选", self._check_all), ("取消全选", self._uncheck_all)]:
|
||||||
|
ctk.CTkButton(lh, text=label, width=62, height=26, corner_radius=14,
|
||||||
|
fg_color=C_SLATE, hover_color=C_SLATE_HOVER,
|
||||||
|
font=ctk.CTkFont(size=11),
|
||||||
|
command=cmd).pack(side="right", padx=2)
|
||||||
|
|
||||||
|
# 可滚动 checkbox 区(撑满剩余高度)
|
||||||
|
self._scroll = ctk.CTkScrollableFrame(left, corner_radius=10)
|
||||||
|
self._scroll.grid(row=2, column=0, sticky="nsew", padx=10, pady=(0, 10))
|
||||||
|
self._scroll.grid_columnconfigure(0, weight=1)
|
||||||
|
self._scroll.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
self._placeholder = ctk.CTkLabel(
|
||||||
|
self._scroll,
|
||||||
|
text="扫描后文件列表将显示在此处",
|
||||||
|
text_color=("gray50", "gray60"),
|
||||||
|
font=ctk.CTkFont(size=12))
|
||||||
|
self._placeholder.grid(row=0, column=0, columnspan=2, pady=30)
|
||||||
|
|
||||||
|
# ════════════════ 右栏:导出 + 进度 + 按钮 ════════════════
|
||||||
|
right = ctk.CTkFrame(self, corner_radius=16)
|
||||||
|
right.grid(row=0, column=1, sticky="nsew", padx=(8, 0))
|
||||||
|
right.grid_rowconfigure(2, weight=1) # 日志区撑满
|
||||||
|
right.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# ── 导出设置 ──
|
||||||
|
exp_card = ctk.CTkFrame(right, corner_radius=12,
|
||||||
|
fg_color=("gray90", "gray20"))
|
||||||
|
exp_card.grid(row=0, column=0, sticky="ew", padx=12, pady=(12, 8))
|
||||||
|
exp_card.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(exp_card, text="导出方式",
|
||||||
|
font=ctk.CTkFont(size=12, weight="bold")
|
||||||
|
).grid(row=0, column=0, columnspan=3,
|
||||||
|
padx=12, pady=(10, 6), sticky="w")
|
||||||
|
|
||||||
|
self._radio_overwrite = ctk.CTkRadioButton(
|
||||||
|
exp_card, text="覆盖源文件",
|
||||||
|
variable=self._export_var, value="overwrite",
|
||||||
|
command=self._on_export_change,
|
||||||
|
font=ctk.CTkFont(size=12))
|
||||||
|
self._radio_overwrite.grid(row=1, column=0, padx=(12, 0), pady=(0, 8), sticky="w")
|
||||||
|
|
||||||
|
self._radio_saveto = ctk.CTkRadioButton(
|
||||||
|
exp_card, text="另存到文件夹",
|
||||||
|
variable=self._export_var, value="saveto",
|
||||||
|
command=self._on_export_change,
|
||||||
|
font=ctk.CTkFont(size=12))
|
||||||
|
self._radio_saveto.grid(row=1, column=1, padx=16, pady=(0, 8), sticky="w")
|
||||||
|
|
||||||
|
# 目标文件夹行(动态显示)
|
||||||
|
self._dst_row = ctk.CTkFrame(exp_card, fg_color="transparent")
|
||||||
|
self._dst_row.grid(row=2, column=0, columnspan=3,
|
||||||
|
sticky="ew", padx=8, pady=(0, 10))
|
||||||
|
self._dst_row.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self._dst_entry = ctk.CTkEntry(
|
||||||
|
self._dst_row, textvariable=self._dst_dir,
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
placeholder_text="选择结果保存位置")
|
||||||
|
self._dst_entry.grid(row=0, column=0, sticky="ew", padx=(0, 6))
|
||||||
|
|
||||||
|
ctk.CTkButton(self._dst_row, text="选择", width=54, height=30,
|
||||||
|
corner_radius=16, fg_color=C_BLUE, hover_color=C_BLUE_HOVER,
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
command=self._pick_dst).grid(row=0, column=1)
|
||||||
|
|
||||||
|
self._dst_row.grid_remove() # 默认隐藏
|
||||||
|
|
||||||
|
# ── 进度条 ──
|
||||||
|
prog_card = ctk.CTkFrame(right, corner_radius=12,
|
||||||
|
fg_color=("gray90", "gray20"))
|
||||||
|
prog_card.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 8))
|
||||||
|
prog_card.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
ph = ctk.CTkFrame(prog_card, fg_color="transparent")
|
||||||
|
ph.grid(row=0, column=0, sticky="ew", padx=12, pady=(10, 4))
|
||||||
|
ctk.CTkLabel(ph, text="处理进度",
|
||||||
|
font=ctk.CTkFont(size=12, weight="bold")).pack(side="left")
|
||||||
|
self._prog_label = ctk.CTkLabel(ph, text="",
|
||||||
|
font=ctk.CTkFont(size=11),
|
||||||
|
text_color=("gray40", "gray70"))
|
||||||
|
self._prog_label.pack(side="left", padx=8)
|
||||||
|
|
||||||
|
self._prog_bar = ctk.CTkProgressBar(prog_card, height=10, corner_radius=5)
|
||||||
|
self._prog_bar.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 10))
|
||||||
|
self._prog_bar.set(0)
|
||||||
|
|
||||||
|
# ── 日志(撑满剩余空间)──
|
||||||
|
log_card = ctk.CTkFrame(right, corner_radius=12,
|
||||||
|
fg_color=("gray90", "gray20"))
|
||||||
|
log_card.grid(row=2, column=0, sticky="nsew", padx=12, pady=(0, 8))
|
||||||
|
log_card.grid_rowconfigure(1, weight=1)
|
||||||
|
log_card.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(log_card, text="处理日志",
|
||||||
|
font=ctk.CTkFont(size=12, weight="bold"),
|
||||||
|
anchor="w").grid(row=0, column=0, sticky="w",
|
||||||
|
padx=12, pady=(8, 4))
|
||||||
|
|
||||||
|
self._log = ctk.CTkTextbox(log_card,
|
||||||
|
font=ctk.CTkFont(size=11, family="Courier"),
|
||||||
|
wrap="none", corner_radius=8, state="disabled")
|
||||||
|
self._log.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8))
|
||||||
|
|
||||||
|
# ── 按钮(固定在底部)──
|
||||||
|
btn_bar = ctk.CTkFrame(right, fg_color="transparent")
|
||||||
|
btn_bar.grid(row=3, column=0, sticky="ew", padx=12, pady=(0, 12))
|
||||||
|
btn_bar.grid_columnconfigure(0, weight=1)
|
||||||
|
btn_bar.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
self._start_btn = ctk.CTkButton(
|
||||||
|
btn_bar, text="▶ 开始处理", height=42,
|
||||||
|
corner_radius=24, font=ctk.CTkFont(size=14, weight="bold"),
|
||||||
|
fg_color=C_GREEN, hover_color=C_GREEN_HOVER,
|
||||||
|
command=self._start_batch)
|
||||||
|
self._start_btn.grid(row=0, column=0, sticky="ew", padx=(0, 5))
|
||||||
|
|
||||||
|
self._cancel_btn = ctk.CTkButton(
|
||||||
|
btn_bar, text="✖ 取消", height=42,
|
||||||
|
corner_radius=24, font=ctk.CTkFont(size=13),
|
||||||
|
fg_color=C_RED, hover_color=C_RED_HOVER,
|
||||||
|
state="disabled",
|
||||||
|
command=self._cancel_batch)
|
||||||
|
self._cancel_btn.grid(row=0, column=1, sticky="ew", padx=(5, 0))
|
||||||
|
|
||||||
|
# ── 文件夹选择 ────────────────────────────────────────────
|
||||||
|
def _pick_src(self):
|
||||||
|
d = filedialog.askdirectory(title="选择包含 txt 文件的文件夹")
|
||||||
|
if d:
|
||||||
|
self._src_dir.set(d)
|
||||||
|
self._scan()
|
||||||
|
|
||||||
|
def _pick_dst(self):
|
||||||
|
d = filedialog.askdirectory(title="选择结果保存位置")
|
||||||
|
if d:
|
||||||
|
self._dst_dir.set(d)
|
||||||
|
|
||||||
|
# ── 扫描文件夹 ────────────────────────────────────────────
|
||||||
|
def _scan(self):
|
||||||
|
src = self._src_dir.get().strip()
|
||||||
|
if not src or not os.path.isdir(src):
|
||||||
|
Toast(self.winfo_toplevel(), "⚠️ 请先选择有效的源文件夹")
|
||||||
|
return
|
||||||
|
files = sorted(Path(src).glob("*.txt"))
|
||||||
|
# 清空列表
|
||||||
|
for w in self._scroll.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
self._file_vars.clear()
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
self._file_count_label.configure(text="📂 未找到 txt 文件")
|
||||||
|
ctk.CTkLabel(self._scroll,
|
||||||
|
text="该文件夹内没有 .txt 文件",
|
||||||
|
text_color=("gray50", "gray60"),
|
||||||
|
font=ctk.CTkFont(size=12)).grid(row=0, column=0, pady=20)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._file_count_label.configure(
|
||||||
|
text=f"📂 找到 {len(files)} 个 txt 文件(可勾选/取消)")
|
||||||
|
|
||||||
|
for idx, fp in enumerate(files):
|
||||||
|
var = ctk.BooleanVar(value=True)
|
||||||
|
self._file_vars.append((fp, var))
|
||||||
|
col = idx % 2
|
||||||
|
row = idx // 2
|
||||||
|
cb = ctk.CTkCheckBox(self._scroll, text=fp.name,
|
||||||
|
variable=var,
|
||||||
|
font=ctk.CTkFont(size=12))
|
||||||
|
cb.grid(row=row, column=col, sticky="w", padx=12, pady=3)
|
||||||
|
|
||||||
|
self._log_clear()
|
||||||
|
self._prog_bar.set(0)
|
||||||
|
self._prog_label.configure(text="")
|
||||||
|
|
||||||
|
# ── 全选 / 取消全选 ───────────────────────────────────────
|
||||||
|
def _check_all(self):
|
||||||
|
for _, var in self._file_vars:
|
||||||
|
var.set(True)
|
||||||
|
|
||||||
|
def _uncheck_all(self):
|
||||||
|
for _, var in self._file_vars:
|
||||||
|
var.set(False)
|
||||||
|
|
||||||
|
# ── 导出方式切换 ──────────────────────────────────────────
|
||||||
|
def _on_export_change(self):
|
||||||
|
if self._export_var.get() == "saveto":
|
||||||
|
self._dst_row.grid()
|
||||||
|
else:
|
||||||
|
self._dst_row.grid_remove()
|
||||||
|
|
||||||
|
# ── 日志操作 ──────────────────────────────────────────────
|
||||||
|
def _log_clear(self):
|
||||||
|
self._log.configure(state="normal")
|
||||||
|
self._log.delete("1.0", "end")
|
||||||
|
self._log.configure(state="disabled")
|
||||||
|
|
||||||
|
def _log_append(self, line: str):
|
||||||
|
"""线程安全地向日志追加一行"""
|
||||||
|
def _do():
|
||||||
|
self._log.configure(state="normal")
|
||||||
|
self._log.insert("end", line + "\n")
|
||||||
|
self._log.see("end")
|
||||||
|
self._log.configure(state="disabled")
|
||||||
|
self.after(0, _do)
|
||||||
|
|
||||||
|
# ── 批量处理 ──────────────────────────────────────────────
|
||||||
|
def _start_batch(self):
|
||||||
|
checked = [(fp, var) for fp, var in self._file_vars if var.get()]
|
||||||
|
if not checked:
|
||||||
|
Toast(self.winfo_toplevel(), "⚠️ 请先扫描文件夹并至少勾选一个文件")
|
||||||
|
return
|
||||||
|
|
||||||
|
export = self._export_var.get()
|
||||||
|
dst_dir = self._dst_dir.get().strip()
|
||||||
|
if export == "saveto":
|
||||||
|
if not dst_dir:
|
||||||
|
Toast(self.winfo_toplevel(), "⚠️ 请先选择结果保存的目标文件夹")
|
||||||
|
return
|
||||||
|
if not os.path.isdir(dst_dir):
|
||||||
|
try:
|
||||||
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
Toast(self.winfo_toplevel(), f"⚠️ 无法创建目标文件夹:{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
mode = self._get_mode()
|
||||||
|
|
||||||
|
# 锁定 UI
|
||||||
|
self._running = True
|
||||||
|
self._cancel_evt.clear()
|
||||||
|
self._start_btn.configure(state="disabled")
|
||||||
|
self._cancel_btn.configure(state="normal")
|
||||||
|
self._log_clear()
|
||||||
|
self._prog_bar.set(0)
|
||||||
|
self._prog_label.configure(text=f"0 / {len(checked)}")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=self._worker,
|
||||||
|
args=(checked, export, dst_dir, mode),
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _worker(self, checked: list, export: str, dst_dir: str, mode: str):
|
||||||
|
total = len(checked)
|
||||||
|
success = 0
|
||||||
|
fail = 0
|
||||||
|
|
||||||
|
for idx, (fp, _) in enumerate(checked):
|
||||||
|
if self._cancel_evt.is_set():
|
||||||
|
self._log_append(f"⛔ 已取消(已处理 {idx}/{total})")
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = fp.read_text(encoding="utf-8", errors="replace")
|
||||||
|
result = transform(raw, mode)
|
||||||
|
|
||||||
|
if export == "overwrite":
|
||||||
|
fp.write_text(result, encoding="utf-8")
|
||||||
|
out_path = fp
|
||||||
|
else:
|
||||||
|
out_path = Path(dst_dir) / fp.name
|
||||||
|
out_path.write_text(result, encoding="utf-8")
|
||||||
|
|
||||||
|
success += 1
|
||||||
|
self._log_append(f"✅ {fp.name} → {out_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
fail += 1
|
||||||
|
self._log_append(f"❌ {fp.name} 错误:{e}")
|
||||||
|
|
||||||
|
# 更新进度(回主线程)
|
||||||
|
done = idx + 1
|
||||||
|
pct = done / total
|
||||||
|
def _update(d=done, p=pct):
|
||||||
|
self._prog_bar.set(p)
|
||||||
|
self._prog_label.configure(
|
||||||
|
text=f"{d} / {total} ({int(p*100)}%)")
|
||||||
|
self.after(0, _update)
|
||||||
|
|
||||||
|
# 完成
|
||||||
|
def _finish():
|
||||||
|
self._running = False
|
||||||
|
self._start_btn.configure(state="normal")
|
||||||
|
self._cancel_btn.configure(state="disabled")
|
||||||
|
if not self._cancel_evt.is_set():
|
||||||
|
self._prog_bar.set(1)
|
||||||
|
self._prog_label.configure(text=f"完成 ✅ 成功 {success},失败 {fail}")
|
||||||
|
self._log_append(
|
||||||
|
f"\n── 全部处理完毕:{success} 成功,{fail} 失败 ──")
|
||||||
|
Toast(self.winfo_toplevel(),
|
||||||
|
f"✅ 批量处理完成\n成功 {success} 个,失败 {fail} 个",
|
||||||
|
duration=2500)
|
||||||
|
self.after(0, _finish)
|
||||||
|
|
||||||
|
def _cancel_batch(self):
|
||||||
|
self._cancel_evt.set()
|
||||||
|
self._cancel_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =======================
|
||||||
|
# 预处理函数
|
||||||
|
# =======================
|
||||||
|
def normalize_text(text: str) -> str:
|
||||||
|
"""统一清洗 txt 内容"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 去 BOM
|
||||||
|
text = text.replace("\ufeff", "")
|
||||||
|
|
||||||
|
# 统一换行
|
||||||
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
# 制表符转空格
|
||||||
|
text = text.replace("\t", " ")
|
||||||
|
|
||||||
|
# 特殊空格
|
||||||
|
text = text.replace("\u3000", " ")
|
||||||
|
|
||||||
|
# 合并多余空格
|
||||||
|
text = re.sub(r"[ ]{2,}", " ", text)
|
||||||
|
|
||||||
|
# 合并多余空行
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
|
||||||
|
text = text.strip()
|
||||||
|
|
||||||
|
# 最后一句没标点时自动补句号
|
||||||
|
if text and text[-1] not in "。!?!?":
|
||||||
|
text += "。"
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 主窗口
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
class App(ctk.CTk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("✍️ 文章处理工具")
|
||||||
|
self.geometry("1300x840")
|
||||||
|
self.minsize(940, 680)
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# ── 顶部 Hero ──────────────────────────────────────────
|
||||||
|
hero = ctk.CTkFrame(self, corner_radius=18)
|
||||||
|
hero.grid(row=0, column=0, sticky="ew", padx=20, pady=(16, 8))
|
||||||
|
hero.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(hero, text="✍️ 文章处理工具",
|
||||||
|
font=ctk.CTkFont(size=22, weight="bold")
|
||||||
|
).grid(row=0, column=0, padx=20, pady=(14, 4), sticky="w")
|
||||||
|
|
||||||
|
ctk.CTkLabel(hero,
|
||||||
|
text="过滤符号 · 长句拆分 · 段落分组(2~4句)· 末句句号,其余逗号 | 支持单篇 & 批量处理",
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
text_color=("gray40", "gray70"),
|
||||||
|
).grid(row=1, column=0, columnspan=2,
|
||||||
|
padx=24, pady=(0, 14), sticky="w")
|
||||||
|
|
||||||
|
# 全局模式切换(单篇 + 批量共用)
|
||||||
|
self._mode_btn = ctk.CTkSegmentedButton(
|
||||||
|
hero,
|
||||||
|
values=["🧹 去AI排版", "「」 直角引号版"],
|
||||||
|
font=ctk.CTkFont(size=13),
|
||||||
|
width=290,
|
||||||
|
)
|
||||||
|
self._mode_btn.set("🧹 去AI排版")
|
||||||
|
self._mode_btn.grid(row=0, column=2, padx=20, pady=14, sticky="e")
|
||||||
|
|
||||||
|
# ── Tab 视图 ───────────────────────────────────────────
|
||||||
|
self._tabs = ctk.CTkTabview(self, corner_radius=18)
|
||||||
|
self._tabs.grid(row=1, column=0, sticky="nsew", padx=20, pady=(0, 16))
|
||||||
|
self._tabs.grid_columnconfigure(0, weight=1)
|
||||||
|
self._tabs.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self._tabs.add("📝 单篇处理")
|
||||||
|
self._tabs.add("📦 批量处理")
|
||||||
|
|
||||||
|
# 单篇 Tab
|
||||||
|
single_host = self._tabs.tab("📝 单篇处理")
|
||||||
|
single_host.grid_rowconfigure(0, weight=1)
|
||||||
|
single_host.grid_columnconfigure(0, weight=1)
|
||||||
|
self._single = SingleTab(single_host, self._get_mode)
|
||||||
|
self._single.grid(row=0, column=0, sticky="nsew")
|
||||||
|
|
||||||
|
# 批量 Tab
|
||||||
|
batch_host = self._tabs.tab("📦 批量处理")
|
||||||
|
batch_host.grid_columnconfigure(0, weight=1)
|
||||||
|
self._batch = BatchTab(batch_host, self._get_mode)
|
||||||
|
self._batch.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
|
||||||
|
|
||||||
|
def _get_mode(self) -> str:
|
||||||
|
return 'corner' if '直角' in self._mode_btn.get() else 'remove'
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = App()
|
||||||
|
app.mainloop()
|
||||||
48
README.md
Normal file
48
README.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# 文章处理工具
|
||||||
|
|
||||||
|
将 HTML 工具迁移为 Python 桌面应用(CustomTkinter GUI)。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
| 模式 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 🧹 去AI排版 | 删除引号、星号、下划线等 AI 排版符号 |
|
||||||
|
| 「」直角引号版 | 将双引号转为直角引号「」,其余符号同样清理 |
|
||||||
|
|
||||||
|
两种模式共用处理流程:
|
||||||
|
1. 符号清理
|
||||||
|
2. 长句拆分(>15字 + 含全角逗号 → 在首个逗号处断句,递归)
|
||||||
|
3. 段落分组(每段 2~4 句,相邻段句数不同)
|
||||||
|
4. 标点统一(末句句号,其余逗号)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install customtkinter
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 打包为 EXE(Windows)
|
||||||
|
|
||||||
|
```bat
|
||||||
|
build_win.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
打包完成后在 `dist/` 目录找到 `文章处理工具.exe`,双击即用,无需安装 Python。
|
||||||
|
|
||||||
|
## 打包为 App(macOS / Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x build_unix.sh
|
||||||
|
./build_unix.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
article_tool/
|
||||||
|
├── main.py # 全部源码(逻辑 + GUI)
|
||||||
|
├── requirements.txt # 依赖
|
||||||
|
├── build_win.bat # Windows 打包脚本
|
||||||
|
└── build_unix.sh # macOS / Linux 打包脚本
|
||||||
|
```
|
||||||
396
main.py
Normal file
396
main.py
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
"""
|
||||||
|
文章处理工具 · 去AI排版 + 直角引号版
|
||||||
|
Core logic ported from HTML/JS → Python
|
||||||
|
GUI: CustomTkinter
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 示例文本
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
EXAMPLE_TEXT = (
|
||||||
|
'他说:"时间会证明一切"。*这是一个星号标记*,还有下划线_test_。'
|
||||||
|
'实际上,这段话里包含引号、星号、下划线等复杂符号。'
|
||||||
|
'我们的工具应该能自动删除这些符号,只保留常规标点,比如逗号,句号。'
|
||||||
|
'感叹号!问号?冒号:以及书名号《西游记》。'
|
||||||
|
'长句子超过十五个字符的时候,我们会在逗号处强制拆分成短句。'
|
||||||
|
'另外,段落会随机分成2-4句一段,并且相邻段落的句子数量不会相同。'
|
||||||
|
'每个段落内部,除了最后一个句子末尾是句号,其他句子末尾都会变成逗号。'
|
||||||
|
'这是一个特别长的句子,它里面有很多内容,比如我们故意写很多字,'
|
||||||
|
'让它超过十五个字符,并且中间有逗号,那么它就会被拆开。'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 核心处理逻辑(移植自 JS)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def convert_quotes_to_corner(text: str) -> str:
|
||||||
|
"""双引号 → 直角引号「」(DeepSeek版专用)"""
|
||||||
|
# 全角左右双引号
|
||||||
|
text = text.replace('\u201c', '「').replace('\u201d', '」')
|
||||||
|
# 全角双引号变体 "(U+FF02) → 半角预备配对
|
||||||
|
text = text.replace('\uff02', '"')
|
||||||
|
# 半角双引号顺序配对
|
||||||
|
out, left_flag = [], True
|
||||||
|
for ch in text:
|
||||||
|
if ch == '"':
|
||||||
|
out.append('「' if left_flag else '」')
|
||||||
|
left_flag = not left_flag
|
||||||
|
else:
|
||||||
|
out.append(ch)
|
||||||
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_symbols(text: str, keep_corner: bool = False) -> str:
|
||||||
|
"""过滤复杂符号,只保留汉字 / 数字 / 字母 / 常规标点"""
|
||||||
|
if keep_corner:
|
||||||
|
pattern = r'[^\u4e00-\u9fff0-9a-zA-Z\s,。!?:《》,.!?:「」]'
|
||||||
|
else:
|
||||||
|
pattern = r'[^\u4e00-\u9fff0-9a-zA-Z\s,。!?:《》,.!?:]'
|
||||||
|
return re.sub(pattern, '', text)
|
||||||
|
|
||||||
|
|
||||||
|
def split_long_sentence(sentence: str) -> list[str]:
|
||||||
|
"""长句强制拆分(递归):长度 > 15 且含全角逗号 → 在第一个逗号处断开"""
|
||||||
|
s = sentence.strip()
|
||||||
|
if len(s) <= 15:
|
||||||
|
return [s]
|
||||||
|
comma_pos = -1
|
||||||
|
for i, ch in enumerate(s):
|
||||||
|
if ch == ',' and 2 < i < len(s) - 2:
|
||||||
|
comma_pos = i
|
||||||
|
break
|
||||||
|
if comma_pos == -1:
|
||||||
|
return [s]
|
||||||
|
chars = list(s)
|
||||||
|
chars[comma_pos] = '。'
|
||||||
|
new_text = ''.join(chars)
|
||||||
|
parts = new_text.split('。')
|
||||||
|
if len(parts) < 2:
|
||||||
|
return [s]
|
||||||
|
first = parts[0] + '。'
|
||||||
|
rest = '。'.join(parts[1:])
|
||||||
|
second = rest if new_text.endswith('。') else rest + '。'
|
||||||
|
return split_long_sentence(first) + split_long_sentence(second)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_split(text: str) -> str:
|
||||||
|
"""对整段文本执行长句拆分"""
|
||||||
|
raws = re.findall(r'[^。!?!?]+[。!?!?]', text)
|
||||||
|
if not raws:
|
||||||
|
return text
|
||||||
|
result = []
|
||||||
|
for sent in raws:
|
||||||
|
result.extend(split_long_sentence(sent))
|
||||||
|
return ''.join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sentences(text: str) -> list[str]:
|
||||||
|
"""提取句子列表"""
|
||||||
|
matches = re.findall(r'[^。!?!?]+[。!?!?]', text)
|
||||||
|
return [s.strip() for s in matches if s.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def group_paragraphs(sentences: list[str]) -> list[str]:
|
||||||
|
"""段落分组:每段 2~4 句,相邻段句数不同"""
|
||||||
|
if not sentences:
|
||||||
|
return []
|
||||||
|
paras, i, total = [], 0, len(sentences)
|
||||||
|
while i < total:
|
||||||
|
size = random.randint(2, 4)
|
||||||
|
if total - i == 1 and paras:
|
||||||
|
paras[-1] += sentences[i]
|
||||||
|
break
|
||||||
|
end = min(i + size, total)
|
||||||
|
if total - end == 1 and end < total:
|
||||||
|
end -= 1
|
||||||
|
if end <= i:
|
||||||
|
end = i + 1
|
||||||
|
paras.append(''.join(sentences[i:end]))
|
||||||
|
i = end
|
||||||
|
|
||||||
|
# 相邻段句数不同
|
||||||
|
idx = 1
|
||||||
|
while idx < len(paras):
|
||||||
|
pc = len(get_sentences(paras[idx - 1]))
|
||||||
|
cc = len(get_sentences(paras[idx]))
|
||||||
|
if pc == cc:
|
||||||
|
cs = get_sentences(paras[idx])
|
||||||
|
if len(cs) > 2:
|
||||||
|
paras[idx - 1] += cs.pop(0)
|
||||||
|
paras[idx] = ''.join(cs)
|
||||||
|
elif len(get_sentences(paras[idx - 1])) > 2:
|
||||||
|
ps = get_sentences(paras[idx - 1])
|
||||||
|
last = ps.pop()
|
||||||
|
paras[idx - 1] = ''.join(ps)
|
||||||
|
paras[idx] = last + paras[idx]
|
||||||
|
else:
|
||||||
|
paras[idx - 1] += paras.pop(idx)
|
||||||
|
idx -= 1
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
# 每段至少 2 句
|
||||||
|
idx2 = 0
|
||||||
|
while idx2 < len(paras):
|
||||||
|
if len(get_sentences(paras[idx2])) == 1 and len(paras) > 1:
|
||||||
|
if idx2 > 0:
|
||||||
|
paras[idx2 - 1] += paras.pop(idx2)
|
||||||
|
idx2 -= 1
|
||||||
|
elif idx2 + 1 < len(paras):
|
||||||
|
paras[idx2 + 1] = paras.pop(idx2) + paras[idx2]
|
||||||
|
idx2 -= 1
|
||||||
|
idx2 += 1
|
||||||
|
return paras
|
||||||
|
|
||||||
|
|
||||||
|
def unify_punctuation(paragraph: str) -> str:
|
||||||
|
"""末句句号,其余句子改逗号"""
|
||||||
|
sents = re.findall(r'[^。!?!?]+[。!?!?]', paragraph)
|
||||||
|
if not sents:
|
||||||
|
return paragraph
|
||||||
|
sents = [s.strip() for s in sents]
|
||||||
|
result = []
|
||||||
|
for i, sent in enumerate(sents):
|
||||||
|
body = sent
|
||||||
|
for j in range(len(sent) - 1, -1, -1):
|
||||||
|
if sent[j] in '。!?':
|
||||||
|
body = sent[:j]
|
||||||
|
break
|
||||||
|
result.append(body + ('。' if i == len(sents) - 1 else ','))
|
||||||
|
return ''.join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def transform(text: str, mode: str = 'remove') -> str:
|
||||||
|
"""
|
||||||
|
主处理入口
|
||||||
|
mode='remove' → 去AI排版(删除引号)
|
||||||
|
mode='corner' → 转直角引号版
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
if mode == 'corner':
|
||||||
|
text = convert_quotes_to_corner(text)
|
||||||
|
cleaned = clean_symbols(text, keep_corner=True)
|
||||||
|
else:
|
||||||
|
cleaned = clean_symbols(text, keep_corner=False)
|
||||||
|
|
||||||
|
split_text = apply_split(cleaned)
|
||||||
|
sentences = get_sentences(split_text)
|
||||||
|
if not sentences:
|
||||||
|
return split_text
|
||||||
|
paragraphs = group_paragraphs(sentences)
|
||||||
|
return '\n\n'.join(unify_punctuation(p) for p in paragraphs)
|
||||||
|
except Exception as e:
|
||||||
|
return f'处理出错:{e}'
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# GUI
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 配色常量
|
||||||
|
C_GREEN = "#1f6e43"
|
||||||
|
C_GREEN_HOVER = "#155a38"
|
||||||
|
C_BLUE = "#2c6e8f"
|
||||||
|
C_BLUE_HOVER = "#1f5c7a"
|
||||||
|
C_SLATE = "#4a6f88"
|
||||||
|
C_SLATE_HOVER = "#375a70"
|
||||||
|
|
||||||
|
ctk.set_appearance_mode("System")
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
|
||||||
|
class Toast(ctk.CTkToplevel):
|
||||||
|
"""轻量 toast 弹窗(1.5 s 自动关闭)"""
|
||||||
|
def __init__(self, parent, msg: str):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.title("")
|
||||||
|
self.geometry("300x70")
|
||||||
|
self.resizable(False, False)
|
||||||
|
self.grab_set()
|
||||||
|
# 居中到父窗口
|
||||||
|
px = parent.winfo_rootx() + parent.winfo_width() // 2 - 150
|
||||||
|
py = parent.winfo_rooty() + parent.winfo_height() // 2 - 35
|
||||||
|
self.geometry(f"+{px}+{py}")
|
||||||
|
ctk.CTkLabel(self, text=msg, font=ctk.CTkFont(size=13)).pack(expand=True)
|
||||||
|
self.after(1500, self.destroy)
|
||||||
|
|
||||||
|
|
||||||
|
class App(ctk.CTk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("✍️ 文章处理工具")
|
||||||
|
self.geometry("1280x800")
|
||||||
|
self.minsize(900, 620)
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
# ── 构建界面 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# ── 顶部 Hero ──────────────────────────────────────────
|
||||||
|
hero = ctk.CTkFrame(self, corner_radius=18)
|
||||||
|
hero.grid(row=0, column=0, sticky="ew", padx=20, pady=(16, 8))
|
||||||
|
hero.grid_columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
hero,
|
||||||
|
text="✍️ 文章处理工具",
|
||||||
|
font=ctk.CTkFont(size=22, weight="bold"),
|
||||||
|
).grid(row=0, column=0, padx=20, pady=(14, 4), sticky="w")
|
||||||
|
|
||||||
|
sub = ctk.CTkLabel(
|
||||||
|
hero,
|
||||||
|
text="过滤符号 · 长句拆分 · 段落分组(2~4句)· 末句句号,其余逗号",
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
text_color=("gray40", "gray70"),
|
||||||
|
)
|
||||||
|
sub.grid(row=1, column=0, columnspan=2, padx=24, pady=(0, 14), sticky="w")
|
||||||
|
|
||||||
|
# 模式切换
|
||||||
|
self._mode_btn = ctk.CTkSegmentedButton(
|
||||||
|
hero,
|
||||||
|
values=["🧹 去AI排版", "「」 直角引号版"],
|
||||||
|
font=ctk.CTkFont(size=13),
|
||||||
|
width=280,
|
||||||
|
)
|
||||||
|
self._mode_btn.set("🧹 去AI排版")
|
||||||
|
self._mode_btn.grid(row=0, column=2, padx=20, pady=14, sticky="e")
|
||||||
|
|
||||||
|
# ── 两列文本区 ─────────────────────────────────────────
|
||||||
|
columns = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
columns.grid(row=1, column=0, sticky="nsew", padx=20, pady=0)
|
||||||
|
columns.grid_columnconfigure(0, weight=1)
|
||||||
|
columns.grid_columnconfigure(1, weight=1)
|
||||||
|
columns.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# 左列
|
||||||
|
left = ctk.CTkFrame(columns, corner_radius=18)
|
||||||
|
left.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
|
||||||
|
left.grid_rowconfigure(1, weight=1)
|
||||||
|
left.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
lh = ctk.CTkFrame(left, fg_color="transparent")
|
||||||
|
lh.grid(row=0, column=0, sticky="ew", padx=14, pady=(12, 0))
|
||||||
|
ctk.CTkLabel(lh, text="📄 原始文章",
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold")).pack(side="left")
|
||||||
|
|
||||||
|
for text, cmd, color, hov in [
|
||||||
|
("加载示例", self._load_example, C_BLUE, C_BLUE_HOVER),
|
||||||
|
("清 空", self._clear_left, C_SLATE, C_SLATE_HOVER),
|
||||||
|
("复制原文", self._copy_source, C_SLATE, C_SLATE_HOVER),
|
||||||
|
]:
|
||||||
|
ctk.CTkButton(lh, text=text, width=72, height=30, corner_radius=20,
|
||||||
|
fg_color=color, hover_color=hov,
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
command=cmd).pack(side="right", padx=3)
|
||||||
|
|
||||||
|
self._src = ctk.CTkTextbox(left, font=ctk.CTkFont(size=14),
|
||||||
|
wrap="word", corner_radius=12)
|
||||||
|
self._src.grid(row=1, column=0, sticky="nsew", padx=12, pady=10)
|
||||||
|
|
||||||
|
# 右列
|
||||||
|
right = ctk.CTkFrame(columns, corner_radius=18)
|
||||||
|
right.grid(row=0, column=1, sticky="nsew", padx=(10, 0))
|
||||||
|
right.grid_rowconfigure(1, weight=1)
|
||||||
|
right.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
rh = ctk.CTkFrame(right, fg_color="transparent")
|
||||||
|
rh.grid(row=0, column=0, sticky="ew", padx=14, pady=(12, 0))
|
||||||
|
ctk.CTkLabel(rh, text="✨ 调整后",
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold")).pack(side="left")
|
||||||
|
ctk.CTkButton(rh, text="复制结果", width=72, height=30, corner_radius=20,
|
||||||
|
fg_color=C_SLATE, hover_color=C_SLATE_HOVER,
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
command=self._copy_result).pack(side="right", padx=3)
|
||||||
|
|
||||||
|
self._res = ctk.CTkTextbox(right, font=ctk.CTkFont(size=14),
|
||||||
|
wrap="word", corner_radius=12)
|
||||||
|
self._res.grid(row=1, column=0, sticky="nsew", padx=12, pady=10)
|
||||||
|
self._set_result("等待调整… 请粘贴文章或点击「加载示例」后点击「一键调整」")
|
||||||
|
|
||||||
|
# ── 底部操作栏 ─────────────────────────────────────────
|
||||||
|
bar = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
bar.grid(row=2, column=0, pady=(6, 18))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
bar, text="✨ 一键调整", width=180, height=48,
|
||||||
|
corner_radius=30, font=ctk.CTkFont(size=15, weight="bold"),
|
||||||
|
fg_color=C_GREEN, hover_color=C_GREEN_HOVER,
|
||||||
|
command=self._do_transform,
|
||||||
|
).pack(side="left", padx=12)
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
bar, text="🔄 同步并调整", width=180, height=48,
|
||||||
|
corner_radius=30, font=ctk.CTkFont(size=15),
|
||||||
|
fg_color=C_SLATE, hover_color=C_SLATE_HOVER,
|
||||||
|
command=self._do_transform,
|
||||||
|
).pack(side="left", padx=12)
|
||||||
|
|
||||||
|
# ── 辅助方法 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_mode(self) -> str:
|
||||||
|
return 'corner' if '直角' in self._mode_btn.get() else 'remove'
|
||||||
|
|
||||||
|
def _get_source(self) -> str:
|
||||||
|
return self._src.get("1.0", "end-1c")
|
||||||
|
|
||||||
|
def _set_result(self, text: str):
|
||||||
|
self._res.configure(state="normal")
|
||||||
|
self._res.delete("1.0", "end")
|
||||||
|
self._res.insert("1.0", text)
|
||||||
|
|
||||||
|
def _toast(self, msg: str):
|
||||||
|
Toast(self, msg)
|
||||||
|
|
||||||
|
# ── 按钮回调 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_example(self):
|
||||||
|
self._src.delete("1.0", "end")
|
||||||
|
self._src.insert("1.0", EXAMPLE_TEXT)
|
||||||
|
self._set_result("示例已加载,点击「一键调整」查看效果。")
|
||||||
|
|
||||||
|
def _clear_left(self):
|
||||||
|
self._src.delete("1.0", "end")
|
||||||
|
self._set_result("左侧已清空,请粘贴新文章或加载示例。")
|
||||||
|
|
||||||
|
def _copy_source(self):
|
||||||
|
content = self._get_source()
|
||||||
|
if not content.strip():
|
||||||
|
self._toast("原文为空,无可复制内容。")
|
||||||
|
return
|
||||||
|
self.clipboard_clear()
|
||||||
|
self.clipboard_append(content)
|
||||||
|
self._toast("✅ 原文已复制")
|
||||||
|
|
||||||
|
def _copy_result(self):
|
||||||
|
content = self._res.get("1.0", "end-1c")
|
||||||
|
skip = ("等待调整", "左侧无内容", "左侧已清空", "示例已加载", "⚠️")
|
||||||
|
if not content.strip() or any(k in content for k in skip):
|
||||||
|
self._toast("没有可复制的结果,请先生成。")
|
||||||
|
return
|
||||||
|
self.clipboard_clear()
|
||||||
|
self.clipboard_append(content)
|
||||||
|
self._toast("✅ 结果已复制")
|
||||||
|
|
||||||
|
def _do_transform(self):
|
||||||
|
src = self._get_source()
|
||||||
|
if not src.strip():
|
||||||
|
self._set_result("⚠️ 左侧无内容,请粘贴文章或点击「加载示例」。")
|
||||||
|
return
|
||||||
|
result = transform(src, self._get_mode())
|
||||||
|
self._set_result(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = App()
|
||||||
|
app.mainloop()
|
||||||
38
main.spec
Normal file
38
main.spec
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['main.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='main',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
customtkinter>=5.2.0
|
||||||
|
pyinstaller>=6.0.0
|
||||||
Loading…
Reference in New Issue
Block a user