<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>mss 的随便记录</title><description>想到哪写到哪</description><link>https://mssworld.me/</link><language>zh_CN</language><item><title>同一集下载两次，得到一份损坏的音频</title><link>https://mssworld.me/posts/concurrent-download-corruption/</link><guid isPermaLink="true">https://mssworld.me/posts/concurrent-download-corruption/</guid><description>自动下载和手动下载撞在一起，两个字节流写同一个 .part；cancel token 被顶掉；根子是判重和占位之间跨了 await。</description><pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;流声里有个 bug：开着「自动下载最新一集」，某天又手动点了同一集的下载，结果那集音频是坏的，点取消也没反应。&lt;/p&gt;
&lt;p&gt;这个 bug 值得写，它是三个小问题叠在一起，漏掉哪个都修不干净。&lt;/p&gt;
&lt;h2&gt;两条路径撞在一起&lt;/h2&gt;
&lt;p&gt;开了「自动下载最新一集」的节目，刷新缓存时会自动把最新一集存到本机。这时候我正好手点同一集，两条下载路径就同时起来了。&lt;/p&gt;
&lt;p&gt;两条路径各自先判断一下「这集是不是在下载」。问题是这个判断和真正的下载之间，隔了两个 &lt;code&gt;await&lt;/code&gt;——一个查 WiFi 设置，一个读存储。&lt;code&gt;await&lt;/code&gt; 会让出事件循环，另一条路径就在这个空隙里挤进来。&lt;/p&gt;
&lt;p&gt;于是两边都看到「没在下载」，都往下走。&lt;/p&gt;
&lt;h2&gt;两个字节流写同一个文件&lt;/h2&gt;
&lt;p&gt;下载用 Dio，先写到一个临时文件 &lt;code&gt;.part&lt;/code&gt;，下完再改名。这个临时文件的名字只由 guid 决定，所以两条路径打开的是&lt;strong&gt;同一个文件&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;两个 Dio 各自按自己的节奏收数据、往里写，字节流交错落地。下出来的文件是坏的。&lt;/p&gt;
&lt;h2&gt;取消为什么没反应&lt;/h2&gt;
&lt;p&gt;cancel token 原本按 guid 存一个。第二条路径启动时，把先启动那次的 token 顶掉了。&lt;/p&gt;
&lt;p&gt;顶掉之后，先启动的那次再也取消不掉——点取消，取消的是后启动的那个。用户看到的就是「按了没反应」。&lt;/p&gt;
&lt;h2&gt;三处修法&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;临时文件加唯一后缀。&lt;/strong&gt; 时间戳加序号，两次并发下载各写各的，不再撞车。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;cancel token 按 guid 存集合。&lt;/strong&gt; 一个 guid 可能挂着好几个 token，取消时全停掉。清理时只摘掉自己那个，不能整条 &lt;code&gt;remove&lt;/code&gt;，否则又把并发的那条抹了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;判重和占位不能跨 &lt;code&gt;await&lt;/code&gt;。&lt;/strong&gt; 这才是根子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 判重和占位之间不能跨 await
if (_inflightFeedByGuid.containsKey(episode.guid)) return;
_inflightFeedByGuid[episode.guid] = feed.id;   // 先占位
final wifiOnly = await ...;                     // 再 await
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把「占住这一集」前移到第一个 &lt;code&gt;await&lt;/code&gt; 之前，另一条路径进来时看到的就已经是「在下载」。&lt;/p&gt;
&lt;p&gt;占位还有个细节：如果 WiFi 检查不通过要还回去，不然这一集永远下不了。&lt;/p&gt;
&lt;h2&gt;之后&lt;/h2&gt;
&lt;p&gt;现在同一集的并发下载各写各的临时文件，取消时一起停。损坏音频和「取消没反应」都没再出现。&lt;/p&gt;
&lt;p&gt;这种事以后大概还会犯。教训就一条：判重和占位之间，别跨 &lt;code&gt;await&lt;/code&gt;。&lt;/p&gt;
</content:encoded></item><item><title>HTTP 200 不等于能播</title><link>https://mssworld.me/posts/http-200-not-playable/</link><guid isPermaLink="true">https://mssworld.me/posts/http-200-not-playable/</guid><description>HTTP 200 会撒谎、Range 请求怎么把能播的台判死、gzip 压缩的 m3u8，以及两轮探测和 403 的处理。</description><pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;精选列表里放着一个台，URL 看着没毛病，点进去转圈。这种源我遇到得多了，最后搞清楚一件事：HTTP 状态码说明不了能不能播。&lt;/p&gt;
&lt;h2&gt;200 会撒谎&lt;/h2&gt;
&lt;p&gt;不少源会返回 200，但正文根本不是音频。可能是 JSON 错误信息，也可能是一个漂亮的 HTML 错误页——服务器挂了，状态码照样 200。&lt;/p&gt;
&lt;p&gt;所以探测得读正文。我的规则是读前 2048 个字节，看它到底像什么。&lt;/p&gt;
&lt;h2&gt;探测看什么&lt;/h2&gt;
&lt;p&gt;先发一个普通 GET，不带 Range，模拟真实播放。读够 2048 字节就断开，最多等 4 秒——直播流会一直推数据，不主动断就卡住了。&lt;/p&gt;
&lt;p&gt;判断规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;正文以 &lt;code&gt;{&lt;/code&gt; 或 &lt;code&gt;[&lt;/code&gt; 开头，或 &lt;code&gt;Content-Type&lt;/code&gt; 带 &lt;code&gt;json&lt;/code&gt;：死。&lt;/li&gt;
&lt;li&gt;正文是 HTML（&lt;code&gt;&amp;lt;!doctype html&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;）：死。&lt;/li&gt;
&lt;li&gt;URL 带 &lt;code&gt;.m3u8&lt;/code&gt; 或正文含 &lt;code&gt;#EXTM3U&lt;/code&gt;：算 HLS，但还要再查一层——播放列表里至少要有一条非注释的地址，光有 &lt;code&gt;#EXTM3U&lt;/code&gt; 没用。&lt;/li&gt;
&lt;li&gt;剩下的才算能播。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;有个坑是 &lt;strong&gt;gzip&lt;/strong&gt;。有的源（新城电台走 cdn77）把 m3u8 压缩了返回，不解压看到的是一团乱码，会被误判成「不是播放列表」。所以先看 &lt;code&gt;Content-Encoding&lt;/code&gt;，是 gzip 就先解开再判断。&lt;/p&gt;
&lt;h2&gt;Range 那次翻车&lt;/h2&gt;
&lt;p&gt;最早我按常规思路来：探测就发 &lt;code&gt;Range: bytes=0-2047&lt;/code&gt;，只要前 2KB，省流量。&lt;/p&gt;
&lt;p&gt;结果蜻蜓的 CDN 对 Range 请求回 404，普通 GET 却能正常出音频。一批本来能播的台，被我自己判死了。&lt;/p&gt;
&lt;p&gt;现在反过来：普通 GET 优先，Range 只在普通 GET 失败、而且这个 URL 不是 HLS 的时候兜底——探测模拟的是真实播放。&lt;/p&gt;
&lt;h2&gt;两轮探测&lt;/h2&gt;
&lt;p&gt;精选列表不是抓下来就用。重建的时候，每个 URL 要过两轮全量探测，两轮都过才算数；中间某一轮抽风（超时、限流）的台，直接不要。&lt;/p&gt;
&lt;p&gt;采编来源是蜻蜓、央广网、tingfm、radio5 的公开页面，抓完按 URL 和规范化台名去重，一个台只留一条，最后 409 个。&lt;/p&gt;
&lt;p&gt;两件事值得单独说。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;403 不能当死链。&lt;/strong&gt; 央广 ytlive 的链接带 &lt;code&gt;key&lt;/code&gt; 和 &lt;code&gt;time&lt;/code&gt;，大约两周过期，过期后探测拿到 403。但这不是台死了，是签名过期，换个新签就行。要是因为一个 403 就把整张表删了，那才叫出事。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;探测本身也会挂。&lt;/strong&gt; 清理死链的工具里有个保险：如果要删的台超过一半，直接中止，不写回。这种情况多半是探测环境出了问题，不是列表坏了。&lt;/p&gt;
&lt;h2&gt;App 里怎么用&lt;/h2&gt;
&lt;p&gt;App 只在三个时候探测：首次启动选完收听范围、下拉刷新、设置里点「检测可播放的源」。其他时候沿用上次的结果，不重复折腾。&lt;/p&gt;
&lt;p&gt;探测是并发的，测通的台立刻出现在列表里，能马上听，不用等全部测完。进度条旁边有「停止检测」，按下去保留已经测到的，剩下的不测了。&lt;/p&gt;
&lt;p&gt;测失败的台会从主页隐藏，但能在设置「电台管理 → 连不上的电台」里看到，长按就能换个地址。换完只存在本机，不用等我发版。&lt;/p&gt;
</content:encoded></item><item><title>想听电台和播客，所以我写了个 App</title><link>https://mssworld.me/posts/why-liusheng/</link><guid isPermaLink="true">https://mssworld.me/posts/why-liusheng/</guid><description>现有 App 为什么不够用、几条管住设计的规矩，以及这个播放器到底借鉴了哪些开源和闭源项目。</description><pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我想听电台。手机应用商店里下了一堆，没一个顺手。&lt;/p&gt;
&lt;p&gt;蜻蜓、喜马拉雅、小宇宙这类，内容是授权目录，我想听的不一定在里面，直播电台反而弱，还要开会员。专门听电台的 App 呢，国内台不全，列表看着挺长，能播的没几个。播客客户端倒是成熟，可它们不管电台，我还得再装一个。&lt;/p&gt;
&lt;p&gt;装来装去不如自己写。流声（Liusheng）就是这么来的，Android 和 Windows 都做，前身叫澄波（Chengbo），2.2.1 改的名。&lt;/p&gt;
&lt;h2&gt;几条后来管了大部分设计的规矩&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;能播比数量多重要。&lt;/strong&gt; 我宁可要一份测过的台清单，也不要一份没验证过的大全。精选列表现在 409 个台，每一个都是手工重建、两轮探测全过的。判断能不能播不能看状态码——有的源返回 200，内容其实是 JSON 或网页，那也是死的，删掉，不为了凑数留着。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;就做公共电台。&lt;/strong&gt; 只播公开直播流和公开 RSS，版权点播库不碰，没授权。直播音频只在内存里缓冲，绝不存成文件。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;死链得让用户自己能修。&lt;/strong&gt; 第三方的流一定会过期。所以台坏了不用等我发版，长按换个地址就行，换完只存在他自己设备上。精选列表和手动添加两条路都留着，就是不想让「能播」完全靠别人。&lt;/p&gt;
&lt;h2&gt;借鉴了谁&lt;/h2&gt;
&lt;p&gt;这项目借了不少东西，得说清楚。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;播放器的样子照 Spotify 做的。&lt;/strong&gt; 底部一条迷你播放器，点开上滑出 Now Playing，这套交互我直接用了。但我不想整个抄。拓展计划里写了一条：不整体复刻 Apple Music / Spotify——用 Spotify 式的交互，视觉保持 Material 3，不照任何一家。Apple Music 那个方向撞了三条硬约束，而且它的视觉是围着版权曲库搭的，跟「公开直播流 + RSS」这个形态不对路。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;代码基本是轮子。&lt;/strong&gt; 音频用 &lt;code&gt;just_audio&lt;/code&gt; 加 &lt;code&gt;audio_service&lt;/code&gt;，Windows 端是仓库里自己 fork 的 &lt;code&gt;just_audio_windows&lt;/code&gt;（UI 线程回调得自己处理）；状态管理是 Riverpod；界面是 Google 的 Material 3。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;数据来源分几路。&lt;/strong&gt; 电台发现用 Radio Browser，社区维护的电台目录 API，元数据是公共域的；只取中国大陆、中文和港澳台，当成全球目录用就偏了。播客发现是三级兜底：先搜 iTunes，不行查 Podcast Index，再不行翻本机目录（GetPodcast 精选加 xyzrank 前 1000，大概 1250 个中文节目）。那 409 个精选台是从蜻蜓、央广网、tingfm、radio5 的公开页面一个个采下来的，每台都实测过。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;没碰的也算借鉴。&lt;/strong&gt; 蜻蜓、喜马拉雅、小宇宙、荔枝的点播库要授权，不碰。但它们自己的公开 RSS 可以用——实测发现喜马拉雅、荔枝、蜻蜓返回的其实是平台自己的标准 RSS 2.0，跟第三方转接源不是一回事。&lt;/p&gt;
&lt;h2&gt;现在&lt;/h2&gt;
&lt;p&gt;2.2.4，双端，一套 Material 3。电台、播客、收藏、最近、统计、本机备份、Windows 托盘和浮条、Android 桌面小组件都有了。&lt;/p&gt;
&lt;p&gt;这东西是为我自己做的。谁要是也想听，拿去用。&lt;/p&gt;
</content:encoded></item><item><title>微博实况图，一个 43 字节的假 mp4</title><link>https://mssworld.me/posts/weibo-livephoto-pitfalls/</link><guid isPermaLink="true">https://mssworld.me/posts/weibo-livephoto-pitfalls/</guid><description>直接 GET 实况地址是 403，走播放接口又会返回假文件。记录这条链路上的四个坑。</description><pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;做社交存档的时候，微博实况图是整条链路上最麻烦的一块。实况图在微博上显示成一个格子：静图打底，鼠标悬停播放几秒动态。存档得同时拿到静图和那段视频，否则这个&quot;归档&quot;就是残的。&lt;/p&gt;
&lt;p&gt;下面几个坑，按踩到的顺序记。&lt;/p&gt;
&lt;h2&gt;直接下载是 403&lt;/h2&gt;
&lt;p&gt;API 返回的 &lt;code&gt;pic_infos&lt;/code&gt; 里有个 &lt;code&gt;video_url&lt;/code&gt;，看着就是视频地址。直接 GET，403。&lt;/p&gt;
&lt;p&gt;微博对这个域名的媒体做了来源校验，从别的上下文请求一律拒绝。真正能用的是播放接口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET https://video.weibo.com/media/play?livephoto=&amp;lt;urlencode 后的原始地址&amp;gt;
Referer: https://weibo.com/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个必须：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Referer 必须是 &lt;code&gt;https://weibo.com/&lt;/code&gt;，别的值直接 403&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不要带 Cookie&lt;/strong&gt;，带了反而被拒&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不要自定义 Host&lt;/strong&gt;，用 requests 时如果顺手把 API 请求的 Host 头带过去，同样 403&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这三点都写进注释了。因为每次重构都有机会手贱把 Host 加回去。&lt;/p&gt;
&lt;h2&gt;接口会返回假文件&lt;/h2&gt;
&lt;p&gt;更麻烦的是，它不总是返回视频。限流、签名过期、目标不存在的时候，它返回一个 HTTP 200 的 JSON 错误体。把响应直接写进文件，磁盘上就多一个 &lt;code&gt;.mp4&lt;/code&gt;：43 字节，内容是 &lt;code&gt;{&quot;code&quot;:&quot;403&quot;,...}&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;播放器打开一片黑。校验脚本也看不出来——文件存在、大小非零、扩展名对。&lt;/p&gt;
&lt;p&gt;所以落盘前必须验：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if size &amp;lt; 64 or head[:1] in (b&quot;{&quot;, b&quot;&amp;lt;&quot;, b&quot;[&quot;):
    # 不是视频，重下
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个条件缺一不可：空文件靠大小识别，JSON 和 HTML 错误页靠首字节识别。&lt;/p&gt;
&lt;h2&gt;签名地址不能剥参数&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;video_url&lt;/code&gt; 上带 &lt;code&gt;Expires&lt;/code&gt; 和 &lt;code&gt;ssig&lt;/code&gt;，是短时效签名。很容易想到：既然最终要换成 play 接口，那先把查询参数剥掉再拼，干净。&lt;/p&gt;
&lt;p&gt;不行。签名参数是播放接口校验的一部分，剥了就 403，跟第一条同一个下场。&lt;/p&gt;
&lt;p&gt;所以元数据里存的一直是 API 返回的&lt;strong&gt;原始带签名地址&lt;/strong&gt;，下载时才转 play URL。这样重跑、校验、迁移都不会因为地址被改坏而失效。&lt;/p&gt;
&lt;h2&gt;HEVC 在窗口里放不了&lt;/h2&gt;
&lt;p&gt;下载下来的是 H.264 QuickTime，正常情况下 Electron 能播。但有一部分是 HEVC（&lt;code&gt;hvc1&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;而这个应用启动时&lt;strong&gt;关掉了 GPU&lt;/strong&gt;——历史上 WebGL 崩过几次，直接一刀切。代价就是 Chromium 解不了 HEVC。&lt;/p&gt;
&lt;p&gt;这不是下载失败，是渲染限制。所以卡片上得给一个&quot;用系统播放器打开&quot;的兜底，走 &lt;code&gt;shell.openPath&lt;/code&gt;。判断依据也简单：文件在、&lt;code&gt;ftyp&lt;/code&gt; 有、视频元素报错。&lt;/p&gt;
&lt;p&gt;另外本地文件走自定义协议 &lt;code&gt;social-archiver://&lt;/code&gt;，返回的 MIME 一律标 &lt;code&gt;video/mp4&lt;/code&gt;，否则 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; 连认都不认。&lt;/p&gt;
&lt;h2&gt;整条链路&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;API 元数据（带签名 URL）
  → 下载时转 play URL（Referer + 无 Cookie + 无 Host）
  → 落 .part
  → 校验大小与首字节
  → os.replace 成正式文件
  → 自定义协议 + video/mp4 给渲染进程
  → 解不了就交给系统播放器
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每一步都能失败，每一步失败的表现都不一样，而且大多不报错，只是&quot;没东西&quot;。&lt;/p&gt;
&lt;p&gt;写下来是因为下次改这块的时候，我大概会忘掉其中至少两条。&lt;/p&gt;
</content:encoded></item><item><title>限流是别人的，退避是自己的</title><link>https://mssworld.me/posts/rate-limiting-backoff/</link><guid isPermaLink="true">https://mssworld.me/posts/rate-limiting-backoff/</guid><description>微博一页 8 到 13 秒、每三页停一分钟，X 的 429 交给 gallery-dl 自己退让。慢是一种设计。</description><pubDate>Fri, 06 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;抓别人平台的内容，第一原则不是&quot;快&quot;，是&quot;别把对方搞死，也别把自己搞死&quot;。&lt;/p&gt;
&lt;p&gt;这条原则写进代码之后，整个抓取流程会变得很慢。慢到你第一次用会怀疑是不是卡住了——所以最后还得专门做&quot;等待可见&quot;。&lt;/p&gt;
&lt;h2&gt;微博的节奏&lt;/h2&gt;
&lt;p&gt;翻页间隔 &lt;strong&gt;8 到 13 秒&lt;/strong&gt;，随机。&lt;/p&gt;
&lt;p&gt;每连续 3 页，额外停 &lt;strong&gt;45 到 75 秒&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;失败的时候，任务内最多再等 3 分钟，然后把断点保存下来。&lt;/p&gt;
&lt;p&gt;这不是我拍的数，是社区里被风控教育出来的经验（RSSHub 和几个微博爬虫的 issue 里都是类似量级）。微博的翻页风控按频率和深度双重判断：短时间内连续翻，几十页就触发；慢慢翻，能翻几百页。&lt;/p&gt;
&lt;p&gt;所以「深度回溯」这个功能，文档里写着&lt;strong&gt;请隔 30 到 60 分钟再跑&lt;/strong&gt;。早年内容不是一次能拉完的，得多次续跑。&lt;/p&gt;
&lt;p&gt;打满 200 页要明确提示&quot;更早的没拉到&quot;。这一点比限流本身重要——如果界面显示成功，用户会以为存全了，实际上只存了一半。&lt;/p&gt;
&lt;h2&gt;X 和 Instagram 交给 gallery-dl&lt;/h2&gt;
&lt;p&gt;这两个不用自己控制节奏，gallery-dl 的配置项够用：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;配置&lt;/th&gt;
&lt;th&gt;值&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sleep&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.5 – 4s&lt;/td&gt;
&lt;td&gt;每个请求之间&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sleep-request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6 – 12s&lt;/td&gt;
&lt;td&gt;请求间隔的另一个档位&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sleep-429&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60s&lt;/td&gt;
&lt;td&gt;遇到 429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ratelimit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;wait: 300&lt;/td&gt;
&lt;td&gt;触发速率限制时等 5 分钟&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;下载线程&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;上限&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;最要紧的一条：&lt;strong&gt;429 不要手动杀进程&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;gallery-dl 收到 429 会自己退避重试。你一看日志不动就杀掉重启，等于把它已经排好的等待重置了，下次更快撞墙。看起来&quot;重启一下更快&quot;，实际是越来越慢。&lt;/p&gt;
&lt;h2&gt;批量任务：失败要能重来，但不能立刻重来&lt;/h2&gt;
&lt;p&gt;批量缓存几十个用户，中间挂几个是正常的。&lt;/p&gt;
&lt;p&gt;失败自动重试，指数退避，默认最多 3 次，设置里可关。&lt;/p&gt;
&lt;p&gt;有些错误重试有用（网络抖动、临时 5xx），有些没用（Cookie 失效、账号私密）。指数退避的作用是让前者过关，至于后者，重试三次之后失败，日志里能看见真实原因。&lt;/p&gt;
&lt;h2&gt;等待要有反馈&lt;/h2&gt;
&lt;p&gt;30 秒没有任何输出，主进程会往日志里发一条心跳，提示&quot;可能正在限流或重试&quot;。&lt;/p&gt;
&lt;p&gt;这条是给用户看的，不是给机器看的。因为未知的等待比慢更难受——你不知道它是卡住了还是在老老实实地睡。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;这个项目里慢的地方几乎都是刻意的。真正该优化的不是抓取速度，是让用户知道自己在等，而且等得有意义。&lt;/p&gt;
</content:encoded></item><item><title>本地文件怎么喂给 video</title><link>https://mssworld.me/posts/local-file-protocol/</link><guid isPermaLink="true">https://mssworld.me/posts/local-file-protocol/</guid><description>自定义协议、Range 请求、Readable.toWeb 为什么不能用，以及 100MB 这条线背后的取舍。</description><pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;存档里全是本地文件。要让它们在 Electron 窗口里像网页一样播放，比想象麻烦。&lt;/p&gt;
&lt;h2&gt;为什么不用 file://&lt;/h2&gt;
&lt;p&gt;渲染进程里直接 &lt;code&gt;&amp;lt;video src=&quot;file:///D:/archives/xxx.mp4&quot;&amp;gt;&lt;/code&gt;，会遇到两个问题：&lt;/p&gt;
&lt;p&gt;一是路径。存档根目录是用户自己选的，可能在任意盘符，中文路径、空格、特殊字符都有。手拼 URL 很容易漏转义。&lt;/p&gt;
&lt;p&gt;二是安全。现代 Electron 默认不允许渲染进程随便读本地文件，绕过限制要么放宽配置（等于开门），要么改架构。&lt;/p&gt;
&lt;h2&gt;自定义协议&lt;/h2&gt;
&lt;p&gt;最后用了一个注册协议：&lt;code&gt;social-archiver://asset/?path=&amp;lt;绝对路径&amp;gt;&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;有两个必须先做的事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;在 &lt;code&gt;app.whenReady()&lt;/code&gt; 之前&lt;/strong&gt;调 &lt;code&gt;registerSchemesAsPrivileged&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;两个标志都要带上：&lt;code&gt;standard&lt;/code&gt; 和 &lt;code&gt;stream&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;少任何一个，&lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; 拿到的响应就不支持流式读取，拖动进度条直接失败。&lt;code&gt;standard&lt;/code&gt; 让它有正常的 URL 语义，&lt;code&gt;stream&lt;/code&gt; 让它支持 Range。&lt;/p&gt;
&lt;h2&gt;Range 是重点&lt;/h2&gt;
&lt;p&gt;视频能拖进度条，靠的是 HTTP 206 和 &lt;code&gt;Content-Range&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这里有个分界：&lt;strong&gt;100MB&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;小于 100MB 的文件，直接把整个文件读进内存 Buffer，自己实现 Range 解析和切片。简单、可靠，反正内存扛得住。&lt;/p&gt;
&lt;p&gt;超过 100MB 的走流式。流式下 Range 要自己算，而且要考虑边界：&lt;code&gt;bytes=0-&lt;/code&gt;、&lt;code&gt;bytes=-500&lt;/code&gt;、超出文件长度、多段 Range——这些都得处理，不然播放器会贴心地让你看到&quot;无法播放&quot;。&lt;/p&gt;
&lt;h2&gt;那个不该踩的坑&lt;/h2&gt;
&lt;p&gt;有段时间用过这个写法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const stream = Readable.toWeb(fs.createReadStream(filePath));
return new Response(stream, { headers: { &apos;Content-Type&apos;: &apos;video/mp4&apos; } });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看起来很现代：Node 的 Stream 转成 Web Stream，直接喂给 Response。&lt;/p&gt;
&lt;p&gt;结果：视频能播，但&lt;strong&gt;拖进度条就断&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;原因是这个转换出来的流是&quot;一次性的&quot;——它已经按顺序把数据推出来了，再去要第二段拿不到。Range 请求本质是&quot;重新给我 0 到 1000 字节&quot;，不是&quot;接着上次的地方继续&quot;。&lt;/p&gt;
&lt;p&gt;现在这条写在交接文档里，用加粗标着。&lt;/p&gt;
&lt;h2&gt;其他细节&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;MIME 类型&lt;/strong&gt;：&lt;code&gt;.mov&lt;/code&gt; 一律标 &lt;code&gt;video/mp4&lt;/code&gt;。Chromium 对 QuickTime 容器不认，标错就不播。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;缓存&lt;/strong&gt;：加了长期缓存和 ETag。同一个文件反复打开不重复读盘。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;路径校验&lt;/strong&gt;：协议处理里要 &lt;code&gt;path.resolve&lt;/code&gt; 之后检查是否在存档根目录内。不然就是任意文件读取漏洞——&lt;code&gt;social-archiver://asset/?path=C:/Windows/System32/config/SAM&lt;/code&gt; 这种请求本来能成。&lt;/p&gt;
&lt;h2&gt;播不了的那些&lt;/h2&gt;
&lt;p&gt;有些实况图是 HEVC 编码的，而应用启动时禁用了 GPU（历史上 WebGL 崩过几次）。结果是 Chromium 里解不了 HEVC。&lt;/p&gt;
&lt;p&gt;这不是下载失败，是渲染限制。&lt;/p&gt;
&lt;p&gt;所以卡片上有个兜底：检测到播放失败就显示「打开实况」，走 &lt;code&gt;shell.openPath&lt;/code&gt; 交给系统播放器。判断依据是文件存在、有 &lt;code&gt;ftyp&lt;/code&gt; 头、但 &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; 报错。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;本地文件协议这件事，最花时间的不是写代码，是搞清楚为什么明明能播却不能拖。&lt;/p&gt;
</content:encoded></item><item><title>把 Electron 和 Python 塞进一个 zip</title><link>https://mssworld.me/posts/packaging-electron-python/</link><guid isPermaLink="true">https://mssworld.me/posts/packaging-electron-python/</guid><description>PyInstaller onedir、gallery-dl 为什么装两份、只拷 exe 为什么会坏，以及未签名包的 SmartScreen。</description><pubDate>Sat, 31 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这个应用的形态是：Electron 34 套一个 Vue 3 界面，真正的抓取逻辑全是 Python。&lt;/p&gt;
&lt;p&gt;开发的时候很舒服：改 Vue 热更新，改 Python 下次点按钮就生效。打包发给别人是另一回事——对方电脑上没有 Python。&lt;/p&gt;
&lt;h2&gt;两层打包&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;第一层，Python 冻成 exe。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;PyInstaller &lt;code&gt;onedir&lt;/code&gt; 模式，输出到 &lt;code&gt;build-backend/backend/&lt;/code&gt;，里面是一个 &lt;code&gt;backend.exe&lt;/code&gt; 加一个 &lt;code&gt;_internal/&lt;/code&gt; 目录。&lt;/p&gt;
&lt;p&gt;用 &lt;code&gt;onedir&lt;/code&gt; 不用 &lt;code&gt;onefile&lt;/code&gt;：&lt;code&gt;onefile&lt;/code&gt; 每次启动都要把整个运行时解压到临时目录，启动慢，而且杀毒软件对&quot;自解压的程序&quot;格外敏感，误报率高得多。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二层，electron-builder 把这个目录拷进 &lt;code&gt;resources/backend/&lt;/code&gt;。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;最终产物：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dist-electron/
  SocialArchiver-1.2.5-win.zip     # 解压即用
  SocialArchiver-1.2.5-win.exe     # NSIS 安装包
  win-unpacked/                    # 同内容，本机直接跑
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;坑一：只拷 exe 一定坏&lt;/h2&gt;
&lt;p&gt;这是我这项目里被问得最多的问题。&lt;/p&gt;
&lt;p&gt;有人从 &lt;code&gt;win-unpacked&lt;/code&gt; 里把 &lt;code&gt;Social Archiver.exe&lt;/code&gt; 单独拷到桌面，双击，报错。&lt;/p&gt;
&lt;p&gt;Electron 的 exe 只是入口，它要去找同目录下的 &lt;code&gt;resources/app.asar&lt;/code&gt;、&lt;code&gt;resources/backend/backend.exe&lt;/code&gt;、&lt;code&gt;_internal/&lt;/code&gt; 里几千个 DLL 和 Python 模块。少任何一个都起不来。&lt;/p&gt;
&lt;p&gt;所以文档里反复写：&lt;strong&gt;必须保留整个目录&lt;/strong&gt;，要么发 zip，要么发安装包。&lt;/p&gt;
&lt;h2&gt;坑二：gallery-dl 装了两份&lt;/h2&gt;
&lt;p&gt;X 和 Instagram 都靠 gallery-dl。它被打进了冻结的后端里——但冻结的版本永远是打包那天的版本。&lt;/p&gt;
&lt;p&gt;gallery-dl 更新很勤，平台一改接口它就得跟。冻结版没法 &lt;code&gt;pip install -U&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;所以运行时的策略是：先用 &lt;code&gt;shutil.which(&apos;gallery-dl&apos;)&lt;/code&gt; 找系统里装的，找到就用系统的（用户可以自己升级）；找不到才用冻结的兜底。&lt;/p&gt;
&lt;p&gt;这样既能&quot;解压即用、不用装 Python&quot;，又能&quot;想升级随时升&quot;。&lt;/p&gt;
&lt;h2&gt;坑三：中文乱码&lt;/h2&gt;
&lt;p&gt;打包版有一段时间日志全是乱码。&lt;/p&gt;
&lt;p&gt;原因：&lt;code&gt;subprocess&lt;/code&gt; 在 Windows 上的默认编码跟着系统区域走，中文系统是 GBK，而管道里传的是 UTF-8。&lt;/p&gt;
&lt;p&gt;修了两处：&lt;code&gt;cli.py&lt;/code&gt; 里 &lt;code&gt;_force_utf8_stdio()&lt;/code&gt; 强制重设 stdin/stdout，spawn 的时候显式指定编码。&lt;/p&gt;
&lt;p&gt;还有个连带问题：stdout 是 JSON Lines，一行一条。如果缓冲区没行缓冲，一条消息可能被切成两半发出来，前端解析失败，界面一直转圈。所以 spawn 时开了行缓冲。&lt;/p&gt;
&lt;h2&gt;坑四：SmartScreen&lt;/h2&gt;
&lt;p&gt;安装包没有代码签名。&lt;/p&gt;
&lt;p&gt;第一次运行，Windows 会弹「Windows 已保护你的电脑」。处理方式是「更多信息」→「仍要运行」。&lt;/p&gt;
&lt;p&gt;这是预期行为，不是中毒。想消掉只有一条路：买 Authenticode 证书签 exe，一年几千块。个人项目不值当，所以在 README 里写清楚怎么绕过。&lt;/p&gt;
&lt;p&gt;企业环境部署的话，可以在组策略里给这个 exe 加路径排除。&lt;/p&gt;
&lt;h2&gt;坑五：环境门卫&lt;/h2&gt;
&lt;p&gt;PyInstaller 打包前检查 Python 版本，低于 3.9 直接报错退出。&lt;/p&gt;
&lt;p&gt;不检查的话，低版本打出来的包能生成，但运行时一堆语法错误，排查起来很痛苦。&lt;/p&gt;
&lt;h2&gt;坑六：输出目录被占用&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;win-unpacked&lt;/code&gt; 里的 exe 还在跑，再打包就失败——文件被锁。&lt;/p&gt;
&lt;p&gt;解决方法是要么先关掉，要么指定临时输出目录：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm run electron:build -- --config.directories.output=%TEMP%\sa-electron-out
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;打完再拷回来。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;打包本身的代码量不大，但它决定了别人能不能用。开发环境跑得通，和发给朋友能双击打开，中间隔着的全是这类细节。&lt;/p&gt;
</content:encoded></item><item><title>三个平台，三套 Cookie，一堆坑</title><link>https://mssworld.me/posts/cookie-management/</link><guid isPermaLink="true">https://mssworld.me/posts/cookie-management/</guid><description>per_user 键、30 分钟验证 TTL、Edge 导入为什么不能覆盖、X 的 ct0 会轮换。</description><pubDate>Sun, 25 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;社交存档要访问三个平台，每个平台认的东西都不一样，也都不肯好好说。&lt;/p&gt;
&lt;h2&gt;各自要什么&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;平台&lt;/th&gt;
&lt;th&gt;必需项&lt;/th&gt;
&lt;th&gt;特别要求&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;微博&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SUB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;必须是 &lt;code&gt;m.weibo.cn&lt;/code&gt; 的，&lt;strong&gt;不是&lt;/strong&gt; weibo.com 桌面站的&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auth_token&lt;/code&gt; + &lt;code&gt;ct0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;两个都要，缺一个就是游客态&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instagram&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sessionid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;只认这一个，不支持用户名密码登录&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;微博这个坑我踩过：从 weibo.com 复制出来的 Cookie 里也有 &lt;code&gt;SUB&lt;/code&gt;，看起来一样，但拿去请求 &lt;code&gt;m.weibo.cn&lt;/code&gt; 的接口就是不认。移动端和桌面端是两套会话。&lt;/p&gt;
&lt;h2&gt;坑一：一个账号一套 Cookie&lt;/h2&gt;
&lt;p&gt;最初只有「每个平台一个 Cookie」的设计。测试多账号的时候立刻出问题：缓存完 A 用户，切到 B 用户，用的还是 A 的会话。&lt;/p&gt;
&lt;p&gt;改成 &lt;code&gt;per_user&lt;/code&gt; 键，格式是 &lt;code&gt;platform:userId&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;cookies&quot;: {
  &quot;per_user&quot;: {
    &quot;instagram:username&quot;: &quot;sessionid=...&quot;,
    &quot;twitter:123456789&quot;: &quot;auth_token=...; ct0=...&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;取值顺序是：先找 &lt;code&gt;per_user&lt;/code&gt;，没有就回退到平台全局键。全局键是「这个平台我登的谁」，per_user 是「这个用户我要用谁的登录态」。&lt;/p&gt;
&lt;p&gt;顺便说一个细节：验证 TTL 的存储用的是对象数组而不是 &lt;code&gt;Map&lt;/code&gt; 加字符串 key。因为 userId 里可能有 &lt;code&gt;:&lt;/code&gt; 或者别的特殊字符，用 &lt;code&gt;platform:userId&lt;/code&gt; 拼成字符串 key 再拆分解析，迟早切错位。&lt;/p&gt;
&lt;h2&gt;坑二：验证太勤，网络一抖就弹登录框&lt;/h2&gt;
&lt;p&gt;每次切换任务都去 &lt;code&gt;x.com&lt;/code&gt; / &lt;code&gt;instagram.com&lt;/code&gt; 打一次远端校验，网络稍微抖一下，就返回失败，界面弹登录窗。你根本没掉登录态，只是刚才那次请求超时了。&lt;/p&gt;
&lt;p&gt;解决办法是进程内 30 分钟 TTL：任务完成、或 &lt;code&gt;checkCookie&lt;/code&gt; 明确返回有效之后，30 分钟内信任本地缓存，不再打远端。&lt;/p&gt;
&lt;p&gt;浏览页也加了短路：&lt;code&gt;ensureCookie&lt;/code&gt; 先问「最近验证过吗」，命中就直接跳过校验。&lt;/p&gt;
&lt;h2&gt;坑三：从浏览器导入，别顺手覆盖用户级 Cookie&lt;/h2&gt;
&lt;p&gt;Edge 导入是个很实用的功能——不用手抄 Cookie。但它写的是&lt;strong&gt;平台全局键&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;早期版本顺手把该平台下所有 &lt;code&gt;per_user&lt;/code&gt; 也一并更新了。结果是你为某个号单独准备的登录态，被一次导入全冲掉。&lt;/p&gt;
&lt;p&gt;现在只更新 &lt;code&gt;cookies[platform]&lt;/code&gt;，&lt;code&gt;per_user&lt;/code&gt; 一个不动，导入完清掉这个平台的 TTL 记录，下次任务重新校验。&lt;/p&gt;
&lt;h2&gt;坑四：X 的 ct0 会自己换&lt;/h2&gt;
&lt;p&gt;这是最阴的一个。&lt;/p&gt;
&lt;p&gt;X 的 &lt;code&gt;ct0&lt;/code&gt; 是 CSRF token，会轮换。&lt;code&gt;auth_token&lt;/code&gt; 不变。gallery-dl 在下载过程中会刷新 &lt;code&gt;ct0&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;于是你缓存完 A 用户，本地存的还是那个旧 &lt;code&gt;ct0&lt;/code&gt;；下一个任务用 B 用户，同一个 &lt;code&gt;auth_token&lt;/code&gt;，带着过期的 &lt;code&gt;ct0&lt;/code&gt;，直接失败。&lt;/p&gt;
&lt;p&gt;现在的做法：gallery-dl 刷新后的会话 Cookie 写到 &lt;code&gt;{root}/twitter/.session-cookies.txt&lt;/code&gt;，同一个 &lt;code&gt;auth_token&lt;/code&gt; 换用户时沿用这份。任务结束再写回设置和缓存页输入框。&lt;/p&gt;
&lt;p&gt;另外批量队列每个任务开始前会重读一次最新 Cookie，不用入队时的快照。&lt;/p&gt;
&lt;h2&gt;坑五：Instagram 的应用内登录分区&lt;/h2&gt;
&lt;p&gt;应用内登录会往一个 Electron partition 里存 &lt;code&gt;sessionid&lt;/code&gt;。有个看起来理所当然的逻辑：缓存前把分区里的 Cookie 同步到设置里。&lt;/p&gt;
&lt;p&gt;问题是切到另一个 IG 账号时，分区里还是上一个的 &lt;code&gt;sessionid&lt;/code&gt;，一同步就把 per_user 里这个账号的凭据覆盖了。&lt;/p&gt;
&lt;p&gt;现在改成：缓存里已有可用 &lt;code&gt;sessionid&lt;/code&gt; 就信任它；只有缓存为空或不可用时，才从分区回填。&lt;/p&gt;
&lt;h2&gt;坑六：Cookie 不要出现在命令行&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;python xxx.py --cookie &quot;auth_token=...; ct0=...&quot;&lt;/code&gt; 会把整个 Cookie 写进进程命令行，任何能看进程列表的程序都能读到。&lt;/p&gt;
&lt;p&gt;改成走临时文件 &lt;code&gt;--cookie-file&lt;/code&gt;，用完删掉。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;这套东西最后散在四处：&lt;code&gt;session.js&lt;/code&gt; 的可用性判断、&lt;code&gt;electron/cookie-rules.js&lt;/code&gt; 的平台规则、&lt;code&gt;cookie_check.py&lt;/code&gt; 的远端校验、&lt;code&gt;cookie-validation.js&lt;/code&gt; 的 TTL。三处平台规则必须保持一致，改一个地方就得三个一起改。&lt;/p&gt;
</content:encoded></item><item><title>增量归档：第二次跑得比第一次快</title><link>https://mssworld.me/posts/incremental-archive/</link><guid isPermaLink="true">https://mssworld.me/posts/incremental-archive/</guid><description>连续 5 条命中就停翻页、.part 断点续传、原子写入，以及为什么这些比&quot;能下载&quot;更重要。</description><pubDate>Mon, 19 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;第一次缓存一个用户，可能要走几百页、下几千个文件。第二次只该花几秒钟。&lt;/p&gt;
&lt;p&gt;这听着像优化，其实是能不能用的分界线：每次更新都要重走整条时间线，你就不会去点那个更新按钮。&lt;/p&gt;
&lt;h2&gt;微博：连续 5 条命中就停&lt;/h2&gt;
&lt;p&gt;微博的翻页接口是 &lt;code&gt;m.weibo.cn/api/container/getIndex&lt;/code&gt;，&lt;code&gt;containerid=107603{uid}&lt;/code&gt;，靠 &lt;code&gt;since_id&lt;/code&gt; 往前翻。&lt;/p&gt;
&lt;p&gt;判断「已经存过了」最直接的办法是看这条的原帖 id 在不在本地。但直接拿这个当停止条件会出事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;置顶帖&lt;/strong&gt;永远在第一条，会误判成&quot;到头了&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;转发&lt;/strong&gt;的 id 和内容归属不一致，容易算错&lt;/li&gt;
&lt;li&gt;帖子标题在，但&lt;strong&gt;媒体只下了一半&lt;/strong&gt;，也算不上完整&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以停止条件是：&lt;strong&gt;连续 5 条「已缓存且媒体完整」的非置顶原创&lt;/strong&gt;。连续，不是累计——中间夹一条新的就清零重算。&lt;/p&gt;
&lt;p&gt;5 这个数字是试出来的。1 条太容易误判，10 条在白翻好几页。&lt;/p&gt;
&lt;p&gt;另有一条保底：翻到 200 页就停，并明确告诉用户「更早的内容没拉到」。这个提示很重要，否则界面显示&quot;完成&quot;，你会以为全在了。&lt;/p&gt;
&lt;h2&gt;断点：别让中断等于白干&lt;/h2&gt;
&lt;p&gt;微博的翻页有风控（后面单独写），跑到一半断掉是常态。&lt;/p&gt;
&lt;p&gt;所以 &lt;code&gt;_profile.json&lt;/code&gt; 里记三个值：&lt;code&gt;lastSinceId&lt;/code&gt;、&lt;code&gt;lastPage&lt;/code&gt;、&lt;code&gt;fetchStatus&lt;/code&gt;。&lt;code&gt;fetchStatus&lt;/code&gt; 只有三种：&lt;code&gt;complete&lt;/code&gt; / &lt;code&gt;partial&lt;/code&gt; / &lt;code&gt;page_limit&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;下次点「更新」，从 &lt;code&gt;lastSinceId&lt;/code&gt; 继续，不用从头来。&lt;/p&gt;
&lt;p&gt;换日期范围不会重置断点，只按新范围过滤。这条是为了避免&quot;改个日期就重新爬一遍&quot;。&lt;/p&gt;
&lt;h2&gt;媒体：&lt;code&gt;.part&lt;/code&gt; 加 Range&lt;/h2&gt;
&lt;p&gt;帖子层面能续，单个文件层面呢？&lt;/p&gt;
&lt;p&gt;微博的媒体下载现在这么走：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;下到 &lt;code&gt;{文件名}.part&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;已经存在有效的 &lt;code&gt;.part&lt;/code&gt; 就用 &lt;code&gt;Range: bytes=N-&lt;/code&gt; 接着下&lt;/li&gt;
&lt;li&gt;服务端忽略 Range（回 200 而不是 206），就从零重下&lt;/li&gt;
&lt;li&gt;校验通过后 &lt;code&gt;os.replace()&lt;/code&gt; 改名成正式文件&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;关键在于最后那步用 &lt;code&gt;os.replace&lt;/code&gt; 而不是 &lt;code&gt;rename&lt;/code&gt; 加判断——同一个文件系统内它是原子的，要么旧的要么新的，不会出现半个文件。&lt;/p&gt;
&lt;p&gt;进度条也分两种：知道总大小的显示百分比，不知道的（gallery-dl 的很多场景）显示「已完成 N 个」加一个不确定态的动画条。&lt;strong&gt;不伪造百分比&lt;/strong&gt;，转了半天的 99% 比没有进度更让人焦虑。&lt;/p&gt;
&lt;h2&gt;原子写入&lt;/h2&gt;
&lt;p&gt;不只是媒体，JSON 也一样。&lt;/p&gt;
&lt;p&gt;断电、强杀进程、写到一半磁盘满——都会留下一个语法不完整的 &lt;code&gt;settings.json&lt;/code&gt;，下次启动直接崩。&lt;/p&gt;
&lt;p&gt;所有元数据写入都改成：写临时文件 → &lt;code&gt;flush()&lt;/code&gt; → &lt;code&gt;os.fsync()&lt;/code&gt; → &lt;code&gt;os.replace()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fsync&lt;/code&gt; 那步不能省。&lt;code&gt;flush&lt;/code&gt; 只是把数据从 Python 的缓冲区交给操作系统，&lt;code&gt;fsync&lt;/code&gt; 才是真正落盘。省掉它，断电的时候文件系统里可能只有一个空文件。&lt;/p&gt;
&lt;h2&gt;X 和 Instagram：交给 download-archive&lt;/h2&gt;
&lt;p&gt;这两个走 gallery-dl，增量不用自己实现——它在存档根目录维护 &lt;code&gt;.download-archive.sqlite&lt;/code&gt;，已经下过的媒体直接跳过。&lt;/p&gt;
&lt;p&gt;不加这个文件的话，缓存一个几千条的大号，每次更新都要把整条时间线走一遍，光请求就要几十分钟。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;增量做对之后，日常使用的手感完全不一样：日更的账号，点一下更新等十几秒；半年没动的，第一次慢，之后也快。&lt;/p&gt;
&lt;p&gt;第一次慢是应该的，每次都慢是设计错了。&lt;/p&gt;
</content:encoded></item><item><title>收藏不等于存档</title><link>https://mssworld.me/posts/why-archive/</link><guid isPermaLink="true">https://mssworld.me/posts/why-archive/</guid><description>平台的收藏夹只存了一个指针，指针那头的东西随时可以消失。这是我做社交存档的起点。</description><pubDate>Tue, 13 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;微博和 X 上都有一个「收藏」按钮。按下去，你会觉得东西存住了。&lt;/p&gt;
&lt;p&gt;其实没有——收藏夹存的只是一个 id，内容还留在对方服务器上。&lt;/p&gt;
&lt;h2&gt;收藏夹的问题&lt;/h2&gt;
&lt;p&gt;它存的是指针，不是内容。原帖被删、账号注销、平台改版，收藏夹里就剩一行「该内容不存在」。收藏得越久，失效的越多。&lt;/p&gt;
&lt;p&gt;你也看不到原文的真实样子。图片经过压缩和转码，视频清晰度看你当时的网络，长文被折叠，评论区的上下文丢了。你以为存的是那条微博，其实存的是它的一个渲染结果。&lt;/p&gt;
&lt;p&gt;它还不能离线。没网就是空的。平台哪天关站、哪个地区被墙、哪个功能下线，都不在你控制范围内。&lt;/p&gt;
&lt;h2&gt;「存档」应该是什么样&lt;/h2&gt;
&lt;p&gt;我给这个词定了几条硬标准，后来变成这个工具的设计原则。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;文件在你自己磁盘上。&lt;/strong&gt; 不是数据库、不是云端、不是「导出成 PDF 存进某个 App」，就是 &lt;code&gt;{平台}/{用户}/&lt;/code&gt; 目录下的一堆 jpg、mp4 和 json。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;重跑要加深它，不能分叉它。&lt;/strong&gt; 同一个用户第二次缓存，新内容进同一个目录，已有的跳过。跑一百次也只有一个文件夹，不会变成 &lt;code&gt;user_2026-08-29&lt;/code&gt;、&lt;code&gt;user_2026-09-01&lt;/code&gt; 这样满地都是。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;脱离 App 也能读。&lt;/strong&gt; 每个用户目录里有一个 &lt;code&gt;index.html&lt;/code&gt;，双击就能在任何浏览器里按时间线看，样式和 App 里一致，浅色深色都跟着系统走。哪天这个 Electron 程序跑不起来了，存档本身还是活的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;文字是内容，不只是媒体的附属品。&lt;/strong&gt; 纯文字帖不会被跳过。很多人做下载器只存图，把配文扔了——那是把一半的原始信息丢了。&lt;/p&gt;
&lt;h2&gt;边界&lt;/h2&gt;
&lt;p&gt;只存原创，不存转发。转发的内容属于别人，把它当成目标用户的原创存下来，等于伪造。&lt;/p&gt;
&lt;p&gt;只存公开内容，靠你自己的 Cookie 访问，不做账号池、不做代理池、不碰别人的隐私。&lt;/p&gt;
&lt;p&gt;不做云端同步，不做账号体系，不上传任何东西。Cookie 只留在本机 &lt;code&gt;%APPDATA%&lt;/code&gt; 里。&lt;/p&gt;
&lt;p&gt;这不是一个备份服务，是给自己留一份。&lt;/p&gt;
</content:encoded></item><item><title>开张</title><link>https://mssworld.me/posts/hello-world/</link><guid isPermaLink="true">https://mssworld.me/posts/hello-world/</guid><description>第一篇文章，说说这里打算写什么。</description><pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这里是 &lt;strong&gt;mss 的随便记录&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;不是每篇都正经排版、认真配图的博客，更像个草稿本：写代码时踩的坑、做东西时的想法、看到的觉得有意思的东西。想到哪写到哪，不保证有主线。&lt;/p&gt;
&lt;p&gt;大概会写这几类：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;项目记录&lt;/strong&gt;：自己写的东西怎么设计、为什么那么设计、哪里翻车了&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;工具折腾&lt;/strong&gt;：环境配置、脚本、还有那些&quot;本来十分钟结果搞了一下午&quot;的事&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;随手记&lt;/strong&gt;：读书、看片、想到的一些没头没尾的东西&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;更新频率随缘。写这个只是因为有些东西记在脑子里会忘，写下来至少以后自己搜得到。&lt;/p&gt;
</content:encoded></item></channel></rss>