Make Chaturbate thumbnail grids responsive

This commit is contained in:
2026-06-06 10:18:54 +08:00
parent bde00f27d3
commit 5d6a0d7d41
2 changed files with 520 additions and 75 deletions

View File

@@ -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();

View File

@@ -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);