// ==UserScript==
// @name 抖音表情包下载(评论区)Douyin_download
// @namespace https://local.9527/
// @version 0.1.2
// @description Parse already-loaded Douyin comment stickers safely, preview them, and download individually or as a zip bundle.
// @author 代号_9527
// @match https://www.douyin.com/*
// @grant GM_addStyle
// @grant GM_download
// @grant GM.download
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @connect *
// ==/UserScript==
(function () {
"use strict";
const PANEL_ID = "dy-sticker-parser-panel";
const MODAL_ID = "dy-sticker-parser-modal";
const LAUNCHER_CAT_DATA_URL = "data:image/png;base64,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";
const STATE = {
items: [],
lastScanAt: null,
pageUrl: "",
pageTitle: "",
ui: {
collapsed: false,
position: null,
drag: null,
},
};
const CRC32_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i;
for (let j = 0; j < 8; j += 1) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c >>> 0;
}
return table;
})();
function getGmXmlHttpRequest() {
if (typeof GM_xmlhttpRequest === "function") return GM_xmlhttpRequest;
if (typeof GM !== "undefined" && GM && typeof GM.xmlHttpRequest === "function") {
return GM.xmlHttpRequest.bind(GM);
}
return null;
}
function getGmDownload() {
if (typeof GM_download === "function") return GM_download;
if (typeof GM !== "undefined" && GM && typeof GM.download === "function") {
return GM.download.bind(GM);
}
return null;
}
function isStickerResourceUrl(url) {
if (!url || typeof url !== "string") return false;
return (
url.includes("douyinpic.com") &&
(url.includes("sticker_comment") || url.includes("biz_tag=aweme_comment"))
);
}
function stickerResourceParams(url) {
const params = normalizeUrl(url).split("?").slice(1).join("?");
const has = {};
params.split("&").forEach(function(entry) {
const eq = entry.indexOf("=");
if (eq < 0) return;
has[entry.substring(0, eq)] = entry.substring(eq + 1);
});
return has;
}
function isStickerLikelyAnimatedSticker(url) {
const params = stickerResourceParams(url);
return params.se === "false" && params.sc === "sticker_heif";
}
function isInlineEmojiImage(img) {
if (!img || img.tagName !== "IMG") return false;
const src = img.getAttribute("src") || "";
const alt = img.getAttribute("alt") || "";
return !!alt && !isStickerResourceUrl(src);
}
function sanitizeFilename(input) {
const value = String(input || "")
.replace(/[\\/:*?"<>|]/g, "_")
.replace(/\s+/g, " ")
.trim();
return value || "sticker";
}
function extensionFromContentType(contentType) {
const value = String(contentType || "").toLowerCase();
if (value.includes("image/gif")) return ".gif";
if (value.includes("image/webp")) return ".webp";
if (value.includes("image/png")) return ".png";
if (value.includes("image/jpeg")) return ".jpg";
if (value.includes("image/jpg")) return ".jpg";
if (value.includes("image/bmp")) return ".bmp";
if (value.includes("image/heic")) return ".heic";
if (value.includes("image/heif")) return ".heif";
return ".bin";
}
function normalizeUrl(url) {
return String(url || "").replace(/&/g, "&").trim();
}
function inferExtensionFromUrl(url) {
const clean = normalizeUrl(url);
try {
const parsed = new URL(clean, location.href);
const path = parsed.pathname.toLowerCase();
const match = path.match(/\.(gif|png|jpe?g|webp|bmp|heic|heif)$/);
return match ? `.${match[1] === "jpeg" ? "jpg" : match[1]}` : "";
} catch (e) {
console.warn("[douyin-sticker-parser] 无法从 URL 推断扩展名:", e);
return "";
}
}
function buildReadmeText({ pageTitle, pageUrl, generatedAt, items, failures }) {
const lines = [
"抖音评论贴纸导出摘要",
"",
`导出时间:${generatedAt}`,
`页面标题:${pageTitle || ""}`,
`页面链接:${pageUrl || ""}`,
`资源总数:${items.length}`,
`失败项:${failures.length}`,
"",
"资源明细:",
];
items.forEach((item, index) => {
lines.push(
`${index + 1}. 文件名:${item.filename}`,
` 作者:${item.author || "未知作者"}`,
` 时间:${item.timeText || "未知时间"}`,
` 类型:${item.resourceKind || "未知类型"}`,
` 原始 MIME:${item.originalMime || "未知"}`,
` 最终 MIME:${item.finalMime || "未知"}`,
` 来源:${item.resourceUrl}`,
""
);
});
if (failures.length > 0) {
lines.push("失败明细:");
failures.forEach((failure, index) => {
lines.push(
`${index + 1}. 链接:${failure.resourceUrl}`,
` 原因:${failure.reason}`,
""
);
});
}
return lines.join("\n");
}
function parseContentTypeFromHeaders(headersText) {
const line = String(headersText || "")
.split(/\r?\n/)
.find((entry) => /^content-type:/i.test(entry));
return line ? line.split(":").slice(1).join(":").trim() : "";
}
function clampPanelPosition(position, viewport, panelSize, margin = 12) {
const maxLeft = Math.max(margin, viewport.width - panelSize.width - margin);
const maxTop = Math.max(margin, viewport.height - panelSize.height - margin);
return {
left: Math.min(Math.max(position.left, margin), maxLeft),
top: Math.min(Math.max(position.top, margin), maxTop),
};
}
function getCollapsedBadgeText(count) {
if (!Number.isFinite(count) || count <= 0) return "";
return count > 99 ? "99+" : String(count);
}
function createStyles() {
const css = `
#${PANEL_ID} {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 2147483640;
width: 368px;
max-width: calc(100vw - 20px);
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 14px;
pointer-events: none;
color: #553246;
font: 13px/1.5 "Trebuchet MS", "Microsoft YaHei", sans-serif;
--dysp-bg-1: #fff9fc;
--dysp-bg-2: #ffe5f1;
--dysp-bg-3: #ffd2e7;
--dysp-ink: #553246;
--dysp-subtle: #8a6476;
--dysp-accent: #ff72ac;
--dysp-accent-strong: #ff4f95;
--dysp-accent-soft: #ffc0da;
--dysp-line: rgba(255, 119, 174, 0.2);
--dysp-shadow: 0 24px 60px rgba(255, 116, 172, 0.22);
}
#${PANEL_ID} * {
box-sizing: border-box;
}
#${PANEL_ID} .dysp-launcher {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 58px;
height: 48px;
padding: 0 12px;
pointer-events: auto;
border: 1px solid rgba(255, 122, 180, 0.34);
border-radius: 18px;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.96), transparent 38%),
linear-gradient(135deg, rgba(255, 227, 239, 0.98), rgba(255, 244, 249, 0.98) 56%, rgba(255, 210, 231, 0.98));
color: #8b4069;
box-shadow:
0 18px 34px rgba(255, 109, 170, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
transform-origin: right bottom;
transition:
transform 340ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 240ms ease,
filter 240ms ease,
box-shadow 240ms ease;
touch-action: none;
}
#${PANEL_ID} .dysp-launcher::before {
content: "✿";
font-size: 18px;
line-height: 1;
display: none;
}
#${PANEL_ID} .dysp-launcher-badge:empty {
display: none;
}
#${PANEL_ID} .dysp-launcher-cat {
width: 24px;
height: 24px;
flex: none;
object-fit: contain;
pointer-events: none;
}
#${PANEL_ID} .dysp-launcher-face {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.03em;
}
#${PANEL_ID} .dysp-launcher-badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #ff76b1, #ff5a97);
color: #fff8fc;
box-shadow: 0 8px 18px rgba(255, 91, 150, 0.28);
font-size: 11px;
font-weight: 800;
}
#${PANEL_ID} .dysp-shell {
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
max-height: 78vh;
pointer-events: auto;
position: relative;
overflow: hidden;
border: 1px solid var(--dysp-line);
border-radius: 28px;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 28%),
linear-gradient(180deg, rgba(255, 251, 253, 0.98), rgba(255, 234, 243, 0.96) 55%, rgba(255, 223, 236, 0.96));
box-shadow: var(--dysp-shadow);
backdrop-filter: blur(16px);
transform-origin: right bottom;
transition:
transform 360ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 260ms ease,
visibility 260ms ease,
max-height 260ms ease,
filter 260ms ease;
}
#${PANEL_ID} .dysp-shell::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(circle at 16% 18%, rgba(255, 255, 255, 0.9), transparent 16%),
radial-gradient(circle at 84% 12%, rgba(255, 191, 221, 0.35), transparent 18%),
linear-gradient(135deg, rgba(255, 255, 255, 0.2), transparent 48%);
pointer-events: none;
}
#${PANEL_ID}.is-collapsed {
width: auto;
max-width: none;
}
#${PANEL_ID}.is-collapsed .dysp-launcher {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
filter: saturate(1);
}
#${PANEL_ID}.is-collapsed .dysp-shell {
opacity: 0;
visibility: hidden;
pointer-events: none;
max-height: 0;
transform: translateY(22px) scale(0.86);
filter: blur(2px);
}
#${PANEL_ID}.is-expanded .dysp-launcher {
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(14px) scale(0.84);
filter: saturate(0.85);
animation: none;
}
#${PANEL_ID}.is-expanded .dysp-shell {
opacity: 1;
visibility: visible;
max-height: 78vh;
transform: translateY(0) scale(1);
filter: blur(0);
}
#${PANEL_ID} .dysp-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 18px 14px;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.9), transparent 26%),
linear-gradient(135deg, rgba(255, 228, 240, 0.96), rgba(255, 247, 251, 0.98) 58%, rgba(255, 219, 235, 0.94));
border-bottom: 1px solid var(--dysp-line);
cursor: grab;
user-select: none;
touch-action: none;
}
#${PANEL_ID}.is-dragging .dysp-header {
cursor: grabbing;
}
#${PANEL_ID}.is-dragging .dysp-launcher {
cursor: grabbing;
}
#${PANEL_ID} .dysp-header::after {
content: "";
width: 16px;
height: 16px;
border-radius: 999px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.88), rgba(255, 196, 221, 0.28) 70%, transparent 72%);
color: transparent;
font-size: 0;
}
#${PANEL_ID} .dysp-header-copy {
min-width: 0;
}
#${PANEL_ID} .dysp-title {
font-size: 16px;
font-weight: 800;
letter-spacing: 0.02em;
}
#${PANEL_ID} .dysp-subtitle {
margin-top: 7px;
color: var(--dysp-subtle);
font-size: 12px;
}
#${PANEL_ID} .dysp-actions {
display: flex;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--dysp-line);
background: rgba(255, 252, 254, 0.7);
}
#${PANEL_ID} button {
appearance: none;
border: 1px solid rgba(255, 128, 181, 0.22);
border-radius: 16px;
padding: 10px 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 241, 247, 0.94));
color: var(--dysp-ink);
cursor: pointer;
font-weight: 700;
box-shadow:
0 8px 18px rgba(255, 134, 187, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
transition:
transform 180ms ease,
box-shadow 180ms ease,
background 180ms ease,
border-color 180ms ease;
}
#${PANEL_ID} button:hover {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 231, 241, 0.96));
box-shadow:
0 12px 22px rgba(255, 118, 177, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.94);
transform: translateY(-1px);
}
#${PANEL_ID} .dysp-utility-button {
color: #151318;
border-color: rgba(84, 58, 72, 0.18);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 243, 248, 0.94));
}
#${PANEL_ID} .dysp-utility-button:hover {
color: #0f0e12;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 238, 245, 0.96));
}
#${PANEL_ID} button:active {
transform: translateY(0);
}
#${PANEL_ID} button:disabled {
cursor: not-allowed;
opacity: 0.65;
transform: none;
box-shadow: none;
}
#${PANEL_ID} .dysp-icon-button {
flex: none;
min-width: 44px;
padding: 10px 0;
border-radius: 14px;
font-size: 12px;
}
#${PANEL_ID} .dysp-primary {
background: linear-gradient(135deg, var(--dysp-accent), var(--dysp-accent-strong));
color: #fff9fc;
border-color: transparent;
box-shadow:
0 14px 26px rgba(255, 95, 156, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
#${PANEL_ID} .dysp-primary:hover {
background: linear-gradient(135deg, #ff6ca8, #ff4b90);
color: #fff9fc;
}
#${PANEL_ID} .dysp-status {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid var(--dysp-line);
color: #70475d;
background: linear-gradient(180deg, rgba(255, 248, 251, 0.82), rgba(255, 236, 244, 0.74));
}
#${PANEL_ID} .dysp-list {
overflow: auto;
padding: 14px;
display: grid;
gap: 12px;
}
#${PANEL_ID} .dysp-empty {
padding: 18px;
text-align: center;
color: var(--dysp-subtle);
border: 1px dashed rgba(255, 128, 181, 0.28);
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(255, 242, 248, 0.88));
}
#${PANEL_ID} .dysp-card {
display: grid;
grid-template-columns: 80px 1fr;
gap: 12px;
padding: 12px;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 241, 247, 0.9)),
linear-gradient(135deg, rgba(255, 236, 244, 0.7), rgba(255, 255, 255, 0.1));
border: 1px solid rgba(255, 140, 188, 0.18);
box-shadow: 0 12px 26px rgba(255, 144, 190, 0.1);
}
#${PANEL_ID} .dysp-thumb {
width: 80px;
height: 80px;
border-radius: 18px;
overflow: hidden;
background: linear-gradient(135deg, #ffe0ef, #fff8fc);
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88);
}
#${PANEL_ID} .dysp-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
#${PANEL_ID} .dysp-thumb-button {
padding: 0;
border: none;
cursor: zoom-in;
}
#${PANEL_ID} .dysp-thumb-button:hover,
#${PANEL_ID} .dysp-thumb-button:active {
background: linear-gradient(135deg, #ffe0ef, #fff8fc);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88);
transform: none;
}
#${PANEL_ID} .dysp-card-title {
font-weight: 700;
margin-bottom: 6px;
word-break: break-all;
}
#${PANEL_ID} .dysp-card-title-button {
display: block;
width: 100%;
padding: 0;
margin-bottom: 6px;
border: none;
background: transparent;
box-shadow: none;
border-radius: 0;
color: #402635;
font-size: 15px;
font-weight: 800;
text-align: left;
word-break: break-all;
cursor: pointer;
}
#${PANEL_ID} .dysp-card-title-button:hover,
#${PANEL_ID} .dysp-card-title-button:active {
background: transparent;
box-shadow: none;
color: #23131d;
transform: none;
}
#${PANEL_ID} .dysp-meta {
color: #7c6070;
margin-bottom: 8px;
word-break: break-word;
}
#${PANEL_ID} .dysp-tag {
display: inline-flex;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(255, 209, 229, 0.98), rgba(255, 236, 245, 0.98));
color: #af4f7b;
font-size: 11px;
margin-bottom: 8px;
border: 1px solid rgba(255, 141, 190, 0.24);
}
#${PANEL_ID} .dysp-card-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
#${PANEL_ID} .dysp-download-button,
#${PANEL_ID} .dysp-copy-button {
display: inline-flex;
width: 100%;
justify-content: center;
}
#${MODAL_ID} {
position: fixed;
inset: 0;
z-index: 2147483646;
display: none;
align-items: center;
justify-content: center;
background: rgba(64, 30, 48, 0.48);
padding: 24px;
}
#${MODAL_ID}.is-open {
display: flex;
}
#${MODAL_ID} .dysp-modal-card {
width: min(720px, 100%);
max-height: 86vh;
overflow: auto;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, rgba(255, 250, 253, 0.98), rgba(255, 233, 242, 0.96));
border-radius: 28px;
padding: 20px;
border: 1px solid rgba(255, 155, 198, 0.28);
box-shadow: 0 28px 60px rgba(98, 43, 69, 0.28);
}
#${MODAL_ID} .dysp-modal-preview {
width: 100%;
min-height: 260px;
border-radius: 20px;
background: linear-gradient(135deg, #ffe7f2, #fffafc);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#${MODAL_ID} .dysp-modal-preview img {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
}
#${MODAL_ID} .dysp-modal-actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
@media (prefers-reduced-motion: reduce) {
#${PANEL_ID} .dysp-launcher,
#${PANEL_ID} .dysp-shell,
#${PANEL_ID} button {
animation: none !important;
transition: none !important;
}
}
@media (max-width: 680px) {
#${PANEL_ID} {
width: calc(100vw - 20px);
max-width: calc(100vw - 20px);
bottom: 10px;
right: 10px;
left: auto;
}
#${PANEL_ID} .dysp-shell {
max-height: 70vh;
}
#${PANEL_ID}.is-expanded .dysp-shell {
max-height: 70vh;
}
#${PANEL_ID} .dysp-actions {
flex-direction: column;
}
}`;
if (typeof GM_addStyle === "function") {
GM_addStyle(css);
return;
}
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
}
function findCommentRoot(node) {
if (!node || typeof node.closest !== "function") return null;
return (
node.closest('[data-e2e="comment-item"]') ||
node.closest(".UuCzPLbi") ||
node.closest(".EpsntdUI")
);
}
function textOf(element) {
return element ? String(element.textContent || "").trim() : "";
}
function simpleHash(input) {
const text = String(input || "");
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36);
}
function formatLabelFromMime(contentType) {
const value = String(contentType || "").toLowerCase();
if (value.includes("image/gif")) return "GIF";
if (value.includes("image/png")) return "PNG";
if (value.includes("image/jpeg")) return "JPG";
if (value.includes("image/webp")) return "WEBP";
if (value.includes("image/heic")) return "HEIC";
if (value.includes("image/heif")) return "HEIF";
return "";
}
function formatLabelFromUrl(url) {
const value = String(url || "").toLowerCase();
if (value.includes(".gif")) return "GIF";
if (value.includes(".png")) return "PNG";
if (value.includes(".jpg") || value.includes(".jpeg")) return "JPG";
if (value.includes(".webp")) return "WEBP";
if (isStickerLikelyAnimatedSticker(url)) return "GIF";
if (value.includes("sc=sticker_heif")) return "HEIF";
return "未知";
}
function resourceKindFromUrl(url) {
const clean = normalizeUrl(url).toLowerCase();
if (clean.includes(".gif")) return "gif";
if (isStickerLikelyAnimatedSticker(url)) return "gif";
if (clean.includes("sticker_heif")) return "sticker";
if (clean.includes("sticker_comment")) return "sticker";
return "image";
}
function chooseDownloadOptions(item) {
const resourceUrl = item && item.resourceUrl ? item.resourceUrl : "";
const resourceKind = item && item.resourceKind ? item.resourceKind : "";
const mime = String((item && item.originalMime) || "").toLowerCase();
const isAnimatedSticker = isStickerLikelyAnimatedSticker(resourceUrl);
if (mime.includes("image/gif")) {
return { preserveOriginal: true };
}
if (isAnimatedSticker) {
return mime.includes("image/webp")
? { webpToGif: true }
: { preserveOriginal: true };
}
if (resourceKind === "gif") {
return { preserveOriginal: true };
}
return { targetStaticMime: "image/jpeg" };
}
function collectLoadedResources() {
const seen = new Set();
const items = [];
const candidates = Array.from(document.querySelectorAll("img[src]"));
for (const img of candidates) {
const rawSrc = img.getAttribute("src") || "";
const resourceUrl = normalizeUrl(rawSrc);
if (!isStickerResourceUrl(resourceUrl)) continue;
if (isInlineEmojiImage(img)) continue;
const commentRoot = findCommentRoot(img);
if (!commentRoot) continue;
const key = `${location.href}::${resourceUrl}`;
if (seen.has(key)) continue;
seen.add(key);
const authorLink =
commentRoot.querySelector(".comment-item-info-wrap a") ||
commentRoot.querySelector('a[href*="/user/"]');
const timeNode = commentRoot.querySelector(".fJhvAqos") || commentRoot.querySelector("time");
const contentWrap = commentRoot.querySelector(".C7LroK_h") || commentRoot;
const textFlow = contentWrap.querySelector(".WFJiGxr7");
const textContent = textOf(textFlow || contentWrap).replace(/\s+/g, " ").trim();
items.push({
id: key,
resourceUrl,
previewUrl: resourceUrl,
author: textOf(authorLink) || "未知作者",
timeText: textOf(timeNode) || "未知时间",
commentText: textContent || "无可读文本",
resourceKind: resourceKindFromUrl(resourceUrl),
formatHint: formatLabelFromUrl(resourceUrl),
originalMime: "",
finalMime: "",
});
}
return items;
}
// Legacy panel markup removed. Use ensurePanel() instead.
function ensurePanel() {
let panel = document.getElementById(PANEL_ID);
if (panel) return panel;
panel = document.createElement("aside");
panel.id = PANEL_ID;
panel.innerHTML = `
等待手动扫描。
`;
const launcher = panel.querySelector(".dysp-launcher");
const launcherFace = panel.querySelector(".dysp-launcher-face");
const titleNode = panel.querySelector(".dysp-title");
const subtitleNode = panel.querySelector(".dysp-subtitle");
const collapseButton = panel.querySelector('[data-action="collapse-panel"]');
const scanButton = panel.querySelector('[data-action="scan"]');
const downloadAllButton = panel.querySelector('[data-action="download-all"]');
const statusNode = panel.querySelector('[data-role="status"]');
const emptyNode = panel.querySelector(".dysp-empty");
if (launcher instanceof HTMLElement && !launcher.querySelector(".dysp-launcher-cat")) {
const cat = document.createElement("img");
cat.className = "dysp-launcher-cat";
cat.src = LAUNCHER_CAT_DATA_URL;
cat.alt = "";
cat.setAttribute("aria-hidden", "true");
launcher.insertBefore(cat, launcher.firstChild);
}
if (launcherFace) launcherFace.textContent = "摸摸";
if (titleNode) titleNode.textContent = "抖音表情包助手";
if (subtitleNode) subtitleNode.textContent = "只处理当前已加载评论,不自动滚动,不自动点击。";
if (collapseButton) {
collapseButton.textContent = "收起";
collapseButton.setAttribute("aria-label", "折叠面板");
}
if (scanButton) {
scanButton.textContent = "扫描已加载";
scanButton.classList.add("dysp-utility-button");
}
if (downloadAllButton) downloadAllButton.textContent = "全部下载";
if (statusNode) statusNode.textContent = "等待手动扫描。";
if (emptyNode) emptyNode.textContent = "还没有扫描结果。";
panel.addEventListener("click", handlePanelClick);
document.body.appendChild(panel);
bindPanelDrag(panel);
applyPanelUiState();
return panel;
}
function setPanelCollapsed(nextCollapsed) {
STATE.ui.collapsed = !!nextCollapsed;
applyPanelUiState();
}
function shouldStartPanelDrag(event, target, handle) {
if (event && typeof event.button === "number" && event.button !== 0) return false;
if (!target || !handle || target === handle) return true;
const innerButton = typeof target.closest === "function" ? target.closest("button") : null;
return !(innerButton && innerButton !== handle);
}
function bindPanelDrag(panel) {
if (panel.dataset.dragBound === "true") return;
panel.dataset.dragBound = "true";
const handles = Array.from(panel.querySelectorAll('[data-role="drag-handle"]'))
.filter((entry) => entry instanceof HTMLElement);
if (!handles.length) return;
let lastPointerDownAt = 0;
const dragThreshold = 4;
const updateDragPosition = (clientX, clientY) => {
if (!STATE.ui.drag) return;
const dx = clientX - STATE.ui.drag.startX;
const dy = clientY - STATE.ui.drag.startY;
if (!STATE.ui.drag.moved && Math.hypot(dx, dy) < dragThreshold) return;
STATE.ui.drag.moved = true;
STATE.ui.position = clampPanelPosition(
{
left: clientX - STATE.ui.drag.offsetX,
top: clientY - STATE.ui.drag.offsetY,
},
{ width: window.innerWidth, height: window.innerHeight },
{ width: STATE.ui.drag.width, height: STATE.ui.drag.height }
);
applyPanelUiState();
};
const clearDrag = (event) => {
if (!STATE.ui.drag) return;
const wasMoved = STATE.ui.drag.moved;
if (STATE.ui.drag.mode === "pointer" && typeof STATE.ui.drag.pointerId === "number") {
try {
STATE.ui.drag.handle?.releasePointerCapture?.(STATE.ui.drag.pointerId);
} catch (_error) {
// Ignore release failures when capture was never acquired.
}
}
STATE.ui.drag = null;
applyPanelUiState();
if (wasMoved && event) {
const swallowClick = (clickEvent) => {
clickEvent.stopPropagation();
clickEvent.preventDefault();
document.removeEventListener("click", swallowClick, true);
};
document.addEventListener("click", swallowClick, true);
setTimeout(() => document.removeEventListener("click", swallowClick, true), 80);
}
};
const beginDrag = (event, mode, handle, pointerId = null) => {
if (!shouldStartPanelDrag(event, event.target, handle)) return false;
const rect = panel.getBoundingClientRect();
STATE.ui.drag = {
mode,
pointerId,
handle,
moved: false,
startX: event.clientX,
startY: event.clientY,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
width: rect.width,
height: rect.height,
};
if (mode === "pointer" && typeof pointerId === "number") {
handle.setPointerCapture?.(pointerId);
}
applyPanelUiState();
return true;
};
handles.forEach((handle) => {
handle.addEventListener("pointerdown", (event) => {
lastPointerDownAt = Date.now();
beginDrag(event, "pointer", handle, event.pointerId);
});
handle.addEventListener("mousedown", (event) => {
if (Date.now() - lastPointerDownAt < 80) return;
beginDrag(event, "mouse", handle);
});
});
document.addEventListener("pointermove", (event) => {
if (!STATE.ui.drag || STATE.ui.drag.mode !== "pointer") return;
if (typeof STATE.ui.drag.pointerId === "number" && event.pointerId !== STATE.ui.drag.pointerId) return;
updateDragPosition(event.clientX, event.clientY);
});
document.addEventListener("mousemove", (event) => {
if (!STATE.ui.drag || STATE.ui.drag.mode !== "mouse") return;
if (typeof event.buttons === "number" && (event.buttons & 1) !== 1) {
clearDrag();
return;
}
updateDragPosition(event.clientX, event.clientY);
});
document.addEventListener("pointerup", (event) => {
if (!STATE.ui.drag || STATE.ui.drag.mode !== "pointer") return;
if (typeof STATE.ui.drag.pointerId === "number" && event.pointerId !== STATE.ui.drag.pointerId) return;
clearDrag(event);
});
document.addEventListener("pointercancel", clearDrag);
document.addEventListener("mouseup", (event) => {
if (!STATE.ui.drag || STATE.ui.drag.mode !== "mouse") return;
if (typeof event.button === "number" && event.button !== 0) return;
clearDrag(event);
});
}
function applyPanelUiState() {
const panel = ensurePanel();
const launcher = panel.querySelector('[data-action="toggle-panel"]');
const shell = panel.querySelector('[data-role="shell"]');
const badge = panel.querySelector('[data-role="launcher-badge"]');
const expanded = !STATE.ui.collapsed;
panel.classList.toggle("is-collapsed", STATE.ui.collapsed);
panel.classList.toggle("is-expanded", expanded);
panel.classList.toggle("is-dragging", Boolean(STATE.ui.drag));
panel.style.left = STATE.ui.position ? `${STATE.ui.position.left}px` : "";
panel.style.top = STATE.ui.position ? `${STATE.ui.position.top}px` : "";
panel.style.right = STATE.ui.position ? "auto" : "20px";
panel.style.bottom = STATE.ui.position ? "auto" : "20px";
if (launcher instanceof HTMLElement) {
launcher.setAttribute("aria-expanded", String(expanded));
launcher.setAttribute("aria-hidden", String(expanded));
}
if (shell instanceof HTMLElement) {
shell.setAttribute("aria-hidden", String(STATE.ui.collapsed));
}
if (badge) badge.textContent = getCollapsedBadgeText(STATE.items.length);
}
function ensureModal() {
let modal = document.getElementById(MODAL_ID);
if (modal) return modal;
modal = document.createElement("div");
modal.id = MODAL_ID;
modal.innerHTML = `
`;
const closeButton = modal.querySelector('[data-action="close-preview"]');
const copyButton = modal.querySelector('[data-action="copy-preview-url"]');
const confirmButton = modal.querySelector('[data-action="confirm-download"]');
if (closeButton) closeButton.textContent = "关闭";
if (copyButton) copyButton.textContent = "复制URL";
if (confirmButton) confirmButton.remove();
modal.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (target.id === MODAL_ID) {
closePreviewModal();
return;
}
if (target.dataset.action === "close-preview") {
closePreviewModal();
return;
}
if (target.dataset.action === "copy-preview-url") {
const item = STATE.items.find((entry) => entry.id === modal.getAttribute("data-item-id"));
if (item) void copyItemUrl(item);
}
});
document.body.appendChild(modal);
return modal;
}
function setStatus(message) {
const panel = ensurePanel();
const node = panel.querySelector('[data-role="status"]');
if (node) node.textContent = message;
}
function renderList() {
const panel = ensurePanel();
const list = panel.querySelector('[data-role="list"]');
const downloadAllButton = panel.querySelector('[data-action="download-all"]');
if (!list || !downloadAllButton) return;
downloadAllButton.disabled = STATE.items.length === 0;
if (STATE.items.length === 0) {
const emptyState = document.createElement("div");
emptyState.className = "dysp-empty";
emptyState.textContent = "当前已加载评论里还没有找到贴纸资源。";
list.innerHTML = "";
list.appendChild(emptyState);
return;
}
list.innerHTML = STATE.items
.map((item, index) => {
const title = sanitizeFilename(`${index + 1}-${item.author}`);
const summary = sanitizeFilename(item.commentText).slice(0, 60);
const formatTag = item.finalMime
? formatLabelFromMime(item.finalMime)
: (item.originalMime ? formatLabelFromMime(item.originalMime) : item.formatHint);
return `
${escapeHtml(item.resourceKind.toUpperCase())} / ${escapeHtml(formatTag || "未知")}
作者:${escapeHtml(item.author)}
时间:${escapeHtml(item.timeText)}
`;
})
.join("");
applyPanelUiState();
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function scanLoaded() {
STATE.pageUrl = location.href;
STATE.pageTitle = document.title;
STATE.items = collectLoadedResources();
STATE.lastScanAt = new Date();
renderList();
setStatus(
STATE.items.length > 0
? `扫描完成:识别到 ${STATE.items.length} 个已加载资源。`
: "扫描完成:当前已加载评论中未发现贴纸资源。"
);
}
function handlePanelClick(event) {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const actionTarget = target.closest("[data-action]");
if (!(actionTarget instanceof HTMLElement)) return;
const action = actionTarget.dataset.action;
if (action === "toggle-panel") {
setPanelCollapsed(!STATE.ui.collapsed);
return;
}
if (action === "collapse-panel") {
setPanelCollapsed(true);
return;
}
if (action === "scan") {
scanLoaded();
return;
}
if (action === "download-all") {
void downloadAllItems();
return;
}
if (action === "download-item") {
const item = STATE.items.find((entry) => entry.id === actionTarget.dataset.itemId);
if (item) void downloadSingleItem(item);
return;
}
if (action === "copy-url") {
const item = STATE.items.find((entry) => entry.id === actionTarget.dataset.itemId);
if (item) void copyItemUrl(item);
return;
}
if (action === "open-preview" || action === "preview-item") {
const item = STATE.items.find((entry) => entry.id === actionTarget.dataset.itemId);
if (item) void openPreviewModal(item);
}
}
async function copyTextToClipboard(text) {
const value = normalizeUrl(text);
if (!value) {
throw new Error("URL 为空");
}
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
try {
await navigator.clipboard.writeText(value);
return;
} catch (_error) {
// 继续使用 textarea 兜底。
}
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "readonly");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
if (!document.execCommand("copy")) {
throw new Error("浏览器拒绝复制");
}
} finally {
textarea.remove();
}
}
async function copyItemUrl(item) {
try {
await copyTextToClipboard(item.resourceUrl);
setStatus(`已复制URL:${normalizeUrl(item.resourceUrl)}`);
} catch (error) {
setStatus(`复制URL失败:${error instanceof Error ? error.message : String(error)}`);
}
}
async function openPreviewModal(item) {
const modal = ensureModal();
modal.setAttribute("data-item-id", item.id);
const preview = modal.querySelector('[data-role="preview"]');
const meta = modal.querySelector('[data-role="meta"]');
if (preview) {
preview.innerHTML = `
`;
}
if (meta) {
meta.innerHTML = [
`作者:${escapeHtml(item.author)}`,
`时间:${escapeHtml(item.timeText)}`,
`类型:${escapeHtml(item.resourceKind)}`,
`估计格式:${escapeHtml(item.formatHint || "未知")}`,
`说明:动图贴纸保留原格式下载。`,
].join("
");
}
modal.classList.add("is-open");
if (meta && !item.originalMime) {
const probe = await probeResourceMetadata(item.resourceUrl).catch(() => ({ contentType: "" }));
if (probe.contentType) {
item.originalMime = probe.contentType;
if (item.originalMime.includes("image/gif") || isStickerLikelyAnimatedSticker(item.resourceUrl)) {
item.resourceKind = "gif";
item.formatHint = "GIF";
}
renderList();
var downloadOptions = chooseDownloadOptions(item);
var finalSave = downloadOptions.webpToGif
? "动图转 GIF"
: (downloadOptions.preserveOriginal ? "保留原格式" : "JPG");
meta.innerHTML = [
`作者:${escapeHtml(item.author)}`,
`时间:${escapeHtml(item.timeText)}`,
`类型:GIF (动图)`,
`原始格式:${escapeHtml(formatLabelFromMime(item.originalMime) || item.originalMime)}`,
`最终保存:${finalSave}`,
].join("
");
}
}
}
function closePreviewModal() {
const modal = ensureModal();
modal.classList.remove("is-open");
modal.removeAttribute("data-item-id");
}
function fileBaseName(item, index) {
const author = sanitizeFilename(item.author).slice(0, 24);
const hash = simpleHash(item.resourceUrl).slice(0, 6);
return `${String(index + 1).padStart(3, "0")}-${author}-${hash}`;
}
function crc32(data) {
let crc = 0xffffffff;
for (let i = 0; i < data.length; i += 1) {
crc = CRC32_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function dosDateTime(date) {
const year = Math.max(1980, date.getFullYear());
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = Math.floor(date.getSeconds() / 2);
const dosTime = (hours << 11) | (minutes << 5) | seconds;
const dosDate = ((year - 1980) << 9) | (month << 5) | day;
return { dosDate, dosTime };
}
function encodeUtf8(text) {
return new TextEncoder().encode(text);
}
function writeUint16(view, offset, value) {
view.setUint16(offset, value & 0xffff, true);
}
function writeUint32(view, offset, value) {
view.setUint32(offset, value >>> 0, true);
}
function createStoredZip(files) {
const now = new Date();
const { dosDate, dosTime } = dosDateTime(now);
const utf8Flag = 1 << 11;
const localParts = [];
const centralParts = [];
let offset = 0;
files.forEach((file) => {
const nameBytes = encodeUtf8(file.name);
const data = file.data instanceof Uint8Array ? file.data : new Uint8Array(file.data);
const fileCrc32 = crc32(data);
const localHeader = new Uint8Array(30 + nameBytes.length);
const localView = new DataView(localHeader.buffer);
writeUint32(localView, 0, 0x04034b50);
writeUint16(localView, 4, 20);
writeUint16(localView, 6, utf8Flag);
writeUint16(localView, 8, 0);
writeUint16(localView, 10, dosTime);
writeUint16(localView, 12, dosDate);
writeUint32(localView, 14, fileCrc32);
writeUint32(localView, 18, data.length);
writeUint32(localView, 22, data.length);
writeUint16(localView, 26, nameBytes.length);
writeUint16(localView, 28, 0);
localHeader.set(nameBytes, 30);
localParts.push(localHeader, data);
const centralHeader = new Uint8Array(46 + nameBytes.length);
const centralView = new DataView(centralHeader.buffer);
writeUint32(centralView, 0, 0x02014b50);
writeUint16(centralView, 4, 20);
writeUint16(centralView, 6, 20);
writeUint16(centralView, 8, utf8Flag);
writeUint16(centralView, 10, 0);
writeUint16(centralView, 12, dosTime);
writeUint16(centralView, 14, dosDate);
writeUint32(centralView, 16, fileCrc32);
writeUint32(centralView, 20, data.length);
writeUint32(centralView, 24, data.length);
writeUint16(centralView, 28, nameBytes.length);
writeUint16(centralView, 30, 0);
writeUint16(centralView, 32, 0);
writeUint16(centralView, 34, 0);
writeUint16(centralView, 36, 0);
writeUint32(centralView, 38, 0);
writeUint32(centralView, 42, offset);
centralHeader.set(nameBytes, 46);
centralParts.push(centralHeader);
offset += localHeader.length + data.length;
});
const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
const endRecord = new Uint8Array(22);
const endView = new DataView(endRecord.buffer);
writeUint32(endView, 0, 0x06054b50);
writeUint16(endView, 4, 0);
writeUint16(endView, 6, 0);
writeUint16(endView, 8, files.length);
writeUint16(endView, 10, files.length);
writeUint32(endView, 12, centralSize);
writeUint32(endView, 16, offset);
writeUint16(endView, 20, 0);
return new Blob([...localParts, ...centralParts, endRecord], {
type: "application/zip",
});
}
async function blobToUint8Array(blob) {
return new Uint8Array(await blob.arrayBuffer());
}
function ensureBlobValue(value, contentType) {
if (value instanceof Blob) {
return value;
}
if (value instanceof ArrayBuffer) {
return new Blob([value], { type: contentType || "application/octet-stream" });
}
if (ArrayBuffer.isView(value)) {
return new Blob([value], { type: contentType || "application/octet-stream" });
}
if (typeof value === "string") {
return new Blob([value], { type: contentType || "text/plain;charset=utf-8" });
}
if (value == null) {
throw new Error("资源内容为空");
}
return new Blob([value], { type: contentType || "application/octet-stream" });
}
async function convertWebpToGif(blob) {
var buf = await blob.arrayBuffer();
var decoder = new ImageDecoder({ data: buf, type: "image/webp" });
await decoder.tracks.ready;
var firstFrame = await decoder.decode({ frameIndex: 0 });
var fullWidth = firstFrame.image.displayWidth || firstFrame.image.width;
var fullHeight = firstFrame.image.displayHeight || firstFrame.image.height;
firstFrame.image.close();
var frameCount = decoder.tracks.selectedTrack.frameCount || 0;
if (frameCount < 2) {
var single = await decoder.decode({ frameIndex: 0 });
var canvas = document.createElement("canvas");
canvas.width = fullWidth;
canvas.height = fullHeight;
var ctx = canvas.getContext("2d");
ctx.drawImage(single.image, 0, 0);
single.image.close();
return await canvasToPngBlob(canvas);
}
var canvas = document.createElement("canvas");
canvas.width = fullWidth;
canvas.height = fullHeight;
var ctx = canvas.getContext("2d");
var colorMap = {};
var colorTable = new Uint8Array(768);
var colorCount = 0;
function addColor(r, g, b) {
var key = r + ',' + g + ',' + b;
if (colorMap.hasOwnProperty(key)) return colorMap[key];
if (colorCount >= 256) return 0;
var idx = colorCount;
colorMap[key] = idx;
colorTable[idx * 3] = r;
colorTable[idx * 3 + 1] = g;
colorTable[idx * 3 + 2] = b;
colorCount += 1;
return idx;
}
addColor(0, 0, 0);
var encodedFrames = [];
for (var i = 0; i < frameCount; i += 1) {
var result = await decoder.decode({ frameIndex: i });
ctx.clearRect(0, 0, fullWidth, fullHeight);
ctx.drawImage(result.image, 0, 0);
var pixelCount = fullWidth * fullHeight;
var imageData = ctx.getImageData(0, 0, fullWidth, fullHeight);
var indices = new Uint8Array(pixelCount);
for (var pi = 0; pi < pixelCount; pi += 1) {
var o = pi * 4;
var r = imageData.data[o], g = imageData.data[o + 1], b = imageData.data[o + 2];
var qr = (r >> 5) & 7, qg = (g >> 5) & 7, qb = (b >> 5) & 7;
indices[pi] = addColor(qr << 5 | 16, qg << 5 | 16, qb << 5 | 16);
}
var delay = Math.max(3, Math.round((result.image.duration || 50000) / 10000));
encodedFrames.push({ indices: indices, delay: delay });
result.image.close();
}
return buildGifFromFrames(encodedFrames, fullWidth, fullHeight, colorTable);
}
function buildGifFromFrames(frames, width, height, colorTable) {
var header = new Uint8Array(13);
header.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 0);
header[6] = width & 0xff;
header[7] = (width >> 8) & 0xff;
header[8] = height & 0xff;
header[9] = (height >> 8) & 0xff;
header[10] = 0xf7;
header[11] = 0;
header[12] = 0;
var netscape = new Uint8Array(19);
netscape.set([0x21, 0xFF, 0x0B, 0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30, 0x03, 0x01, 0x00, 0x00, 0x00], 0);
var gce = new Uint8Array([0x21, 0xF9, 0x04, 0x04, 0, 0, 0, 0x00]);
var imgd = new Uint8Array([0x2C, 0, 0, 0, 0, width & 0xff, (width >> 8) & 0xff, height & 0xff, (height >> 8) & 0xff, 0x00]);
var minCode = new Uint8Array([8]);
// 重新压缩每帧(因为之前addColor过程中colorCount可能已变化)
var parts = [header, colorTable, netscape];
for (var f = 0; f < frames.length; f += 1) {
var frame = frames[f];
var lzwData = gifLzwCompress(frame.indices);
gce[4] = frame.delay & 0xff;
gce[5] = (frame.delay >> 8) & 0xff;
parts.push(new Uint8Array(gce), new Uint8Array(imgd), minCode);
var pos = 0;
while (pos < lzwData.length) {
var chunkLen = Math.min(255, lzwData.length - pos);
parts.push(new Uint8Array([chunkLen]), lzwData.slice(pos, pos + chunkLen));
pos += chunkLen;
}
parts.push(new Uint8Array([0]));
}
parts.push(new Uint8Array([0x3B]));
var totalLen = 0;
for (var pi = 0; pi < parts.length; pi += 1) { totalLen += parts[pi].length; }
var gif = new Uint8Array(totalLen);
var off = 0;
for (var pi = 0; pi < parts.length; pi += 1) {
gif.set(parts[pi], off);
off += parts[pi].length;
}
return new Blob([gif], { type: "image/gif" });
}
function gifLzwCompress(indices) {
var initCodeSize = 8;
var clearCode = 1 << initCodeSize;
var eoiCode = clearCode + 1;
var nextCode = clearCode + 2;
var codeSize = initCodeSize + 1;
var maxCode = (1 << codeSize) - 1;
var dict = {};
for (var j = 0; j < clearCode + 2; j += 1) {
dict[String(j)] = j;
}
var output = [];
var bitBuf = 0;
var bitCount = 0;
function emit(code) {
bitBuf |= (code << bitCount);
bitCount += codeSize;
while (bitCount >= 8) {
output.push(bitBuf & 0xff);
bitBuf >>= 8;
bitCount -= 8;
}
}
emit(clearCode);
if (indices.length > 0) {
var w = String(indices[0]);
for (var k = 1; k < indices.length; k += 1) {
var wk = w + ',' + indices[k];
if (dict.hasOwnProperty(wk)) {
w = wk;
} else {
emit(dict[w]);
if (nextCode < 4096) {
dict[wk] = nextCode;
nextCode += 1;
if (nextCode > maxCode && codeSize < 12) {
codeSize += 1;
maxCode = (1 << codeSize) - 1;
}
}
w = String(indices[k]);
}
}
emit(dict[w]);
}
emit(eoiCode);
if (bitCount > 0) { output.push(bitBuf & 0xff); }
return new Uint8Array(output);
}
async function convertImagePayload(payload, options = {}) {
const type = String(payload.contentType || "").toLowerCase();
const safeToRecode = ["image/png", "image/jpeg", "image/jpg", "image/bmp"];
if (
options.preserveOriginal ||
!type.startsWith("image/") ||
type.includes("image/gif") ||
type.includes("image/heic") ||
type.includes("image/heif") ||
type.includes("image/webp") ||
!safeToRecode.includes(type)
) {
return payload;
}
const targetStaticMime = options.targetStaticMime || "";
if (!targetStaticMime || targetStaticMime === type) {
return payload;
}
try {
const bitmap = await createImageBitmap(payload.blob);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
bitmap.close();
return payload;
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
const convertedBlob = await new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error(`图片转码失败:${targetStaticMime}`));
}, targetStaticMime, targetStaticMime === "image/jpeg" ? 0.92 : undefined);
});
return {
...payload,
blob: convertedBlob,
contentType: targetStaticMime,
convertedFrom: type,
};
} catch (e) {
console.warn("[douyin-sticker-parser] 转码失败,使用原始格式:", e);
return payload;
}
}
async function fetchBinary(url) {
const normalized = normalizeUrl(url);
const gmXhr = getGmXmlHttpRequest();
if (gmXhr) {
return await new Promise((resolve, reject) => {
gmXhr({
method: "GET",
url: normalized,
responseType: "blob",
timeout: 20000,
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
const contentType = parseContentTypeFromHeaders(response.responseHeaders);
let blob;
try {
blob = ensureBlobValue(response.response, contentType);
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;
}
resolve({
blob,
contentType,
});
return;
}
reject(new Error(`HTTP ${response.status}`));
},
onerror: () => reject(new Error("网络请求失败")),
ontimeout: () => reject(new Error("请求超时")),
});
});
}
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), 20000) : null;
try {
const response = await fetch(normalized, {
credentials: "omit",
signal: controller ? controller.signal : undefined,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const contentType = response.headers.get("content-type") || "";
const blob = await response.blob();
return { blob, contentType };
} finally {
if (timer) clearTimeout(timer);
}
}
async function probeResourceMetadata(url) {
const normalized = normalizeUrl(url);
const gmXhr = getGmXmlHttpRequest();
if (gmXhr) {
try {
return await new Promise((resolve, reject) => {
gmXhr({
method: "HEAD",
url: normalized,
timeout: 15000,
onload: (response) => {
if (response.status >= 200 && response.status < 400) {
resolve({
contentType: parseContentTypeFromHeaders(response.responseHeaders),
});
return;
}
reject(new Error(`HTTP ${response.status}`));
},
onerror: () => reject(new Error("HEAD 请求失败")),
ontimeout: () => reject(new Error("HEAD 请求超时")),
});
});
} catch (_error) {
// 继续走 fetch fallback
}
}
const response = await fetch(normalized, {
method: "HEAD",
credentials: "omit",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return {
contentType: response.headers.get("content-type") || "",
};
}
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 1500);
}
async function resolveDownloadPayload(item, index, options = {}) {
const rawPayload = await fetchBinary(item.resourceUrl);
let needWebpToGif = false;
if (
options.webpToGif &&
rawPayload.contentType &&
rawPayload.contentType.toLowerCase().includes("image/webp") &&
isStickerLikelyAnimatedSticker(item.resourceUrl)
) {
needWebpToGif = true;
}
let convertedPayload;
if (needWebpToGif) {
try {
const gifBlob = await convertWebpToGif(rawPayload.blob);
convertedPayload = {
blob: gifBlob,
contentType: "image/gif",
convertedFrom: rawPayload.contentType || "",
};
} catch (e) {
console.warn("[douyin-sticker-parser] WEBP转GIF失败,保留WEBP:", e);
convertedPayload = { blob: rawPayload.blob, contentType: rawPayload.contentType || "" };
}
} else {
convertedPayload = await convertImagePayload(rawPayload, options);
}
const { blob, contentType } = convertedPayload;
const guessedExtension = convertedPayload.convertedFrom
? extensionFromContentType(contentType)
: (extensionFromContentType(rawPayload.contentType) || inferExtensionFromUrl(item.resourceUrl) || extensionFromContentType(contentType));
const filename = `${fileBaseName(item, index)}${guessedExtension}`;
return {
filename,
blob,
contentType,
originalMime: rawPayload.contentType || "",
finalMime: contentType || "",
convertedFrom: convertedPayload.convertedFrom || "",
};
}
async function downloadSingleItem(item) {
setStatus(`准备下载:${item.author} / ${item.timeText}`);
try {
const index = STATE.items.findIndex((entry) => entry.id === item.id);
if (!item.originalMime) {
const probe = await probeResourceMetadata(item.resourceUrl).catch(() => ({ contentType: "" }));
item.originalMime = probe.contentType || "";
}
const downloadOptions = chooseDownloadOptions(item);
const payload = await resolveDownloadPayload(item, Math.max(index, 0), downloadOptions);
const filename = payload.filename;
item.originalMime = payload.originalMime;
item.finalMime = payload.finalMime;
triggerDownload(payload.blob, payload.filename);
closePreviewModal();
renderList();
setStatus(`单项下载完成:${filename}`);
} catch (error) {
setStatus(`单项下载失败:${error instanceof Error ? error.message : String(error)}`);
}
}
async function downloadAllItems() {
if (!STATE.items.length) {
setStatus("没有可打包的资源,请先扫描。");
return;
}
setStatus(`开始打包 ${STATE.items.length} 个已加载资源...`);
const failures = [];
const manifestItems = [];
const zipEntries = [];
for (let index = 0; index < STATE.items.length; index += 1) {
const item = STATE.items[index];
try {
if (!item.originalMime) {
const probe = await probeResourceMetadata(item.resourceUrl).catch(() => ({ contentType: "" }));
item.originalMime = probe.contentType || "";
}
const dlOptions = chooseDownloadOptions(item);
const payload = await resolveDownloadPayload(item, index, dlOptions);
const bytes = await blobToUint8Array(payload.blob);
zipEntries.push({
name: payload.filename,
data: bytes,
});
manifestItems.push({
...item,
filename: payload.filename,
originalMime: payload.originalMime,
finalMime: payload.finalMime,
});
setStatus(`打包中 ${index + 1}/${STATE.items.length}:${payload.filename}`);
} catch (error) {
failures.push({
resourceUrl: item.resourceUrl,
reason: error instanceof Error ? error.message : String(error),
});
}
}
const readmeText = buildReadmeText({
pageTitle: STATE.pageTitle,
pageUrl: STATE.pageUrl,
generatedAt: new Date().toISOString(),
items: manifestItems,
failures,
});
zipEntries.push({
name: "README.txt",
data: encodeUtf8(readmeText),
});
const archiveBlob = createStoredZip(zipEntries);
const archiveName = `${sanitizeFilename(document.title).slice(0, 48) || "douyin-stickers"}-${new Date()
.toISOString()
.replace(/[:.]/g, "-")}.zip`;
triggerDownload(archiveBlob, archiveName);
if (failures.length > 0) {
setStatus(`整包下载完成,但有 ${failures.length} 个资源失败,详情见 README.txt。`);
return;
}
setStatus(`整包下载完成:${archiveName}`);
}
function init() {
if (!document.body) return;
createStyles();
ensurePanel();
ensureModal();
}
const exported = {
isStickerResourceUrl,
isInlineEmojiImage,
sanitizeFilename,
simpleHash,
formatLabelFromMime,
formatLabelFromUrl,
isStickerLikelyAnimatedSticker,
normalizeUrl,
resourceKindFromUrl,
chooseDownloadOptions,
shouldStartPanelDrag,
extensionFromContentType,
parseContentTypeFromHeaders,
clampPanelPosition,
getCollapsedBadgeText,
ensureBlobValue,
createStoredZip,
buildReadmeText,
};
if (typeof module !== "undefined" && module.exports) {
module.exports = exported;
}
if (typeof window !== "undefined" && typeof document !== "undefined") {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
window.__DOUYIN_STICKER_PARSER__ = exported;
}
})();