diff --git a/Chaturbate/chaturbate-thumbnails-2x.user.js b/Chaturbate/chaturbate-thumbnails-2x.user.js index cbd565e..7d886df 100644 --- a/Chaturbate/chaturbate-thumbnails-2x.user.js +++ b/Chaturbate/chaturbate-thumbnails-2x.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Chaturbate 缩略图放大 2 倍 // @namespace https://chaturbate.com/ -// @version 0.13.0 +// @version 0.13.9 // @description 放大当前 Chaturbate 房间列表, 发现页轮播, 关注下拉与悬停预览缩略图, 并中文化标签页 // @match https://chaturbate.com/* // @match https://*.chaturbate.com/* @@ -61,7 +61,7 @@ const DEBUG = true; // =========================== - const VER = "0.13.0"; + const VER = "0.13.9"; // 旧版本用过 localStorage 尺寸缓存.当前版本不再读写缓存, 避免旧探测值污染新布局. const LEGACY_CACHE_PREFIX = "tm-thumb-scale:size-cache:"; // 标签页中文化表: key 使用站点 tag slug 或英文显示文本归一化后的结果. @@ -946,7 +946,8 @@ * home 模块: * - 适用范围: 主页, 分类页等普通 #roomlist_root 房间列表. * - 探测值: 站点 CSS 里的 grid 最小列宽 + 卡片原始高度 + 缩略图原始高度. - * - 放大方式: 原站列数除以 THUMBNAIL_SCALE; 每列仍用 1fr 均分, 保留原站对齐方式. + * - 放大方式: 只把 grid 最小列宽乘 THUMBNAIL_SCALE; 列宽仍用 1fr 自适应. + * - 这样窗口缩小时会自动减列, 不会把固定列数硬压窄. * - 注意: 关注页列表单独用 followingList 模块, 不和 home 共用探测结果. */ html[data-tm-thumb-scale-home="1"] #main #roomlist_root ul.RoomCardGrid, @@ -955,8 +956,8 @@ 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; + /* --tm-thumb-home-min-width 来自原站最小列宽, JS 写入时已经乘过倍率. */ + grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-home-min-width), 1fr)) !important; justify-content: stretch !important; align-items: start !important; /* 保留一个稳定间距, 避免放大后卡片互相贴住. */ @@ -969,14 +970,14 @@ 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; + height: auto !important; + min-height: 0 !important; + max-height: none !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; + height: auto !important; display: block !important; max-width: none !important; box-sizing: border-box !important; @@ -985,7 +986,7 @@ 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; + height: auto !important; /* block 可以消掉图片 inline baseline 带来的细小空隙. */ display: block !important; max-width: none !important; @@ -999,14 +1000,14 @@ * - 为什么和 home 分开: 两者 DOM 选择器很像, 但站点可能给不同页面不同原始列宽. * - followingList 单独探测, 避免首页探测值污染关注页. * - 探测值: 站点 CSS 里的 grid 最小列宽 + 卡片原始高度 + 缩略图原始高度. - * - 和 home 一样, 原站列数除以 THUMBNAIL_SCALE; 每列仍用 1fr 均分, 保留原站对齐方式. + * - 和 home 一样, 只放大最小列宽, 列数交给 auto-fill 随窗口自动变化. */ 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; + grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-following-list-min-width), 1fr)) !important; justify-content: stretch !important; align-items: start !important; gap: 0.6em 0.75em !important; @@ -1017,14 +1018,14 @@ 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; + height: auto !important; + min-height: 0 !important; + max-height: none !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; + height: auto !important; display: block !important; max-width: none !important; box-sizing: border-box !important; @@ -1033,7 +1034,7 @@ 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; + height: auto !important; display: block !important; max-width: none !important; box-sizing: border-box !important; @@ -1043,37 +1044,34 @@ * related 模块: * - 适用范围: 直播间页面底部"更多这样的房间". * - 探测值: 卡片原始宽度 + 卡片原始高度 + 缩略图原始高度. - * - 放大方式: 卡片宽度按 THUMBNAIL_SCALE 固定放大; 缩略图宽度跟随卡片宽度. - * - 为什么不按列数: 这个区域是频道页里的局部推荐列表, 容器宽度和首页/关注页不同, - * 用"列数除倍率"会把卡片拉成容器均分宽度, 导致"更多这样的房间"宽高比例失真. + * - 放大方式: 把卡片原始宽度乘 THUMBNAIL_SCALE 作为最小列宽, 列宽仍用 1fr 均分填满. + * - 这样右侧不会留空, 窗口变窄时也会自动减列. */ 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; + grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-related-width), 1fr)) !important; + justify-content: stretch !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; + width: auto !important; + justify-self: stretch !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; + max-width: none !important; + height: auto !important; + min-height: 0 !important; + max-height: none !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; + height: auto !important; display: block !important; max-width: none !important; box-sizing: border-box !important; @@ -1084,8 +1082,7 @@ 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; + height: auto !important; display: block !important; max-width: none !important; box-sizing: border-box !important; @@ -1171,6 +1168,8 @@ * - 箭头高度: 跟对应轮播高度同步, 避免右侧箭头与轮播内容错位. * - 启用条件: 卡片和缩略图探测到就先启用; 某种行数的轮播不存在时跳过对应高度变量. * 不能强制要求 triple/double/single 全部存在, 否则页面少一种轮播就会导致发现页完全不放大. + * - 关注页底部"为您推荐"也使用 .carousel-root, 但它不是发现页完整轮播排版. + * 该区域放大后可能从一行变多行, 所以下面另有 following-page 覆盖规则释放高度. */ 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 { @@ -1232,6 +1231,189 @@ max-width: none !important; box-sizing: border-box !important; } + + /* 关注页底部"为您推荐" */ + /* + * followedPage 模块: + * - 适用范围: /followed-cams/ 页面底部"为您推荐"推荐区. + * - 这个推荐区复用 .carousel-root / .room-list-carousel, 因此会吃到发现页放大规则. + * - 和发现页不同的是, 关注页推荐区放大后常会从原来的一行变成多行. + * - 如果继续使用发现页的 fixed height / 横向 track, 第二行卡片会被裁成一条条横带. + * - 这里不硬写列数; 用 auto-fill + 探测出的放大后卡片宽度让浏览器自然计算列数. + * - 也释放外层和中间 viewport/track 的高度与裁切, 避免换行后的第二行被裁成细条. + */ + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root div, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root > div, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel > div, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .single-row, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .double-rows, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .triple-rows { + height: auto !important; + min-height: 0 !important; + max-height: none !important; + overflow: visible !important; + } + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .single-row ul.list, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .double-rows ul.list, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .triple-rows ul.list { + display: grid !important; + grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-discover-width), 1fr)) !important; + grid-auto-rows: auto !important; + justify-content: stretch !important; + align-items: start !important; + gap: 0.6em 0.75em !important; + width: auto !important; + height: auto !important; + min-height: 0 !important; + max-height: none !important; + overflow: visible !important; + position: relative !important; + left: auto !important; + top: auto !important; + transform: none !important; + } + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel ul.list > li { + width: auto !important; + min-width: 0 !important; + max-width: none !important; + height: auto !important; + min-height: 0 !important; + max-height: none !important; + flex: none !important; + overflow: visible !important; + justify-self: stretch !important; + position: relative !important; + left: auto !important; + top: auto !important; + right: auto !important; + bottom: auto !important; + transform: none !important; + } + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel ul.list > li > a, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel ul.list > li > div { + height: auto !important; + min-height: 0 !important; + max-height: none !important; + overflow: visible !important; + } + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail_container { + width: 100% !important; + height: auto !important; + min-height: 0 !important; + overflow: visible !important; + } + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail_container img, + html[data-tm-thumb-scale-followed-page="1"][data-tm-thumb-scale-discover="1"] .carousel-root .room-list-carousel .room_thumbnail { + width: 100% !important; + height: auto !important; + display: block !important; + } + html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer, + html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable, + html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable > div, + html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable > div > div { + height: auto !important; + min-height: 0 !important; + max-height: none !important; + overflow: visible !important; + } + html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable { + display: grid !important; + grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-discover-width), 1fr)) !important; + grid-auto-rows: auto !important; + justify-content: stretch !important; + gap: 0.6em 0.75em !important; + align-items: start !important; + } + html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable > div, + html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .followRoomTable > div > div { + display: contents !important; + } + html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .roomElement { + width: auto !important; + min-width: 0 !important; + max-width: none !important; + height: auto !important; + min-height: 0 !important; + max-height: none !important; + overflow: visible !important; + position: relative !important; + left: auto !important; + top: auto !important; + right: auto !important; + bottom: auto !important; + transform: none !important; + } + html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .roomElementAnchor { + width: 100% !important; + height: auto !important; + min-height: 0 !important; + max-height: none !important; + overflow: visible !important; + display: block !important; + } + html[data-tm-thumb-scale-followed-page="1"] .followRecommendedContainer .room_thumbnail { + width: 100% !important; + height: auto !important; + min-height: 0 !important; + display: block !important; + } + + /* 关注页"为您推荐": 标题定位版 */ + /* + * followedRecommendations 模块: + * - 当前站点的关注页推荐区不再稳定使用 .carousel-root 或 .followRecommendedContainer. + * - JS 先找中文标题"为您推荐", 再从标题后面的区域里找缩略图和卡片, 打上 data 标记. + * - CSS 只作用于这些 data 标记, 不依赖站点随机 class. + * - 这里只放大 grid 的最小列宽, 列宽仍然用 1fr 让浏览器按窗口自然分配. + */ + html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations="1"], + html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations="1"] > *, + html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations="1"] > * > * { + height: auto !important; + max-height: none !important; + overflow: visible !important; + } + html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations-list="1"] { + display: grid !important; + grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-followed-recommendations-width), 1fr)) !important; + grid-auto-rows: auto !important; + justify-content: stretch !important; + align-items: start !important; + gap: 0.6em 0.75em !important; + width: auto !important; + height: auto !important; + max-height: none !important; + overflow: visible !important; + transform: none !important; + } + html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations-card="1"] { + width: auto !important; + min-width: 0 !important; + max-width: none !important; + height: auto !important; + min-height: 0 !important; + max-height: none !important; + overflow: visible !important; + position: relative !important; + left: auto !important; + top: auto !important; + right: auto !important; + bottom: auto !important; + transform: none !important; + flex: none !important; + justify-self: stretch !important; + } + html[data-tm-thumb-scale-followed-recommendations="1"] [data-tm-thumb-followed-recommendations-thumb="1"] { + width: 100% !important; + height: auto !important; + min-height: 0 !important; + display: block !important; + max-width: none !important; + box-sizing: border-box !important; + } `; const STYLE_ID = "tm-thumb-scale-style"; @@ -1257,6 +1439,7 @@ const moduleReady = { home: false, followingList: false, + followedRecommendations: false, related: false, follow: false, tooltip: false, @@ -1477,35 +1660,138 @@ style.setProperty(arrowName, value); return true; }; - const setListScaleVars = (columnsName, cardHeightName, thumbHeightName, metrics, cardWidth, cardHeight, thumbHeight) => { + const setListScaleVars = (minWidthName, 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`); + const originalMinWidth = Number.isFinite(metrics.minColumn) && metrics.minColumn > 0 + ? metrics.minColumn + : cardWidth; + style.setProperty(minWidthName, scaledPx(originalMinWidth)); + style.setProperty(cardHeightName, scaledCardHeightFromThumbPx(cardHeight, thumbHeight)); + style.setProperty(thumbHeightName, scaledPx(thumbHeight)); + return true; + }; + const clearFollowedRecommendationMarkers = () => { + try { + document.querySelectorAll( + "[data-tm-thumb-followed-recommendations]," + + "[data-tm-thumb-followed-recommendations-list]," + + "[data-tm-thumb-followed-recommendations-card]," + + "[data-tm-thumb-followed-recommendations-thumb]", + ).forEach((el) => { + el.removeAttribute("data-tm-thumb-followed-recommendations"); + el.removeAttribute("data-tm-thumb-followed-recommendations-list"); + el.removeAttribute("data-tm-thumb-followed-recommendations-card"); + el.removeAttribute("data-tm-thumb-followed-recommendations-thumb"); + }); + } catch (_) {} + }; + const directText = (el) => { + try { + return Array.from(el.childNodes || []) + .filter((node) => node.nodeType === 3) + .map((node) => node.nodeValue || "") + .join("") + .trim(); + } catch (_) { + return ""; + } + }; + const followedRecommendationHeadings = () => { + if (!isFollowingListPage() || !document.querySelectorAll) return []; + const result = []; + try { + document.querySelectorAll("h1,h2,h3,h4,strong,b,div,span").forEach((el) => { + const text = (directText(el) || el.textContent || "").trim(); + if (/^(为您推荐|Recommended)$/i.test(text)) result.push(el); + }); + } catch (_) {} + return result; + }; + const hasRecommendationMedia = (el) => { + try { + return !!(el && el.querySelector && el.querySelector( + 'img[src*="live.mmcdn"], img[src*="thumb.live.mmcdn"], img.room_thumbnail, .room_thumbnail img, .room_thumbnail_container img', + )); + } catch (_) { + return false; + } + }; + const firstFollowingRecommendationRoot = () => { + const headings = followedRecommendationHeadings(); + for (const heading of headings) { + const probes = []; + let node = heading; + for (let depth = 0; node && depth < 6; depth += 1) { + if (node.nextElementSibling) probes.push(node.nextElementSibling); + if (node.parentElement && node.parentElement.nextElementSibling) probes.push(node.parentElement.nextElementSibling); + probes.push(node.parentElement); + node = node.parentElement; + } + const root = probes.find((candidate) => candidate && candidate !== document.body && hasRecommendationMedia(candidate)); + if (root) return root; + } + return null; + }; + const markFollowedRecommendations = () => { + clearFollowedRecommendationMarkers(); + const root = firstFollowingRecommendationRoot(); + if (!root) return null; + root.setAttribute("data-tm-thumb-followed-recommendations", "1"); + let list = null; + try { + list = Array.from(root.querySelectorAll("ul,ol,div")) + .filter(hasRecommendationMedia) + .sort((a, b) => { + const aCount = a.querySelectorAll("img").length; + const bCount = b.querySelectorAll("img").length; + return bCount - aCount; + })[0] || root; + } catch (_) { + list = root; + } + list.setAttribute("data-tm-thumb-followed-recommendations-list", "1"); + const thumbs = Array.from(root.querySelectorAll( + 'img[src*="live.mmcdn"], img[src*="thumb.live.mmcdn"], img.room_thumbnail, .room_thumbnail img, .room_thumbnail_container img', + )); + let cards = []; + try { + cards = Array.from(root.querySelectorAll("li.RoomCard, li.roomCard, .roomElement")); + } catch (_) { + cards = []; + } + thumbs.forEach((img) => { + const thumb = img.closest(".room_thumbnail_container, .RoomCardThumbnail, .room_thumbnail") || img; + thumb.setAttribute("data-tm-thumb-followed-recommendations-thumb", "1"); + const finalCard = img.closest("li.RoomCard, li.roomCard, .roomElement, li, a") || thumb.parentElement || img.parentElement; + if (finalCard && !cards.includes(finalCard)) { + cards.push(finalCard); + } + }); + cards.forEach((card) => { + card.setAttribute("data-tm-thumb-followed-recommendations-card", "1"); + }); + return { root, list, cards, thumbs }; + }; + const setFollowedRecommendationVars = (marked) => { + const style = rootStyle(); + if (!style || !marked || !marked.cards.length || !marked.thumbs.length) return false; + const cardRect = rectOf(marked.cards[0]); + const thumbRect = rectOf(marked.thumbs[0]); + if (!isRect(cardRect) || !isRect(thumbRect)) return false; + style.setProperty("--tm-thumb-followed-recommendations-width", scaledPx(cardRect.width)); + style.setProperty("--tm-thumb-followed-recommendations-card-height", "auto"); + style.setProperty("--tm-thumb-followed-recommendations-thumb-width", scaledPx(thumbRect.width)); + style.setProperty("--tm-thumb-followed-recommendations-thumb-height", "auto"); return true; }; // 每个模块的 CSS 都由 html[data-tm-thumb-scale-模块名="1"] 控制. @@ -1645,6 +1931,7 @@ columns: Math.max(1, Math.floor((rect.width + columnGap) / (minColumn + columnGap))), width: rect.width, columnGap, + minColumn, }; } const columns = computedGridColumnCount(selector); @@ -1652,6 +1939,7 @@ columns, width: rect && rect.width, columnGap, + minColumn, } : null; }; const detectAndApplySizes = (stage = "detect") => { @@ -1664,6 +1952,10 @@ * 4) 锁住后的模块后续不再探测, 避免把已放大的尺寸再次当成原始尺寸. */ const followingListPage = isFollowingListPage(); + // followedPage 只代表 URL 处在关注页, 用于限制底部"为您推荐"的轮播高度覆盖规则. + // 它不依赖尺寸探测; 具体是否放大仍由 discover 模块探测成功后决定. + setModuleReady("followedPage", followingListPage); + const followedRecommendations = followingListPage ? markFollowedRecommendations() : null; 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 = { @@ -1675,6 +1967,15 @@ 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"), + // 关注页底部"为您推荐": 当前站点 class 不稳定, 使用标题定位后打 data 标记. + followedRecommendations: followedRecommendations ? { + root: rectOf(followedRecommendations.root), + list: rectOf(followedRecommendations.list), + card: followedRecommendations.cards[0] && rectOf(followedRecommendations.cards[0]), + thumb: followedRecommendations.thumbs[0] && rectOf(followedRecommendations.thumbs[0]), + cards: followedRecommendations.cards.length, + thumbs: followedRecommendations.thumbs.length, + } : null, // 频道页"更多这样的房间": 使用局部推荐区自己的卡片原始宽度和高度, 不套用首页列数算法. 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"), @@ -1695,7 +1996,7 @@ if (!moduleReady.home && !followingListPage) { // home 依赖当前页面的自然列数和原始高度; 每次加载重新探测. moduleReady.home = setListScaleVars( - "--tm-thumb-home-columns", + "--tm-thumb-home-min-width", "--tm-thumb-home-card-height", "--tm-thumb-home-thumb-height", measured.homeMetrics, @@ -1709,7 +2010,7 @@ if (!moduleReady.followingList && followingListPage) { // followingList 单独探测, 避免关注页拿到首页的列数. moduleReady.followingList = setListScaleVars( - "--tm-thumb-following-list-columns", + "--tm-thumb-following-list-min-width", "--tm-thumb-following-list-card-height", "--tm-thumb-following-list-thumb-height", measured.followingListMetrics, @@ -1720,6 +2021,13 @@ setModuleReady("followingList", moduleReady.followingList); } + if (!moduleReady.followedRecommendations && followingListPage) { + moduleReady.followedRecommendations = setFollowedRecommendationVars(followedRecommendations); + setModuleReady("followedRecommendations", moduleReady.followedRecommendations); + } else if (moduleReady.followedRecommendations && followingListPage) { + setModuleReady("followedRecommendations", true); + } + if (!moduleReady.related) { // related 同时依赖卡片宽度, 卡片高度和缩略图高度; 三者缺一不可. moduleReady.related = [ @@ -1878,10 +2186,10 @@ try { const style = getComputedStyle(document.documentElement); return { - homeColumns: style.getPropertyValue("--tm-thumb-home-columns").trim(), + homeMinWidth: style.getPropertyValue("--tm-thumb-home-min-width").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(), + followingListMinWidth: style.getPropertyValue("--tm-thumb-following-list-min-width").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(), @@ -1894,6 +2202,10 @@ 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(), + followedRecommendationsWidth: style.getPropertyValue("--tm-thumb-followed-recommendations-width").trim(), + followedRecommendationsCardHeight: style.getPropertyValue("--tm-thumb-followed-recommendations-card-height").trim(), + followedRecommendationsThumbWidth: style.getPropertyValue("--tm-thumb-followed-recommendations-thumb-width").trim(), + followedRecommendationsThumbHeight: style.getPropertyValue("--tm-thumb-followed-recommendations-thumb-height").trim(), }; } catch (_) { return {}; @@ -1931,6 +2243,105 @@ return report; }; + const inspectFollowedRecommendations = () => { + /* + * 专门排查关注页底部"为您推荐"被压扁的问题. + * 输出重点: + * - 哪些推荐区候选容器存在; + * - 前几张卡片的真实矩形, 用来判断第 3 张是否已经被压成横条; + * - 被压扁卡片向上的祖先层高度/overflow/position/transform, 用来找真正裁切的节点. + */ + const rectInfo = (el) => { + if (!el || !el.getBoundingClientRect) return null; + const rect = el.getBoundingClientRect(); + return { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + }; + const styleInfo = (el) => { + if (!el || typeof getComputedStyle !== "function") return {}; + const style = getComputedStyle(el); + return { + display: style.display, + position: style.position, + overflow: style.overflow, + overflowX: style.overflowX, + overflowY: style.overflowY, + height: style.height, + minHeight: style.minHeight, + maxHeight: style.maxHeight, + gridTemplateColumns: style.gridTemplateColumns, + gridAutoRows: style.gridAutoRows, + transform: style.transform, + }; + }; + const nodeInfo = (el) => ({ + tag: el && el.tagName, + id: el && el.id, + className: el && String(el.className || "").slice(0, 220), + rect: rectInfo(el), + style: styleInfo(el), + }); + const ancestorChain = (el, stop) => { + const chain = []; + let current = el; + while (current && chain.length < 14) { + chain.push(nodeInfo(current)); + if (current === stop || current === document.body || current === document.documentElement) break; + current = current.parentElement; + } + return chain; + }; + const candidates = Array.from(document.querySelectorAll( + '[data-tm-thumb-followed-recommendations="1"], .followRecommendedContainer, .carousel-root .room-list-carousel, .carousel-root', + )); + const reports = candidates.map((root, index) => { + const cards = Array.from(root.querySelectorAll( + "ul.list > li, .roomElement, li.RoomCard, li.roomCard", + )).slice(0, 8); + const targetCard = cards[2] || cards[0] || root; + return { + index, + root: nodeInfo(root), + cards: cards.map(nodeInfo), + targetAncestors: ancestorChain(targetCard, root), + }; + }); + const report = { + ver: VER, + href: typeof location === "undefined" ? "" : location.href, + htmlAttrs: document.documentElement ? { + followedPage: document.documentElement.getAttribute("data-tm-thumb-scale-followed-page"), + followedRecommendations: document.documentElement.getAttribute("data-tm-thumb-scale-followed-recommendations"), + discover: document.documentElement.getAttribute("data-tm-thumb-scale-discover"), + ver: document.documentElement.getAttribute("data-tm-thumb-scale-ver"), + } : {}, + cssVars: (() => { + try { + const style = getComputedStyle(document.documentElement); + return { + 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(), + followedRecommendationsWidth: style.getPropertyValue("--tm-thumb-followed-recommendations-width").trim(), + followedRecommendationsCardHeight: style.getPropertyValue("--tm-thumb-followed-recommendations-card-height").trim(), + followedRecommendationsThumbWidth: style.getPropertyValue("--tm-thumb-followed-recommendations-thumb-width").trim(), + followedRecommendationsThumbHeight: style.getPropertyValue("--tm-thumb-followed-recommendations-thumb-height").trim(), + }; + } catch (_) { + return {}; + } + })(), + candidates: reports, + }; + console.log("[tm-thumb-scale recommendations]", report); + return report; + }; + const exposeDebugApi = () => { // 手动调试入口: 控制台执行 tmThumbScaleDebug() 可立即生成报告. const api = (stage = "manual") => reportDiagnostics(stage); @@ -1941,6 +2352,7 @@ targets.forEach((target) => { try { target.tmThumbScaleDebug = api; + target.tmThumbScaleInspectRecommendations = inspectFollowedRecommendations; target.tmThumbScaleLastReport = lastReport; } catch (_) {} }); @@ -2038,6 +2450,9 @@ reportDiagnostics("start-before-inject"); // 标签页中文化: document-start 时可能还没有标签 DOM, 后续 observer/定时器会继续补. scheduleTagsTranslation("start", 0); + // 关注页底部"为您推荐"复用发现页轮播结构, 必须在注入 CSS 前先打页面标记. + // 否则它会短暂吃到发现页固定高度, 后续推荐卡片换行时容易被裁成横条. + setModuleReady("followedPage", isFollowingListPage()); // 注入 CSS.因为模块默认关闭, 即使 CSS 很早注入, 也不会改变原始布局. inject(); diff --git a/tests/followed-dropdown-css.test.js b/tests/followed-dropdown-css.test.js index 4642b79..8348a3e 100644 --- a/tests/followed-dropdown-css.test.js +++ b/tests/followed-dropdown-css.test.js @@ -86,43 +86,43 @@ assert.match( ); assert.match( capturedCss, - /data-tm-thumb-scale-following-list="1"[\s\S]*--tm-thumb-following-list-columns/, - "followed-cams list should be separated from homepage list column count", + /data-tm-thumb-scale-following-list="1"[\s\S]*--tm-thumb-following-list-min-width/, + "followed-cams list should use its own detected minimum column width", ); assert.match( capturedCss, - /data-tm-thumb-scale-following-list="1"[\s\S]*grid-template-columns:\s*repeat\(var\(--tm-thumb-following-list-columns\),\s*minmax\(0,\s*1fr\)\)\s*!important/, - "followed-cams list should reduce the original column count and keep 1fr alignment", + /data-tm-thumb-scale-following-list="1"[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(var\(--tm-thumb-following-list-min-width\),\s*1fr\)\)\s*!important/, + "followed-cams list should reduce columns responsively using scaled minimum width", ); assert.match( capturedCss, - /#main\.roomPage\s+ul\.list:has\(li\.roomCard\)\s+\.room_thumbnail_container[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*var\(--tm-thumb-related-height\)\s*!important/, - "room page related rooms thumbnail containers should follow card width and use detected height", + /#main\.roomPage\s+ul\.list:has\(li\.roomCard\)\s+\.room_thumbnail_container[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*auto\s*!important/, + "room page related rooms thumbnail containers should follow card width and automatic height", ); assert.match( capturedCss, - /\.BaseRoomContents\s+ul\.list:has\(li\.roomCard\)\s+\.room_thumbnail[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*var\(--tm-thumb-related-height\)\s*!important/, - "base room related thumbnails should follow card width and use detected height", + /\.BaseRoomContents\s+ul\.list:has\(li\.roomCard\)\s+\.room_thumbnail[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*auto\s*!important/, + "base room related thumbnails should follow card width and automatic height", ); assert.match( capturedCss, - /\.BaseRoomContents\s+ul\.RoomCardGrid[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*var\(--tm-thumb-related-width\)\)\s*!important/, - "base room RoomCardGrid related rooms should use detected card width", + /\.BaseRoomContents\s+ul\.RoomCardGrid[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(var\(--tm-thumb-related-width\),\s*1fr\)\)\s*!important/, + "base room RoomCardGrid related rooms should fill the row using detected scaled minimum width", ); assert.match( capturedCss, - /\.BaseRoomContents\s+ul\.RoomCardGrid\s+\.RoomCardThumbnail[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*var\(--tm-thumb-related-height\)\s*!important/, - "base room RoomCardGrid thumbnails should follow card width and use detected height", + /\.BaseRoomContents\s+ul\.RoomCardGrid\s+\.RoomCardThumbnail[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*auto\s*!important/, + "base room RoomCardGrid thumbnails should follow card width and automatic height", ); assert.match( capturedCss, - /\.BaseRoomContents\s+ul\.RoomCardGrid\s*>\s*li\.RoomCard[\s\S]*height:\s*var\(--tm-thumb-related-card-height\)\s*!important/, - "base room RoomCardGrid related cards should use detected card height", + /\.BaseRoomContents\s+ul\.RoomCardGrid\s*>\s*li\.RoomCard[\s\S]*height:\s*auto\s*!important[\s\S]*max-height:\s*none\s*!important/, + "base room RoomCardGrid related cards should use automatic height", ); assert.match( capturedCss, - /\.BaseRoomContents\s+ul\.RoomCardGrid\s*>\s*li\.RoomCard[\s\S]*width:\s*var\(--tm-thumb-related-width\)\s*!important/, - "base room RoomCardGrid related cards should use detected card width", + /\.BaseRoomContents\s+ul\.RoomCardGrid\s*>\s*li\.RoomCard[\s\S]*width:\s*auto\s*!important[\s\S]*justify-self:\s*stretch\s*!important[\s\S]*max-width:\s*none\s*!important/, + "base room RoomCardGrid related cards should stretch to fill responsive columns", ); assert.doesNotMatch(capturedCss, /--tm-thumb-min-root:\s*\d+px;/, "script should not hard-code a root thumbnail fallback width"); assert.doesNotMatch(capturedCss, /--tm-thumb-height-root:\s*\d+px;/, "script should not hard-code a root thumbnail fallback height"); @@ -151,6 +151,36 @@ assert.match( /height:\s*var\(--tm-thumb-discover-double-arrow\)\s*!important/, "discover carousel arrow heights should use detected original heights", ); +assert.match( + capturedCss, + /data-tm-thumb-scale-followed-page="1"[\s\S]*\.carousel-root\s+\.single-row\s+ul\.list[\s\S]*height:\s*auto\s*!important[\s\S]*max-height:\s*none\s*!important[\s\S]*overflow:\s*visible\s*!important/, + "followed-cams recommendations should release carousel list height after scaling", +); +assert.match( + capturedCss, + /data-tm-thumb-scale-followed-page="1"[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(var\(--tm-thumb-discover-width\),\s*1fr\)\)\s*!important/, + "followed-cams legacy recommendations should use responsive scaled minimum width", +); +assert.doesNotMatch( + capturedCss, + /data-tm-thumb-scale-followed-page="1"[\s\S]*grid-template-columns:\s*repeat\(2,/, + "followed-cams recommendations should not force a hard-coded two-column layout", +); +assert.match( + capturedCss, + /data-tm-thumb-scale-followed-recommendations="1"[\s\S]*data-tm-thumb-followed-recommendations-list="1"[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(var\(--tm-thumb-followed-recommendations-width\),\s*1fr\)\)\s*!important[\s\S]*grid-auto-rows:\s*auto\s*!important/, + "title-detected followed recommendations should use responsive scaled minimum columns and automatic row height", +); +assert.match( + capturedCss, + /data-tm-thumb-scale-followed-recommendations="1"[\s\S]*data-tm-thumb-followed-recommendations-card="1"[\s\S]*width:\s*auto\s*!important[\s\S]*max-width:\s*none\s*!important[\s\S]*height:\s*auto\s*!important[\s\S]*max-height:\s*none\s*!important/, + "title-detected followed recommendation cards should stretch responsively and grow naturally", +); +assert.match( + capturedCss, + /data-tm-thumb-scale-followed-recommendations="1"[\s\S]*data-tm-thumb-followed-recommendations-thumb="1"[\s\S]*height:\s*auto\s*!important/, + "title-detected followed recommendation thumbnails should keep automatic height", +); assert.match( capturedCss, /#discover_root\s+\.room-list-carousel\s+\.room_thumbnail[\s\S]*height:\s*var\(--tm-thumb-discover-height\)\s*!important/, @@ -420,12 +450,12 @@ vm.runInNewContext(source, { cardHeightScale: 1.55, modules: { home: { - homeColumns: 7, + homeMinWidth: 190, homeCard: { width: 174, height: 120 }, homeThumb: { width: 174, height: 98 }, }, followingList: { - followingListColumns: 7, + followingListMinWidth: 190, followingListCard: { width: 190, height: 120 }, followingListThumb: { width: 188, height: 106 }, }, @@ -453,8 +483,8 @@ vm.runInNewContext(source, { assert.equal(followingAttrs.get("data-tm-thumb-scale-following-list"), undefined, "followed-cams should not apply legacy cached list module"); assert.equal(followingAttrs.get("data-tm-thumb-scale-home"), undefined, "followed-cams should not apply legacy homepage cache"); -assert.equal(followingVars.get("--tm-thumb-following-list-columns"), undefined); +assert.equal(followingVars.get("--tm-thumb-following-list-min-width"), undefined); assert.equal(followingVars.get("--tm-thumb-following-list-card-height"), undefined); assert.equal(followingVars.get("--tm-thumb-following-list-thumb-width"), undefined); assert.equal(followingVars.get("--tm-thumb-following-list-thumb-height"), undefined); -assert.equal(followingVars.get("--tm-thumb-home-columns"), undefined); +assert.equal(followingVars.get("--tm-thumb-home-min-width"), undefined);