









记录正在发生的一切
事情的起因很简单,某天在群里聊天时,正好在逛 Steam 的点数商店。看着那些魔性又可爱的动态贴纸,我突然萌生了一个想法:如果能把这些贴纸放到微信里当表情包用该多好?
碰壁:格式的鸿沟#
说干就干,我立刻下载了几个贴纸文件,结果发现 Steam 的动态贴纸都是 APNG (Animated PNG) 格式的。虽然这种格式在网页上支持得很好,画质也高,但微信并不直接支持 APNG 动图。
要想在微信里发,必须得转成 GIF。
探索:寻找最优解#
我先是从网上找了几个在线转换工具,试了一圈下来,发现效果参差不齐。 有的转换出来体积巨大,有的画质压缩得惨不忍睹,还有的边缘锯齿严重。
经过反复测试,我发现 64色(64-color) 的调色板模式是一个“甜蜜点”:
- 体积适中:虽然比单纯的黑白或极简色板大一点,但远小于全彩 GIF。
- 画质丝滑:对于贴纸这种色彩相对简单的图形,64色足以保留大部分细节,动态效果也很流畅。
- 符合预期:这正是我想要的效果。
但问题又来了:我找到的能完美转出 64色 GIF 的工具,只能一个一个文件手动上传转换。如果不怕麻烦也就罢了,但我可是要把 Steam 商店搬空的人(误),这得点到什么时候去?
尝试:油猴脚本的滑铁卢#
作为一个程序员,第一反应肯定是“自动化”。 既然源头在网页上,我首先想到的是写一个 油猴脚本 (Tampermonkey Script)。
起初的设想很美好: 脚本直接拦截图片 -> 在浏览器内调用 JS 库进行格式转换 -> 点击直接下载 GIF。
然而现实很骨感。在浏览器环境下进行高性能的图像处理(尤其是涉及到 GIF 编码和调色板生成)不仅效率低,而且兼容性问题一大堆。忙活了一下午,最后以失败告终:要么不仅转换慢,浏览器还容易卡死。
转机:混合双打#
既然纯前端搞不定,那就分两步走:
- 获取素材:写一个简单的油猴脚本,只负责把网页上的 APNG 图片原样下载到本地。
- 本地处理:写一个 Python 程序,在本地利用强大的图像处理库(Pillow)来进行高性能的批量转换。
这个方案非常成功。
关于那个本地转换工具,因为涉及到我自己的一些私有优化逻辑,目前暂时闭源自用(如果大家有兴趣,网上也有很多开源的 APNG 转 GIF 库,原理都是类似的,推荐大家去尝试)。
不过,我可以把那个 辅助下载 Steam 贴纸的油猴脚本 分享给大家。
这个脚本会在 Steam 点数商店的每个贴纸右上角添加一个下载按钮。它会自动检测图片是普通的 PNG 还是 APNG(动态),并在下载时加上相应的前缀,非常方便后续的整理或转换。
Steam Sticker PNG/APNG Downloader#
安装这个脚本后,只需点击贴纸右上角的按钮,即可直接下载原始的高清素材。
1// ==UserScript==2// @name Steam Sticker PNG/APNG Downloader (Cute Corner Button)3// @namespace http://tampermonkey.net/4// @version 1.05// @description Steam Points Shop: one cute download button at top-right of each sticker card. Downloads original PNG/APNG only (no conversion).6// @author You7// @match https://store.steampowered.com/points/shop*8// @match https://store.steampowered.com/points/*9// @connect *10// @icon https://store.steampowered.com/favicon.ico11// @run-at document-idle12// @grant GM_xmlhttpRequest13// @grant GM_download14// ==/UserScript==15
16(function () {17 "use strict";18
19 console.log("[Sticker DL] start");20
21 // ---------- Styles ----------22 const style = document.createElement("style");23 style.textContent = `24 .stkrdl-host{ position:relative !important; }25
26 .stkrdl-btn{27 position:absolute;28 top:6px;29 right:6px;30 z-index:999999;31 width:28px;32 height:28px;33 border-radius:10px;34
35 display:flex;36 align-items:center;37 justify-content:center;38
39 background: rgba(20,20,24,.38);40 border: 1px solid rgba(255,255,255,.16);41 backdrop-filter: blur(8px);42 box-shadow: 0 8px 18px rgba(0,0,0,.35);43
44 color: rgba(255,255,255,.92);45 cursor:pointer;46 user-select:none;47
48 opacity:.35;49 transform: translateY(-1px);50 transition: opacity .12s ease, transform .12s ease, background .12s ease, filter .12s ease;51
52 /* 让按钮可点,不让事件穿透 */53 pointer-events:auto;54 }55
56 /* 鼠标移到卡片上时按钮更明显 */57 .stkrdl-host:hover .stkrdl-btn{58 opacity:1;59 transform: translateY(0);60 background: rgba(255,255,255,.10);61 }62
63 .stkrdl-btn:hover{64 filter: brightness(1.05);65 }66
67 .stkrdl-btn:active{68 transform: translateY(0) scale(.98);69 }70
71 .stkrdl-btn[disabled]{72 opacity:.6;73 cursor:wait;74 }75
76 /* 小图标(纯 CSS + 字符,避免外部资源) */77 .stkrdl-ico{78 font-size:14px;79 line-height:1;80 transform: translateY(-.5px);81 }82 `;83 document.head.appendChild(style);84
85 // ---------- Observe DOM changes ----------86 const observer = new MutationObserver(() => processImages());87 observer.observe(document.body, { childList: true, subtree: true });88
89 setTimeout(processImages, 1200);90
91 function processImages() {92 const imgs = document.querySelectorAll("img");93 imgs.forEach((img) => {94 if (img.dataset.stkrdlInjected) return;95
96 const src = img.currentSrc || img.src;97 if (!src || !src.includes(".png")) return;98
99 // 过滤太小的图标100 const rect = img.getBoundingClientRect();101 if (rect.width < 50 || rect.height < 50) return;102
103 img.dataset.stkrdlInjected = "true";104
105 // 找到更“卡片级”的容器:尽量用 RewardItem/Sticker 外层106 const container =107 img.closest('[class*="RewardItem"], [class*="Sticker"], [class*="rewarditem"], [class*="sticker"]') ||108 img.parentElement;109
110 injectButton(container, src);111 });112 }113
114 function injectButton(parent, imgSrc) {115 if (!parent || parent.querySelector(":scope > .stkrdl-btn")) return;116
117 // 确保定位正确118 parent.classList.add("stkrdl-host");119 const cs = getComputedStyle(parent);120 if (cs.position === "static") parent.style.position = "relative";121
122 const btn = document.createElement("button");123 btn.className = "stkrdl-btn";124 btn.type = "button";125 btn.title = "下载原始 PNG/APNG";126 btn.innerHTML = `<span class="stkrdl-ico">⬇</span>`;127
128 // 防止点击卡片跳转129 btn.addEventListener("click", (e) => {130 e.stopPropagation();131 e.preventDefault();132 });133
134 btn.onclick = async () => {135 const old = btn.innerHTML;136 btn.setAttribute("disabled", "disabled");137 btn.innerHTML = `<span class="stkrdl-ico">…</span>`;138
139 try {140 const { buffer, filename } = await fetchAndName(imgSrc);141 const blob = new Blob([buffer], { type: "image/png" });142 await downloadBlob(blob, filename);143 btn.innerHTML = `<span class="stkrdl-ico">✓</span>`;144 } catch (err) {145 console.error("[Sticker DL] error", err);146 btn.innerHTML = `<span class="stkrdl-ico">×</span>`;147 } finally {148 setTimeout(() => {149 btn.removeAttribute("disabled");150 btn.innerHTML = old;151 }, 900);152 }153 };154
155 parent.appendChild(btn);156 }157
158 // ---------- Download / network ----------159 async function fetchAndName(url) {160 const buffer = await fetchArrayBuffer(url);161
162 let base = "steam_sticker";163 try {164 const parts = url.split("/");165 base = (parts[parts.length - 1] || base).split("?")[0].replace(/\.png$/i, "") || base;166 } catch {}167
168 // 不显示 APNG,但为了你后续批量处理方便:仍然用 apng_/png_ 前缀区分169 const isApng = hasChunk(buffer, "acTL");170 const filename = (isApng ? "apng_" : "png_") + base + ".png";171
172 return { buffer, filename };173 }174
175 function fetchArrayBuffer(url) {176 return new Promise((resolve, reject) => {177 GM_xmlhttpRequest({178 method: "GET",179 url,180 responseType: "arraybuffer",181 headers: { Referer: "https://store.steampowered.com/" },182 onload: (res) => {183 if (res.status >= 200 && res.status < 300) resolve(res.response);184 else reject(new Error("HTTP " + res.status));185 },186 onerror: () => reject(new Error("network error")),187 ontimeout: () => reject(new Error("timeout")),188 });189 });190 }191
192 async function downloadBlob(blob, filename) {193 // Prefer GM_download194 if (typeof GM_download === "function") {195 return new Promise((resolve, reject) => {196 const url = URL.createObjectURL(blob);197 GM_download({198 url,199 name: filename,200 saveAs: false,201 onload: () => { URL.revokeObjectURL(url); resolve(); },202 onerror: (e) => { URL.revokeObjectURL(url); reject(e); },203 ontimeout: () => { URL.revokeObjectURL(url); reject(new Error("download timeout")); },204 });205 });206 }207
208 // Fallback209 const a = document.createElement("a");210 a.href = URL.createObjectURL(blob);211 a.download = filename;212 document.body.appendChild(a);213 a.click();214 a.remove();215 setTimeout(() => URL.revokeObjectURL(a.href), 8000);216 }217
218 // ---------- APNG detector (silent, used only for filename prefix) ----------219 function hasChunk(arrayBuffer, chunkType4) {220 const u8 = new Uint8Array(arrayBuffer);221 if (u8.length < 8) return false;222
223 // PNG signature224 const sig = [137,80,78,71,13,10,26,10];225 for (let i = 0; i < 8; i++) if (u8[i] !== sig[i]) return false;226
227 const typeBytes = [228 chunkType4.charCodeAt(0),229 chunkType4.charCodeAt(1),230 chunkType4.charCodeAt(2),231 chunkType4.charCodeAt(3),232 ];233
234 let off = 8;235 while (off + 8 <= u8.length) {236 const len = readU32BE(u8, off);237 const t0 = u8[off + 4], t1 = u8[off + 5], t2 = u8[off + 6], t3 = u8[off + 7];238
239 if (t0 === typeBytes[0] && t1 === typeBytes[1] && t2 === typeBytes[2] && t3 === typeBytes[3]) {240 return true;241 }242
243 off += 8 + len + 4; // length + type + data + crc244 if (off > u8.length) break;245 }246 return false;247 }248
249 function readU32BE(u8, off) {250 return ((u8[off] << 24) | (u8[off+1] << 16) | (u8[off+2] << 8) | (u8[off+3])) >>> 0;251 }252
253})();结语#
虽然折腾了一圈(从找工具到写 JS 再到写 Python),但最终看着微信里那一个个流畅播放的 Steam 贴纸,感觉还是挺值的。这也算是一次“为了偷懒而更努力”的典型案例吧!
部分信息可能已经过时