77import os
88import glob
99import yaml
10+ import re
11+ from typing import List , Tuple , Dict
1012import clean_text
13+ from xai_sdk import Client
14+ from xai_sdk .chat import system , user
1115
1216path_to = f'src/content/blog/{ datetime .datetime .now ().strftime ("%Y-%m-%d" )} '
1317
2125start = time .time ()
2226print (" Connecting remote:" )
2327deepseek = OpenAI (base_url = "https://api.deepseek.com" , api_key = os .environ .get ("DS_APIKEY" ))
28+ xai_client = Client (api_key = os .getenv ("XAI_API_KEY" ), timeout = 7200 )
2429print (f" Time spent on init: { time .time () - start :.1f} s" )
2530
26- def generate (context , provider , model ):
31+ def generate (context , provider , model ): #for openrouter
2732 completion = provider .chat .completions .create (
2833 model = model ,
2934 messages = context
3035 )
3136 return completion .choices [0 ].message .content .strip ()
3237
38+ def grok_generate (context , provider , model ): #for xai
39+ """
40+ e.g.
41+ model="grok-3-mini"
42+ provider=xai_client
43+ context=[system("You are a highly intelligent AI assistant."),user("What is 101*3?")]
44+ """
45+ chat = provider .chat .create (
46+ model = model ,
47+ messages = context ,
48+ )
49+ return chat .sample ()
50+
3351def scrape_website (url , css_selector ):
3452 response = requests .get (url )
3553 if response .status_code == 200 :
@@ -71,44 +89,246 @@ def get_existing_blog_posts():
7189
7290def extract_topic (topics ):
7391 global deepseek , existing_posts_text
74- return generate ([
75- {"role" : "system" , "content" : "你在为一篇技术博客确定一个主题。直接用中文输出主题。" },
76- {"role" : "user" , "content" : f"阅读以下是HackerNews的热门文章,然后写一个可以用于技术博客的主题。这个主题应当是一个通用、普通的技术,不能是一个事件或其它东西。\n \n { topics } \n \n 以下是已有的博客文章,请避免选择相似的主题:\n { existing_posts_text } \n \n 只需要一个主题,直接输出。" },
77- ], deepseek , "deepseek-chat" )
92+ # return generate([
93+ # {"role": "system", "content": "你在为一篇技术博客确定一个主题。直接用中文输出主题。"},
94+ # {"role": "user", "content": f"阅读以下是HackerNews的热门文章,然后写一个可以用于技术博客的主题。这个主题应当是一个通用、普通的技术,不能是一个事件或其它东西。\n\n{topics}\n\n以下是已有的博客文章,请避免选择相似的主题:\n{existing_posts_text}\n\n只需要一个主题,直接输出。"},
95+ # ], deepseek, "deepseek-chat")
96+ return grok_generate ([
97+ system ("你在为一篇技术博客确定一个主题。直接用中文输出主题。" ),
98+ user (f"阅读以下是HackerNews的热门文章,然后写一个可以用于技术博客的主题。这个主题应当是一个通用、普通的技术,不能是一个事件或其它东西。\n \n { topics } \n \n 以下是已有的博客文章,请避免选择相似的主题:\n { existing_posts_text } \n \n 只需要一个主题,直接输出。" )
99+ ], xai_client , "grok-3-mini" )
78100
79101def outline (topic ):
80102 global deepseek
81- return generate ([
82- {"role" : "user" , "content" : f"我要写一篇关于「{ topic } 」的博客文章。帮我列一个详细的文章提纲。" }
83- ], deepseek , "deepseek-reasoner" )
103+ # return generate([
104+ # {"role": "user", "content": f"我要写一篇关于「{topic}」的博客文章。帮我列一个详细的文章提纲。"}
105+ # ], deepseek, "deepseek-reasoner")
106+ return grok_generate ([
107+ user (f"我要写一篇关于「{ topic } 」的博客文章。帮我列一个详细的文章提纲。" )
108+ ], xai_client , "grok-3-mini" )
84109
85110def write_from_outline (outline ):
86111 global deepseek , existing_posts_text
87- return generate ([
88- {"role" : "system" , "content" : "你是一位专业技术博客作者。在写作时请遵循以下中文排版规范:使用全角中文标点;专有名词大小写正确;英文、数字使用半角字符;使用直角引号「」。" },
89- {"role" : "user" , "content" : f"{ outline } \n \n 根据这个提纲中关于技术知识的部分,写出一篇技术博客文章。文章中避免出现图片,不能使用任何列表。每一段出现的代码都进行较为详细的解读。在讲述内容时尽量使用段落的语言,语言风格可以略偏专业,但保持清晰。使用Markdown(要求符合Common Markdown规范)输出,使用LaTeX公式(注意:数学的开闭定界符前后不能有字母或数字字符。像x$a + b = c$或$a + b = c$1将无法渲染为数学公式(所有$会被渲染为$);但x $\\ infty$ 1和($\\ infty$)会正常渲染),标题尽量只用一级标题 `#` 和二级标题 `##`,不要用分割线。请遵循中文排版规范,使用正确的标点符号。直接输出正文。" }
90- ], deepseek , "deepseek-reasoner" )
112+ # return generate([
113+ # {"role": "system", "content": "你是一位专业技术博客作者。在写作时请遵循以下中文排版规范:使用全角中文标点;专有名词大小写正确;英文、数字使用半角字符;使用直角引号「」。"},
114+ # {"role": "user", "content": f"{outline}\n\n根据这个提纲中关于技术知识的部分,写出一篇技术博客文章。文章中避免出现图片,不能使用任何列表。每一段出现的代码都进行较为详细的解读。在讲述内容时尽量使用段落的语言,语言风格可以略偏专业,但保持清晰。使用Markdown(要求符合Common Markdown规范)输出,使用LaTeX公式(注意:数学的开闭定界符前后不能有字母或数字字符。像x$a + b = c$或$a + b = c$1将无法渲染为数学公式(所有$会被渲染为$);但x $\\infty$ 1和($\\infty$)会正常渲染),标题尽量只用一级标题 `#` 和二级标题 `##`,不要用分割线。请遵循中文排版规范,使用正确的标点符号。直接输出正文。"}
115+ # ], deepseek, "deepseek-reasoner")
116+ return grok_generate ([
117+ system ("你是一位专业技术博客作者。在写作时请遵循以下中文排版规范:使用全角中文标点;专有名词大小写正确;英文、数字使用半角字符;使用直角引号「」。" ),
118+ user (f"{ outline } \n \n 根据这个提纲中关于技术知识的部分,写出一篇技术博客文章。文章中避免出现图片,不能使用任何列表。每一段出现的代码都进行较为详细的解读。在讲述内容时尽量使用段落的语言,语言风格可以略偏专业,但保持清晰。使用Markdown(要求符合Common Markdown规范)输出,使用LaTeX公式(注意:数学的开闭定界符前后不能有字母或数字字符。像x$a + b = c$或$a + b = c$1将无法渲染为数学公式(所有$会被渲染为$);但x $\\ infty$ 1和($\\ infty$)会正常渲染),标题尽量只用一级标题 `#` 和二级标题 `##`,不要用分割线。请遵循中文排版规范,使用正确的标点符号。直接输出正文。" )
119+ ], xai_client , "grok-3-mini" )
91120
92121def summary (article ):
93122 global deepseek
94- return generate ([
95- {"role" : "system" , "content" : "你是一个技术博客简介写作者,简介不一定需要涵盖文章的全部内容,能起到一定的提示作用即可。直接输出简介。遵循以下中文排版规范:使用全角中文标点;专有名词大小写正确;英文、数字使用半角字符。注意简介被作为副标题使用,不是一句句子,不要以句号结尾。" },
96- {"role" : "user" , "content" : f"给这篇文章写一个15字的简短介绍:\n \n { article } " }
97- ], deepseek , "deepseek-chat" )
123+ # return generate([
124+ # {"role": "system", "content": "你是一个技术博客简介写作者,简介不一定需要涵盖文章的全部内容,能起到一定的提示作用即可。直接输出简介。遵循以下中文排版规范:使用全角中文标点;专有名词大小写正确;英文、数字使用半角字符。注意简介被作为副标题使用,不是一句句子,不要以句号结尾。"},
125+ # {"role": "user", "content": f"给这篇文章写一个15字的简短介绍:\n\n{article}"}
126+ # ], deepseek, "deepseek-chat")
127+ return grok_generate ([
128+ system ("你是一个技术博客简介写作者,简介不一定需要涵盖文章的全部内容,能起到一定的提示作用即可。直接输出简介。遵循以下中文排版规范:使用全角中文标点;专有名词大小写正确;英文、数字使用半角字符。注意简介被作为副标题使用,不是一句句子,不要以句号结尾。" ),
129+ user (f"给这篇文章写一个15字的简短介绍:\n \n { article } " )
130+ ], xai_client , "grok-3-mini" )
131+
132+ # LaTeX error handling
133+ def extract_latex_segments (markdown_text : str ) -> List [Tuple [str , int , int ]]:
134+ segments : List [Tuple [str ,int ,int ]] = []
135+ block_pattern = re .compile (r'(\$\$[\s\S]+?\$\$)' , re .DOTALL )
136+ for m in block_pattern .finditer (markdown_text ):
137+ segments .append ((m .group (1 ), m .start (), m .end ()))
138+
139+ inline_pattern = re .compile (r'(?<!\\)(\$(?:\\.|[^$])+?\$)' , re .DOTALL )
140+ for m in inline_pattern .finditer (markdown_text ):
141+ if any (start <= m .start () < end for _ , start , end in segments ):
142+ continue
143+ segments .append ((m .group (1 ), m .start (), m .end ()))
144+
145+ return segments
146+
147+ def latex_checks (latex_str : str ) -> List [str ]:
148+ errors : List [str ] = []
149+
150+ # 命令后多余空格 (忽略 \tt, \it, \bf)
151+ for m in re .finditer (r"\\([a-zA-Z]+)(\s+)" , latex_str ):
152+ cmd = m .group (1 )
153+ if cmd not in ('tt' , 'it' , 'bf' ):
154+ errors .append (f"命令 '\\ { cmd } ' 后跟有空格,建议去掉空格。" )
155+
156+ # 引用前多余空格,建议用 '~'
157+ if re .search (r"\s+\\ref\{" , latex_str ):
158+ errors .append ("'\\ ref' 前有空格,应使用 '~\\ ref{...}' 保持断开。" )
159+
160+ # 省略号 '...' 而非 \dots 或 \ldots
161+ if re .search (r'(?<!\\)(?:\.\.\.|…)' , latex_str ):
162+ errors .append ("检测到省略号,建议使用 '\\ dots'、'\\ cdots' 或 '\\ ldots'。" )
163+
164+ # 缩写后不加特殊空格
165+ for m in re .finditer (r"\b(e\.g|i\.e|etc)\.(\s+)" , latex_str ):
166+ errors .append (f"缩写 '{ m .group (1 )} .' 后应使用 '\\ ' 或 '~' 保持空格。" )
167+
168+ # 句末大写字母后应有两个空格
169+ for m in re .finditer (r"([A-Z])\.(\s)(?=[A-Z])" , latex_str ):
170+ errors .append (f"句子结尾 '{ m .group (1 )} .' 后只有单个空格,建议使用两个空格。" )
171+
172+ # 再次检查数学mode的$
173+ # 块级
174+ block_marks = re .findall (r'\$\$' , latex_str )
175+ if len (block_marks ) % 2 != 0 :
176+ errors .append ("块级数学模式 '$$' 不成对。" )
177+ # 去掉所有 $$…$$ 段
178+ no_block = re .sub (r'\$\$[\s\S]+?\$\$' , '' , latex_str )
179+ # 行内
180+ inline_marks = len (re .findall (r'(?<!\\)\$' , no_block ))
181+ if inline_marks % 2 != 0 :
182+ errors .append ("行内数学模式 '$' 不成对。" )
183+
184+ # 引号 `` ''
185+ if '"' in latex_str and not re .search (r"``.*?''" , latex_str , re .DOTALL ):
186+ errors .append ("检测到直引号 '\" ',建议使用 LaTeX 引号 ``...'' 。" )
187+
188+ # \label 前空格
189+ if re .search (r"\s+\\label\{" , latex_str ):
190+ errors .append ("'\\ label' 前有空格,应紧贴前文。" )
191+
192+ # \footnote 前空格
193+ if re .search (r"\s+\\footnote\{" , latex_str ):
194+ errors .append ("'\\ footnote' 前有空格,应紧贴前文。" )
195+
196+ # 数学中用 x 而非 \times
197+ for m in re .finditer (r"(?<!\\)\b(\d+)\s*x\s*(\d+)\b" , latex_str ):
198+ errors .append (f"'{ m .group (1 )} x { m .group (2 )} ' 建议用 '$\\ times$'。" )
199+
200+ # 多余连续空格
201+ if re .search (r" {2,}" , latex_str ):
202+ errors .append ("检测到连续多个空格,可能要删掉" )
203+
204+ # 大括号匹配
205+ stack : List [int ] = []
206+ for pos , ch in enumerate (latex_str ):
207+ if ch == '{' : stack .append (pos )
208+ elif ch == '}' :
209+ if not stack :
210+ errors .append (f"位置 { pos } : 多余 '}}' 。" )
211+ else :
212+ stack .pop ()
213+ for pos in stack :
214+ errors .append (f"位置 { pos } : 多余 '{{' 。" )
215+
216+ # \begin / \end 匹配(修正 \end raw-string 报错)
217+ env_stack : List [Tuple [str , int ]] = []
218+ for m in re .finditer (r"\\(begin|end)\s*\{([^}]+)\}" , latex_str ):
219+ cmd , env = m .group (1 ), m .group (2 )
220+ pos = m .start ()
221+ if cmd == 'begin' :
222+ env_stack .append ((env , pos ))
223+ else : # cmd == 'end'
224+ if not env_stack or env_stack [- 1 ][0 ] != env :
225+ # 注意这里用双反斜杠来正确表示 '\end'
226+ errors .append (f"位置 { pos } : '\\ end{{{ env } }}' 无匹配或顺序错误。" )
227+ else :
228+ env_stack .pop ()
229+ # 剩余未闭合的 begin
230+ for env , pos in env_stack :
231+ errors .append (f"位置 { pos } : '\\ begin{{{ env } }}' 未关闭。" )
232+
233+ # 括号前多余空格
234+ if re .search (r"\s+\(" , latex_str ):
235+ errors .append ("左括号 '(' 前有空格,应去除。" )
236+
237+ # 数学模式中不应有标点
238+ for m in re .finditer (r"\$(?:[^$]*?)[.,;:!?]+(?:[^$]*?)\$" , latex_str ):
239+ errors .append ("数学模式中包含标点符号,建议放在模式外。" )
240+
241+ return errors
242+
243+ def latex_errors (markdown_text : str ) -> Dict [Tuple [str , int ], List [str ]]:
244+ report = {}
245+ for seg , start_idx , _ in extract_latex_segments (markdown_text ):
246+ errs = latex_checks (seg )
247+ if errs :
248+ report [(seg , start_idx )] = errs
249+ return report
250+
251+ def modify_latex (markdown_text : str , error_report : Dict [Tuple [str ,int ], List [str ]]) -> str :
252+ """
253+ 遍历 error_report,按 start_idx 从大到小替换,
254+ 保证后面的替换不影响前面的 start_idx。
255+ """
256+ corrected = markdown_text
257+ items = sorted (error_report .items (), key = lambda x : x [0 ][1 ], reverse = True )
258+
259+ for (seg , start_idx ), errs in items :
260+ end_idx = start_idx + len (seg )
261+ context = corrected [max (0 , start_idx - 50 ): end_idx + 50 ]
262+ user_msg = (
263+ f"修正此 LaTeX 片段(包含 $ 定界符):\n { seg } \n \n "
264+ "检测到错误:\n - " + "\n - " .join (errs ) +
265+ "\n \n 上下文:\n " + context +
266+ "\n \n 请只返回修正后的完整片段,不要添加其它标记。"
267+ )
268+ # fixed = generate([
269+ # {"role":"system","content":"你是 LaTeX 专家,负责修正以下代码:"},
270+ # {"role":"user","content":user_msg}
271+ # ], deepseek, "deepseek-reasoner").strip()
272+ fixed = grok_generate ([
273+ system ("你是 LaTeX 专家,负责修正以下代码:" ),
274+ user (user_msg )
275+ ], xai_client , "grok-3-mini" ).strip ()
276+
277+ # 去掉```,如果不小心生成了
278+ if fixed .startswith ("```" ) and fixed .endswith ("```" ):
279+ fixed = "\n " .join (fixed .splitlines ()[1 :- 1 ]).strip ()
280+
281+ # 给重新生成的丢失的加上 $/$$,如果ds忘记了
282+ if not fixed .startswith ('$' ):
283+ if seg .startswith ('$$' ) and seg .endswith ('$$' ):
284+ fixed = '$$' + fixed + '$$'
285+ elif seg .startswith ('$' ) and seg .endswith ('$' ):
286+ fixed = '$' + fixed + '$'
287+
288+ # 最终替换
289+ corrected = corrected [:start_idx ] + fixed + corrected [end_idx :]
290+
291+ return corrected
98292
99293is_latin = lambda ch : '\u0000 ' <= ch <= '\u007F ' or '\u00A0 ' <= ch <= '\u024F '
100294is_nonspace_latin = lambda ch : is_latin (ch ) and not ch .isspace () and not ch in """*()[]{}"'/-@#"""
101295is_nonpunct_cjk = lambda ch : not is_latin (ch ) and ch not in "·!¥…()—【】、;:‘’“”,。《》?「」"
102296
103- def beautify_string (text ):
104- res = ""
105- for idx in range (len (text )):
106- if idx and (
107- (is_nonspace_latin (text [idx ]) and is_nonpunct_cjk (text [idx - 1 ])) or
108- (is_nonspace_latin (text [idx - 1 ]) and is_nonpunct_cjk (text [idx ]))
109- ): res += " "
110- res += text [idx ]
111- return res
297+ # beautify的时候跳过 LaTeX
298+ def beautify_string (text : str ) -> str :
299+ segments = extract_latex_segments (text )
300+ segments .sort (key = lambda x : x [1 ])
301+
302+ result_parts = []
303+ last_end = 0
304+
305+ for seg_content , seg_start , seg_end in segments :
306+ non_latex_part = text [last_end :seg_start ]
307+ processed_part = ""
308+ for i , char in enumerate (non_latex_part ):
309+ if i > 0 and (
310+ (is_nonspace_latin (char ) and is_nonpunct_cjk (non_latex_part [i - 1 ])) or
311+ (is_nonspace_latin (non_latex_part [i - 1 ]) and is_nonpunct_cjk (char ))
312+ ):
313+ processed_part += " "
314+ processed_part += char
315+ result_parts .append (processed_part )
316+
317+ result_parts .append (seg_content )
318+ last_end = seg_end
319+
320+ final_part = text [last_end :]
321+ processed_final_part = ""
322+ for i , char in enumerate (final_part ):
323+ if i > 0 and (
324+ (is_nonspace_latin (char ) and is_nonpunct_cjk (final_part [i - 1 ])) or
325+ (is_nonspace_latin (final_part [i - 1 ]) and is_nonpunct_cjk (char ))
326+ ):
327+ processed_final_part += " "
328+ processed_final_part += char
329+ result_parts .append (processed_final_part )
330+
331+ return "" .join (result_parts )
112332
113333start = time .time ()
114334print (" Generating topic:" )
@@ -122,9 +342,21 @@ def beautify_string(text):
122342
123343start = time .time ()
124344print (" Generating article:" )
125- article = beautify_string ( write_from_outline (outline_result ) )
345+ article = write_from_outline (outline_result )
126346print (f" Article written: time spent { time .time () - start :.1f} s" )
127347
348+ start = time .time ()
349+ while latex_errors (article ):
350+ print ("latex_errors still exist" )
351+ article = modify_latex (article , latex_errors (article ))
352+
353+ print (f" LaTeX errors fixed: time spent { time .time () - start :.1f} s" )
354+
355+ start = time .time ()
356+ article = beautify_string (article )
357+ print (f" Article beautified: time spent { time .time () - start :.1f} s" )
358+
359+
128360start = time .time ()
129361print (" Generating summary:" )
130362summary_result = beautify_string (summary (article ))
@@ -160,4 +392,4 @@ def beautify_string(text):
160392with open (f"{ path_to } /index.md" , "w" , encoding = "utf-8" ) as f :
161393 f .write (markdown_file )
162394
163- print (f" Composed article: { path_to } /index.md" )
395+ print (f" Composed article: { path_to } /index.md" )
0 commit comments