現(xiàn)數(shù)據(jù)工程:桌游棋牌研究報(bào)告從采集到PDF自動(dòng)出稿)
簡(jiǎn)介這份《2020年桌游棋牌研究報(bào)告》為PDF電子文檔面向游戲策劃、發(fā)行運(yùn)營(yíng)、市場(chǎng)研究及出海從業(yè)者用于快速了解桌游棋牌類手游的品類格局與投放思路。壓縮包內(nèi)含1個(gè)PDF文件約18.73MB無(wú)需額外工具即可閱讀檢索。報(bào)告以500余款樣本為基礎(chǔ)將玩法歸納為飛行棋、各國(guó)象棋、牌九、卡羅姆、Okey、跳棋、UNO、黑白棋、紙牌、Backgammon、井字棋、Rummikub、Connect Four等16類并附中英文玩法對(duì)照表便于跨地區(qū)產(chǎn)品比對(duì)。內(nèi)容沿發(fā)展歷程、市場(chǎng)現(xiàn)狀、熱門地區(qū)與案例分析四條線索展開(kāi)給出印度、美國(guó)、巴西、土耳其、印尼等地的DAU與IAP分布并對(duì)多款頭部產(chǎn)品的玩法特色、獲客路徑、留存與變現(xiàn)進(jìn)行拆解可供立項(xiàng)調(diào)研、競(jìng)品分析與投放策略參考。目前已有151人學(xué)習(xí)下載。1. 一份《2020年桌游棋牌研究報(bào)告.pdf》背后是一整套數(shù)據(jù)報(bào)告工程先給一個(gè)不太好聽(tīng)的結(jié)論把桌游棋牌研究報(bào)告做成一份 PDF最不值錢的就是 PDF 本身。真正決定這份報(bào)告能不能被引用、能不能被復(fù)核的是數(shù)據(jù)口徑——同一句「2020年桌游棋牌市場(chǎng)規(guī)?!褂秒娚淘u(píng)論數(shù)做代理變量和用線下門店抽樣做代理變量結(jié)論能差出一倍。常見(jiàn)做法是從幾個(gè)公開(kāi)榜單抄一晚上數(shù)據(jù)粘進(jìn) Excel出幾十張圖導(dǎo)成 PDF 發(fā)出去。三個(gè)月后有人問(wèn)「12.7% 是怎么來(lái)的」沒(méi)人答得上來(lái)。短板不在分析能力而在鏈路沒(méi)留痕數(shù)據(jù)從哪抓的、字段怎么定義的、異常值怎么處理的、圖表用的是哪一版數(shù)據(jù)。要解決的是可復(fù)現(xiàn)性把公開(kāi)數(shù)據(jù)采集、清洗、入庫(kù)、算指標(biāo)、出圖、排版成 PDF 串成一條能重跑的流水線任何一個(gè)數(shù)字都能追到原始記錄。適合做行業(yè)研究、投研支持、產(chǎn)品調(diào)研的 IT 從業(yè)者也適合手頭攢了一堆零散榜單、想把它變成可交付報(bào)告的人。下面按采集到交付的順序展開(kāi)代碼都能直接跑。2. 桌游棋牌研究報(bào)告的數(shù)據(jù)底座公開(kāi)榜單采集、清洗與庫(kù)表設(shè)計(jì)2.1 三類數(shù)據(jù)源怎么選先定口徑再定采集做 2020 年這一年的行業(yè)畫(huà)像數(shù)據(jù)源無(wú)非三類評(píng)分與榜單站點(diǎn)、應(yīng)用商店和電商的公開(kāi)榜單、行業(yè)媒體與展會(huì)的公開(kāi)信息。三者的粒度和更新頻率差得很遠(yuǎn)混在一張表里直接算指標(biāo)必然出問(wèn)題所以采集前先把它們分開(kāi)。數(shù)據(jù)源類型典型載體可拿到的字段更新頻率采集方式榜單/評(píng)分站點(diǎn)公開(kāi)的 XML/JSON 接口或頁(yè)面作品名、評(píng)分、排名、評(píng)分人數(shù)、出版年份月度快照接口輪詢 請(qǐng)求節(jié)流應(yīng)用商店/電商公開(kāi)榜公開(kāi)榜單頁(yè)、公開(kāi)評(píng)論總數(shù)品類、評(píng)論量級(jí)、上架時(shí)間、渠道標(biāo)識(shí)周度頁(yè)面解析 重試隊(duì)列行業(yè)媒體與展會(huì)信息年度盤(pán)點(diǎn)文章、展會(huì)名錄品類趨勢(shì)描述、舉辦城市、場(chǎng)次不定期人工結(jié)構(gòu)化錄入線下門店公開(kāi)信息門店名錄頁(yè)城市、門店數(shù)量、主營(yíng)品類季度半自動(dòng)整理選擇順序上我一般會(huì)接口優(yōu)先結(jié)構(gòu)化頁(yè)面次之人工錄入兜底。接口的字段固定、翻頁(yè)可枚舉、能增量頁(yè)面解析的維護(hù)成本隨對(duì)方改版線性上升人工錄入只在前面兩條路都拿不到時(shí)才用并且必須留一列原始出處 URL。提示采集范圍只覆蓋公開(kāi)的休閑娛樂(lè)類桌游棋牌產(chǎn)品信息不涉及任何帶金錢對(duì)賭的玩法字段設(shè)計(jì)上也不留這類入口。2.2 用 Python 把桌游棋牌的原始數(shù)據(jù)抓下來(lái)并歸一化原始層不做任何加工一行一條 JSON落到data/raw/下面按月份分文件。這樣后面無(wú)論換數(shù)據(jù)庫(kù)還是換計(jì)算引擎原始證據(jù)都在。# fetch_rank.py按月份拉取榜單快照落地為 jsonl 原始層 import json, time, hashlib, datetime as dt import requests from requests.adapters import HTTPAdapter SESSION requests.Session() SESSION.mount(https://, HTTPAdapter(pool_maxsize4)) RAW_PATH data/raw/rank_{month}.jsonl HEADERS {User-Agent: boardgame-research-bot/1.0 (contact: dataexample.com)} def fetch_page(page: int, month: str, retry: int 3) - list: url https://example-rank-site/api/v1/ranks params {page: page, limit: 100, snapshot: month} for i in range(retry): try: r SESSION.get(url, paramsparams, headersHEADERS, timeout15) r.raise_for_status() return r.json().get(items, []) except Exception: if i retry - 1: raise time.sleep(2 ** i) # 指數(shù)退避別觸發(fā)對(duì)端限流 return [] def main(month: str): with open(RAW_PATH.format(monthmonth), w, encodingutf-8) as f: for page in range(1, 21): # 20 頁(yè)上限覆蓋約 2000 條 items fetch_page(page, month) if not items: break for it in items: base {k: it.get(k) for k in (id, name, rank, rating, users_rated, year)} it[row_hash] hashlib.md5( # 行級(jí)指紋只算業(yè)務(wù)字段 json.dumps(base, sort_keysTrue, ensure_asciiFalse).encode() ).hexdigest() it[snapshot_month] month # 快照月份同環(huán)比全靠它 f.write(json.dumps(it, ensure_asciiFalse) \n) time.sleep(1.2) # 固定間隔禮貌抓取 if __name__ __main__: main(dt.date.today().strftime(%Y-%m))邏輯說(shuō)明row_hash只對(duì)業(yè)務(wù)字段做摘要如果把snapshot_month也算進(jìn)去同一條數(shù)據(jù)每個(gè)月哈希都不同去重就失效了。參數(shù)說(shuō)明pool_maxsize4控制并發(fā)連接數(shù)抓公開(kāi)站點(diǎn)四路足夠timeout15是單請(qǐng)求上限超時(shí)直接進(jìn)入重試sleep(2 ** i)讓重試間隔按 1、2、4 秒遞增limit100是常見(jiàn)的單頁(yè)上限翻到空列表就跳出避免無(wú)意義請(qǐng)求。2.2.1 名稱歸一化與品類映射榜單數(shù)據(jù)最大的噪聲在名稱「三國(guó)殺標(biāo)準(zhǔn)版」和「三國(guó)殺典藏版」會(huì)被當(dāng)成兩個(gè)作品「UNO牌」和「UNO」也是。歸一化不做好后面所有按品類的統(tǒng)計(jì)都是錯(cuò)的。# normalize.py作品名歸一化 品類映射產(chǎn)物寫(xiě) staging 層 import re, unicodedata ALIAS {uno牌: uno, 狼人殺官方版: 狼人殺} # 別名表人工維護(hù) CATEGORY_RULES [(劇本, 劇本推理), (殺, 身份推理), (棋, 傳統(tǒng)棋類), (牌, 卡牌), (戰(zhàn)棋, 戰(zhàn)棋)] def normalize_name(name: str) - str: s unicodedata.normalize(NFKC, name or ) # 全角/兼容字符統(tǒng)一 s re.sub(r[\(].*?[\)], , s) # 剝離副標(biāo)題與版本括號(hào) s re.sub(r\s, , s).strip().lower() return ALIAS.get(s, s) def map_category(name: str, raw_type: str) - str: if raw_type: # 一級(jí)信任上游字段 return raw_type for kw, cat in CATEGORY_RULES: # 二級(jí)關(guān)鍵詞兜底 if kw in name: return cat return 其他邏輯說(shuō)明NFKC 把全角括號(hào)、羅馬數(shù)字這類兼容字符壓平括號(hào)剝離讓同作品的不同版本合并ALIAS不寫(xiě)死在代碼里長(zhǎng)期膨脹每季度 review 一次導(dǎo)到 CSV 單獨(dú)維護(hù)。參數(shù)說(shuō)明map_category采用兩級(jí)策略上游給了類型就用上游的沒(méi)給才用關(guān)鍵詞猜猜不出來(lái)的統(tǒng)一進(jìn)「其他」并在報(bào)告里注明「其他」占比占比超過(guò) 15% 說(shuō)明規(guī)則該補(bǔ)了。2.3 桌游棋牌研究報(bào)告的星型表結(jié)構(gòu)與索引清洗完落到一張事實(shí)表加三張維度表按月做快照。事實(shí)表主鍵帶上snapshot_month同一作品每月一行同環(huán)比全靠它對(duì)齊。表名類型關(guān)鍵字段說(shuō)明dim_game維度game_id, name_norm, release_year, category_id一個(gè)作品一行名稱存歸一化后的值dim_category維度category_id, category_name, parent_id支持二級(jí)品類dim_channel維度channel_id, channel_name, channel_type線上榜單/線下門店/應(yīng)用商店fact_rank_snapshot事實(shí)game_id, channel_id, snapshot_month, rank, rating, users_rated, row_hash月度粒度-- schema.sqlSQLite 可直接執(zhí)行PostgreSQL 只需調(diào)整分區(qū)語(yǔ)法 CREATE TABLE IF NOT EXISTS dim_game ( game_id INTEGER PRIMARY KEY, name_norm TEXT NOT NULL, release_year INTEGER, category_id INTEGER ); CREATE TABLE IF NOT EXISTS fact_rank_snapshot ( game_id INTEGER NOT NULL, channel_id INTEGER NOT NULL, snapshot_month TEXT NOT NULL, -- YYYY-MM字符串便于切片 rank INTEGER, rating REAL, users_rated INTEGER, row_hash TEXT, PRIMARY KEY (game_id, channel_id, snapshot_month) ); CREATE INDEX IF NOT EXISTS idx_fact_month ON fact_rank_snapshot(snapshot_month); CREATE INDEX IF NOT EXISTS idx_fact_rank ON fact_rank_snapshot(snapshot_month, rank); CREATE UNIQUE INDEX IF NOT EXISTS uk_game_name ON dim_game(name_norm);邏輯說(shuō)明idx_fact_month支撐按月切片這是跑得最頻繁的一類查詢idx_fact_rank是復(fù)合索引WHERE snapshot_month ? ORDER BY rank能直接走索引序dim_game上name_norm的唯一索引是整條清洗鏈路的兜底——重復(fù)抓取會(huì)在入庫(kù)階段直接報(bào)錯(cuò)而不是靜默產(chǎn)生兩份數(shù)據(jù)那才是最難查的坑。數(shù)據(jù)量在百萬(wàn)行以內(nèi)SQLite 完全夠多人協(xié)作或者要接 BI 工具時(shí)換 PostgreSQL把索引語(yǔ)句原樣搬過(guò)去即可。3. 桌游棋牌研究報(bào)告的核心指標(biāo)怎么落地SQL 窗口函數(shù)與 Pandas 雙路復(fù)算3.1 指標(biāo)體系與口徑定義2020 年這類報(bào)告里「市場(chǎng)規(guī)模」基本都是代理變量沒(méi)有哪家能拿到全量真實(shí)營(yíng)收。所以指標(biāo)表里必須把口徑寫(xiě)死報(bào)告腳注再?gòu)?fù)述一遍。指標(biāo)業(yè)務(wù)含義計(jì)算口徑常見(jiàn)誤用CR10 集中度頭部作品占據(jù)的份額當(dāng)月 Top10 的 users_rated 之和 / 當(dāng)月全量之和拿評(píng)分代替人數(shù)頭部被高估品類熱度占比各品類在榜單中的比重按 category_id 分組計(jì)數(shù) / 總計(jì)數(shù)不做名稱歸一化卡牌被拆成十幾類年度新增作品數(shù)供給側(cè)活躍度release_year 2020 的去重作品數(shù)用首次上榜時(shí)間代替出版年份榜單留存率作品的持續(xù)吸引力連續(xù) N 月進(jìn) Top100 的作品數(shù) / 當(dāng)月 Top100快照缺月時(shí)直接算結(jié)果虛高評(píng)論量中位數(shù)用戶關(guān)注度分布users_rated 的 50 分位用均值被極值拉偏城市覆蓋數(shù)線下滲透門店表中 distinct 城市數(shù)與線上榜單數(shù)值直接相加這里有兩個(gè)容易被忽略的點(diǎn)。一是用中位數(shù)而不是均值桌游作品的長(zhǎng)尾極長(zhǎng)個(gè)別頭部作品能把均值拉得完全失去代表性二是線上與線下的口徑不能相加線上是榜單計(jì)數(shù)線下是門店計(jì)數(shù)量綱不同硬加出來(lái)的「總規(guī)?!乖谠u(píng)審時(shí)一問(wèn)就穿幫。3.2 用 SQL 窗口函數(shù)算份額與同比同比用LAG(..., 12)做前提是月份連續(xù)。先把月度聚合做成 CTE再在窗口里取去年同期值。-- metric_monthly.sql品類份額、當(dāng)月排名與同比 WITH monthly AS ( SELECT f.snapshot_month, c.category_name, COUNT(*) AS title_cnt, SUM(f.users_rated) AS rated_sum FROM fact_rank_snapshot f JOIN dim_game g ON g.game_id f.game_id JOIN dim_category c ON c.category_id g.category_id WHERE f.snapshot_month BETWEEN :month_from AND :month_to GROUP BY f.snapshot_month, c.category_name ), with_prev AS ( SELECT snapshot_month, category_name, title_cnt, rated_sum, LAG(rated_sum, 12) OVER ( -- 去年同期 PARTITION BY category_name ORDER BY snapshot_month ) AS rated_sum_ly, ROW_NUMBER() OVER ( -- 當(dāng)月熱度排名 PARTITION BY snapshot_month ORDER BY rated_sum DESC ) AS rn_cur FROM monthly ) SELECT snapshot_month, category_name, title_cnt, rated_sum, rated_sum_ly, ROUND(1.0 * (rated_sum - rated_sum_ly) / NULLIF(rated_sum_ly, 0), 4) AS yoy, rn_cur FROM with_prev WHERE snapshot_month :month ORDER BY rated_sum DESC;邏輯說(shuō)明PARTITION BY category_name ORDER BY snapshot_month保證同品類內(nèi)按月份推進(jìn)LAG(..., 12)取到的才是去年同月而不是上一月。參數(shù)說(shuō)明:month_from和:month_to至少往前推 13 個(gè)月否則第一年的同比全是 NULLNULLIF(rated_sum_ly, 0)防止新品類除零報(bào)錯(cuò)ROW_NUMBER()生成當(dāng)月排名用來(lái)做「新晉 Top10」這類榜單。這套寫(xiě)法在 SQLite 3.25 以上和 PostgreSQL 里都能直接跑。3.3 Pandas 復(fù)算與勾稽校驗(yàn)同一指標(biāo)用兩套獨(dú)立實(shí)現(xiàn)算一遍是發(fā)現(xiàn)口徑漂移最省錢的辦法。注意是「獨(dú)立寫(xiě)」不是把 SQL 復(fù)制成兩遍。# crosscheck.pyPandas 復(fù)算份額與庫(kù)內(nèi)結(jié)果對(duì)拍 import sqlite3 import pandas as pd SQL SELECT c.category_name, COUNT(*) AS title_cnt, SUM(f.users_rated) AS rated_sum FROM fact_rank_snapshot f JOIN dim_game g ON g.game_id f.game_id JOIN dim_category c ON c.category_id g.category_id WHERE f.snapshot_month ? GROUP BY c.category_name def load(month: str) - pd.DataFrame: with sqlite3.connect(boardgame.db) as conn: return pd.read_sql(SQL, conn, params(month,)) def check(month: str) - None: df load(month) total df[rated_sum].sum() assert total 0, f{month} 無(wú)數(shù)據(jù)先查采集日志 df[share_pd] df[rated_sum] / total assert round(df[share_pd].sum(), 6) 1.0, 份額之和不等于 1分母口徑不一致 top10 df.sort_values(share_pd, ascendingFalse).head(10)[share_pd].sum() print(f{month} CR10 {top10:.4f}) print(df.sort_values(share_pd, ascendingFalse) [[category_name, title_cnt, share_pd]].head())邏輯說(shuō)明斷言點(diǎn)選在「份額之和恒等于 1」這種數(shù)學(xué)上必然成立的恒等式上一旦不成立就是分母口徑變了或者混進(jìn)了空品類。參數(shù)說(shuō)明round(..., 6)是給浮點(diǎn)誤差留的余量CR10 用排序后取前 10 求和和庫(kù)里ROW_NUMBER() 10的結(jié)果對(duì)拍差異超過(guò)閾值就停下看是不是缺月。把這段掛到采集任務(wù)后面每天跑一次比出報(bào)告時(shí)才發(fā)現(xiàn)問(wèn)題省事得多。4. 從分析結(jié)果到《2020年桌游棋牌研究報(bào)告.pdf》圖表、排版與自動(dòng)出稿4.1 圖表批量生成中文字體、配色與導(dǎo)出參數(shù)圖表最容易翻車的地方不是配色是中文變方框。服務(wù)器上沒(méi)有中文字體matplotlib會(huì)靜默用默認(rèn)字體渲染出圖看著正常打開(kāi) PDF 全是豆腐塊。# 先確認(rèn)系統(tǒng)里有中文字體沒(méi)有就裝一個(gè)開(kāi)源思源黑體或 Noto Sans CJK fc-list :langzh | head -n 5# charts.py統(tǒng)一字體與導(dǎo)出參數(shù)一次出全套圖 import matplotlib matplotlib.use(Agg) # 無(wú)界面環(huán)境必須指定否則會(huì)掛 import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [Noto Sans CJK SC, Source Han Sans SC] plt.rcParams[axes.unicode_minus] False # 負(fù)號(hào)正常顯示 plt.rcParams[figure.dpi] 150 plt.rcParams[savefig.bbox] tight def barh(df, x, y, out, title): fig, ax plt.subplots(figsize(6.5, 3.6)) # 寬度對(duì)齊 A4 正文欄寬 ax.barh(df[y], df[x], color#3B6EA5) ax.set_title(title, fontsize11) ax.grid(axisx, linestyle:, alpha0.5) fig.savefig(out, formatsvg) # 矢量PDF 內(nèi)縮放不糊 plt.close(fig)邏輯說(shuō)明matplotlib.use(Agg)指定無(wú)界面后端在容器或 CI 里必須加。參數(shù)說(shuō)明font.sans-serif給的是一個(gè)候選列表按順序匹配第一個(gè)不存在就往下找所以可以一次寫(xiě)好幾個(gè)figure.dpi只影響 PNG 位圖導(dǎo)出 SVG 時(shí)關(guān)系不大figsize(6.5, 3.6)是按 A4 去掉 18mm 左右頁(yè)邊距后的正文寬度估的圖表寬度和正文對(duì)齊排版時(shí)才不會(huì)一寬一窄savefig.bboxtight裁掉多余留白否則圖與題注之間的空隙會(huì)很大。4.2 HTML 轉(zhuǎn) PDF 的兩條路線與關(guān)鍵參數(shù)排版路線主要有三條選哪條取決于文檔復(fù)雜度和團(tuán)隊(duì)熟悉度。路線依賴適合場(chǎng)景主要坑WeasyPrintPython Pango結(jié)構(gòu)化長(zhǎng)文檔、要目錄與頁(yè)碼對(duì)復(fù)雜 Grid 布局支持有限Chromium 無(wú)頭打印Playwright / Chrome圖表多、CSS 復(fù)雜頁(yè)眉頁(yè)腳要用參數(shù)單獨(dú)傳LaTeXTeX Live公式多、排版精度要求高模板調(diào)試成本高中文字體配置麻煩行業(yè)研究報(bào)告這類文檔結(jié)構(gòu)規(guī)整章、節(jié)、表格、圖我一般選 WeasyPrintPython 生態(tài)內(nèi)一條命令出稿目錄頁(yè)碼有原生支持。# render_pdf.py把 Jinja2 渲染好的 HTML 打成交付 PDF from weasyprint import HTML, CSS HTML(filenamebuild/report.html).write_pdf( dist/2020年桌游棋牌研究報(bào)告.pdf, stylesheets[CSS(filenameassets/print.css)], presentational_hintsTrue, # 尊重 HTML 里的 width/align 等表現(xiàn)屬性 )/* print.cssA4 版面、頁(yè)眉頁(yè)腳與表格分頁(yè) */ page { size: A4; margin: 20mm 18mm 18mm 18mm; top-center { content: 2020年桌游棋牌研究報(bào)告; font-size: 9pt; color: #666; } bottom-right { content: counter(page) / counter(pages); font-size: 9pt; } } table { width: 100%; border-collapse: collapse; font-size: 9.5pt; } tr, img, figure { break-inside: avoid; } /* 關(guān)鍵別讓表格行被切到下一頁(yè) */ h2 { break-before: page; } /* 每章另起一頁(yè) */邏輯說(shuō)明counter(page)和counter(pages)由排版引擎在生成階段解析不需要自己數(shù)頁(yè)。參數(shù)說(shuō)明margin上下左右分開(kāi)寫(xiě)是因?yàn)轫敳恳繇?yè)眉空間通常比底部大 2mmbreak-inside: avoid加在tr上是表格跨頁(yè)最實(shí)用的一招不然一張 4 行的表經(jīng)常被切成 22break-before: page讓每個(gè)一級(jí)章節(jié)獨(dú)立起頁(yè)配合目錄頁(yè)碼才整齊。走 Chromium 路線時(shí)page的頁(yè)眉頁(yè)腳支持不完整得改用page.pdf(header_template...)傳 HTML 片段。4.3 目錄頁(yè)碼與圖表索引不要手寫(xiě)目錄頁(yè)碼是研究報(bào)告里最容易出錯(cuò)的細(xì)節(jié)正文改一段頁(yè)碼全錯(cuò)。CSS 里有原生方案讓排版引擎自己填。/* 目錄頁(yè)碼由 target-counter 自動(dòng)生成禁止手寫(xiě)數(shù)字 */ .toc a::after { content: target-counter(attr(href), page); float: right; } .toc a { text-decoration: none; color: #000; }邏輯說(shuō)明attr(href)取到目錄項(xiàng)的錨點(diǎn)target-counter(..., page)反查該錨點(diǎn)落在第幾頁(yè)。參數(shù)說(shuō)明要求每個(gè)章節(jié)標(biāo)題都有穩(wěn)定的id生成 HTML 時(shí)從同一份大綱數(shù)據(jù)同時(shí)渲染目錄和正文兩邊就不會(huì)漂移圖表索引同理把figure的id和題注從同一份數(shù)據(jù)渲染出來(lái)。這套做法的前提是出稿流程里沒(méi)有人工改 HTML 的環(huán)節(jié)——只要有人手動(dòng)改了正文就得重新跑一遍渲染別直接改產(chǎn)物。5. 讓這份桌游棋牌研究報(bào)告每次都能重跑口徑字典、快照校驗(yàn)與產(chǎn)物命名5.1 口徑字典版本化別讓口徑散在 SQL 里規(guī)模、集中度、份額這些指標(biāo)的定義一旦散落在十幾個(gè) SQL 文件里半年后沒(méi)人說(shuō)得清哪個(gè)是最終口徑。做法是把口徑抽成一份 YAMLSQL 從它生成。# dict/metrics.yaml version: 2020.4 metrics: cr10: desc: 當(dāng)月 Top10 作品評(píng)分人數(shù)合計(jì)占全量比重 source: fact_rank_snapshot filter: snapshot_month :month and channel_id 1 denominator: sum(users_rated) category_share: desc: 品類評(píng)分人數(shù)份額 group_by: dim_category.category_name denominator: sum(users_rated)邏輯說(shuō)明version在每次口徑變更時(shí)遞增產(chǎn)物文件名里帶上文件內(nèi)容的哈希前綴報(bào)告尾頁(yè)把版本號(hào)打出來(lái)。讀者對(duì)不上數(shù)的時(shí)候能直接說(shuō)清是哪一版口徑算的而不是從頭吵。# 產(chǎn)物命名帶上口徑哈希與運(yùn)行時(shí)間避免最終版v3式命名 DICT_HASH$(md5sum dict/metrics.yaml | cut -c1-8) STAMP$(date %Y%m%dT%H%M) cp dist/report.pdf dist/2020桌游棋牌研究報(bào)告_${STAMP}_${DICT_HASH}.pdf echo 口徑版本 ${DICT_HASH}產(chǎn)物已歸檔參數(shù)說(shuō)明cut -c1-8取哈希前 8 位夠用又不至于文件名過(guò)長(zhǎng)時(shí)間戳用 ISO 緊湊格式按文件名排序就是時(shí)間順序。這套命名在歸檔目錄里一眼能看出哪幾份報(bào)告用的是同一版口徑。5.2 勾稽校驗(yàn)清單與缺月補(bǔ)齊校驗(yàn)項(xiàng)期望值失敗時(shí)先看哪快照月份完整性2020-01 到 2020-12 共 12 個(gè)采集日志里的 4xx 與超時(shí)記錄份額之和每次切片加總 1分母是否漏了「其他」品類榜單條數(shù)每月 100 條分頁(yè)上限是否被截?cái)嗝Q唯一性dim_game 無(wú)重復(fù) name_norm別名表是否漏了新版本圖表與數(shù)據(jù)一致圖形值等于查詢結(jié)果出圖腳本是否讀了緩存 CSV最后說(shuō)一個(gè)幾乎每份年度報(bào)告都會(huì)踩的坑LAG(..., 12)依賴月份連續(xù)2020 年某個(gè)月采集失敗同比就會(huì)錯(cuò)位到別的月份上去而且不會(huì)報(bào)錯(cuò)只是數(shù)字變小。穩(wěn)妥的做法是建一張日歷序列左連接補(bǔ)月讓缺口顯式暴露出來(lái)。-- SQLite 語(yǔ)法PostgreSQL 換成 generate_series(2020-01-01,2020-12-01,1 month) WITH RECURSIVE cal(m) AS ( SELECT 2020-01 UNION ALL SELECT strftime(%Y-%m, date(m || -01, 1 month)) FROM cal WHERE m 2020-12 ) SELECT cal.m, COUNT(f.game_id) AS title_cnt FROM cal LEFT JOIN fact_rank_snapshot f ON f.snapshot_month cal.m GROUP BY cal.m ORDER BY cal.m;邏輯說(shuō)明遞歸 CTE 生成 12 個(gè)月的完整序列左連接后沒(méi)有數(shù)據(jù)的月份title_cnt 0會(huì)顯式出現(xiàn)而不是從結(jié)果集里消失。參數(shù)說(shuō)明遞歸終止條件WHERE m 2020-12要寫(xiě)成小于上界寫(xiě)成會(huì)多算一個(gè)月strftime是 SQLite 的函數(shù)換 PostgreSQL 用generate_series一行就能替代。補(bǔ)出來(lái)之后title_cnt 0的那一行就是采集缺口順著snapshot_month去data/raw/目錄里找對(duì)應(yīng)的 jsonl 文件比在 Excel 里對(duì)半天快得多。本文還有配套的精品資源點(diǎn)擊獲取