打卡統(tǒng)計(jì):消息監(jiān)聽(tīng)與定時(shí)匯總實(shí)戰(zhàn))
1. 從群里接龍到自動(dòng)點(diǎn)名這個(gè)需求到底卡在哪做過(guò)社群運(yùn)營(yíng)的人都有一個(gè)共同的痛每天早上的健康打卡、作業(yè)提交、活動(dòng)報(bào)名全靠群成員手動(dòng)接龍。人少還好幾十個(gè)人的群消息刷得飛快誰(shuí)交了誰(shuí)沒(méi)交翻聊天記錄翻到眼花。更麻煩的是總有人格式不統(tǒng)一——有人寫(xiě)張三已打卡有人寫(xiě)1. 張三 已提交還有人直接甩一張截圖。你要是靠肉眼去核對(duì)不出三天就得崩潰。這個(gè)項(xiàng)目的核心思路很直接用 Python 的 itchat 庫(kù)登錄微信網(wǎng)頁(yè)版監(jiān)聽(tīng)指定群聊的消息把符合打卡格式的消息自動(dòng)記錄下來(lái)再定時(shí)輸出一份已打卡名單和未打卡名單。說(shuō)白了就是給微信群配了一個(gè)不知疲倦的點(diǎn)名員。它適合誰(shuí)用三類人最需要一是社群運(yùn)營(yíng)者每天要統(tǒng)計(jì)幾十上百人的提交情況二是班級(jí)或團(tuán)隊(duì)的小負(fù)責(zé)人需要定期匯總成員反饋三是想學(xué) Python 自動(dòng)化辦公的初學(xué)者這個(gè)場(chǎng)景足夠真實(shí)代碼量也不大拿來(lái)練手非常合適。但我要先把丑話說(shuō)在前面itchat 依賴的是網(wǎng)頁(yè)版微信協(xié)議而網(wǎng)頁(yè)版微信的可用性這些年一直在收緊。很多賬號(hào)已經(jīng)無(wú)法登錄網(wǎng)頁(yè)版這是這個(gè)方案最大的不確定性。所以這篇文章不會(huì)只講怎么跑起來(lái)更會(huì)講清楚什么情況下它跑不起來(lái)以及跑不起來(lái)時(shí)怎么換思路。這才是真實(shí)項(xiàng)目里最有價(jià)值的部分。2. itchat 的工作機(jī)制與登錄環(huán)節(jié)的真實(shí)門(mén)檻2.1 itchat 到底做了什么itchat 本質(zhì)上是對(duì)網(wǎng)頁(yè)版微信 HTTP 接口的一層 Python 封裝。你調(diào)用itchat.auto_login()它會(huì)在本地起一個(gè)服務(wù)彈出一個(gè)二維碼圖片你用手機(jī)掃碼確認(rèn)后它就拿到了一組登錄憑證包括 cookie 和同步密鑰。之后它通過(guò)長(zhǎng)輪詢的方式不斷向服務(wù)器請(qǐng)求有沒(méi)有新消息拿到消息后回調(diào)你注冊(cè)的處理函數(shù)。理解這一點(diǎn)很關(guān)鍵itchat 不是破解微信它走的是官方曾經(jīng)開(kāi)放的網(wǎng)頁(yè)版通道。所以它的能力邊界完全取決于這個(gè)通道是否對(duì)你的賬號(hào)開(kāi)放。這也是為什么同一段代碼有人跑得好好的有人掃碼后直接提示無(wú)法登錄。2.2 登錄環(huán)節(jié)最容易踩的三個(gè)坑第一個(gè)坑是賬號(hào)類型限制。實(shí)測(cè)下來(lái)很多近期注冊(cè)的賬號(hào)、以及部分被系統(tǒng)判定為風(fēng)險(xiǎn)較高的賬號(hào)是無(wú)法登錄網(wǎng)頁(yè)版的。這不是代碼問(wèn)題換多少臺(tái)電腦都一樣。判斷方法很簡(jiǎn)單你先用瀏覽器打開(kāi)網(wǎng)頁(yè)版微信如果瀏覽器都登不上itchat 必然也登不上。第二個(gè)坑是二維碼顯示不出來(lái)。auto_login()默認(rèn)會(huì)嘗試用系統(tǒng)圖片查看器打開(kāi)二維碼在服務(wù)器環(huán)境或者某些精簡(jiǎn)系統(tǒng)上會(huì)失敗。穩(wěn)妥的做法是加上enableCmdQR參數(shù)直接在終端里用字符畫(huà)顯示二維碼import itchat # enableCmdQR2 表示用兩個(gè)字符寬度渲染一個(gè)像素塊終端里更清晰 itchat.auto_login(enableCmdQR2, hotReloadTrue)hotReloadTrue這個(gè)參數(shù)值得單獨(dú)說(shuō)。它會(huì)把登錄狀態(tài)緩存到本地文件短時(shí)間內(nèi)再次運(yùn)行程序時(shí)不用重新掃碼。調(diào)試階段這個(gè)能省下大量時(shí)間不然你每改一行代碼就要掃一次碼心態(tài)很容易崩。第三個(gè)坑是登錄后立刻掉線。常見(jiàn)原因是同一賬號(hào)在多個(gè)地方登錄網(wǎng)頁(yè)版或者程序運(yùn)行環(huán)境網(wǎng)絡(luò)不穩(wěn)定導(dǎo)致長(zhǎng)輪詢超時(shí)。我的經(jīng)驗(yàn)是調(diào)試期間手機(jī)端不要頻繁切換網(wǎng)絡(luò)程序盡量跑在穩(wěn)定的有線網(wǎng)絡(luò)環(huán)境里。提示如果你發(fā)現(xiàn)掃碼后程序卡住不動(dòng)先別急著改代碼。打開(kāi)調(diào)試日志看看是不是卡在get_contact或web_init這一步這通常意味著賬號(hào)權(quán)限受限而不是代碼寫(xiě)錯(cuò)了。3. 群消息監(jiān)聽(tīng)的實(shí)現(xiàn)細(xì)節(jié)從注冊(cè)回調(diào)到精準(zhǔn)匹配3.1 消息回調(diào)的注冊(cè)與群聊定位itchat 的消息處理靠裝飾器注冊(cè)回調(diào)函數(shù)。核心是itchat.msg_register你需要指定監(jiān)聽(tīng)的消息類型。群聊文本消息的類型是itchat.content.TEXT但光這樣還不夠因?yàn)槟J(rèn)情況下群消息里的發(fā)送者信息是不完整的你需要額外開(kāi)啟群聊成員信息獲取。import itchat from itchat.content import TEXT # 開(kāi)啟群聊消息中攜帶發(fā)送者昵稱 itchat.msg_register(TEXT, isGroupChatTrue) def handle_group_msg(msg): # msg[ActualNickName] 是群內(nèi)昵稱 # msg[Content] 是消息正文 # msg[FromUserName] 是群聊的ID pass這里有個(gè)非常關(guān)鍵的細(xì)節(jié)群聊的FromUserName是一串以開(kāi)頭的 ID不是群名稱。你不能直接用群名去匹配得先拿到群 ID。獲取方法是遍歷itchat.get_chatrooms()按群名找到對(duì)應(yīng)的UserName然后緩存起來(lái)。def find_group_id(group_name): chatrooms itchat.get_chatrooms(updateTrue) for room in chatrooms: if room[NickName] group_name: return room[UserName] return NoneupdateTrue會(huì)強(qiáng)制從服務(wù)器拉取最新的群列表第一次運(yùn)行時(shí)建議加上之后可以緩存結(jié)果減少請(qǐng)求。3.2 打卡消息的匹配策略匹配邏輯是整個(gè)程序的大腦。最粗糙的做法是判斷消息里有沒(méi)有打卡兩個(gè)字但這樣誤判率極高——有人發(fā)我今天不想打卡也會(huì)被算進(jìn)去。我的做法是設(shè)計(jì)一個(gè)結(jié)構(gòu)化匹配規(guī)則用正則表達(dá)式同時(shí)約束關(guān)鍵詞和格式。假設(shè)打卡格式約定為姓名已打卡可以這樣寫(xiě)import re # 匹配 張三 已打卡 或 張三已打卡 或 張三: 已打卡 pattern re.compile(r^([\u4e00-\u9fa5A-Za-z]{2,10})\s*[:]?\s*(已打卡|打卡完成|已完成)$) def parse_checkin(content): text content.strip() match pattern.match(text) if match: return match.group(1) # 返回姓名 return None這個(gè)正則做了幾件事限制姓名是 2 到 10 個(gè)中英文字符允許中間有冒號(hào)或空格結(jié)尾必須是約定的打卡詞之一。^和$保證整條消息完全符合格式避免張三已打卡但是李四沒(méi)打這種句子被誤判。實(shí)際運(yùn)營(yíng)中你很難要求所有人都嚴(yán)格按格式來(lái)。所以更實(shí)用的策略是分級(jí)匹配先嘗試嚴(yán)格匹配匹配不上再走寬松匹配比如只提取消息開(kāi)頭的姓名最后把寬松匹配的結(jié)果單獨(dú)列出來(lái)人工復(fù)核。這樣既不漏人也不會(huì)把臟數(shù)據(jù)混進(jìn)正式名單。3.3 去重與時(shí)間窗口同一個(gè)人可能重復(fù)打卡或者手滑發(fā)了兩遍。所以記錄的時(shí)候要用集合去重并且加上時(shí)間窗口——只統(tǒng)計(jì)當(dāng)天某個(gè)時(shí)間段內(nèi)的消息。時(shí)間窗口的判斷用消息自帶的CreateTime字段它是 Unix 時(shí)間戳。import time from datetime import datetime def is_in_window(msg_time, start_hour6, end_hour12): dt datetime.fromtimestamp(msg_time) return start_hour dt.hour end_hour把去重集合和時(shí)間窗口結(jié)合起來(lái)就能保證統(tǒng)計(jì)結(jié)果干凈可靠。這里我踩過(guò)一個(gè)坑CreateTime是服務(wù)器時(shí)間戳如果你的運(yùn)行環(huán)境時(shí)區(qū)設(shè)置不對(duì)算出來(lái)的小時(shí)數(shù)會(huì)偏。穩(wěn)妥做法是顯式指定時(shí)區(qū)或者干脆用本地時(shí)間做二次校驗(yàn)。4. 名單生成、定時(shí)任務(wù)與結(jié)果推送4.1 已打卡與未打卡名單的比對(duì)統(tǒng)計(jì)的核心是全集減去已打卡集合。全集從哪來(lái)兩個(gè)來(lái)源一是群成員列表itchat.get_chatrooms()里每個(gè)群的MemberList二是你預(yù)先維護(hù)的一份花名冊(cè)。我更推薦后者因?yàn)槿撼蓡T會(huì)變動(dòng)而且很多人群昵稱和真實(shí)姓名對(duì)不上。# 預(yù)置花名冊(cè) roster [張三, 李四, 王五, 趙六] def build_report(checked_in): done [name for name in roster if name in checked_in] undone [name for name in roster if name not in checked_in] return done, undone用列表推導(dǎo)式做比對(duì)代碼簡(jiǎn)潔邏輯也一目了然。注意checked_in是集合查找效率是 O(1)花名冊(cè)哪怕有幾百人比對(duì)也是瞬間完成。4.2 定時(shí)輸出與消息推送統(tǒng)計(jì)不能只靠手動(dòng)觸發(fā)得定時(shí)跑。最簡(jiǎn)單的方案是用threading.Timer或者schedule庫(kù)起一個(gè)后臺(tái)線程每隔一段時(shí)間生成一次報(bào)告。但要注意itchat 的消息監(jiān)聽(tīng)本身是阻塞的定時(shí)任務(wù)必須跑在獨(dú)立線程里否則會(huì)把監(jiān)聽(tīng)循環(huán)卡死。import threading import schedule def job(): done, undone build_report(checked_in_set) report f已打卡 {len(done)} 人{(lán)、.join(done)}\n report f未打卡 {len(undone)} 人{(lán)、.join(undone)} # 把報(bào)告發(fā)回群里或發(fā)給指定聯(lián)系人 itchat.send(report, toUserNamegroup_id) def run_scheduler(): schedule.every().day.at(12:00).do(job) while True: schedule.run_pending() time.sleep(1) # 在獨(dú)立線程里啟動(dòng)定時(shí)器 threading.Thread(targetrun_scheduler, daemonTrue).start()daemonTrue保證主程序退出時(shí)定時(shí)線程也跟著結(jié)束不會(huì)留下僵尸進(jìn)程。發(fā)送報(bào)告用itchat.sendtoUserName填群 ID 就能發(fā)回群里填個(gè)人 ID 就能私發(fā)給負(fù)責(zé)人。4.3 數(shù)據(jù)持久化別讓統(tǒng)計(jì)結(jié)果只活在內(nèi)存里程序一重啟內(nèi)存里的打卡記錄就沒(méi)了。所以必須落盤(pán)。最輕量的方案是寫(xiě) CSV每天一個(gè)文件方便用 Excel 打開(kāi)核對(duì)。import csv from datetime import date def save_record(name, timestamp): filename fcheckin_{date.today()}.csv with open(filename, a, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([name, datetime.fromtimestamp(timestamp).strftime(%H:%M:%S)])注意encodingutf-8-sig這個(gè)帶 BOM 的編碼能讓 Excel 正確識(shí)別中文不然打開(kāi)就是亂碼。這個(gè)細(xì)節(jié)很多教程都不提但實(shí)際用起來(lái)差別巨大。5. 實(shí)測(cè)中的意外情況與排查鏈路5.1 消息收不到從回調(diào)注冊(cè)查起程序跑起來(lái)了群里也發(fā)了打卡消息但日志里什么都沒(méi)有。這時(shí)候按下面的順序排查第一步確認(rèn)回調(diào)函數(shù)真的被注冊(cè)了。裝飾器寫(xiě)錯(cuò)、函數(shù)名重復(fù)、或者msg_register的參數(shù)類型不對(duì)都會(huì)導(dǎo)致回調(diào)不觸發(fā)??梢栽诨卣{(diào)函數(shù)第一行加一句print(收到消息)看有沒(méi)有輸出。第二步確認(rèn)群消息類型。如果你只注冊(cè)了TEXT那圖片、語(yǔ)音、表情都不會(huì)觸發(fā)。打卡如果允許發(fā)截圖就得額外注冊(cè)PICTURE類型。第三步確認(rèn)群 ID 匹配正確。前面說(shuō)過(guò)群 ID 是開(kāi)頭的字符串如果你拿群名去比對(duì)FromUserName永遠(yuǎn)匹配不上。打印出來(lái)看一眼就清楚了。5.2 中文亂碼編碼問(wèn)題的三種表現(xiàn)中文亂碼在不同環(huán)節(jié)表現(xiàn)不一樣。終端打印亂碼通常是終端編碼不是 UTF-8寫(xiě)文件亂碼是沒(méi)加utf-8-sig發(fā)送消息亂碼多半是字符串拼接時(shí)混入了非 UTF-8 的內(nèi)容。我的習(xí)慣是在程序入口統(tǒng)一設(shè)置import sys import io sys.stdout io.TextIOWrapper(sys.stdout.buffer, encodingutf-8)這樣終端輸出就穩(wěn)定了。文件寫(xiě)入按前面說(shuō)的加 BOM發(fā)送消息前對(duì)內(nèi)容做一次encode(utf-8).decode(utf-8)的清洗基本能覆蓋絕大多數(shù)亂碼場(chǎng)景。5.3 賬號(hào)被限制登錄這個(gè)方案的天花板這是最需要正視的問(wèn)題。當(dāng) itchat 提示無(wú)法登錄網(wǎng)頁(yè)版時(shí)說(shuō)明你的賬號(hào)已經(jīng)不在網(wǎng)頁(yè)版通道的開(kāi)放范圍內(nèi)。這時(shí)候繼續(xù)折騰代碼沒(méi)有意義得換思路。替代方案有幾個(gè)方向一是改用企業(yè)微信或釘釘?shù)臋C(jī)器人接口它們對(duì)自動(dòng)化更友好有官方 API二是用群機(jī)器人 webhook讓成員通過(guò)點(diǎn)擊卡片或填表的方式打卡數(shù)據(jù)直接進(jìn)后臺(tái)三是如果只是內(nèi)部小范圍使用可以考慮用共享文檔加腳本讀取的方式成員在文檔里填腳本定時(shí)抓取。每個(gè)方案都有取舍。官方 API 穩(wěn)定但需要配置webhook 簡(jiǎn)單但交互形式受限共享文檔靈活但依賴成員自覺(jué)。選哪個(gè)取決于你的群規(guī)模和成員的技術(shù)接受度。6. 幾個(gè)讓程序更耐用的工程習(xí)慣6.1 把配置抽出來(lái)別硬編碼群名、花名冊(cè)、打卡關(guān)鍵詞、統(tǒng)計(jì)時(shí)間窗口這些東西都會(huì)變。如果全寫(xiě)在代碼里每次調(diào)整都要改代碼、重啟程序。更好的做法是放到一個(gè)config.json里{ group_name: 某打卡群, roster: [張三, 李四, 王五], keywords: [已打卡, 打卡完成], window: {start: 6, end: 12}, report_time: 12:00 }程序啟動(dòng)時(shí)讀一次配置改規(guī)則只需要改 JSON 文件代碼一行不動(dòng)。這個(gè)習(xí)慣在長(zhǎng)期運(yùn)行的項(xiàng)目里價(jià)值極高。6.2 日志要分級(jí)別只 print調(diào)試階段print夠用但程序一旦長(zhǎng)期跑你需要知道什么時(shí)候收到了什么消息哪條消息匹配失敗了定時(shí)任務(wù)有沒(méi)有正常執(zhí)行。用logging模塊分級(jí)別記錄import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(checkin.log, encodingutf-8), logging.StreamHandler() ] )匹配失敗的消息用WARNING級(jí)別記下來(lái)方便事后分析是不是格式約定需要調(diào)整。定時(shí)任務(wù)的執(zhí)行用INFO記錄出問(wèn)題時(shí)能快速定位是任務(wù)沒(méi)跑還是跑了但結(jié)果不對(duì)。6.3 異常兜底別讓一個(gè)錯(cuò)誤搞崩整個(gè)程序消息處理函數(shù)里任何一行拋異常都可能導(dǎo)致回調(diào)中斷。所以核心邏輯一定要包在 try 里def handle_group_msg(msg): try: # 處理邏輯 pass except Exception as e: logging.error(f處理消息出錯(cuò): {e}, exc_infoTrue)exc_infoTrue會(huì)把完整堆棧寫(xiě)進(jìn)日志排查時(shí)能直接看到是哪一行出的問(wèn)題。這個(gè)習(xí)慣看起來(lái)啰嗦但在無(wú)人值守的場(chǎng)景下它是程序能連續(xù)跑幾周不崩的關(guān)鍵。7. 關(guān)于這類自動(dòng)化工具的一點(diǎn)個(gè)人體會(huì)我前后用類似思路做過(guò)好幾個(gè)群統(tǒng)計(jì)工具最大的感受是技術(shù)實(shí)現(xiàn)從來(lái)不是最難的部分難的是讓規(guī)則被所有人接受并執(zhí)行。你代碼寫(xiě)得再漂亮如果群里一半人不按格式打卡統(tǒng)計(jì)結(jié)果照樣一團(tuán)糟。所以我的建議是工具上線前先在群里做三天雙軌運(yùn)行——人工統(tǒng)計(jì)和自動(dòng)統(tǒng)計(jì)同時(shí)跑對(duì)比結(jié)果找出格式歧義的地方把規(guī)則打磨清楚再正式切換。這個(gè)過(guò)程看起來(lái)慢但能省掉后面無(wú)數(shù)次的返工。另外itchat 這類依賴非官方通道的方案本質(zhì)上是在借用一個(gè)隨時(shí)可能關(guān)閉的入口。用它做臨時(shí)工具、做個(gè)人練手項(xiàng)目都沒(méi)問(wèn)題但如果是要長(zhǎng)期穩(wěn)定運(yùn)行的業(yè)務(wù)系統(tǒng)還是盡早遷移到有官方支持的平臺(tái)上。把 itchat 當(dāng)作學(xué)習(xí)消息監(jiān)聽(tīng)、回調(diào)機(jī)制、定時(shí)任務(wù)的練手項(xiàng)目它的價(jià)值就已經(jīng)足夠了。真正沉淀下來(lái)的是你對(duì)消息驅(qū)動(dòng)規(guī)則匹配定時(shí)匯總這套模式的理解換任何平臺(tái)都能復(fù)用。