Skip to content

Commit a4713c2

Browse files
authored
Merge pull request #22 from Gavin-WangSC/main
feat: new xai api with grok-3-mini
2 parents dbb8518 + 1979cf8 commit a4713c2

File tree

2 files changed

+261
-28
lines changed

2 files changed

+261
-28
lines changed

.github/workflows/auto-writer.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ jobs:
1414
uses: actions/checkout@v4
1515

1616
- name: Install Python Dependencies
17-
run: pip install openai bs4 requests pyyaml
17+
run: pip install openai bs4 requests pyyaml xai_sdk
1818

1919
- name: Compose New Article
2020
env:
2121
DS_APIKEY: ${{ secrets.DS_APIKEY }}
22+
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
2223
run: python3 scripts/writer.py
2324

2425
- name: Commit and Push Changes

scripts/writer.py

Lines changed: 259 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
import os
88
import glob
99
import yaml
10+
import re
11+
from typing import List, Tuple, Dict
1012
import clean_text
13+
from xai_sdk import Client
14+
from xai_sdk.chat import system, user
1115

1216
path_to = f'src/content/blog/{datetime.datetime.now().strftime("%Y-%m-%d")}'
1317

@@ -21,15 +25,29 @@
2125
start = time.time()
2226
print(" Connecting remote:")
2327
deepseek = 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)
2429
print(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+
3351
def 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

7290
def 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

79101
def 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

85110
def 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

92121
def 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

99293
is_latin = lambda ch: '\u0000' <= ch <= '\u007F' or '\u00A0' <= ch <= '\u024F'
100294
is_nonspace_latin = lambda ch: is_latin(ch) and not ch.isspace() and not ch in """*()[]{}"'/-@#"""
101295
is_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

113333
start = time.time()
114334
print(" Generating topic:")
@@ -122,9 +342,21 @@ def beautify_string(text):
122342

123343
start = time.time()
124344
print(" Generating article:")
125-
article = beautify_string(write_from_outline(outline_result))
345+
article = write_from_outline(outline_result)
126346
print(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+
128360
start = time.time()
129361
print(" Generating summary:")
130362
summary_result = beautify_string(summary(article))
@@ -160,4 +392,4 @@ def beautify_string(text):
160392
with 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

Comments
 (0)