Skip to content

Commit ecfa33e

Browse files
authored
Merge pull request #52 from iflytek/chore/doc-watch-openai
chore: doc-watch 增加 OpenAI 起草层(联网搜索 + 自动开 PR)
2 parents 5955cb7 + 1096544 commit ecfa33e

6 files changed

Lines changed: 557 additions & 170 deletions

File tree

‎.github/workflows/doc-watch.yml‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
name: doc-watch
22

3-
# 每日抓取上游文档站(Loomy Docs / AstronClaw 使用指南),与仓库基线对比,
4-
# 发现新增内容或图片时,自动维护一条 [doc-watch] Issue 汇总待补全清单。
3+
# 每日抓取上游文档站(Loomy Docs / AstronClaw 使用指南),与仓库基线对比:
4+
# 1) 始终:发现新增内容时维护一条 [doc-watch] Issue 汇总待补全清单。
5+
# 2) 可选:若配置了 OPENAI_API_KEY secret,则用 OpenAI(自带 web_search,按你自己的
6+
# API 计费、不消耗 Claude 额度)起草新页面+下载配图+联网检索官方更新,并开一个待审 PR。
57
#
68
# 触发:每天 01:00 UTC(北京时间 09:00)+ 可手动触发。
9+
#
10+
# 需要的仓库配置(用到 PR 自动起草时):
11+
# - Secret OPENAI_API_KEY :你的 OpenAI key
12+
# - Variable OPENAI_MODEL :可选,默认 gpt-4.1(须支持 web_search)
13+
# - 仓库 Settings → Actions → General → 勾选 “Allow GitHub Actions to create and approve pull requests”
714

815
on:
916
schedule:
1017
- cron: '0 1 * * *'
1118
workflow_dispatch: {}
1219

1320
permissions:
14-
contents: read
21+
contents: write
1522
issues: write
23+
pull-requests: write
1624

1725
jobs:
1826
watch:
1927
runs-on: ubuntu-latest
28+
env:
29+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
30+
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
2031
steps:
2132
- uses: actions/checkout@v4
2233

@@ -57,6 +68,45 @@ jobs:
5768
gh issue create --title "$TITLE" --label doc-watch --body-file "$BODY_FILE"
5869
fi
5970
71+
# ---- 可选:OpenAI 起草 + 自动开 PR(仅在配置了 OPENAI_API_KEY 时启用)----
72+
- name: Draft new pages with OpenAI
73+
id: draft
74+
if: steps.watch.outputs.code == '1' && env.OPENAI_API_KEY != ''
75+
run: |
76+
set +e
77+
node scripts/doc-watch/draft-with-openai.mjs
78+
echo "code=$?" >> "$GITHUB_OUTPUT"
79+
set -e
80+
81+
- name: Build, refresh baseline and open PR
82+
if: steps.watch.outputs.code == '1' && env.OPENAI_API_KEY != ''
83+
env:
84+
GH_TOKEN: ${{ github.token }}
85+
run: |
86+
if [ -z "$(git status --porcelain docs/)" ]; then
87+
echo "OpenAI 未产出可落地的页面改动,跳过 PR(详情见 doc-watch Issue)。"
88+
exit 0
89+
fi
90+
npm ci
91+
npm run docs:build
92+
node scripts/doc-watch/check-updates.mjs --update || true
93+
94+
DATE=$(date +%Y-%m-%d)
95+
BRANCH="docs/sync-upstream-$(date +%Y%m%d-%H%M)"
96+
git config user.name "${{ github.actor }}"
97+
git config user.email "${{ github.actor }}@users.noreply.github.com"
98+
git checkout -b "$BRANCH"
99+
# 只提交源文件与重建的搜索索引,避免把已被 .gitignore 但历史已跟踪的
100+
# docs/.vitepress/dist|cache 构建产物卷进来。
101+
git add docs/guide docs/en docs/public/loomy docs/public/astronclaw \
102+
docs/public/docs-index.json docs/.vitepress/config.mts \
103+
scripts/doc-watch/baseline/
104+
git commit -m "docs: 同步上游文档新增内容 ($DATE)"
105+
git push -u origin "$BRANCH"
106+
gh pr create --base main --head "$BRANCH" \
107+
--title "docs: 同步上游文档新增内容 ($DATE)" \
108+
--body-file doc-watch-draft-pr.md
109+
60110
- name: Report capture failure
61111
if: steps.watch.outputs.code == '2'
62112
env:

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,7 @@ secrets.js
5454
ehthumbs.db
5555
Thumbs.db
5656
Desktop.ini
57+
58+
# doc-watch 生成的临时报告
59+
doc-watch-report.md
60+
doc-watch-draft-pr.md

‎scripts/doc-watch/README.md‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313

1414
```
1515
scripts/doc-watch/
16-
check-updates.mjs # 抓取 + 对比引擎(零依赖,Node 18+ 内置 fetch)
16+
lib.mjs # 共享库:抓取 + HTML 解析助手(零依赖)
17+
check-updates.mjs # 抓取 + 对比引擎(退出码 0/1/2)
18+
draft-with-openai.mjs # 可选:用 OpenAI 起草新页面+下载配图+联网检索,产出 PR 描述
1719
baseline/ # 基线快照(每源一个 JSON:页面 / 小节 / 图片 / 正文哈希)
1820
loomy.json
1921
astronclaw.json
20-
.github/workflows/doc-watch.yml # 每日 cron,发现变化时维护一条 [doc-watch] Issue
22+
.github/workflows/doc-watch.yml # 每日 cron:Issue 告警 +(可选)OpenAI 起草并开 PR
2123
```
2224

2325
## 本地用法
@@ -56,10 +58,40 @@ node scripts/doc-watch/check-updates.mjs --update
5658
自动维护**一条**带 `doc-watch` 标签的 Issue,列出新增页面 / 小节 / 图片 URL,供后续补全。
5759
免费、纯确定性,但不含联网搜索与自动起草。
5860

59-
### 第二层:Claude 定时云 agent(联网搜索 + 自动起草 PR)
61+
### 第二层 A:OpenAI 起草(在 GitHub Actions 里跑你自己的 API,省 Claude 额度)✅ 推荐
6062

61-
确定性巡检之上,再用一个每日 Claude routine 完成「联网找新内容 + 抓图 + 起草 PR」。
62-
routine 的提示词如下(可直接用于 `/schedule` 或定时 agent):
63+
`draft-with-openai.mjs` 用 **OpenAI Responses API**(自带 `web_search` 内置工具)完成
64+
「读懂上游新页面 → 起草中英双语页面 → 下载配图 → 联网检索官方更新」,按你自己的
65+
OpenAI key 计费,不消耗 Claude 额度。工作流在检测到变化且配置了 key 时自动调用它并开 PR。
66+
67+
启用步骤:
68+
69+
1. 仓库 **Settings → Secrets and variables → Actions**:
70+
- 新增 Secret `OPENAI_API_KEY` = 你的 OpenAI key。
71+
- (可选)新增 Variable `OPENAI_MODEL`,默认 `gpt-4.1`;须用支持 `web_search` 的模型。
72+
2. 仓库 **Settings → Actions → General → Workflow permissions**:勾选
73+
**「Allow GitHub Actions to create and approve pull requests」**(否则无法自动开 PR)。
74+
3. 之后每天若上游有新增页面,工作流会自动:起草双语页面 + 下载配图 → `npm run docs:build`
75+
→ 刷新基线 → 开一个 `docs: 同步上游文档新增内容 (日期)` 的待审 PR。
76+
77+
本地试跑(需要 key;`--dry-run` 不调用 OpenAI、不写文件,仅打印计划):
78+
79+
```bash
80+
node scripts/doc-watch/draft-with-openai.mjs --dry-run
81+
OPENAI_API_KEY=sk-... node scripts/doc-watch/draft-with-openai.mjs
82+
```
83+
84+
护栏:只自动镜像新的 Loomy 内容页(跳过法务/隐私/索引页);侧边栏入口与「内容有改动的
85+
已有页面 / AstronClaw 指南」留在 PR 描述里由人工处理;PR 仅起草、不自动合并,需人工审阅。
86+
另需 `OPENAI_BASE_URL` 时可指向 OpenAI 兼容端点。
87+
88+
> ⚠️ web_search 需要联网;纯文档同步(翻译/改写上游正文)即使模型不带搜索也能完成,
89+
> 只有「发现全网全新功能」那部分依赖 web_search。
90+
91+
### 第二层 B:Claude 定时云 agent(备选,消耗 Claude 额度)
92+
93+
如果不想用 OpenAI,也可以用一个每日 Claude routine 完成同样的「联网找新内容 + 抓图 +
94+
起草 PR」。routine 的提示词如下(可直接用于 `/schedule` 或定时 agent):
6395

6496
```
6597
每天巡检本仓库的上游文档,发现并补全新内容:

‎scripts/doc-watch/check-updates.mjs‎

Lines changed: 1 addition & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -18,173 +18,12 @@
1818

1919
import fs from 'node:fs';
2020
import path from 'node:path';
21-
import crypto from 'node:crypto';
2221
import { fileURLToPath } from 'node:url';
22+
import { SOURCES, captureSource } from './lib.mjs';
2323

2424
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2525
const BASELINE_DIR = path.join(__dirname, 'baseline');
2626

27-
const SOURCES = [
28-
{
29-
id: 'loomy',
30-
name: 'Loomy Docs',
31-
type: 'vitepress',
32-
origin: 'https://loomy.xunfei.cn',
33-
index: 'https://loomy.xunfei.cn/docs',
34-
},
35-
{
36-
id: 'astronclaw',
37-
name: 'AstronClaw 使用指南',
38-
type: 'single',
39-
origin: 'https://www.xfyun.cn',
40-
pages: [
41-
'https://www.xfyun.cn/doc/spark/Agent-AstronClaw%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97.html',
42-
],
43-
},
44-
];
45-
46-
const UA =
47-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) doc-watch/1.0';
48-
49-
async function fetchText(url) {
50-
const res = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow' });
51-
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
52-
return res.text();
53-
}
54-
55-
// ---------- HTML helpers (regex-based, no deps) ----------
56-
57-
function decodeEntities(s) {
58-
return s
59-
.replace(/&/g, '&')
60-
.replace(/&lt;/g, '<')
61-
.replace(/&gt;/g, '>')
62-
.replace(/&quot;/g, '"')
63-
.replace(/&#39;/g, "'")
64-
.replace(/&nbsp;/g, ' ')
65-
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n))
66-
.replace(/[​-‍]/g, ''); // zero-width chars (VitePress anchors)
67-
}
68-
69-
function stripTags(html) {
70-
return decodeEntities(
71-
html
72-
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
73-
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
74-
.replace(/<[^>]+>/g, ' ')
75-
)
76-
.replace(/\s+/g, ' ')
77-
.trim();
78-
}
79-
80-
function hash(s) {
81-
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
82-
}
83-
84-
function absolutize(src, origin) {
85-
if (!src) return src;
86-
if (/^https?:\/\//i.test(src)) return src;
87-
if (src.startsWith('//')) return 'https:' + src;
88-
if (src.startsWith('/')) return origin + src;
89-
return origin + '/' + src;
90-
}
91-
92-
// Grab the main article region so we don't diff against nav/footer chrome.
93-
function extractMain(html) {
94-
const main = html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i);
95-
if (main) return main[1];
96-
const vpdoc = html.match(/<div class="vp-doc[\s\S]*?<\/main>/i);
97-
if (vpdoc) return vpdoc[0];
98-
// xfyun / generic: try common content containers, else fall back to <body>.
99-
for (const re of [
100-
/<article\b[^>]*>([\s\S]*?)<\/article>/i,
101-
/<div[^>]*class="[^"]*(?:doc-content|markdown-body|article|content-detail)[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/i,
102-
]) {
103-
const m = html.match(re);
104-
if (m) return m[1];
105-
}
106-
const body = html.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
107-
return body ? body[1] : html;
108-
}
109-
110-
function extractHeadings(contentHtml) {
111-
const out = [];
112-
const re = /<h([1-4])\b[^>]*>([\s\S]*?)<\/h\1>/gi;
113-
let m;
114-
while ((m = re.exec(contentHtml))) {
115-
// Drop VitePress header-anchor links before flattening to text.
116-
const inner = m[2].replace(/<a\b[^>]*class="[^"]*header-anchor[^"]*"[^>]*>[\s\S]*?<\/a>/gi, '');
117-
const text = stripTags(inner).trim();
118-
if (text) out.push(`h${m[1]}:${text}`);
119-
}
120-
return out;
121-
}
122-
123-
function extractImages(contentHtml, origin) {
124-
const out = new Set();
125-
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
126-
let m;
127-
while ((m = re.exec(contentHtml))) {
128-
const src = absolutize(m[1], origin);
129-
// Skip ubiquitous logo/avatar chrome so it isn't flagged as "content".
130-
if (/loomy-avatar\.png$/i.test(src)) continue;
131-
out.add(src);
132-
}
133-
return [...out];
134-
}
135-
136-
// VitePress sidebar carries the full page list. Pull /docs/* hrefs.
137-
function extractSidebarLinks(html, origin, indexUrl) {
138-
const links = new Set();
139-
const re = /<a\b[^>]*class="[^"]*VPLink[^"]*"[^>]*href="([^"]+)"/gi;
140-
let m;
141-
while ((m = re.exec(html))) {
142-
let href = m[1];
143-
if (!href.startsWith('/docs')) continue;
144-
href = href.replace(/#.*$/, '').replace(/\/$/, '');
145-
links.add(absolutize(href, origin));
146-
}
147-
links.add(indexUrl.replace(/\/$/, ''));
148-
return [...links].sort();
149-
}
150-
151-
// ---------- per-source capture ----------
152-
153-
async function capturePage(url, origin) {
154-
const html = await fetchText(url);
155-
const titleM = html.match(/<title>([^<]*)<\/title>/i);
156-
const main = extractMain(html);
157-
const text = stripTags(main);
158-
return {
159-
url,
160-
title: titleM ? decodeEntities(titleM[1]).trim() : url,
161-
headings: extractHeadings(main),
162-
images: extractImages(main, origin),
163-
textHash: hash(text),
164-
textLen: text.length,
165-
};
166-
}
167-
168-
async function captureSource(src) {
169-
let pageUrls;
170-
if (src.type === 'vitepress') {
171-
const indexHtml = await fetchText(src.index);
172-
pageUrls = extractSidebarLinks(indexHtml, src.origin, src.index);
173-
} else {
174-
pageUrls = src.pages;
175-
}
176-
177-
const pages = {};
178-
for (const url of pageUrls) {
179-
try {
180-
pages[url] = await capturePage(url, src.origin);
181-
} catch (e) {
182-
pages[url] = { url, error: String(e.message || e) };
183-
}
184-
}
185-
return { source: src.id, name: src.name, pageCount: Object.keys(pages).length, pages };
186-
}
187-
18827
// ---------- diff ----------
18928

19029
function diffSource(oldSnap, newSnap) {

0 commit comments

Comments
 (0)