简介:这份《2020年桌游棋牌研究报告》为PDF电子文档,面向游戏策划、发行运营、市场研究及出海从业者,用于快速了解桌游棋牌类手游的品类格局与投放思路。压缩包内含1个PDF文件,约18.73MB,无需额外工具即可阅读检索。报告以500余款样本为基础,将玩法归纳为飞行棋、各国象棋、牌九、卡罗姆、Okey、跳棋、UNO、黑白棋、纸牌、Backgammon、井字棋、Rummikub、Connect Four等16类,并附中英文玩法对照表,便于跨地区产品比对。内容沿发展历程、市场现状、热门地区与案例分析四条线索展开,给出印度、美国、巴西、土耳其、印尼等地的DAU与IAP分布,并对多款头部产品的玩法特色、获客路径、留存与变现进行拆解,可供立项调研、竞品分析与投放策略参考。目前已有151人学习下载。
1. 一份《2020年桌游棋牌研究报告.pdf》背后,是一整套数据报告工程
先给一个不太好听的结论:把桌游棋牌研究报告做成一份 PDF,最不值钱的就是 PDF 本身。真正决定这份报告能不能被引用、能不能被复核的是数据口径——同一句「2020年桌游棋牌市场规模」,用电商评论数做代理变量和用线下门店抽样做代理变量,结论能差出一倍。
常见做法是从几个公开榜单抄一晚上数据,粘进 Excel,出几十张图导成 PDF 发出去。三个月后有人问「12.7% 是怎么来的」,没人答得上来。短板不在分析能力,而在链路没留痕:数据从哪抓的、字段怎么定义的、异常值怎么处理的、图表用的是哪一版数据。
要解决的是可复现性:把公开数据采集、清洗、入库、算指标、出图、排版成 PDF 串成一条能重跑的流水线,任何一个数字都能追到原始记录。适合做行业研究、投研支持、产品调研的 IT 从业者,也适合手头攒了一堆零散榜单、想把它变成可交付报告的人。下面按采集到交付的顺序展开,代码都能直接跑。
2. 桌游棋牌研究报告的数据底座:公开榜单采集、清洗与库表设计
2.1 三类数据源怎么选,先定口径再定采集
做 2020 年这一年的行业画像,数据源无非三类:评分与榜单站点、应用商店和电商的公开榜单、行业媒体与展会的公开信息。三者的粒度和更新频率差得很远,混在一张表里直接算指标必然出问题,所以采集前先把它们分开。
| 数据源类型 | 典型载体 | 可拿到的字段 | 更新频率 | 采集方式 |
|---|---|---|---|---|
| 榜单/评分站点 | 公开的 XML/JSON 接口或页面 | 作品名、评分、排名、评分人数、出版年份 | 月度快照 | 接口轮询 + 请求节流 |
| 应用商店/电商公开榜 | 公开榜单页、公开评论总数 | 品类、评论量级、上架时间、渠道标识 | 周度 | 页面解析 + 重试队列 |
| 行业媒体与展会信息 | 年度盘点文章、展会名录 | 品类趋势描述、举办城市、场次 | 不定期 | 人工结构化录入 |
| 线下门店公开信息 | 门店名录页 | 城市、门店数量、主营品类 | 季度 | 半自动整理 |
选择顺序上,我一般会:接口优先,结构化页面次之,人工录入兜底。接口的字段固定、翻页可枚举、能增量;页面解析的维护成本随对方改版线性上升;人工录入只在前面两条路都拿不到时才用,并且必须留一列原始出处 URL。
提示:采集范围只覆盖公开的休闲娱乐类桌游棋牌产品信息,不涉及任何带金钱对赌的玩法,字段设计上也不留这类入口。
2.2 用 Python 把桌游棋牌的原始数据抓下来并归一化
原始层不做任何加工,一行一条 JSON,落到data/raw/下面按月份分文件。这样后面无论换数据库还是换计算引擎,原始证据都在。
# 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_maxsize=4)) RAW_PATH = "data/raw/rank_{month}.jsonl" HEADERS = {"User-Agent": "boardgame-research-bot/1.0 (contact: data@example.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, params=params, headers=HEADERS, timeout=15) r.raise_for_status() return r.json().get("items", []) except Exception: if i == retry - 1: raise time.sleep(2 ** i) # 指数退避,别触发对端限流 return [] def main(month: str): with open(RAW_PATH.format(month=month), "w", encoding="utf-8") as f: for page in range(1, 21): # 20 页上限,覆盖约 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( # 行级指纹,只算业务字段 json.dumps(base, sort_keys=True, ensure_ascii=False).encode() ).hexdigest() it["snapshot_month"] = month # 快照月份,同环比全靠它 f.write(json.dumps(it, ensure_ascii=False) + "\n") time.sleep(1.2) # 固定间隔,礼貌抓取 if __name__ == "__main__": main(dt.date.today().strftime("%Y-%m"))逻辑说明:row_hash只对业务字段做摘要,如果把snapshot_month也算进去,同一条数据每个月哈希都不同,去重就失效了。参数说明:pool_maxsize=4控制并发连接数,抓公开站点四路足够;timeout=15是单请求上限,超时直接进入重试;sleep(2 ** i)让重试间隔按 1、2、4 秒递增;limit=100是常见的单页上限,翻到空列表就跳出,避免无意义请求。
2.2.1 名称归一化与品类映射
榜单数据最大的噪声在名称:「三国杀(标准版)」和「三国杀(典藏版)」会被当成两个作品,「UNO牌」和「UNO」也是。归一化不做好,后面所有按品类的统计都是错的。
# normalize.py:作品名归一化 + 品类映射,产物写 staging 层 import re, unicodedata ALIAS = {"uno牌": "uno", "狼人杀(官方版)": "狼人杀"} # 别名表,人工维护 CATEGORY_RULES = [("剧本", "剧本推理"), ("杀", "身份推理"), ("棋", "传统棋类"), ("牌", "卡牌"), ("战棋", "战棋")] def normalize_name(name: str) -> str: s = unicodedata.normalize("NFKC", name or "") # 全角/兼容字符统一 s = re.sub(r"[\((].*?[\))]", "", s) # 剥离副标题与版本括号 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: # 一级:信任上游字段 return raw_type for kw, cat in CATEGORY_RULES: # 二级:关键词兜底 if kw in name: return cat return "其他"逻辑说明:NFKC 把全角括号、罗马数字这类兼容字符压平;括号剥离让同作品的不同版本合并;ALIAS不写死在代码里长期膨胀,每季度 review 一次导到 CSV 单独维护。参数说明:map_category采用两级策略,上游给了类型就用上游的,没给才用关键词猜,猜不出来的统一进「其他」,并在报告里注明「其他」占比,占比超过 15% 说明规则该补了。
2.3 桌游棋牌研究报告的星型表结构与索引
清洗完落到一张事实表加三张维度表,按月做快照。事实表主键带上snapshot_month,同一作品每月一行,同环比全靠它对齐。
| 表名 | 类型 | 关键字段 | 说明 |
|---|---|---|---|
| dim_game | 维度 | game_id, name_norm, release_year, category_id | 一个作品一行,名称存归一化后的值 |
| dim_category | 维度 | category_id, category_name, parent_id | 支持二级品类 |
| dim_channel | 维度 | channel_id, channel_name, channel_type | 线上榜单/线下门店/应用商店 |
| fact_rank_snapshot | 事实 | game_id, channel_id, snapshot_month, rank, rating, users_rated, row_hash | 月度粒度 |
-- schema.sql:SQLite 可直接执行,PostgreSQL 只需调整分区语法 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);逻辑说明:idx_fact_month支撑按月切片,这是跑得最频繁的一类查询;idx_fact_rank是复合索引,WHERE snapshot_month = ? ORDER BY rank能直接走索引序;dim_game上name_norm的唯一索引是整条清洗链路的兜底——重复抓取会在入库阶段直接报错,而不是静默产生两份数据,那才是最难查的坑。数据量在百万行以内,SQLite 完全够;多人协作或者要接 BI 工具时换 PostgreSQL,把索引语句原样搬过去即可。
3. 桌游棋牌研究报告的核心指标怎么落地:SQL 窗口函数与 Pandas 双路复算
3.1 指标体系与口径定义
2020 年这类报告里,「市场规模」基本都是代理变量,没有哪家能拿到全量真实营收。所以指标表里必须把口径写死,报告脚注再复述一遍。
| 指标 | 业务含义 | 计算口径 | 常见误用 |
|---|---|---|---|
| CR10 集中度 | 头部作品占据的份额 | 当月 Top10 的 users_rated 之和 / 当月全量之和 | 拿评分代替人数,头部被高估 |
| 品类热度占比 | 各品类在榜单中的比重 | 按 category_id 分组计数 / 总计数 | 不做名称归一化,卡牌被拆成十几类 |
| 年度新增作品数 | 供给侧活跃度 | release_year = 2020 的去重作品数 | 用首次上榜时间代替出版年份 |
| 榜单留存率 | 作品的持续吸引力 | 连续 N 月进 Top100 的作品数 / 当月 Top100 | 快照缺月时直接算,结果虚高 |
| 评论量中位数 | 用户关注度分布 | users_rated 的 50 分位 | 用均值,被极值拉偏 |
| 城市覆盖数 | 线下渗透 | 门店表中 distinct 城市数 | 与线上榜单数值直接相加 |
这里有两个容易被忽略的点。一是用中位数而不是均值,桌游作品的长尾极长,个别头部作品能把均值拉得完全失去代表性;二是线上与线下的口径不能相加,线上是榜单计数,线下是门店计数,量纲不同,硬加出来的「总规模」在评审时一问就穿帮。
3.2 用 SQL 窗口函数算份额与同比
同比用LAG(..., 12)做,前提是月份连续。先把月度聚合做成 CTE,再在窗口里取去年同期值。
-- metric_monthly.sql:品类份额、当月排名与同比 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 ( -- 当月热度排名 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;逻辑说明:PARTITION BY category_name ORDER BY snapshot_month保证同品类内按月份推进,LAG(..., 12)取到的才是去年同月而不是上一月。参数说明::month_from和:month_to至少往前推 13 个月,否则第一年的同比全是 NULL;NULLIF(rated_sum_ly, 0)防止新品类除零报错;ROW_NUMBER()生成当月排名,用来做「新晋 Top10」这类榜单。这套写法在 SQLite 3.25 以上和 PostgreSQL 里都能直接跑。
3.3 Pandas 复算与勾稽校验
同一指标用两套独立实现算一遍,是发现口径漂移最省钱的办法。注意是「独立写」,不是把 SQL 复制成两遍。
# crosscheck.py:Pandas 复算份额,与库内结果对拍 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} 无数据,先查采集日志" df["share_pd"] = df["rated_sum"] / total assert round(df["share_pd"].sum(), 6) == 1.0, "份额之和不等于 1,分母口径不一致" top10 = df.sort_values("share_pd", ascending=False).head(10)["share_pd"].sum() print(f"{month} CR10 = {top10:.4f}") print(df.sort_values("share_pd", ascending=False) [["category_name", "title_cnt", "share_pd"]].head())逻辑说明:断言点选在「份额之和恒等于 1」这种数学上必然成立的恒等式上,一旦不成立就是分母口径变了或者混进了空品类。参数说明:round(..., 6)是给浮点误差留的余量;CR10 用排序后取前 10 求和,和库里ROW_NUMBER() <= 10的结果对拍,差异超过阈值就停下看是不是缺月。把这段挂到采集任务后面,每天跑一次,比出报告时才发现问题省事得多。
4. 从分析结果到《2020年桌游棋牌研究报告.pdf》:图表、排版与自动出稿
4.1 图表批量生成:中文字体、配色与导出参数
图表最容易翻车的地方不是配色,是中文变方框。服务器上没有中文字体,matplotlib会静默用默认字体渲染,出图看着正常,打开 PDF 全是豆腐块。
# 先确认系统里有中文字体,没有就装一个开源思源黑体或 Noto Sans CJK fc-list :lang=zh | head -n 5# charts.py:统一字体与导出参数,一次出全套图 import matplotlib matplotlib.use("Agg") # 无界面环境必须指定,否则会挂 import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["Noto Sans CJK SC", "Source Han Sans SC"] plt.rcParams["axes.unicode_minus"] = False # 负号正常显示 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)) # 宽度对齐 A4 正文栏宽 ax.barh(df[y], df[x], color="#3B6EA5") ax.set_title(title, fontsize=11) ax.grid(axis="x", linestyle=":", alpha=0.5) fig.savefig(out, format="svg") # 矢量,PDF 内缩放不糊 plt.close(fig)逻辑说明:matplotlib.use("Agg")指定无界面后端,在容器或 CI 里必须加。参数说明:font.sans-serif给的是一个候选列表,按顺序匹配,第一个不存在就往下找,所以可以一次写好几个;figure.dpi只影响 PNG 位图,导出 SVG 时关系不大;figsize=(6.5, 3.6)是按 A4 去掉 18mm 左右页边距后的正文宽度估的,图表宽度和正文对齐,排版时才不会一宽一窄;savefig.bbox="tight"裁掉多余留白,否则图与题注之间的空隙会很大。
4.2 HTML 转 PDF 的两条路线与关键参数
排版路线主要有三条,选哪条取决于文档复杂度和团队熟悉度。
| 路线 | 依赖 | 适合场景 | 主要坑 |
|---|---|---|---|
| WeasyPrint | Python + Pango | 结构化长文档、要目录与页码 | 对复杂 Grid 布局支持有限 |
| Chromium 无头打印 | Playwright / Chrome | 图表多、CSS 复杂 | 页眉页脚要用参数单独传 |
| LaTeX | TeX Live | 公式多、排版精度要求高 | 模板调试成本高,中文字体配置麻烦 |
行业研究报告这类文档结构规整(章、节、表格、图),我一般选 WeasyPrint:Python 生态内一条命令出稿,目录页码有原生支持。
# render_pdf.py:把 Jinja2 渲染好的 HTML 打成交付 PDF from weasyprint import HTML, CSS HTML(filename="build/report.html").write_pdf( "dist/2020年桌游棋牌研究报告.pdf", stylesheets=[CSS(filename="assets/print.css")], presentational_hints=True, # 尊重 HTML 里的 width/align 等表现属性 )/* print.css:A4 版面、页眉页脚与表格分页 */ @page { size: A4; margin: 20mm 18mm 18mm 18mm; @top-center { content: "2020年桌游棋牌研究报告"; 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; } /* 关键:别让表格行被切到下一页 */ h2 { break-before: page; } /* 每章另起一页 */逻辑说明:counter(page)和counter(pages)由排版引擎在生成阶段解析,不需要自己数页。参数说明:margin上下左右分开写,是因为顶部要留页眉空间,通常比底部大 2mm;break-inside: avoid加在tr上是表格跨页最实用的一招,不然一张 4 行的表经常被切成 2+2;break-before: page让每个一级章节独立起页,配合目录页码才整齐。走 Chromium 路线时@page的页眉页脚支持不完整,得改用page.pdf(header_template=...)传 HTML 片段。
4.3 目录页码与图表索引不要手写
目录页码是研究报告里最容易出错的细节:正文改一段,页码全错。CSS 里有原生方案,让排版引擎自己填。
/* 目录页码由 target-counter 自动生成,禁止手写数字 */ .toc a::after { content: target-counter(attr(href), page); float: right; } .toc a { text-decoration: none; color: #000; }逻辑说明:attr(href)取到目录项的锚点,target-counter(..., page)反查该锚点落在第几页。参数说明:要求每个章节标题都有稳定的id,生成 HTML 时从同一份大纲数据同时渲染目录和正文,两边就不会漂移;图表索引同理,把figure的id和题注从同一份数据渲染出来。这套做法的前提是出稿流程里没有人工改 HTML 的环节——只要有人手动改了正文,就得重新跑一遍渲染,别直接改产物。
5. 让这份桌游棋牌研究报告每次都能重跑:口径字典、快照校验与产物命名
5.1 口径字典版本化,别让口径散在 SQL 里
规模、集中度、份额这些指标的定义一旦散落在十几个 SQL 文件里,半年后没人说得清哪个是最终口径。做法是把口径抽成一份 YAML,SQL 从它生成。
# dict/metrics.yaml version: 2020.4 metrics: cr10: desc: 当月 Top10 作品评分人数合计占全量比重 source: fact_rank_snapshot filter: "snapshot_month = :month and channel_id = 1" denominator: sum(users_rated) category_share: desc: 品类评分人数份额 group_by: dim_category.category_name denominator: sum(users_rated)逻辑说明:version在每次口径变更时递增,产物文件名里带上文件内容的哈希前缀,报告尾页把版本号打出来。读者对不上数的时候,能直接说清是哪一版口径算的,而不是从头吵。
# 产物命名带上口径哈希与运行时间,避免"最终版v3"式命名 DICT_HASH=$(md5sum dict/metrics.yaml | cut -c1-8) STAMP=$(date +%Y%m%dT%H%M) cp dist/report.pdf "dist/2020桌游棋牌研究报告_${STAMP}_${DICT_HASH}.pdf" echo "口径版本 ${DICT_HASH},产物已归档"参数说明:cut -c1-8取哈希前 8 位,够用又不至于文件名过长;时间戳用 ISO 紧凑格式,按文件名排序就是时间顺序。这套命名在归档目录里一眼能看出哪几份报告用的是同一版口径。
5.2 勾稽校验清单与缺月补齐
| 校验项 | 期望值 | 失败时先看哪 |
|---|---|---|
| 快照月份完整性 | 2020-01 到 2020-12 共 12 个 | 采集日志里的 4xx 与超时记录 |
| 份额之和 | 每次切片加总 = 1 | 分母是否漏了「其他」品类 |
| 榜单条数 | 每月 100 条 | 分页上限是否被截断 |
| 名称唯一性 | dim_game 无重复 name_norm | 别名表是否漏了新版本 |
| 图表与数据一致 | 图形值等于查询结果 | 出图脚本是否读了缓存 CSV |
最后说一个几乎每份年度报告都会踩的坑:LAG(..., 12)依赖月份连续,2020 年某个月采集失败,同比就会错位到别的月份上去,而且不会报错,只是数字变小。稳妥的做法是建一张日历序列左连接补月,让缺口显式暴露出来。
-- SQLite 语法;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;逻辑说明:递归 CTE 生成 12 个月的完整序列,左连接后没有数据的月份title_cnt = 0会显式出现,而不是从结果集里消失。参数说明:递归终止条件WHERE m < '2020-12'要写成小于上界,写成<=会多算一个月;strftime是 SQLite 的函数,换 PostgreSQL 用generate_series一行就能替代。补出来之后,title_cnt = 0的那一行就是采集缺口,顺着snapshot_month去data/raw/目录里找对应的 jsonl 文件,比在 Excel 里对半天快得多。
本文还有配套的精品资源,点击获取