效率工具

分析 Netflix《What We Watched》半年观看报告

分析 Netflix 官方 “What We Watched” 报告(xlsx 文件,包含每部作品半年的观看时长):自动找到表头,处理空单元格和带 的标记,算出总时长、全球上线作品占的比例、本期新作占的比例和按 views 排的前 N 名,再检查 views 是否约等于“时长 ÷ 片长”。

BINBHO · 图文教程

分析 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

官方报告的数据量很大,但格式上有不少细节,比如空单元格、星号和取整。先用总数和新闻稿核对,再做分析,才能保证每个数字都站得住。