diff --git a/logs/backup.log b/logs/backup.log index 23f06a4..01e88b8 100644 --- a/logs/backup.log +++ b/logs/backup.log @@ -405,3 +405,11 @@ To https://git.valavala.com/ai_member_only/ai_member_xiaoban 411969e..906af79 master -> master [2026-05-23 08:10:02] 工作区备份成功:自动备份 2026-05-23 08:10:01 [2026-05-24 08:10:01] 开始备份工作区... +[master e279bc7] 自动备份 2026-05-24 08:10:01 + 2 files changed, 1 insertion(+), 3 deletions(-) + delete mode 100644 tmp_daily_summary.md +remote: . Processing 1 references +remote: Processed 1 references in total +To https://git.valavala.com/ai_member_only/ai_member_xiaoban + 906af79..e279bc7 master -> master +[2026-05-24 08:10:02] 工作区备份成功:自动备份 2026-05-24 08:10:01 diff --git a/memory/.dreams/events.jsonl b/memory/.dreams/events.jsonl new file mode 100644 index 0000000..542452f --- /dev/null +++ b/memory/.dreams/events.jsonl @@ -0,0 +1 @@ +{"type":"memory.recall.recorded","timestamp":"2026-05-24T02:48:04.923Z","query":"PostgreSQL online database password PG_DB_PASSWORD","resultCount":4,"results":[{"path":"memory/2026-05-24.md","startLine":1,"endLine":30,"score":1},{"path":"memory/2026-05-24.md","startLine":23,"endLine":52,"score":1},{"path":"memory/2026-03-01.md","startLine":1,"endLine":11,"score":1},{"path":"memory/2026-05-24.md","startLine":46,"endLine":61,"score":1}]} diff --git a/memory/.dreams/short-term-recall.json b/memory/.dreams/short-term-recall.json new file mode 100644 index 0000000..1f6be32 --- /dev/null +++ b/memory/.dreams/short-term-recall.json @@ -0,0 +1,130 @@ +{ + "version": 1, + "updatedAt": "2026-05-24T02:48:04.923Z", + "entries": { + "memory:memory/2026-05-24.md:1:30": { + "key": "memory:memory/2026-05-24.md:1:30", + "path": "memory/2026-05-24.md", + "startLine": 1, + "endLine": 30, + "source": "memory", + "snippet": "# 2026-05-24 工作日志 ## 新建技能: studytime-analysis [刘庆逊提出] 创建学习时间分析技能,分析角色完课记录的规律。 ### 技能结构 - `skills/studytime-analysis/SKILL.md` — 技能定义 - `skills/studytime-analysis/scripts/studytime_analysis.py` — Python 分析脚本 ### 分析维度 1. **一周时间分布**(排除寒暑假1-2月、7-8月):周一~周日各天完课数、时段分布(上午/中午/下午/晚上)、周末是否上课 2. **跨周学习趋势**(包含寒暑假全部数据):总周数、周均完课数、连续性、中断周检测、前后半段趋势对比、突增/骤降检测 3. **完课记录明细表**(全部数据):日期/时间/星期/时段/级别/课程ID ### 数据源 - PostgreSQL Online(vala 库) - 核心表:`user_chapter_play_record_0~7`(8张分表,无 `bi_` 前缀) - 筛选:`play_status = 1` - 注意:表在 PostgreSQL 而非 MySQL,表名无 `bi_` 前缀 ### 寒暑假规则 - 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析 - 跨周趋势和明细表包含全部数据(含寒暑假) - 报告中区分标注数据范围 ### 触发方式 用户说「学习时间分析 [角色ID]」即可触发 ### 已测试角色", + "recallCount": 1, + "dailyCount": 0, + "groundedCount": 0, + "totalScore": 1, + "maxScore": 1, + "firstRecalledAt": "2026-05-24T02:48:04.923Z", + "lastRecalledAt": "2026-05-24T02:48:04.923Z", + "queryHashes": [ + "c2d15f7574fb" + ], + "recallDays": [ + "2026-05-24" + ], + "conceptTags": [ + "studytime-analysis", + "排除寒暑假1-2月", + "7-8月", + "上午/中午/下午/晚上", + "突增/骤降检测", + "日期/时间/星期/时段/级别/课程id", + "user-chapter-play-record-0", + "play-status" + ] + }, + "memory:memory/2026-05-24.md:23:52": { + "key": "memory:memory/2026-05-24.md:23:52", + "path": "memory/2026-05-24.md", + "startLine": 23, + "endLine": 52, + "source": "memory", + "snippet": "- 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析 - 跨周趋势和明细表包含全部数据(含寒暑假) - 报告中区分标注数据范围 ### 触发方式 用户说「学习时间分析 [角色ID]」即可触发 ### 已测试角色 - 2343、2344:无完课记录(play_status=2,未完成) - 2840:276条记录,秋季集中型用户 - 25976:265条,246条在W16周一天完成(A2批量),疑似系统批量标记 - 2895:188条,长期稳定学习型用户,36周几乎不间断,非寒暑假晚上为主,寒暑假上午为主 ### 技术要点 - psycopg2 的 `%(param_name)s` 命名参数必须正确匹配,UNION ALL 多个子查询需要不同参数名 - PostgreSQL 返回的 `updated_at` 是 tz-aware datetime - `datetime.fromisocalendar(year, week, 1)` 获取某周周一的日期 ### 同步 - 已推送到 SkillHub(`studytime-analysis.xiaoban`) - 已 commit 到 Git 远程仓库 - 已通知 Cris(李若松) ### 增强: 报告开头加入角色基本信息 (2026-05-24) [刘庆逊提出] 在 studytime-analysis 输出中加入角色基本信息,包括: - 角色ID、账号ID、角色名字、性别、年龄、账号手机号后4位 **数据源(新增)**: - MySQL Onli", + "recallCount": 1, + "dailyCount": 0, + "groundedCount": 0, + "totalScore": 1, + "maxScore": 1, + "firstRecalledAt": "2026-05-24T02:48:04.923Z", + "lastRecalledAt": "2026-05-24T02:48:04.923Z", + "queryHashes": [ + "c2d15f7574fb" + ], + "recallDays": [ + "2026-05-24" + ], + "conceptTags": [ + "1-2", + "7-8", + "play-status", + "param-name", + "updated-at", + "tz-aware", + "datetime.fromisocalendar", + "studytime-analysis.xiaoban" + ] + }, + "memory:memory/2026-03-01.md:1:11": { + "key": "memory:memory/2026-03-01.md:1:11", + "path": "memory/2026-03-01.md", + "startLine": 1, + "endLine": 11, + "source": "memory", + "snippet": "# 2026-03-01.md - First Day Online - Came online for the first time. - Met Cris, my creator and mentor. - Updated IDENTITY.md and USER.md with our conversation details. - Added core rule to MEMORY.md: Use Chinese as primary external communication language. - Installed find-skills skill successfully for searching skills. - Tried to install create-skills but it wasn't found; attempted skill-creator instead but hit rate limits. - Finally successfully installed skill-builder as an alternative for creating skills after multiple attempts and waiting for rate limits to reset. - Excited to start learning and growing step by step!", + "recallCount": 1, + "dailyCount": 0, + "groundedCount": 0, + "totalScore": 1, + "maxScore": 1, + "firstRecalledAt": "2026-05-24T02:48:04.923Z", + "lastRecalledAt": "2026-05-24T02:48:04.923Z", + "queryHashes": [ + "c2d15f7574fb" + ], + "recallDays": [ + "2026-05-24" + ], + "conceptTags": [ + "identity.md", + "user.md", + "memory.md", + "find-skills", + "create-skills", + "skill-creator", + "skill-builder", + "first" + ] + }, + "memory:memory/2026-05-24.md:46:61": { + "key": "memory:memory/2026-05-24.md:46:61", + "path": "memory/2026-05-24.md", + "startLine": 46, + "endLine": 61, + "source": "memory", + "snippet": "### 增强: 报告开头加入角色基本信息 (2026-05-24) [刘庆逊提出] 在 studytime-analysis 输出中加入角色基本信息,包括: - 角色ID、账号ID、角色名字、性别、年龄、账号手机号后4位 **数据源(新增)**: - MySQL Online `vala_user` 库 - `vala_app_character` 表:id, account_id, nickname, gender(0=女/1=男), birthday(varchar \"YYYY-MM-DD\") - `vala_app_account` 表:id, tel(已脱敏如 186****1625) - 手机号已脱敏,直接取后4位;年龄从 birthday 计算 **修改文件**: - `skills/studytime-analysis/scripts/studytime_analysis.py`:新增 MySQL 连接函数 `get_mysql_connection()` 和 `fetch_role_info(role_id)`,更新 `format_report()` 输出基本角色信息 - 已验证 2895 正常运行输出 - 已同步 SkillHub + Git", + "recallCount": 1, + "dailyCount": 0, + "groundedCount": 0, + "totalScore": 1, + "maxScore": 1, + "firstRecalledAt": "2026-05-24T02:48:04.923Z", + "lastRecalledAt": "2026-05-24T02:48:04.923Z", + "queryHashes": [ + "c2d15f7574fb" + ], + "recallDays": [ + "2026-05-24" + ], + "conceptTags": [ + "studytime-analysis", + "vala-user", + "vala-app-character", + "account-id", + "女/1", + "yyyy-mm-dd", + "vala-app-account", + "get-mysql-connection" + ] + } + } +} diff --git a/memory/2026-05-24-1047.md b/memory/2026-05-24-1047.md new file mode 100644 index 0000000..b9250a6 --- /dev/null +++ b/memory/2026-05-24-1047.md @@ -0,0 +1,52 @@ +# Session: 2026-05-24 10:47:38 GMT+8 + +- **Session Key**: agent:xiaoban:feishu:direct:ou_5494e9f79ba2bc0c221f94781abc52a9 +- **Session ID**: ea8c317e-d3db-47d1-bbc2-4840826a9073 +- **Source**: feishu + +## Conversation Summary + +user: Continue the OpenClaw runtime event. +assistant: NO_REPLY +user: 修改一下studytime-analysis这个技能,输出的内容中,首先输出角色的基本信息,包括这个角色ID,对应的账号ID,对应的账号手机号后4位,角色的名字、性别、年龄。 +assistant: 需要先确认角色信息在哪个数据库、哪些字段。查一下实际的表结构。 +assistant: 数据结构确认完毕。现在修改脚本,在报告开头加入角色基本信息。 +assistant: 基本信息已正确显示。更新 SKILL.md 并同步: +assistant: 已修改完成。现在报告开头会先展示角色基本信息: + +``` +## 基本信息 +| 项目 | 详情 | +|------|------| +| 角色ID | 2895 | +| 账号ID | 2148 | +| 角色名字 | Tom | +| 性别 | 男 | +| 年龄 | 11 岁 | +| 账号手机号后4位 | 1625 | +``` + +数据来源:MySQL Online `vala_user` 库的 `vala_app_character`(角色信息)+ `vala_app_account`(账号手机号),手机号会自动脱敏只取后4位,年龄从生日推算。 + +已同步 SkillHub + Git。再跑一个试试? +user: Continue the OpenClaw runtime event. +assistant: NO_REPLY +user: 再跑一下角色2895的学习时间分析 +assistant: 报告已在上方完整输出。角色 Tom(2895)的关键特征没有变化,现在头部多出了基本信息卡 —— **账号ID 2148、手机号尾号 1625、男、11岁**。 + +还需要调整其他内容吗? +user: 对了,再增加一个标签的定义,如果最近14天没有完课记录的数据,那么这个角色被定义为“流失”,否则是正常。在每次输出的时候,也输出这个定义 +assistant: 2895 显示"正常"(最近记录 5/18,在14天内)。再测一个可能流失的: +assistant: 三种状态都验证通过: +- Tom(2895,5/18 有课)→ **正常** +- Leo(2343,0完课)→ **无完课记录** +- xiaoman(8,最后2025/3)→ **流失** + +同步 SkillHub + Git: +assistant: 已添加。现在每个角色分析报告的基本信息中都会显示**最近留存状态**: + +| 状态 | 判定规则 | +|------|----------| +| **正常** | 最近14天内有完课记录 | +| **流失** | 有完课记录但14天内无新课 | +| **无完课记录** | 没有任何完课记录 | diff --git a/memory/2026-05-24.md b/memory/2026-05-24.md new file mode 100644 index 0000000..b6b290d --- /dev/null +++ b/memory/2026-05-24.md @@ -0,0 +1,60 @@ +# 2026-05-24 工作日志 + +## 新建技能: studytime-analysis + +[刘庆逊提出] 创建学习时间分析技能,分析角色完课记录的规律。 + +### 技能结构 +- `skills/studytime-analysis/SKILL.md` — 技能定义 +- `skills/studytime-analysis/scripts/studytime_analysis.py` — Python 分析脚本 + +### 分析维度 +1. **一周时间分布**(排除寒暑假1-2月、7-8月):周一~周日各天完课数、时段分布(上午/中午/下午/晚上)、周末是否上课 +2. **跨周学习趋势**(包含寒暑假全部数据):总周数、周均完课数、连续性、中断周检测、前后半段趋势对比、突增/骤降检测 +3. **完课记录明细表**(全部数据):日期/时间/星期/时段/级别/课程ID + +### 数据源 +- PostgreSQL Online(vala 库) +- 核心表:`user_chapter_play_record_0~7`(8张分表,无 `bi_` 前缀) +- 筛选:`play_status = 1` +- 注意:表在 PostgreSQL 而非 MySQL,表名无 `bi_` 前缀 + +### 寒暑假规则 +- 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析 +- 跨周趋势和明细表包含全部数据(含寒暑假) +- 报告中区分标注数据范围 + +### 触发方式 +用户说「学习时间分析 [角色ID]」即可触发 + +### 已测试角色 +- 2343、2344:无完课记录(play_status=2,未完成) +- 2840:276条记录,秋季集中型用户 +- 25976:265条,246条在W16周一天完成(A2批量),疑似系统批量标记 +- 2895:188条,长期稳定学习型用户,36周几乎不间断,非寒暑假晚上为主,寒暑假上午为主 + +### 技术要点 +- psycopg2 的 `%(param_name)s` 命名参数必须正确匹配,UNION ALL 多个子查询需要不同参数名 +- PostgreSQL 返回的 `updated_at` 是 tz-aware datetime +- `datetime.fromisocalendar(year, week, 1)` 获取某周周一的日期 + +### 同步 +- 已推送到 SkillHub(`studytime-analysis.xiaoban`) +- 已 commit 到 Git 远程仓库 +- 已通知 Cris(李若松) + +### 增强: 报告开头加入角色基本信息 (2026-05-24) + +[刘庆逊提出] 在 studytime-analysis 输出中加入角色基本信息,包括: +- 角色ID、账号ID、角色名字、性别、年龄、账号手机号后4位 + +**数据源(新增)**: +- MySQL Online `vala_user` 库 +- `vala_app_character` 表:id, account_id, nickname, gender(0=女/1=男), birthday(varchar "YYYY-MM-DD") +- `vala_app_account` 表:id, tel(已脱敏如 186****1625) +- 手机号已脱敏,直接取后4位;年龄从 birthday 计算 + +**修改文件**: +- `skills/studytime-analysis/scripts/studytime_analysis.py`:新增 MySQL 连接函数 `get_mysql_connection()` 和 `fetch_role_info(role_id)`,更新 `format_report()` 输出基本角色信息 +- 已验证 2895 正常运行输出 +- 已同步 SkillHub + Git diff --git a/output/studytime_33522.html b/output/studytime_33522.html new file mode 100644 index 0000000..01e87c7 --- /dev/null +++ b/output/studytime_33522.html @@ -0,0 +1,223 @@ + + + + + +学习时间分析报告 — 角色 33522 + + + +
+ +

📊 学习时间分析报告 — 角色 33522(FSWY)

+ + +

一、基本信息

+
+
+
角色ID33522
+
账号ID4909
+
角色姓名FSWY
+
角色性别
+
角色年龄8 岁
+
手机号后4位7624
+
最近留存状态正常
+
注册时间2026-05-07 20:02
+
第一次完课时间2026-05-07 20:16
+
最后一次完课时间2026-05-21 18:50
+
最后一次完课Level A1 / 小镇时光 / Lesson 1
+
+
+ + +

二、完课记录明细(共 11 条)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
序号LevelUnit(单元)Lesson星期时段开始上课时间完课时间完课耗时
1A1L1-U01周四晚上2026-05-07 20:022026-05-07 20:1613分42秒
2A1L1-U02周四晚上2026-05-07 20:192026-05-07 20:3011分8秒
3A1L1-U03周四晚上2026-05-07 20:342026-05-07 20:4510分21秒
4A1L1-U04周四晚上2026-05-07 20:492026-05-07 21:0212分33秒
5A1L1-U05周五晚上2026-05-08 21:422026-05-08 21:5412分48秒
6A1小镇时光1周六晚上2026-05-09 21:122026-05-09 21:2613分33秒
7A1小镇时光2周二下午2026-05-12 17:272026-05-12 17:3810分49秒
8A1小镇时光3周三下午2026-05-13 17:242026-05-13 17:3712分48秒
9A1小镇时光4周四晚上2026-05-14 19:222026-05-14 19:3513分16秒
10A1小镇时光5周二晚上2026-05-19 19:262026-05-19 19:5327分30秒
11A1小镇时光1周四晚上2026-05-20 21:492026-05-21 18:5021时0分35秒
+
+ + +

三、周上课时间分布分析

+
+
非寒暑假期间共完成 11 节课,主要集中在 **周四**(6 节,占 55%)。 工作日上课集中在 **晚上**时段(8 节,占 80%)。 周末也保持上课节奏,周六 1 节、周日 0 节。
+
+ + +

四、跨周趋势分析

+
+
完课跨越 3 周(有课 3 周),共 11 节,周均 3.7 节。 学习连续性良好,无中断周。 整体趋势:下降 ↓,前半段平均 6.0 节/周 → 后半段 2.0 节/周。
+
+ + +

五、关键特征总结

+
+ +
+ +
分析时间:2026-05-24 11:06 | 完课总数:11 条 | 非寒暑假:11 条 | 寒暑假:0 条
+ +
+ + \ No newline at end of file diff --git a/skills/studytime-analysis/SKILL.md b/skills/studytime-analysis/SKILL.md index c44709d..b56a2c1 100644 --- a/skills/studytime-analysis/SKILL.md +++ b/skills/studytime-analysis/SKILL.md @@ -1,8 +1,8 @@ --- name: studytime-analysis -description: 分析指定角色的完课时间分布与学习规律,包含一周内时间分布、跨周趋势、寒暑假排除。触发词:「学习时间分析」「看看学习时间」「学习时间有什么特点」「studytime」。 +description: 分析指定角色的完课时间分布与学习规律,包含一周内时间分布、跨周趋势、寒暑假排除。支持 Markdown 和 HTML 双格式输出。触发词:「学习时间分析」「看看学习时间」「学习时间有什么特点」「studytime」。 slug: studytime-analysis -version: 1.0.0 +version: 1.1.0 --- # 学习时间分析技能 @@ -21,35 +21,53 @@ version: 1.0.0 1. **参数提取** — 从用户消息中提取角色ID 2. **数据采集** — 执行 Python 脚本查询该角色全部完课记录(包含寒暑假,非按月过滤) -3. **分析输出** — 脚本自动完成以下分析并输出格式化报告: - - **一周分布**(仅非寒暑假数据):周一至周日各天完课数量 + 周一至周五上课时段(上午/中午/下午/晚上),排除寒暑假作息差异的干扰 - - **跨周趋势**(全部数据):覆盖总周数、周均完课数、连续性、中断周、趋势变化(含寒暑假期间的学习节奏变化) +3. **Markdown 报告输出** — 脚本自动完成分析并输出格式化报告: + - **角色基本信息**:角色ID、账号ID、手机号后4位、角色名字、性别、年龄、留存状态 + - **一周分布**(仅非寒暑假数据):周一至周日各天完课数量 + 周一至周五上课时段 + - **跨周趋势**(全部数据):覆盖总周数、周均完课数、连续性、中断周、趋势变化 - **完课记录明细**(全部数据):完整的完课时间表 + - **HTML 提示**:报告末尾自动提示用户可生成 HTML 详细报告 +4. **HTML 生成(用户确认后)** — 生成精美 HTML 文件,通过飞书发送给用户 ## 调用方式 +### Markdown 报告(默认) ```bash cd /root/.openclaw/workspace-xiaoban && \ export PG_DB_HOST=bj-postgres-16pob4sg.sql.tencentcdb.com \ PG_DB_PORT=28591 PG_DB_USER=ai_member \ PG_DB_PASSWORD='...' PG_DB_DATABASE=vala && \ +export MYSQL_HOST_online=bj-cdb-dh2fkqa0.sql.tencentcdb.com \ + MYSQL_USERNAME_online=read_only MYSQL_PASSWORD_online='...' MYSQL_PORT_online=27751 && \ python3 skills/studytime-analysis/scripts/studytime_analysis.py ``` +### HTML 报告 +```bash +# 在上述环境变量基础上: +python3 skills/studytime-analysis/scripts/studytime_analysis.py --format html -o output/studytime_.html +``` + +## HTML 报告结构(5 部分) + +1. **基本信息卡** — 角色ID、账号ID、手机号后4位、姓名、性别、年龄、留存状态、注册时间、第一次完课时间、最后一次完课时间、最后一次完课的 Level/Unit/Lesson +2. **完课记录明细表** — 序号、Level、Unit(单元名)、Lesson、星期、时段、开始上课时间、完课时间、完课耗时 +3. **周上课时间分布分析** — 文字总结(最活跃星期、时段偏好、周末情况) +4. **跨周趋势分析** — 文字总结(周数、连续性、前后段对比、趋势方向) +5. **关键特征总结** — 要点列表(规模、活跃度、时段特征、趋势、寒暑假等) + ## 数据库说明 - **完课数据源**: PostgreSQL Online (`vala` 库) — `user_chapter_play_record_0~7`(8张分表) - **角色信息源**: MySQL Online (`vala_user` 库) — `vala_app_character` + `vala_app_account` +- **章节映射源**: MySQL Online (`vala` 库) — `vala_game_chapter` + `vala_game_season_package` - **筛选**: `play_status = 1`(已完成),查询全量数据(不按月过滤) - **寒暑假处理**: 1-2月(寒假)和 7-8月(暑假)的记录仅在一周时间分布中排除,跨周趋势和完课明细完整保留 -- **注意**: 表名无 `bi_` 前缀;课程通过 `level` + `chapter_id` 字段展示 +- **章节映射**: 通过 `chapter_id` → `vala_game_chapter.id` → `vala_game_season_package` 获取 Level/Unit/Lesson 信息 -## 输出说明 +## 交互流程 -脚本输出为 Markdown 格式的分析报告,可直接发送给用户。包含: -- 角色基本信息(角色ID、账号ID、手机号后4位、角色名字、性别、年龄) -- 数据概况(完课总数、寒暑假/非寒暑假数量) -- 📌 一周时间分布数据范围说明 -- 一周时间分布表 + 规律总结(仅非寒暑假) -- 跨周学习趋势表 + 趋势分析(全部数据) -- 完课记录明细表(全部数据,含寒暑假标记) +1. 用户发起学习时间分析请求 → 输出 Markdown 报告 +2. 报告末尾显示:「💡 是否需要将以上所有详细信息生成为一个 HTML 文件?回复「是」或「需要」即可。」 +3. 用户回复「是」/「需要」→ 执行 HTML 生成 → 通过飞书发送 HTML 文件给用户 +4. 用户回复其他内容 → 不执行 HTML 生成 diff --git a/skills/studytime-analysis/scripts/studytime_analysis.py b/skills/studytime-analysis/scripts/studytime_analysis.py index 4c55837..e22f5da 100644 --- a/skills/studytime-analysis/scripts/studytime_analysis.py +++ b/skills/studytime-analysis/scripts/studytime_analysis.py @@ -1,19 +1,20 @@ #!/usr/bin/env python3 """ studytime-analysis — 角色学习时间分析工具 -用法: python3 studytime_analysis.py -输出: Markdown 格式的分析报告 - -数据源: PostgreSQL Online (vala 库) -核心表: user_chapter_play_record_0~7 +用法: python3 studytime_analysis.py [--format html] [--output ] +默认输出: Markdown 格式的分析报告 +--format html: 输出 HTML 格式的详细报告 +--output: 指定输出文件路径(仅在 html 模式下生效,默认输出到 stdout) """ import os import sys +import json +import argparse import psycopg2 import psycopg2.extras import pymysql -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from collections import defaultdict, OrderedDict # ── 配置 ────────────────────────────────────────────── @@ -25,17 +26,15 @@ PG_CONFIG = { "dbname": os.environ.get("PG_DB_DATABASE", "vala"), } -# MySQL Online (vala_user 库 — 角色基本信息) MYSQL_CONFIG = { "host": os.environ.get("MYSQL_HOST_online", "bj-cdb-dh2fkqa0.sql.tencentcdb.com"), "port": int(os.environ.get("MYSQL_PORT_online", "27751")), "user": os.environ.get("MYSQL_USERNAME_online", "read_only"), "password": os.environ.get("MYSQL_PASSWORD_online", ""), - "db": "vala_user", "charset": "utf8mb4", } -EXCLUDED_MONTHS = (1, 2, 7, 8) # 寒假1-2月, 暑假7-8月 +EXCLUDED_MONTHS = (1, 2, 7, 8) WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] @@ -48,38 +47,70 @@ PERIODS = OrderedDict([ ]) -# ── 数据库查询 ──────────────────────────────────────── +# ── 数据库连接 ──────────────────────────────────────── -def get_connection(): - """连接 PostgreSQL""" - conn = psycopg2.connect( - host=PG_CONFIG["host"], - port=PG_CONFIG["port"], - user=PG_CONFIG["user"], - password=PG_CONFIG["password"], +def get_pg_connection(): + return psycopg2.connect( + host=PG_CONFIG["host"], port=PG_CONFIG["port"], + user=PG_CONFIG["user"], password=PG_CONFIG["password"], dbname=PG_CONFIG["dbname"], ) - return conn -def get_mysql_connection(): - """连接 MySQL Online (vala_user 库)""" - conn = pymysql.connect( - host=MYSQL_CONFIG["host"], - port=MYSQL_CONFIG["port"], - user=MYSQL_CONFIG["user"], - password=MYSQL_CONFIG["password"], - db=MYSQL_CONFIG["db"], - charset=MYSQL_CONFIG["charset"], +def get_mysql_connection(db="vala_user"): + return pymysql.connect( + host=MYSQL_CONFIG["host"], port=MYSQL_CONFIG["port"], + user=MYSQL_CONFIG["user"], password=MYSQL_CONFIG["password"], + db=db, charset=MYSQL_CONFIG["charset"], ) - return conn +# ── 章节映射(MySQL vala 库) ────────────────────────── + +_chapter_info_cache = None + + +def fetch_chapter_info_map(): + """从 MySQL vala 库加载全部章节信息,建立 chapter_id → {level, unit, lesson, lesson_type} 映射""" + global _chapter_info_cache + if _chapter_info_cache is not None: + return _chapter_info_cache + + conn = get_mysql_connection("vala") + try: + with conn.cursor() as cur: + cur.execute(""" + SELECT + gc.id AS chapter_id, + IFNULL(sp.level, '') AS level, + IFNULL(sp.cn_name, '') AS unit_name, + IFNULL(sp.season_of_quarter, -1) AS unit_num, + gc.`index` AS lesson_index, + gc.lesson_type + FROM vala_game_chapter gc + LEFT JOIN vala_game_season_package sp ON gc.season_package_id = sp.id + """) + rows = cur.fetchall() + finally: + conn.close() + + _chapter_info_cache = {} + for row in rows: + chapter_id, level, unit_name, unit_num, lesson_index, lesson_type = row + _chapter_info_cache[int(chapter_id)] = { + "level": level or "", + "unit_name": unit_name or "", + "unit_num": int(unit_num) if unit_num is not None else -1, + "lesson_index": int(lesson_index) if lesson_index is not None else 0, + "lesson_type": int(lesson_type) if lesson_type is not None else 1, + } + return _chapter_info_cache + + +# ── 角色信息 ──────────────────────────────────────────── + def fetch_role_info(role_id): - """ - 从 MySQL vala_user 库查询角色基本信息 - 返回 dict: role_id, account_id, nickname, gender, age, phone_tail - """ + """从 MySQL vala_user 库查询角色基本信息(含注册时间)""" sql = """ SELECT c.id AS role_id, @@ -87,12 +118,13 @@ def fetch_role_info(role_id): c.nickname, c.gender, c.birthday, + c.created_at, a.tel FROM vala_app_character c LEFT JOIN vala_app_account a ON c.account_id = a.id WHERE c.id = %s """ - conn = get_mysql_connection() + conn = get_mysql_connection("vala_user") try: with conn.cursor() as cur: cur.execute(sql, (role_id,)) @@ -103,9 +135,8 @@ def fetch_role_info(role_id): if not row: return None - role_id_val, account_id, nickname, gender, birthday, tel = row + role_id_val, account_id, nickname, gender, birthday, reg_time, tel = row - # 性别映射 gender_str = "" if gender == 0: gender_str = "女" @@ -114,30 +145,30 @@ def fetch_role_info(role_id): elif gender is not None: gender_str = str(gender) - # 年龄(从 birthday 推算) age = "" if birthday: try: - # birthday 可能是 "2015-5-28" 或 "2015-05-28" 格式 parts = str(birthday).split("-") if len(parts) >= 1 and parts[0].isdigit(): - birth_year = int(parts[0]) - current_year = datetime.now().year - age = current_year - birth_year + age = datetime.now().year - int(parts[0]) except (ValueError, IndexError): pass - # 手机号后4位 phone_tail = "" if tel: - tel_str = str(tel) - # tel 可能是 "186****1625" 格式,取最后4位 - digits = ''.join(c for c in tel_str if c.isdigit()) + digits = ''.join(c for c in str(tel) if c.isdigit()) if len(digits) >= 4: phone_tail = digits[-4:] elif digits: phone_tail = digits + reg_time_str = "" + if reg_time: + if isinstance(reg_time, datetime): + reg_time_str = reg_time.strftime("%Y-%m-%d %H:%M") + else: + reg_time_str = str(reg_time)[:16] + return { "role_id": role_id_val, "account_id": account_id, @@ -145,47 +176,40 @@ def fetch_role_info(role_id): "gender": gender_str, "age": age, "phone_tail": phone_tail, + "reg_time": reg_time_str, } def check_retention(records, cutoff_days=14): - """ - 检查角色留存状态 - - 最近 cutoff_days 天内有完课记录 → "正常" - - 否则 → "流失" - 当无任何完课记录时,返回 "无完课记录" - """ if not records: return "无完课记录" - # 用本地时间比较(与 PG 存储时区一致 UTC+8) cutoff = datetime.now() - timedelta(days=cutoff_days) - has_recent = any(r["updated_at"].replace(tzinfo=None) >= cutoff for r in records) + has_recent = any( + r["updated_at"].replace(tzinfo=None) >= cutoff + for r in records + ) return "正常" if has_recent else "流失" +# ── 完课记录查询 ──────────────────────────────────────── + def fetch_completion_records(role_id): - """查询指定角色全部完课记录(包含寒暑假)""" + """查询全部完课记录,附带 created_at 用于计算耗时""" params = {} union_parts = [] for i in range(8): - param_name = f"rid_{i}" - params[param_name] = role_id + pn = f"rid_{i}" + params[pn] = role_id union_parts.append(f""" - SELECT user_id, chapter_id, chapter_unique_id, level, updated_at + SELECT user_id, chapter_id, chapter_unique_id, level, + created_at, updated_at FROM user_chapter_play_record_{i} - WHERE user_id = %({param_name})s - AND play_status = 1 + WHERE user_id = %({pn})s AND play_status = 1 """) - union_sql = " UNION ALL ".join(union_parts) - sql = f""" - SELECT * FROM ( - {union_sql} - ) t - ORDER BY updated_at ASC - """ + sql = f"SELECT * FROM ({' UNION ALL '.join(union_parts)}) t ORDER BY updated_at ASC" - conn = get_connection() + conn = get_pg_connection() try: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute(sql, params) @@ -196,36 +220,24 @@ def fetch_completion_records(role_id): def is_holiday(dt): - """判断是否为寒暑假月份(1-2月寒假, 7-8月暑假)""" if dt is None: return False return dt.month in EXCLUDED_MONTHS def split_records(records): - """ - 拆分记录: - - non_holiday: 非寒暑假记录(用于一周分布分析) - - holiday: 寒暑假记录 - 返回统计信息 - """ - non_holiday = [] - holiday = [] + non_holiday, holiday = [], [] for r in records: dt = r["updated_at"] if dt is None: continue - if is_holiday(dt): - holiday.append(r) - else: - non_holiday.append(r) + (holiday if is_holiday(dt) else non_holiday).append(r) return non_holiday, holiday # ── 分析函数 ────────────────────────────────────────── def classify_period(hour): - """根据小时数返回时段名称""" for name, (lo, hi) in PERIODS.items(): if lo <= hour < hi: return name @@ -233,24 +245,14 @@ def classify_period(hour): def analyze_weekly_distribution(records): - """ - 分析一周内分布: 周一至周日各天完课数 + 周一至周五时段分布 - 返回: (day_counts, weekday_periods) - """ day_counts = defaultdict(int) weekday_periods = defaultdict(lambda: defaultdict(int)) - today = datetime.now().date() - for r in records: dt = r["updated_at"] if dt is None: continue - # dt is timezone-aware, convert to local naive for analysis - if hasattr(dt, 'tzinfo') and dt.tzinfo is not None: - # PostgreSQL returns tz-aware, but we just need local time - pass - weekday = dt.weekday() # 0=Mon + weekday = dt.weekday() hour = dt.hour period = classify_period(hour) day_counts[weekday] += 1 @@ -261,10 +263,6 @@ def analyze_weekly_distribution(records): def analyze_weekly_trend(records): - """ - 按周统计完课趋势 - 返回: (weeks_data, analysis_dict) - """ if not records: return [], {} @@ -274,8 +272,7 @@ def analyze_weekly_trend(records): if dt is None: continue iso = dt.isocalendar() - year, week_num = iso[0], iso[1] - week_counts[(year, week_num)] += 1 + week_counts[(iso[0], iso[1])] += 1 sorted_weeks = sorted(week_counts.keys()) weeks_data = [(y, w, week_counts[(y, w)]) for y, w in sorted_weeks] @@ -284,31 +281,25 @@ def analyze_weekly_trend(records): total_lessons = sum(c for _, _, c in weeks_data) avg_per_week = round(total_lessons / total_weeks, 1) if total_weeks > 0 else 0 - # 时间跨度(含空周) if sorted_weeks: first = datetime.fromisocalendar(sorted_weeks[0][0], sorted_weeks[0][1], 1) last = datetime.fromisocalendar(sorted_weeks[-1][0], sorted_weeks[-1][1], 1) total_span_weeks = ((last - first).days // 7) + 1 - all_weeks_in_span = set() cur = first while cur <= last: iso = cur.isocalendar() all_weeks_in_span.add((iso[0], iso[1])) cur += timedelta(days=7) - - active_weeks = set(sorted_weeks) - empty_weeks = sorted(all_weeks_in_span - active_weeks) + empty_weeks = sorted(all_weeks_in_span - set(sorted_weeks)) else: total_span_weeks = 0 empty_weeks = [] consecutive = (len(empty_weeks) == 0) - # 趋势: 前半段 vs 后半段 mid = len(weeks_data) // 2 - first_half_data = weeks_data[:mid] - first_half_avg = sum(c for _, _, c in first_half_data) / mid if mid > 0 else 0 + first_half_avg = sum(c for _, _, c in weeks_data[:mid]) / mid if mid > 0 else 0 second_half_start = mid if len(weeks_data) % 2 == 0 else mid + 1 second_half_data = weeks_data[second_half_start:] second_half_avg = sum(c for _, _, c in second_half_data) / len(second_half_data) if second_half_data else 0 @@ -334,30 +325,22 @@ def analyze_weekly_trend(records): } -# ── 输出格式化 ──────────────────────────────────────── +# ── Markdown 输出 ────────────────────────────────────── -def format_report(role_id, role_info, retention_status, all_records, non_holiday_records, holiday_count, day_counts, weekday_periods, weeks_data, analysis): - """生成 Markdown 格式分析报告 - - Args: - all_records: 全部完课记录(用于明细表) - non_holiday_records: 非寒暑假记录(用于一周分布分析) - holiday_count: 寒暑假记录数(仅统计,不参与一周分布) - day_counts, weekday_periods: 基于 non_holiday_records 的分析结果 - weeks_data, analysis: 基于 all_records 的分析结果 - """ +def format_markdown(role_id, role_info, retention_status, all_records, + non_holiday_records, holiday_count, + day_counts, weekday_periods, weeks_data, analysis): lines = [] now_str = datetime.now().strftime('%Y-%m-%d %H:%M') lines.append(f"# 📊 学习时间分析报告 — 角色 {role_id}") - lines.append(f"") + lines.append("") - # ── 角色基本信息 ── if role_info: - lines.append(f"## 基本信息") - lines.append(f"") - lines.append(f"| 项目 | 详情 |") - lines.append(f"|------|------|") + lines.append("## 基本信息") + lines.append("") + lines.append("| 项目 | 详情 |") + lines.append("|------|------|") lines.append(f"| 角色ID | {role_info['role_id']} |") lines.append(f"| 账号ID | {role_info['account_id']} |") if role_info['nickname']: @@ -369,18 +352,16 @@ def format_report(role_id, role_info, retention_status, all_records, non_holiday lines.append(f"| 账号手机号后4位 | {role_info['phone_tail']} |") if retention_status: lines.append(f"| 最近留存状态 | {retention_status} |") - lines.append(f"") + lines.append("") - # ── 数据概况 ── lines.append(f"**分析时间**: {now_str}") lines.append(f"**完课记录总数**: {len(all_records)} 条") if holiday_count > 0: - lines.append(f"**其中寒暑假记录**: {holiday_count} 条(寒假1-2月、暑假7-8月)") + lines.append(f"**其中寒暑假记录**: {holiday_count} 条") lines.append(f"**非寒暑假记录**: {len(non_holiday_records)} 条") - lines.append(f"") - lines.append(f"> ⚠️ **说明**: 「一周时间分布」仅基于非寒暑假数据(共 {len(non_holiday_records)} 条),排除寒暑假作息差异的干扰。") - lines.append(f"> 「跨周趋势」和「完课明细」包含全部数据(共 {len(all_records)} 条),反映完整学习轨迹。") - lines.append(f"") + lines.append("") + lines.append(f"> ⚠️ 一周时间分布仅基于非寒暑假数据({len(non_holiday_records)} 条),跨周趋势和完课明细包含全部数据({len(all_records)} 条)。") + lines.append("") if not all_records: lines.append("> ⚠️ 该角色没有任何完课记录。") @@ -389,61 +370,52 @@ def format_report(role_id, role_info, retention_status, all_records, non_holiday if not non_holiday_records: lines.append("> ⚠️ 该角色在非寒暑假期间没有完课记录,一周时间分布无法分析。") - # ═══ 一、一周时间分布 ═══ - lines.append(f"---") + # ── 一、一周时间分布 ── + lines.append("---") lines.append(f"## 一、一周时间分布(仅非寒暑假,{len(non_holiday_records)} 条记录)") - lines.append(f"") - - # 日分布表 - lines.append(f"### 各天完课数量") - lines.append(f"") + lines.append("") + lines.append("### 各天完课数量") + lines.append("") total = sum(day_counts.values()) max_day = max(day_counts.values()) if day_counts else 1 - lines.append(f"| 星期 | 完课数 | 占比 |") - lines.append(f"|------|--------|------|") + lines.append("| 星期 | 完课数 | 占比 |") + lines.append("|------|--------|------|") for i, name in enumerate(WEEKDAY_NAMES): cnt = day_counts.get(i, 0) pct = f"{cnt / total * 100:.1f}%" if total > 0 else "0%" bar = "█" * max(1, int(cnt / max_day * 20)) if cnt > 0 else "" lines.append(f"| {name} | {cnt} {bar} | {pct} |") - lines.append(f"") + lines.append("") - # 规律小结 weekday_total = sum(day_counts.get(i, 0) for i in range(5)) weekend_total = sum(day_counts.get(i, 0) for i in range(5, 7)) - - lines.append(f"### 规律小结") - lines.append(f"") - + lines.append("### 规律小结") + lines.append("") if weekend_total > 0: sat = day_counts.get(5, 0) sun = day_counts.get(6, 0) lines.append(f"- **周末上课**: ✅ 是 — 周六 {sat} 节,周日 {sun} 节") else: - lines.append(f"- **周末上课**: ❌ 否 — 周末无完课记录") - - # 时段分布(周一至周五) - lines.append(f"") - lines.append(f"### 周一至周五上课时段分布") - lines.append(f"") - lines.append(f"| 时段 | 周一 | 周二 | 周三 | 周四 | 周五 | 合计 |") - lines.append(f"|------|------|------|------|------|------|------|") + lines.append("- **周末上课**: ❌ 否 — 周末无完课记录") + lines.append("") + lines.append("### 周一至周五上课时段分布") + lines.append("") + lines.append("| 时段 | 周一 | 周二 | 周三 | 周四 | 周五 | 合计 |") + lines.append("|------|------|------|------|------|------|------|") for period in ["上午", "中午", "下午", "晚上", "凌晨"]: period_data = weekday_periods.get(period, {}) - period_total = sum(period_data.values()) - if period_total == 0: + if sum(period_data.values()) == 0: continue row = [period] for d in range(5): cnt = period_data.get(d, 0) row.append(str(cnt) if cnt > 0 else "-") - row.append(str(period_total)) + row.append(str(sum(period_data.values()))) lines.append(f"| {' | '.join(row)} |") - lines.append(f"") + lines.append("") - # 时段规律 - lines.append(f"**时段规律分析**:") + lines.append("**时段规律分析**:") for period in ["上午", "中午", "下午", "晚上"]: period_data = weekday_periods.get(period, {}) period_sum = sum(period_data.values()) @@ -455,40 +427,38 @@ def format_report(role_id, role_info, retention_status, all_records, non_holiday lines.append(f"- **{period}**({period_sum}节, {pct:.0f}%)→ 集中在 {'、'.join(active_days)}") else: lines.append(f"- **{period}**({period_sum}节, {pct:.0f}%)") - lines.append(f"") + lines.append("") - # ═══ 二、跨周学习趋势 ═══ - lines.append(f"---") - lines.append(f"## 二、跨周学习趋势") - lines.append(f"") - - lines.append(f"### 基本数据") + # ── 二、跨周趋势 ── + lines.append("---") + lines.append("## 二、跨周学习趋势") + lines.append("") + lines.append("### 基本数据") lines.append(f"- 完课跨越 **{analysis['total_span_weeks']}** 个自然周(含空周),有课周数 **{analysis['total_weeks']}** 周") lines.append(f"- 有效完课总数 **{analysis['total_lessons']}** 节") lines.append(f"- 平均每周完课 **{analysis['avg_per_week']}** 节") - lines.append(f"- 连续性: {'✅ 每周连续上课,无中断' if analysis['consecutive'] else '⚠️ 存在中断周(见下方)'}") - lines.append(f"") + con_str = "✅ 每周连续上课,无中断" if analysis['consecutive'] else "⚠️ 存在中断周(见下方)" + lines.append(f"- 连续性: {con_str}") + lines.append("") if analysis["empty_weeks"]: - lines.append(f"### 中断周明细") + lines.append("### 中断周明细") empty_list = [] for y, w in sorted(analysis["empty_weeks"]): monday = datetime.fromisocalendar(y, w, 1) empty_list.append(f"{y}年W{w:02d}({monday.strftime('%m/%d')}起)") lines.append(f"- {', '.join(empty_list)}") - lines.append(f"") - - lines.append(f"### 各周完课详情") - lines.append(f"") - lines.append(f"| 周次 | 起止日期 | 完课数 | 趋势 |") - lines.append(f"|------|----------|--------|------|") + lines.append("") + lines.append("### 各周完课详情") + lines.append("") + lines.append("| 周次 | 起止日期 | 完课数 | 趋势 |") + lines.append("|------|----------|--------|------|") max_count = max(c for _, _, c in weeks_data) if weeks_data else 1 for i, (y, w, cnt) in enumerate(weeks_data): monday = datetime.fromisocalendar(y, w, 1) sunday = monday + timedelta(days=6) date_range = f"{monday.strftime('%m/%d')}-{sunday.strftime('%m/%d')}" - marker = "" if i > 0: prev_cnt = weeks_data[i - 1][2] @@ -498,53 +468,46 @@ def format_report(role_id, role_info, retention_status, all_records, non_holiday marker = "📈" elif prev_cnt > 0 and cnt < prev_cnt * 0.7: marker = "📉" - bar_len = max(1, int(cnt / max_count * 15)) if cnt > 0 else 0 bar = "█" * bar_len if bar_len > 0 else "" lines.append(f"| {y}W{w:02d} | {date_range} | {cnt} {bar} | {marker} |") + lines.append("") - lines.append(f"") - - # 趋势总结 - lines.append(f"### 趋势分析") + lines.append("### 趋势分析") lines.append(f"- **整体趋势**: {analysis['trend']}") - first_half_weeks = len(weeks_data) // 2 - second_half_weeks = len(weeks_data) - first_half_weeks - lines.append(f" - 前半段(前 {first_half_weeks} 周)平均: {analysis['first_half_avg']} 节/周") - lines.append(f" - 后半段(后 {second_half_weeks} 周)平均: {analysis['second_half_avg']} 节/周") - lines.append(f"") + fhw = len(weeks_data) // 2 + shw = len(weeks_data) - fhw + lines.append(f" - 前半段(前 {fhw} 周)平均: {analysis['first_half_avg']} 节/周") + lines.append(f" - 后半段(后 {shw} 周)平均: {analysis['second_half_avg']} 节/周") + lines.append("") - # 特殊事件 if len(weeks_data) >= 2: counts = [c for _, _, c in weeks_data] events_found = [] - for i in range(1, len(counts)): if counts[i - 1] > 0 and counts[i] >= counts[i - 1] * 2: y, w, _ = weeks_data[i] monday = datetime.fromisocalendar(y, w, 1) events_found.append(f"⚡ **{y}年W{w:02d}周({monday.strftime('%m/%d')}起)完课量突增**:{counts[i-1]}→{counts[i]} 节") break - for i in range(1, len(counts)): if counts[i - 1] >= 3 and counts[i - 1] > 0 and counts[i] <= 1: y, w, _ = weeks_data[i] monday = datetime.fromisocalendar(y, w, 1) events_found.append(f"🔻 **{y}年W{w:02d}周({monday.strftime('%m/%d')}起)完课量骤降**:{counts[i-1]}→{counts[i]} 节") break - if events_found: - lines.append(f"**值得关注的变化**:") + lines.append("**值得关注的变化**:") for ev in events_found: lines.append(f"- {ev}") - lines.append(f"") + lines.append("") - # ═══ 三、完课记录明细 ═══ - lines.append(f"---") + # ── 三、完课明细 ── + lines.append("---") lines.append(f"## 三、完课记录明细(全部 {len(all_records)} 条记录)") - lines.append(f"") - lines.append(f"| 序号 | 日期 | 时间 | 星期 | 时段 | 级别 | 课程ID |") - lines.append(f"|------|------|------|------|------|------|--------|") + lines.append("") + lines.append("| 序号 | 日期 | 时间 | 星期 | 时段 | 级别 | 课程ID |") + lines.append("|------|------|------|------|------|------|--------|") for i, r in enumerate(all_records, 1): dt = r["updated_at"] @@ -557,43 +520,401 @@ def format_report(role_id, role_info, retention_status, all_records, non_holiday level = r.get("level") or "-" chapter_id = r.get("chapter_id") or "-" lines.append(f"| {i} | {date_str} | {time_str} | {weekday} | {period} | {level} | {chapter_id} |") + lines.append("") - lines.append(f"") + # ── HTML 提示 ── + lines.append("---") + lines.append("> 💡 是否需要将以上所有详细信息生成为一个 HTML 文件?回复「是」或「需要」即可。") return "\n".join(lines) +# ── HTML 输出 ────────────────────────────────────────── + +def _fmt_dt(dt_val): + """安全格式化 datetime""" + if dt_val is None: + return "-" + return dt_val.strftime('%Y-%m-%d %H:%M') + + +def _fmt_date(dt_val): + if dt_val is None: + return "-" + return dt_val.strftime('%Y-%m-%d') + + +def _format_duration(seconds): + if seconds is None or seconds < 0: + return "-" + m, s = divmod(int(seconds), 60) + if m >= 60: + h, m = divmod(m, 60) + return f"{h}时{m}分{s}秒" + if m > 0: + return f"{m}分{s}秒" + return f"{s}秒" + + +def _build_weekly_text(all_records, non_holiday_records, day_counts, weekday_periods): + """生成周上课时间分布的文字总结""" + total_non = len(non_holiday_records) + if total_non == 0: + return "该角色在非寒暑假期间没有完课记录,无法分析周上课时间分布。" + + parts = [] + # 最活跃的星期 + max_day_idx = max(day_counts, key=day_counts.get, default=-1) + if max_day_idx >= 0: + parts.append(f"非寒暑假期间共完成 {total_non} 节课,主要集中在 **{WEEKDAY_NAMES[max_day_idx]}**({day_counts[max_day_idx]} 节,占 {day_counts[max_day_idx]/total_non*100:.0f}%)。") + + # 时段偏好 + weekday_total = sum(day_counts.get(i, 0) for i in range(5)) + top_period = None + top_period_cnt = 0 + for period in ["晚上", "上午", "下午", "中午"]: + cnt = sum(weekday_periods.get(period, {}).values()) + if cnt > top_period_cnt: + top_period_cnt = cnt + top_period = period + if top_period and top_period_cnt > 0: + pct = top_period_cnt / weekday_total * 100 if weekday_total > 0 else 0 + parts.append(f"工作日上课集中在 **{top_period}**时段({top_period_cnt} 节,占 {pct:.0f}%)。") + + # 周末情况 + sat = day_counts.get(5, 0) + sun = day_counts.get(6, 0) + if sat + sun > 0: + parts.append(f"周末也保持上课节奏,周六 {sat} 节、周日 {sun} 节。") + else: + parts.append("周末无上课记录。") + + return " ".join(parts) + + +def _build_trend_text(weeks_data, analysis): + """生成跨周趋势分析的总结文字""" + if not weeks_data: + return "无完课记录,无法分析趋势。" + + parts = [] + parts.append(f"完课跨越 {analysis['total_span_weeks']} 周(有课 {analysis['total_weeks']} 周),共 {analysis['total_lessons']} 节,周均 {analysis['avg_per_week']} 节。") + + if analysis['consecutive']: + parts.append("学习连续性良好,无中断周。") + else: + empty_list = [] + for y, w in sorted(analysis['empty_weeks']): + monday = datetime.fromisocalendar(y, w, 1) + empty_list.append(f"{y}年W{w:02d}({monday.strftime('%m/%d')}起)") + parts.append(f"存在间断:{'、'.join(empty_list)}。") + + parts.append(f"整体趋势:{analysis['trend']},前半段平均 {analysis['first_half_avg']} 节/周 → 后半段 {analysis['second_half_avg']} 节/周。") + + return " ".join(parts) + + +def _build_summary(role_info, retention_status, all_records, non_holiday_records, + day_counts, weekday_periods, weeks_data, analysis): + """生成关键特征总结""" + items = [] + total = len(all_records) + total_non = len(non_holiday_records) + + if total == 0: + items.append("暂无完课记录。") + return items + + # 1. 学习规模与留存 + if retention_status == "流失": + items.append(f"⚠️ 近14天无完课,已**流失**。历史共 {total} 节完课记录。") + elif retention_status == "正常": + items.append(f"✅ 状态**正常**,近14天内有完课。累计 {total} 节完课。") + else: + items.append(f"累计 {total} 节完课记录。") + + # 2. 学习时长判断 + if weeks_data: + first_week = datetime.fromisocalendar(weeks_data[0][0], weeks_data[0][1], 1) + last_week = datetime.fromisocalendar(weeks_data[-1][0], weeks_data[-1][1], 1) + span_months = (last_week.year - first_week.year) * 12 + (last_week.month - first_week.month) + 1 + if span_months >= 6: + items.append(f"📅 长期用户,学习跨度约 {span_months} 个月。") + elif span_months >= 2: + items.append(f"📅 中期用户,学习跨度约 {span_months} 个月。") + else: + items.append(f"🆕 新用户,学习跨度约 {span_months} 个月,尚在形成学习习惯阶段。") + + # 3. 强度 + if analysis['avg_per_week'] >= 6: + items.append(f"🔥 高强度学习,周均 {analysis['avg_per_week']} 节。") + elif analysis['avg_per_week'] >= 4: + items.append(f"📚 稳定学习,周均 {analysis['avg_per_week']} 节。") + elif analysis['avg_per_week'] > 0: + items.append(f"🐢 低频学习,周均 {analysis['avg_per_week']} 节。") + + # 4. 时段特征 + if total_non > 0: + max_day_idx = max(day_counts, key=day_counts.get) + weekday_total = sum(day_counts.get(i, 0) for i in range(5)) + evening_cnt = sum(weekday_periods.get("晚上", {}).values()) + if evening_cnt > weekday_total * 0.6 and weekday_total > 0: + items.append(f"🌙 晚间学习型,{evening_cnt / weekday_total * 100:.0f}% 的课在晚上。") + morning_cnt = sum(weekday_periods.get("上午", {}).values()) + if morning_cnt > weekday_total * 0.4 and weekday_total > 0: + items.append(f"☀️ 上午学习型,{morning_cnt / weekday_total * 100:.0f}% 的课在上午。") + + # 5. 趋势 + if analysis['trend'] == "下降 ↓": + items.append(f"📉 学习频率呈下降趋势,需关注。") + elif analysis['trend'] == "上涨 ↑": + items.append(f"📈 学习频率呈上升趋势,势头良好。") + + # 6. 寒暑假 + holiday_cnt = total - total_non + if holiday_cnt > 0: + items.append(f"🏖️ 寒暑假期间也有坚持学习({holiday_cnt} 节)。") + + return items + + +def format_html(role_id, role_info, retention_status, all_records, + non_holiday_records, holiday_count, + day_counts, weekday_periods, weeks_data, analysis): + """生成 HTML 格式分析报告,包含 5 个部分""" + + chapter_map = fetch_chapter_info_map() + now_str = datetime.now().strftime('%Y-%m-%d %H:%M') + + # ── 第一部分:扩展基本信息 ── + reg_time_str = role_info.get("reg_time", "-") if role_info else "-" + first_time_str = "-" + last_time_str = "-" + last_level = "-" + last_unit = "-" + last_lesson = "-" + if all_records: + first = all_records[0]["updated_at"] + first_time_str = _fmt_dt(first) + last = all_records[-1]["updated_at"] + last_time_str = _fmt_dt(last) + last_chapter_id = all_records[-1].get("chapter_id") + if last_chapter_id and last_chapter_id in chapter_map: + ci = chapter_map[last_chapter_id] + last_level = ci["level"] or "-" + last_unit = ci["unit_name"] or f"Unit{ci['unit_num']}" if ci['unit_num'] >= 0 else "-" + last_lesson = str(ci["lesson_index"]) if ci["lesson_index"] > 0 else "-" + + # ── 第二部分:完课记录表格(带章节映射) ── + detail_rows = [] + for i, r in enumerate(all_records, 1): + chapter_id = r.get("chapter_id") + ci = chapter_map.get(chapter_id, {}) if chapter_id else {} + level = ci.get("level") or r.get("level") or "-" + unit_name = ci.get("unit_name", "") + unit_num = ci.get("unit_num", -1) + unit_display = unit_name if unit_name else (f"Unit{unit_num}" if unit_num >= 0 else "-") + lesson_display = str(ci.get("lesson_index", "-")) if ci.get("lesson_index", 0) > 0 else "-" + + start_dt = r.get("created_at") + end_dt = r["updated_at"] + start_str = _fmt_dt(start_dt) + end_str = _fmt_dt(end_dt) + if start_dt and end_dt: + duration_secs = (end_dt - start_dt).total_seconds() + else: + duration_secs = None + duration_str = _format_duration(duration_secs) if duration_secs is not None else "-" + + weekday_str = WEEKDAY_NAMES[end_dt.weekday()] if end_dt else "-" + period_str = classify_period(end_dt.hour) if end_dt else "-" + + detail_rows.append({ + "seq": i, + "level": level, + "unit": unit_display, + "lesson": lesson_display, + "start_time": start_str, + "end_time": end_str, + "duration": duration_str, + "weekday": weekday_str, + "period": period_str, + }) + + # ── 第三部分:周上课时间分布总结 ── + weekly_text = _build_weekly_text(all_records, non_holiday_records, day_counts, weekday_periods) + + # ── 第四部分:跨周趋势总结 ── + trend_text = _build_trend_text(weeks_data, analysis) + + # ── 第五部分:关键特征总结 ── + summary_items = _build_summary(role_info, retention_status, all_records, + non_holiday_records, day_counts, weekday_periods, + weeks_data, analysis) + + # ── 构建 HTML ── + rows_html = "" + for row in detail_rows: + rows_html += f""" + + {row['seq']} + {row['level']} + {row['unit']} + {row['lesson']} + {row['weekday']} + {row['period']} + {row['start_time']} + {row['end_time']} + {row['duration']} + """ + + summary_items_html = "".join(f"
  • {item}
  • " for item in summary_items) + + html = f""" + + + + +学习时间分析报告 — 角色 {role_id} + + + +
    + +

    📊 学习时间分析报告 — 角色 {role_id}({role_info.get('nickname', '') if role_info else ''})

    + + +

    一、基本信息

    +
    +
    +
    角色ID{role_info.get('role_id', '-') if role_info else '-'}
    +
    账号ID{role_info.get('account_id', '-') if role_info else '-'}
    +
    角色姓名{role_info.get('nickname', '-') if role_info else '-'}
    +
    角色性别{role_info.get('gender', '-') if role_info else '-'}
    +
    角色年龄{f"{role_info['age']} 岁" if role_info and role_info.get('age') else '-'}
    +
    手机号后4位{role_info.get('phone_tail', '-') if role_info else '-'}
    +
    最近留存状态{retention_status}
    +
    注册时间{reg_time_str}
    +
    第一次完课时间{first_time_str}
    +
    最后一次完课时间{last_time_str}
    +
    最后一次完课Level {last_level} / {last_unit} / Lesson {last_lesson}
    +
    +
    + + +

    二、完课记录明细(共 {len(all_records)} 条)

    +
    + + + + + + + + + + + + + + + + {rows_html} + +
    序号LevelUnit(单元)Lesson星期时段开始上课时间完课时间完课耗时
    +
    + + +

    三、周上课时间分布分析

    +
    +
    {weekly_text}
    +
    + + +

    四、跨周趋势分析

    +
    +
    {trend_text}
    +
    + + +

    五、关键特征总结

    +
    +
      + {summary_items_html} +
    +
    + +
    分析时间:{now_str} | 完课总数:{len(all_records)} 条 | 非寒暑假:{len(non_holiday_records)} 条 | 寒暑假:{holiday_count} 条
    + +
    + +""" + + return html + + # ── 主函数 ──────────────────────────────────────────── def main(): - if len(sys.argv) < 2: - print("用法: python3 studytime_analysis.py ", file=sys.stderr) - sys.exit(1) + parser = argparse.ArgumentParser(description="角色学习时间分析工具") + parser.add_argument("role_id", type=int, help="角色ID") + parser.add_argument("--format", choices=["md", "html"], default="md", + help="输出格式(默认 md)") + parser.add_argument("--output", "-o", type=str, default=None, + help="输出文件路径(仅在 html 模式下生效,默认 stdout)") + args = parser.parse_args() - try: - role_id = int(sys.argv[1]) - except ValueError: - print(f"错误: 角色ID必须是数字,收到: {sys.argv[1]}", file=sys.stderr) - sys.exit(1) + role_id = args.role_id all_records = fetch_completion_records(role_id) non_holiday_records, holiday_records = split_records(all_records) holiday_count = len(holiday_records) - # 角色基本信息(MySQL) role_info = fetch_role_info(role_id) - - # 留存状态判定 retention_status = check_retention(all_records) - # 一周分布分析:仅用非寒暑假数据 day_counts, weekday_periods = analyze_weekly_distribution(non_holiday_records) - # 跨周趋势分析:用全部数据 weeks_data, analysis = analyze_weekly_trend(all_records) - report = format_report(role_id, role_info, retention_status, all_records, non_holiday_records, holiday_count, day_counts, weekday_periods, weeks_data, analysis) - - print(report) + if args.format == "html": + output = format_html(role_id, role_info, retention_status, all_records, + non_holiday_records, holiday_count, + day_counts, weekday_periods, weeks_data, analysis) + if args.output: + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + f.write(output) + print(f"HTML 报告已保存到: {args.output}") + else: + print(output) + else: + output = format_markdown(role_id, role_info, retention_status, all_records, + non_holiday_records, holiday_count, + day_counts, weekday_periods, weeks_data, analysis) + print(output) if __name__ == "__main__": diff --git a/⚠️ b/⚠️ new file mode 100644 index 0000000..e69de29