From bde00f27d3f6745152c273a1b9171eeaa8938172 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 3 Jun 2026 13:33:16 +0800 Subject: [PATCH] Translate Chaturbate tag pages --- Chaturbate/chaturbate-thumbnails-2x.user.js | 1026 ++++++++++++++++++- tests/followed-dropdown-css.test.js | 140 +++ 2 files changed, 1156 insertions(+), 10 deletions(-) diff --git a/Chaturbate/chaturbate-thumbnails-2x.user.js b/Chaturbate/chaturbate-thumbnails-2x.user.js index 79fd183..cbd565e 100644 --- a/Chaturbate/chaturbate-thumbnails-2x.user.js +++ b/Chaturbate/chaturbate-thumbnails-2x.user.js @@ -1,8 +1,8 @@ // ==UserScript== // @name Chaturbate 缩略图放大 2 倍 // @namespace https://chaturbate.com/ -// @version 0.12.2 -// @description 放大当前 Chaturbate 房间列表, 发现页轮播, 关注下拉与悬停预览缩略图 +// @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 @@ -41,7 +41,10 @@ * * 5) 顶部"关注"弹窗的 FOLLOW_DROPDOWN_SHIFT_X 只是位置微调, 不属于缩略图倍率. * - * 6) DEBUG 只用于控制台与 window.tmThumbScaleDebug() 诊断, 不应该弹窗或显示页面调试面板. + * 6) 标签页中文化只改页面显示文字, 不改链接地址. + * 翻译表和分词词典里没有的标签保持原 #slug, 避免半英文保底文本影响观感. + * + * 7) DEBUG 只用于控制台与 window.tmThumbScaleDebug() 诊断, 不应该弹窗或显示页面调试面板. */ (function () { @@ -58,9 +61,866 @@ const DEBUG = true; // =========================== - const VER = "0.12.2"; + 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); }; @@ -381,6 +1241,8 @@ let pageObserverStarted = false; // 探测防抖计时器; mutation 很多时只执行最后一次探测. let measureTimer = null; + // 标签页中文化防抖计时器; SPA 动态加载标签时只执行最后一次替换. + let tagsTranslateTimer = null; // 记录样式注入次数, 主要用于调试确认 GM_addStyle 是否真的执行. let injectCount = 0; // 记录最后一次注入方式: GM_addStyle, 手动 style, 失败等. @@ -445,6 +1307,144 @@ 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(); @@ -1018,11 +2018,14 @@ }; const startPageObserver = () => { - // Chaturbate 很多内容是 React 动态插入的, 尤其关注下拉和 tooltip. - // 观察 body 后, 每次 DOM 变化都延迟探测一次尚未 ready 的模块. + // Chaturbate 很多内容是 React 动态插入的, 尤其关注下拉, tooltip 和标签页列表. + // 观察 body 后, 每次 DOM 变化都延迟处理一次尚未 ready 的模块. if (!document.body || pageObserverStarted || typeof MutationObserver !== "function") return; pageObserverStarted = true; - const mo = new MutationObserver(() => scheduleMeasure("mutation", 300)); + const mo = new MutationObserver(() => { + scheduleMeasure("mutation", 300); + scheduleTagsTranslation("mutation", 300); + }); mo.observe(document.body, { childList: true, subtree: true }); }; @@ -1033,6 +2036,8 @@ exposeDebugApi(); // 第一次报告: 记录启动时 CSS 变量是否已经设置. reportDiagnostics("start-before-inject"); + // 标签页中文化: document-start 时可能还没有标签 DOM, 后续 observer/定时器会继续补. + scheduleTagsTranslation("start", 0); // 注入 CSS.因为模块默认关闭, 即使 CSS 很早注入, 也不会改变原始布局. inject(); @@ -1050,6 +2055,7 @@ exposeDebugApi(); // DOMContentLoaded 后再等 600ms, 让 React/图片容器有时间完成初始布局. scheduleMeasure("domcontentloaded", 600); + scheduleTagsTranslation("domcontentloaded", 600); startHeadObserver(); startPageObserver(); reportDiagnostics("domcontentloaded"); @@ -1058,11 +2064,11 @@ if (typeof setTimeout === "function") { // 固定时间点探测用于覆盖: React 首屏慢, 图片容器稍后才出现等情况. - setTimeout(() => { scheduleMeasure("after-800ms"); reportDiagnostics("after-800ms"); startPageObserver(); }, 800); + setTimeout(() => { scheduleMeasure("after-800ms"); scheduleTagsTranslation("after-800ms"); reportDiagnostics("after-800ms"); startPageObserver(); }, 800); // 第二次探测给慢一点的页面留余量. - setTimeout(() => { scheduleMeasure("after-2000ms"); reportDiagnostics("after-2000ms"); startPageObserver(); }, 2000); + setTimeout(() => { scheduleMeasure("after-2000ms"); scheduleTagsTranslation("after-2000ms"); reportDiagnostics("after-2000ms"); startPageObserver(); }, 2000); // 最后一次兜底探测; 成功的模块会被 moduleReady 锁住, 不会重复相乘. - setTimeout(() => { scheduleMeasure("after-5000ms"); reportDiagnostics("after-5000ms"); startPageObserver(); }, 5000); + setTimeout(() => { scheduleMeasure("after-5000ms"); scheduleTagsTranslation("after-5000ms"); reportDiagnostics("after-5000ms"); startPageObserver(); }, 5000); } })(); diff --git a/tests/followed-dropdown-css.test.js b/tests/followed-dropdown-css.test.js index 104d2ac..4642b79 100644 --- a/tests/followed-dropdown-css.test.js +++ b/tests/followed-dropdown-css.test.js @@ -184,6 +184,8 @@ assert.match(source, /setCardHeightVar/, "script should apply card height scalin assert.match(source, /setDiscoverStackHeightVars/, "discover carousel stack heights should be derived from scaled card rows"); assert.match(source, /setCardHeightFromThumbVar\("--tm-thumb-discover-card-height"/, "discover card height should scale the thumbnail area and keep metadata height"); assert.match(source, /stackHeight == null\) return true/, "discover should not require every carousel row type to exist before enabling"); +assert.match(source, /TAG_TRANSLATIONS/, "script should include a centralized tag translation table"); +assert.match(source, /translateTagsPage/, "script should translate the Chaturbate tags page"); assert.doesNotMatch(source, /localStorage\.setItem/, "script should not write detected sizes to localStorage"); assert.match(source, /LEGACY_CACHE_PREFIX/, "script should only keep legacy cache cleanup support"); assert.match(source, /scheduleMeasure\("after-800ms"\)/, "script should delay the first page measurement until original layout can render"); @@ -238,6 +240,144 @@ assert.equal(earlyAppendedTarget, null, "GM_addStyle should avoid manual documen assert.equal(typeof earlyDomContentLoadedHandler, "function", "script should reinject after head becomes available"); assert.equal(earlyObservedTarget, null, "script should not observe a missing head"); +const tagAnchors = [ + { + href: "https://zh-hans.chaturbate.com/tag/asian/", + textContent: "#asian", + dataset: {}, + title: "", + getAttribute(name) { + return name === "href" ? this.href : ""; + }, + }, + { + href: "https://zh-hans.chaturbate.com/tag/bigboobs/", + textContent: "#bigboobs", + dataset: {}, + title: "", + getAttribute(name) { + return name === "href" ? this.href : ""; + }, + }, + { + href: "https://zh-hans.chaturbate.com/tag/ebony/female/", + textContent: "#ebony", + dataset: {}, + title: "", + getAttribute(name) { + return name === "href" ? this.href : ""; + }, + }, + { + href: "https://zh-hans.chaturbate.com/tag/not-in-table/", + textContent: "Not In Table", + dataset: {}, + title: "", + getAttribute(name) { + return name === "href" ? this.href : ""; + }, + }, + { + href: "https://zh-hans.chaturbate.com/tag/new-custom-tag/", + textContent: "#new-custom-tag", + dataset: {}, + title: "", + getAttribute(name) { + return name === "href" ? this.href : ""; + }, + }, + { + href: "https://zh-hans.chaturbate.com/tag/colombiana/female/", + textContent: "#colombiana", + dataset: {}, + title: "", + getAttribute(name) { + return name === "href" ? this.href : ""; + }, + }, + { + href: "https://zh-hans.chaturbate.com/tag/untranslated-example/", + textContent: "#untranslated-example", + dataset: {}, + title: "", + getAttribute(name) { + return name === "href" ? this.href : ""; + }, + }, + { + href: "https://zh-hans.chaturbate.com/tags/female/?page=2", + textContent: "2", + dataset: {}, + title: "", + getAttribute(name) { + return name === "href" ? this.href : ""; + }, + }, +]; +const tagFakeDocument = { + documentElement: { + setAttribute() {}, + }, + head: { + appendChild() {}, + }, + body: {}, + createElement() { + return { id: "", textContent: "", setAttribute() {} }; + }, + getElementById() { + return null; + }, + querySelectorAll(selector) { + return selector.includes("/tag") ? tagAnchors : []; + }, + addEventListener() {}, +}; + +const runTagsPageTranslationTest = (pathname = "/tags/") => vm.runInNewContext(source, { + console, + document: tagFakeDocument, + location: { + href: `https://zh-hans.chaturbate.com${pathname}`, + pathname, + }, + URL, + GM_addStyle() { + return { id: "", textContent: "", setAttribute() {} }; + }, + GM_registerMenuCommand() {}, + MutationObserver: class { + observe() {} + }, +}); + +runTagsPageTranslationTest(); + +assert.equal(tagAnchors[0].textContent, "亚洲", "tags page should translate known tag link text"); +assert.equal(tagAnchors[0].dataset.tmTagOriginal, "#asian", "translated tags should keep the original label in dataset"); +assert.equal(tagAnchors[0].title, "#asian", "translated tags should expose the original label in title"); +assert.equal(tagAnchors[1].textContent, "大胸", "tags page should translate compact multi-word tag slugs"); +assert.equal(tagAnchors[2].textContent, "黑人", "gender-scoped tag links should translate by the first slug segment"); +assert.equal(tagAnchors[3].textContent, "Not In Table", "unknown tags should remain unchanged"); +assert.equal(tagAnchors[4].textContent, "新自定义标签", "untranslated hashtag-style tags should use slug word translation"); +assert.equal(tagAnchors[5].textContent, "哥伦比亚女性", "known gendered nationality tags should be translated"); +assert.equal(tagAnchors[6].textContent, "#untranslated-example", "untranslated hashtag-style tags should keep the original slug"); +assert.equal(tagAnchors[7].textContent, "2", "tags pagination links should not be translated"); + +tagAnchors.forEach((anchor, index) => { + anchor.textContent = ["#asian", "#bigboobs", "#ebony", "Not In Table", "#new-custom-tag", "#colombiana", "#untranslated-example", "2"][index]; + anchor.dataset = {}; + anchor.title = ""; +}); + +runTagsPageTranslationTest("/tags/female/"); +assert.equal(tagAnchors[0].textContent, "亚洲", "tag category pages under /tags/ should also translate links"); +assert.equal(tagAnchors[1].textContent, "大胸", "tag category pages should translate compact slugs too"); +assert.equal(tagAnchors[2].textContent, "黑人", "tag category pages should translate /tag/slug/gender/ links"); +assert.equal(tagAnchors[5].textContent, "哥伦比亚女性", "tag category pages should translate gendered nationality links"); +assert.equal(tagAnchors[6].textContent, "#untranslated-example", "tag category pages should preserve unknown hashtag-style tags"); +assert.equal(tagAnchors[7].textContent, "2", "tag category page pagination should keep page numbers"); + const followingVars = new Map(); const followingAttrs = new Map(); const followingFakeDocument = {