简介:Tinder-1.2.2 是一份面向 Java 开发者与算法学习者的开源工具源码包,聚焦数据匹配、推荐系统或算法优化场景,可帮助读者理解工具内核与实现原理。资源共53个文件,其中 Java 源文件达50个,覆盖核心逻辑与测试用例,另有 HTML 说明、XML 配置和 serialized 数据文件,压缩包仅127KB,结构紧凑,便于快速下载和本地分析。通过精读源码,可以学习模块化、可扩展、可维护的代码组织方式,掌握哈希表快速查找与并行计算等高效数据处理策略,并借鉴其错误处理和异常管理的最佳实践;同时 Tinder-1.2.2 作为次要更新版本,包含功能增强与缺陷修复,为二次开发提供了清晰参考。在实际项目中,这些实现可用于数据清洗、行为偏好匹配或模型训练辅助,显著提升开发效率。已有1632人学习下载,适合具备一定 Java 基础、希望深入剖析匹配/推荐算法实现并在此基础上进行定制优化的开发者。
1. tinder 研究工具不是黑匣子:先弄清楚这份源码能做什么
收到过不止一位同行问类似的问题:tinder 没有开放官方 API,想研究它的推荐机制、想做数据整理,难道只能靠手工截图?结果是反直觉的——移动端请求用的不是 OAuth,而是一套私有令牌协议,核心字段叫 x-auth-token。拿到这个令牌之后,推荐列表、滑动判定、匹配记录都会以普通 JSON 的形式出现在你面前。这套源码工具做的事情,就是把抓包、令牌管理、滑动动作、数据入库和统计验证串成一条本地可跑的流水线,适合想拆接口的客户端开发者、做社交产品调研的数据同学,以及所有不想靠手速刷卡的研究者。一句话:它不教你“怎么多匹配”,它把 tinder 变成一份可离线分析的数据集。
2. 抓包与令牌机制:X-Auth-Token 从哪来、怎么续命
拿到 tinder 核心请求链路的入口只有两步:把 App 的流量导到 mitmproxy,然后从请求头里找到 x-auth-token。这个 header 几乎是所有接口的通用钥匙,后续的刷卡片、看匹配、拉详情全都走它。下面按我实际操作的顺序展开。
2.1 先搭抓包环境:mitmproxy 证书安装与只过滤 tinder 流量
mitmproxy 是我在客户端协议分析里最常用的一套工具,它比 Charles 更适合后续脚本化处理,因为过滤、保存、回放都能走命令行和 Python API。启动方式很多,我一般用 mitmweb,它自带 Web 界面,方便快速看请求列表:
mitmweb --listen-port 8080 --set block_global=false --set stream_large_bodies=5m说明:--listen-port 8080是让 mitmweb 监听 8080 端口,手机或模拟器把 Wi-Fi 代理指向这台机器的 IP:8080 即可;block_global=false是允许非本机发来的连接,否则真机流量会被直接拒绝;stream_large_bodies=5m是把超过 5MB 的响应体按流式处理,避免照片、视频把内存撑爆。
流量进来后界面会非常吵,必须做过滤。推荐直接加 URL 过滤参数启动:
mitmproxy --listen-port 8080 --set 'filter=~u api.gotinder.com' --save-stream-file tinder_flows.flow说明:~u api.gotinder.com是 mitmproxy 的过滤语法,意思是只显示 URL 里包含该域名的请求;--save-stream-file会把全部流量先落盘成.flow文件,哪怕界面卡了,数据也还在,后面可以离线再分析。
证书安装这一步决定后续能不能看到明文。iOS 上要下载证书后在“设置-通用-关于本机-证书信任设置”里打开开关;Android 7 以上 App 默认不信任用户证书,要么把证书装进系统分区,要么用支持调试的包。这一步属于环境准备,多数人卡在这里不是因为证书本身,而是没意识到抓包工具和设备的时钟要一致,时间偏差过大会导致 TLS 校验失败。
提示:抓包完毕记得关掉代理,否则手机会一直断网。
2.2 从请求头里提取 X-Auth-Token,再从响应体读 refresh token
抓包文件里已经包含了完整的请求和响应,接下来要做的是把令牌抽出来。我一般直接写个一次性脚本扫 flow 文件,避免开着界面翻半天:
from mitmproxy import io from mitmproxy.exceptions import FlowReadException with open("tinder_flows.flow", "rb") as f: reader = io.FlowReader(f) for flow in reader.stream(): req = flow.request res = flow.response if req.pretty_host.endswith("gotinder.com"): token = req.headers.get("x-auth-token", "") if token: print(req.method, req.path, token[:16], "...") # 登录/刷新接口的响应体里通常带 refresh_token if req.path.startswith("/v2/auth") and res: body = res.get_text() or "" if "refresh_token" in body: print("REFRESH:", body[:200])逻辑说明:x-auth-token出现在大多数 API 请求头里,打印前 16 位做确认就够了;/v2/auth路径下的响应才会返回refresh_token,把它和x-auth-token一起存到本地,作为后续会话恢复的凭据。注意这里用的是FlowReader.stream()迭代方式,适合大文件,不会一次性把整个文件读进内存。
token 续命是一个关键点。tinder 的令牌不是永久的,常见有效期也就一两个小时,到期后接口直接返回 401。常见做法是保存refresh_token,在需要时调刷新接口换新令牌:
def refresh_session(refresh_token): headers = {"content-type": "application/json"} data = json.dumps({"refresh_token": refresh_token}) r = requests.post("https://api.gotinder.com/v2/auth/refresh", data=data, headers=headers) if r.status_code == 200: return r.json()["data"]说明:刷新接口的入参只有一个refresh_token,返回体里的data同时包含新的token和新的refresh_token,两个值都要覆盖到本地。参数上有个坑:refresh_token是一次性的,一旦刷新成功,旧值立刻失效,不能留着当备用,否则第二次用就会报错。
3. 手动复刻核心流程:swipe 自动化与匹配监控落地
令牌链路打通以后,剩下的就是业务动作。tinder 的移动端协议里,推荐、滑动、匹配都是很规整的 HTTP 接口,不需要走任何诡异的二进制协议,一个 Python 类就能完全复刻。
3.1 最小客户端:把 recommendations 和 like/pass 封装成类
先写一个最小的会话类,把请求头、令牌刷新和核心动作包在一起:
import time import requests class TinderSession: API = "https://api.gotinder.com" def __init__(self, token, refresh_token=None): self._token = token self._refresh_token = refresh_token self._start = time.time() def _headers(self): return { "x-auth-token": self._token, "platform": "android", "content-type": "application/json", } def recommendations(self, limit=30): url = f"{self.API}/v2/recs/core" r = requests.get(url, headers=self._headers(), params={"limit": limit}) r.raise_for_status() return r.json().get("data", {}).get("results", []) def like(self, person_id): url = f"{self.API}/like/{person_id}" r = requests.get(url, headers=self._headers()) return r.json() def pass_user(self, person_id): url = f"{self.API}/pass/{person_id}" r = requests.get(url, headers=self._headers()) return r.json()逻辑说明:recommendations返回的是推荐卡片列表,每一条的user字段里有_id、姓名、出生日期、兴趣、照片等;like和pass_user的路径直接把 person_id 拼在 URL 上,返回体里的match字段是布尔值,为true才表示真正匹配成功。参数上要注意limit别超过 100,实测超过后接口会返回参数错误。
还有一个反直觉的细节:like 和 pass 在官方协议里是 GET 请求,不是 POST。很多同学拿到接口后习惯写成 POST,结果永远 404。追根溯源,这个接口设计得比较老,保留了一开始的风格,我们按客户端实际抓到的请求方式来就行。
调用策略上,我一般不会直接全量刷,加一个随机间隔:
session = TinderSession(token="...", refresh_token="...") for person in session.recommendations(limit=20): user = person.get("user", {}) if user.get("birth_date"): session.like(user["_id"]) else: session.pass_user(user["_id"]) time.sleep(random.uniform(1.2, 2.6))逻辑说明:这里用birth_date是否存在做一次粗糙的筛选,只是为了说明“滑动条件可以自己定”,并不是什么玄学策略。参数说明:随机间隔取 1.2 到 2.6 秒,是模拟真人滑动卡片的经验值;低于 0.8 秒连续请求几十次,很容易触发风控验证,这点后面避坑章还要展开。
3.2 匹配监控:增量落库与去重
滑动只是过程,真正的研究价值在匹配结果。我的做法是把每次查询到的 matches 增量写入 SQLite,这样后面做统计时不用重新请求接口。
import sqlite3 def init_db(conn): conn.execute(""" CREATE TABLE IF NOT EXISTS matches ( match_id TEXT PRIMARY KEY, person_id TEXT, name TEXT, matched_at TEXT, source_op TEXT ) """) def fetch_matches(session): url = f"{session.API}/v2/matches" r = requests.get(url, headers=session._headers(), params={"count": 30}) data = r.json()["data"]["matches"] for m in data: person = m.get("person") or m.get("user") or {} yield (m["_id"], person.get("_id"), person.get("name"), m.get("matched_at"))逻辑说明:/v2/matches接口返回的每条匹配记录里,对方资料字段可能是person,也可能是user,两个都做兜底更稳。入库时必须用match_id做去重,因为同一匹配会在多次轮询里重复出现。source_op字段用于记录这次匹配是哪次滑动产生的,后面统计“哪类画像匹配率高”时全靠它。
conn = sqlite3.connect("tinder.db") init_db(conn) session = TinderSession(token="...", refresh_token="...") for match_id, person_id, name, matched_at in fetch_matches(session): conn.execute( "INSERT OR IGNORE INTO matches VALUES (?, ?, ?, ?, 'like')", (match_id, person_id, name, matched_at), ) conn.commit()说明:INSERT OR IGNORE是主键冲突时的默认策略,重复写入直接跳过,比先 SELECT 再 INSERT 简单。参数说明:count=30是单页上限,接口返回里如果有next_page_token,还要继续请求下一页,否则会漏掉较早的记录;完整分页逻辑建议加一个while循环,我这里只给出单页处理,是为了把去重逻辑讲清楚。
注意:轮询频率不要太密。匹配列表不是实时消息,拉一次管几分钟就够了,我一般控制在 5 分钟一拉。
4. 把 JSON 变成可用画像:字段抽取、照片去重与本地存储
推荐接口返回的 JSON 很臃肿,一层套一层,直接用 Excel 打开没有可读性。必须把字段抽出、清洗、落盘,才能做后面的对照实验。
4.1 画像字段抽取:处理嵌套 user 对象和缺失值
写解析脚本时,最容易翻车的地方是字段路径在不同账号、不同版本下不一致。我的习惯是写一个防御式读取函数:
def extract_profile(user: dict) -> dict: def safe(d, *keys): cur = d for k in keys: if not isinstance(cur, dict): return None cur = cur.get(k) return cur return { "user_id": safe(user, "_id"), "name": safe(user, "name"), "age": calc_age(safe(user, "birth_date")), "bio": safe(user, "bio"), "interests": safe(user, "interests", "name") or [], "distance_mi": safe(user, "distance_mi"), "schools": safe(user, "schools", "name") or [], "jobs": [j.get("title") for j in (safe(user, "jobs") or []) if j], }逻辑说明:safe函数按 key 顺序逐层取,只要中间某一层不是 dict,就返回None,不会抛TypeError。这样不管是interests结构还是schools结构发生变化,脚本都不会整个崩掉,最多是某个字段为空。参数说明:age需要从birth_date推算,原始值一般是 ISO 格式时间串,计算时要固定时区,否则跨日边界会差一岁;interests的路径在不同地区版本里出现过两种写法,一种是interests[].name,另一种是topics[].name,建议先打印一次真实结构再定抽取规则。
4.2 照片归档:按 user_id 建目录、用文件哈希去重
图片下载是资源包里最容易被人忽视的部分。照片 URL 上带 CDN 签名参数,同一个图在不同时间拿到的 URL 可能不一样,但二进制内容一模一样,所以不能用 URL 当文件名,必须用内容哈希。
import os import requests from hashlib import sha1 def save_photo(user_id, url, base_dir="photos"): d = os.path.join(base_dir, user_id) os.makedirs(d, exist_ok=True) r = requests.get(url, timeout=10) if r.status_code != 200: return content_hash = sha1(r.content).hexdigest() ext = url.split("?")[0].split(".")[-1] ext = "jpg" if ext not in ("jpg", "png", "webp") else ext path = os.path.join(d, f"{content_hash}.{ext}") if not os.path.exists(path): with open(path, "wb") as f: f.write(r.content)逻辑说明:先按user_id建目录,再写文件,方便后面按人查看;写入前用content_hash判断文件是否已存在,完全相同的图片不会重复落盘。参数说明:timeout=10比较关键,推荐流里图片源很杂,偶发一个慢请求不至于把整个任务卡死;扩展名判断不能直接用 URL 尾部,因为 URL 后面还有?和一堆参数,必须先split("?")[0],否则拼出来的文件名会带一串无效字符。
批量下载时不要一股脑并发几十个请求,我一般用 4 个线程:
from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=4) as pool: for person in persons: photos = person.get("photos", [])[:1] # 只取第一张当头像 for photo in photos: pool.submit(save_photo, person["_id"], photo["url"])说明:只取第一张是做调研时的常用策略,能控制数据量;max_workers=4是带宽和频率之间的折中,跑本机抓包环境时足够,不用上大并发。
5. 排查手册:Token 失效、429 限流与字段缺失怎么处理
这套流程跑起来不难,真正烦人的是跑着跑着忽然 401、429,或者解析出来全是空值。下面几条踩坑记录都是从实际复现里整理出来的,按“现象→原因→解决”写。
5.1 请求突然 401:Token 失效还是入口参数变了
现象:连续运行 20 分钟后,所有请求突然返回 401,打印 header 里的 token 看起来还是原来的字符串。
原因:x-auth-token的有效期并不长,而且它不会在响应头里给你任何“即将过期”的提示。等到第一轮 token 失效后,后续请求全部被拒。还有一个隐蔽情况:如果在多线程里同时调用刷新接口,旧refresh_token被第一次请求用掉后,第二次刷新必然失败,导致“越刷新越 401”。
解决:在TinderSession里加一个ensure_token方法,每次发起请求前检查时间,超过 30 分钟就先刷新一次,并且加锁:
import threading class TinderSession: def __init__(self, token, refresh_token=None): self._token = token self._refresh_token = refresh_token self._start = time.time() self._lock = threading.Lock() def ensure_token(self): with self._lock: if time.time() - self._start > 1800: data = refresh_session(self._refresh_token) self._token = data["token"] self._refresh_token = data["refresh_token"] self._start = time.time()说明:锁的作用是防止多个线程同时进入刷新逻辑。参数上,30 分钟是一个保守值,如果 token 实际有效期较短也不会踩到边界;刷新成功后旧refresh_token就作废了,所以本地存储务必覆盖更新。
5.2 连续操作后被 429:限流策略与间隔设置不合理
现象:快速刷了 40 张卡片后,所有请求开始 429,日志里没有任何业务错误,响应头里偶尔带着retry-after。
原因:服务端对单条会话的请求频率有约束,连续高频滑动会被判定为非人工操作。固定间隔也不是最优解,因为固定节奏本身就容易被识别。
解决:读retry-after做退避,同时把间隔改成随机抖动:
time.sleep(max(float(r.headers.get("retry-after", 3)), random.uniform(1.2, 2.6)))说明:retry-after存在时以它为准,不存在时按 3 秒兜底。参数上,我一般每刷 40 次主动休息 15 分钟,而不是等 429 出现后再处理;这个 40 次不是官方值,只作为自己脚本里的软限,实际调整看日志里 429 出现的频率。
5.3 返回的字段对不上:接口版本回退与字段迁移
现象:同样一段解析代码,换一个账号或换一台设备后,interests抽取结果全是空数组,其他字段都正常。
原因:不同版本接口对同一语义的字段路径做过调整,比如兴趣字段从interests[].name变成了topics[].name,生物字段也可能从bio变成bio_short。解析脚本只写死了一条路径,遇到新结构就静默失败。
解决:把原始 JSON 完整落盘一份,按user_id命名文件。遇到空字段时先打印 key 树,再决定改哪条路径:
def walk_keys(obj, prefix=""): if isinstance(obj, dict): for k, v in obj.items(): print(f"{prefix}.{k}", type(v).__name__) if isinstance(v, (dict, list)): walk_keys(v, f"{prefix}.{k}") elif isinstance(obj, list) and obj: walk_keys(obj[0], prefix + "[0]")说明:这个方法只递归第一个元素,足够定位字段路径,不会刷屏。参数上,obj传推荐接口返回的user对象即可。从那以后我遇到字段对不上,第一反应不是改代码,而是先跑一次walk_keys。
5.4 已入库的数据想重跑一遍:原始响应没存
现象:入库后第二天发现画像抽取规则有 bug,想重新解析,但库里只有解析结果,没有原始 JSON,等于数据作废。
原因:只存了加工后的profiles表,忽略了recommendations接口的完整响应。接口会分页返回,原始响应是最接近真相的素材,丢了只能重新请求。
解决:所有接口响应按日期落盘成 JSON 文件,路径按日期分目录,比如raw/2025-01-12/recs_001.json。重跑解析时直接读本地文件,不碰线上接口。参数上,文件名里带上时间戳和页号,方便回放时保持顺序。
6. 画像特征与匹配率验证:用最少样本做 A/B 对照
6.1 用一组 SQL 把匹配率按画像分组算出来
数据入库之后,最直观的验证是:哪一类画像的匹配率更高?我用 likes 表做分母、matches 表做分子,按画像特征分组聚合:
SELECT CASE WHEN p.bio LIKE '%travel%' THEN 'travel' WHEN p.bio LIKE '%coffee%' OR p.bio LIKE '%food%' THEN 'coffee_food' ELSE 'other' END AS group_key, COUNT(*) AS total, SUM(CASE WHEN m.match_id IS NOT NULL THEN 1 ELSE 0 END) AS matched, ROUND(SUM(CASE WHEN m.match_id IS NOT NULL THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) AS match_rate FROM likes l LEFT JOIN matches m ON l.person_id = m.person_id LEFT JOIN profiles p ON l.person_id = p.user_id GROUP BY group_key ORDER BY match_rate DESC;逻辑说明:likes是我们主动执行过的滑动记录,matches是服务端返回的匹配记录,通过person_id关联。注意这里LEFT JOIN matches的含义是“这条 like 是否产生了 match”,没有匹配就是NULL。参数说明:COUNT(*)是分母样本量,分组前最好先看下总量,样本太少时匹配率没有任何参考意义。
6.2 样本量不足时怎么判断
匹配率是二项分布,样本量太小误差极大。我自己的判读标准如下:
| 样本量 | 结论可信度 | 建议 |
|---|---|---|
| 小于 15 | 不具统计意义 | 只当个案记录 |
| 15 到 30 | 可观察趋势 | 继续采集,别下结论 |
| 大于 50 | 可做粗略排序 | 结合画像特征做验证 |
匹配率这个指标本身也有口径问题:同一个 like 可能在多次分页里被重复记录,入库时如果没去重,分子分母都会被虚增,所以建表时person_id + action_time要建唯一索引。后来我还发现,真正让这套工具可用的是在 likes 表里多记了source_op和action_time两列,统计时能区分“主动筛选”和“无脑刷”。从那以后,我每次调解析规则都会先跑一遍本地 JSON,确认字段路径没变化,再碰真实接口;这个顺序看起来慢,实际省下了不少因为字段迁移导致的重跑时间。希望帮到你。
本文还有配套的精品资源,点击获取