@adonis0123/weekly-report 1.0.10 → 1.0.12

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/README.md CHANGED
@@ -61,7 +61,7 @@ project-backend
61
61
  - [@adonis0123/commit](https://www.npmjs.com/package/@adonis0123/commit) - 提交信息生成
62
62
  - [@adonis0123/staged-changes-review](https://www.npmjs.com/package/@adonis0123/staged-changes-review) - 代码审查
63
63
  - [@adonis0123/create-skill](https://www.npmjs.com/package/@adonis0123/create-skill) - 创建新技能包
64
-
64
+ - [@adonis0123/code-doc-generator](https://www.npmjs.com/package/@adonis0123/code-doc-generator) - 代码文档生成
65
65
  ## License
66
66
 
67
67
  MIT
package/SKILL.md CHANGED
@@ -40,7 +40,58 @@ metadata:
40
40
  - 自定义周报(输入周一日期)
41
41
  - 自定义时间段(输入起始日期,截止到今天)
42
42
 
43
- **重要**:选择时必须显示具体的日期范围,让用户确认是否正确
43
+ **重要**:
44
+ - 所有日期计算必须使用 **中国时区 (UTC+8 / Asia/Shanghai)**,而不是系统默认时区
45
+ - 选择时必须显示具体的日期范围,让用户确认是否正确
46
+
47
+ **自定义日期输入规范(必须严格遵守)**:
48
+
49
+ ⚠️ **严禁使用 AskUserQuestion 工具收集日期输入** ⚠️
50
+
51
+ **问题原因**:AskUserQuestion 工具会将数字键识别为选项快捷键,导致用户输入 "2026-01-06" 时,按下 "2" 会被当作选择了选项 2,而不是日期的一部分。
52
+
53
+ **使用场景区分**:
54
+ | 场景 | 工具 | 用户操作 |
55
+ |------|------|----------|
56
+ | 选项选择(本周/上周/自定义等) | AskUserQuestion | 按数字快捷键或点击选项 |
57
+ | 自由文本输入(日期、补充说明等) | 不使用任何工具,直接输出文本 | 输入内容后按 **Enter** 发送 |
58
+
59
+ **正确流程**:
60
+ 1. 用 AskUserQuestion 显示时间范围选项(本周/上周/前半年/自定义周报/自定义时间段)
61
+ 2. 如果用户选择了"自定义周报"或"自定义时间段":
62
+ - **立即停止,不要调用任何工具**
63
+ - 直接输出纯文本提示,例如:"请输入周报的周一日期(格式:YYYY-MM-DD,如 2026-01-06):"
64
+ - 等待用户在对话框中输入日期并按 Enter 发送
65
+
66
+ **正确的交互示例**:
67
+ ```
68
+ 第一步(使用 AskUserQuestion 工具选择选项):
69
+ [AskUserQuestion 工具显示选项]
70
+ 请选择时间范围:
71
+ 1. 本周 (2026-01-13 ~ 2026-01-19)
72
+ 2. 上周 (2026-01-06 ~ 2026-01-12)
73
+ 3. 前半年 (2025-07-19 ~ 2026-01-19)
74
+ 4. 自定义周报
75
+ 5. 自定义时间段
76
+
77
+ 用户选择: 4
78
+
79
+ 第二步(直接输出文本,不调用任何工具,等待用户回复):
80
+ Claude 直接输出文本: "请输入周报的周一日期(格式:YYYY-MM-DD,如 2026-01-06):"
81
+
82
+ 用户直接在对话框中输入: 2026-01-06
83
+ [用户按 Enter 发送消息]
84
+ ```
85
+
86
+ **错误示例(严禁)**:
87
+ ```
88
+ # 错误:使用 AskUserQuestion 工具要求用户输入日期
89
+ # 原因:用户按数字键会触发选项选择,无法正常输入日期
90
+ AskUserQuestion({
91
+ question: "请输入日期",
92
+ options: [...] // 这种方式无法让用户自由输入日期!
93
+ })
94
+ ```
44
95
 
45
96
  2. **选择仓库**(如已配置多仓库)
46
97
  - 显示已配置的仓库列表
@@ -61,16 +112,17 @@ metadata:
61
112
 
62
113
  ## Git 提交读取(重要)
63
114
 
64
- 为避免“只读取当前分支而漏掉其它分支(例如 `credits-lite*`)”的问题,读取提交时必须使用 `--all`(覆盖本地分支 + 远端跟踪分支),并确保截止时间包含结束日当天:
115
+ 为避免"只读取当前分支而漏掉其它分支(例如 `credits-lite*`)"的问题,读取提交时必须使用 `--all`(覆盖本地分支 + 远端跟踪分支),并确保截止时间包含结束日当天:
65
116
 
66
117
  ```bash
67
118
  # 关键点:
68
119
  # - 用 --all 覆盖所有本地 refs(包含 remotes/origin/*)
69
- # - --until 用 “结束日 23:59:59 避免漏掉结束日当天提交
120
+ # - --until 用 "结束日 23:59:59" 避免漏掉结束日当天提交
70
121
  # - --author 建议用 name + email 联合匹配,避免不同身份写法漏掉本人提交
122
+ # - 必须使用中国时区 (UTC+8)
71
123
 
72
124
  AUTHOR_PATTERN="(your-name|your@email.com)" # 或仅用你的 name/email
73
- git log --all \
125
+ TZ='Asia/Shanghai' git log --all \
74
126
  --author="$AUTHOR_PATTERN" \
75
127
  --since="$START_DATE 00:00:00" \
76
128
  --until="$END_DATE 23:59:59" \
@@ -78,6 +130,26 @@ git log --all \
78
130
  --date=short
79
131
  ```
80
132
 
133
+ ### 日期计算(中国时区)
134
+
135
+ 计算"本周"、"上周"等日期时,必须基于中国时区 (UTC+8):
136
+
137
+ ```bash
138
+ # 获取中国时区的当前日期
139
+ TODAY=$(TZ='Asia/Shanghai' date +%Y-%m-%d)
140
+
141
+ # 获取中国时区的星期几(1=周一, 7=周日)
142
+ DAY_OF_WEEK=$(TZ='Asia/Shanghai' date +%u)
143
+
144
+ # 计算本周一(中国时区)
145
+ THIS_MONDAY=$(TZ='Asia/Shanghai' date -v-$((DAY_OF_WEEK-1))d +%Y-%m-%d)
146
+
147
+ # 计算本周日(中国时区)
148
+ THIS_SUNDAY=$(TZ='Asia/Shanghai' date -v+$((7-DAY_OF_WEEK))d +%Y-%m-%d)
149
+ ```
150
+
151
+ **注意**:所有显示给用户的日期都必须是中国时区的日期。
152
+
81
153
  如果 `git branch -a` 看不到目标远端分支(说明本地没有对应的远端跟踪引用),需要先 `git fetch --all --prune`(在用户同意且网络可用时执行),否则无法读取到“本地不存在的分支”的提交。
82
154
 
83
155
  ## 输出格式
@@ -118,14 +190,21 @@ git log --all \
118
190
  # 周报 (2026-01-06 ~ 2026-01-12)
119
191
 
120
192
  project-frontend
121
- - 构建工具升级改造
122
- - 核心功能开发流程跟进
123
- - 方案合理性优化
124
- - 脚本国际化优化
193
+ - [新功能] 用户登录系统开发,支持多种登录方式
194
+ - 接口对接和联调
195
+ - 表单验证优化
196
+ - 记住登录状态功能
197
+ - [修复] 认证流程问题排查修复
198
+ - 定位 token 过期问题
199
+ - 优化重试机制
200
+ - [优化] 构建工具升级
201
+ - [文档] 国际化文档更新
125
202
 
126
203
  project-backend
127
- - 自定义类型化消息渲染
128
- - 断线重连流程梳理
204
+ - [新功能] 自定义类型化消息渲染,支持多种格式
205
+ - 富文本渲染
206
+ - 图片消息支持
207
+ - [优化] 断线重连流程
129
208
 
130
209
  其他
131
210
  - 新版国际化方案讨论
@@ -162,6 +241,41 @@ project-backend
162
241
  - **按项目分组**:相同项目的工作归类
163
242
  - **层级清晰**:用缩进表示从属关系
164
243
 
244
+ ### 类型标签
245
+
246
+ 根据提交类型自动添加标签:
247
+
248
+ | 类型 | 标签 | 说明 |
249
+ |------|------|------|
250
+ | feat | [新功能] | 新增功能 |
251
+ | fix | [修复] | 问题修复 |
252
+ | refactor | [优化] | 代码重构 |
253
+ | perf | [性能] | 性能优化 |
254
+ | docs | [文档] | 文档更新 |
255
+ | test | [测试] | 测试相关 |
256
+ | build | [构建] | 构建相关 |
257
+
258
+ ### 重点与难点体现
259
+
260
+ 自动识别工作的重点和难点,通过内容详略体现(而非显式标记):
261
+
262
+ - **重点工作**(feat/perf 类型 + 多次迭代):摘要更详细,保留更多子条目
263
+ - **难点工作**(fix 类型 + 多次尝试修复):保留完整的排查过程细节
264
+ - **普通工作**:简洁摘要,精简细节
265
+
266
+ **示例**:
267
+ ```markdown
268
+ project-frontend
269
+ - [新功能] 用户登录系统开发,支持多种登录方式
270
+ - 接口对接和联调
271
+ - 表单验证优化
272
+ - 记住登录状态功能
273
+ - [修复] 认证流程问题排查修复
274
+ - 定位 token 过期问题
275
+ - 优化重试机制
276
+ - [优化] 构建工具升级
277
+ ```
278
+
165
279
  ### 过滤规则
166
280
 
167
281
  以下提交不会单独列出:
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@adonis0123/weekly-report",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Claude Code Skill - 自动读取 Git 提交记录生成周报,支持多仓库汇总和智能过滤",
5
5
  "scripts": {
6
6
  "postinstall": "node install-skill.js",
7
7
  "preuninstall": "node uninstall-skill.js",
8
- "test": "node install-skill.js && echo 'Installation test completed.'"
8
+ "test": "python3 -m pytest tests/ -v --tb=short",
9
+ "test:install": "node install-skill.js && echo 'Installation test completed.'"
9
10
  },
10
11
  "files": [
11
12
  "SKILL.md",
@@ -10,6 +10,22 @@ from pathlib import Path
10
10
  from typing import Any, Dict, List, Optional
11
11
 
12
12
 
13
+ # 提交类型配置
14
+ COMMIT_TYPE_CONFIG = {
15
+ "feat": {"label": "[新功能]", "priority": 1, "is_highlight": True, "is_challenge": False},
16
+ "fix": {"label": "[修复]", "priority": 2, "is_highlight": False, "is_challenge": True},
17
+ "refactor": {"label": "[优化]", "priority": 3, "is_highlight": False, "is_challenge": False},
18
+ "perf": {"label": "[性能]", "priority": 3, "is_highlight": True, "is_challenge": False},
19
+ "style": {"label": "[样式]", "priority": 6, "is_highlight": False, "is_challenge": False},
20
+ "docs": {"label": "[文档]", "priority": 5, "is_highlight": False, "is_challenge": False},
21
+ "test": {"label": "[测试]", "priority": 4, "is_highlight": False, "is_challenge": False},
22
+ "chore": {"label": "[杂项]", "priority": 6, "is_highlight": False, "is_challenge": False},
23
+ "build": {"label": "[构建]", "priority": 4, "is_highlight": False, "is_challenge": False},
24
+ "ci": {"label": "[CI]", "priority": 5, "is_highlight": False, "is_challenge": False},
25
+ "other": {"label": "", "priority": 7, "is_highlight": False, "is_challenge": False},
26
+ }
27
+
28
+
13
29
  # 琐碎提交的关键词
14
30
  TRIVIAL_PATTERNS = [
15
31
  r"^fix\s*typo",
@@ -155,6 +171,10 @@ def get_commits(
155
171
  "date": parts[3],
156
172
  "type": parsed["type"],
157
173
  "is_trivial": parsed["is_trivial"],
174
+ "is_highlight": parsed["is_highlight"],
175
+ "is_challenge": parsed["is_challenge"],
176
+ "label": parsed["label"],
177
+ "priority": parsed["priority"],
158
178
  "project": get_repo_name(repo_path),
159
179
  })
160
180
 
@@ -192,13 +212,25 @@ def parse_commit_message(message: str) -> Dict[str, Any]:
192
212
  message: 提交信息
193
213
 
194
214
  Returns:
195
- 解析后的提交信息字典
215
+ 解析后的提交信息字典,包含:
216
+ - type: 提交类型
217
+ - scope: 作用域
218
+ - description: 描述
219
+ - is_trivial: 是否为琐碎提交
220
+ - is_highlight: 是否为重点(feat/perf 类型)
221
+ - is_challenge: 是否为难点(fix 类型)
222
+ - label: 类型标签(如 [新功能])
223
+ - priority: 优先级(用于排序)
196
224
  """
197
225
  result = {
198
226
  "type": "other",
199
227
  "scope": None,
200
228
  "description": message,
201
229
  "is_trivial": False,
230
+ "is_highlight": False,
231
+ "is_challenge": False,
232
+ "label": "",
233
+ "priority": 7,
202
234
  }
203
235
 
204
236
  # 检查是否为琐碎提交
@@ -213,11 +245,20 @@ def parse_commit_message(message: str) -> Dict[str, Any]:
213
245
  match = re.match(conventional_pattern, message)
214
246
 
215
247
  if match:
216
- result["type"] = match.group(1).lower()
248
+ commit_type = match.group(1).lower()
249
+ result["type"] = commit_type
217
250
  result["scope"] = match.group(2)
218
251
  result["description"] = match.group(3)
219
252
  else:
220
253
  result["description"] = message
254
+ commit_type = "other"
255
+
256
+ # 从配置中获取类型属性
257
+ type_config = COMMIT_TYPE_CONFIG.get(commit_type, COMMIT_TYPE_CONFIG["other"])
258
+ result["is_highlight"] = type_config["is_highlight"]
259
+ result["is_challenge"] = type_config["is_challenge"]
260
+ result["label"] = type_config["label"]
261
+ result["priority"] = type_config["priority"]
221
262
 
222
263
  return result
223
264
 
@@ -6,7 +6,7 @@
6
6
  import re
7
7
  from typing import Any, Dict, List, Optional
8
8
 
9
- from src.git_analyzer import group_commits_by_project
9
+ from src.git_analyzer import group_commits_by_project, COMMIT_TYPE_CONFIG
10
10
 
11
11
 
12
12
  def generate_report(
@@ -75,8 +75,9 @@ def merge_related_commits(
75
75
  """合并相关提交
76
76
 
77
77
  合并规则:
78
- - 同一功能的多次迭代合并为一条
79
- - 问题排查和解决归为一条
78
+ 1. 先按 commit 类型分组(feat/fix/docs 分开)
79
+ 2. 再按关键词合并相似提交
80
+ 3. 按类型优先级排序输出
80
81
 
81
82
  Args:
82
83
  commits: 提交记录列表
@@ -89,48 +90,61 @@ def merge_related_commits(
89
90
  if len(commits) <= 1:
90
91
  single = commits[0].copy()
91
92
  single.setdefault("details", [])
93
+ single.setdefault("commit_count", 1)
92
94
  return [single]
93
95
 
94
- # 按关键词分组
95
- groups: Dict[str, List[Dict[str, Any]]] = {}
96
-
96
+ # 第一步:按类型分组
97
+ type_groups: Dict[str, List[Dict[str, Any]]] = {}
97
98
  for commit in commits:
98
- # 提取关键词
99
- keywords = extract_keywords(commit["message"])
100
- key = frozenset(keywords) if keywords else commit["message"]
101
-
102
- # 转换为字符串 key
103
- str_key = str(sorted(keywords)) if keywords else commit["message"]
104
-
105
- if str_key not in groups:
106
- groups[str_key] = []
107
- groups[str_key].append(commit)
99
+ commit_type = commit.get("type", "other")
100
+ if commit_type not in type_groups:
101
+ type_groups[commit_type] = []
102
+ type_groups[commit_type].append(commit)
108
103
 
109
- # 合并同组提交(保留主条目 + 子条目细节,避免信息丢失)
104
+ # 第二步:在每个类型组内按关键词合并
110
105
  merged: List[Dict[str, Any]] = []
111
- for group_commits in groups.values():
112
- main_commit = group_commits[0].copy()
113
- for c in group_commits:
114
- if c.get("type") == "feat":
115
- main_commit = c.copy()
116
- break
117
-
118
- details = []
119
- for c in group_commits:
120
- details.append(clean_commit_message(c.get("message", "")))
121
-
122
- # 去重并保持顺序
123
- seen = set()
124
- uniq_details = []
125
- for d in details:
126
- key = d.strip()
127
- if not key or key in seen:
128
- continue
129
- seen.add(key)
130
- uniq_details.append(d.strip())
131
106
 
132
- main_commit["details"] = uniq_details if len(uniq_details) > 1 else []
133
- merged.append(main_commit)
107
+ for commit_type, type_commits in type_groups.items():
108
+ # 按关键词分组
109
+ keyword_groups: Dict[str, List[Dict[str, Any]]] = {}
110
+
111
+ for commit in type_commits:
112
+ keywords = extract_keywords(commit["message"])
113
+ str_key = str(sorted(keywords)) if keywords else commit["message"]
114
+
115
+ if str_key not in keyword_groups:
116
+ keyword_groups[str_key] = []
117
+ keyword_groups[str_key].append(commit)
118
+
119
+ # 合并同组提交
120
+ for group_commits in keyword_groups.values():
121
+ main_commit = group_commits[0].copy()
122
+ # 优先选择 feat 类型作为主条目
123
+ for c in group_commits:
124
+ if c.get("type") == "feat":
125
+ main_commit = c.copy()
126
+ break
127
+
128
+ details = []
129
+ for c in group_commits:
130
+ details.append(clean_commit_message(c.get("message", "")))
131
+
132
+ # 去重并保持顺序
133
+ seen = set()
134
+ uniq_details = []
135
+ for d in details:
136
+ key = d.strip()
137
+ if not key or key in seen:
138
+ continue
139
+ seen.add(key)
140
+ uniq_details.append(d.strip())
141
+
142
+ main_commit["details"] = uniq_details if len(uniq_details) > 1 else []
143
+ main_commit["commit_count"] = len(group_commits)
144
+ merged.append(main_commit)
145
+
146
+ # 第三步:按优先级排序(优先级数字越小越靠前)
147
+ merged.sort(key=lambda x: (x.get("priority", 7), x.get("message", "")))
134
148
 
135
149
  return merged
136
150
 
@@ -165,12 +179,51 @@ def clean_commit_message(message: str) -> str:
165
179
  return re.sub(r"^(\w+)(\([^)]+\))?\s*:\s*", "", message).strip()
166
180
 
167
181
 
182
+ def analyze_work_significance(commit: Dict[str, Any]) -> Dict[str, bool]:
183
+ """分析工作的重点和难点
184
+
185
+ 判断规则:
186
+ - 重点:feat 类型 + 多次迭代(>=2次提交)或显式标记 is_highlight
187
+ - 难点:fix 类型 + 多次尝试(>=2次提交)或显式标记 is_challenge
188
+
189
+ Args:
190
+ commit: 提交记录(合并后的,含 commit_count)
191
+
192
+ Returns:
193
+ 包含 is_highlight 和 is_challenge 的字典
194
+ """
195
+ commit_count = commit.get("commit_count", 1)
196
+ commit_type = commit.get("type", "other")
197
+
198
+ # 判断是否为重点
199
+ is_highlight = commit.get("is_highlight", False)
200
+ if commit_type == "feat" and commit_count >= 2:
201
+ is_highlight = True
202
+ if commit_type == "perf":
203
+ is_highlight = True
204
+
205
+ # 判断是否为难点
206
+ is_challenge = commit.get("is_challenge", False)
207
+ if commit_type == "fix" and commit_count >= 2:
208
+ is_challenge = True
209
+
210
+ return {
211
+ "is_highlight": is_highlight,
212
+ "is_challenge": is_challenge,
213
+ }
214
+
215
+
168
216
  def format_project_section(
169
217
  project: str,
170
218
  commits: List[Dict[str, Any]],
171
219
  ) -> str:
172
220
  """格式化项目部分
173
221
 
222
+ 重点/难点通过以下方式体现(而非显式标记):
223
+ - 重点工作:摘要字数更长(max_length=40),保留更多细节
224
+ - 难点工作:保留更多子条目细节
225
+ - 普通工作:简洁摘要(max_length=25)
226
+
174
227
  Args:
175
228
  project: 项目名称
176
229
  commits: 提交记录列表
@@ -181,11 +234,39 @@ def format_project_section(
181
234
  lines = [project]
182
235
 
183
236
  for commit in commits:
184
- summary = summarize_commit(commit["message"])
237
+ # 获取类型标签
238
+ label = commit.get("label", "")
239
+
240
+ # 分析重点/难点
241
+ significance = analyze_work_significance(commit)
242
+
243
+ # 根据重要程度调整摘要长度
244
+ if significance["is_highlight"]:
245
+ # 重点工作:更长的摘要
246
+ max_len = 40
247
+ elif significance["is_challenge"]:
248
+ # 难点工作:适中的摘要
249
+ max_len = 35
250
+ else:
251
+ # 普通工作:简洁摘要
252
+ max_len = 25
253
+
254
+ # 生成摘要(含类型标签)
255
+ summary = summarize_commit(commit["message"], max_length=max_len, label=label)
256
+
185
257
  lines.append(f" - {summary}")
258
+
259
+ # 添加子条目细节
260
+ # 重点/难点保留更多细节,普通工作限制细节数量
186
261
  details = commit.get("details") or []
187
- for detail in details:
188
- lines.append(f" - {detail}")
262
+ if significance["is_highlight"] or significance["is_challenge"]:
263
+ # 重点/难点:保留所有细节(最多5条)
264
+ for detail in details[:5]:
265
+ lines.append(f" - {detail}")
266
+ else:
267
+ # 普通工作:最多保留2条细节
268
+ for detail in details[:2]:
269
+ lines.append(f" - {detail}")
189
270
 
190
271
  return "\n".join(lines)
191
272
 
@@ -207,21 +288,60 @@ def format_other_section(supplements: List[str]) -> str:
207
288
  return "\n".join(lines)
208
289
 
209
290
 
210
- def summarize_commit(message: str, max_length: int = 20) -> str:
211
- """生成提交摘要
291
+ def summarize_commit(
292
+ message: str,
293
+ max_length: int = 30,
294
+ label: str = "",
295
+ ) -> str:
296
+ """生成提交摘要(智能截断)
297
+
298
+ 在自然断点处截断,避免割裂语义:
299
+ - 优先在标点符号处截断
300
+ - 其次在空格处截断
301
+ - 最后才硬截断
212
302
 
213
303
  Args:
214
304
  message: 提交信息
215
- max_length: 最大长度
305
+ max_length: 最大长度(不含标签)
306
+ label: 类型标签(如 [新功能])
216
307
 
217
308
  Returns:
218
- 摘要文本
309
+ 带标签的摘要文本
219
310
  """
220
311
  cleaned = clean_commit_message(message)
221
312
 
222
- # 截断过长的文本
313
+ # 截断过长的文本(智能截断)
223
314
  if len(cleaned) > max_length:
224
- cleaned = cleaned[:max_length - 3] + "..."
315
+ # 在最大长度范围内寻找自然断点
316
+ truncated = cleaned[:max_length]
317
+
318
+ # 1. 优先在中文标点处截断
319
+ for punct in ["。", ",", "、", ";", ":", "!", "?"]:
320
+ idx = truncated.rfind(punct)
321
+ if idx > max_length // 2: # 至少保留一半内容
322
+ truncated = truncated[:idx + 1]
323
+ break
324
+ else:
325
+ # 2. 尝试在英文标点处截断
326
+ for punct in [".", ",", ";", ":", "!", "?"]:
327
+ idx = truncated.rfind(punct)
328
+ if idx > max_length // 2:
329
+ truncated = truncated[:idx + 1]
330
+ break
331
+ else:
332
+ # 3. 尝试在空格处截断
333
+ idx = truncated.rfind(" ")
334
+ if idx > max_length // 2:
335
+ truncated = truncated[:idx]
336
+ else:
337
+ # 4. 硬截断并添加省略号
338
+ truncated = truncated[:max_length - 3] + "..."
339
+
340
+ cleaned = truncated
341
+
342
+ # 添加类型标签
343
+ if label:
344
+ return f"{label} {cleaned.strip()}"
225
345
 
226
346
  return cleaned.strip()
227
347