把X站关注列表完整导出成一份本地表格这件事看起来简单实际要过的坎真不少。我在尝试了几种路子之后最推荐的还是浏览器开发者工具配合JavaScript控制台这套组合拳。它能读取你自己账号视角下的关注、粉丝数据不依赖第三方站点不装额外插件不碰任何加密登录流程适合所有具备一点点前端基础的人直接上手。下面这套操作完整记录了从找到数据接口、翻页拉取、清洗字段到最后导出CSV表格的全过程包括我在中途踩过的坑和对应的排查思路希望能帮你少走弯路。1. 为什么优先选择浏览器开发者工具而不是现成爬虫1.1 第三方平台的隐患你交出去的不只是“查询一次”的授权很多人整理关注列表的第一反应是找第三方数据平台输入自己的账号信息或者扫个码然后等它生成一份“我的关注分析”。这类服务运行逻辑通常是模拟你的登录状态去请求数据意味着你必须把账号凭证或登录授权交给一个你无法完全信任的第三方。数据拉完之后它自己手里也保留了一份这份数据后续被用在什么地方、会不会被合并到其他数据库、有没有可能泄露你完全无法控制。为了一份几百人的关注清单把账号安全交出去这笔账怎么算都不划算。第三方站点也可能因为平台接口变动、风控策略调整而随时挂掉今天能用下周可能就显示“暂不支持”。更别提某些站点还会故意把功能拆成会员付费区区一个导出功能也要开卡完全没有必要。1.2 开发者工具天然具备三项硬能力浏览器开发者工具DevTools在这类需求里几乎是为你量身定做的原因有三个它是用户侧合法的调试入口打开控制台、查看网络请求、执行脚本本来就是浏览器提供给每个普通用户的功能不需要破解或越权。它天然携带当前登录态你在页面上已经处于登录状态控制台里发起的请求会带上当前会话的Cookie和必要上下文不需要自己处理登录、加密、验证码。它能让你“看见”数据是怎么来的Network面板记录页面发出的每一个请求和返回内容顺着“翻页”这个动作就能反向定位到列表数据接口。三个能力叠加之后你真正要做的事情其实很简单观察页面正常的请求把分页参数往后拉再把返回的一批批数据攒起来。整个过程没有绕过任何权限也没有破解任何签名只是把你自己在浏览器里本来就能看到的关注列表用一种更省力的方式汇总导出。这也是我为什么坚持用这套方案的根本原因。2. 先定位数据接口Network面板的正确打开方式2.1 开着面板翻页从网络记录里反推列表请求很多新手第一步就卡住了明明关注列表在页面上显示得好好的却不知道数据从哪来。定位方法非常机械但极其有效打开关注页面或粉丝页面。按F12打开开发者工具切到 Network网络标签。勾选 Preserve log保留日志这个动作至关重要否则页面一跳转或刷新之前的请求记录就被清空了。保持面板开着在页面上点击“下一页”或者滚动到底部触发下一页加载。在请求列表里筛选XHR/Fetch类型找接口名里带follow、fans、relation、list这类关键词的那个请求。实际操作时我习惯在 Filter 输入框里直接敲follow或词根请求列表会被过滤得很干净。找到那条请求后先别急着看内容右键复制它的完整 URL或者直接在请求详情里看 Payload 和 Response。2.2 读懂列表接口的 JSON 结构点开请求的 Preview 或 Response 标签通常会看到一段 JSON。不同平台的返回结构会有差异但大多数长这样{ code: 0, message: 0, data: { list: [...], total: 1234, cursor: { next: 100, has_more: true } } }真正有用的信息都在data里。list是当前这一页的关注/粉丝明细数组total是总数cursor是一些新式接口用来翻页的游标。list里每个元素通常是一段用户信息对象常见字段我整理成了表格字段名示例含义说明mid/user_id用户唯一ID适合做去重主键这个值一般不会变uname/nickname昵称可能包含特殊字符导出时要注意转义sign/signature个人签名可能为空字符串avatar/head_url头像地址完整链接可以直接访问sex/gender性别可能是“男/女/保密”也可能是数字编码relation/follow_status关系状态里面常含是否互关、是否拉黑等嵌套信息建议第一次接触时把整个list[0]在控制台里console.log出来对照页面上的显示内容逐字段核对比看文档更快。2.3 分页参数页码与游标两种玩法接口分页主要有两套逻辑搞混了最容易写错循环。老式接口通常是pn页码ps每页条数。比如?pn1ps50意思是取第一页一页50条下一页就把pn改成2。这种分页等价于翻书逻辑清晰循环条件就是当前已取数量 total。新式接口则改用游标分页常见的参数是cursor或offset服务端返回的data.cursor.next告诉你下一次要从哪个位置继续取。此时循环不能再把游标加一而是要把上一页返回的next值原封不动塞进下一次请求的参数里。这个区别可以用一个例子解释页码方式像每本书固定从第1页翻到第100页游标方式像迷宫里的路标你只能沿着当前路标指向的下一个路标往前走不能自己乱跳。如果硬把游标当页码用轻则重复拉取同一批数据重则进入死循环。3. 核心脚本怎么写从单页请求到分批拉取3.1 在Console里发起带登录态的请求定位到接口之后先手动验证一次能否通过fetch正常拿到数据。打开 Console 标签执行下面这段最简单的脚本(async () { // 注意URL 从 Network 面板里复制不要手敲 const url https://api.xxx.com/web/following/list?pn1ps50clientxxxsignxxx; const res await fetch(url, { credentials: include }); const json await res.json(); console.log(json.data.list); })();这里有两个细节我要特别强调。第一URL 一定从 Network 面板里完整复制不要自己按记忆拼。很多接口会带公共参数、时间戳、动态签名手拼很容易漏漏一个就返回错误。第二credentials: include建议显式写上它在同源请求下无所谓但碰上接口域名与页面域名不一致时能确保Cookie被正确携带。如果这一步能在控制台打印出数组恭喜你最难的部分已经过去了。接下来的所有工作都建立在“单页请求成功”这个基础上。3.2 循环分页与防呆保护单页请求成功后把它封装成一个通用的翻页函数然后循环拉取直到总数拿完。我这边的模板是const sleep (ms) new Promise((resolve) setTimeout(resolve, ms)); async function fetchPage(page) { const url https://api.xxx.com/web/following/list?pn${page}ps50clientxxxsignxxx; const res await fetch(url, { credentials: include }); const json await res.json(); if (json.code ! 0) throw new Error(接口返回错误: ${json.code}); return json.data; } (async () { let page 1; let results []; let total Infinity; while (results.length total page 60) { // 最多拉60页保险 const data await fetchPage(page); const list data.list || []; results results.concat(list); total data.total || results.length; console.log(第 ${page} 页完成累计 ${results.length} / ${total}); page 1; await sleep(1500 Math.random() * 1000); // 随机间隔避免机械频率 } console.log(results); })();这段循环里有几个防呆设计值得解释。最大页数保护非常必要如果total字段因为接口变动而不返回循环不会变成无限拉取到60页自动停。随机间隔也不是矫情接口请求一旦出现均匀且高频的特征很容易被服务端的风控识别成脚本行为间隔在1.5到2.5秒之间随机抖动是“普通人在手动操作”和“脚本在自动拉取”之间最直观的区别。如果你的数据量不大比如就五六百个关注把每页条数调到50十几秒就能跑完等待时间完全在可接受范围内。3.3 数据处理去重、过滤与字段裁剪分页循环拉完的results是原始数据直接拿来做表格会有两个问题翻页衔接处可能出现重复数据原始对象里塞满了几十个用不上的字段。处理方式是写一个清洗函数const cleaned [...new Map(results.map((u) [u.mid, u])).values()] // 按 mid 去重 .filter((u) u.uname) // 过滤掉已注销/空昵称账户 .map((u) ({ 用户ID: u.mid, 昵称: u.uname, 签名: u.sign || , 性别: u.sex || 未知, 头像: u.avatar || , 是否互关: u.relation u.relation.me_following ? 是 : 否, })); console.log(cleaned);用Map以mid为键去重是最稳的写法比Set或includes过滤性能好也不会因为对象内部嵌套结构不同而漏判。filter这一步会把已经注销的账号从列表里剔除避免最后导出的表格里出现一堆没头像、没签名的空壳数据。字段裁剪时顺手把英文键名换成中文表头这一步看似不起眼却能让最终交付的表格被任何人直接看懂。4. 落本地把JSON变成一份能用的表格4.1 不装任何库手写CSV导出数据在控制台里打印出来只是第一步真正要交付给“未来的自己”的是一份可永久保存的本地文件。CSV 是最通用的格式Excel、WPS、Numbers 都能直接打开而且生成它不需要引入任何第三方库。导出代码我写成了一个函数逻辑很清楚function toCsv(arr) { if (!arr.length) return ; const header Object.keys(arr[0]).join(,); const rows arr.map((row) Object.values(row) .map((v) ${String(v ?? ).replace(//g, )}) .join(,) ); return \ufeff [header, ...rows].join(\n); } const blob new Blob([toCsv(cleaned)], { type: text/csv;charsetutf-8, }); const a document.createElement(a); a.href URL.createObjectURL(blob); a.download 关注列表_${new Date().toISOString().slice(0, 10)}.csv; a.click(); URL.revokeObjectURL(a.href);注意String(v ?? ).replace(//g, )这行绝大多数刚接触CSV导出的人都会在这里翻车。昵称和签名里可能包含英文逗号、双引号、换行符如果不用双引号包起来再对内部双引号做转义导出的表格会出现列错位数据看着对不上行。另外文件开头拼上的\ufeffBOM头也不可省略否则Excel打开这个CSV文件时会把中文显示成乱码。这两处都是“不踩一次不知道存在”的细节。4.2 字段映射与Excel乱码问题生成的CSV默认用英文表头不行我给上一段代码的字段全换成了中文。如果你直接沿用接口返回的英文键名别人拿到表格后每个字段都得猜一遍含义体验很差。建议在清洗环节就完成表头映射比如mid - 用户ID、uname - 昵称、sign - 签名、avatar - 头像。如果打开CSV后中文全部显示成乱码原因基本锁定为缺少BOM头。解决办法就是在CSV内容最前面加上\ufeff。还有一个小技巧把下载文件的扩展名写成.csv而不是.txt很多操作系统会根据扩展名选择默认打开程序扩展名不对会被记事本接管。4.3 拿到表格后还能做的小分析关注列表导出后如果你同时导出了粉丝列表可以做一件对内容创作者很有价值的事计算互关率。思路是取关注列表和粉丝列表的mid交集用一套代码就能搞定const followerMids new Set(followerCleaned.map((u) u.用户ID)); const mutuals cleaned.filter((u) followerMids.has(u.用户ID)); console.log(互关人数${mutuals.length}); console.log(互关率${((mutuals.length / cleaned.length) * 100).toFixed(2)}%);这个数据能直观反映你的粉丝黏性。另外还可以把关注列表按“是否互关”拆分、按性别分布统计、找出签名里标明“接商单”“投稿合作”的账号这些都是数据的衍生价值。导出的表格不只是躺在硬盘里的一张表它本身就是一把分析自己账号状况的钥匙。5. 踩过的坑状态码、动态签名与风控的真实排查过程5.1 403/412直接fetch为什么会被拦我第一次执行脚本时遇到一个诡异现象同一个接口URL复制到浏览器新标签页打开能正常返回数据放进控制台fetch却返回 403/412。我在排查上花了很长时间中途一度怀疑是请求头缺了什么关键字段。后来一步步对照发现新标签页打开URL时浏览器默认发送的请求头很少但它能成功说明服务端校验的重心不在固定的Referer或Origin上控制台里第一次fetch失败后我隔了一分钟再次尝试居然又成功了问题回到本质——第一次失败是因为我在短时间内反复手动发了好几次请求触发了频率限制放慢节奏后自动恢复。这个坑的启发是遇到 403/412优先排查请求频率而不是急着补请求头。频率特征比请求头更容易被风控捕捉而且它会在短时间内恢复。如果你加了随机间隔后依然报错再回头检查Referer和Origin是否缺失。5.2 改页码就报负数错误码动态签名上下文另一个更隐蔽的坑出现在分页循环里。页面里手动点“下一页”完全正常但我把复制来的URL里的pn1改成pn2之后接口返回了一个负数错误码比如常见的-352。这个现象说明接口带了动态签名参数页面每次发起请求前都会用当前时间、随机值、请求参数集合算出一段签名签名与参数是绑定的。直接改pn导致参数与签名不匹配服务端自然拒绝。遇到这个情况我的建议是千万不要去逆向签名算法。在浏览器里获取自己账号的数据根本不需要破解任何安全机制因为你已经身处页面上下文内部。更稳妥的做法有两个复用页面刚刚发出的真实请求打开Network面板找到最近一次成功的列表请求把完整URL复制下来只修改pn或cursor参数其他参数包括签名原样保留。页面刚请求过的签名在短时间内依然可用于同参数集合下的其他页码实测在几十页内问题不大。读取页面内嵌的初始化数据很多站点会在首屏HTML里塞一个window.__INITIAL_STATE__之类的全局变量里面已经包含了第一屏数据。把目标收敛为“合并页面已经加载的数据”完全不构造新请求自然不会有签名问题。本质上这个坑的解决方案就是放弃“全新构造请求”改为“站在页面的肩膀上继续走”。5.3 请求太猛被限流我的频率控制取向不同平台对个人账号的接口访问频率阈值不一样谁也不会把阈值公开。我根据自己的实践给出一套保守参数仅供你在自己账号上参考原则是宁可慢不可快项目我的保守取值原因单页请求间隔1.5秒 - 2.5秒随机避免均匀高频请求的脚本特征单次运行最大页数60页约3000条防止失控兼顾绝大多数个人账号量级触发限流后停止脚本24小时内不再跑让风控判定恢复常态换登录态如果浏览器重新登录先手动翻页验证一次再跑脚本避免在新会话下盲跑被限流时通常的表现是返回正常code但list为空或者接口返回“操作频繁请稍后再试”。别硬顶硬顶只会让风控等级升高。关掉控制台过一天再跑基本就恢复正常了。6. 边界与底线这套方法能用在哪不能用在哪6.1 个人数据可携带性你只能导自己的数据必须把适用范围说清楚整套脚本里所有请求都建立在“你当前账号的登录态”之上读到的数据是这个账号视角下可见的关注列表和粉丝列表本质是导出自己的个人数据。这对个人来说是数据可携带性的合理诉求。但同一套方法不能扩展到“批量抓取别人的粉丝列表”上。别人的关注/粉丝数据虽然在他的主页上是公开可见的但把大量账号的数据抓下来汇总、分析、使用就超出了个人浏览的范围会触及隐私边界也不符合平台的使用规则。我在文章里写的所有技巧默认前提都是只操作自己的账号只获取自己的数据。6.2 我的几条自用原则经过几次实际运行之后我给自己定了几条铁律也一并写在这里只拉自己的数据不写任何针对他人账号的采集脚本。导出的数据只用于本地备份和统计分析不公开、不传播、不交给任何第三方。严格控制请求频率哪怕慢一点也不让脚本变成一台“执着请求机”。不分享Cookie和登录Token整个流程没有任何需要把会话信息交出去的动作。如果会话过期主动去浏览器页面重新登录不要在控制台里反复重试报错的请求。这些原则不是套话是从实际运行中总结出来的安全边界。比起“能把数据拉出来”的快感我更看重“整个操作从头到尾都干干净净、不越界”的安心。拿到最终表格后我还会定期跑一次同样的脚本把新的关注列表和上一次导出的做对比。哪些UP主取关了、哪些号注销了、哪些新增关注是从哪里来的一目了然。这套流程最适合变成一份“长期数据日记”每月花十分钟跑一遍攒一年再看你会对自己账号的关注生态有非常清晰的认识。