# 人流分析與自動留存
入口 /traffic。SQLite 檔案為 data/traffic.sqlite,WAL 模式,每筆事件與分鐘彙總交易式提交;不再因超過 24 小時或重啟刪除。備份請停止主服務後複製資料庫;運行中不要單獨複製 sqlite 而忽略 WAL。檔案已排除 Git。
## 圖表操作(2026-09 更新)
`/traffic` 使用單一分析區間,操作流程為「選觀測區段 → 平移/縮放 → 必要時指定起訖時間」。不再提供舊版的範圍選單、瀏覽模式或壓縮空白長條總覽。
- 預設選取最近一段紀錄;區段卡片由新到舊排列,每頁最多 6 段,顯示日期、起訖與有紀錄的分鐘數。卡片翻頁只瀏覽紀錄,點選才更新分析。
- 拖曳下方圖表或使用平移、放大/縮小,只改變分析時間視窗,不切換版面,也不自動跳過空窗。「回到所選區段」恢復最後點選的區段。
- 跨日分析展開「指定起訖時間」,按頁面選定的時區輸入,再按「套用區間」。結束須晚於開始、不超過現在,最長 366 天;不存在或重複的夏令時間會被拒絕。日期修改未套用時有獨立提示,下方仍代表已載入區間。
- 「細節」起訖、圖表與統計使用同一次查詢結果。切換區間時隱藏舊分析結果;請求失敗也不將舊結果當作新區間呈現。快速連點時捨棄過期回應。
- 完全無觀看或互動紀錄的視窗顯示「此區間無紀錄」,可跳到前後紀錄;有取樣且觀看數為 0 仍畫出來。無紀錄不能直接認定未開播。
卡片依有觀看或互動紀錄的分鐘分段,相鄰紀錄間隔超過 2 分鐘才切開。這是找資料用的「觀測區段」,不等於下述 `sessions` 場次,也不表示有完整開播/關播證據。
## 統計口徑與更新
圖表讀取 SQLite `minutes`,包含完整歷史,不受近期記憶體事件上限影響;桶寬依所選區間自動調整(目標約 240 點)。
- 平均觀看先計算各有效分鐘的取樣平均,再按有效分鐘加權;不直接平均大小不同的桶,也不把無取樣分鐘補成 0。保留小數,單一零值桶仍顯示水平線。
- 「最高分鐘平均」是分鐘平均的最高值,不是原始瞬間峰值。
- 覆蓋率是所選區間內有觀看取樣的分鐘比例。它不是完整在線時間,也不代表該分鐘每一秒都有觀測。
- 互動圖可顯示每桶總筆數或每分鐘進房/聊天;每分鐘模式按桶在所選區間內的時間長度換算,隱藏活躍人數線。未收到事件不等於確認沒有互動。
- 圖表刻度依資料選整數或小數:觀看數與筆數用 0/1/2 這類整數刻度,比率才用小數。觀看圖下方摘要顯示平均、最高分鐘與發生時間、有效分鐘與樣本數;沒有取樣或沒有互動的圖表會直接顯示說明,不留下看不懂的空白格線。
- 狀態列的觀看數是目前連線狀態;分析卡片是所選區間統計,不再混入目前成長速度等即時卡片。
圖表與表格採差異更新;相同結果不重繪或重建。歷史視窗結束超過 90 秒時每 60 秒查詢,近期視窗每 5 秒查詢;頁面隱藏時暫停,回到頁面時重新查詢。長期時段分布另以每 60 秒更新。
週/月分布依選定時區顯示星期 × 小時平均觀看與每日進房/聊天/有效取樣分鐘,無取樣顯示 —。進房明細只涵蓋近期記憶體事件(最多近 24 小時/20,000 筆事件);前端再依所選區間篩選,最多列出 200 筆。歷史明細為空不代表當時沒有進房,分鐘彙總仍保留。
## 歷史查詢 API
`GET /api/traffic?platform=TikTok&range=all` 提供全部歷史摘要與 `runs` 觀測區段。指定 `from`、`to`(Unix 毫秒整數)可查詢細節,例如 `/api/traffic?platform=TikTok&from=1789587600000&to=1789594920000`。
指定區間採 `[from, to)`;未來上限只容許目前分鐘的尾端,實際 `chartUntil` 不超過伺服器現在時間。無效區間回傳 400。回應包含 `since`、`chartUntil`、`bucketMs`、`buckets` 與 `chartSummary`;桶包含 `observedMinutes`、`peak`,摘要包含有效分鐘、樣本數、平均與最高分鐘平均。統計精度為分鐘,桶起點可能早於指定起點,查詢資料仍限定在指定範圍。
此版本需重啟主服務才能載入新的查詢與統計邏輯,之後重新整理頁面。只有前端或翻譯更新時通常只需重新整理。繁體中文與英文由 `lang/zh-TW.json`、`lang/en.json` 的 `traffic.*` 管理,見 [本地化說明](I18N.md)。
驗證命令:`node --test Test/traffic.test.cjs Test/i18n.test.cjs Test/web_assets.test.cjs`。涵蓋歷史查詢、零值與缺值、分鐘加權、時區解析、視窗邊界及翻譯;瀏覽器另驗證選段、平移縮放、還原、快速切換、載入失敗與空窗跳轉。
**活躍發言人數**(`chat_users` 表)=該範圍內**不同的發言者**,同一人跨分鐘只算一次(用 `COUNT(DISTINCT)`,不是把每分鐘人數相加)。圖表以琥珀色線呈現,卡片顯示範圍總數。寫入時以「平台+分鐘+發言者」去重;userscript 訊息常只有暱稱沒有 id,所以身分取 `userId || user`。`chat_users` 是後加的,第一次開啟舊資料庫時會從 `events` 的 payload 一次性回填。
**過濾攔截率與廣告比例**:被過濾器擋下的訊息由呼叫端**另記一筆 `type:'filter'` 事件**(只進 `minutes.blocked`/`adBlocked`,不算聊天數,所以總訊息數不變)。「廣告類」由規則的 `ad: true` 或**名稱含「廣告」**判定(`MessageFilter` 算好放在 `fr.ad`);`chat_users.ad` 記下同分鐘被廣告規則擋過的帳號,用來算「廣告帳戶比例 = 廣告帳號 ÷ 活躍發言人數」。卡片另有「過濾攔截率 = 被擋訊息 ÷ 總訊息」。
**保留期**:`events` 只保留 **7 天**(去重只需要 24 小時),`minutes`/`chat_users`/`sessions`/`heartbeats` **永久保留**——所以成長趨勢看得到,資料庫又不會無限膨脹。由 `TrafficRoutes` 啟動時與每 6 小時清理一次(`database.prune()`),順便 `wal_checkpoint` 收斂 WAL。
分鐘與場次等長期彙總不自動清除,磁碟用量仍會隨資料增長。舊版已遺失資料無法補回;升級前仍在舊程序記憶體中的資料無自動移轉。首次套用需重啟主服務與聊天程序。
週月頁的連續觀測區段用觀看取樣間隔判斷,缺超過 90 秒切段。跨重啟的「觀測場次」另外落地在 `sessions` 表:
平台有穩定串流 ID(TikTok `roomId`、Twitch `stream.id`)時,同一 ID 一律視為同一場次——即使中間程式離線、間隔再久也不切;
沒有 ID 的平台才用 120 秒間隔推斷。同一場次中未觀測的空檔不計入時間加權平均,避免用線性內插憑空生出中間的觀看數。
中斷期間若程式有心跳,記為「斷流」;沒有心跳則記為「程式離線」,該段只知道「程式沒在觀察」,無法斷定主播當時是否仍在直播。
心跳由主服務每分鐘寫入一次 `heartbeats` 表。
觀看數以原生連線為準:原生超過 90 秒沒有樣本時,才由 userscript 回報的「頭號觀眾數」(同時觀看人數)接手,避免兩條來源互相污染。
原生取樣來自 `viewCache`(每 30 秒,需 `fetchRoomInfo` 成功);userscript 也**每 30 秒**固定回報,不再只在收到聊天時才送(否則聊天一安靜就會超過 90 秒門檻)。
頁面狀態列下方會顯示「最近原生/備用取樣距今多久、範圍內幾格有值」,用來判斷是哪一條來源斷了;缺少新鮮觀看取樣時顯示過期提示,不把缺值當成 0 人。
中控台「直播指標」(鑽石數量、觀眾總數、送禮者、新粉絲、獲讚)由 liveCenter userscript 每 30 秒以 `type:'metrics'` 上報。
它們是本場**累計值**,只前進不退,因此記在場次上取最大值;metrics 也能開場次,但沒有觀看樣本的場次不會進觀看排名。
其中「觀眾總數」是**累計不重複**觀眾,不是同時在線,所以不當觀看樣本使用。
「開播時段比較」以場次為單位(避免長場次主宰排名),取場次開始後 30 分鐘(exact/estimated)或 45 分鐘(late/approx)的**時間加權**平均觀看,
依本地(星期 × 小時)分桶;exact/late 開播前 15 分鐘內算下一個整點(19:45–20:44 歸到 20:00),approx/estimated 則前 30 分鐘內算下一個整點(19:30–20:29 歸到 20:00)。
計算中位數、平均、標準差與**收縮估計**(往整體平均拉,k=3),每桶至少 3 場才列入排名。
開播時間優先採用來源明確提供的時間:Twitch 用平台 `startDate`;TikTok 取 `room/info` 的 `start_time`/`create_time`(秒或毫秒皆可);其他平台有回報就用,沒有時以第一次有效觀測作 estimated,仍可列入排名,但會標示開播信心。
長場次只歸在開播起點那一個時段;開播後跨到 21/22/23 點的觀看不計入那些時段的排名,只會出現在上方的星期 × 小時熱圖。
未達標的時段仍會列出「已記錄幾場/幾天」,沒有任何觀看樣本且無開播時間的場次才另外註明原因。
這是你的歷史觀察,不是平台推流規律,也不代表因果。
說明卡片分開顯示範圍內總場次、可用場次、達標時段與各時段進度。場次以來源開播時間歸屬查詢區間;缺開播時間時,以首次觀測時間歸屬。未納入原因依序分為缺少開播時間且無樣本、開播未滿窗口、開場觀測不足,每場只計一次,含只有一筆取樣或只有成效的場次。API 的 `totalSessions` 是總數,`sessionCount` 維持可用場次數,`excluded` 提供分類計數。
「觀看時段(每小時平均)」用 `minutes` 依當地星期/小時彙總,不需要開播時間;只用觀看樣本,不用進房或人數變化推估。長場次跨到幾個小時就貢獻幾個小時。每個日期先算該小時平均(至少 10 分鐘才計入),再跨日期取中位數,避免單一長場次主導。這是觀看時段,不是開播時段;高觀看時段可能是前一小時的觀眾延續,不代表適合開播,開播決策仍看開播時段排名。`minutes` 是平台層級,同平台多主播時可能混入。達 3 天列正式,未達列暫定。
「場次開場比較」以 `actualStarted`(沒有時用 `started`)為 0 分,對齊前 120 分鐘;最多取最近 6 場,每 5 分鐘一個點,曲線為各場、粗線為跨場中位數(至少 3 場時顯示)。開場平均依開播信心取前 30 分(exact/estimated)或 45 分(late/approx);最高分鐘、峰值時間與峰值後低點取前 120 分。圖上點為 5 分鐘平均,表格最高/最低為原始分鐘平均;峰值有圓點,滑鼠移到圖上會顯示十字線與當格各場數值,圖上另標全範圍最高。缺值保持 null,不補 0;只計自己的場次,沒有 own 時才回退全來源並標示。場次需有至少 10 分鐘開場觀測;曲線取自平台層級的 `minutes`,同平台同時有兩個主播的極端情況可能混入。
`GET /api/traffic/history` 另外回傳 `comparison`:`windowMinutes`、`bucketMs`、`slots`、`sessions`、`median`;每場包含 `anchor`、`confidence`、`points`、`openingAverage`、`peak`、`peakMinute`、`observedMinutes`、`ended`。
星期 × 小時的 3 日期門檻需要跨 3 週累積;最近 7 天不能達標,卡片會提示切換最近 30 天。至少 2 個時段達標才顯示排名;只有 1 個達標時段只顯示數據。未達 3 場/3 日期、但已有 2 場/2 日期的時段會以「暫定觀察」顯示,明確標示樣本不足,不進入排名;排名仍需 3 場/3 日期。這些門檻是最低資料量要求,並非統計顯著性或因果判斷。
原生 TikTok 提供 MEMBER(進房)與 CHAT;Twitch/Kick/Odysee/YouTube 的原生聊天也回報為 chat(先前只進熱度/關鍵字統計)。**進房事件只有 TikTok 有**——其他平台沒有「觀眾進房」這種事件,所以那些平台的進房次數與明細本來就會是空的。觀看取樣目前由 TikTok、Twitch 原生提供。其他來源 /chat 可送 eventType: join/chat,platform、transport、userId、msgId、sentAt;userscript 的聊天以 type:'StreamMessage' 送出(沒有 eventType)也計為 chat;觀看數使用 type: audience 與數字 userNum。Unknown 仍計數,測試資料排除,不依留言包含「來了」判斷進房。聊天事件未套用剪輯有效互動過濾。
去重與延遲限制沿用第一階段:事件 ID 24 小時、無 ID 同人同類型文字 10 秒,時間早超過 2 分鐘或超前 10 秒排除。去重資訊也落地,重啟不重算已接收 ID。原生 IPC 背壓時會丟失觀察事件,故不宣稱完整人流。正式剪輯規則維持原樣。
## 正確性修正
開播排名依來源可信度分級:Twitch 用平台 `startDate` 視為 **exact**(30 分鐘窗口、至少 20 分鐘觀測、15 分鐘分桶);TikTok 有平台開播時間但連線通常較晚,列為 **late**(45 分鐘窗口、20 分鐘觀測、15 分鐘分桶);Kick/Odysee/YouTube 有回報開播時間時列為 **approx**(45/20、30 分鐘分桶);完全沒有平台開播時間但有 viewer sample 的場次,以第一次有效觀測作 **estimated**(30/20、30 分鐘分桶,並標記「以連線時刻起算」)。沒有 viewer sample 的 metrics-only 場次才算缺開播時間。場次若先由沒有開播時間的事件開啟,之後收到帶開播時間的取樣會補齊 `actualStarted`。TikTok 的開場觀測可能從開播後幾十秒才開始;請保持程式運行讓它自動接上,之後的取樣無法補足已錯過的開場。排名資格只看資料量(3 場/3 日期/開場觀測 20 分鐘);late/approx/estimated 也能達標,表格會標示開播信心,estimated 以第一次觀測估算、連線越晚誤差越大。沒有開播時間且沒有樣本的場次才排除。舊場次不猜測回填。移除小樣本常態近似 95% 區間。
成效更新與觀看取樣分開記時。遲到事件保留原始與分鐘資料,不回頭改寫場次積分;後來取得的 stream ID 會補入場次,重連可沿用。
清理 API 必須登入、同 Origin 並送 JSON,confirm 只接受布林值;資料刪除包在交易內。只涵蓋部分場次的範圍回傳 409,需擴大到完整場次重新預覽;時間邊界須為整分鐘,避免與分鐘彙總不一致。
## 主播分類與自動開播提醒
人流頁新增「下次開播建議與提醒」,管理 API 為 `/api/traffic/planner`。不要求登入;修改仍要求同來源 JSON。初始關閉,不會自動替使用者啟用或發送測試通知。ntfy 與聊天推播共用目標解析:`NTFY_API` 可填 ntfy.sh 主題名稱(英數、底線、連字號),也可填完整 HTTP(S) 網址(包含主題,自架可帶子路徑);Bark 使用完整網址。前端區分未設定與格式錯誤,不顯示主題或密鑰。儲存失敗會指出通知管道、時區或可開播時間等具體原因。
- 原生五個平台都附帶目前觀測的主播識別(TikTok 連線帳號、Twitch UserID、Kick/Odysee 設定的頻道、YouTube 解析後的 UC 頻道 ID);YouTube 沒有有效 OAuth 時,會改用專用瀏覽器讀取實際觀看頁的聊天 iframe(transport 記為 browser),不影響主播歸屬;以 `broadcasterId` 明確宣告主播,不使用留言者 ID 推測。YouTube 的 `YOUTUBE_CHANNEL_ID` 可以填 `@handle`,但原生連線解析後會改用真正的 `UC...` 頻道 ID;YouTube userscript 會嘗試從頁面自動偵測,抓不到時請填 UC ID。`TikTokChat` userscript 從頁面 `/@帳號/live` 自動帶入主播;`liveCenter` 與 YouTube userscript 未填時維持未分類,不猜測。YouTube userscript 啟用時會鎖定目前的 `v=` 影片;開到別的聊天室會暫停轉送,同一個影片重新整理會自動恢復;可選「自動跟隨此頻道的新直播」,只在頻道 ID 相同且頁面確認直播中時接手。場次及原生/備用來源優先權按平台+主播隔離;舊資料不自動猜測主播,可用批次補分類明確回填。
- 帳號可設定「自己/參考/測試」的預設用途;每場可覆寫或沿用,清除設定即回到「未分類」(它是狀態,不是可指派的用途值)。單場沒有帳號預設也能明確歸屬。分類清單預設只列待分類,可用狀態切換 own/reference/test/全部;每頁 50 場,批次指定使用 `assign`(ids+purpose,purpose 可為 null 清除)。「本頁未分類標為測試」只影響當頁列出的未分類場次,未來新場次不跟著標記。
- 舊場次可用「補舊資料分類」批次補回:依平台、狀態(預設待分類)、最近 N 天與「只含已有觀測資料」篩選,先預覽符合數、平台分布與最近 50 場明細(時長、平均/峰值觀看、聊天、進房、樣本)。預設全選,可取消勾選只套用部分場次;套用時帶 ids 與 expected,資料變動會回 409 要求重新預覽。只更新 `broadcaster` 與 `purpose`,不動觀看數、聊天數、場次時間。主播識別會去空白、去開頭 `@`;YouTube 的 `UC...` 頻道 ID 保留大小寫,其他平台轉小寫。原生事件會把最近一次宣告的主播識別寫入 `traffic_identities`;表單的「帶入 .env/偵測值」優先填入這個原生偵測值,沒有時才用 `.env`(Twitch 依序 `TWITCH_USER_ID`、`TWITCH_USER_NAME`)。Twitch 原生事件使用數字 UserID,建議在 `.env` 設定 `TWITCH_USER_ID`(可從啟動日誌 `[Twitch] UserID` 取得)或先讓原生事件跑一次。每次套用建立批次並記錄前一版,只允許復原最近一批;復原前會展開該批次的場次、目前值與復原後值供確認。API 為 `backfillPreview`/`backfillApply`/`backfillBatch`/`backfillUndo`。
- 頁面上的「開播時段比較」優先只計自己的場次;若尚未標記任何 own,暫以全來源(排除 test)計算並在畫面標示 `scope`。測試場次不納入新建議區;新建議只採自己的場次,Unknown 不推薦。既有分鐘總圖、每日統計仍混合所有來源,因歷史分鐘資料沒有主播維度,不能從場次標記精確扣除。需要實際刪除時使用清理預覽。
- 分析可選最近 30/90/180 天(預設 90),每個平台與主播獨立計算。同一帳號須至少有兩個時段各達 3 場/3 日期、開播滿 30 分鐘且有至少 20 分鐘開場觀測。按收縮平均排列,只在指定星期與整點內推薦;每個日期最多一次、每帳號每週上限可設定 1–7(預設 1)。每週以提醒時區的星期一開始,預排未來 14 天並持續更新。參考主播分開顯示樣本及平均;共同星期/小時只表示曾在該時段開播,不表示同日在線或因果。
- 資料不足(少於 2 個時段達標)時,「下次開播建議」先顯示**資料累積建議**:只取自己的、目前可開播星期/整點內已部分累積的時段,依缺口、最近開播與開場平均排序;這是補資料目標,不是最佳時段推薦。每個時段可選擇「加入提醒」;同一個時段的多個平台會合併成一組、共用一則提醒,但平台資料仍分開計算。也可以從已知時段建立**同一個整點的其他星期**(例如從週二 20:00 建立週一/三/五 20:00);尚無資料的冷啟動目標顯示 0/3,選定後每週在該星期於**開播窗口開始**提醒;正點若尚未觀測到開播,再補一次提醒;已觀測到就跳過。exact/late 的窗口是時段前 15 分鐘,approx/estimated 是前 30 分鐘;每個時段每週最多兩次(窗口+正點 fallback),直到達標或手動移除;達標後自動停用並回到正式推薦。輔助/estimated 時段仍可進入 planner 推薦,但會標示開播信心。同一時段已有正式推薦時不重複發累積提醒;累積提醒不佔正式推薦的每週上限,通知標題為「資料累積提醒」。若很多時段各只有 1 場,卡片會提示先固定 1–2 個時段,而不是假裝已能找到最佳時段。
- 提前 30 分鐘,ntfy/Bark 可同時啟用,沿用 `/config` 網址而不依賴聊天推播開關。同一時間多平台合併成各管道一則訊息。程式每分鐘檢查;最多容許 5 分鐘延遲,超過不補發。同一主播有已知開播時間、未收尾場次且最近 90 秒收到觀看取樣時,標記為正在直播而略過提醒;未連線或取樣過期時無法確認是否已開播。
- 設定、排程與通道發送結果保存於 SQLite。發送前先記錄意圖,失敗或程序中斷造成結果不明時不自動重試,以免重複通知;頁面可查看狀態與略過排程。停用會取消尚未送出的提醒。主服務須保持運行,無需開著網頁或聊天子程序。
部署需重啟主服務與聊天子程序,重新整理頁面。先分類自己的帳號與舊場次,確認通知網址、時區和可開播時間,再啟用。測試使用記憶體資料庫及假的發送器,不接觸真實通知服務:`node --test Test/traffic_planner.test.cjs`。
## 清理預覽分頁(操作)
清理預覽的每日彙總、場次、帳號與所有類型原始事件每類每頁 50 筆,可用上一頁/下一頁查完指定範圍,不再只取前 5 筆。各類均顯示總筆數與本頁範圍;帳號依平台分開,原始事件依時間與 ID 穩定排序。未保存的聊天文字及已超過 7 天清除的事件無法還原。
翻頁與刪除沿用預覽時的固定起訖和平台;變更篩選條件需重新預覽。「最近 N 小時」以完整分鐘為邊界。預覽是即時查詢,不鎖定資料庫快照;持續收到的遲到資料仍可能改變範圍內筆數。