From 02172caf18ef74d4fa7df2fabddfe0f3ffbf2b9e Mon Sep 17 00:00:00 2001 From: taiyi Date: Fri, 22 May 2026 18:52:43 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=AC=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 + .idea/.gitignore | 16 + .idea/article_tool_project.iml | 12 + JiangAIP.py | 833 +++++++++++++++++++++++++++++++++ README.md | 48 ++ main.py | 396 ++++++++++++++++ main.spec | 38 ++ requirements.txt | 2 + 8 files changed, 1354 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/article_tool_project.iml create mode 100644 JiangAIP.py create mode 100644 README.md create mode 100644 main.py create mode 100644 main.spec create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..041c26d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ + +build +dist +*.exe + +*.xml + +*.sh +*.bat diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..55508a5 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/article_tool_project.iml b/.idea/article_tool_project.iml new file mode 100644 index 0000000..fa893c9 --- /dev/null +++ b/.idea/article_tool_project.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/JiangAIP.py b/JiangAIP.py new file mode 100644 index 0000000..7c12841 --- /dev/null +++ b/JiangAIP.py @@ -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() \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..753a408 --- /dev/null +++ b/README.md @@ -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 打包脚本 +``` diff --git a/main.py b/main.py new file mode 100644 index 0000000..05f9dae --- /dev/null +++ b/main.py @@ -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() diff --git a/main.spec b/main.spec new file mode 100644 index 0000000..2ba8dd9 --- /dev/null +++ b/main.spec @@ -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, +) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..78a0b3e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +customtkinter>=5.2.0 +pyinstaller>=6.0.0