分析 Netflix 官方 “What We Watched” 报告(xlsx 文件,包含每部作品半年的观看时长):自动找到表头,处理空单元格和带
*的标记,算出总时长、全球上线作品占的比例、本期新作占的比例和按 views 排的前 N 名,再检查 views 是否约等于“时长 ÷ 片长”。
先看清成品
本机下载了 2026 年上半年的真实报告(988,392 字节;Shows 和 Movies 两个工作表)。第一版脚本运行时报错
invalid literal for int(): '02*';修复后又发现另一个更隐蔽的列错位问题(见调试示例)。最终结果:剧集 8,194 个条目、743.5 亿小时,电影 9,178 个、233.1 亿小时,两者合计约 976.6 亿小时,和新闻稿里说的 “more than 97B hours” 一致。电影 views 第 1 名是 “War Machine”(146.9M),剧集第 1 名是 “His & Hers: Limited Series”(104.0M),与官方新闻稿和媒体报道一致。pytest 3 个测试通过。
注意:Netflix 没有面向公众的开放 API,本系列只用官方提供的功能和公开数据:账号里的观看记录导出、Tudum 的 Top 10 数据文件,以及 “What We Watched” 报告。不要爬取网站,不要绕过 DRM 或录屏,不要用 VPN 或代理切换地区,不要违反官方的家庭共享规则去共享账号(见“项目资源”里的帮助页面)。套餐价格以官方页面为准,本文不列数字。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
www_report.py |
找到表头、按列位置读取、处理空单元格和星号、生成汇总 |
| 自检 | views 应该约等于“时长 ÷ 片长”,偏差大的条目会被列出来 |
| 防止格式变化 | 表头变了就报错,不会读错列 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Python | 3.9 以上 | pip install openpyxl pytest |
| 报告 | about.netflix.com 每期新闻稿里的 “Download our full report” 链接 | 2027 年起改为每年发布一次(官方新闻稿) |
文件结构
33-netflix-C-src/
├── www_report.py
└── test_www.py # 用 openpyxl 生成一个格式相同的小表来测试
第 1 步:报告结构(本机实测)
| 项目 | 内容 |
|---|---|
| 工作表 | Shows、Movies |
| 表头 | 第 6 行,从 B 列开始:Title、Available Globally?、Release Date、Hours Viewed、Runtime、Views |
| Runtime | h:mm 文本,有 311 个剧集条目带 *(例如 1:02*),表里没有说明星号的含义 |
| 最后几行 | “Other Shows / Other Movies”(观看次数少于 5 万的作品合并在一起,视频播客也算在里面),以及一行脚注 |
第 2 步:脚本
www_report.py:
"""分析 Netflix 官方 “What We Watched” 互动报告(xlsx,about.netflix.com 每期新闻稿里的 Download 链接)。
表结构(2026 年上半年版实测):Shows / Movies 两个工作表,第 6 行是表头:
Title | Available Globally? | Release Date | Hours Viewed | Runtime(h:mm) | Views
用法:python www_report.py 报告.xlsx [前N]"""
import sys
from collections import Counter
import openpyxl
COLS = ["Title", "Available Globally?", "Release Date", "Hours Viewed", "Runtime", "Views"]
def runtime_hours(s):
"""'1:49' → 1.8167;'1:02*' 去掉星号(报告未说明星号含义,只做标记);'*' 或空 → None"""
s = str(s or "").rstrip("*")
if ":" not in s: return None
h, m = s.split(":")
return int(h) + int(m) / 60
def load(path):
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
out = {}
for ws in wb.worksheets:
rows, header = [], None
for r in ws.iter_rows(values_only=True):
if header is None:
if COLS[0] in r: # 找到表头行,记下 Title 所在的列
start = list(r).index(COLS[0]); header = True
if list(r[start:start + 6]) != COLS: raise ValueError(f"{ws.title} 表头变了:{r}")
continue
t, glob, rel, hours, rt, views = (list(r[start:start + 6]) + [None] * 6)[:6] # 按列位置取,不丢空单元格
if t is None or not isinstance(hours, (int, float)): continue # 空行、脚注
rows.append({"title": str(t), "global": glob == "Yes", "release": str(rel or "")[:10],
"hours": int(hours), "runtime_h": runtime_hours(rt),
"views": int(views) if isinstance(views, (int, float)) else None,
"flag": "*" in str(rt or "") or "*" in str(glob or "")})
if header is None: raise ValueError(f"工作表 {ws.title} 没找到表头,报告格式可能变了")
out[ws.title] = rows
return out
def summary(rows, n=10, period_start=None):
total = sum(r["hours"] for r in rows)
top = sorted((r for r in rows if r["views"]), key=lambda r: -r["views"])[:n]
new = [r for r in rows if period_start and r["release"] >= period_start]
bad = [r for r in rows if r["views"] and r["views"] >= 1_000_000 and r["runtime_h"] and abs(r["hours"] / r["runtime_h"] - r["views"]) / r["views"] > 0.05]
return {"titles": len(rows), "hours": total,
"global_share": sum(r["hours"] for r in rows if r["global"]) / total if total else 0,
"new_share": sum(r["hours"] for r in new) / total if total else 0,
"top": top, "views_mismatch": len(bad)}
if __name__ == "__main__":
data = load(sys.argv[1]); n = int(sys.argv[2]) if len(sys.argv) > 2 else 10
for sheet, rows in data.items():
s = summary(rows, n, period_start="2026-01-01")
print(f"## {sheet}:{s['titles']} 个条目,合计 {s['hours'] / 1e8:.1f} 亿小时(带 * 标记 {sum(r['flag'] for r in rows)} 个)")
print(f" 全球上线的条目占观看时长 {s['global_share']:.0%};本期新上线的占 {s['new_share']:.0%};views≥1M 中与“时长÷片长”偏差 >5% 的条目 {s['views_mismatch']} 个")
for i, r in enumerate(s["top"], 1):
print(f" {i:>2}. {r['title']} views {r['views'] / 1e6:.1f}M 时长 {r['hours'] / 1e6:.1f}M 小时")
第 3 步:测试
test_www.py:
import openpyxl, pytest
from www_report import load, summary, runtime_hours
def make(path, rows, header=("Title", "Available Globally?", "Release Date", "Hours Viewed", "Runtime", "Views")):
wb = openpyxl.Workbook(); ws = wb.active; ws.title = "Shows"
ws.append([]); ws.append([None, "Netflix's What We Watched Report"]); ws.append([]); ws.append([None, *header])
for r in rows: ws.append([None, *r])
ws.append([]); ws.append([None, "*footnote"])
wb.save(path)
def test_layout_and_empty_cells(tmp_path):
p = tmp_path / "r.xlsx"
make(p, [("A: Season 1", "Yes", "2026-01-08", 2_000_000, "2:00", 1_000_000),
("B: Season 1", "No", None, 600_000, "1:02*", 300_000), # 没有发布日期、带星号
("Other Shows", "*", "*", 5_000_000, "*", "*")])
rows = load(p)["Shows"]
assert [r["title"] for r in rows] == ["A: Season 1", "B: Season 1", "Other Shows"]
assert rows[1]["release"] == "" and rows[1]["hours"] == 600_000 and rows[1]["flag"]
assert rows[2]["views"] is None and rows[2]["runtime_h"] is None
s = summary(rows, 5, "2026-01-01")
assert [r["title"] for r in s["top"]] == ["A: Season 1", "B: Season 1"] and s["views_mismatch"] == 0
def test_header_changed(tmp_path):
p = tmp_path / "r.xlsx"; make(p, [], header=("Title", "Global", "Date", "Hours", "Runtime", "Views"))
with pytest.raises(ValueError, match="表头变了"): load(p)
def test_runtime():
assert runtime_hours("1:30") == 1.5 and runtime_hours("*") is None and runtime_hours(None) is None
pip install openpyxl pytest
python -m pytest -q # 3 passed
第 4 步:运行
$ python www_report.py "Netflix-s_What_We_Watched_Report_2026Jan-Jun__1_.xlsx" 3
## Shows:8194 个条目,合计 743.5 亿小时(带 * 标记 311 个)
全球上线的条目占观看时长 49%;本期新上线的占 20%;views≥1M 中与“时长÷片长”偏差 >5% 的条目 14 个
1. His & Hers: Limited Series views 104.0M 时长 454.3M 小时
2. Bridgerton: Season 4 views 100.2M 时长 889.8M 小时
3. I Will Find You: Limited Series views 63.9M 时长 350.5M 小时
## Movies:9178 个条目,合计 233.1 亿小时(带 * 标记 1 个)
全球上线的条目占观看时长 40%;本期新上线的占 15%;views≥1M 中与“时长÷片长”偏差 >5% 的条目 0 个
1. War Machine views 146.9M 时长 266.8M 小时
...
“全球上线”的比例把 Other 行算作非全球作品(这一行的这个字段是 *),所以实际比例会略高一些。
调试示例
1. 片长里有星号
ValueError: invalid literal for int() with base 10: '02*'
“BTS THE COMEBACK LIVE…” 的片长是 1:02*,还有一些条目片长只有一个 *。脚本改成先去掉星号再解析,并给这些条目打上 flag 标记。
2. 更隐蔽的列错位
第一版用 [c for c in r if c is not None] 去掉空单元格,再按顺序取 6 个值。“Peppa Pig: Season 1” 没有发布日期,去掉空值后,后面的列整体左移了一格:观看时长被当成了发布日期。改成先记下 Title 所在的列,再按位置取值。测试里专门放了一行没有发布日期的数据。
3. 偏差检查一开始误报了几千条
最初检查所有条目,剧集有 4,027 条偏差超过 5%。仔细看发现,小作品的 views 都是 100,000:报告的数字是四舍五入过的(2023 年的新闻稿说明时长按 10 万取整)。只检查 views ≥ 1M 的条目后,剧集剩 14 条(多为 “Raw: January 5, 2026” 这类直播节目),电影是 0 条。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
没找到表头 / 表头变了 |
新一期报告改了格式 | 打开文件核对表头,再修改 COLS |
| 合计和新闻稿差一点 | 数字经过取整,再加上 Other 行 | 正常现象 |
| 想比较不同作品的受欢迎程度 | 官方建议看每周的 Top 10 | 见 B 篇 |
模板规则
- 读表格要按列位置读,不要因为单元格为空就跳过。
- 不认识的标记保留下来并打上 flag,不要随便丢掉。
- 先用总数和官方新闻稿对一遍,再做细分分析。
- 报告文件从官方链接下载,不要转载分发。
项目资源
| 用途 | 地址 |
|---|---|
| What We Watched(2026 上半年) | https://about.netflix.com/news/what-we-watched-the-first-half-of-2026 |
| 首期报告说明(取整、字段) | https://about.netflix.com/en/news/what-we-watched-a-netflix-engagement-report |
| openpyxl 文档 | https://openpyxl.readthedocs.io/en/stable/ |
| 使用条款 | https://help.netflix.com/legal/termsofuse |
官方报告的数据量很大,但格式上有不少细节,比如空单元格、星号和取整。先用总数和新闻稿核对,再做分析,才能保证每个数字都站得住。