Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4Mobile wallpaper 5Mobile wallpaper 6
1748 字
9 分钟
Steam 贴纸提取记:从 APNG 到 64色 GIF 的折腾之路
2026-01-19
统计加载中...

事情的起因很简单,某天在群里聊天时,正好在逛 Steam 的点数商店。看着那些魔性又可爱的动态贴纸,我突然萌生了一个想法:如果能把这些贴纸放到微信里当表情包用该多好?

碰壁:格式的鸿沟#

说干就干,我立刻下载了几个贴纸文件,结果发现 Steam 的动态贴纸都是 APNG (Animated PNG) 格式的。虽然这种格式在网页上支持得很好,画质也高,但微信并不直接支持 APNG 动图。

要想在微信里发,必须得转成 GIF

探索:寻找最优解#

我先是从网上找了几个在线转换工具,试了一圈下来,发现效果参差不齐。 有的转换出来体积巨大,有的画质压缩得惨不忍睹,还有的边缘锯齿严重。

经过反复测试,我发现 64色(64-color) 的调色板模式是一个“甜蜜点”:

  • 体积适中:虽然比单纯的黑白或极简色板大一点,但远小于全彩 GIF。
  • 画质丝滑:对于贴纸这种色彩相对简单的图形,64色足以保留大部分细节,动态效果也很流畅。
  • 符合预期:这正是我想要的效果。

但问题又来了:我找到的能完美转出 64色 GIF 的工具,只能一个一个文件手动上传转换。如果不怕麻烦也就罢了,但我可是要把 Steam 商店搬空的人(误),这得点到什么时候去?

尝试:油猴脚本的滑铁卢#

作为一个程序员,第一反应肯定是“自动化”。 既然源头在网页上,我首先想到的是写一个 油猴脚本 (Tampermonkey Script)

起初的设想很美好: 脚本直接拦截图片 -> 在浏览器内调用 JS 库进行格式转换 -> 点击直接下载 GIF。

然而现实很骨感。在浏览器环境下进行高性能的图像处理(尤其是涉及到 GIF 编码和调色板生成)不仅效率低,而且兼容性问题一大堆。忙活了一下午,最后以失败告终:要么不仅转换慢,浏览器还容易卡死。

转机:混合双打#

既然纯前端搞不定,那就分两步走:

  1. 获取素材:写一个简单的油猴脚本,只负责把网页上的 APNG 图片原样下载到本地。
  2. 本地处理:写一个 Python 程序,在本地利用强大的图像处理库(Pillow)来进行高性能的批量转换。

这个方案非常成功。

关于那个本地转换工具,因为涉及到我自己的一些私有优化逻辑,目前暂时闭源自用(如果大家有兴趣,网上也有很多开源的 APNG 转 GIF 库,原理都是类似的,推荐大家去尝试)。

不过,我可以把那个 辅助下载 Steam 贴纸的油猴脚本 分享给大家。

这个脚本会在 Steam 点数商店的每个贴纸右上角添加一个下载按钮。它会自动检测图片是普通的 PNG 还是 APNG(动态),并在下载时加上相应的前缀,非常方便后续的整理或转换。

Steam Sticker PNG/APNG Downloader#

安装这个脚本后,只需点击贴纸右上角的按钮,即可直接下载原始的高清素材。

// ==UserScript==
// @name Steam Sticker PNG/APNG Downloader (Cute Corner Button)
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Steam Points Shop: one cute download button at top-right of each sticker card. Downloads original PNG/APNG only (no conversion).
// @author You
// @match https://store.steampowered.com/points/shop*
// @match https://store.steampowered.com/points/*
// @connect *
// @icon https://store.steampowered.com/favicon.ico
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @grant GM_download
// ==/UserScript==
(function () {
"use strict";
console.log("[Sticker DL] start");
// ---------- Styles ----------
const style = document.createElement("style");
style.textContent = `
.stkrdl-host{ position:relative !important; }
.stkrdl-btn{
position:absolute;
top:6px;
right:6px;
z-index:999999;
width:28px;
height:28px;
border-radius:10px;
display:flex;
align-items:center;
justify-content:center;
background: rgba(20,20,24,.38);
border: 1px solid rgba(255,255,255,.16);
backdrop-filter: blur(8px);
box-shadow: 0 8px 18px rgba(0,0,0,.35);
color: rgba(255,255,255,.92);
cursor:pointer;
user-select:none;
opacity:.35;
transform: translateY(-1px);
transition: opacity .12s ease, transform .12s ease, background .12s ease, filter .12s ease;
/* 让按钮可点,不让事件穿透 */
pointer-events:auto;
}
/* 鼠标移到卡片上时按钮更明显 */
.stkrdl-host:hover .stkrdl-btn{
opacity:1;
transform: translateY(0);
background: rgba(255,255,255,.10);
}
.stkrdl-btn:hover{
filter: brightness(1.05);
}
.stkrdl-btn:active{
transform: translateY(0) scale(.98);
}
.stkrdl-btn[disabled]{
opacity:.6;
cursor:wait;
}
/* 小图标(纯 CSS + 字符,避免外部资源) */
.stkrdl-ico{
font-size:14px;
line-height:1;
transform: translateY(-.5px);
}
`;
document.head.appendChild(style);
// ---------- Observe DOM changes ----------
const observer = new MutationObserver(() => processImages());
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(processImages, 1200);
function processImages() {
const imgs = document.querySelectorAll("img");
imgs.forEach((img) => {
if (img.dataset.stkrdlInjected) return;
const src = img.currentSrc || img.src;
if (!src || !src.includes(".png")) return;
// 过滤太小的图标
const rect = img.getBoundingClientRect();
if (rect.width < 50 || rect.height < 50) return;
img.dataset.stkrdlInjected = "true";
// 找到更“卡片级”的容器:尽量用 RewardItem/Sticker 外层
const container =
img.closest('[class*="RewardItem"], [class*="Sticker"], [class*="rewarditem"], [class*="sticker"]') ||
img.parentElement;
injectButton(container, src);
});
}
function injectButton(parent, imgSrc) {
if (!parent || parent.querySelector(":scope > .stkrdl-btn")) return;
// 确保定位正确
parent.classList.add("stkrdl-host");
const cs = getComputedStyle(parent);
if (cs.position === "static") parent.style.position = "relative";
const btn = document.createElement("button");
btn.className = "stkrdl-btn";
btn.type = "button";
btn.title = "下载原始 PNG/APNG";
btn.innerHTML = `<span class="stkrdl-ico">⬇</span>`;
// 防止点击卡片跳转
btn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
});
btn.onclick = async () => {
const old = btn.innerHTML;
btn.setAttribute("disabled", "disabled");
btn.innerHTML = `<span class="stkrdl-ico">…</span>`;
try {
const { buffer, filename } = await fetchAndName(imgSrc);
const blob = new Blob([buffer], { type: "image/png" });
await downloadBlob(blob, filename);
btn.innerHTML = `<span class="stkrdl-ico">✓</span>`;
} catch (err) {
console.error("[Sticker DL] error", err);
btn.innerHTML = `<span class="stkrdl-ico">×</span>`;
} finally {
setTimeout(() => {
btn.removeAttribute("disabled");
btn.innerHTML = old;
}, 900);
}
};
parent.appendChild(btn);
}
// ---------- Download / network ----------
async function fetchAndName(url) {
const buffer = await fetchArrayBuffer(url);
let base = "steam_sticker";
try {
const parts = url.split("/");
base = (parts[parts.length - 1] || base).split("?")[0].replace(/\.png$/i, "") || base;
} catch {}
// 不显示 APNG,但为了你后续批量处理方便:仍然用 apng_/png_ 前缀区分
const isApng = hasChunk(buffer, "acTL");
const filename = (isApng ? "apng_" : "png_") + base + ".png";
return { buffer, filename };
}
function fetchArrayBuffer(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url,
responseType: "arraybuffer",
headers: { Referer: "https://store.steampowered.com/" },
onload: (res) => {
if (res.status >= 200 && res.status < 300) resolve(res.response);
else reject(new Error("HTTP " + res.status));
},
onerror: () => reject(new Error("network error")),
ontimeout: () => reject(new Error("timeout")),
});
});
}
async function downloadBlob(blob, filename) {
// Prefer GM_download
if (typeof GM_download === "function") {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
GM_download({
url,
name: filename,
saveAs: false,
onload: () => { URL.revokeObjectURL(url); resolve(); },
onerror: (e) => { URL.revokeObjectURL(url); reject(e); },
ontimeout: () => { URL.revokeObjectURL(url); reject(new Error("download timeout")); },
});
});
}
// Fallback
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 8000);
}
// ---------- APNG detector (silent, used only for filename prefix) ----------
function hasChunk(arrayBuffer, chunkType4) {
const u8 = new Uint8Array(arrayBuffer);
if (u8.length < 8) return false;
// PNG signature
const sig = [137,80,78,71,13,10,26,10];
for (let i = 0; i < 8; i++) if (u8[i] !== sig[i]) return false;
const typeBytes = [
chunkType4.charCodeAt(0),
chunkType4.charCodeAt(1),
chunkType4.charCodeAt(2),
chunkType4.charCodeAt(3),
];
let off = 8;
while (off + 8 <= u8.length) {
const len = readU32BE(u8, off);
const t0 = u8[off + 4], t1 = u8[off + 5], t2 = u8[off + 6], t3 = u8[off + 7];
if (t0 === typeBytes[0] && t1 === typeBytes[1] && t2 === typeBytes[2] && t3 === typeBytes[3]) {
return true;
}
off += 8 + len + 4; // length + type + data + crc
if (off > u8.length) break;
}
return false;
}
function readU32BE(u8, off) {
return ((u8[off] << 24) | (u8[off+1] << 16) | (u8[off+2] << 8) | (u8[off+3])) >>> 0;
}
})();

结语#

虽然折腾了一圈(从找工具到写 JS 再到写 Python),但最终看着微信里那一个个流畅播放的 Steam 贴纸,感觉还是挺值的。这也算是一次“为了偷懒而更努力”的典型案例吧!

Steam 贴纸提取记:从 APNG 到 64色 GIF 的折腾之路
https://blog.jisuk.top/posts/steam-贴纸提取记/
作者
不鹤Buhe
发布于
2026-01-19
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00