做数据采集的人最怕两件事:一是慢,二是不准。手动拉群成员,复制粘贴到眼花,还漏掉潜水号;用某些现成工具,刚跑半小时账号就被踢下线。

早年 QQ 网页版还能直接走 OAuth1.0,拿个 consumer_key 就能握手。现在呢?登录入口搬到 https://im.qq.com,协议换成 OAuth2.0,微信扫码、手机验证码都能进,旧签名算法一碰就崩。你如果还在用 phpcms V9 默认的 QQ 登录模块,会发现跳转后 token 拿不到,session 也对不上,后台永远显示“未绑定”。这不是你代码写得烂,而是腾讯早就不审 OAuth1.0 应用了,接口说砍就砍。

有人想出曲线救国:开十几个 PC 端模拟器,脚本点点鼠标录宏。结果一天下来,CPU 飘红,IP 被风控,导出的数据还夹杂一堆“已退群”幽灵号。更惨的是,某些第三方黑盒工具表面号称一键采集,背地里偷偷上传 cookie,第二天你的主号连带一批小号集体冻结。

纯人工效率低,隐藏成员根本看不到;黑盒工具协议不透明,封号风险高,售后随时跑路。

OAuth2.0 之后,QQ 把授权域收紧,跨域请求会被拦截。以前只需一个 qq_appid 和 qq_appkey 就能调 qqOAuth2,现在还得配置 redirect_uri、scope、state,并且全程 HTTPS。若项目仍在 PHP 5.6 上跑,openssl 版本跟不上,TLS 握手失败,浏览器控制台只给你一句含糊的“network error”。想抓包分析?不好意思,WebSocket 加密帧已经混在长连接里,Charles 只能看到乱码。

// 老代码在 phpcms V9 里的位置
\phpcms\modules\member\index.php
public function public_qq_login2() {
    $appid = pc_base::load_config('system', 'qq_appid');
    $appkey = pc_base::load_config('system', 'qq_appkey');
    $callback = pc_base::load_config('system', 'qq_callback');
    pc_base::load_app_class('qqOAuth2', '', 0);
    $info = new qqOAuth2($appid, $appkey);
    // 后续步骤缺失,无法通过 im.qq.com 的新验证流程
}

这段从论坛淘来的插件当年确实能用,如今打开 https://im.qq.com 扫码登录,页面提示“应用不存在”,日志里只剩一行 Uncaught exception。协议变了,认证体系也变了,继续缝补丁只会越陷越深。

抓包才是硬道理:登录协议就三把钥匙

打开浏览器开发者工具,Network 面板先清空。接着在登录页输一次错误密码——不是为了登进去,是想看它往哪发了包。

一个 POST 请求飞向某个地址,参数里藏着 appid、一个验证盐、一个时间戳哈希。这三个东西就是腾讯登录体系的三把钥匙。appid 固定,每个业务线有自己的值,QQ 网页版是 716027609(后来改过几版,得实时抓)。验证盐是服务器下发的,每次请求前要从 check 接口拿,附带一个 uin 参数(QQ 号前缀,比如 0 代表未指定)。时间戳哈希则是本地 JS 生成,存在 cookie 里随请求送回。

这些东西很多文章讲得玄乎,其实你抓三遍就能看穿套路:先 GET check 拿 salt,再 POST login 提交加密后的密码。密码加密不是简单 MD5,是 md5(md5(password) + salt) 再加一次大写 hex 拼接 uin。腾讯那套 TEA 加密已经嵌套了好几层,但抓包看到的就是这个结构,跑不偏。

有个坑:包里的 verifycode 字段,check 接口返回的是一张图片验证码的 token,你得配上识别去填。验证码是 4 位字母数字,简单 OCR 就能过,但实际跑批时经常因为验证码识别不准卡住。我后来在 ThinkPHP 的登录类里加了三次重试逻辑,识别失败就重新 check 拿新图。

扫码登录其实更简单。WebSocket 连接到某个地址,发送一个 ptqrlogin 消息,服务器回一个二维码 token。你把 token 生成二维码图片扔到前端,用户扫完,回调里就能拿到一个临时凭证,然后做最终确认。全程不需要密码,但 cookie 里的 skey 和 p_skey 得持久化存下来,否则下次请求群列表时直接报 401。

我在封装的类里这么处理的:

namespace app\common\lib;

use think\Cache;
use think\Log;

class QQLogin
{
    private $appid = 716027609;
    private $cookieJar = [];

    public function checkSalt($uin = '0')
    {
        $url = "https://ssl.ptlogin2.qq.com/check?uin={$uin}&appid={$this->appid}&r=" . time();
        $resp = $this->httpGet($url);
        // 返回格式: ptui_checkVC('0','!ABCD','abcdef1234567890','...')
        preg_match("/'([^']+)','([^']+)','([^']+)'/", $resp, $m);
        return [
            'pt_verifier_salt' => $m[2],
            'verifycode' => $m[1],
            'check_token' => $m[3],
        ];
    }

    public function login($uin, $pwd, $salt, $vcode)
    {
        $encPwd = strtoupper(md5(md5($pwd) . $salt));
        $url = "https://ssl.ptlogin2.qq.com/login?uin={$uin}&pwd={$encPwd}&verifycode={$vcode}&appid={$this->appid}&pt_verifier_salt={$salt}";
        $resp = $this->httpGet($url);
        if (strpos($resp, 'ptui_login_success') !== false) {
            // 从 Set-Cookie 里提取 skey、p_skey
            $this->persistCookies();
            return true;
        }
        return false;
    }

    private function persistCookies()
    {
        $skey = $this->cookieJar['skey'] ?? '';
        $p_skey = $this->cookieJar['p_skey'] ?? '';
        Cache::set('qq_skey_' . $this->uin, $skey, 7200);
        Cache::set('qq_p_skey_' . $this->uin, $p_skey, 7200);
    }
}

这里故意没处理验证码识别——把这部分抽成独立服务了,后面会讲。cookie 持久化我用 ThinkPHP 的 Cache,存两小时,因为 skey 的有效期不长,过期要重新登录。扫码登录的 WebSocket 部分我没贴,那个涉及心跳包和状态轮询,代码长了点,但核心是监听 ptqrlogin 的返回状态码:0 表示成功,其他值继续等。

最烦的还真不是协议本身——腾讯隔三差五改参数名,去年有个字段改成 pt_vst,跑了两个月突然又改回去了。光靠文档维护?迟早跪。只有定期抓包对比才能跟上这帮人的节奏。

batch collect QQ group members

群成员采集:从列表到每个活人

登录成功只是进门,真正麻烦的是把群里的人都捞出来。QQ 网页版这块并没有官方文档写得明明白白,更多靠抓包和反复试探。核心思路很直白:先用 cookie 维持身份,再调 get_group_list 拉群列表,接着对每个群 ID 循环请求 get_member_list,一边翻页一边防封。

登录后的第一个坎是群列表接口。你期望它返回一个清晰的 JSON,结果往往带着混淆字段和分页令牌。我用 TEA 解密过一段返回里的 encrypted_group_ids,也遇到过 pfs 分页把起始索引藏在某个不显眼的键里。最烦的是腾讯某次把 API 路径从 v2/group/list 切回旧版 v1/qun/getlist,连带签名方式一起变,不更新就只剩 404。

<?php
// Laravel Job:只负责「拿群」这一件事
class FetchGroupListJob implements ShouldQueue {
    use Dispatchable, InteractsWithQueue, QueueableCollection;

    public function handle(HttpClient $client) {
        $url = 'https://v2.qq.com/cgi-bin/qun/get_group_list';
        $payload = [
            'bkn' => $this->buildBkn($client->cookie('skey')),
            'f': 'json',
        ];
        $resp = $client->post($url, $payload);
        // 有时返回在 data.info.gidlist;有时在 result.group_list
        $groups = $this->normalizeGroups($resp);
        $this->storeGroups($groups); // 存到本地 DB,后续按群并发
    }
}

你以为群成员就是不断往后翻页?别忘了有些群开了匿名、禁言、游客模式,接口会悄悄降级,甚至给你个空数组。另一个坑是 uin 和 group_id 不是一回事,有人用前者做外键,结果同步时错位。分页方面,pn 从 0 还是 1 开始取决于平台当天的心情;一旦出现 ps=0 且 data=[],多半是被限速了,立刻降频并切换代理。

我把单个群的成员抓取封装成 Job,按 5 个并发跑,数据库用 replace into 避免重复。碰到大群(超过两千人)会触发滑块或短信验证,这种就别硬刚,转人工复核。最难的不是并发控制,而是判断什么时候该退一步。

phone number cleaning and verification

清洗数据:去重、验证,分三档定价

抓回来的数据,确实比我想象的还要脏。uin 重复、手机号格式乱、空号夹杂、还有那种一串“test001”的测试号混进来。你要是直接把这几万条塞进 CSV 丢给甲方,对方反手就给你打回来——一群死号谁要?

最简单的,PHP 里过一遍去重?不够,同一个用户可能用不同方式加了两次群,uin 一样但备注不同。我习惯先按 uin 做第一层去重,再把手机号格式拉出来用正则扫一遍:

// 优先保留最近一条记录
$deduped = collect($rawMembers)->groupBy('uin')->map(function ($group) {
    return $group->sortByDesc('last_login_time')->first();
})->values()->toArray();

// 号码格式清洗:只保留11位纯数字,开头非1或长度不对直接扔
$validPhones = array_filter($deduped, function ($item) {
    return preg_match('/^1[3-9]\d{9}$/', $item['phone'] ?? '');
});

这里有个坑:uin 可能是负数(腾讯内部标识),你拿 intval 会溢出,建议全程用字符串比较。格式清洗阶段,我见过 +86 前缀的、空格插在中间的、甚至“1 8 6 x x x”这种——全要统一剥离。别指望甲方会帮你补全,他们只看结果。

很多人以为调个在线状态接口就能知道活跃不活跃。但腾讯对这个接口限得很死,同一个 IP 刷 200 次就封。我换了个思路:模拟加好友请求。不是真要加,而是发一个添加好友的请求,看返回码。如果是 0 表示允许添加,说明这个号最近登录过且没被冻结;返回 -1 或 1202 说明对方设置了拒绝或长时间未登录。当然这个操作不能太快,我每验证一个就 sleep 3 秒,配合代理池,一天能跑一万左右。

$response = $httpClient->post('https://qun.qq.com/cgi-bin/qun/add_friend', [
    'form_params' => [
        'bkn'      => buildBkn($skey),
        'frd_uin'  => $targetUin,
        'group_code' => $groupCode,
        'msg'      => '',
    ]
]);

$result = json_decode($response->getBody(), true);
if ($result['ec'] === 0) {
    // 可加好友,标记为高活跃
    $activePool[] = $targetUin;
}

别问我为什么不用某个在线接口——那个接口经常抽风,返回在线但你发消息就是失败。加好友验证虽然慢,但准确率能到 90% 以上。

清洗完毕的数据,我分成三档:S 级是最后登录在 7 天内且可加好友,这批直接标高价;A 级是最后登录在 30 天内但加好友受限,算普通活跃;B 级是超过 30 天无记录或返回冻结,这些送都没人要。导出 CSV 时,我顺便把号段(前 7 位)做了一次分组统计。比如 138xxxx 开头的高活跃号集中在哪个城市、哪个运营商,这些信息甲方很喜欢。写到文件头时注意编码,我用 fputcsv 加 UTF-8 BOM,否则 Excel 打开中文乱码。

$fp = fopen('active_segments.csv', 'w');
fputs($fp, chr(0xEF).chr(0xBB).chr(0xBF)); // BOM
fputcsv($fp, ['号段', '城市', '运营商', '活跃数量', '最后活跃日期']);
foreach ($segments as $seg) {
    fputcsv($fp, [$seg['prefix'], $seg['city'], $seg['isp'], $seg['count'], $seg['last_active']]);
}
fclose($fp);

收工。别忘了把那个 bkn 计算函数单独放一个 trait,后面改协议版本时只改一处就行。群成员采集做到这一步,剩下的就是运维层面的代理池轮换和异常告警——那是另一章的事了。

代码骨架:基于 Yii 的采集脚本

我在 models 目录里放了三件套:User.php(账号凭据)、Group.php(群元数据)、Member.php(群成员)。跨模块的工具函数扔到 components,里面就干两件事:算 bkn、兜底重试。耗时动作丢 jobs/,例如 FetchGroupListJob 与 FetchGroupMembersJob;控制器只剩两行:校验入参、投任务。

<?php
namespace backend\models;
use yii\base\Model;
class LoginForm extends Model
{
    public $uin; public $password; private $_client;
    public function rules(){ return [['uin','password','required']]; }
    public function login()
    {
        $this->_client = new \GuzzleHttp\Client(['timeout'=>20]);
        $resp = $this->_client->post('https://ssl.ptlogin2.qq.com/login',[
            'form_params'=>['u'=>$this->uin,'p'=>$this->password,'verifycode'=>''],
        ]);
        preg_match('/(Set-Cookie:[^\n]+)/i',$resp->getHeaderLine('set-cookie'),$m);
        $this->_client->getConfig()['headers']['Cookie']=trim($m[1]);
        return true;
    }
}

采集放进队列,失败自动重投:拿到群 ID 后循环拉成员,分页指针用 next_start 透传,超时就重跑,成功才落库。

<?php
namespace console\jobs;
use yii\queue\JobInterface; use GuzzleHttp\Client;
class IsActiveJob implements JobInterface{ public $uin; public $friend_token;
    public function execute($queue){
        $c = new Client(['timeout'=>15]); try{
            $r=$c->get("https://qun.qq.com/cgi-bin/qun_mgr/search_group_members",[
                'query'=>['gc'=>$this->group_code,'flt'=>$this->uin,'st'=>0,'end'=>10,'sort'=>0]
            ]); $j=json_decode($r->getBody(),true); if(($j['ec']??null)===0) return 1;
        }catch(\Throwable $e){ return 0; }}}

我把“可加好友”当最高置信度,返回 1 就进 S 级;能搜到但受限的标 A;抛异常或超过 30 天无记录的直接归 B。CSV 导出用 fputcsv,顺手塞 UTF-8 BOM,甲方 Excel 不乱码。

# 投递采集
yii queue/fetch --group_id=123456
# 投递清洗
yii queue/is-active --uin=123456789 --friend_token=abc
# 常驻监听
yii queue/listen &

crontab 每 10 分钟扫一次未处理:*/10 * * * * /usr/local/bin/php /var/www/html/yii queue/cron >> /var/log/qcollect.log 2>&1。supervisor 里把 queue/listen 拉起来,失败重启,内存上限 512M,滚日志按天切。这套东西换到 Laravel/ThinkPHP 也一样,无非是把门面与门牌号换个写法。

安全底线:封号应对与代理策略

写到这,该聊点实在的。协议跑通了,队列也转起来了,但腾讯的 WAF 不是摆设。我自己踩过最狠的一次——凌晨三点,用主号挂了俩采集任务,第二天醒来发现 QQ 被限制登录,所有群都退了。那之后我才老老实实把“安全”两个字当回事。

单个 IP 在短时间内发起大量登录请求,腾讯的风控模型会直接判为异常。我试过用 cURL 模拟,每秒一次,十分钟后返回的就不是 cookie 而是验证码页面了。解决方式分两层:一是给每个登录请求绑一个代理 IP,池子里至少备上五十个,HTTP 和 SOCKS5 混合着用;二是随机延时,不要让请求间隔落在固定的秒数上。比如用 usleep(rand(800000, 1500000)),把延迟控制在 0.8 到 1.5 秒之间,让时间戳看起来像真人操作。代理 IP 的质量比数量重要,那些公开的免费代理池,多半已经被腾讯标记过,用了反而更快触发验证。

登录频率踩过红线,腾讯那边直接甩个滑块验证码过来。我一开始也傻乎乎用 OpenCV 自己搞缺口识别,结果发现那背景图每次都是随机噪点,成功率连六成都不到,折腾半天心态都崩了。后来索性接了个打码平台,HTTP 接口把图丢过去,几秒钟坐标就回来了。成本?一次几分钱,但换来的是全天候采集不中断,这账怎么算都不亏。要是你只是小规模跑跑、不想额外花钱,把验证码截图存下来,写个简单的 Web 页面让人手动点一下,也够用。

最蠢的事就是拿自己的主号去验证协议。专门注册了一个新号,挂上实名认证,开了设备锁,但从不聊天。这个号只干一件事:生成 skey,然后扔给队列去检测好友状态。被封?顶多损失个空号,日常联系人毫发无伤。登录设备信息也要伪造完整——User-Agent 用 Chrome 120 的字符串,X-Forwarded-For 和 Client-IP 头都填上代理 IP,别留窟窿让腾讯抓到是脚本在跑。整套玩法的核心就俩字:像人。你越追求速度,离封号就越近。慢一点,随机一点,把容错做进去,才能跑得久。