// ==UserScript== // @name 抖音表情导出 // @namespace https://github.com/LinZX1314/douyin-emoji-exporter // @version 1.0.0 // @description 从抖音私信表情面板抓取「收藏表情」的图片地址,导出为 JSON 供离线转换器使用 // @author - // @match https://www.douyin.com/* // @run-at document-idle // @grant GM_setClipboard // @license MIT // ==/UserScript== /* * ========================================================================== * 设计说明 * ========================================================================== * * 【本脚本只做一件事】 * 抓取表情面板里每张表情的图片地址,导出成一份 JSON 清单。 * 不下载、不转换、不打包 —— 那些交给独立的转换器。 * * 【为什么不需要登录态 / Cookie】 * 实测:表情 CDN (p*-im-emoticon-sign.byteimg.com) 对匿名请求返回 200, * 且带 Access-Control-Allow-Origin: *。 * 签名 URL 本身就是唯一凭证,所以转换器拿到 URL 就能直接下载。 * * 【签名有效期】 * 实测 x-expires 距当时还有约 365 天,不是分钟级临时签名。 * 因此不需要「抓取瞬间落盘」,用户抓完慢慢挑、隔天再转换都没问题。 * * 【为什么用「帧数/后缀」而不是「尺寸」区分动静】 * 实测 .awebp = 动画,.webp = 静态,与帧数 100% 对应。 * 大尺寸表情(2048x1926 等)其实都是静态的,体积也小。 * 这个信息会写进清单,转换器据此决定输出 GIF 还是 PNG。 */ (function () { 'use strict'; // 面板容器选择器(实测可用的主选择器 + 兜底) const PANEL_SELECTORS = [ '#dialog-0 .componentsemojiemojiPanel', '[class*="components"][class*="emoji"][class*="emojiPanel"]', '[class*="emojiPanel"]', ]; const SCROLLER_CLASS = 'componentsemojitabPanel'; // ---- 工具函数 ---- /** * 从表情 URL 提取稳定 ID。 * * 抖音表情 URL 形如: * https://p26-im-emoticon-sign.byteimg.com/tos-cn-o-0812/ * o8BABlJUEEMAAjdQZ4TfEFz0DDehBeI1H9sKrA~tplv-0wx4r9yasq-awebp-resize:0:0.awebp * ?biz_tag=...&x-expires=...&x-signature=... * * `~tplv-` 之前那一段是文件本体标识,稳定且唯一(实测 87 个全部互异)。 * 不能用「路径末段」—— 那是 resize 参数,所有表情都一样。 */ function stableId(url) { const m = url.match(/\/([A-Za-z0-9~_-]{16,})~tplv/); if (m) return m[1]; // 兜底:剥掉查询串后取末段 const clean = url.split('?')[0]; return clean.substring(clean.lastIndexOf('/') + 1) || clean; } /** 判断是否动画:.awebp 后缀 = 动画 .webp = 静态(实测 100% 对应) */ function isAnimated(url) { return /\.awebp(\?|$)/i.test(url.split('?')[0]) || url.includes('.awebp'); } /** 找表情面板 */ function findPanel() { for (const sel of PANEL_SELECTORS) { const el = document.querySelector(sel); if (el) return el; } return null; } /** 找面板内可滚动的容器 */ function findScroller(panel) { const byClass = panel.querySelector('.' + SCROLLER_CLASS); if (byClass) return byClass; // 兜底:找第一个真正能滚的 for (const el of [panel, ...panel.querySelectorAll('*')]) { if (el.scrollHeight > el.clientHeight + 20 && el.clientHeight > 50) return el; } return panel; } /** 收集当前 DOM 里所有表情(去重) */ function collect(panel) { const map = new Map(); for (const img of panel.querySelectorAll('img')) { const src = img.src || img.getAttribute('data-src') || ''; if (!src || !/^https?:/.test(src)) continue; // 只认表情 CDN,排除头像/封面等噪音 if (!/im-emoticon/i.test(src)) continue; const id = stableId(src); if (!map.has(id)) { map.set(id, { id, url: src, w: img.naturalWidth || 0, h: img.naturalHeight || 0, animated: isAnimated(src), }); } } return [...map.values()]; } /** * 自动滚动到底,把懒加载的表情全部加载出来。 * * 抖音表情面板是滚动加载的:初始只有第一屏,滚到底才继续加载。 * 这里每滚一次数一下数量,连续 N 轮不增长就认为到底了。 */ async function scrollToEnd(panel, onProgress) { const scroller = findScroller(panel); let lastCount = -1; let stillRounds = 0; let round = 0; const MAX_ROUNDS = 60; // 安全上限,防止无限滚动 const STILL_LIMIT = 3; // 连续 3 轮无新增即停止 const WAIT = 450; // 每轮等待渲染 while (round < MAX_ROUNDS) { round++; scroller.scrollTop = scroller.scrollHeight; await new Promise(r => setTimeout(r, WAIT)); const count = collect(panel).length; if (onProgress) onProgress(round, count, stillRounds); if (count <= lastCount) { stillRounds++; if (stillRounds >= STILL_LIMIT) break; } else { stillRounds = 0; } lastCount = count; } return collect(panel); } // ---- UI ---- let ui = null; let busy = false; function buildUI() { if (ui) return ui; const box = document.createElement('div'); box.id = 'dy-emoji-exporter-ui'; box.style.cssText = ` position: fixed; right: 18px; bottom: 18px; z-index: 2147483647; width: 268px; background: #fff; color: #1f2d26; border: 1px solid #d8e6dd; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,.16); font: 13px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif; overflow: hidden; user-select: none; `; box.innerHTML = `