相信很多长期关注“术力口”文化圈的朋友,每个月都有类似的习惯:等着看一批播放量数据接近“神话级”的曲目更新。与其说这是单纯的音乐盘点,不如说它是一套典型的“数据指标监控”需求——把作品的播放量按固定周期拉成快照,再计算增量、距离阈值缺口和排名变化。手动整理一次两次还能接受,但如果做成月刊,每个月都靠人工复制粘贴,既容易漏歌,也容易在“十万”、“百万”、“千万”这些数量级上写错。所以就有了本篇的设想:用 Python 实现一个“虚拟的术力口准神话曲月刊系统”,只要给定本期与上期的播放量快照数据,就能自动生成准神话候选、增量榜和可视化图片。本文不讨论任何平台数据的非法抓取方案,只围绕本地快照做分级统计与月报生成,帮你把这类“周期性榜单监控”的思路沉淀成可复用代码。
1. 背景与核心概念
1.1 什么是“术力口”和“准神话曲”
“术力口”是中文 VOCALOID 文化社区里使用频率很高的代称,通常泛指使用歌声合成软件创作歌曲的作者与听众圈子。歌曲被投递到弹幕视频平台后,平台会记录累计播放量或再生数,社区玩家为了方便交流,习惯把这些播放量划出几个等级。比较常见的说法是:达到十万播放称为“殿堂曲”,达到一百万播放称为“传说曲”,达到一千万播放称为“神话曲”。
那“准神话曲”是什么?从字面理解,就是播放量已经非常高、但还没有正式跨过“神话曲”门槛的作品。它并不是一个官方产品字段,而是社区在月度榜单或专题盘点时设计出来的“业务概念”。在一个数据分析系统里,这类概念必须被转成可执行的规则,比如“当前播放量在一百万到一千万之间,并且距离一千万阈值的缺口小于某个百分比”,或者“本月增量在所有传说曲中排名靠前”。本文采用“已完成神话曲阈值的 95% 以上且尚未达到一千万”的判断口径,并在后续代码中开放成配置项,方便自行修改。
1.2 为什么要用“月报系统”代替人工整理
当我们只是临时想知道“哪些歌有望冲击下一首神话曲”时,人工在页面上排序当然可行。但月刊的难点在于周期性、可比较、可追溯。每个月如果只看绝对播放量,很难发现歌曲是否增长放缓;只有把本期快照与上期快照做差,才能知道一首歌在过去一个月实际涨了多少。再者,统计口径不能每次都不一样,例如“上月纳入的歌这个月如果被删除或下架,算不算入总榜”“本月新增歌曲没有上月值时怎么办”都必须由代码统一处理。
因此,月报系统的核心价值并不在于“生成一张好看的表格”,而在于把统计规则稳定下来。哪怕未来数据量从几十首扩展到几千首,也不需要重新研发算法;只要保证输入文件格式一致,脚本就能继续输出工作报告。这正是很多数据处理任务适合自动化的原因:不是计算有多难,而是重复劳动会引入人为误差。
1.3 数据边界与合规说明
在做这类榜单统计时,最容易踩到的坑不是代码逻辑,而是数据来源。如果某个平台的用户协议或服务条款没有开放批量数据接口,个人去写爬虫抓取大量页面并不合适,既可能违反规则,也可能给平台带来压力。本文之所以强调“本地快照 CSV”,就是为了把代码逻辑和来源合规性分开。你可以通过官方导出、人工记录或企业内部授权的数据仓库来生成快照文件,拿到文件后,使用下面的统计脚本做分析就是完全可控的。 这样操作既保证项目可以演示,也避免把技术教程写成爬虫教程。
2. 环境准备与项目结构
2.1 Python 运行环境
本项目的代码主要依赖 Python 的数据处理生态。建议使用 Python 3.10 及以上版本,因为脚本中会用到较新的类型提示写法。需要安装的第三方库包括:pandas、openpyxl、matplotlib。pandas负责读取 CSV 和完成表合并、分组、排序;openpyxl用于把数据写进带多个 Sheet 的 Excel 文件;matplotlib负责绘制月度增量图和距离神话曲缺口图。
如果你的电脑还没有安装这些库,可以在终端中执行:
pip install pandas openpyxl matplotlib版本不需要完全固定,建议使用当前 PyPI 上的稳定版本。需要注意,pandas和openpyxl都经常更新,某些老的 API 可能有废弃警告,但本文使用的核心方法非常稳定,不会依赖实验性功能。
2.2 项目文件规划
为了让读者可以清楚地看到每个模块的职责,建议采用下面的工程结构:
voca_monthly/ ├── config.py # 阈值、目录、期数等配置 ├── make_demo_data.py # 生成本地演示快照 ├── snapshot_loader.py # 读取并清洗快照 CSV ├── report_builder.py # 计算月度指标与准神话候选 ├── excel_exporter.py # 输出 Excel 月报 ├── chart_exporter.py # 输出可视化图片 ├── main.py # 统一入口 ├── requirements.txt # 依赖清单 ├── data/ # 输入快照 └── output/ # 输出结果这里每一个 Python 文件职责单一:config.py只放全局参数;make_demo_data.py生产演示数据,让读者在没有真实数据时也能跑通流程;snapshot_loader.py只负责数据读取;report_builder.py只负责指标计算。后期如果数据结构变化,优先调整配置文件或加载函数,而不会改动主流程。
2.3 依赖清单示例
requirements.txt可以写成如下内容,具体版本号可以结合自己环境锁定:
pandas>=2.0.0 openpyxl>=3.1.0 matplotlib>=3.8.0当项目需要部署到服务器或交给其他同事运行时,建议使用pip freeze > requirements-lock.txt生成带精确版本的环境锁文件。这样能最大程度避免“本地能跑,服务器报错”的问题。
3. 统计口径与算法拆解
3.1 播放量等级划分
把播放量映射为等级时,最简单的方法是使用多个“阈值判断”。先确认上限,再依次向下判断,避免一首一千万以上的歌曲被误判成“传说曲”。下面这段伪代码描述了划分逻辑:
def tier_of(count: float) -> str: if count >= 10_000_000: return "神话曲" if count >= 1_000_000: return "传说曲" if count >= 100_000: return "殿堂曲" return "普通区"在实际业务中,“殿堂曲”这些称呼可能有平台定制含义,因此我推荐把阈值全部放到配置文件中,而不是散落在函数里。例如,本文整理的“准神话曲”候选只关心传说曲和神话曲之间的区间,但月报概览页仍会展示各等级歌曲的数量,以便知道整体数据分布。
3.2 准神话曲候选的判断方法
“准神话曲”并没有唯一标准,项目中通常采用“百分比缺口”与“绝对量缺口”两种方式。百分比缺口适合不同量级的对比,例如“已到达神话曲阈值的 95%”;绝对量缺口则直接看还差多少播放量,例如“距离一千万还差不到五十万”。两种方式都能用,但需要特别注意:当歌曲已经超过一千万时,它已经正式成为神话曲,不应继续出现在准神话候选里。
因此,更稳妥的业务口径是:先计算percent_to_myth = current_count / myth_threshold,然后同时判断“当前播放量大于等于传说曲阈值”和“百分比小于 1”以及“百分比大于等于 0.95”。如果某首歌本月刚刚突破一千万,它应该出现在“本月新晋神话曲”榜中,而不是继续留在“准神话曲”栏目里。把这种细节规则写清楚,能避免不同月份之间前后口径不一致。
3.3 月度快照模型
月度快照可以理解成“每个月月底或月初导出的全量歌曲表”。每首歌在快照里通常有唯一 ID、标题、作者名称和截止到快照日期的累计播放量。我们需要比较的是“本月底累计播放量”和“上月底累计播放量”,两值相减得到当月净增。因为累计播放量永远不会倒减少,所以正常情况下delta应大于等于零;如果出现负数,基本可以判断是快照错误、口径不同或歌曲曾下架又重新上传。
采用快照模型最大的好处是数据可追溯。假设八月份的月报出现了明显异常,我们可以直接查看数据目录里的snap_202508.csv,与上一期快照做对比。每次运行都保留原始输入文件,远比只保留一份 Excel 结果更适合排查问题。
3.4 合并时必须考虑的新增与下架
两个月份快照的歌单并不是一定完全一致。本月可能有新歌第一次进入统计范围,也可能有旧歌因为作者删除、视频失效等原因不再出现在本期快照里。最常用的合并方式是“外连接”,把左边上月快照和右边本月快照都保留下来;本月不存在的新歌,上月播放量填充为0;本月已消失的旧歌,本期播放量填充为0,但在后续统计中不应把它当作正常歌曲看待。
实际操作中,建议在输出明细表时增加一列is_quasi_myth和一列is_in_current之类标记,方便读者快速筛选。若歌曲本月已经下架,即使上月播放量接近一千万,也不能把它标记成当前有效的准神话曲候选,否则会误导阅读者。
4. 完整实战案例
下面进入核心流程。为便于复现,本文使用脚本生成一批演示数据,并演示从读取、计算到导出 Excel、绘图的全过程。
4.1 生成演示数据
创建make_demo_data.py,写入如下代码。它会模拟 12 首歌曲在 2025 年 7 月和 8 月两个快照中的累计播放量,并把文件输出到data目录。这些歌曲名称均为“演示用”,不代表任何真实作品。
# -*- coding: utf-8 -*- """ 生成两份演示快照数据,用于月报统计。 运行后会在 data 目录下生成: - snap_202507.csv - snap_202508.csv """ import random from pathlib import Path import pandas as pd random.seed(202508) BASE_DIR = Path(__file__).parent DATA_DIR = BASE_DIR / "data" DATA_DIR.mkdir(exist_ok=True) SONGS = [ ("v0001", "示例术曲·月夜观测站", "青月P"), ("v0002", "示例术曲·星轨回信", "青月P"), ("v0003", "示例术曲·黑箱里的人偶", "白泽P"), ("v0004", "示例术曲·一百秒后告别", "MochaP"), ("v0005", "示例术曲·极北列车", "灰岛P"), ("v0006", "示例术曲·电子海豚假日", "凉宫P"), ("v0007", "示例术曲·重力落体", "北白川P"), ("v0008", "示例术曲·雨音博物馆", "音律研究社"), ("v0009", "示例术曲·透明伞问卷", "无糖P"), ("v0010", "示例术曲·旧时钟塔", "八度音P"), ("v0011", "示例术曲·星期天的云", "青月P"), ("v0012", "示例术曲·地下铁诗人", "灰岛P"), ] BASE_PLAY = { "v0001": 210_000, "v0002": 4_800_000, "v0003": 9_710_000, "v0004": 8_200_000, "v0005": 9_950_000, "v0006": 3_000_000, "v0007": 650_000, "v0008": 9_890_000, "v0009": 5_500_000, "v0010": 11_200_000, "v0011": 50_000, "v0012": 720_000, } def make_snapshot(month: str, previous_df): snap_date = f"{month[:4]}-{month[4:]}-01 00:00:00" rows = [] for song_id, title, author in SONGS: if previous_df is None: base_count = BASE_PLAY[song_id] else: last_row = previous_df.loc[previous_df["song_id"] == song_id] base_count = int(last_row["play_count"].iloc[0]) growth = random.randint(5_000, 180_000) play = base_count + growth rows.append({ "song_id": song_id, "title": title, "author": author, "snap_date": snap_date, "play_count": play, }) return pd.DataFrame(rows) if __name__ == "__main__": snap_july = make_snapshot("202507", None) snap_july.to_csv(DATA_DIR / "snap_202507.csv", index=False, encoding="utf-8-sig") snap_aug = make_snapshot("202508", snap_july) snap_aug.to_csv(DATA_DIR / "snap_202508.csv", index=False, encoding="utf-8-sig") print("演示数据已生成到 data 目录。")运行命令:
python make_demo_data.py生成后的snap_202508.csv文件内容类似:
song_id,title,author,snap_date,play_count v0001,示例术曲·月夜观测站,青月P,2025-08-01 00:00:00,310000 v0002,示例术曲·星轨回信,青月P,2025-08-01 00:00:00,4850000 ...要注意,这里把快照时间写为次月月初,是模拟“月末结算后导出”的场景,业务上完全没问题,只要两期快照时间保持一致即可。
4.2 配置文件管理阈值
创建config.py,集中放置目录、期数和阈值。
# -*- coding: utf-8 -*- """全局配置。""" from pathlib import Path BASE_DIR = Path(__file__).parent DATA_DIR = BASE_DIR / "data" OUTPUT_DIR = BASE_DIR / "output" OUTPUT_DIR.mkdir(exist_ok=True) # 默认统计周期与期数 DEFAULT_LAST_MONTH = "202507" DEFAULT_MONTH = "202508" DEFAULT_ISSUE = 10 # 平台分级阈值 HALL_THRESHOLD = 100_000 # 殿堂曲 LEGEND_THRESHOLD = 1_000_000 # 传说曲 MYTH_THRESHOLD = 10_000_000 # 神话曲 # 准神话曲候选:播放量达到神话阈值的比例 QUASI_MYTH_RATIO = 0.95 # 增量榜展示数量 TOP_N = 15把所有阈值放在同一个文件里,后期做“第十一期”“第十二期”时,不需要读懂每个函数,只要修改月份、期数,以及可能变化的业务阈值即可。这也是工程项目中很常见的“约定优于配置”思路。
4.3 读取与清洗快照
创建snapshot_loader.py,统一完成 CSV 读取。虽然简单,但这一步能避免重复代码。
# -*- coding: utf-8 -*- """加载 CSV 快照。""" import pandas as pd def load_snapshot(path: str) -> pd.DataFrame: df = pd.read_csv(path, dtype={"song_id": str}) df["play_count"] = pd.to_numeric(df["play_count"], errors="coerce") df["snap_date"] = pd.to_datetime(df["snap_date"], errors="coerce") df = df.sort_values("snap_date") df = df.drop_duplicates(subset=["song_id"], keep="last") df = df.dropna(subset=["play_count", "snap_date"]) return df.reset_index(drop=True)这段代码包含两个关键动作:第一,按snap_date排序后对song_id去重,保证同一首歌在快照中只有一行;第二,把播放量和日期列做类型转换,避免后续计算因字符串类型报错。如果真实数据带有脏字符,可以继续在该函数里增加清洗逻辑,例如去除播放量中的逗号、处理标题空格等。
4.4 核心指标计算
创建report_builder.py。这里实现“上月快照 + 本月快照 -> 完整指标表”的核心逻辑。
# -*- coding: utf-8 -*- """月度播放量指标计算。""" import pandas as pd from config import LEGEND_THRESHOLD, MYTH_THRESHOLD, QUASI_MYTH_RATIO def _merge_snapshots(last_df: pd.DataFrame, current_df: pd.DataFrame) -> pd.DataFrame: last_cols = ["song_id", "title", "author", "play_count"] current_cols = ["song_id", "title", "author", "play_count"] merged = pd.merge( last_df[last_cols], current_df[current_cols], on="song_id", how="outer", suffixes=("_last", "_current"), ) merged["play_count_last"] = merged["play_count_last"].fillna(0).astype(float) merged["play_count_current"] = merged["play_count_current"].fillna(0).astype(float) merged["title"] = merged["title_current"].fillna(merged["title_last"]) merged["author"] = merged["author_current"].fillna(merged["author_last"]) return merged def _calc_indicators(merged: pd.DataFrame) -> pd.DataFrame: merged["delta"] = merged["play_count_current"] - merged["play_count_last"] merged["delta_rate"] = merged["delta"] / merged["play_count_last"].replace(0, pd.NA) merged["remain_to_myth"] = (MYTH_THRESHOLD - merged["play_count_current"]).clip(lower=0) merged["percent_to_myth"] = (merged["play_count_current"] / MYTH_THRESHOLD).round(6) return merged def _mark_tier(df: pd.DataFrame) -> pd.DataFrame: def tier_of(count: float) -> str: if count >= MYTH_THRESHOLD: return "神话曲" if count >= LEGEND_THRESHOLD: return "传说曲" return "普通区" df["tier"] = df["play_count_current"].apply(tier_of) return df def build_monthly_report(last_df: pd.DataFrame, current_df: pd.DataFrame) -> pd.DataFrame: merged = _merge_snapshots(last_df, current_df) merged = _calc_indicators(merged) merged = _mark_tier(merged) merged["is_in_current"] = merged["play_count_current"] > 0 merged["is_quasi_myth"] = ( merged["is_in_current"] & (merged["play_count_current"] >= LEGEND_THRESHOLD) & (merged["play_count_current"] < MYTH_THRESHOLD) & (merged["percent_to_myth"] >= QUASI_MYTH_RATIO) ) merged = merged.sort_values( ["is_quasi_myth", "delta"], ascending=[False, False] ).reset_index(drop=True) return merged这段代码的思路是:先做外连接,再计算增量、增速和距离神话曲的缺口,最后根据规则打标签。is_in_current的作用是标记本月是否仍然存在,缺失的本月歌曲即使上月播放量很高,也不能成为准神话候选。借助布尔类型配合,后续 Excel 输出可以非常方便地筛选不同子集。
4.5 导出 Excel 月报
创建excel_exporter.py,把结果输出到带多个 Sheet 的 Excel 文件。
# -*- coding: utf-8 -*- """导出 Excel 月度报告。""" from pathlib import Path import pandas as pd from config import OUTPUT_DIR, TOP_N def export_excel( report_df: pd.DataFrame, month: str, issue: int, ) -> Path: output_path = OUTPUT_DIR / f"vocaloid_monthly_{month}_issue{issue}.xlsx" summary_df = pd.DataFrame({ "统计月份": [month], "期数": [f"第{issue}期"], "歌曲总数": [len(report_df)], "本月仍在榜歌曲数": [int(report_df["is_in_current"].sum())], "神话曲数量": [int((report_df["tier"] == "神话曲").sum())], "准神话曲候选数量": [int(report_df["is_quasi_myth"].sum())], }) quasi_df = report_df[report_df["is_quasi_myth"]].sort_values( "percent_to_myth", ascending=False ) growth_df = report_df[report_df["is_in_current"]].sort_values( "delta", ascending=False ).head(TOP_N) with pd.ExcelWriter(output_path, engine="openpyxl") as writer: summary_df.to_excel(writer, sheet_name="概览", index=False) quasi_df.to_excel(writer, sheet_name="准神话曲候选", index=False) growth_df.to_excel(writer, sheet_name="本月增量Top", index=False) report_df.to_excel(writer, sheet_name="全部明细", index=False) return output_pathExcel 导出后,概览 Sheet 会直接显示本期有多少首神话曲、多少首准神话曲候选;准神话曲候选 Sheet 则按完成度从高到低排列。由于全部明细 Sheet 包含所有歌曲,分析人员也能自行使用 Excel 透视表做更深一步分析,减少重复沟通成本。
4.6 输出可视化图
创建chart_exporter.py,生成“月度增量 Top 15”和“准神话候选缺口”两张图。
# -*- coding: utf-8 -*- """输出图表。""" import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import pandas as pd from config import MYTH_THRESHOLD, TOP_N plt.rcParams["font.sans-serif"] = [ "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "SimHei", "sans-serif", ] plt.rcParams["axes.unicode_minus"] = False def export_charts(report_df: pd.DataFrame, output_dir, month: str) -> list: charts = [] growth_df = report_df[report_df["is_in_current"]].sort_values( "delta", ascending=True ).tail(TOP_N) fig1, ax1 = plt.subplots(figsize=(10, 7)) ax1.barh(growth_df["title"], growth_df["delta"], color="#4C72B0") ax1.set_title(f"{month} 播放量增量 Top {TOP_N}") ax1.set_xlabel("播放量增量") ax1.set_ylabel("歌曲") path1 = output_dir / f"growth_top_{month}.png" fig1.savefig(path1, dpi=150, bbox_inches