Files
Tampermonkey_scripts/Chaturbate/chaturbate-thumbnails-2x.user.js
2026-06-03 13:33:16 +08:00

2075 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name Chaturbate 缩略图放大 2 倍
// @namespace https://chaturbate.com/
// @version 0.13.0
// @description 放大当前 Chaturbate 房间列表, 发现页轮播, 关注下拉与悬停预览缩略图, 并中文化标签页
// @match https://chaturbate.com/*
// @match https://*.chaturbate.com/*
// @updateURL http://127.0.0.1:49234/chaturbate-thumbnails-2x.user.js
// @downloadURL http://127.0.0.1:49234/chaturbate-thumbnails-2x.user.js
// @run-at document-start
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// ==/UserScript==
/*
* --- Microsoft Edge: 脚本完全不生效时可先试 ---
*
* 1) 地址栏打开 edge://extensions, 开启"开发人员模式"(Developer mode; 位置依版本可能在左侧栏或页面底部).
* 2) 无痕窗口: Tampermonkey ->"详细信息"->"在 InPrivate 中允许".
* 3) Tampermonkey 仪表盘确认脚本已启用; 域名匹配当前站点(含 zh-hans 等语言子域).
*/
/*
* 工作原则:
*
* 1) 只做"站点原始尺寸 * 倍率"的放大.
* 不重写站点原有排版模型, 不用 object-fit / aspect-ratio / 自定义行高去替代站点布局.
* 图片宽高和卡片宽度使用 THUMBNAIL_SCALE; 卡片整体高度使用 CARD_HEIGHT_SCALE.
*
* 2) 各模块必须自己探测原始值, 再乘倍率.
* 没有探测到原始值, 就不放大该模块; 不拿默认尺寸猜页面.
*
* 3) 延迟探测, 不临时禁用样式.
* CSS 先注入但所有模块默认关闭; 等页面原始 DOM 渲染后, 逐个模块探测原始尺寸, 成功后才打开该模块.
* 模块一旦探测成功就锁定, 避免后续 mutation 把已经放大的尺寸当成原始值再次相乘.
*
* 4) 不再缓存探测结果.
* Chaturbate 的列表宽度会随页面/窗口变化, 缓存旧尺寸容易污染新布局.
* 当前版本每次页面加载都重新探测; 油猴菜单只用于清除旧版本遗留的 localStorage 缓存.
*
* 5) 顶部"关注"弹窗的 FOLLOW_DROPDOWN_SHIFT_X 只是位置微调, 不属于缩略图倍率.
*
* 6) 标签页中文化只改页面显示文字, 不改链接地址.
* 翻译表和分词词典里没有的标签保持原 #slug, 避免半英文保底文本影响观感.
*
* 7) DEBUG 只用于控制台与 window.tmThumbScaleDebug() 诊断, 不应该弹窗或显示页面调试面板.
*/
(function () {
"use strict";
// ========== 配置项 ==========
// 图片宽高和卡片宽度倍率: 2 表示按站点原始尺寸放大到 2 倍.
const THUMBNAIL_SCALE = 2;
// 卡片整体高度倍率: 信息栏, 边距等不需要和图片一样放大到 2 倍, 过大会产生空白.
const CARD_HEIGHT_SCALE = 1.55;
// 顶部"关注"弹出层水平偏移; 只调位置, 不参与缩略图放大倍率.
const FOLLOW_DROPDOWN_SHIFT_X = 10;
// 调试开关: 控制控制台日志, html data-tm-* 标记和 window.tmThumbScaleDebug().
const DEBUG = true;
// ===========================
const VER = "0.13.0";
// 旧版本用过 localStorage 尺寸缓存.当前版本不再读写缓存, 避免旧探测值污染新布局.
const LEGACY_CACHE_PREFIX = "tm-thumb-scale:size-cache:";
// 标签页中文化表: key 使用站点 tag slug 或英文显示文本归一化后的结果.
// 只在 /tags/ 页面替换显示文字, 不改变 href, 所以点击后的站点行为保持原样.
const TAG_TRANSLATIONS = Object.freeze({
"18": "18岁",
"18yo": "18岁",
"amateur": "业余",
"anal": "肛交",
"asian": "亚洲",
"ass": "臀部",
"bbw": "大码",
"bdsm": "BDSM",
"big-ass": "大臀",
"bigass": "大臀",
"big-boobs": "大胸",
"bigboobs": "大胸",
"big-tits": "大胸",
"bigtits": "大胸",
"bisexual": "双性恋",
"blonde": "金发",
"blowjob": "口交",
"bondage": "束缚",
"boobs": "胸部",
"brunette": "深发色",
"cam-to-cam": "双向视频",
"c2c": "双向视频",
"cosplay": "角色扮演",
"couple": "情侣",
"cum": "射精",
"cumshot": "射精",
"curvy": "丰满",
"cute": "可爱",
"custom": "自定义",
"daddy": "爹系",
"dance": "跳舞",
"dildo": "假阳具",
"dom": "支配",
"ebony": "黑人",
"feet": "足部",
"femdom": "女王",
"female": "女性",
"fetish": "恋物",
"fingering": "指交",
"fit": "健身",
"fitness": "健身",
"french": "法国",
"gay": "男同",
"german": "德国",
"girlfriend": "女友感",
"hairy": "毛发",
"hardcore": "重口",
"heels": "高跟鞋",
"hentai": "二次元",
"indian": "印度",
"interracial": "跨种族",
"italian": "意大利",
"japanese": "日本",
"korean": "韩国",
"latina": "拉丁裔",
"lesbian": "女同",
"lingerie": "内衣",
"lovense": "Lovense",
"male": "男性",
"mature": "成熟女性",
"milf": "熟女",
"mistress": "女主人",
"natural": "自然",
"new": "新主播",
"nylon": "尼龙",
"oil": "油光",
"orgasm": "高潮",
"panties": "内裤",
"pantyhose": "连裤袜",
"petite": "娇小",
"pov": "第一视角",
"pregnant": "孕妇",
"private": "私密",
"public": "公开",
"redhead": "红发",
"roleplay": "角色扮演",
"romantic": "浪漫",
"russian": "俄罗斯",
"saggy": "下垂",
"shaved": "剃毛",
"skinny": "纤瘦",
"small-tits": "小胸",
"smalltits": "小胸",
"smoking": "吸烟",
"squirt": "潮吹",
"stockings": "长筒袜",
"striptease": "脱衣舞",
"submissive": "服从",
"teen": "年轻",
"thick": "丰腴",
"tiny": "娇小",
"toys": "玩具",
"trans": "跨性别",
"transgender": "跨性别",
"twink": "男孩感",
"vibrator": "振动器",
"young": "年轻",
"3dxchat": "3DXChat",
"asmr": "ASMR",
"bbclover": "BBC爱好者",
"bj": "口交",
"cam2cam": "双向视频",
"cd": "异装",
"cumshow": "射精秀",
"domme": "女王",
"gfmaterial": "女友感",
"gilf": "老年熟女",
"happynewyear": "新年快乐",
"leagueoflegends": "英雄联盟",
"makemecum": "让我射",
"makemecuminpvt": "私密让我射",
"makemehard": "让我硬",
"mtf": "男跨女",
"nolush": "无 Lush",
"ohmibod": "OhMiBod",
"pm": "私信",
"prv": "私密",
"prvt": "私密",
"pvtshow": "私密秀",
"prvtshow": "私密秀",
"str8": "异性恋",
"teamfighttactics": "云顶之弈",
"tgirl": "跨性别女孩",
"ts": "跨性别",
"vseeface": "VSeeFace",
"welcomeall": "欢迎所有人",
"18to25": "18到25岁",
"19": "19岁",
"19yearsold": "19岁",
"20": "20岁",
"69": "69式",
"africa": "非洲",
"alwayshorny": "一直想要",
"analsex": "肛交",
"artist": "艺术家",
"asianbeauty": "亚洲美女",
"asstomouth": "臀到口",
"bareback": "无套",
"beauty": "美人",
"beautifulsmile": "美丽笑容",
"bigassandtits": "大臀大胸",
"bighead": "大头",
"bigsquirt": "大量潮吹",
"bikini": "比基尼",
"blackbeauty": "黑人美女",
"bouncytits": "弹跳胸部",
"blond": "金发",
"blondie": "金发女",
"blowjobs": "口交",
"bra": "胸罩",
"braoff": "脱胸罩",
"brownskin": "棕色皮肤",
"bubblebutt": "圆翘臀",
"bulging": "凸起",
"california": "加州",
"cam": "摄像头",
"cuminprivateshow": "私密秀射精",
"cuminpvt": "私密射精",
"cumatgoal": "目标射精",
"cumslut": "射精荡妇",
"cumsquirt": "射精潮吹",
"cumwithme": "和我一起射",
"curvybody": "丰满身材",
"curvygoddess": "丰满女神",
"curvypetite": "丰满娇小",
"cuckolding": "绿帽",
"cutesmile": "可爱笑容",
"dancesexy": "性感跳舞",
"doggystyle": "后入姿势",
"doll": "娃娃",
"dominate": "支配",
"doublepenetration": "双重插入",
"eyeglasses": "眼镜",
"fem": "女性化",
"firsttime": "第一次",
"flashtits": "闪露胸部",
"fleshlight": "飞机杯",
"flexing": "展示肌肉",
"flipflops": "人字拖",
"frenchkiss": "法式接吻",
"fuckshow": "性爱秀",
"funny": "有趣",
"girlnextdoor": "邻家女孩",
"gothic": "哥特",
"halfjapanese": "半日本",
"hairyman": "毛发男",
"happybirthdaytome": "祝我生日快乐",
"india": "印度",
"interactivetoy": "互动玩具",
"jackingoff": "撸管",
"jerkingoff": "撸管",
"latinosexy": "性感拉丁男",
"lbignipples": "大乳头",
"legshow": "腿部展示",
"lesbianprivateshow": "女同私密秀",
"lesbianshow": "女同秀",
"love": "爱",
"masterforslave": "奴隶的主人",
"miss": "小姐",
"moaning": "呻吟",
"naturalbeauty": "自然美",
"newzealand": "新西兰",
"newyork": "纽约",
"nicesweetpussy": "漂亮甜美私处",
"nonbinary": "非二元",
"nudist": "裸体主义",
"painting": "绘画",
"passwordshow": "密码秀",
"phonesex": "电话性爱",
"pinky": "粉色",
"prettysexyandsmart": "漂亮性感聪明",
"privateshow": "私密秀",
"privateshows": "私密秀",
"roleplaying": "角色扮演",
"ridetoy": "骑玩具",
"selfsuck": "自吸",
"sensualadventuresthegame": "感官冒险游戏",
"sexyandtall": "性感高挑",
"sexyman": "性感男",
"shaven": "剃毛",
"shave": "剃毛",
"showsoles": "展示脚底",
"shybutfreaky": "害羞但狂野",
"sissification": "伪娘化",
"sissyfication": "伪娘化",
"sissyslut": "伪娘荡妇",
"sloppyblowjobs": "湿滑口交",
"slutty": "荡妇风",
"sneakers": "运动鞋",
"spinel": "Spinel",
"sperm": "精液",
"squirtshow": "潮吹秀",
"squirty": "潮吹",
"stayhomeandchaturbate": "宅家上 Chaturbate",
"takeoffclothes": "脱衣服",
"talkative": "健谈",
"tan": "古铜肤色",
"teasing": "挑逗",
"thai": "泰国",
"ticketshow": "门票秀",
"tinydick": "小阴茎",
"toe": "脚趾",
"tipmenu": "打赏菜单",
"titjob": "乳交",
"tokenkeno": "代币基诺",
"toy": "玩具",
"transfem": "跨性别女性",
"transman": "跨性别男性",
"tsgirl": "跨性别女孩",
"twerking": "电臀舞",
"twogirls": "两个女孩",
"veiny": "青筋",
"verbal": "语言",
"vibrations": "震动",
"videogames": "电子游戏",
"welcome": "欢迎",
"whore": "荡妇",
"yourwifematerial": "你的理想妻子",
"youngbeauty": "年轻美女",
});
// slug 分词翻译表: 标签页有大量 #bigboobs, #smalltits 这类拼接 slug.
// 完整翻译表没有命中时, 会用这个词典按最长匹配拆词, 尽量把 10 页主题标签都变成中文显示.
const TAG_WORD_TRANSLATIONS = Object.freeze({
"18": "18岁",
"24": "24岁",
"3d": "3D",
"abdl": "成人婴儿",
"abs": "腹肌",
"af": "很",
"african": "非洲",
"ahegao": "阿嘿颜",
"all": "全",
"alt": "另类",
"alpha": "强势男",
"american": "美国",
"amputee": "截肢",
"alternative": "另类",
"amateur": "业余",
"anal": "肛交",
"anime": "动漫",
"arab": "阿拉伯",
"armpits": "腋下",
"asia": "亚洲",
"asian": "亚洲",
"ass": "臀",
"atm": "ATM",
"aussie": "澳洲",
"awesome": "超棒",
"baby": "宝贝",
"balloons": "气球",
"balls": "睾丸",
"bbc": "黑人大屌",
"bbw": "大码",
"bbws": "大码",
"bdsm": "BDSM",
"bear": "熊系",
"beefy": "壮硕",
"beard": "胡子",
"belly": "肚子",
"bi": "双性恋",
"biceps": "二头肌",
"big": "大",
"bimbo": "花瓶美人",
"black": "黑人",
"blonde": "金发",
"boobs": "胸",
"body": "身体",
"bodybuilder": "健美",
"boots": "靴子",
"boy": "男孩",
"boys": "男孩",
"braces": "牙套",
"british": "英国",
"brown": "棕色",
"brunette": "深发色",
"bush": "毛丛",
"busty": "大胸",
"butt": "臀",
"butts": "臀",
"cameltoe": "骆驼趾",
"cash": "现金",
"chastity": "贞操",
"chubby": "胖乎乎",
"christmas": "圣诞",
"chinese": "中国",
"chat": "聊天",
"clamps": "夹子",
"clit": "阴蒂",
"cock": "阴茎",
"college": "大学",
"colombiana": "哥伦比亚女性",
"colombiano": "哥伦比亚男性",
"colombia": "哥伦比亚",
"colombian": "哥伦比亚",
"control": "控制",
"cosplay": "角色扮演",
"country": "乡村",
"couple": "情侣",
"cougar": "熟女",
"crazy": "疯狂",
"creampie": "内射",
"crossdresser": "异装",
"cuckold": "绿帽",
"cum": "射精",
"cut": "包皮环切",
"cute": "可爱",
"custom": "自定义",
"dad": "爸爸",
"daddy": "爹系",
"dance": "跳舞",
"deep": "深",
"desi": "南亚",
"dick": "阴茎",
"dilf": "熟男",
"dirty": "挑逗",
"dildo": "假阳具",
"dom": "支配",
"domi": "支配",
"dominant": "支配",
"domination": "支配",
"dominatrix": "女施虐者",
"door": "邻家",
"dream": "梦幻",
"drink": "饮酒",
"dutch": "荷兰",
"ebony": "黑人",
"edge": "边缘",
"edging": "边缘控制",
"emo": "情绪风",
"erotic": "情色",
"exhibitionist": "露出",
"eyes": "眼睛",
"facial": "颜射",
"face": "脸",
"fantasy": "幻想",
"femboy": "伪娘",
"femdom": "女王",
"fetish": "恋物",
"findom": "金钱支配",
"fingering": "指交",
"fishnet": "网袜",
"fit": "健身",
"flex": "展示肌肉",
"flexible": "柔韧",
"foot": "足",
"feet": "足",
"foreskin": "包皮",
"france": "法国",
"free": "免费",
"friend": "朋友",
"fuck": "性爱",
"gaming": "游戏",
"gag": "堵嘴",
"gamer": "玩家",
"gay": "男同",
"genz": "Z世代",
"german": "德国",
"girl": "女孩",
"girly": "少女",
"glasses": "眼镜",
"goddess": "女神",
"goth": "哥特",
"hair": "头发",
"hairy": "毛发",
"hard": "硬",
"hd": "高清",
"heels": "高跟鞋",
"hentai": "二次元",
"hidden": "隐藏",
"hindi": "印地语",
"hornyaf": "非常欲望",
"horny": "欲望",
"hot": "火辣",
"huge": "巨大",
"humiliation": "羞辱",
"hung": "大尺寸",
"hypno": "催眠",
"indian": "印度",
"italian": "意大利",
"japan": "日本",
"joi": "指令助兴",
"kinky": "重口",
"latex": "乳胶",
"latin": "拉丁",
"latina": "拉丁裔",
"latino": "拉丁裔男",
"leather": "皮革",
"leggings": "打底裤",
"legs": "腿",
"league": "联盟",
"legends": "传奇",
"lesbian": "女同",
"lingerie": "内衣",
"long": "长",
"lips": "唇",
"lovense": "Lovense",
"lush": "Lush",
"madam": "夫人",
"machine": "机器",
"married": "已婚",
"master": "主人",
"material": "料",
"masturbation": "自慰",
"mature": "成熟",
"messy": "凌乱",
"milf": "熟女",
"milk": "乳汁",
"mistress": "女主人",
"moan": "呻吟",
"mom": "妈妈",
"mommy": "妈咪",
"monster": "巨型",
"muscle": "肌肉",
"muscles": "肌肉",
"muscular": "肌肉型",
"nails": "指甲",
"nasty": "重口",
"natural": "自然",
"naked": "裸露",
"naughty": "淘气",
"new": "新",
"nipple": "乳头",
"nipples": "乳头",
"nonude": "不露点",
"nonnude": "不露点",
"nude": "裸露",
"nudes": "裸照",
"nyc": "纽约",
"office": "办公室",
"online": "在线",
"open": "打开",
"orgasm": "高潮",
"panties": "内裤",
"pantyhose": "连裤袜",
"party": "派对",
"pawg": "丰满大臀",
"pegging": "佩戴式插入",
"petite": "娇小",
"perverted": "变态",
"piercing": "穿环",
"pinay": "菲律宾女性",
"pov": "第一视角",
"pornstar": "色情明星",
"precum": "前列腺液",
"pregnant": "孕妇",
"princess": "公主",
"private": "私密",
"pussy": "私处",
"puffy": "蓬起",
"pvt": "私密",
"red": "红",
"redhead": "红发",
"request": "点播",
"roleplay": "角色扮演",
"russian": "俄罗斯",
"saliva": "唾液",
"shaved": "剃毛",
"sex": "性爱",
"self": "自己",
"short": "短",
"show": "展示",
"shower": "淋浴",
"shy": "害羞",
"sissy": "伪娘",
"skin": "皮肤",
"skinny": "纤瘦",
"slave": "奴役",
"slim": "苗条",
"slut": "荡妇",
"small": "小",
"smoke": "吸烟",
"smoking": "吸烟",
"smoker": "吸烟者",
"snow": "雪白",
"socks": "袜子",
"soles": "脚底",
"spain": "西班牙",
"spanish": "西班牙",
"spit": "吐口水",
"sph": "尺寸羞辱",
"squirt": "潮吹",
"stockings": "长筒袜",
"straight": "异性恋",
"strapon": "假阳具",
"student": "学生",
"sub": "服从",
"submissive": "服从",
"suck": "吸吮",
"tag": "标签",
"tattoo": "纹身",
"tattoos": "纹身",
"tease": "挑逗",
"teen": "年轻",
"thick": "丰腴",
"throat": "喉",
"tight": "紧致",
"tits": "胸",
"titty": "胸",
"tomboy": "假小子",
"toys": "玩具",
"toes": "脚趾",
"tongue": "舌头",
"torso": "躯干",
"trans": "跨性别",
"transgender": "跨性别",
"twerk": "电臀舞",
"twink": "男孩感",
"ukraine": "乌克兰",
"uk": "英国",
"uncut": "未割包皮",
"vibrator": "振动器",
"voice": "声音",
"voyeur": "偷窥",
"waifu": "二次元老婆",
"wife": "妻子",
"worship": "崇拜",
"adventures": "冒险",
"alltips": "所有打赏",
"appreciated": "感谢",
"armpit": "腋下",
"asshole": "肛门",
"athletic": "运动型",
"balloon": "气球",
"bare": "裸",
"beautiful": "漂亮",
"best": "最佳",
"binary": "二元",
"bitch": "荡妇",
"blue": "蓝色",
"boob": "胸",
"boobies": "胸",
"booty": "臀",
"bottom": "底部",
"brat": "顽皮",
"brazilian": "巴西",
"breast": "胸部",
"breasts": "胸部",
"brackets": "牙套",
"bull": "公牛",
"canada": "加拿大",
"canadian": "加拿大",
"candy": "糖果",
"cat": "猫系",
"chaturbate": "Chaturbate",
"chatting": "聊天",
"chatty": "健谈",
"chest": "胸膛",
"chill": "放松",
"chilling": "放松",
"chocolate": "巧克力",
"circumcised": "已割包皮",
"classy": "优雅",
"clitoris": "阴蒂",
"clothes": "衣服",
"color": "彩色",
"come": "来",
"cowboy": "牛仔",
"cream": "奶油",
"creamy": "奶油感",
"cross": "异装",
"cumdump": "射精目标",
"curve": "曲线",
"curved": "弯曲",
"curves": "曲线",
"cutie": "可爱女孩",
"dallas": "达拉斯",
"dancing": "跳舞",
"dark": "深色",
"destroy": "摧毁",
"devilish": "小恶魔",
"dice": "骰子",
"doggy": "后入",
"double": "双重",
"dress": "裙装",
"easter": "复活节",
"elegant": "优雅",
"english": "英语",
"erotica": "情色",
"european": "欧洲",
"experience": "体验",
"fancy": "精致",
"feminine": "女性化",
"feminization": "女性化",
"femme": "女性化",
"finger": "手指",
"fingers": "手指",
"first": "首次",
"flashes": "闪露",
"flirt": "调情",
"florida": "佛罗里达",
"follow": "关注",
"fountain": "喷泉",
"freak": "狂野",
"freckles": "雀斑",
"fresh": "清新",
"friendly": "友好",
"fun": "有趣",
"game": "游戏",
"games": "游戏",
"gangbang": "多人",
"genderfluid": "性别流动",
"girlfriend": "女友",
"glamour": "魅力",
"gloves": "手套",
"goal": "目标",
"goals": "目标",
"good": "乖",
"green": "绿色",
"guitar": "吉他",
"guy": "男士",
"guys": "男士",
"gym": "健身房",
"handsome": "英俊",
"happy": "开心",
"hetero": "异性恋",
"high": "高",
"hispanic": "西语裔",
"hole": "洞",
"home": "家",
"hose": "长袜",
"hotwax": "热蜡",
"house": "家庭",
"hunk": "猛男",
"hypnosis": "催眠",
"innocent": "清纯",
"interactive": "互动",
"intimate": "亲密",
"jacking": "撸动",
"jerk": "撸",
"jerking": "撸动",
"jerkoff": "撸管",
"jersey": "泽西",
"kawaii": "可爱",
"keyholder": "钥匙持有者",
"kink": "癖好",
"kiss": "亲吻",
"large": "大",
"lactation": "哺乳",
"leg": "腿",
"lets": "一起",
"lick": "舔",
"licking": "舔",
"little": "小",
"live": "直播",
"load": "量",
"lover": "爱好者",
"madure": "成熟",
"maid": "女仆",
"masculine": "男性化",
"mask": "面具",
"massage": "按摩",
"masturbate": "自慰",
"me": "我",
"medellin": "麦德林",
"men": "男性",
"metal": "金属",
"milky": "乳白",
"model": "模特",
"motivation": "激励",
"mouth": "嘴",
"music": "音乐",
"mustache": "胡须",
"nature": "自然",
"naturale": "自然",
"nerd": "书呆子",
"nerdy": "书呆子",
"newbie": "新人",
"night": "夜晚",
"nice": "漂亮",
"nora": "Nora",
"obey": "服从",
"older": "年长",
"on": "开启",
"pakistani": "巴基斯坦",
"pale": "白皙",
"pansexual": "泛性恋",
"passion": "热情",
"password": "密码",
"paypig": "金钱奴",
"paypigs": "金钱奴",
"penis": "阴茎",
"perfect": "完美",
"perky": "挺翘",
"phat": "丰满",
"phone": "电话",
"pierced": "穿环",
"plastic": "塑料",
"play": "玩",
"playful": "爱玩",
"playing": "玩耍",
"pleasure": "愉悦",
"plug": "塞子",
"pretty": "漂亮",
"punish": "惩罚",
"quickie": "快速",
"queen": "女王",
"real": "真实",
"ride": "骑乘",
"riding": "骑乘",
"ring": "环",
"roll": "掷",
"rubber": "橡胶",
"rubia": "金发女",
"scottish": "苏格兰",
"secretary": "秘书",
"seduction": "诱惑",
"showershow": "淋浴秀",
"single": "单身",
"sixpack": "六块腹肌",
"skirt": "短裙",
"slap": "拍打",
"slender": "苗条",
"sloppy": "湿滑",
"smile": "微笑",
"smiley": "微笑",
"smooth": "光滑",
"soft": "柔软",
"solo": "单人",
"song": "歌曲",
"south": "南方",
"spank": "打屁股",
"spanking": "打屁股",
"spin": "旋转",
"spinner": "转盘",
"sport": "运动",
"sporty": "运动风",
"squirter": "潮吹者",
"squirting": "潮吹",
"stay": "待在",
"step": "继",
"stocking": "长筒袜",
"stoner": "嗨感",
"strong": "强壮",
"strip": "脱衣",
"stripper": "脱衣舞者",
"stroke": "撸动",
"stud": "猛男",
"style": "姿势",
"submission": "臣服",
"sugar": "甜心",
"surprise": "惊喜",
"sweet": "甜美",
"switch": "双向",
"take": "脱",
"tatted": "纹身",
"tattooed": "纹身",
"tender": "温柔",
"thicc": "丰厚",
"thighs": "大腿",
"thin": "纤细",
"ticket": "门票",
"tip": "打赏",
"tips": "打赏",
"token": "代币",
"tokenstrokin": "代币撸动",
"top": "上位",
"topless": "上身裸露",
"travel": "旅行",
"trimmed": "修剪",
"tummy": "肚子",
"undress": "脱衣",
"underwear": "内衣",
"university": "大学",
"usa": "美国",
"valentinesday": "情人节",
"valorant": "无畏契约",
"venezolana": "委内瑞拉女性",
"vers": "多面手",
"versatile": "多才多艺",
"very": "非常",
"vibes": "氛围",
"videos": "视频",
"viral": "热门",
"wet": "湿润",
"wheelchair": "轮椅",
"whip": "打发",
"white": "白人",
"wild": "狂野",
"women": "女性",
"workout": "健身",
"wax": "蜡",
"year": "年",
"yoga": "瑜伽",
"bunny": "兔女郎",
"bulge": "凸起",
"bwc": "白人大屌",
"cb15": "CB15",
"cei": "射精指令",
"cuck": "绿帽",
"curly": "卷发",
"dadbod": "爸爸身材",
"daddys": "爹系",
"deutsch": "德语",
"dirtytalk": "挑逗聊天",
"dp": "双插",
"fat": "胖",
"footjob": "足交",
"ftm": "女跨男",
"futa": "扶她",
"ginger": "姜色发",
"handjob": "手交",
"jeans": "牛仔裤",
"material": "料",
"pink": "粉色",
"pump": "打气",
"saggy": "下垂",
"sensual": "感性",
"sexy": "性感",
"ssbbw": "超大码",
"talk": "聊天",
"tall": "高挑",
"turkish": "土耳其",
"young": "年轻",
});
const log = (...args) => {
if (DEBUG) console.log("[tm-thumb-scale]", ...args);
};
if (DEBUG) {
try {
document.documentElement.setAttribute("data-tm-thumb-scale", "1");
document.documentElement.setAttribute("data-tm-thumb-scale-ver", VER);
} catch (_) {}
}
const css = `
/*
* CSS 总开关说明:
*
* 下面每一组规则都挂在 html[data-tm-thumb-scale-xxx="1"] 后面.
* JS 没有探测到对应模块的原始尺寸前, 不会设置这个 data 属性.
* 这样 CSS 可以提前注入, 但不会提前改变页面原始布局, 延迟探测才能读到真实原始值.
*/
/* 首页当前列表 */
/*
* home 模块:
* - 适用范围: 主页, 分类页等普通 #roomlist_root 房间列表.
* - 探测值: 站点 CSS 里的 grid 最小列宽 + 卡片原始高度 + 缩略图原始高度.
* - 放大方式: 原站列数除以 THUMBNAIL_SCALE; 每列仍用 1fr 均分, 保留原站对齐方式.
* - 注意: 关注页列表单独用 followingList 模块, 不和 home 共用探测结果.
*/
html[data-tm-thumb-scale-home="1"] #main #roomlist_root ul.RoomCardGrid,
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid,
html[data-tm-thumb-scale-home="1"] #main #roomlist_root ul.list:has(li.roomCard),
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) {
/* 改成 grid, 是为了让放大后的卡片按新宽度自然换行. */
display: grid !important;
/* --tm-thumb-home-columns 来自原站自然列数, JS 写入时已经除过倍率. */
grid-template-columns: repeat(var(--tm-thumb-home-columns), minmax(0, 1fr)) !important;
justify-content: stretch !important;
align-items: start !important;
/* 保留一个稳定间距, 避免放大后卡片互相贴住. */
gap: 0.6em 0.75em !important;
}
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid > li.RoomCard,
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) li.roomCard {
/* 列宽交给 grid 控制, 清掉站点原本写死的 li 宽度限制. */
width: auto !important;
justify-self: stretch !important;
min-width: 0 !important;
max-width: none !important;
height: var(--tm-thumb-home-card-height) !important;
min-height: var(--tm-thumb-home-card-height) !important;
max-height: var(--tm-thumb-home-card-height) !important;
}
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail,
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container {
width: 100% !important;
height: var(--tm-thumb-home-thumb-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail img,
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container img,
html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail {
width: 100% !important;
height: var(--tm-thumb-home-thumb-height) !important;
/* block 可以消掉图片 inline baseline 带来的细小空隙. */
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
/* 关注页当前列表 */
/*
* followingList 模块:
* - 适用范围: /followed-cams/ 关注页里的 #roomlist_root 房间列表.
* - 为什么和 home 分开: 两者 DOM 选择器很像, 但站点可能给不同页面不同原始列宽.
* - followingList 单独探测, 避免首页探测值污染关注页.
* - 探测值: 站点 CSS 里的 grid 最小列宽 + 卡片原始高度 + 缩略图原始高度.
* - 和 home 一样, 原站列数除以 THUMBNAIL_SCALE; 每列仍用 1fr 均分, 保留原站对齐方式.
*/
html[data-tm-thumb-scale-following-list="1"] #main #roomlist_root ul.RoomCardGrid,
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid,
html[data-tm-thumb-scale-following-list="1"] #main #roomlist_root ul.list:has(li.roomCard),
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) {
display: grid !important;
grid-template-columns: repeat(var(--tm-thumb-following-list-columns), minmax(0, 1fr)) !important;
justify-content: stretch !important;
align-items: start !important;
gap: 0.6em 0.75em !important;
}
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid > li.RoomCard,
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) li.roomCard {
width: auto !important;
justify-self: stretch !important;
min-width: 0 !important;
max-width: none !important;
height: var(--tm-thumb-following-list-card-height) !important;
min-height: var(--tm-thumb-following-list-card-height) !important;
max-height: var(--tm-thumb-following-list-card-height) !important;
}
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail,
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container {
width: 100% !important;
height: var(--tm-thumb-following-list-thumb-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid .RoomCardThumbnail img,
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail_container img,
html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) .room_thumbnail {
width: 100% !important;
height: var(--tm-thumb-following-list-thumb-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
/* 直播间页底部"更多这样的直播间" */
/*
* related 模块:
* - 适用范围: 直播间页面底部"更多这样的房间".
* - 探测值: 卡片原始宽度 + 卡片原始高度 + 缩略图原始高度.
* - 放大方式: 卡片宽度按 THUMBNAIL_SCALE 固定放大; 缩略图宽度跟随卡片宽度.
* - 为什么不按列数: 这个区域是频道页里的局部推荐列表, 容器宽度和首页/关注页不同,
* 用"列数除倍率"会把卡片拉成容器均分宽度, 导致"更多这样的房间"宽高比例失真.
*/
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid,
html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard),
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) {
display: grid !important;
grid-template-columns: repeat(auto-fill, var(--tm-thumb-related-width)) !important;
justify-content: start !important;
align-items: start !important;
gap: 0.6em 0.75em !important;
}
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid > li.RoomCard,
html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) li.roomCard,
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) li.roomCard {
width: var(--tm-thumb-related-width) !important;
justify-self: start !important;
min-width: 0 !important;
max-width: var(--tm-thumb-related-width) !important;
/* 卡片整体高度也要放大; 只放大缩略图高度时, 下方用户名/人数栏可能被压住. */
height: var(--tm-thumb-related-card-height) !important;
min-height: var(--tm-thumb-related-card-height) !important;
max-height: var(--tm-thumb-related-card-height) !important;
}
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail,
html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container,
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container {
width: 100% !important;
/* --tm-thumb-related-height 来自原始缩略图容器高度. */
height: var(--tm-thumb-related-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail img,
html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container img,
html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail,
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container img,
html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail {
width: 100% !important;
/* 图片和容器使用同一个高度, 避免图片溢出或下方信息栏错位. */
height: var(--tm-thumb-related-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
/* 顶部"关注"下拉 */
/*
* follow 模块:
* - 适用范围: 顶部"关注"菜单弹出的房间列表.
* - 探测值: 弹窗中单个房间卡片宽高 + 图片宽高.
* - 放大方式: 卡片和图片各按自己的原始宽高乘倍率; 弹窗总宽度按 2 列计算.
*/
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown {
/* 96vw 防止弹窗在窄窗口里横向溢出. */
width: min(96vw, var(--tm-thumb-follow-width)) !important;
min-width: min(96vw, var(--tm-thumb-follow-width)) !important;
max-width: min(96vw, var(--tm-thumb-follow-width)) !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__container,
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__rooms {
width: 100% !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__rooms {
display: grid !important;
/* 当前设计保持两列, 列宽来自原始卡片宽度 * 倍率. */
grid-template-columns: repeat(2, var(--tm-thumb-follow-card-width)) !important;
justify-content: start !important;
align-items: start !important;
grid-gap: 0.6em 0.75em !important;
}
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__room {
width: var(--tm-thumb-follow-card-width) !important;
min-width: var(--tm-thumb-follow-card-width) !important;
max-width: var(--tm-thumb-follow-card-width) !important;
height: var(--tm-thumb-follow-card-height) !important;
min-height: var(--tm-thumb-follow-card-height) !important;
max-height: var(--tm-thumb-follow-card-height) !important;
}
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__room-link {
width: 100% !important;
height: var(--tm-thumb-follow-card-height) !important;
display: block !important;
}
html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__room-image {
width: 100% !important;
height: var(--tm-thumb-follow-thumb-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-follow="1"] .HeaderNavBar__following .FollowedDropdown {
/* 只做用户要求的右移微调, 不参与缩略图尺寸计算. */
transform: translateX(${FOLLOW_DROPDOWN_SHIFT_X}px) !important;
}
/*
* 悬停"关注"星星等出现的浮层预览(多为 react-tooltip, portal 挂在 body)
* 仅放大直播间缩略图域名, 避免误伤图标类小图
*/
html[data-tm-thumb-scale-tooltip="1"] body > div[id^="react-tooltip"] img[src*="live.mmcdn.com"],
html[data-tm-thumb-scale-tooltip="1"] body > div[id^="react-tooltip"] img[src*="thumb.live.mmcdn"],
html[data-tm-thumb-scale-tooltip="1"] [data-floating-ui-portal] img[src*="live.mmcdn.com"] {
/* tooltip 模块只改直播缩略图域名, 避免误伤页面里的小图标. */
width: var(--tm-thumb-tooltip-width) !important;
max-width: min(96vw, var(--tm-thumb-tooltip-width)) !important;
height: var(--tm-thumb-tooltip-height) !important;
display: block !important;
box-sizing: border-box !important;
}
/* 发现页: 轮播缩略图 + 容器高度 */
/*
* 发现页模块:
* - 适用范围: 发现页轮播, 比如"最受欢迎".
* - 探测值:
* 1. li 卡片宽高;
* 2. 缩略图容器高度;
* 3. triple/double/single 三种轮播 ul 高度.
* - 放大方式: 图片宽高和卡片宽度乘 THUMBNAIL_SCALE.
* - 卡片高度: 只把缩略图区域乘 THUMBNAIL_SCALE, 用户名/人数等信息栏保留原始高度.
* - 轮播高度: triple/double/single 按 3/2/1 行卡片高度重新计算, 保留原始行间距.
* - 箭头高度: 跟对应轮播高度同步, 避免右侧箭头与轮播内容错位.
* - 启用条件: 卡片和缩略图探测到就先启用; 某种行数的轮播不存在时跳过对应高度变量.
* 不能强制要求 triple/double/single 全部存在, 否则页面少一种轮播就会导致发现页完全不放大.
*/
html[data-tm-thumb-scale-discover="1"] .carousel-root .triple-rows ul.list,
html[data-tm-thumb-scale-discover="1"] #discover_root .triple-rows ul.list {
/* 三行轮播高度由"放大后卡片高度 * 3 + 原始行间距"推导. */
height: var(--tm-thumb-discover-triple-ul) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .triple-rows .carousel-arrow-container,
html[data-tm-thumb-scale-discover="1"] #discover_root .triple-rows .carousel-arrow-container {
/* 箭头容器跟轮播高度一致, 否则右侧箭头会和轮播内容错位. */
height: var(--tm-thumb-discover-triple-arrow) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .double-rows ul.list,
html[data-tm-thumb-scale-discover="1"] #discover_root .double-rows ul.list {
height: var(--tm-thumb-discover-double-ul) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .double-rows .carousel-arrow-container,
html[data-tm-thumb-scale-discover="1"] #discover_root .double-rows .carousel-arrow-container {
height: var(--tm-thumb-discover-double-arrow) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .single-row ul.list,
html[data-tm-thumb-scale-discover="1"] #discover_root .single-row ul.list {
height: var(--tm-thumb-discover-single-ul) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .single-row .carousel-arrow-container,
html[data-tm-thumb-scale-discover="1"] #discover_root .single-row .carousel-arrow-container {
height: var(--tm-thumb-discover-single-arrow) !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel ul.list > li,
html[data-tm-thumb-scale-discover="1"] #discover_root .room-list-carousel ul.list > li {
/* 卡片宽度来自原始 li 宽度, 例如日志里出现过 182 -> 364. */
width: var(--tm-thumb-discover-width) !important;
min-width: var(--tm-thumb-discover-width) !important;
max-width: var(--tm-thumb-discover-width) !important;
/* 卡片整体高度也必须放大, 否则用户名/人数信息区会压住下一行. */
height: var(--tm-thumb-discover-card-height) !important;
min-height: var(--tm-thumb-discover-card-height) !important;
max-height: var(--tm-thumb-discover-card-height) !important;
/* flex-basis 和 width 保持一致, 轮播横向滑动距离才稳定. */
flex: 0 0 var(--tm-thumb-discover-width) !important;
box-sizing: border-box !important;
vertical-align: top !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail_container,
html[data-tm-thumb-scale-discover="1"] #discover_root .room-list-carousel .room_thumbnail_container {
display: block !important;
width: var(--tm-thumb-discover-thumb-width) !important;
height: var(--tm-thumb-discover-height) !important;
max-width: none !important;
box-sizing: border-box !important;
}
html[data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail_container img,
html[data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail,
html[data-tm-thumb-scale-discover="1"] #discover_root .room-list-carousel .room_thumbnail_container img,
html[data-tm-thumb-scale-discover="1"] #discover_root .room-list-carousel .room_thumbnail {
width: var(--tm-thumb-discover-thumb-width) !important;
/* 缩略图高度来自原始 thumb 高度, 例如 101 -> 202. */
height: var(--tm-thumb-discover-height) !important;
display: block !important;
max-width: none !important;
box-sizing: border-box !important;
}
`;
const STYLE_ID = "tm-thumb-scale-style";
// 只观察一次 head, 避免重复创建 MutationObserver.
let observerStarted = false;
// 只观察一次 body, 页面内容变化时用于捕捉动态弹窗/tooltip.
let pageObserverStarted = false;
// 探测防抖计时器; mutation 很多时只执行最后一次探测.
let measureTimer = null;
// 标签页中文化防抖计时器; SPA 动态加载标签时只执行最后一次替换.
let tagsTranslateTimer = null;
// 记录样式注入次数, 主要用于调试确认 GM_addStyle 是否真的执行.
let injectCount = 0;
// 记录最后一次注入方式: GM_addStyle, 手动 style, 失败等.
let lastInjectMethod = "none";
// GM_addStyle 在某些环境不会返回可查询的 style 节点, 所以单独记录逻辑状态.
let styleInjected = false;
// 最近一次 window.tmThumbScaleDebug() 返回的数据.
let lastReport = null;
// 最近一次尺寸探测结果, 控制台里排错主要看这个.
let lastMeasure = null;
// 模块锁: 某模块一旦成功应用探测值, 就不再重新探测.
const moduleReady = {
home: false,
followingList: false,
related: false,
follow: false,
tooltip: false,
discover: false,
};
const setDebugAttr = (name, value) => {
// 调试属性写到 html 上, 方便在 Elements 面板确认脚本是否运行.
if (!DEBUG) return;
try {
document.documentElement.setAttribute(`data-tm-thumb-scale-${name}`, String(value));
} catch (_) {}
};
const scaledPx = (value) => `${Math.round(value * THUMBNAIL_SCALE)}px`;
const scaledCardHeightPx = (value) => `${Math.round(value * CARD_HEIGHT_SCALE)}px`;
const scaledCardHeightFromThumbPx = (cardHeight, thumbHeight) => {
const nonThumbHeight = Math.max(0, cardHeight - thumbHeight);
return `${Math.round((thumbHeight * THUMBNAIL_SCALE) + nonThumbHeight)}px`;
};
const scaledCardHeightFromThumbValue = (cardHeight, thumbHeight) => {
const nonThumbHeight = Math.max(0, cardHeight - thumbHeight);
return Math.round((thumbHeight * THUMBNAIL_SCALE) + nonThumbHeight);
};
const scaledStackHeightFromCardValue = (stackHeight, cardHeight, thumbHeight, rows) => {
if (
!Number.isFinite(stackHeight)
|| !Number.isFinite(cardHeight)
|| !Number.isFinite(thumbHeight)
|| !Number.isFinite(rows)
|| stackHeight <= 0
|| cardHeight <= 0
|| thumbHeight <= 0
|| rows <= 0
) return null;
const originalNonCardSpace = Math.max(0, stackHeight - (cardHeight * rows));
return Math.round((scaledCardHeightFromThumbValue(cardHeight, thumbHeight) * rows) + originalNonCardSpace);
};
const scaledCardHeightFromThumbWithScalePx = (cardHeight, thumbHeight, scale) => {
const nonThumbHeight = Math.max(0, cardHeight - thumbHeight);
return `${Math.round((thumbHeight * scale) + nonThumbHeight)}px`;
};
const rootStyle = () => document.documentElement && document.documentElement.style;
const isFollowingListPage = () => {
try {
const path = typeof location === "undefined" ? "" : location.pathname;
return /\/followed-cams\/?$/i.test(path) || /\/followed\/?$/i.test(path);
} catch (_) {
return false;
}
};
const isTagsPage = () => {
try {
const path = typeof location === "undefined" ? "" : location.pathname;
return /^\/tags(?:\/|$)/i.test(path);
} catch (_) {
return false;
}
};
const normalizeTagKey = (value) => String(value || "")
.trim()
.replace(/^#/, "")
.replace(/\s*\([^)]*\)\s*$/, "")
.toLowerCase()
.replace(/&/g, "and")
.replace(/[']/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const tagWordTranslation = (word) => {
if (!word) return "";
if (TAG_WORD_TRANSLATIONS[word]) return TAG_WORD_TRANSLATIONS[word];
if (word.endsWith("ies")) {
const singularY = `${word.slice(0, -3)}y`;
if (TAG_WORD_TRANSLATIONS[singularY]) return TAG_WORD_TRANSLATIONS[singularY];
}
if (word.endsWith("es") && TAG_WORD_TRANSLATIONS[word.slice(0, -2)]) {
return TAG_WORD_TRANSLATIONS[word.slice(0, -2)];
}
if (word.endsWith("s") && TAG_WORD_TRANSLATIONS[word.slice(0, -1)]) {
return TAG_WORD_TRANSLATIONS[word.slice(0, -1)];
}
return "";
};
const compactTagKey = (value) => normalizeTagKey(value).replace(/-/g, "");
const splitCompactTagSlug = (slug) => {
const compact = compactTagKey(slug);
if (!compact) return [];
const words = Object.keys(TAG_WORD_TRANSLATIONS).flatMap((word) => {
const variants = [{ match: word, word }];
if (/^[a-z0-9]+$/.test(word) && !word.endsWith("s")) {
variants.push({ match: `${word}s`, word });
variants.push({ match: `${word}es`, word });
if (word.endsWith("y")) variants.push({ match: `${word.slice(0, -1)}ies`, word });
}
return variants;
}).sort((a, b) => b.match.length - a.match.length);
const parts = [];
let index = 0;
while (index < compact.length) {
const found = words.find((item) => compact.slice(index).startsWith(item.match));
if (!found) return [];
parts.push(found.word);
index += found.match.length;
}
return parts;
};
const translateTagKey = (key, originalText = "", allowFallback = false) => {
const normalized = normalizeTagKey(key);
const compact = compactTagKey(normalized);
const textKey = normalizeTagKey(originalText);
const textCompact = compactTagKey(textKey);
const direct = TAG_TRANSLATIONS[normalized]
|| TAG_TRANSLATIONS[compact]
|| TAG_TRANSLATIONS[textKey]
|| TAG_TRANSLATIONS[textCompact];
if (direct) return direct;
const hyphenParts = normalized.split("-").filter(Boolean);
const hyphenTranslated = hyphenParts.length > 1
? hyphenParts.map((part) => tagWordTranslation(part)).filter(Boolean)
: [];
if (hyphenTranslated.length === hyphenParts.length) {
return hyphenTranslated.join("");
}
const compactParts = splitCompactTagSlug(compact);
if (compactParts.length > 0) {
return compactParts.map((part) => tagWordTranslation(part)).join("");
}
return "";
};
const tagKeyFromHref = (anchor) => {
try {
const url = new URL(anchor.href || anchor.getAttribute("href") || "", location.href);
const match = url.pathname.match(/\/tag\/([^/]+)(?:\/[^/]+)?\/?$/i);
if (!match) return "";
return normalizeTagKey(decodeURIComponent(match[1]));
} catch (_) {
return "";
}
};
const translateTagAnchor = (anchor) => {
if (!anchor || !anchor.textContent) return false;
// 标签页真正需要中文化的主题标签都以 #slug 显示.
// 用这个页面特征做第一道过滤, 防止分页, 导航或分类链接被误翻译.
if (!anchor.textContent.trim().startsWith("#")) return false;
const hrefKey = tagKeyFromHref(anchor);
if (!hrefKey) return false;
const original = anchor.dataset && anchor.dataset.tmTagOriginal
? anchor.dataset.tmTagOriginal
: anchor.textContent.trim();
const looksUntranslated = /^#/.test(original) || original.trim().toLowerCase() === hrefKey;
const translated = translateTagKey(hrefKey, original, looksUntranslated);
if (!translated || (!looksUntranslated && translated === original)) return false;
if (anchor.dataset && !anchor.dataset.tmTagOriginal) {
anchor.dataset.tmTagOriginal = original;
}
if (anchor.textContent.trim() !== translated) {
anchor.textContent = translated;
}
if (!anchor.title) {
anchor.title = original;
}
return true;
};
const translateTagsPage = (stage = "translate-tags") => {
if (!isTagsPage() || !document.querySelectorAll) return 0;
let translatedCount = 0;
try {
document.querySelectorAll('a[href*="/tag/"]').forEach((anchor) => {
if (translateTagAnchor(anchor)) translatedCount += 1;
});
} catch (_) {}
if (DEBUG && translatedCount > 0) log("translated tags", { stage, translatedCount });
return translatedCount;
};
const scheduleTagsTranslation = (stage, delay = 0) => {
if (!isTagsPage()) return;
if (typeof setTimeout !== "function") {
translateTagsPage(stage);
return;
}
if (tagsTranslateTimer) clearTimeout(tagsTranslateTimer);
tagsTranslateTimer = setTimeout(() => {
tagsTranslateTimer = null;
translateTagsPage(stage);
}, delay);
};
// 所有 CSS 变量都在这里统一乘倍率.调用方传入的必须是页面原始尺寸.
const setVar = (name, value) => {
const style = rootStyle();
if (!style || !Number.isFinite(value) || value <= 0) return false;
style.setProperty(name, scaledPx(value));
return true;
};
// 只给 card 整体高度使用.图片宽高仍然走 setVar(), 继续乘 THUMBNAIL_SCALE.
const setCardHeightVar = (name, value) => {
const style = rootStyle();
if (!style || !Number.isFinite(value) || value <= 0) return false;
style.setProperty(name, scaledCardHeightPx(value));
return true;
};
const setCardHeightFromThumbVar = (name, cardHeight, thumbHeight) => {
const style = rootStyle();
if (!style || !Number.isFinite(cardHeight) || !Number.isFinite(thumbHeight) || cardHeight <= 0 || thumbHeight <= 0) return false;
style.setProperty(name, scaledCardHeightFromThumbPx(cardHeight, thumbHeight));
return true;
};
const setDiscoverStackHeightVars = (ulName, arrowName, stackHeight, cardHeight, thumbHeight, rows) => {
const style = rootStyle();
// 发现页并不保证 triple/double/single 三种轮播同时存在.
// 缺少某一种时直接视为完成, 避免一个缺失行把整个发现页放大总开关卡住.
if (stackHeight == null) return true;
const height = scaledStackHeightFromCardValue(stackHeight, cardHeight, thumbHeight, rows);
if (!style || !height) return false;
const value = `${height}px`;
style.setProperty(ulName, value);
style.setProperty(arrowName, value);
return true;
};
const setListScaleVars = (columnsName, cardHeightName, thumbHeightName, metrics, cardWidth, cardHeight, thumbHeight) => {
const style = rootStyle();
if (
!style
|| !metrics
|| !Number.isFinite(metrics.columns)
|| !Number.isFinite(cardWidth)
|| !Number.isFinite(cardHeight)
|| !Number.isFinite(thumbHeight)
|| metrics.columns <= 0
|| cardWidth <= 0
|| cardHeight <= 0
|| thumbHeight <= 0
) return false;
const scaledColumns = Math.max(1, Math.floor(metrics.columns / THUMBNAIL_SCALE));
const targetCardWidth = (
Number.isFinite(metrics.width)
&& metrics.width > 0
&& Number.isFinite(metrics.columnGap)
&& metrics.columnGap >= 0
)
? (metrics.width - (metrics.columnGap * Math.max(0, scaledColumns - 1))) / scaledColumns
: null;
const effectiveScale = targetCardWidth && targetCardWidth > 0
? targetCardWidth / cardWidth
: THUMBNAIL_SCALE;
style.setProperty(columnsName, String(scaledColumns));
style.setProperty(cardHeightName, scaledCardHeightFromThumbWithScalePx(cardHeight, thumbHeight, effectiveScale));
style.setProperty(thumbHeightName, `${Math.round(thumbHeight * effectiveScale)}px`);
return true;
};
// 每个模块的 CSS 都由 html[data-tm-thumb-scale-模块名="1"] 控制.
// 未探测到原始值时不设置开关, 对应模块完全不放大, 避免用错误尺寸猜页面.
const setModuleReady = (name, ready) => {
try {
const attrName = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
const attr = `data-tm-thumb-scale-${attrName}`;
if (ready) document.documentElement.setAttribute(attr, "1");
else document.documentElement.removeAttribute(attr);
} catch (_) {}
};
const isRect = (value) => (
value
&& Number.isFinite(value.width)
&& Number.isFinite(value.height)
&& value.width > 0
&& value.height > 0
);
const clearCache = () => {
try {
if (typeof localStorage !== "undefined") {
Object.keys(localStorage)
.filter((key) => key.indexOf(LEGACY_CACHE_PREFIX) === 0)
.forEach((key) => localStorage.removeItem(key));
}
} catch (_) {}
log("legacy cache cleared; current version always detects sizes fresh");
};
const registerMenu = () => {
try {
// GM_registerMenuCommand 需要在 header 里 @grant; 没有该 API 时静默跳过.
if (typeof GM_registerMenuCommand === "function") {
GM_registerMenuCommand("清除 Chaturbate 缩略图探测缓存", clearCache);
}
} catch (_) {}
};
const rectOf = (el) => {
// getBoundingClientRect 能拿到 CSS 布局后的实际渲染尺寸.
if (!el || !el.getBoundingClientRect) return null;
const rect = el.getBoundingClientRect();
const width = rect.width || el.offsetWidth || 0;
const height = rect.height || el.offsetHeight || 0;
// 太小的元素通常是图标, 占位或尚未布局完成的节点, 不作为缩略图基准.
if (width < 20 && height < 20) return null;
return { width, height };
};
const firstRect = (selector) => {
try {
// 同一类列表里取第一个有尺寸的节点即可, 因为同模块卡片原始尺寸应一致.
if (!document.querySelectorAll) return null;
const nodes = document.querySelectorAll(selector);
for (const node of nodes) {
const rect = rectOf(node);
if (rect) return rect;
}
} catch (_) {}
return null;
};
const firstElement = (selector) => {
try {
if (!document.querySelector) return null;
return document.querySelector(selector);
} catch (_) {
return null;
}
};
const parseMinmaxPx = (value) => {
if (!value) return null;
const match = String(value).match(/minmax\(\s*([0-9.]+)px\s*,\s*1fr\s*\)/i);
if (!match) return null;
const width = Number.parseFloat(match[1]);
return Number.isFinite(width) && width > 0 ? width : null;
};
const selectorMatches = (el, selectorText) => {
try {
return !!(el && selectorText && el.matches && el.matches(selectorText));
} catch (_) {
return false;
}
};
const ruleGridMinColumn = (el, rules) => {
if (!el || !rules) return null;
for (const rule of rules) {
if (rule && rule.cssRules) {
const nested = ruleGridMinColumn(el, rule.cssRules);
if (nested) return nested;
}
if (!rule || !rule.style || !rule.selectorText) continue;
if (!selectorMatches(el, rule.selectorText)) continue;
const value = rule.style.getPropertyValue("grid-template-columns");
const width = parseMinmaxPx(value);
if (width) return width;
}
return null;
};
const gridMinColumnFor = (selector) => {
const el = firstElement(selector);
if (!el || !document.styleSheets) return null;
for (const sheet of Array.from(document.styleSheets)) {
let rules = null;
try {
rules = sheet.cssRules;
} catch (_) {
continue;
}
const width = ruleGridMinColumn(el, rules);
if (width) return width;
}
return null;
};
const computedGridColumnCount = (selector) => {
const el = firstElement(selector);
if (!el) return null;
try {
const value = getComputedStyle(el).getPropertyValue("grid-template-columns");
const count = String(value).split(/\s+/).filter(Boolean).length;
return Number.isFinite(count) && count > 0 ? count : null;
} catch (_) {
return null;
}
};
const naturalGridMetrics = (selector) => {
const minColumn = gridMinColumnFor(selector);
const el = firstElement(selector);
const rect = rectOf(el);
let columnGap = 0;
try {
if (el && typeof getComputedStyle === "function") {
const styles = getComputedStyle(el);
const parsedGap = Number.parseFloat(styles.columnGap || styles.gap || "0");
columnGap = Number.isFinite(parsedGap) && parsedGap > 0 ? parsedGap : 0;
}
} catch (_) {}
if (Number.isFinite(minColumn) && minColumn > 0 && rect && rect.width > 0) {
return {
columns: Math.max(1, Math.floor((rect.width + columnGap) / (minColumn + columnGap))),
width: rect.width,
columnGap,
};
}
const columns = computedGridColumnCount(selector);
return Number.isFinite(columns) && columns > 0 ? {
columns,
width: rect && rect.width,
columnGap,
} : null;
};
const detectAndApplySizes = (stage = "detect") => {
/*
* 探测策略:
*
* 1) CSS 已经注入, 但各模块默认关闭, 所以页面先按站点原始布局渲染.
* 2) 延迟/MutationObserver 触发后, 读取还没 ready 的模块尺寸.
* 3) 某个模块探测成功后, 写入 CSS 变量, 打开模块开关, 并把 moduleReady 锁住.
* 4) 锁住后的模块后续不再探测, 避免把已放大的尺寸再次当成原始尺寸.
*/
const followingListPage = isFollowingListPage();
const homeMetrics = moduleReady.home || followingListPage ? null : naturalGridMetrics("#roomlist_root ul.RoomCardGrid, #roomlist_root ul.list:has(li.roomCard)");
const followingListMetrics = moduleReady.followingList || !followingListPage ? null : naturalGridMetrics("#roomlist_root ul.RoomCardGrid, #roomlist_root ul.list:has(li.roomCard)");
const measured = {
// 首页普通房间列表: 列宽来自站点 CSS 的 minmax 最小值, 避免读取到 1fr 拉伸后的当前渲染宽度.
homeMetrics,
homeCard: moduleReady.home || followingListPage ? null : firstRect("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"),
homeThumb: moduleReady.home || followingListPage ? null : firstRect("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"),
// 关注页房间列表: 同样必须读 CSS 最小列宽, 否则会把已拉伸宽度再乘倍率.
followingListMetrics,
followingListCard: moduleReady.followingList || !followingListPage ? null : firstRect("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"),
followingListThumb: moduleReady.followingList || !followingListPage ? null : firstRect("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"),
// 频道页"更多这样的房间": 使用局部推荐区自己的卡片原始宽度和高度, 不套用首页列数算法.
relatedCard: moduleReady.related ? null : firstRect(".BaseRoomContents ul.RoomCardGrid > li.RoomCard, #main.roomPage ul.list:has(li.roomCard) li.roomCard, .BaseRoomContents ul.list:has(li.roomCard) li.roomCard"),
relatedThumb: moduleReady.related ? null : firstRect(".BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail, #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container, .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container"),
// 顶部"关注"弹窗: 通常弹出后才有 DOM, 靠 mutation 触发探测.
followCard: moduleReady.follow ? null : firstRect(".FollowedDropdown__room, .FollowedDropdown__room-image"),
followThumb: moduleReady.follow ? null : firstRect(".FollowedDropdown__room-image"),
// 悬浮预览: tooltip 是 portal 动态插入, 也靠 mutation 捕捉.
tooltipThumb: moduleReady.tooltip ? null : firstRect('body > div[id^="react-tooltip"] img[src*="live.mmcdn.com"], body > div[id^="react-tooltip"] img[src*="thumb.live.mmcdn"], [data-floating-ui-portal] img[src*="live.mmcdn.com"]'),
// 发现页 carousel: 必须同时探测 card, thumb 和行容器高度, 全部成功后才启用.
// 这样"最受欢迎"等多行轮播能保持站点原来的对齐关系, 只是整体按倍率放大.
discoverCard: moduleReady.discover ? null : firstRect(".carousel-root .room-list-carousel ul.list > li, #discover_root .room-list-carousel ul.list > li"),
discoverThumb: moduleReady.discover ? null : firstRect(".carousel-root .room-list-carousel .room_thumbnail_container, #discover_root .room-list-carousel .room_thumbnail_container"),
discoverTripleUl: moduleReady.discover ? null : firstRect(".carousel-root .triple-rows ul.list, #discover_root .triple-rows ul.list"),
discoverDoubleUl: moduleReady.discover ? null : firstRect(".carousel-root .double-rows ul.list, #discover_root .double-rows ul.list"),
discoverSingleUl: moduleReady.discover ? null : firstRect(".carousel-root .single-row ul.list, #discover_root .single-row ul.list"),
};
if (!moduleReady.home && !followingListPage) {
// home 依赖当前页面的自然列数和原始高度; 每次加载重新探测.
moduleReady.home = setListScaleVars(
"--tm-thumb-home-columns",
"--tm-thumb-home-card-height",
"--tm-thumb-home-thumb-height",
measured.homeMetrics,
measured.homeCard && measured.homeCard.width,
measured.homeCard && measured.homeCard.height,
measured.homeThumb && measured.homeThumb.height,
);
setModuleReady("home", moduleReady.home);
}
if (!moduleReady.followingList && followingListPage) {
// followingList 单独探测, 避免关注页拿到首页的列数.
moduleReady.followingList = setListScaleVars(
"--tm-thumb-following-list-columns",
"--tm-thumb-following-list-card-height",
"--tm-thumb-following-list-thumb-height",
measured.followingListMetrics,
measured.followingListCard && measured.followingListCard.width,
measured.followingListCard && measured.followingListCard.height,
measured.followingListThumb && measured.followingListThumb.height,
);
setModuleReady("followingList", moduleReady.followingList);
}
if (!moduleReady.related) {
// related 同时依赖卡片宽度, 卡片高度和缩略图高度; 三者缺一不可.
moduleReady.related = [
setVar("--tm-thumb-related-width", measured.relatedCard && measured.relatedCard.width),
setCardHeightFromThumbVar("--tm-thumb-related-card-height", measured.relatedCard && measured.relatedCard.height, measured.relatedThumb && measured.relatedThumb.height),
setVar("--tm-thumb-related-height", measured.relatedThumb && measured.relatedThumb.height),
].every(Boolean);
setModuleReady("related", moduleReady.related);
}
if (!moduleReady.follow) {
// follow 的总弹窗宽度按两列计算: 单卡原始宽度 * 倍率 * 2 + 2em 间距余量.
const followReady = [
setVar("--tm-thumb-follow-card-width", measured.followCard && measured.followCard.width),
setCardHeightFromThumbVar("--tm-thumb-follow-card-height", measured.followCard && measured.followCard.height, measured.followThumb && measured.followThumb.height),
setVar("--tm-thumb-follow-thumb-height", measured.followThumb && measured.followThumb.height),
].every(Boolean);
moduleReady.follow = followReady;
const style = rootStyle();
if (followReady && style) {
style.setProperty("--tm-thumb-follow-width", `calc(${Math.round(measured.followCard.width * THUMBNAIL_SCALE * 2)}px + 2em)`);
}
setModuleReady("follow", moduleReady.follow);
}
if (!moduleReady.tooltip) {
// tooltip 出现时机不固定, 所以通常由 body mutation 触发探测.
moduleReady.tooltip = [
setVar("--tm-thumb-tooltip-width", measured.tooltipThumb && measured.tooltipThumb.width),
setVar("--tm-thumb-tooltip-height", measured.tooltipThumb && measured.tooltipThumb.height),
].every(Boolean);
setModuleReady("tooltip", moduleReady.tooltip);
}
if (!moduleReady.discover) {
// 发现页要求更严格: 少一个高度都不开启, 宁可不放大, 也不要错位.
const discoverWidthReady = setVar("--tm-thumb-discover-width", measured.discoverCard && measured.discoverCard.width);
const discoverCardHeightReady = setCardHeightFromThumbVar("--tm-thumb-discover-card-height", measured.discoverCard && measured.discoverCard.height, measured.discoverThumb && measured.discoverThumb.height);
const discoverThumbWidthReady = setVar("--tm-thumb-discover-thumb-width", measured.discoverThumb && measured.discoverThumb.width);
const discoverHeightReady = setVar("--tm-thumb-discover-height", measured.discoverThumb && measured.discoverThumb.height);
const discoverTripleReady = setDiscoverStackHeightVars(
"--tm-thumb-discover-triple-ul",
"--tm-thumb-discover-triple-arrow",
measured.discoverTripleUl && measured.discoverTripleUl.height,
measured.discoverCard && measured.discoverCard.height,
measured.discoverThumb && measured.discoverThumb.height,
3,
);
const discoverDoubleReady = setDiscoverStackHeightVars(
"--tm-thumb-discover-double-ul",
"--tm-thumb-discover-double-arrow",
measured.discoverDoubleUl && measured.discoverDoubleUl.height,
measured.discoverCard && measured.discoverCard.height,
measured.discoverThumb && measured.discoverThumb.height,
2,
);
const discoverSingleReady = setDiscoverStackHeightVars(
"--tm-thumb-discover-single-ul",
"--tm-thumb-discover-single-arrow",
measured.discoverSingleUl && measured.discoverSingleUl.height,
measured.discoverCard && measured.discoverCard.height,
measured.discoverThumb && measured.discoverThumb.height,
1,
);
moduleReady.discover = discoverWidthReady && discoverCardHeightReady && discoverThumbWidthReady && discoverHeightReady
&& discoverTripleReady
&& discoverDoubleReady
&& discoverSingleReady;
setModuleReady("discover", moduleReady.discover);
}
lastMeasure = { stage, moduleReady: { ...moduleReady }, measured };
if (DEBUG) log("measured original sizes", lastMeasure);
return lastMeasure;
};
const scheduleMeasure = (stage, delay = 0) => {
// 所有探测都通过这里排队, 避免 React 连续插入节点时频繁测量布局.
if (typeof setTimeout !== "function") {
detectAndApplySizes(stage);
return;
}
if (measureTimer) clearTimeout(measureTimer);
measureTimer = setTimeout(() => {
measureTimer = null;
detectAndApplySizes(stage);
}, delay);
};
const selectorCount = (selector) => {
try {
// 诊断用计数; selector 不被浏览器支持时返回错误字符串而不是中断脚本.
if (!document.querySelectorAll) return -1;
return document.querySelectorAll(selector).length;
} catch (e) {
return `ERR: ${e && e.message ? e.message : e}`;
}
};
const computedInfo = (selector) => {
try {
// 诊断用: 输出关键容器的 display/grid/width, 方便在控制台判断 CSS 是否命中.
if (!document.querySelector || typeof getComputedStyle !== "function") return null;
const el = document.querySelector(selector);
if (!el) return null;
const style = getComputedStyle(el);
return {
selector,
tag: el.tagName,
className: el.className,
display: style.display,
gridTemplateColumns: style.gridTemplateColumns,
width: style.width,
minWidth: style.minWidth,
maxWidth: style.maxWidth,
};
} catch (e) {
return { selector, error: e && e.message ? e.message : String(e) };
}
};
const reportDiagnostics = (stage) => {
// 统一收集调试信息, 手动执行 window.tmThumbScaleDebug() 也会走这里.
const styleNodePresent = !!document.getElementById(STYLE_ID);
const stylePresent = styleInjected || styleNodePresent;
const counts = {
roomlistRoot: selectorCount("#roomlist_root"),
homepageList: selectorCount("#roomlist_root ul.RoomCardGrid, #roomlist_root ul.list:has(li.roomCard)"),
homepageCards: selectorCount("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"),
homepageThumbs: selectorCount("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"),
followedDropdown: selectorCount(".FollowedDropdown"),
followedRooms: selectorCount(".FollowedDropdown__room"),
tooltipThumbs: selectorCount('body > div[id^="react-tooltip"] img[src*="live.mmcdn.com"], body > div[id^="react-tooltip"] img[src*="thumb.live.mmcdn"], [data-floating-ui-portal] img[src*="live.mmcdn.com"]'),
};
const computed = [
computedInfo("#roomlist_root ul.RoomCardGrid"),
computedInfo("#roomlist_root ul.list:has(li.roomCard)"),
computedInfo(".FollowedDropdown__rooms"),
computedInfo(".FollowedDropdown__room-image"),
computedInfo("#discover_root .room-list-carousel ul.list"),
].filter(Boolean);
const report = {
stage,
ver: VER,
debug: DEBUG,
href: typeof location === "undefined" ? "" : location.href,
readyState: document.readyState,
stylePresent,
styleInjected,
styleNodePresent,
injectCount,
lastInjectMethod,
cssLength: css.length,
cache: "disabled",
cssVars: (() => {
try {
const style = getComputedStyle(document.documentElement);
return {
homeColumns: style.getPropertyValue("--tm-thumb-home-columns").trim(),
homeCardHeight: style.getPropertyValue("--tm-thumb-home-card-height").trim(),
homeThumbHeight: style.getPropertyValue("--tm-thumb-home-thumb-height").trim(),
followingListColumns: style.getPropertyValue("--tm-thumb-following-list-columns").trim(),
followingListCardHeight: style.getPropertyValue("--tm-thumb-following-list-card-height").trim(),
followingListThumbHeight: style.getPropertyValue("--tm-thumb-following-list-thumb-height").trim(),
relatedWidth: style.getPropertyValue("--tm-thumb-related-width").trim(),
relatedCardHeight: style.getPropertyValue("--tm-thumb-related-card-height").trim(),
relatedHeight: style.getPropertyValue("--tm-thumb-related-height").trim(),
followWidth: style.getPropertyValue("--tm-thumb-follow-width").trim(),
followCardHeight: style.getPropertyValue("--tm-thumb-follow-card-height").trim(),
followThumbHeight: style.getPropertyValue("--tm-thumb-follow-thumb-height").trim(),
discoverWidth: style.getPropertyValue("--tm-thumb-discover-width").trim(),
discoverCardHeight: style.getPropertyValue("--tm-thumb-discover-card-height").trim(),
discoverThumbWidth: style.getPropertyValue("--tm-thumb-discover-thumb-width").trim(),
discoverHeight: style.getPropertyValue("--tm-thumb-discover-height").trim(),
};
} catch (_) {
return {};
}
})(),
lastMeasure,
counts,
computed,
};
lastReport = report;
if (typeof window !== "undefined") {
// 暴露在页面 window, 方便控制台直接查看最近一次报告.
try {
window.tmThumbScaleLastReport = report;
} catch (_) {}
}
if (typeof unsafeWindow !== "undefined") {
// Tampermonkey 隔离环境下, unsafeWindow 能把 API 暴露给页面上下文.
try {
unsafeWindow.tmThumbScaleLastReport = report;
} catch (_) {}
}
if (!DEBUG) return report;
setDebugAttr("ver", VER);
setDebugAttr("style-present", stylePresent ? "1" : "0");
setDebugAttr("style-node-present", styleNodePresent ? "1" : "0");
setDebugAttr("inject-count", injectCount);
setDebugAttr("inject-method", lastInjectMethod);
setDebugAttr("homepage-list-count", counts.homepageList);
console.log("[tm-thumb-scale]", report);
return report;
};
const exposeDebugApi = () => {
// 手动调试入口: 控制台执行 tmThumbScaleDebug() 可立即生成报告.
const api = (stage = "manual") => reportDiagnostics(stage);
const targets = [];
if (typeof window !== "undefined") targets.push(window);
if (typeof unsafeWindow !== "undefined" && unsafeWindow !== window) targets.push(unsafeWindow);
targets.forEach((target) => {
try {
target.tmThumbScaleDebug = api;
target.tmThumbScaleLastReport = lastReport;
} catch (_) {}
});
};
const inject = () => {
// 注入 CSS 前先处理旧版本 style, 避免更新脚本后新旧规则同时存在.
const existingStyle = document.getElementById(STYLE_ID);
if (existingStyle) {
const existingVer = existingStyle.getAttribute("data-tm-thumb-scale-ver");
if (existingVer === VER) {
styleInjected = true;
log("style already present, skip");
reportDiagnostics("inject-skip-current-style");
return;
}
try {
existingStyle.remove();
log("removed stale style", { existingVer });
} catch (e) {
log("remove stale style failed", e);
}
}
try {
if (typeof GM_addStyle === "function") {
// Tampermonkey 推荐使用 GM_addStyle, 比手动 append style 更稳定.
const style = GM_addStyle(css);
if (style) {
style.id = STYLE_ID;
style.setAttribute("data-tm-thumb-scale-ver", VER);
}
injectCount += 1;
styleInjected = true;
lastInjectMethod = style ? "GM_addStyle-with-node" : "GM_addStyle-no-node";
log("injected via GM_addStyle", { returnedStyleNode: !!style });
reportDiagnostics("inject-gm-add-style");
return;
}
const style = document.createElement("style");
// GM_addStyle 不可用时才走手动 style 兜底.
style.id = STYLE_ID;
style.setAttribute("data-tm-thumb-scale-ver", VER);
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
injectCount += 1;
styleInjected = true;
lastInjectMethod = "manual-style";
log("appended <style id=" + STYLE_ID + ">");
reportDiagnostics("inject-manual-style");
} catch (e) {
lastInjectMethod = "failed";
log("append <style> failed", e);
reportDiagnostics("inject-failed");
}
if (DEBUG) {
const el = document.getElementById(STYLE_ID);
log("post-inject style node:", el ? "ok" : "missing");
}
};
const startHeadObserver = () => {
// 有些 SPA/扩展会移除 head 里的 style, 这里只负责把本脚本样式补回去.
if (!document.head || observerStarted) return;
observerStarted = true;
const mo = new MutationObserver(() => {
if (!document.getElementById(STYLE_ID)) {
log("style removed, reinject");
inject();
}
});
mo.observe(document.head, { childList: true });
};
const startPageObserver = () => {
// Chaturbate 很多内容是 React 动态插入的, 尤其关注下拉, tooltip 和标签页列表.
// 观察 body 后, 每次 DOM 变化都延迟处理一次尚未 ready 的模块.
if (!document.body || pageObserverStarted || typeof MutationObserver !== "function") return;
pageObserverStarted = true;
const mo = new MutationObserver(() => {
scheduleMeasure("mutation", 300);
scheduleTagsTranslation("mutation", 300);
});
mo.observe(document.body, { childList: true, subtree: true });
};
log("start v" + VER);
// 注册油猴菜单; 只用于清除旧版本遗留的 localStorage 缓存.
registerMenu();
// 暴露调试 API, 方便后续在控制台手动检查.
exposeDebugApi();
// 第一次报告: 记录启动时 CSS 变量是否已经设置.
reportDiagnostics("start-before-inject");
// 标签页中文化: document-start 时可能还没有标签 DOM, 后续 observer/定时器会继续补.
scheduleTagsTranslation("start", 0);
// 注入 CSS.因为模块默认关闭, 即使 CSS 很早注入, 也不会改变原始布局.
inject();
// GM_addStyle 后再暴露一次 API, 保证 lastReport / style 状态更新.
exposeDebugApi();
// 观察 head, 防止 style 被站点或扩展移除.
startHeadObserver();
// 观察 body, 用于动态内容出现后触发尚未 ready 模块的探测.
startPageObserver();
if (!document.head && document.addEventListener) {
// 极早 document-start 时 head 可能还不存在; DOMContentLoaded 再补一次注入和探测.
document.addEventListener("DOMContentLoaded", () => {
inject();
exposeDebugApi();
// DOMContentLoaded 后再等 600ms, 让 React/图片容器有时间完成初始布局.
scheduleMeasure("domcontentloaded", 600);
scheduleTagsTranslation("domcontentloaded", 600);
startHeadObserver();
startPageObserver();
reportDiagnostics("domcontentloaded");
}, { once: true });
}
if (typeof setTimeout === "function") {
// 固定时间点探测用于覆盖: React 首屏慢, 图片容器稍后才出现等情况.
setTimeout(() => { scheduleMeasure("after-800ms"); scheduleTagsTranslation("after-800ms"); reportDiagnostics("after-800ms"); startPageObserver(); }, 800);
// 第二次探测给慢一点的页面留余量.
setTimeout(() => { scheduleMeasure("after-2000ms"); scheduleTagsTranslation("after-2000ms"); reportDiagnostics("after-2000ms"); startPageObserver(); }, 2000);
// 最后一次兜底探测; 成功的模块会被 moduleReady 锁住, 不会重复相乘.
setTimeout(() => { scheduleMeasure("after-5000ms"); scheduleTagsTranslation("after-5000ms"); reportDiagnostics("after-5000ms"); startPageObserver(); }, 5000);
}
})();