ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从爬虫到推荐:TapTap游戏热度分析与混合推荐系统实践

从爬虫到推荐:TapTap游戏热度分析与混合推荐系统实践 简介面向游戏数据采集与推荐系统方向的学习者、毕业设计开发者以及希望掌握爬虫与机器学习落地流程的工程师这套基于Python与Django框架的完整项目以TapTap游戏平台为对象实现了多维度游戏数据采集、热度分析、标签自动分类、用户协同过滤、物品协同过滤与混合神经网络推荐算法并利用K-means聚类对游戏进行分组优化。整个资源包含45个文件核心资产包括png/jpg格式的可视化图表与界面运行截图、docx格式的系统功能说明与免费论文、py格式的爬虫主程序、scala格式的RDD数据处理脚本以及txt和md格式的说明文档压缩包大小约8.91MB目录划分清晰适合按模块逐步研读。目前已有107人浏览学习。除推荐算法实现外资料还附带了项目文档、部署说明与图表辅助材料能够帮助读者理解从爬虫采集、数据清洗、特征分析到模型训练与聚类评估的完整链路无论是用于课程设计、毕业设计还是推荐系统入门实践都具有很高的参考价值。1. 从 TapTap 热度榜到个性化推荐这条链路值得完整走一遍TapTap 这类游戏社区每天产生的热度、评分、标签和用户行为数据足够支撑一个有意思的推荐系统实验。直接把榜单推给所有人太粗放纯靠编辑人工整理又跟不上新游上架速度。常见做法是用爬虫把游戏列表、评分、下载量、用户评价这些结构化数据按固定频率抓下来经过热度归一化和 K-means 聚类给游戏分层再叠加协同过滤和混合神经网络推荐算法最后通过 Django 框架把推荐结果以 API 形式暴露给前端或 App 客户端。这篇文章会把这条链路完整过一遍数据采集怎么做才不会被封、热度指标怎么算才不偏、协同过滤和神经网络各自适合哪一层、Django 里怎么把离线模型的结果高效地服务出去。适合具备 Python 基础、想自己搭一套完整推荐系统而不是只跑通一个算法的开发者读完后能直接照着搭出可运行的版本。2. TapTap 数据爬虫的请求构造、解析与 Django 落库2.1 数据源接口分析与请求头伪装TapTap 的页面数据大量由前端接口动态加载直接解析 HTML 效率不高抓取结果也容易因为页面改版而失效。更稳定的做法是直接抓取 Web API 接口返回 JSON 后只需要处理业务字段。常见的接口路径会包含 app 列表、详情、评价等资源。第一件事是打开浏览器开发者工具在 Network 面板里观察 App 列表加载时发出的 XHR 请求记录下真实的 URL、请求方式和返回结构。请求头伪装是绕不过去的一步浏览器会校验来源。构造请求时一般需要携带完整的 User-Agent、Referer 和 Accept 头。下面是一个最基础的请求会话封装import requests from requests.adapters import HTTPAdapter UA ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 ) def make_session() - requests.Session: s requests.Session() s.headers.update({ User-Agent: UA, Referer: https://www.taptap.cn/, Accept: application/json, text/plain, */*, }) adapter HTTPAdapter(max_retries3) s.mount(https://, adapter) s.mount(http://, adapter) return s这段代码把公共头和重试机制统一塞进 Session后续所有抓取请求只需要传入 URL 和必要参数。HTTPAdapter 的max_retries3解决的是网络抖动导致的偶发失败而不是 HTTP 状态码错误状态码处理需要在业务层单独判断。Referer写主页地址是为了让请求看起来是从站内导航发起的去掉之后部分接口可能返回空数据。2.2 列表页抓取与限速逻辑拿到接口后一般通过params参数控制分页。TapTap 这类列表接口通常返回total和data两个关键字段data里是游戏条目的结构化数组。抓取时要把分页循环、状态码校验和响应数据容器分开写方便后面做断点续采。import time import random def fetch_app_list(session: requests.Session, page: int, page_size: int 20) - dict: params { page: page, page_size: page_size, sort: hot, X-UA: V1PNWebApp, } resp session.get(https://www.taptap.cn/webapiv2/app-top-list/v2, paramsparams, timeout10) if resp.status_code ! 200: raise RuntimeError(fHTTP {resp.status_code}, page{page}) payload resp.json() if payload.get(code) ! 0: raise RuntimeError(fAPI error code{payload.get(code)}, message{payload.get(msg)}) return payload def crawl_top_list(session: requests.Session, total_pages: int) - list: rows [] for page in range(1, total_pages 1): try: payload fetch_app_list(session, page) rows.extend(payload[data][list]) time.sleep(random.uniform(1.5, 3.5)) except RuntimeError as exc: print(f[warn] page {page} failed: {exc}) time.sleep(5) return rows这里的限速放在每次请求完成之后延时范围拉宽到 1.5 到 3.5 秒避免出现固定间隔的节奏特征。X-UA这个自定义参数是 TapTap 接口识别来源的标志不同版本接口取值不同实际接入时以开发者工具里抓到的 Request Headers 和 Query String 为准。爬虫并发设计不需要一上来就上线程池列表页本身是串行分页保持低频稳定比“快”更重要。2.3 Django Models 与应用数据表结构抓下来的数据要落库Django 模型设计直接影响后面协同过滤和热度分析的效率。游戏基础信息、热度相关数值、标签列表、用户行为记录四类数据要分开建表避免把标签存在字符串里导致后续做分类时要反复 split。from django.db import models class Game(models.Model): app_id models.CharField(max_length32, uniqueTrue) title models.CharField(max_length128) publisher models.CharField(max_length128, blankTrue) score models.FloatField(default0.0) rating_count models.IntegerField(default0) download_count models.IntegerField(default0) tags models.JSONField(defaultlist) hot_score models.FloatField(default0.0) cluster_label models.IntegerField(nullTrue) updated_at models.DateTimeField(auto_nowTrue) class Meta: db_table game class UserAction(models.Model): uid models.CharField(max_length64, db_indexTrue) app_id models.CharField(max_length32, db_indexTrue) action_type models.CharField(max_length16) # browse, play, wish score models.FloatField(default0.0) created_at models.DateTimeField(auto_now_addTrue) class Meta: db_table user_action unique_together ((uid, app_id, action_type),)Game.app_id设成唯一键数据刷新时用update_or_create保证幂等写入。tags用 JSONField 而不是逗号分隔字符串后续做标签分类时可以一次性读到完整数组省去了字符串切分和去重的麻烦。UserAction.score存的是行为权重浏览打 1.0、试玩打 3.0、预约打 5.0这个值喂给协同过滤做评分矩阵。cluster_label字段是第 3 章聚类结果回填的位置这样查询时不需要每次重新跑模型。3. 游戏热度分析归一化指标、K-means 聚类与标签分类3.1 热度指标的定义与归一化处理热度不能只看单个指标。下载量高但评分低、评分高但用户少、评价数爆棚却都是负面反馈这几种情况单独拿出来都不能代表真实热度。常见做法是把评分、评价数、下载量、标签总量合并成一个综合热度分各项先做归一化再按权重求和。import pandas as pd from sklearn.preprocessing import MinMaxScaler def build_heat_features(df: pd.DataFrame) - pd.DataFrame: scaler MinMaxScaler() features scaler.fit_transform( df[[score, rating_count, download_count]].values ) heat ( features[:, 0] * 0.4 features[:, 1] * 0.3 features[:, 2] * 0.3 ) df[hot_score] heat return df.sort_values(hot_score, ascendingFalse)权重设置没有标准答案。我这里把评分权重放到 0.4因为 TapTap 用户对评分的信任度高于下载量评价数和下载量各占 0.3。要注意的是 MinMaxScaler 会把最小值映射到 0如果某个新游下载量还是 0评分权重再高整体热度分也会被拉低这正好符合长尾内容的实际状态。归一化时要把整张表的指标放在一起算不能按批次算否则每批数据尺度不一致聚类结果会失真。3.2 用 K-means 对游戏分层并确定 K 值热度分算出来后直接排序只能得到单一排名但产品运营通常需要分层的视角头部爆款、腰部增长型、长尾冷启。K-means 聚类在这个场景里做的就是对游戏分层聚类特征除了hot_score之外还可以加入rating_count和download_count的对数变换值压缩量纲差异。import numpy as np from sklearn.cluster import KMeans def find_best_k(features: np.ndarray, max_k: int 8) - int: inertias [] for k in range(2, max_k 1): model KMeans(n_clustersk, n_init10, random_state42) model.fit(features) inertias.append(model.inertia_) # 手动观察下降拐点或直接用 kneed 库识别 return inertias def assign_cluster(df: pd.DataFrame) - pd.DataFrame: features np.column_stack([ df[hot_score].values, np.log1p(df[rating_count].values), np.log1p(df[download_count].values), ]) model KMeans(n_clusters3, n_init10, random_state42) df[cluster_label] model.fit_predict(features) return dfK 值的选取建议直接看inertia_曲线的拐点。比如 K3 往后下降趋缓就选 3。实际项目中我不会直接用聚类中心解释业务含义而是等聚类结果出来后统计每个簇内hot_score、评分数、下载数的均值再给簇打业务标签。这一步不要省否则聚类结果只是一堆无法解读的整数编号。得到了 cluster_label 之后回填到 Django 的Game表Django admin 后台就能直接按热度分层筛选。3.3 游戏标签分类与主题标签的聚合标签分类的作用是给推荐系统准备内容侧特征。TapTap 上每个游戏都携带多个标签直接拿标签做匹配准确率有限因为同义标签分散。常见做法是用标签共现矩阵做聚合把高频共现的标签归并成主题标签例如“角色扮演”“开放世界”“像素风”各自的共现群。from collections import defaultdict from itertools import combinations def build_tag_matrix(games: list) - dict: tag_counter defaultdict(int) co_occur defaultdict(lambda: defaultdict(int)) for game in games: tags set(game[tags]) for tag in tags: tag_counter[tag] 1 for tag_a, tag_b in combinations(tags, 2): co_occur[tag_a][tag_b] 1 co_occur[tag_b][tag_a] 1 tag_matrix {} for tag, neighbors in co_occur.items(): total tag_counter[tag] tag_matrix[tag] { neighbor: count / total for neighbor, count in neighbors.items() if count / total 0.3 } return tag_matrix这段代码输出的tag_matrix里每个标签保存了与它共现概率超过 30% 的其他标签。拿到这个矩阵后可以把强关联的标签合并成一个主题词存储为游戏的扩展标签字段。K-means 做的是游戏热度分层标签聚合做的是内容侧分类两者互不冲突在推荐阶段会同时作用于候选集生成和排序特征。实际运行时标签聚合是离线任务每天或每周跑一次即可不需要实时计算。4. 协同过滤、矩阵分解与混合神经网络推荐算法4.1 用户协同过滤与物品协同过滤的实现边界协同过滤是推荐系统的地基。用户协同过滤计算的是用户之间的相似度适合用户量小于物品量的场景物品协同过滤计算物品相似度适合物品数量有限、更新频率不高的场景。TapTap 这类游戏平台游戏总量远小于用户总量所以物品协同过滤能离线算好相似矩阵线上只需要查表响应速度快得多。import pandas as pd from sklearn.metrics.pairwise import cosine_similarity def build_item_sim_matrix(actions: pd.DataFrame) - pd.DataFrame: matrix actions.pivot_table( indexuid, columnsapp_id, valuesscore, fill_value0 ) item_sim cosine_similarity(matrix.T, matrix.T) item_sim_df pd.DataFrame(item_sim, indexmatrix.columns, columnsmatrix.columns) return item_sim_df用户协同过滤和物品协同过滤可以同时实现互相兜底。用户冷启动时新用户没有任何行为记录无法计算相似用户此时以热门榜和聚类结果兜底新游戏没有交互数据无法算物品相似度此时用第 3 章算出的标签矩阵做内容匹配。协同过滤的参数主要控制相似度计算方式余弦相似度和皮尔逊相关系数都可以用前者对数值尺度不敏感后者对用户评分习惯做了均值中心化在实际数据上通常比余弦相似度效果好一点。4.2 矩阵分解与隐语义模型补全评分矩阵协同过滤直接使用用户行为矩阵会遇到两个问题矩阵稀疏零值占比极高相似度计算复杂度随用户数和物品数线性增长。矩阵分解的思路是把用户和物品映射到同一个隐向量空间用向量内积预测评分SVD 是其中最常见的一种实现。from scipy.sparse import csr_matrix from sklearn.decomposition import TruncatedSVD def train_svd(interaction: pd.DataFrame, components: int 10) - TruncatedSVD: matrix interaction.pivot_table( indexuid, columnsapp_id, valuesscore, fill_value0 ) svd TruncatedSVD(n_componentscomponents, random_state42) user_factors svd.fit_transform(csr_matrix(matrix.values)) return svd, user_factorscomponents是隐向量的维度一般取 10 到 50。维度过小模型偏置大维度过大容易过拟合且计算量大。矩阵分解适合在每次离线训练时跑把用户向量和物品向量保存下来线上推荐时直接做向量内积。和协同过滤相比矩阵分解的优点在于隐向量能捕捉到词面标签体现不出的隐含偏好缺点是结果难以解释给用户做推荐理由展示时还需要回退到标签匹配。4.3 混合神经网络推荐模型的设计与实现协同过滤和矩阵分解都是线性交互无法建模用户和游戏之间的非线性关系。混合神经网络推荐算法的切入点是用 Embedding 层把用户 ID 和游戏 ID 映射成稠密向量拼接后送入全连接网络最后的输出层预测用户对游戏的行为得分。这个结构在推荐系统里常被称为 Wide 与 Deep 的结合本质上是把协同过滤的共现信息和高阶特征交互放在同一个模型里学习。import torch import torch.nn as nn class HybridRecModel(nn.Module): def __init__(self, num_users: int, num_items: int, embed_dim: int 32): super().__init__() self.user_emb nn.Embedding(num_users, embed_dim) self.item_emb nn.Embedding(num_items, embed_dim) self.fc nn.Sequential( nn.Linear(embed_dim * 2, 64), nn.ReLU(), nn.Dropout(0.2), nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 1), ) def forward(self, user_ids: torch.Tensor, item_ids: torch.Tensor) - torch.Tensor: user_vec self.user_emb(user_ids) item_vec self.item_emb(item_ids) concat torch.cat([user_vec, item_vec], dim-1) return self.fc(concat).squeeze(-1)模型的关键参数是embed_dim它决定隐向量的容量。32 维是起步值如果训练集样本量大可以升到 64但要同步增加训练轮数和正则化强度。Dropout(0.2)放在第一层全连接后防止模型在少量用户行为上过拟合。选型参考这张表能省去不少对比时间算法输入要求适合场景线上耗时用户协同过滤用户行为矩阵物品多用户少快速落地中等需实时算相似用户物品协同过滤物品交互记录物品集合稳定更新不频繁低离线算好查表即可SVD 矩阵分解评分矩阵需要隐向量做向量召回低向量内积混合神经网络用户/物品 ID 特征特征丰富追求精度上限高需做缓存和批处理训练完模型后把item_emb层的权重导出成向量表存成 numpy 文件或 Django model线上阶段用向量相似度做召回再用模型排序。神经网络并不是用来替代协同过滤的而是在协同过滤召回的基础上做精排标题里的“混合”正是这层含义。5. Django 项目集成推荐 API、定时任务与 Redis 缓存5.1 Django App 模块划分与推荐数据流Django 项目的组织方式直接决定后续迭代效率。爬虫、算法、接口三层逻辑分散在不同 App 里模型训练脚本放在独立的recsys/模块不归 Django App 管Django 只负责加载离线产物和暴露 API。project/ ├── account/ # 用户模块 ├── content/ # Game、UserAction、Tag 模型 ├── recsys/ # 离线训练脚本与模型产物 │ ├── cf.py │ ├── cluster.py │ └── nn_model.py ├── api/ # Django REST Framework 接口 │ ├── views.py │ └── serializers.py └── config/ # Django 配置、Celery、RediscontentApp 存放数据模型recsys目录里放训练脚本和产出的模型文件apiApp 是推荐结果对外的唯一出口。数据流方向是Celery 定时任务爬数据 → 更新 Game 和 UserAction 表 → 离线任务重算热度聚类和向量表 → 推荐接口读取向量表和聚类结果 → 组装最终推荐列表。5.2 构建推荐 API 端点推荐 API 需要接收用户 ID返回该用户最可能喜欢的游戏列表。接口内部先查 Redis 缓存没有命中就执行推荐逻辑结果再写回缓存。from rest_framework.views import APIView from rest_framework.response import Response from content.models import Game from recsys.cf import get_item_cf_recommend, get_user_cf_recommend from django.core.cache import cache class RecommendView(APIView): def get(self, request, uid: str): cache_key frec:user:{uid} cached cache.get(cache_key) if cached is not None: return Response({uid: uid, items: cached}) item_cf get_item_cf_recommend(uid, top_n20) user_cf get_user_cf_recommend(uid, top_n10) merged list({x[app_id]: x for x in item_cf user_cf}.values()) merged.sort(keylambda x: x[score], reverseTrue) result merged[:20] cache.set(cache_key, result, timeout1800) return Response({uid: uid, items: result})缓存控制很关键。这里把timeout设为 1800 秒防止推荐列表一次性缓存过久导致用户行为变化后推荐内容不变。接口层只负责组装结果不负责计算相似度所有计算都在离线阶段完成。Django 的cache默认使用内存缓存单机够用线上部署推荐换到 Redis配置CACHES后这段代码不需要改动。5.3 Celery 定时采集与定时刷新爬虫不能手动跑要交给 Celery Beat 按固定节奏调度。每天凌晨和中午各跑一次榜单采集采集完成后触发热度聚类和协同过滤矩阵重建。from celery import shared_task from django.core.management import call_command shared_task def job_refresh_games(): call_command(crawl_taptap, max_pages5) shared_task def job_rebuild_recsys(): from recsys.cluster import run_cluster_pipeline from recsys.cf import rebuild_item_sim run_cluster_pipeline() rebuild_item_sim()定时任务的执行顺序很重要必须先刷新数据再做重算否则模型用的还是旧数据。Celery Beat 配置里把两个任务按照固定间隔错开避免在同一时刻抢占数据库连接。Django 的call_command可以复用 manage.py 里的 command爬虫脚本写成 Django management command 之后既能手动执行也能被 Celery 调度这是比较标准的工程化方式。6. 新游戏冷启动与推荐效果离线验证6.1 新游戏冷启动兜底策略游戏推荐系统的冷启动比电商更棘手新游戏上线第一周往往只有预约数据没有任何评分和下载行为供协同过滤使用。兜底策略是把第 3 章的标签分类结果拿来做内容匹配用新游戏的标签组合去召回标签向量距离最近的已有游戏再按已有游戏的热度分层排序。def cold_start_recommend(new_tags: list, tag_matrix: dict, top_n: int 10): tag_set set(new_tags) scores {} for tag, related in tag_matrix.items(): if tag not in tag_set: continue for neighbor, weight in related.items(): scores[neighbor] max(scores.get(neighbor, 0), weight) ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [tag for tag, _ in ranked[:top_n]]这个兜底逻辑不需要任何用户行为完全是内容侧驱动。权重取的是第 3 章算出的共现概率取max而不是累加避免出现某个标签在矩阵中被重复计权。新用户冷启动也可以直接用这套逻辑配合热门榜的聚类结果输出“大家都在玩”的推荐。6.2 离线评估与缓存击穿防护推荐效果验证不能靠上线后拍脑袋要在离线阶段把模型对比清楚。把用户行为数据按时间切分前 80% 做训练集后 20% 做测试集用 PrecisionK 和 RecallK 评估协同过滤和神经网络的差异。def evaluate_precision_at_k(reco_func, test_data, top_k: int 10) - float: hits 0 total 0 for uid, group in test_data.groupby(uid): reco_apps set(reco_func(uid, top_ntop_k)) held_out set(group[app_id]) hits len(reco_apps held_out) total top_k return hits / total评估结果决定线上主推哪条推荐链路。如果神经网络模型离线评测表现提升不到 5%不建议直接上神经网络排序维护成本太高反之如果收益明显可以配置梯度上线流量做 AB 对比。推荐的接口层还需要做防护避免热点用户集中访问同一缓存键导致数据库被打穿。给 Redis 缓存加不上锁重建或者引入单飞模式都能有效降低缓存失效瞬间的请求风暴。推荐系统做到这一步已经能稳定服务每日几十万次请求了。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进