@hupan56/wlkj 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hupan56/wlkj",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "AI Product R&D Workflow - PRD/Prototype/Search/Task/Report",
5
5
  "bin": {
6
6
  "wlkj": "bin/cli.js"
@@ -24,31 +24,64 @@ from typing import Dict, List, Optional
24
24
 
25
25
 
26
26
  def find_repowiki_dirs(code_dir: str) -> List[tuple]:
27
- """扫描 data/code/{project}/.qoder/repowiki/, 返回 [(project, wiki_root), ...]。"""
27
+ """扫描 data/code/{project}/.qoder/repowiki/, 返回 [(project, wiki_root), ...]。
28
+
29
+ 健壮性: repowiki 目录可能存在但为空/损坏/正在生成中。
30
+ 只返回 metadata 文件存在且非空的目录。
31
+ """
28
32
  results = []
29
33
  if not os.path.isdir(code_dir):
30
34
  return results
31
- for proj in os.listdir(code_dir):
32
- # repowiki 目录 (可能是 zh/ 或 en/)
33
- wiki_base = os.path.join(code_dir, proj, '.qoder', 'repowiki')
34
- if not os.path.isdir(wiki_base):
35
- continue
36
- for lang in os.listdir(wiki_base):
37
- lang_dir = os.path.join(wiki_base, lang)
38
- meta = os.path.join(lang_dir, 'meta', 'repowiki-metadata.json')
39
- if os.path.isfile(meta):
35
+ try:
36
+ projects = os.listdir(code_dir)
37
+ except OSError:
38
+ return results
39
+ for proj in projects:
40
+ try:
41
+ wiki_base = os.path.join(code_dir, proj, '.qoder', 'repowiki')
42
+ if not os.path.isdir(wiki_base):
43
+ continue
44
+ for lang in os.listdir(wiki_base):
45
+ lang_dir = os.path.join(wiki_base, lang)
46
+ if not os.path.isdir(lang_dir):
47
+ continue
48
+ meta = os.path.join(lang_dir, 'meta', 'repowiki-metadata.json')
49
+ if not os.path.isfile(meta):
50
+ continue
51
+ # 校验文件非空 (生成中可能写出 0 字节文件)
52
+ try:
53
+ if os.path.getsize(meta) < 10:
54
+ continue # 太小, 可能正在生成
55
+ except OSError:
56
+ continue
40
57
  results.append((proj, lang_dir))
41
58
  break # 每个项目只取一个语言版本
59
+ except OSError:
60
+ continue # 权限/路径问题, 跳过这个项目
42
61
  return results
43
62
 
44
63
 
45
64
  def load_wiki_metadata(wiki_root: str) -> Optional[dict]:
46
- """加载 repowiki-metadata.json。"""
65
+ """加载 repowiki-metadata.json。
66
+
67
+ 健壮性: 文件可能不存在/空/损坏/正在生成。
68
+ """
47
69
  meta_path = os.path.join(wiki_root, 'meta', 'repowiki-metadata.json')
48
70
  try:
71
+ if not os.path.isfile(meta_path):
72
+ return None
73
+ if os.path.getsize(meta_path) < 10:
74
+ return None # 正在生成或空
49
75
  with open(meta_path, encoding='utf-8') as f:
50
- return json.load(f)
51
- except (OSError, json.JSONDecodeError):
76
+ data = json.load(f)
77
+ # 校验基本结构
78
+ if not isinstance(data, dict):
79
+ return None
80
+ # wiki_catalogs 可能为空 (生成中)
81
+ if not data.get('wiki_catalogs'):
82
+ return None # 没有内容, 视为无效
83
+ return data
84
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
52
85
  return None
53
86
 
54
87
 
@@ -73,76 +106,100 @@ def build_wiki_index(code_dir: str, output_path: str) -> dict:
73
106
  """扫描所有项目的 Repo Wiki, 构建 wiki-index.json。
74
107
 
75
108
  Returns:
76
- 构建的索引 dict: {keyword: [{project, title, catalog_id, md_path, dependent_files, description}]}
109
+ {'index': {...}, 'stats': {...}} {'index': {}, 'stats': {...}}
110
+ 始终返回含 'index' 和 'stats' 两个 key 的 dict (调用方可安全 .get)。
111
+ wiki 不存在/为空/损坏时 index 为 {}, stats 计数为 0。
77
112
  """
78
- wiki_dirs = find_repowiki_dirs(code_dir)
79
- if not wiki_dirs:
80
- return {}
81
-
82
113
  index = {} # keyword -> [entry, ...]
83
114
  stats = {'projects': 0, 'pages': 0, 'keywords': 0}
84
115
 
85
- for project, wiki_root in wiki_dirs:
86
- meta = load_wiki_metadata(wiki_root)
87
- if not meta:
88
- continue
89
- stats['projects'] += 1
90
-
91
- catalogs = meta.get('wiki_catalogs', [])
92
- items = meta.get('wiki_items', [])
93
- # catalog_id -> title 映射
94
- id_to_title = {it.get('catalog_id'): it.get('title', '') for it in items}
95
-
96
- for cat in catalogs:
97
- cat_id = cat.get('id', '')
98
- name = cat.get('name', '')
99
- desc = cat.get('description', '')
100
- dep_files = cat.get('dependent_files', '')
101
- title = id_to_title.get(cat_id, name)
102
-
103
- # 找对应的 markdown 文档
104
- md_path = _find_md_for_catalog(wiki_root, cat_id, name)
105
- if not md_path:
106
- continue
116
+ wiki_dirs = find_repowiki_dirs(code_dir)
117
+ if not wiki_dirs:
118
+ # wiki 不存在或都损坏 → 写空索引, 避免每次搜索都重建
119
+ _write_wiki_index(output_path, index, stats)
120
+ return {'index': index, 'stats': stats}
107
121
 
108
- stats['pages'] += 1
109
-
110
- # 提取关键词 (标题 + 描述 + 依赖文件名)
111
- kw_text = ' '.join([title, name, desc])
112
- # 依赖文件也作为关键词 (异常.java 搜"异常"能命中)
113
- if dep_files:
114
- kw_text += ' ' + dep_files.replace(',', ' ').replace('/', ' ')
115
- keywords = _extract_keywords(kw_text)
116
-
117
- entry = {
118
- 'project': project,
119
- 'title': title or name,
120
- 'catalog_id': cat_id,
121
- 'md_path': md_path,
122
- 'dependent_files': [f.strip() for f in dep_files.split(',') if f.strip()] if dep_files else [],
123
- 'description': desc,
124
- }
125
-
126
- for kw in keywords:
127
- index.setdefault(kw, [])
128
- if entry not in index[kw]:
129
- index[kw].append(entry)
122
+ for project, wiki_root in wiki_dirs:
123
+ try:
124
+ meta = load_wiki_metadata(wiki_root)
125
+ if not meta:
126
+ continue # 这个项目的 wiki 损坏/为空, 跳过
127
+ stats['projects'] += 1
128
+
129
+ catalogs = meta.get('wiki_catalogs') or []
130
+ items = meta.get('wiki_items') or []
131
+ # catalog_id -> title 映射
132
+ id_to_title = {}
133
+ for it in items:
134
+ if isinstance(it, dict):
135
+ cid = it.get('catalog_id')
136
+ if cid:
137
+ id_to_title[cid] = it.get('title', '')
138
+
139
+ for cat in catalogs:
140
+ try:
141
+ if not isinstance(cat, dict):
142
+ continue
143
+ cat_id = cat.get('id', '')
144
+ name = cat.get('name', '') or ''
145
+ desc = cat.get('description', '') or ''
146
+ dep_files = cat.get('dependent_files', '') or ''
147
+ title = id_to_title.get(cat_id, name)
148
+
149
+ # 找对应的 markdown 文档 (可能不存在, 生成中)
150
+ md_path = _find_md_for_catalog(wiki_root, cat_id, name)
151
+ if not md_path:
152
+ continue # 文档还没生成, 跳过
153
+
154
+ stats['pages'] += 1
155
+
156
+ # 提取关键词
157
+ kw_text = ' '.join([str(title), str(name), str(desc)])
158
+ if dep_files:
159
+ kw_text += ' ' + str(dep_files).replace(',', ' ').replace('/', ' ')
160
+ keywords = _extract_keywords(kw_text)
161
+
162
+ entry = {
163
+ 'project': project,
164
+ 'title': str(title or name),
165
+ 'catalog_id': str(cat_id),
166
+ 'md_path': md_path,
167
+ 'dependent_files': [f.strip() for f in str(dep_files).split(',') if f.strip()],
168
+ 'description': str(desc),
169
+ }
170
+
171
+ for kw in keywords:
172
+ index.setdefault(kw, [])
173
+ if entry not in index[kw]:
174
+ index[kw].append(entry)
175
+ except (OSError, TypeError, ValueError):
176
+ continue # 单条 catalog 出错不影响其他
177
+ except (OSError, TypeError):
178
+ continue # 整个项目的 wiki 出错, 跳过
130
179
 
131
180
  stats['keywords'] = len(index)
181
+ _write_wiki_index(output_path, index, stats)
182
+ return {'index': index, 'stats': stats}
132
183
 
133
- # 写入
134
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
135
- with open(output_path, 'w', encoding='utf-8') as f:
136
- json.dump({'index': index, 'stats': stats}, f, ensure_ascii=False, indent=2)
137
184
 
138
- return {'index': index, 'stats': stats}
185
+ def _write_wiki_index(output_path: str, index: dict, stats: dict) -> None:
186
+ """安全写入 wiki-index.json (best-effort, 失败不崩)。"""
187
+ try:
188
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
189
+ with open(output_path, 'w', encoding='utf-8') as f:
190
+ json.dump({'index': index, 'stats': stats}, f, ensure_ascii=False, indent=2)
191
+ except OSError:
192
+ pass
139
193
 
140
194
 
141
195
  def _find_md_for_catalog(wiki_root: str, catalog_id: str, name: str) -> Optional[str]:
142
196
  """在 content/ 目录下找对应的 markdown 文件。
143
197
 
144
198
  文件名通常等于 catalog name (.md), 在某个子分类目录下。
199
+ 健壮性: name 为空/content 目录不存在时返回 None。
145
200
  """
201
+ if not name:
202
+ return None
146
203
  content_dir = os.path.join(wiki_root, 'content')
147
204
  if not os.path.isdir(content_dir):
148
205
  return None
@@ -80,6 +80,26 @@ python .qoder/scripts/team_sync.py push
80
80
  ```
81
81
  让团队/上级看到。
82
82
 
83
+ ## 推送通知(可选,二选一)
84
+
85
+ 生成报告后,如果用户要求"发到飞书/钉钉/群里",按环境选:
86
+
87
+ ### 方式 A: QoderWork 连接器(QoderWork 桌面端,交互式)
88
+ 如果你在 **QoderWork** 里运行(有连接器):
89
+ 1. 检查用户是否装了飞书/钉钉连接器(Settings → Connectors & MCP)
90
+ 2. 用连接器把报告发到指定群/个人
91
+ 3. 这是最自然的方式(富文本卡片、@提及、审批流转都支持)
92
+ > 提示用户: "要我发到飞书吗?先在 QoderWork Settings → Connectors 装飞书连接器"
93
+
94
+ ### 方式 B: webhook(任何环境,脚本化)
95
+ 如果没有连接器,用引擎自带的 webhook 推送:
96
+ ```bash
97
+ python .qoder/scripts/report.py daily --push-feishu
98
+ python .qoder/scripts/report.py weekly --push-feishu
99
+ ```
100
+ 这会通过 config.yaml 里配的飞书 bot webhook 发卡片通知。
101
+ 两种方式不冲突,连接器更富交互,webhook 更可靠无依赖。
102
+
83
103
  ## 输出规则
84
104
 
85
105
  - 报告是中文,简洁(日报 ≤20 行,周报 ≤40 行)