直播结束了考勤该提了。负责在线课程、内部培训或者活动直播最让人头疼的不是直播当天的设备调试而是散场之后那份观看明细怎么变成汇报材料。现在大多数办公协同平台都带直播功能后台也能导出一份观看记录里面通常包含姓名、部门、进入时间、离开时间、观看时长这些字段。问题在于导出的文件常常有几百上千行同一个人名出现好几次时间格式还不统一靠人肉筛选加Excel透视表也能做但做多了特别容易漏一漏就是考勤事故。这个项目思路正好一句话点透直播导出观看时长数据统计C语言——把平台导出的观看记录交给一个C语言小程序程序跑一遍就能自动完成清洗、去重、汇总最终输出一份可以直接发给上级或者教学负责人看的统计报表。适合正在学C语言、想找一个真实文件处理练手项目的人也适合没有现成脚本环境、只想快速把一批数据汇总完的老同事。本文不讨论平台的具体实现只讲通用做法贴的代码可以在自己的机器上直接编译运行。1. 内容整体设计与思路拆解拿到这个题目第一件事不是打开编辑器写代码而是先想清楚三件事数据从哪来、统计到哪个粒度、最终给谁看。很多同学做这类项目时上来就写结构体数组写完发现字段对不上、重复记录一大堆、输出还要回去改Excel最后越改越乱。先把数据流捋明白后面的C代码反而是最省事的。1.1 原始数据有哪些常见字段和脏数据从直播后台导出观看明细后不同平台的列名会有差异但字段构成基本是这一套常见字段实际含义常见脏数据直播名称/会议主题直播场次标识空字符串、含特殊符号姓名观看者空值、昵称、重名部门/班级组织归属前后空格、多级部门进入时间进入直播间的时间“--”、缺字段、多种日期格式离开时间退出直播间的时间“--”、缺字段、跨天观看时长有效观看时长“1小时2分3秒”、“0.5小时”、空值观看次数进入直播间次数部分平台不提供该列这里最早期的一个坑就是总有人以为导出文件可以直接交给C程序读实际不是。平台导出大多是Excel文件扩展名是.xlsx或.xlsC语言标准库没有直接解析这种二进制格式的能力。所以准备工作里必然有一道工序用Excel打开后另存为CSV格式。CSV本质是逗号分隔的纯文本C语言按行读取再按逗号分列就能处理。另一个坑是编码。中文平台导出的文件离不开中文字段文件可能是UTF-8编码也可能是GBK编码甚至同一批文件两种编码混着来。C语言对字符串的处理是基于字节的按字节比较也能比出中文是否相同但前提是整个文件编码一致。所以数据准备阶段的第一条纪律就是统一成UTF-8 CSV最好在Excel里选择“CSV UTF-8”这种格式另存省掉后面一堆乱码问题。1.2 统计需求拆解与输出口径把一句“我要统计观看时长”拆开实际包含三层工作。第一层是逐人累加同一个观看者多次进出直播间平台导出的明细可能有多行要把这些行的观看时长累加成一个总数。第二层是按部门汇总领导关心的是平均观看时长、缺勤人数得把人员聚合的结果再按部门做一次汇总。第三层是达标判断比如某场培训要求观看满30分钟才算有效参与程序里需要一个阈值参数跑的时候能动态传入。输出口径也要提前定死。我的建议是至少生成两个文件个人维度的明细汇总部门维度的汇总。字段分别是姓名、部门、累计观看秒数、观看次数、首次进入时间、最后离开时间、是否达标以及部门、人数、部门总时长、人均时长、部门达标人数。这些字段足够应付绝大多数考勤和统计场景。三段需求对应到C语言里就是文件读取、哈希表聚合、排序输出。后面所有代码都围绕这三个环节展开。2. 数据结构和存储方案选型选数据结构是C语言项目里最有意思的一步因为这直接决定后面能处理多大的数据量、代码写起来顺不顺手。很多人第一反应是定义一个大结构体数组MemberStat arr[5000]然后遍历查找。这种做法对一两百行的数据没问题一旦数据量到几千行线性查找的耗时就会肉眼可见地上升而且删除、扩展、排序都麻烦。更合适的做法是链式哈希表。2.1 用结构体记录聚合结果而不是原始明细程序没必要把所有原始行都留在内存里。读取一行、解析一行、立即更新对应人的统计节点这样内存消耗非常低。统计节点只存放聚合结果typedef struct StatNode { char name[64]; /* 观看成员 */ char dept[64]; /* 部门/班级 */ char live_title[128]; /* 直播名称多文件合并时用到 */ int total_sec; /* 累计观看总时长单位秒 */ int visit_count; /* 进入直播间次数 */ long first_ts; /* 首次进入时间Unix时间戳 */ long last_ts; /* 最后离开时间Unix时间戳 */ struct StatNode *next; /* 哈希桶里的链表指针 */ } StatNode;字段用秒而不是分钟或小时是因为秒可以精确换算成任意展示格式而且累加时不会出现浮点误差。first_ts和last_ts用Unix时间戳方便跨天比较也方便格式化输出。2.2 链式哈希表去重和累加一步完成链式哈希表的思路是把“姓名”算出一个哈希值映射到一个固定大小的数组下标。同一个下标下可能有多个人名用链表串起来查找和插入时平均复杂度接近O(1)。#define HASH_SIZE 1024 static StatNode *bucket[HASH_SIZE]; static unsigned long hash_key(const char *s) { unsigned long h 5381; while (*s) { h (h 5) h (unsigned char)*s; } return h % HASH_SIZE; } static StatNode *find_or_create(const char *name, const char *dept) { unsigned long idx hash_key(name); for (StatNode *p bucket[idx]; p; p p-next) { if (strcmp(p-name, name) 0) { return p; } } StatNode *n (StatNode *)calloc(1, sizeof(StatNode)); strncpy(n-name, name, sizeof(n-name) - 1); strncpy(n-dept, dept, sizeof(n-dept) - 1); n-next bucket[idx]; bucket[idx] n; return n; }哈希函数用的是经典的DJB2变体字符串分布比较均匀代码又短。find_or_create名字起得很直白找到就返回没找到就创建。这样在聚合主循环里不需要先判断“这个人有没有出现过”直接调用就行了。2.3 别忽略字符串边界和内存释放C语言项目里越界和泄漏最容易翻车。定义结构体数组时strncpy之后必须手动给最后一个字符赋\0否则一旦源头字符串过长后面输出时会带着内存里的残留字节。我习惯写一个小工具函数static void set_field(char *dst, size_t size, const char *src) { if (size 0) return; strncpy(dst, src, size - 1); dst[size - 1] \0; }这样任何字段赋值都走同一个安全通道不容易漏。内存释放也要做。程序退出前遍历所有哈希桶逐个释放节点static void free_all(void) { for (int i 0; i HASH_SIZE; i) { StatNode *p bucket[i]; while (p) { StatNode *next p-next; free(p); p next; } bucket[i] NULL; } }这个函数表面上只是收尾但在循环处理多个输入文件时特别有用。不释放会导致内存只涨不降数据一多程序就可能被系统终止。3. 核心代码实现解析、聚合、排序、输出这一章是全文的技术重点。整个程序大概三百行左右拆开看其实就四块CSV解析、时间解析、哈希聚合、报表输出。每一块单独都能讲出不少细节。3.1 CSV分列不能简单用strtok很多初学者看到CSV就写strtok(line, ,)这是典型的用鼻子喝汤。平台导出的字段内容里可能包含逗号比如“市场部,渠道组”如果不带引号分列就会多出字段。规范CSV会用双引号把含逗号的内容包起来所以解析时必须处理引号状态。下面这段分列函数是我在实际代码里用的一个简短版本支持双引号包裹字段也支持字段里的转义双引号#define MAX_FIELD 256 #define MAX_COL 10 #define MAX_LINE 4096 static int split_csv(const char *line, char out[][MAX_FIELD]) { int col 0; const char *p line; while (*p) { char field[MAX_FIELD] {0}; int idx 0; if (*p ) { p; while (*p) { if (*p p[1] ) { if (idx MAX_FIELD - 1) field[idx] ; p 2; } else if (*p ) { p; break; } else { if (idx MAX_FIELD - 1) field[idx] *p; } } if (*p ,) p; } else { while (*p *p ! ,) { if (idx MAX_FIELD - 1) field[idx] *p; } if (*p ,) p; } trim(field); memcpy(out[col], field, MAX_FIELD); col; if (col MAX_COL) break; } return col; }trim函数负责去掉字段首尾的空格、回车、制表符。因为CSV行尾经常自带\r\nfgets读进来后如果不处理最后一个字段会带着回车后面做字符串比较全乱套。static char *trim(char *s) { while (*s || *s \t || *s \r || *s \n) s; char *end s strlen(s); while (end s (end[-1] || end[-1] \t || end[-1] \r || end[-1] \n)) { end--; } *end \0; return s; }3.2 时长字段和时间字段解析时长字段是最需要兼容性处理的。平台可能输出HH:MM:SS也可能输出1小时2分3秒还可能直接输出0.5小时。统一函数转成秒static int parse_duration_to_sec(const char *s) { int h 0, m 0, sec 0; if (sscanf(s, %d小时%d分%d秒, h, m, sec) 3) { return h * 3600 m * 60 sec; } if (strchr(s, :)) { if (sscanf(s, %d:%d:%d, h, m, sec) 3) { return h * 3600 m * 60 sec; } if (sscanf(s, %d:%d, h, m) 2) { return h * 3600 m * 60; } } double minutes atof(s); if (minutes 0) { return (int)(minutes * 60); } return 0; }这里有几个重要的判断逻辑。先看中文格式是因为sscanf的格式串带中文时必须确保源字符串也是UTF-8编码否则匹配失败。再看冒号格式时%d:%d和%d:%d:%d要同时支持有些平台会把50分钟写成50:00如果直接按HH:MM:SS三字段解析会当50小时差出一大截。代码里先尝试三字段成功才用失败再退到两字段最后才按纯数字分钟处理。进入和离开时间解析成Unix时间戳用来比较先后和排序static long parse_datetime_to_ts(const char *s) { struct tm tm {0}; int y 0, mo 0, d 0, h 0, mi 0, se 0; if (sscanf(s, %d-%d-%d %d:%d:%d, y, mo, d, h, mi, se) 6) { tm.tm_year y - 1900; tm.tm_mon mo - 1; tm.tm_mday d; tm.tm_hour h; tm.tm_min mi; tm.tm_sec se; return mktime(tm); } if (sscanf(s, %d/%d/%d %d:%d:%d, y, mo, d, h, mi, se) 6) { tm.tm_year y - 1900; tm.tm_mon mo - 1; tm.tm_mday d; tm.tm_hour h; tm.tm_min mi; tm.tm_sec se; return mktime(tm); } return 0; }mktime使用本地时区但在同一台机器上处理同一批数据时时间戳的相对大小关系是正确的不会影响排序和“谁先进谁后走”的判断。3.3 聚合主流程读一行更新一个节点主循环的核心代码很干净。逐行读入CSV跳过表头忽略空行然后分列、解析、更新哈希节点while (fgets(line, sizeof(line), fp)) { line_no; if (line_no 1) continue; /* 跳过表头 */ if (line[0] \n || line[0] \0) continue; char f[MAX_COL][MAX_FIELD] {{0}}; int fs split_csv(line, f); if (fs 4) continue; const char *name f[0]; const char *dept f[1]; const char *dur_raw f[2]; const char *enter_raw f[3]; const char *leave_raw f[4]; int dur parse_duration_to_sec(dur_raw); long enter_ts parse_datetime_to_ts(enter_raw); long leave_ts parse_datetime_to_ts(leave_raw); StatNode *node find_or_create(name, dept); node-total_sec dur; node-visit_count; if (enter_ts 0 (node-first_ts 0 || enter_ts node-first_ts)) { node-first_ts enter_ts; } if (leave_ts 0 leave_ts node-last_ts) { node-last_ts leave_ts; } }这里有一个我在实际中多次被问到的点为什么name是唯一键而不是姓名 部门因为同一个人可能跨部门出现在数据里如果按“姓名部门”聚合会被拆成两条记录统计考勤时就会少算。所以主键只取姓名部门取该人最后一次出现时所带的部门。如果你所在场景确实允许同名异人可以把主键改成“姓名 工号或手机号”字段结构里加一个成员ID列代码几乎不用改。3.4 报表输出文件必须带BOM部门表要按人去重输出个人统计表时有一个老生常谈但特别容易忘的问题UTF-8编码的CSV文件如果开头没有BOMExcel打开会出现中文乱码。C程序写入时直接输出三个字节的BOM头fputs(\xEF\xBB\xBF, fp);\xEF\xBB\xBF就是UTF-8 BOM。这一行能省去你打开文件后怀疑人生的半小时。然后逐个遍历哈希桶中的人员节点格式化时间并写出static void format_ts(long ts, char *buf, size_t size) { if (ts 0) { strncpy(buf, --, size); return; } struct tm tm; localtime_r(ts, tm); strftime(buf, size, %Y-%m-%d %H:%M:%S, tm); } static void dump_stat_file(const char *path, int pass_sec) { FILE *fp fopen(path, wb); if (!fp) { perror(fopen); return; } fputs(\xEF\xBB\xBF, fp); fputs(姓名,部门,累计观看秒数,累计观看分钟,观看次数,首次进入时间,最后离开时间,是否达标\n, fp); for (int i 0; i HASH_SIZE; i) { for (StatNode *p bucket[i]; p; p p-next) { char enter_buf[32] --; char leave_buf[32] --; format_ts(p-first_ts, enter_buf, sizeof(enter_buf)); format_ts(p-last_ts, leave_buf, sizeof(leave_buf)); fprintf(fp, %s,%s,%d,%d,%d,%s,%s,%s\n, p-name, p-dept, p-total_sec, p-total_sec / 60, p-visit_count, enter_buf, leave_buf, p-total_sec pass_sec ? 是 : 否); } } fclose(fp); }部门汇总稍麻烦一点。最容易犯的错误是直接遍历原始明细去按部门累加导致同一个成员被重复计算。正确做法是遍历刚才的人员哈希节点因为每个StatNode已经是一个人一条了在此基础上按部门聚合人数才不会虚增。typedef struct DeptNode { char dept[64]; int member_count; int pass_count; long total_sec; struct DeptNode *next; } DeptNode; static DeptNode *dept_table[128]; static unsigned int dept_hash(const char *s) { unsigned int h 0; while (*s) { h (h 5) h (unsigned char)*s; } return h % 128; } static void update_dept(const char *dept, int total_sec, int pass_sec) { unsigned int idx dept_hash(dept); for (DeptNode *p dept_table[idx]; p; p p-next) { if (strcmp(p-dept, dept) 0) { p-member_count; p-total_sec total_sec; if (total_sec pass_sec) p-pass_count; return; } } DeptNode *n (DeptNode *)calloc(1, sizeof(DeptNode)); strncpy(n-dept, dept, sizeof(n-dept) - 1); n-dept[sizeof(n-dept) - 1] \0; n-pass_count (total_sec pass_sec) ? 1 : 0; n-total_sec total_sec; n-member_count 1; n-next dept_table[idx]; dept_table[idx] n; }主程序最后加一个main入口支持命令行参数传入输入文件路径和达标秒数int main(int argc, char **argv) { if (argc 2) { fprintf(stderr, 用法: %s 输入CSV文件 [达标秒数]\n, argv[0]); fprintf(stderr, 示例: %s 观看明细.csv 1800\n, argv[0]); return 1; } const char *input_path argv[1]; int pass_sec 1800; if (argc 3) { pass_sec atoi(argv[2]); if (pass_sec 0) pass_sec 1800; } FILE *fp fopen(input_path, r); if (!fp) { perror(fopen); return 1; } char line[MAX_LINE]; int line_no 0; while (fgets(line, sizeof(line), fp)) { line_no; if (line_no 1) continue; if (line[0] \n || line[0] \0) continue; char f[MAX_COL][MAX_FIELD] {{0}}; int fs split_csv(line, f); if (fs 4) continue; const char *name f[0]; const char *dept f[1]; const char *dur_raw f[2]; const char *enter_raw f[3]; const char *leave_raw f[4]; int dur parse_duration_to_sec(dur_raw); long enter_ts parse_datetime_to_ts(enter_raw); long leave_ts parse_datetime_to_ts(leave_raw); StatNode *node find_or_create(name, dept); node-total_sec dur; node-visit_count; if (enter_ts 0 (node-first_ts 0 || enter_ts node-first_ts)) { node-first_ts enter_ts; } if (leave_ts 0 leave_ts node-last_ts) { node-last_ts leave_ts; } } fclose(fp); dump_stat_file(个人观看统计.csv, pass_sec); for (int i 0; i 128; i) { for (StatNode *p bucket[i]; p; p p-next) { update_dept(p-dept, p-total_sec, pass_sec); } } dump_dept_file(部门观看统计.csv, pass_sec); free_all(); return 0; }这样整个程序就完整了。从文件读取到输出每一步都可独立测试出了问题也好定位。4. 从导出文件到最终报表的操作实录代码写完后真正要跑起来的流程比想象中多一些步骤但每步都有明确目的。我把一次完整的数据处理过程拆开方便你照着做。4.1 准备输入文件先把Excel转成CSV打开直播后台找到对应场次的直播数据页面找到“观看明细”或“观看记录”导出入口。导出后大概率得到一个Excel文件。不管后台导出的是.xlsx还是.xls先用Excel打开然后执行“另存为”文件类型选择“CSV UTF-8逗号分隔”。这一步的目的是让C程序可以按文本方式读取同时把编码统一成UTF-8。接下来检查CSV文件里的列顺序。不同平台列顺序不同有的把进入时间放前面有的把观看时长放前面。我推荐的列顺序是姓名,部门,观看时长,进入时间,离开时间如果拿到文件的列顺序不是这样两种办法一是在Excel里手动调整列顺序二是改主程序里访问f[0]到f[4]的对应关系。我建议直接用第一种因为改代码虽然也简单但每换一份数据就要看一遍列名容易搞混。4.2 编译和运行把上面的代码保存成stat_live.c然后编译gcc -o stat stat_live.c -Wall-Wall必须开上线前把警告当成错误处理能省掉很多隐蔽问题。在Windows上用MinGW也是同一个命令只是生成的程序名变成stat.exe。运行命令./stat 观看明细.csv 1800第二个参数1800是达标秒数即观看满30分钟判为达标。如果你要统计的是100分钟的课程可以改成6000。不传第二个参数时默认30分钟。程序跑完会在当前目录生成两个文件个人观看统计.csv和部门观看统计.csv。输出会直接写上“统计完成”之类的提示不过C程序里简单打印一行就够了已生成: 个人观看统计.csv 已生成: 部门观看统计.csv4.3 结果文件长什么样个人观看统计.csv打开后大致是姓名,部门,累计观看秒数,累计观看分钟,观看次数,首次进入时间,最后离开时间,是否达标 A同学,市场一组,8420,140,3,2024-01-10 13:02:15,2024-01-10 15:17:30,是 某员工,研发二组,1800,30,1,2024-01-10 14:00:00,2024-01-10 14:30:00,是 B同学,市场一组,300,5,1,2024-01-10 14:00:00,2024-01-10 14:05:00,否部门观看统计.csv则类似部门,人数,部门总秒数,部门人均秒数,达标人数 市场一组,2,8720,4360,1 研发二组,1,1800,1800,1你还可以在部门表里再加一列达标率用达标人数 / 人数 * 100算一下展示时更直观。C里用整数计算会丢精度想精确就保留一位小数或者用%.1f格式化输出。5. 我踩过的坑和排查技巧实录代码写得再顺真实数据里也总有几个坑在等着。把我在处理平台导出数据时遇到过的问题整理出来给你当排查手册用。5.1 中文乱码控制台乱码和Excel乱码是两回事很多人在Windows命令行里运行程序看到中文输出是一堆乱码就以为代码编码错了其实未必。命令行乱码是因为控制台默认代码页跟程序输出字符集不一致这不影响CSV文件的正确性。真正要盯的是Excel打开生成文件是否正常。Excel打开CSV乱码绝大多数情况是没有写UTF-8 BOM。用wb模式打开文件开头写\xEF\xBB\xBF中文就能被正确识别。另一个注意点如果你用文本模式w打开在Windows上换行会被自动转换BOM字节也有被改写的小概率风险所以打开文件写报告务必要用二进制模式wb。5.2 时长字段里的“1小时2分3秒”和“0.5小时”平台导出时长格式非常随缘。有的输出1:02:03有的输出1小时2分3秒还有的直接是0.5。我的解析顺序是中文格式优先冒号格式其次纯数字当分钟处理。纯数字当分钟而不是当秒是根据大多数直播平台的习惯定的。保险的做法是先抽几行人工核对再决定atof(raw)后面乘60还是直接返回。如果发现平台给的是秒把parse_duration_to_sec最后的return (int)(minutes * 60)改成return (int)minutes就行一行搞定。5.3 姓名两头的空格、重名、空值导出数据里姓名字段经常带着空格可能是Excel里对齐产生的也可能是平台模板生成得粗糙。不处理的话“A同学”和“ A同学”会被当成两个人统计结果凭空多出一条。所以分列后第一件事就是trim每个字段这个操作在整个流程里决定了后续比较的准确性。重名问题处理起来要看有没有其他标识。如果平台还导出工号、手机号或成员ID一定要把这些字段加到StatNode里并作为哈希键的一部分如果只有姓名建议在开发前跟需求方确认“同名按一个人处理”还是“按部门区分”。这类口径问题改代码很快数据口径定错了返工才痛。5.4 进入和离开时间有“--”或者空值很多没进入过页面的人平台明细里不会有一行记录所以可能出现进入时间为空、离开时间也有空的情况。我的处理方式是时间戳为0时不更新first_ts和last_ts输出时显示--。但要注意观看时长如果为0但明明有进入记录可能是平台把跳进跳出的几秒钟直接计成0了。这种行不要盲目丢弃因为观看次数还要算进去。只有在整行都是空字段时才跳过否则会影响考勤的“是否出现过”判断。5.5 跨天直播和多个直播合并跨天直播最容易错的地方是用离开时间减进入时间算观看时长。如果直播开始于晚上23:50结束于次日00:20直接减会得到负数。所以我在程序里优先采用平台导出的“观看时长”字段而不是自己算时间差。只有当平台没有时长时间列时才用leave_ts - enter_ts并且补上if (diff 0) diff 86400的跨天修正。多个直播合并导出到同一个CSV时只按姓名聚合会串场。处理方法是在输入表里增加一个直播名称列把哈希键从name改成live_title # name或者在读取时按直播名称分开统计。如果输入文件里已经有直播名称列可以在find_or_create中传入live_title参数结构体里也预留了live_title字段扩展起来不需要大改架构。最后再分享一个我自己的习惯每当拿到一批新的导出数据先用Excel把前50行人工看一遍特别关注时长格式、是否带引号、有没有空行。这个习惯帮我避开了至少一半的“程序为什么跑出来不对劲”问题。真正在C语言里写解析和聚合反而只是按部就班的体力活。数据口径一旦理清楚这份代码就是你以后处理任何观看统计需求的基础模板。
阅读完成 · 觉得有帮助?