diff --git a/README.md b/README.md index e818e2f..c49dbb0 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,26 @@ ## 同步本地Markdown至Typecho站点 -场景:本人喜欢在本地用Typora写markdown文件,但又想同时同步至Typecho发表成文章;且由于md文件并不是一成不变的,经常需要对各个文件缝缝补补,要能实现本地更新/同步至博客更新。 +本项目基于https://github.com/Sundae97/typecho-markdown-file-publisher -亲测适配:Typecho1.2 php7.4.33 +实现效果: + +- [x] 将markdown发布到typecho +- [x] 发布前将markdown的图片资源上传到TencentCloud的COS中, 并替换markdown中的图片链接 +- [x] 将md所在的文件夹名称作为post的category(mysql发布可以插入category, xmlrpc接口暂时不支持category操作) +- [x] 以title和category作为文章的唯一标识,如果数据库中已有该数据,将会更新现有文章,否则新增文章。 + +环境:Typecho1.2.1 php7.4.33 ### 项目目录 ![image-20250319173057792](D:\folder\study\md_files\output\image-20250319173057792.png) +`typecho_markdown_upload/main.py`是上传md文件到站点的核心脚本 + +`transfer_md/transfer.py`是对md文件进行预处理的脚本。 + + + ### **核心思路** **一、预先准备** @@ -58,7 +71,7 @@ github地址:[icret/EasyImages2.0: 简单图床 - 一款功能强大无数据 **文件结构统一**: -``` +```text md_files ├── category1 │ ├── file1.md @@ -94,7 +107,7 @@ md_files **初始化本地仓库**: -``` +```text git init ``` @@ -102,26 +115,26 @@ git init 将远程仓库地址添加为 `origin`(请将 `http://xxx` 替换为你的实际仓库地址): -``` +```text git remote add origin http://xxx ``` **添加文件并提交**: -``` +```text git add . git commit -m "Initial commit" ``` **推送到远程仓库:** -``` +```text git push -u origin master ``` -**后续更新:** +**后续更新(可写个.bat批量执行):** -``` +```text git add . git commit -m "更新了xxx内容" git push @@ -133,11 +146,11 @@ git push **1. 确保脚本能够连接到 Typecho 使用的数据库** -本博客使用 docker-compose 部署 Typecho(参考:[【好玩儿的Docker项目】10分钟搭建一个Typecho博客|太破口!念念不忘,必有回响!-我不是咕咕鸽](https://blog.laoda.de/archives/docker-compose-install-typecho))。为了让脚本能访问 Typecho 的数据库,我将 Python 应用也通过 docker-compose 部署,这样所有服务均在同一网络中,互相之间可以直接通信。 +本博客使用 docker-compose 部署 Typecho(参考:[【好玩儿的Docker项目】10分钟搭建一个Typecho博客|太破口!念念不忘,必有回响!-我不是咕咕鸽](https://blog.laoda.de/archives/docker-compose-install-typecho))。为了让脚本能访问 Typecho 的数据库,我将 Python 应用pyapp也通过 docker-compose 部署,这样所有服务均在同一网络中,互相之间可以直接通信。 参考docker-compose.yml如下: -``` +```text services: nginx: image: nginx @@ -171,6 +184,8 @@ services: pyapp: build: ./markdown_operation # Dockerfile所在的目录 restart: "no" + volumes: + - /home/zy123/md_files:/markdown_operation/md_files networks: - web env_file: @@ -201,9 +216,13 @@ networks: -**2. 将版本控制的 md_files 仓库克隆到 markdown_operation 目录中** +**2. 将 `md_files` 挂载到容器中,保持最新内容同步** -确保在容器内可以直接访问到 md_files 内容,因此我们将使用 Git 进行版本控制的 md_files 仓库克隆到 markdown_operation 内部。这样,无论是执行脚本还是其他操作,都能轻松访问和更新 Markdown 文件。 +这样有几个优势: + +- 不需要每次构建镜像或进入容器手动拉取; +- 本地更新 `md_files` 后,容器内自动同步,无需额外操作; +- 保持了宿主机上的 Git 版本控制和容器内的数据一致性。 @@ -211,61 +230,100 @@ networks: `pyapp`是本Python应用在容器内的名称。 -构建镜像: +1.构建镜像: -``` +```text docker-compose build pyapp ``` -启动容器并进入 Bash: +2.启动容器并进入 Bash: -``` +```text docker-compose run --rm -it pyapp /bin/bash ``` -在容器内运行脚本: +3.在容器内运行脚本: -``` +```text python typecho_markdown_upload/main.py ``` +2、3两步可合并为: + +```text +docker-compose run --rm pyapp python typecho_markdown_upload/main.py +``` + +![image-20250320103325650](https://pic.bitday.top/i/2025/03/20/h37pze-0.png) + 此时可以打开博客验证一下是否成功发布文章了! **如果失败,可以验证mysql数据库:** 1️⃣ 进入 MySQL 容器: -``` -docker compose exec mysql mysql -uroot -p +```text +docker-compose exec mysql mysql -uroot -p # 输入你的 root 密码 ``` 2️⃣ 切换到 Typecho 数据库并列出表: -``` +```text USE typecho; SHOW TABLES; ``` 3️⃣ 查看 `typecho_contents` 表结构(文章表): -``` +```text DESCRIBE typecho_contents; -SHOW CREATE TABLE typecho_contents\G +``` + +```text +mysql> DESCRIBE typecho_contents; ++--------------+------------------+------+-----+---------+----------------+ +| Field | Type | Null | Key | Default | Extra | ++--------------+------------------+------+-----+---------+----------------+ +| cid | int(10) unsigned | NO | PRI | NULL | auto_increment | +| title | varchar(150) | YES | | NULL | | +| slug | varchar(150) | YES | UNI | NULL | | +| created | int(10) unsigned | YES | MUL | 0 | | +| modified | int(10) unsigned | YES | | 0 | | +| text | longtext | YES | | NULL | | +| order | int(10) unsigned | YES | | 0 | | +| authorId | int(10) unsigned | YES | | 0 | | +| template | varchar(32) | YES | | NULL | | +| type | varchar(16) | YES | | post | | +| status | varchar(16) | YES | | publish | | +| password | varchar(32) | YES | | NULL | | +| commentsNum | int(10) unsigned | YES | | 0 | | +| allowComment | char(1) | YES | | 0 | | +| allowPing | char(1) | YES | | 0 | | +| allowFeed | char(1) | YES | | 0 | | +| parent | int(10) unsigned | YES | | 0 | | +| views | int(11) | YES | | 0 | | +| agree | int(11) | YES | | 0 | | ++--------------+------------------+------+-----+---------+----------------+ ``` 4️⃣ 查询当前文章数量(确认执行前后有无变化): -``` +```text SELECT COUNT(*) AS cnt FROM typecho_contents; ``` +### **自动化** + +1.windows下写脚本自动/手动提交每日更新 + +2.远程仓库监测到更新自动实现钩子脚本,更新md_files并执行脚本 + + + ### TODO -- [x] 将markdown发布到typecho -- [x] 发布前将markdown的图片资源上传到TencentCloud的COS中, 并替换markdown中的图片链接 -- [x] 将md所在的文件夹名称作为post的category(mysql发布可以插入category, xmlrpc接口暂时不支持category操作) -- [ ] category的层级 -- [ ] 发布前先获取所有post信息, 不发布已经发布过的post \ No newline at end of file +- [ ] typecho_contents表中的slug字段代表链接中的日志缩略名,如wordpress风格 `/archives/{slug}.html`,目前是默认int自增,有需要的话可以在插入文章时手动设置该字段。 + diff --git a/transfer_md/transfer.py b/transfer_md/transfer.py index 4773f4f..2d7511b 100644 --- a/transfer_md/transfer.py +++ b/transfer_md/transfer.py @@ -194,6 +194,35 @@ def process_md_file_remote(md_file): f.write(content) print(f"已更新: {md_file}") +def add_language_to_codeblocks(filepath, language="text"): + ''' + 若代码块没有指定语言,Typera中能正常显示,但是博客中的markdown解析器通常会错误显示。 + ''' + with open(filepath, 'r', encoding='utf-8') as f: + lines = f.readlines() + + in_code_block = False + new_lines = [] + for line in lines: + stripped = line.strip() + # 判断是否为代码块标记 + if stripped.startswith("```"): + if not in_code_block: + # 当前为代码块的起始位置 + # 如果这一行只有三个反引号,则添加语言参数 + if stripped == "```": + # 这里使用replace仅替换第一次出现的```,保留原有的换行符和空白 + line = line.replace("```", f"```{language}", 1) + in_code_block = True + else: + # 当前为代码块的结束位置,不添加语言 + in_code_block = False + new_lines.append(line) + + # 将修改后的内容写回文件(也可选择写入新文件) + with open(filepath, 'w', encoding='utf-8') as f: + f.writelines(new_lines) + def scan_files(base_folder, exclude_folders): """ @@ -218,6 +247,7 @@ def process_md_files(input_path, output_path, type, exclude_folders=None): type == 1: process_md_file_local type == 2: process_md_file_with_assets type == 3: process_md_file_remote + type == 4: """ # 创建输出目录(如果不存在) os.makedirs(output_path, exist_ok=True) @@ -235,10 +265,12 @@ def process_md_files(input_path, output_path, type, exclude_folders=None): process_md_file_with_assets(md_file, output_path) # url改为本地,assets方式,图片和md文件都存output_path elif type == 3: process_md_file_remote(md_file) # 图片url改为公网链接 + elif type == 4: + add_language_to_codeblocks(md_file) else: print(f"未知的处理类型: {type}") - print("处理完成!所有图片已保存至:", os.path.abspath(output_path)) + print("该文件夹下的md_files已全部处理完成!", os.path.abspath(input_path)) if __name__ == "__main__": @@ -247,14 +279,12 @@ if __name__ == "__main__": try: type_value = int(sys.argv[1]) except ValueError: - print("第一个参数必须为整数,表示处理类型(1, 2 或 3)") + print("第一个参数必须为整数,表示处理类型(1, 2 , 3, 4)") sys.exit(1) else: - type_value = 3 + type_value = 4 # 这里的输入输出路径根据实际情况修改 - # input_path = os.getenv('BASE_FOLDER') - input_path=r'D:\folder\study\md_files' - # output_path = os.getenv('OUTPUT_FOLDER') - output_path=r'D:\folder\study\md_files\output' + input_path = os.getenv('BASE_FOLDER') #docker环境 + output_path = os.getenv('OUTPUT_FOLDER') process_md_files(input_path, output_path, type_value) diff --git a/typecho_markdown_upload/typecho_direct_mysql_publisher.py b/typecho_markdown_upload/typecho_direct_mysql_publisher.py index 76021e0..66985d3 100644 --- a/typecho_markdown_upload/typecho_direct_mysql_publisher.py +++ b/typecho_markdown_upload/typecho_direct_mysql_publisher.py @@ -23,7 +23,7 @@ class TypechoDirectMysqlPublisher: 初始化分类列表到 self.__exist_categories """ cursor = self.__db.cursor() - sql = "SELECT mid, name FROM %s WHERE type='category'" % self.__categories_table_name + sql = f"SELECT mid, name FROM {self.__categories_table_name} WHERE type='category'" cursor.execute(sql) results = cursor.fetchall() self.__exist_categories = [] @@ -45,11 +45,10 @@ class TypechoDirectMysqlPublisher: """ cursor = self.__db.cursor() sql = ( - "INSERT INTO %s " + f"INSERT INTO {self.__categories_table_name} " "(`name`, `slug`, `type`, `description`, `count`, `order`, `parent`) " - "VALUES " - "('%s', '%s', 'category', '', 0, 1, 0)" - ) % (self.__categories_table_name, category_name, category_name) + f"VALUES ('{category_name}', '{category_name}', 'category', '', 0, 1, 0)" + ) cursor.execute(sql) mid = cursor.lastrowid self.__db.commit() @@ -63,11 +62,10 @@ class TypechoDirectMysqlPublisher: 在 typecho_relationships 中插入文章与分类的关联 """ insert_relationship_sql = ( - "INSERT INTO %s " - "(`cid`, `mid`) " - "VALUES " - "(%d, %d)" - ) % (self.__relationships_table_name, cid, mid) + f"INSERT INTO {self.__relationships_table_name} " + f"(`cid`, `mid`) " + f"VALUES ({cid}, {mid})" + ) cursor.execute(insert_relationship_sql) def __update_category_count(self, cursor, mid): @@ -75,14 +73,16 @@ class TypechoDirectMysqlPublisher: 分类下文章数 +1 """ update_category_count_sql = ( - "UPDATE %s SET `count`=`count`+1 WHERE mid=%d" - ) % (self.__categories_table_name, mid) + f"UPDATE {self.__categories_table_name} SET `count`=`count`+1 WHERE mid={mid}" + ) cursor.execute(update_category_count_sql) def publish_post(self, title, content, category): """ - 如果 (category, title) 已存在 → 更新旧文章 - 否则 → 插入新文章 + 如果 (category, title) 已存在,则对比旧内容与新内容: + - 若内容相同,则跳过更新 + - 若内容不同,则更新 + 否则插入新文章 """ cursor = self.__db.cursor() @@ -92,67 +92,53 @@ class TypechoDirectMysqlPublisher: mid = self.__add_category(category) # 2. 查找同一分类下,是否已存在相同 title 的文章 - check_sql = """ - SELECT c.cid - FROM %s c - JOIN %s r ON c.cid = r.cid - WHERE c.title = '%s' - AND r.mid = %d + check_sql = f""" + SELECT c.cid, c.text + FROM {self.__contents_table_name} c + JOIN {self.__relationships_table_name} r ON c.cid = r.cid + WHERE c.title = '{escape_string(title)}' + AND r.mid = {mid} LIMIT 1 - """ % ( - self.__contents_table_name, - self.__relationships_table_name, - escape_string(title), - mid - ) + """ cursor.execute(check_sql) exist_row = cursor.fetchone() now_time_int = int(time.time()) - content = '' + content + content_with_mark = '' + content # 新内容加上标记 + escaped_content = escape_string(content_with_mark) if exist_row: # ========== 执行更新逻辑 ========== cid = exist_row[0] - update_sql = """ - UPDATE %s - SET modified=%d, - text='%s' - WHERE cid=%d - """ % ( - self.__contents_table_name, - now_time_int, - escape_string(content), - cid - ) - cursor.execute(update_sql) + old_content = exist_row[1] or "" - # 如果你需要修改 slug、authorId、status 等字段,可在这里加上 - # 不需要改 relationships (分类关系) 和分类计数,因为 category 没变 - - print(f"[INFO] 更新文章成功: title={title}, cid={cid}, category={category}") + if old_content.strip() == content_with_mark.strip(): + # 内容相同,不更新 + print(f"[INFO] 文章已存在且内容未修改: title={title}, cid={cid}, category={category},跳过更新。") + return cid + else: + # 内容不同,执行更新 + update_sql = f""" + UPDATE {self.__contents_table_name} + SET modified={now_time_int}, + text='{escaped_content}' + WHERE cid={cid} + """ + cursor.execute(update_sql) + print(f"[INFO] 更新文章成功: title={title}, cid={cid}, category={category}") else: # ========== 执行插入逻辑 ========== insert_sql = ( - "INSERT INTO %s " + f"INSERT INTO {self.__contents_table_name} " "(`title`, `slug`, `created`, `modified`, `text`, `order`, `authorId`, `template`, `type`, `status`, `password`, `commentsNum`, `allowComment`, `allowPing`, `allowFeed`, `parent`) " - "VALUES " - "('%s', NULL, %d, %d, '%s', 0, 1, NULL, 'post', 'publish', NULL, 0, '1', '1', '1', 0)" - ) % ( - self.__contents_table_name, - escape_string(title), - now_time_int, - now_time_int, - escape_string(content) + f"VALUES ('{escape_string(title)}', NULL, {now_time_int}, {now_time_int}, '{escaped_content}', 0, 1, NULL, 'post', 'publish', NULL, 0, '1', '1', '1', 0)" ) cursor.execute(insert_sql) cid = cursor.lastrowid # slug = cid - update_slug_sql = ( - "UPDATE %s SET slug=%d WHERE cid=%d" - ) % (self.__contents_table_name, cid, cid) + update_slug_sql = f"UPDATE {self.__contents_table_name} SET slug={cid} WHERE cid={cid}" cursor.execute(update_slug_sql) # 建立文章与分类的关系