Markdown+Git+脚本:长文写作与多平台投稿管理
很多人在写一篇几万字的个人经历长文时,第一反应是打开 Word 或者在线文档,从头写到尾。写到一半发现结构乱了,想大改,只能靠“另存为 最终版 2 修订版”;想投给几个不同的平台,每个平台对标题层级、字数、配图位置、正文格式的要求都不一样,只能人工复制粘贴反复调整;更麻烦的是,文章里写到的真实姓名、电话、住址、单位、时间线,稍不注意就在公开发布的版本里留了下来。这些问题跟文笔无关,本质上是写作过程缺少工程管理。
这篇文章要讲的,就是一套用技术手段管理长文创作与投稿的完整方案:用 Markdown 作为唯一的内容来源,用 Git 管理版本和备份,用脚本做发布前的隐私自检和格式导出,用静态站点托管可控的发布渠道,用自动化流水线把重复劳动交给机器。它的目标读者不是“想学写作技巧的人”,而是已经写了一篇长文,正卡在整理、修改、投稿、保密这几个环节上的人。
先给一个明确判断:对于超过一万字、需要反复修改、需要投递多个渠道、并且包含大量真实个人信息的长文,用“文档软件 + 手工复制”的方式管理,出错概率远高于收益。你省下的那点学习成本,会在一次隐私泄露或者一次版本覆盖里全部还回去。而下面这套方案的学习成本,熟悉命令行的人一个下午就能跑通,不熟悉的人照着做也能在两天内成型。
全文按“为什么需要—概念与选型—环境准备—工作流拆解—两个核心脚本—发布与验证—问题排查—长期实践”的顺序展开,每一节都能单独拿去用。代码和配置都可以直接复制运行,重点不是炫技,而是让你在写完之后,能安全、完整、低损耗地把稿子交出去。
1. 为什么“写长文”这件事值得用工程方法解决
先说一个很常见的场景。一篇两万字的个人经历长文,写到第三稿的时候,作者通常已经分不清哪一版是最新的。改一个段落,要来回翻找前后文;删掉一段回忆,又担心以后想加回来。这种状态下的写作效率是极低的,因为每改一次都要重新建立上下文,而人脑并不擅长在几万字里做精确的文本定位。
再看投稿环节。不同渠道对稿件的格式要求差别很大:有的要求纯文本、段落之间空一行;有的要求 Markdown、标题用##;有的要求 Word 文档、正文小四号字;还有的要求先提交一个大纲和字数统计。如果每一份都要手工整理,一份稿子投五个渠道,就是五次重复劳动,而且每次都可能漏掉某一段,或者把不同版本的句子混进去。这种损耗看起来不起眼,实际上是把创作精力花在了搬运上。
最容易出事的其实是隐私。个人经历类文章天然包含大量可识别信息:真实姓名、亲属称谓、具体地址、工作单位、时间节点、电话号码、社交账号,甚至聊天截图里的头像和昵称。这些东西在写作时是必要的细节,在公开发布时却是风险。靠人眼通读一遍去检查,几乎一定会漏。而且很多平台一旦发布,即使后续删除,也可能已经被转载或缓存。
所以这套方案要解决的,是三个具体问题:版本不混乱、格式不重复劳动、隐私不漏发。这三件事都有成熟的技术手段可以覆盖,而且不需要你成为程序员。你只需要接受一个前提:把文章当成一份需要被管理的文件,而不是一个只能存在于文档软件里的东西。
2. 核心概念与技术选型:把文章当作代码来管理
这套方案由四个部分组成,每个部分解决一个问题,彼此之间通过“纯文本文件”这个共同格式串起来。下面逐个解释它们是什么、为什么选它、以及它在整个流程里处于什么位置。
2.1 Markdown:唯一的内容来源
Markdown 是一种用纯文本表示排版的标记语法,比如## 标题表示二级标题,**加粗**表示加粗。它的关键优势不是“好看”,而是纯文本:任何编辑器都能打开,任何版本管理工具都能比较差异,任何转换工具都能读它。这意味着你的稿子不会被某个软件绑架,十年后依然能打开。
在长文写作中,Markdown 带来的最实际的好处是结构可见。当你用## 1.## 2.标记章节时,文章骨架一目了然,调整章节顺序只需要剪切粘贴几行。相比之下,富文本编辑器里的样式是隐式的,你看到的是“看起来像标题”,但机器不知道它是标题,导出时就容易乱。
需要说明的是,Markdown 不是排版工具,它只描述结构。最终呈现成什么样,由渲染它的地方决定。这正是我们要的效果:一份内容,多种输出。
2.2 Git:版本管理 + 异地备份
Git 是分布式版本控制系统,最初为代码协作设计。用在写作上有三个直接收益:第一,每次提交都是一个可回溯的存档点,改错了可以精确回退到某一个版本;第二,git diff能逐行显示你这次改了什么,比肉眼对比两版文档可靠得多;第三,推送到远程仓库等于多了一份异地备份。
很多写作者对 Git 有心理负担,觉得是程序员专属。实际上写作场景只需要五条命令:init、add、commit、log、diff。学会这五条,就足够管理一篇文章的完整生命周期。分支、合并、变基这些可以先完全不管。
这里有一个重要提醒:如果文章包含真实个人信息,不要推到公开仓库。Git 的历史是永久的,即使后来删除文件,历史提交里依然存在。这一点在第 7 节会详细展开。
2.3 静态站点:可控的发布渠道
静态站点是指提前生成好 HTML 文件、不需要数据库和后端程序的网站,常见工具有 Hugo、Jekyll、Hexo 等。对写作者来说,它的价值在于:文章以 Markdown 存放,构建后变成网页,部署成本极低,并且你能完全控制哪些内容公开、哪些内容只在本地。
对于个人经历长文,静态站点有两个典型用法。一是作为“正式版”的归档地,投稿被采用后,自己保留一份带日期的版本;二是作为草稿预览环境,在投出去之前先看看排版效果。它不替代投稿渠道,而是给你一个不依赖第三方的存档位置。
2.4 导出层:把一份内容变成多种格式
导出层是几个小脚本和模板,负责把 Markdown 转换成投稿需要的格式:纯文本、带 front matter 的 Markdown、字数统计报告、分段文件等。这部分不需要复杂框架,Python 标准库加上一两个转换工具就够用。
下表把四个组成部分的定位、解决的问题和上手难度放在一起对比,方便判断优先级:
| 组成部分 | 主要解决的问题 | 典型工具 | 上手难度 | 建议优先级 |
|---|---|---|---|---|
| Markdown | 内容与格式解耦、结构清晰 | 任意文本编辑器 | 低 | 必选 |
| Git | 版本回溯、异地备份 | git 命令行 | 中 | 强烈建议 |
| 静态站点 | 可控发布、草稿预览 | Hugo / Jekyll | 中 | 可选 |
| 导出脚本 | 多平台格式转换、字数统计 | Python | 中 | 投稿多时必选 |
选型上有一个原则值得强调:优先选纯文本和本地工具,避免把唯一副本放在某个在线服务里。在线文档协作方便,但它的导出格式、权限模型、服务可用性都不由你控制。长文这种“写一次、存很多年”的东西,本地纯文本 + 版本管理是最稳的底座。
3. 环境准备与前置条件
这一节说明跑通整套流程需要什么。版本号只给出建议区间,具体以你机器上实际安装的为准,本文重点演示通用思路,不绑定某个特定版本。
3.1 操作系统与基础工具
Windows、macOS、Linux 都可以。Windows 用户建议使用 Git Bash 或 WSL,因为后面的命令都是 Unix 风格。需要准备的东西不多:
- 一个文本编辑器。VS Code、Typora、Obsidian 均可。VS Code 的优势是内置 Git 面板和 Markdown 预览,对新手最友好。
- Git。用于版本管理。
- Python 3.10 及以上。用于运行导出和自检脚本。标准库足够,不需要额外装包。
- 可选:Hugo,用于本地预览静态站点。
如果你的文章里有大量图片,还需要准备一个图片目录,并在 Markdown 里用相对路径引用。这一点在第 4 节的目录结构里会体现。
3.2 安装与版本确认
不同系统的安装方式不同,下面给出一组通用命令,按需选择。安装完成后必须确认版本,避免后面脚本因为版本过低报错。
# 文件路径:终端直接执行 # 1. 确认 Git 是否已安装 git --version # 2. 确认 Python 版本(脚本要求 3.10+) python3 --version # 3. 可选:确认 Hugo 是否已安装 hugo version # macOS 使用 Homebrew 安装缺失项 brew install git python hugo # Ubuntu / Debian sudo apt update sudo apt install -y git python3 python3-pip # Windows 建议先安装 Git for Windows,然后在 Git Bash 中执行上面的确认命令版本确认这一步经常被跳过,结果是脚本跑起来才发现语法不兼容。比如 Python 3.9 之前不支持某些类型标注写法,如果脚本里用了list[str]之外的更激进语法,就会直接报错。先花三十秒确认版本,比事后排查十分钟划算。
3.3 创建写作仓库
仓库就是一个被 Git 管理的文件夹。建议单独建一个,只放这一篇长文及其素材,不要和别的项目混在一起。
# 文件路径:终端直接执行 # 创建一个新的写作目录 mkdir -p ~/writing/long-story cd ~/writing/long-story # 初始化 Git 仓库 git init # 配置提交身份(只需一次,全局配置) git config --global user.name "Your Name" git config --global user.email "you@example.com" # 创建基础目录结构 mkdir -p drafts chapters assets/images exports scripts # 添加一个 .gitignore,避免把中间产物提交进去 cat > .gitignore <<'EOF' exports/*.txt exports/*.docx .DS_Store *.tmp EOF # 首次提交 git add . git commit -m "chore: 初始化写作仓库"这里有一个细节需要注意:.gitignore排除的是exports目录下的中间产物,而不是源文件。源文件(Markdown)必须全部纳入版本管理,否则就失去了回溯的意义。中间产物随时可以重新生成,没必要占用仓库空间。
4. 目录结构与写作工作流拆解
结构决定效率。一篇长文如果没有清晰的目录约定,写到后期一定会出现“这段到底属于哪一章”“这个素材放哪了”的问题。下面这套结构经过实际使用验证,能覆盖从草稿到投稿的完整流程。
4.1 推荐的目录结构
long-story/ ├── drafts/ # 草稿区,允许混乱 │ ├── 2024-01-notes.md # 素材、片段、灵感 │ └── outline.md # 大纲,随时调整 ├── chapters/ # 正式章节,按顺序命名 │ ├── 01-opening.md │ ├── 02-background.md │ ├── 03-turning-point.md │ └── 99-ending.md ├── assets/ │ └── images/ # 文中引用的图片 ├── scripts/ │ ├── privacy_check.py # 隐私自检 │ └── export.py # 多格式导出 ├── exports/ # 导出产物,不纳入版本管理 └── README.md # 写作说明与投稿记录这个结构的关键是把“草稿”和“正式章节”分开。草稿区允许写得很乱,想到什么写什么;正式章节区按编号排列,最终合并时按文件名排序即可。这样做的直接好处是:你永远知道该往哪个文件里写,也永远不会因为一次误操作丢掉全部内容。
README.md容易被忽略,但在投稿场景里很有用。可以在里面记录:每个渠道的投稿时间、要求、状态、反馈。这比在备忘录里零散记录可靠得多。
4.2 一次完整的写作循环
从“改一段话”到“投出去”,一次完整的循环包含六步。理解这个循环,比记住任何命令都重要。
第一步是开工前同步。打开终端,进入目录,执行git status看看有没有上次忘记提交的改动。如果有,先提交或丢弃,保证工作区干净。
第二步是写作与拆分。在chapters/下修改对应章节。如果某一段暂时没想好,就写一个TODO:标记,不要停下来硬憋。长文写作的节奏比句子质量更重要。
第三步是阶段提交。写完一个完整意思(比如解决了某一节的逻辑问题)就提交一次。提交信息写清楚改了什么,比如feat: 补充第三章的转折细节。这一步的意义是建立存档点,不是为了好看。
第四步是合并与自检。把所有章节合并成一个完整文件,然后运行隐私自检脚本。这一步不能省,原因在第 5 节会详细说。
第五步是导出投稿版本。根据目标渠道的要求生成对应格式,同时生成字数统计,方便填写投稿表单。
第六步是记录与归档。把这次投稿的信息写进README.md,给当前版本打一个 tag,比如git tag v1-submit-2024xx。这样以后对方说“投的是哪一版”,你能立刻找出来。
这六步看起来繁琐,实际跑熟之后每一步都是几秒钟的事。它带来的最大变化是:你不需要再依赖记忆去管理稿子。记忆是最不可靠的存储介质,而版本库不是。
5. 完整示例:发布前的隐私自检脚本
这一节给出第一个核心脚本。它的作用是:把所有章节合并后,扫描文本中可能泄露个人身份的信息,输出一份报告,让你在发布前逐条确认。它不是万能的,但能覆盖绝大多数低级失误。
5.1 为什么要用脚本检查
人眼通读检查隐私有两个天然缺陷。一是注意力衰减:读到第五千字时,注意力已经大幅下降,对电话号码这类模式化信息的敏感度会明显降低。二是上下文干扰:一段文字在叙事上很自然,你读的时候关注的是情节,不会主动去想“这个地名加上这个单位名称,是不是能定位到具体的人”。
脚本的优势恰好在于它没有注意力问题,也不会被叙事带走。它只做一件事:按规则匹配,命中就报告。至于命中项是否真的需要修改,仍然由你判断。
5.2 脚本实现
脚本放在scripts/privacy_check.py,只用标准库,不依赖第三方包。
# 文件路径:scripts/privacy_check.py """发布前隐私自检脚本:扫描合并后的长文,报告可能泄露个人身份的信息。""" from __future__ import annotations import argparse import re from dataclasses import dataclass from pathlib import Path @dataclass class Rule: """一条检测规则。""" name: str pattern: re.Pattern[str] suggestion: str RULES: list[Rule] = [ Rule( name="手机号", pattern=re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)"), suggestion="替换为“某号码”或做部分掩码处理", ), Rule( name="身份证号", pattern=re.compile(r"(?<!\d)\d{17}[\dXx](?!\d)"), suggestion="整段删除或改为“身份证号已隐去”", ), Rule( name="邮箱地址", pattern=re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"), suggestion="改为不可用的示例地址或直接删除", ), Rule( name="银行卡号", pattern=re.compile(r"(?<!\d)\d{16,19}(?!\d)"), suggestion="确认是否为卡号,是则整段删除", ), Rule( name="详细门牌地址", pattern=re.compile(r"[\u4e00-\u9fa5]{2,10}(路|街|巷|弄|号院|小区)\d{1,5}(号|栋|楼|室)"), suggestion="降级为“某小区”级别的模糊表述", ), Rule( name="疑似 URL", pattern=re.compile(r"https?://[^\s))]+"), suggestion="确认链接是否指向包含个人信息的页面", ), ] def scan_text(text: str, source: str) -> list[tuple[Rule, int, str]]: """扫描单段文本,返回 (规则, 行号, 命中片段) 列表。""" hits: list[tuple[Rule, int, str]] = [] for line_no, line in enumerate(text.splitlines(), start=1): for rule in RULES: for match in rule.pattern.finditer(line): hits.append((rule, line_no, match.group(0))) return hits def build_report(target: Path) -> str: """合并目录下所有 Markdown 文件并生成报告。""" files = sorted(target.rglob("*.md")) if target.is_dir() else [target] if not files: return f"未找到任何 Markdown 文件:{target}" lines: list[str] = [] total_hits = 0 for file_path in files: text = file_path.read_text(encoding="utf-8") hits = scan_text(text, str(file_path)) if not hits: continue lines.append(f"\n文件:{file_path}") for rule, line_no, snippet in hits: total_hits += 1 masked = snippet[:3] + "*" * max(len(snippet) - 3, 0) lines.append( f" 第 {line_no} 行 | 规则:{rule.name} | 命中:{masked} | 建议:{rule.suggestion}" ) header = [ "=" * 60, "隐私自检报告", "=" * 60, f"扫描文件数:{len(files)}", f"命中总数:{total_hits}", ] if total_hits == 0: header.append("结果:未发现规则内命中的敏感信息,仍建议人工通读一遍。") else: header.append("结果:存在命中项,请逐条确认后再发布。") return "\n".join(header + lines) def main() -> None: parser = argparse.ArgumentParser(description="发布前隐私自检") parser.add_argument( "target", nargs="?", default="chapters", help="要扫描的文件或目录,默认 chapters", ) parser.add_argument( "--output", default="exports/privacy_report.txt", help="报告输出路径", ) args = parser.parse_args() report = build_report(Path(args.target)) output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(report, encoding="utf-8") print(report) print(f"\n报告已写入:{output_path}") if __name__ == "__main__": main()5.3 关键逻辑说明
脚本的设计有三个考虑。第一,规则集中定义。所有正则放在RULES列表里,想加新规则只改这一处,不用动扫描逻辑。实际使用中,你可能会根据自己文章的内容补充规则,比如“疑似学校全称”“疑似车牌号”,直接往列表里加一条即可。
第二,命中片段做掩码。报告里不直接打印完整号码,只保留前三位加星号。这样做是为了防止报告文件本身变成新的泄露源——很多人会把报告截图发出去问别人,如果报告里是完整号码,等于又泄露了一次。
第三,没有命中不等于安全。脚本只能匹配有固定模式的字符串,无法识别“某个特定小区名字 + 某年某月 + 某职业”这种组合式定位。所以报告结尾始终保留人工通读的建议,这个提示不能删。
5.4 运行与验证
在仓库根目录执行下面命令,观察输出是否符合预期。
# 文件路径:仓库根目录执行 # 扫描 chapters 目录 python3 scripts/privacy_check.py chapters # 也可以只扫描单个合并后的文件 python3 scripts/privacy_check.py merged.md --output exports/privacy_report.txt如果报告显示“命中总数:0”,说明规则内没有匹配项。你可以先在drafts/里临时写一行测试内容验证脚本确实在工作,例如包含一个虚构的 11 位号码,跑完之后确认脚本能命中,再把这行删掉。这个“先验证工具本身有效”的习惯很重要,否则你永远不知道零命中是真的干净,还是脚本根本没跑起来。
6. 完整示例:多平台投稿格式导出
第二个脚本解决重复劳动问题:把合并后的长文一键导出成多种投稿格式,并同时生成字数统计和分段文件。这是整套流程里节省时间最明显的一环。
6.1 导出脚本实现
脚本放在scripts/export.py,同样只用标准库。
# 文件路径:scripts/export.py """把 chapters 目录下的章节合并,导出为多种投稿格式。""" from __future__ import annotations import argparse import re from datetime import date from pathlib import Path def merge_chapters(chapter_dir: Path) -> tuple[str, list[Path]]: """按文件名排序合并所有 Markdown 章节。""" files = sorted(chapter_dir.glob("*.md")) if not files: raise FileNotFoundError(f"目录中没有 Markdown 文件:{chapter_dir}") parts = [f.read_text(encoding="utf-8").strip() for f in files] return "\n\n".join(parts) + "\n", files def count_chars(text: str) -> dict[str, int]: """统计字数:总字符、去空白字符、中文字符、段落数。""" no_space = re.sub(r"\s", "", text) chinese = re.findall(r"[\u4e00-\u9fa5]", text) paragraphs = [ p for p in re.split(r"\n\s*\n", text.strip()) if p.strip() ] return { "总字符数": len(text), "去空白字符数": len(no_space), "中文字符数": len(chinese), "段落数": len(paragraphs), } def to_plain_text(md: str) -> str: """把 Markdown 降级为纯文本,去掉常见标记。""" text = re.sub(r"^#{1,6}\s*", "", md, flags=re.MULTILINE) # 标题符号 text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) # 加粗 text = re.sub(r"\*(.+?)\*", r"\1", text) # 斜体 text = re.sub(r"`([^`]+)`", r"\1", text) # 行内代码 text = re.sub(r"!\[.*?\]\(.*?\)", "", text) # 图片引用 text = re.sub(r"\[(.+?)\]\(.*?\)", r"\1", text) # 链接 text = re.sub(r"^>\s?", "", text, flags=re.MULTILINE) # 引用 text = re.sub(r"\n{3,}", "\n\n", text) # 多余空行 return text.strip() + "\n" def to_front_matter_doc(md: str, title: str) -> str: """生成带 front matter 的 Markdown,适合静态站点或支持元数据的平台。""" head = ( "---\n" f"title: \"{title}\"\n" f"date: {date.today().isoformat()}\n" "draft: true\n" "---\n\n" ) return head + md def split_by_heading(md: str) -> dict[str, str]: """按二级标题切分成多个文件,便于分段投稿。""" sections: dict[str, str] = {} current = "preamble" buffer: list[str] = [] for line in md.splitlines(): if line.startswith("## "): if buffer: sections[current] = "\n".join(buffer).strip() current = line[3:].strip() or f"section-{len(sections) + 1}" buffer = [line] else: buffer.append(line) if buffer: sections[current] = "\n".join(buffer).strip() return sections def safe_name(name: str, index: int) -> str: """生成安全的文件名。""" cleaned = re.sub(r"[^\w\u4e00-\u9fa5-]+", "_", name).strip("_") return f"{index:02d}_{cleaned[:40] or 'section'}.md" def main() -> None: parser = argparse.ArgumentParser(description="长文多格式导出") parser.add_argument("--title", default="未命名长文", help="文章标题") parser.add_argument("--chapter-dir", default="chapters", help="章节目录") parser.add_argument("--out-dir", default="exports", help="输出目录") args = parser.parse_args() chapter_dir = Path(args.chapter_dir) out_dir = Path(args.out_dir) out_dir.mkdir(parents=True, exist_ok=True) merged, files = merge_chapters(chapter_dir) (out_dir / "merged.md").write_text(merged, encoding="utf-8") (out_dir / "merged_plain.txt").write_text( to_plain_text(merged), encoding="utf-8" ) (out_dir / "merged_with_meta.md").write_text( to_front_matter_doc(merged, args.title), encoding="utf-8" ) stats = count_chars(merged) stat_lines = [f"合并章节数:{len(files)}"] stat_lines += [f"{k}:{v}" for k, v in stats.items()] (out_dir / "stats.txt").write_text("\n".join(stat_lines) + "\n", encoding="utf-8") sections_dir = out_dir / "sections" sections_dir.mkdir(exist_ok=True) for index, (heading, content) in enumerate(split_by_heading(merged).items(), 1): (sections_dir / safe_name(heading, index)).write_text( content + "\n", encoding="utf-8" ) print("\n".join(stat_lines)) print(f"\n导出完成,输出目录:{out_dir.resolve()}") if __name__ == "__main__": main()6.2 关键逻辑说明
merge_chapters依赖文件名的字典序,所以章节文件必须以两位数字开头命名,否则10-xxx.md会排在2-xxx.md前面。这是最容易踩的坑,第 9 节会再提一次。
to_plain_text只处理常见的行内标记,不追求完整的 Markdown 解析。这是有意为之:投稿平台要的纯文本通常也不需要复杂格式,过度处理反而可能破坏正文内容。如果你的文章里有大量表格、代码块,建议在导出后人工检查一遍。
count_chars同时给出四个口径。不同渠道对“字数”的定义不一样,有的算空格,有的不算,有的只算中文。四个数字都留着,填表时按需要选择,比事后重新统计省事。
split_by_heading按二级标题切分,主要用于两种情况:一是某些平台要求分章节提交,二是你想单独检查某一章的内容是否完整。切分后的文件名做了安全处理,避免标题里的标点导致文件系统报错。
6.3 运行与验证
# 文件路径:仓库根目录执行 python3 scripts/export.py --title "一段个人经历的完整记录" # 查看导出结果 ls -lh exports/ cat exports/stats.txt head -n 20 exports/merged_plain.txt预期输出类似下面这样,数字取决于你的实际内容:
合并章节数:8 总字符数:18742 去空白字符数:17103 中文字符数:16240 段落数:213 导出完成,输出目录:/home/user/writing/long-story/exports验证成功的标准有三条:exports/merged.md的章节顺序与预期一致;stats.txt里的中文字符数与你的直觉大致相符(差太多说明有章节没被读到);merged_plain.txt里没有残留的##或**。如果merged.md顺序不对,先检查章节文件名前缀。
7. 静态站点:可控的草稿预览与归档
如果你需要看排版效果,或者想保留一个不带平台广告的正式版归档,静态站点是最省事的方案。这里以 Hugo 为例,其他工具思路类似。
7.1 初始化站点与配置
# 文件路径:仓库根目录执行 # 初始化站点到 site 目录 hugo new site site --force # 创建一个内容文件 hugo new content story/index.md # 本地预览,默认地址 http://localhost:1313/ hugo server --buildDrafts配置文件site/hugo.toml里需要关注的几项:
# 文件路径:site/hugo.toml baseURL = "https://example.com/" languageCode = "zh-cn" title = "写作归档" disableKinds = ["taxonomy", "term", "RSS", "sitemap"] [build] writeStats = true [markup.goldmark.renderer] unsafe = falsedisableKinds关闭了分类页和 RSS,个人归档场景通常用不到,关掉能减少无关页面。unsafe = false表示不允许 Markdown 中直接嵌入原始 HTML,这是一条安全默认值:如果你的稿子里有从网页复制的内容,可能夹带脚本标签,关闭它可以把这类风险挡在渲染层之外。
7.2 草稿与公开内容的边界
Hugo 的 front matter 里有draft: true字段,标记为草稿的内容默认不会出现在构建结果里,只有加--buildDrafts才能在本地看到。这个机制非常适合长文场景:未完成的章节保持草稿状态,确认可以公开的才改为 false。
需要提醒的是,draft只是构建时的过滤,它不能替代访问控制。如果你的站点部署在公开网络上,任何被构建出来的页面都是可访问的。因此包含真实个人信息的版本,不要构建、不要部署,只保留在本地仓库里。
8. 自动化:把自检和导出串成一条命令
手动执行两个脚本容易忘。可以用一个简单的 shell 脚本把“合并、自检、导出”串起来,再配合 Git 的钩子,在提交时自动跑一次检查。
# 文件路径:scripts/build.sh #!/usr/bin/env bash set -euo pipefail echo "[1/3] 隐私自检..." python3 scripts/privacy_check.py chapters --output exports/privacy_report.txt echo "[2/3] 检查报告是否有关键命中..." if grep -q "命中总数:0" exports/privacy_report.txt; then echo "未发现规则内命中项。" else echo "存在命中项,请打开 exports/privacy_report.txt 逐条确认。" fi echo "[3/3] 导出投稿格式..." python3 scripts/export.py --title "${1:-未命名长文}" echo "完成。输出位于 exports/"赋予执行权限后运行:
# 文件路径:仓库根目录执行 chmod +x scripts/build.sh ./scripts/build.sh "一段个人经历的完整记录"这个脚本刻意没有在发现命中时直接中断。原因是隐私判断需要人来做,某些命中可能是误报,比如文章中引用的公开客服电话、示例邮箱。让脚本报告、让人决策,比让脚本一刀切更实用。如果你的团队或合作编辑要求强制拦截,把else分支里的exit 1加上即可。
9. 常见问题与排查思路
下面这些问题在实际使用中出现的频率最高,按“现象—原因—排查—解决”整理成表,遇到时可以直接对照。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 合并后章节顺序错乱 | 章节文件名前缀位数不一致 | 执行ls chapters/看排序 | 统一改为两位数前缀,如01、02 |
脚本报FileNotFoundError | 章节目录为空或路径写错 | 确认chapters/下有.md文件 | 检查--chapter-dir参数或先创建章节 |
| 字数统计明显偏少 | 部分章节未匹配到*.md后缀 | 查看stats.txt里的“合并章节数” | 统一文件后缀,避免.markdown混用 |
导出的纯文本仍带## | 标题符号前有空格或用了全角符号 | 用grep -n "^ *#"定位 | 统一使用半角##且顶格书写 |
| 隐私报告零命中但确有号码 | 号码被空格或连字符分隔 | 手工搜索前几位数字 | 补充规则或在写作时避免拆分号码 |
| Hugo 构建后看不到文章 | front matter 里draft: true | 查看文章头部元数据 | 确认可公开后改为false |
| Git 提交后发现包含隐私 | 检查发生在提交之后 | git log -p查看历史 | 在提交前跑build.sh,必要时重写历史 |
| 中文文件名在 Windows 下乱码 | 终端编码与文件编码不一致 | 用编辑器确认文件为 UTF-8 | 统一保存为 UTF-8,终端设置对应编码 |
表格之外还有两个需要单独说明的点。
第一个是隐私检查的时机。很多人习惯在“准备发布”时才检查,这时候文章已经定稿,改起来心理成本很高,容易抱着侥幸心理放过某些命中项。更稳妥的做法是在每个章节写完后就跑一次针对该章节的检查,问题在上下文还热的时候处理,改动阻力最小。
第二个是Git 历史里的隐私无法靠删除文件解决。如果已经提交了包含真实信息的版本,仅仅删掉文件再提交一次是不够的,历史提交里依然完整保留。处理方式有两种:如果仓库从未推送到远程,可以重建仓库;如果已经推送,需要用git filter-repo之类的工具重写历史,并且要意识到任何已经拉取过的人本地仍有副本。这也是为什么检查必须前置到提交之前。
10. 最佳实践与长期维护建议
工具跑通只是起点,真正决定这套方案能不能长期用的是习惯。下面几条建议来自实际使用中的反复踩坑,按重要性排序。
第一,把“提交”当成存档而不是任务。不要为了凑提交次数去提交无意义的改动,也不要在写完一大段之后忘记提交。判断标准很简单:如果现在电脑突然坏掉,你损失的内容是否超过十分钟的工作量。超过就提交。
第二,备份遵守三份原则。本地仓库一份,远程私有仓库一份,离线介质(移动硬盘或加密压缩包)一份。三份里至少有一份不在同一台设备上。个人经历类长文往往写了很多年,丢失的代价远高于任何技术成本。
第三,隐私处理要“写到就模糊”,而不是“发之前再改”。写作时直接用“某年冬天”“南方一个中等城市”“一位亲戚”这类表述,比写完再回头逐条替换省力得多,也不容易漏。真实的细节留在你自己的记忆和未公开的草稿里,公开版本不需要精确到可以定位。
第四,投稿记录要结构化。在README.md里维护一张表:渠道名称、投稿日期、要求格式、当前状态、反馈要点。这看起来是小事,但当你在三个月后收到回复时,能立刻知道当时投的是哪一版、承诺了什么,避免答非所问。
第五,给重要节点打 tag。每次正式投出去,执行git tag v1-submit-日期。tag 是不可变的指针,比提交信息更容易检索。对方问“你投的是哪一版”,一条命令就能导出当时的完整内容。
第六,不要为了工具而工具。如果你只写一篇三千字的短文、只投一个渠道,用 Markdown 加一个云盘备份就够了,不必上静态站点和自动化脚本。这套方案的价值随文章长度、修改次数和投稿渠道数量增长,规模不够时反而增加负担。判断标准是:当你开始因为“找不到最新版”或“又要重新排版一次”而感到烦躁时,就是引入它的时机。
第七,注意投稿渠道的合规要求。不同平台对原创声明、首发要求、独家授权的规定不同,投稿前应阅读对应规则,避免一稿多投引发的纠纷。这部分与技术无关,但和技术方案直接相关——正因为你的稿子在本地有完整版本记录,才能在需要证明“这是我什么时候写的”时拿得出证据。
第八,定期做一次仓库体检。每隔一段时间执行git status和du -sh .git,确认没有误提交的大文件(比如几十兆的原始照片)。Git 对大二进制文件不友好,图片建议压缩后再入库,或者用单独的资源目录管理。仓库体积一旦膨胀,后续操作都会变慢。
写到这一步,整套流程其实已经闭环了:Markdown 负责内容,Git 负责版本与备份,脚本负责自检与导出,静态站点负责预览与归档。它不改变你写作的方式,只改变你管理稿子的方式。对一篇需要反复打磨、谨慎发布的长文来说,这种“管理方式”的稳定性,往往比某一段写得好不好更影响最终结果。
如果你现在正准备动手,建议的顺序是:先建仓库、把已有内容按章节拆进去、提交一次;然后加上隐私检查脚本,跑一遍看看命中什么;最后再考虑导出和站点。不要一上来就把所有工具装齐,那样容易在配置环节消耗掉写作的热情。工具是为内容服务的,顺序反过来就本末倒置了。