简介:QZoneExport-master 是一个面向 QQ 空间用户的数据导出工具项目包,适合需要备份个人动态、留言、相册等内容的普通用户,也适合希望研究社交平台数据交互的开发者。压缩包共含149个文件,大小约2.59MB,以 JavaScript 脚本、HTML 页面、CSS 样式及 PNG 图片等前端资源为主,同时包含 PowerShell 执行脚本、JSON 配置和项目说明文档,便于直接部署或二次开发。目前已有947人学习浏览,可见其具备一定实用参考价值。通过该项目,用户可以了解如何借助腾讯开放接口实现 QQ 空间日志、说说、留言等数据的批量导出与本地存储,并理解备份与恢复的基本流程;开发者也能从源码中学习到 OAuth 授权、接口调用与前端展示的一套完整实现思路。整体目录结构清晰,样式组件齐全,既适合普通用户快速运行,也适合开发者扩展功能,是一份简便易行的社交平台数据备份参考资源。
1. 先别急着翻接口:这份QQ空间备份到底解决了什么
某开发者准备把自己十几年的QQ空间数据搬回家,翻了一圈官方导出工具,发现日志能导出HTML,相册却得一张一张点,说说和留言压根没有出口。折腾了两天,他放弃手工,改按接口同步的方式写了一套备份脚本——日志、说说、留言、相册原图全部落到本地,支持断点续传和增量同步。这套脚本整理打包后,就是这份QQ空间备份资源。
它解决的问题不是“导出个页面”,而是把分散在说说的JSON、日志的HTML、相册的原始图片,统一成一份可离线浏览的数据目录。适合正在经历“官方导出缺胳膊少腿”的人,也适合想把QQ空间从平台里搬出来做二次加工的开发者。
这份资源不是一个双击就能跑的黑匣子。它包含接口路径、登录与分页代码、转换工具和一批踩坑记录,你需要按自己的账号改参数才能跑通。这恰好合理:备份自己的数据,就不该依赖某个现成软件替你包办一切。
2. 数据地图先画对:说说、日志、相册、留言各自走什么接口
动手写代码之前,先得搞清楚QQ空间上的数据分别长什么样。这里的核心区别是:说说是结构化JSON,日志是富文本HTML,相册是图片二进制,留言又是另一套JSON接口。四类数据的接口形态不同,落盘策略也必须分开设计。
| 数据类别 | 接口特征 | 返回内容 | 落盘建议 |
|---|---|---|---|
| 说说 | taotao/emotion 分页接口 | JSON,含 createTime、content、picInfo | JSON文件 + 图片原图 |
| 日志 | blog 列表 + 详情 | HTML片段 | HTML文件 + 内嵌图片 |
| 留言 | 留言板 JSON 接口 | JSON,字段与说说接近 | JSON文件 + 图片原图 |
| 相册 | album/list + photo/list | JSON,含图片URL与说明 | JSON文件 + 相册原图 |
2.1 登录态是前提:cookie怎么拿、能撑多久
QQ空间的所有数据接口都要求登录态。常见做法是:浏览器里正常登录一次,从开发者工具里复制 cookie 字符串,注入到脚本的请求会话里。这个方法比模拟扫码登录简单得多,而且不触发额外的验证流程。
cookie 不是永久有效的。我自己遇到的情况是,同一份 cookie 用一两天没问题,第二三天再跑,请求开始出现鉴权错误码。所以脚本里要留一个明确的报错出口——发现鉴权失败,停下来告诉你该重新复制 cookie 了,而不是无限重试浪费带宽。
另外注意 cookie 里的 p_skey 字段,它通常被用来计算请求参数里的 g_tk。没带 g_tk 或者 g_tk 算错,接口会直接回错误码。所以登录之后第一件事,是把 p_skey 存下来,之后每个请求都要带。
2.2 说说与留言:JSON接口与分页参数start的意义
说说的核心接口是 emotion,它返回的是一个 JSON对象,里面有一个 msgList 数组。每个元素对应一条说说,字段比较规整:createTime 是发布时间,content 是正文,picInfo 里是配图信息。留言板的接口结构和这个几乎一样,处理代码可以复用同一套函数。
这个接口的分页方式值得多说一句:参数 start 不是页码,而是偏移量。第一页 start=0,第二页 start=20,第三页 start=40,以此类推。pageSize 通常取 20 或 50。pageSize 设大了,单次请求体量大、容易被风控;设小了,请求次数变多。我一般用 pageSize=20,配合 1 秒到 2 秒的请求间隔,跑得又稳又不惹眼。
判断是否拉完的标准,不是 start 到了某个值,而是返回的 msgList 长度小于 pageSize,或者直接为空。用这两个条件做循环出口,才能避免在边界情况下多拉一次空请求。
2.3 日志与相册:HTML片段与图片二进制,处理思路不同
日志接口返回的不是干净正文,而是一段带内联样式的 HTML 片段。这意味着你不能用处理说说的 JSON 方式硬套。我一般会把这段 HTML 原样保存为 .html 文件,图片单独下载后,再在转换阶段把 HTML 里的图片地址替换成本地路径。这样做的好处是保留原始排版,不会因为过滤标签而丢掉字体、颜色和引用块。
相册的逻辑又不一样。album/list 返回的是相册元信息,包括相册ID、名称、照片数量;photo/list 才返回具体照片列表。每个相册必须单独拉一次照片列表,再逐个下载原图。这里有一个常见的坑:photo/list 单次最多返回一批固定数量的照片,如果你的相册超过这个数量,必须以相册为单位循环翻页,否则照片数会莫名对不上。
日志和相册都不适合“一次性全量请求所有数据”。日志详情需要逐条请求,相册原图需要逐个下载。所以这两个模块的备份速度取决于条目数,跑的时候要有耐心。
3. 主流程跑起来:登录、分页拉取与磁盘落盘
数据地图画完,接下来就是把主流程写出来。我的做法是分成三层:登录层负责维护会话,拉取层负责分页循环,落盘层负责按规则写文件。三层分开,后面排查问题时不用从头看代码。
3.1 一行session接管登录态:requests.Session与cookie注入
用 requests 库的 Session 来承载 cookie,是最省事的做法。Session 会自动保存服务端返回的 cookie,并且对同一主机的请求自动带上,不需要每次手动拼 header。
import requests def build_session(cookie_str): s = requests.Session() s.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": "https://h5.qzone.qq.com/" }) for kv in cookie_str.split(";"): kv = kv.strip() if not kv: continue k, _, v = kv.partition("=") s.cookies.set(k, v, domain="qq.com") return s这里的核心是s.cookies.set(k, v, domain="qq.com"),把复制到的一整串 cookie 逐个拆成键值对写进 Session。为什么要指定 domain?因为 QQ 空间的接口跨多个子域,比如 h5.qzone.qq.com 和 user.qzone.qq.com,统一设置到 qq.com 才不至于在子域切换时丢了登录态。
User-Agent 和 Referer 是常规防拦截配置。Referer 固定指向 h5.qzone.qq.com 的原因是,部分接口如果发现 Referer 是空的或者来自脚本环境,会直接拒绝响应。cookie_str 从浏览器开发者工具里复制,注意别带上浏览器自身生成的多余空格。
3.2 分页拉取写成可中断重入的循环:start、休眠、重试
拉取函数的核心是循环。我把每个分页请求写成一个独立函数,这样一旦中途出错,保存好当前 start 值,下次从断点继续就行。
import time import json def fetch_msg_page(session, uin, start, page_size=20): url = "https://h5.qzone.qq.com/proxy/domain/taotao/taotao/emotion" params = { "uin": uin, "pageSize": page_size, "start": start, "format": "json", "g_tk": session.cookies.get("p_skey", "") } resp = session.get(url, params=params, timeout=10) data = resp.json() # 接口返回正常时 code 为 0,非 0 需要打印出来人工判断 if data.get("code") != 0: raise RuntimeError(f"接口返回异常 code={data.get('code')} msg={data.get('message')}") return data["data"]["msgList"] def fetch_all_until_end(session, uin, save_func, page_size=20): start = 0 total = 0 while True: msg_list = fetch_msg_page(session, uin, start, page_size) if not msg_list: break for msg in msg_list: save_func(msg) total += len(msg_list) start += page_size time.sleep(1.5) if len(msg_list) < page_size: break return total这里的start是偏移量,不是页码,所以循环体里更新方式是start += page_size,而不是start += 1。很多人写错成加 1,结果每一页都拉同一批数据。
休眠为什么要 1.5 秒?因为连续快速请求会在几分钟内触发风控,到时候返回的就不是数据而是错误码。1.5 秒是速度和安全的折中。如果日志、相册这种要拉更大量数据的接口,我会把间隔提到 2 秒。
save_func是落盘回调函数,它接收一条消息对象,负责把 JSON 存好、把图片下载好。这样拉取层不关心数据具体存到哪,替换落盘策略时不需要动循环代码。
3.3 磁盘目录怎么组织:按类型/时间分目录,避免二次搬运
落盘的目录结构直接决定后面转换脚本好不好写。我推荐的布局是这样的:
backup_root/ ├─ shuoshuo/ │ ├─ 2024-01-01_120001_001.json │ └─ images/ ├─ blog/ │ ├─ 2024-01-01_120001_001.html │ └─ images/ ├─ album/ │ ├─ album_2023_albumlist.json │ ├─ pics_2023_a1c2e3.json │ └─ images/album_2023/ ├─ msgboard/ └─ meta/ └─ backup_status.jsonJSON 文件按时间戳+序号命名,方便直接排序;图片单独放 images 子目录,避免和 JSON 混在一起;相册按相册ID建子目录,因为一个相册可能有一百来张图,混着放会让总数核对变成灾难。
backup_status.json是元信息文件,记录每个模块上次备份到的最后一条时间戳。这个文件是后面做增量备份的断点依据。我建议每次成功跑完一个模块,就把当前断点写进去,而不是等全部跑完再写,这样中途断掉也能从最近位置恢复。
这一层没有高深技术,但目录命名规范直接决定你后续转换脚本要写多复杂。目录越整齐,转换越省事。
4. 把原始数据转成离线站点:时间戳、图片懒加载与HTML索引
数据落盘只是第一步,备份的意义在于“以后能看”。原始 JSON 没法直接翻,所以要用一个转换脚本,把所有数据加工成本地可浏览的 HTML 索引站。这一步有三个高频坑:13位时间戳、图片懒加载、防盗链。
4.1 13位时间戳与图片懒加载:最容易被忽略的两个字段
QQ空间接口返回的时间戳是毫秒级的,长度 13 位。如果你当普通秒级时间戳用,格式化出来永远是 1970 年或 1970 年代,这基本是每个人都会遇到的第一个翻车点。
图片字段的坑更隐蔽。JSON 里返回的图片地址,直接取src很可能拿到的是一张 1x1 的占位图,因为前端页面做了懒加载,真实图片地址藏在picInfo的ori_url或者>import time def ts_to_str(ts_ms, fmt="%Y-%m-%d %H:%M:%S"): # 接口返回的是毫秒级时间戳,这里必须除以 1000 ts = int(ts_ms) / 1000 return time.strftime(fmt, time.localtime(ts)) def pick_image_url(msg): pic_info = msg.get("picInfo") or {} # 优先使用原始大图字段,没有再用其他字段兜底 if pic_info.get("ori_url"): return pic_info["ori_url"] return pic_info.get("url")
ts_to_str里除 1000 是必须的,一旦漏掉,后面的所有时间排序和文件命名都会错。pick_image_url体现的是选字段优先级:原图优于缩略图,避免辛苦拉回来的都是模糊小图。
4.2 图片下载要带Referer,文件名别用原始URL
图片服务的防盗链规则和页面接口不太一样。如果直接裸请求图片 URL,大概率收到 403。解决办法是给下载请求单独加 Referer,指向https://user.qzone.qq.com/。
文件命名也不要直接把 URL 尾巴拿来用。URL 里常带时间戳参数、随机参数,直接在本地创建文件会得到一堆乱码名。我用时间戳加图片序号重命名,再记录原始 URL 到 JSON 里,保证可以回溯。
def fetch_image(session, url, save_path): headers = {"Referer": "https://user.qzone.qq.com/"} r = session.get(url, headers=headers, timeout=15) if r.status_code != 200: print(f"下载失败: {url} -> {r.status_code}") return False with open(save_path, "wb") as f: f.write(r.content) return True下载函数的逻辑很简单:带 Referer 请求,状态码 200 才落盘,失败就打印 URL 但不中断。整个备份跑完后,再根据打印日志统一重试失败项。这样设计是因为备份几万张图时,偶尔失败是常态,中断所有任务反而让问题变大。
4.3 生成HTML索引页:树状目录与内容列表
全部转成 HTML 页面的思路是:每个 JSON/HML 文件本身是内容页,另外单独生成一个 index.html,把全部条目按时间倒序列出来。索引页用相对路径链接,这样整个 backup_root 目录拷到任何机器上都能打开。
import os def build_index(index_path, entries): header = "<html><head><meta charset='utf-8'></head><body><h1>QQ空间备份索引</h1><ul>" footer = "</ul></body></html>" items = [] for e in entries: rel = os.path.relpath(e["path"], os.path.dirname(index_path)) items.append(f"<li><a href='{rel}'>{e['title']}</a> · {e['time']}</li>") with open(index_path, "w", encoding="utf-8") as f: f.write(header + "\n".join(items) + footer)os.path.relpath是用来把绝对路径转成相对路径的关键,不然换电脑后路径变了,索引页全部打不开。标题字段可以取说说前 20 个字,或日志的标题字段;时间用前面格式化好的字符串。只要 entries 按时间倒序排好,生成的索引页就是一份可以年翻的备份目录。
这一步做完,备份从“数据文件”变成了“可浏览的站点”。后面验证备份是否完整,也直接在这个索引页上抽查。
5. 备份翻车排查:五个高频问题与对应处理
跑备份脚本不翻车是不可能的。这一章把我实践中踩过、也帮人排查过的五类高频问题列出来,每条按现象、原因、解决三步写清楚,省得你再走一遍弯路。
5.1 问题一:连续跑几分钟后,所有请求返回错误码-3000
现象:脚本前几分钟正常,往后再请求,返回 JSON 里 code 变成负值,message 提示非法请求。等一会儿再跑,又恢复正常。
原因:请求频率触发了风控。每个接口每分钟的请求次数是有限制的,日志和相册这类需要逐条请求的模块,特别容易踩线。
解决:把请求间隔从 1 秒提到 2 秒到 3 秒,并且在循环里随机化间隔,用time.sleep(random.uniform(1.5, 3.0)),避免节奏规律。如果还是被限流,就按模块拆分执行,比如白天只跑说说,晚上跑相册。不要试图硬闯,风控加重的话可能整批请求都变非法。
5.2 问题二:图片全是一模一样的灰块
现象:下载到本地的图片文件大小都差不多,打开是同一张灰色占位图,根本不是原图。
原因:取图字段选错了。JSON 里多个字段都像图片地址,但其中src是懒加载占位,真图地址在其他字段里。之前用src直接下载,自然全中招。
解决:下载前先打印一条消息的完整 JSON,人工确认原图字段的名字。这个字段通常是ori_url或类似命名。改掉pick_image_url函数后,重新下载失败文件即可。已经下载的占位图建议直接删掉,因为浏览器缓存经常会让你误以为下载成功了。
5.3 问题三:时间戳格式化出来全是1970年
现象:所有说说时间显示成 1970 年 1 月 1 日前后,排序完全乱掉。
原因:接口返回的是 13 位毫秒级时间戳,而你用整型直接除以 86400 再转时间,单位对不上。严格说,问题出现得比你想的还要早:格式化函数里没除以 1000。
解决:把ts_to_str里的ts = int(ts_ms) / 1000这行补上。改完之后重新生成索引页,时间就恢复正常了。这个坑几乎人人会踩一次,所以我把它写在转换章节的前面,算是给个预防针。
5.4 问题四:相册张数对不上,总是少一截
现象:相册列表里显示 200 张,拉完照片列表后数一下本地文件,只有 150 张。
原因:photo/list 接口有单次返回数量上限,默认 pageSize 只拉了第一页,循环条件又写成了“返回列表为空才停”。当最后一页恰好返回满一页数据时,循环就漏掉了。
解决:相册内部也按 start 偏移量循环拉取,出口条件改成“本次返回数量小于 pageSize”。并且每拉完一页,拿累加数和相册元信息里的 total 比对,对不上就立刻告警。不要等到全跑完再核对。
5.5 问题五:重新跑一遍全量备份,重复下载了几万条
现象:第二次跑备份脚本,把第一次已经拉过的数据又下载了一遍,浪费了时间和流量。
原因:主循环没有记录断点,每次都是从 start=0 开始。全量备份场景小,数据量大了以后,重复下载的代价会成倍放大。
解决:每个模块跑完后,把最后一条数据的 createTime 写进backup_status.json。下次跑之前读这个文件,从上次的时间戳之后开始。代码在第 6 章给出,这个断点思路同样适用于日志和留言模块。
6. 备份完之后:快照校验、增量同步与收尾习惯
备份跑完不等于事情结束,至少要花几分钟验证三件事:数据量对不对、索引页能不能打开、图片是不是原图。我的校验方法是先用脚本统计各模块的条目数,跟接口返回的总数比对;然后从索引页里随机抽三到五条,点进去看内容排版;最后在图片目录里随机打开几张,确认文件大小正常、没有全是 1KB 的占位图。
验证通过后,增量同步就成了你后续维护这份备份的主要动作。增量不是重新跑全量,而是从上次断点位置继续拉。
import json import os def load_breakpoint(meta_path, module): if os.path.exists(meta_path): data = json.load(open(meta_path, encoding="utf-8")) return data.get(module, {}).get("last_time_ms", 0) return 0 def sync_incremental(session, uin, module, save_func, page_size=20): meta_path = "meta/backup_status.json" last_time_ms = load_breakpoint(meta_path, module) start = 0 while True: msg_list = fetch_msg_page(session, uin, start, page_size) if not msg_list: break for msg in msg_list: ts = int(msg.get("createTime") or 0) if ts <= last_time_ms: # 已经同步过的数据,直接结束本模块增量 break save_func(msg) start += page_size time.sleep(1.5) if len(msg_list) < page_size: break这段代码的核心判断是ts <= last_time_ms:遇到时间戳小于等于断点的数据,说明已经追平了,立刻停止。为什么不用页码做断点?因为删除、重复等操作会让页码漂移,而时间戳是稳定的。
增量跑完后,同样要更新断点。我习惯把最终断点写入backup_status.json,并且在文件末尾加一行注释记录本次同步时间。从那以后,我每个月跑一次增量同步,每次都先看断点文件再决定是否全量重校验,再也没有因为重复下载白白刷掉一整晚流量。
希望这份踩坑记录能帮你在备份QQ空间时少绕几个弯。
本文还有配套的精品资源,点击获取