// ==UserScript== // @name 抖音表情包下载(网页版)douyin // @namespace https://local.9527/ // @version 0.1.1 // @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 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; } #${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} .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-download-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 (value.includes("sc=sticker_heif")) return "HEIF"; return "未知"; } function resourceKindFromUrl(url) { const clean = normalizeUrl(url).toLowerCase(); if (clean.includes(".gif")) return "gif"; if (clean.includes("sticker_heif")) return "sticker"; if (clean.includes("sticker_comment")) return "sticker"; return "image"; } 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 = `
Douyin Sticker Parser
只扫描当前已加载评论,不自动滚动,不自动点击。
等待手动扫描。
还没有扫描结果。
`; 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 bindPanelDrag(panel) { if (panel.dataset.dragBound === "true") return; panel.dataset.dragBound = "true"; const handle = panel.querySelector('[data-role="drag-handle"]'); if (!(handle instanceof HTMLElement)) return; let lastPointerDownAt = 0; const updateDragPosition = (clientX, clientY) => { if (!STATE.ui.drag) return; 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 = () => { if (!STATE.ui.drag) return; if (STATE.ui.drag.mode === "pointer" && typeof STATE.ui.drag.pointerId === "number") { try { handle.releasePointerCapture?.(STATE.ui.drag.pointerId); } catch (_error) { // Ignore release failures when capture was never acquired. } } STATE.ui.drag = null; applyPanelUiState(); }; const beginDrag = (event, mode, pointerId = null) => { if (STATE.ui.collapsed) return false; if (typeof event.button === "number" && event.button !== 0) return false; if (event.target instanceof HTMLElement && event.target.closest("button")) return false; const rect = panel.getBoundingClientRect(); STATE.ui.drag = { mode, pointerId, 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(); event.preventDefault(); return true; }; handle.addEventListener("pointerdown", (event) => { lastPointerDownAt = Date.now(); beginDrag(event, "pointer", event.pointerId); }); handle.addEventListener("mousedown", (event) => { if (Date.now() - lastPointerDownAt < 80) return; beginDrag(event, "mouse"); }); 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(); }); 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(); }); } 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 confirmButton = modal.querySelector('[data-action="confirm-download"]'); if (closeButton) closeButton.textContent = "关闭"; 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(); } }); 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 === "open-preview" || action === "preview-item") { const item = STATE.items.find((entry) => entry.id === actionTarget.dataset.itemId); if (item) void openPreviewModal(item); } } 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 = `${escapeHtml(item.commentText || item.author)}`; } if (meta) { meta.innerHTML = [ `作者:${escapeHtml(item.author)}`, `时间:${escapeHtml(item.timeText)}`, `类型:${escapeHtml(item.resourceKind)}`, `估计格式:${escapeHtml(item.formatHint || "未知")}`, `说明:单项下载时,非 GIF 优先保存为 JPG。`, ].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; renderList(); meta.innerHTML = [ `作者:${escapeHtml(item.author)}`, `时间:${escapeHtml(item.timeText)}`, `类型:${escapeHtml(item.resourceKind)}`, `真实格式:${escapeHtml(formatLabelFromMime(item.originalMime) || item.originalMime)}`, `最终保存:${escapeHtml(item.originalMime.includes("image/gif") ? "GIF 原样" : "JPG")}`, ].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 maybeConvertPayload(payload, options = {}) { const type = String(payload.contentType || "").toLowerCase(); if (!type.startsWith("image/") || type.includes("image/gif")) { return payload; } const targetStaticMime = options.targetStaticMime || (type.includes("image/webp") ? "image/png" : ""); 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 pngBlob = 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: pngBlob, contentType: targetStaticMime, convertedFrom: type, }; } catch (_error) { 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); const convertedPayload = await maybeConvertPayload(rawPayload, options); const { blob, contentType } = convertedPayload; const guessedExtension = convertedPayload.convertedFrom ? extensionFromContentType(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); const payload = await resolveDownloadPayload(item, Math.max(index, 0), { targetStaticMime: "image/jpeg", }); 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 { const payload = await resolveDownloadPayload(item, index); 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, 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; } })();