v0.10.37: posts 卡片 has-item-ratio 模式收口(列堆叠 ≤767 全列 100% + 缩略图比率框/绝对居中 scale1.01/fit-height per-image + media 宽高回填 7989 行)——桌面/移动卡盒与原站 ≤0.7px
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* # 脚本 回填 media 表缺失的宽高(width/height 为 null 的行)
|
||||
*
|
||||
* 背景:早期导入管线部分媒体行未填 width/height(8210 行),posts 卡片
|
||||
* fit-height 判定(图片高宽比 < cards_item_ratio 时按高适配)依赖该字段。
|
||||
* 本脚本扫描 public/uploads 下物理文件,解析 PNG/JPEG/GIF/WebP 文件头取尺寸
|
||||
* 回填;文件不存在的行跳过(由 download-missing-media.ts 补拉)。
|
||||
*
|
||||
* 用法:bun scripts/backfill-media-dimensions.ts [--dry] [limit=N]
|
||||
*/
|
||||
import { SQL } from "bun";
|
||||
import { readdir } from "node:fs/promises";
|
||||
|
||||
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 1 });
|
||||
const dry = process.argv.includes("--dry");
|
||||
const limitArg = Number(process.argv.find((a) => a.startsWith("limit="))?.slice(6) || 0);
|
||||
const UPLOADS = process.cwd() + "/public/uploads";
|
||||
|
||||
/** # 函数 解析图片文件头取宽高(PNG/JPEG/GIF/WebP 四格式)
|
||||
* 参数 buf 文件头部字节(≥ 64 字节足够)
|
||||
* 返回值 [宽, 高];无法解析返回 null
|
||||
*/
|
||||
function imageSize(buf: Uint8Array): [number, number] | null {
|
||||
const u8 = buf, dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
// PNG: 8 字节签名 + IHDR(宽高在 16/20 偏移)
|
||||
if (u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47)
|
||||
return [dv.getUint32(16), dv.getUint32(20)];
|
||||
// GIF: "GIF8" 宽高在 6/8(小端)
|
||||
if (u8[0] === 0x47 && u8[1] === 0x49 && u8[2] === 0x46)
|
||||
return [dv.getUint16(6, true), dv.getUint16(8, true)];
|
||||
// WebP: "RIFF....WEBP",VP8/VP8L/VP8X 三子格式
|
||||
if (u8[0] === 0x52 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x46 && u8[8] === 0x57 && u8[9] === 0x45) {
|
||||
const fourcc = String.fromCharCode(u8[12], u8[13], u8[14], u8[15]);
|
||||
if (fourcc === "VP8 ") return [dv.getUint16(26, true) & 0x3fff, dv.getUint16(28, true) & 0x3fff];
|
||||
if (fourcc === "VP8L") {
|
||||
const b = dv.getUint32(21, true);
|
||||
return [(b & 0x3fff) + 1, ((b >> 14) & 0x3fff) + 1];
|
||||
}
|
||||
if (fourcc === "VP8X") {
|
||||
// canvas 宽/高各 3 字节小端(24-26 宽、27-29 高),getUint32 会读到相邻字段越界
|
||||
const w = (u8[24] | (u8[25] << 8) | (u8[26] << 16)) + 1;
|
||||
const h = (u8[27] | (u8[28] << 8) | (u8[29] << 16)) + 1;
|
||||
return [w, h];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// JPEG: 扫 SOFn 段(FFC0-FFCF 除 FFD8/FF01/FFD0-FFD7)
|
||||
if (u8[0] === 0xff && u8[1] === 0xd8) {
|
||||
let i = 2;
|
||||
while (i + 9 < u8.length) {
|
||||
if (u8[i] !== 0xff) { i++; continue; }
|
||||
const marker = u8[i + 1];
|
||||
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
|
||||
const len = dv.getUint16(i + 2);
|
||||
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc)
|
||||
return [dv.getUint16(i + 7), dv.getUint16(i + 9)];
|
||||
i += 2 + len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// SVG 是文本(<svg width/height 或 viewBox),跳过( rarity 低)
|
||||
return null;
|
||||
}
|
||||
|
||||
/** # 函数 从 URL/路径还原 uploads 物理路径(/uploads/2023/01/x.jpg → 2023/01/x.jpg) */
|
||||
function physicalPath(url: string | null): string | null {
|
||||
if (!url) return null;
|
||||
const i = url.indexOf("/uploads/");
|
||||
return i === -1 ? null : url.slice(i + "/uploads/".length);
|
||||
}
|
||||
|
||||
const rows = await sql`select id, url, path from media where width is null order by id limit ${limitArg || 100000}`;
|
||||
console.log("待回填:", rows.length);
|
||||
let ok = 0, miss = 0, fail = 0;
|
||||
for (const r of rows) {
|
||||
const rel = physicalPath(r.path) || physicalPath(r.url);
|
||||
if (!rel) { miss++; continue; }
|
||||
const f = Bun.file(`${UPLOADS}/${rel}`);
|
||||
if (!(await f.exists())) { miss++; continue; }
|
||||
const head = new Uint8Array(await f.slice(0, 1024 * 64).arrayBuffer());
|
||||
const size = imageSize(head);
|
||||
// 防御:解析出异常尺寸(如文件头误读)不写库
|
||||
if (!size || !size[0] || !size[1] || size[0] > 100000 || size[1] > 100000) { fail++; continue; }
|
||||
if (!dry) await sql`update media set width = ${size[0]}, height = ${size[1]} where id = ${r.id}`;
|
||||
ok++;
|
||||
}
|
||||
console.log(`回填 ${ok}${dry ? "(dry)" : ""},文件缺失 ${miss},解析失败 ${fail}`);
|
||||
await sql.end();
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* # 脚本 取证 posts 卡片缩略图(本地 vs 原站,390px 移动视口)
|
||||
* 对比每张 .elementor-post__thumbnail img 的渲染盒与当前选中资源(srcset 候选),
|
||||
* 定位遗留表 #6 移动端个别卡 ±5px 差异。
|
||||
* 用法:bun scripts/forensic-posts-cards.ts <path>
|
||||
*/
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const CHROME = process.env.HOME + "/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome";
|
||||
const LOCAL = "http://127.0.0.1:3312";
|
||||
const LIVE = "https://www.dusuniot.com";
|
||||
const path = process.argv[2] || "/";
|
||||
|
||||
/** # 函数 抓取页面上全部 posts 卡片缩略图盒 */
|
||||
async function measure(base: string) {
|
||||
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto(base + path, { waitUntil: "networkidle", timeout: 60000 });
|
||||
// 逐步滚到底触发懒加载图片,再滚回首个 posts 区块停稳(避免离开视口后浏览器取消加载)
|
||||
await page.evaluate(async () => {
|
||||
for (let y = 0; y < document.body.scrollHeight; y += 600) {
|
||||
window.scrollTo(0, y);
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
}
|
||||
const t = document.querySelector(".elementor-posts-container");
|
||||
if (t) t.scrollIntoView({ block: "start" });
|
||||
});
|
||||
await page.waitForTimeout(3000);
|
||||
const data = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll(".elementor-post__thumbnail img")).map((img) => {
|
||||
const r = img.getBoundingClientRect();
|
||||
const card = img.closest(".elementor-post__card")!.getBoundingClientRect();
|
||||
const title = (img.closest("article")?.querySelector(".elementor-post__title")?.textContent || "").trim().slice(0, 40);
|
||||
return {
|
||||
title,
|
||||
img: { x: +r.x.toFixed(1), y: +r.y.toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1) },
|
||||
cardH: +card.height.toFixed(1),
|
||||
cur: (img as HTMLImageElement).currentSrc.split("/uploads/")[1] || (img as HTMLImageElement).currentSrc,
|
||||
nw: (img as HTMLImageElement).naturalWidth,
|
||||
nh: (img as HTMLImageElement).naturalHeight,
|
||||
};
|
||||
});
|
||||
});
|
||||
await browser.close();
|
||||
return data;
|
||||
}
|
||||
|
||||
const [local, live] = await Promise.all([measure(LOCAL), measure(LIVE)]);
|
||||
console.log("=== 本地 ===");
|
||||
for (const d of local) console.log(JSON.stringify(d));
|
||||
console.log("=== 原站 ===");
|
||||
for (const d of live) console.log(JSON.stringify(d));
|
||||
console.log("=== 差异 ===");
|
||||
const n = Math.max(local.length, live.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = local[i], b = live[i];
|
||||
if (!a || !b) { console.log(i, "缺侧", !!a, !!b); continue; }
|
||||
const dh = +(a.img.h - b.img.h).toFixed(1);
|
||||
const dch = +(a.cardH - b.cardH).toFixed(1);
|
||||
const same = a.cur === b.cur;
|
||||
if (Math.abs(dh) > 0.5 || Math.abs(dch) > 0.5 || !same)
|
||||
console.log(`#${i} [${a.title}] imgH ${a.img.h} vs ${b.img.h} (${dh > 0 ? "+" : ""}${dh}) cardH ${a.cardH} vs ${b.cardH} (${dch > 0 ? "+" : ""}${dch}) 资源${same ? "同" : "异: " + a.cur + " <-> " + b.cur} nat ${a.nw}x${a.nh} vs ${b.nw}x${b.nh}`);
|
||||
}
|
||||
console.log("完成");
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* # 脚本 取证 posts widget 容器结构(本地 vs 原站,390px)
|
||||
* 抓每个 posts 区块的 widget data-id、grid/容器样式、article 数、swiper 迹象。
|
||||
* 用法:bun scripts/forensic-posts-struct.ts <path>
|
||||
*/
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const CHROME = process.env.HOME + "/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome";
|
||||
const LOCAL = "http://127.0.0.1:3312";
|
||||
const LIVE = "https://www.dusuniot.com";
|
||||
const path = process.argv[2] || "/";
|
||||
|
||||
async function measure(base: string) {
|
||||
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
await page.goto(base + path, { waitUntil: "networkidle", timeout: 60000 });
|
||||
await page.evaluate(async () => {
|
||||
for (let y = 0; y < document.body.scrollHeight; y += 600) {
|
||||
window.scrollTo(0, y);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
const data = await page.evaluate(() => {
|
||||
const out: unknown[] = [];
|
||||
for (const grid of Array.from(document.querySelectorAll(".elementor-posts-container"))) {
|
||||
const widget = grid.closest("[data-id]") as HTMLElement | null;
|
||||
const art = grid.querySelectorAll("article");
|
||||
const cs = getComputedStyle(grid);
|
||||
const wrap = widget?.parentElement;
|
||||
out.push({
|
||||
widgetId: widget?.getAttribute("data-id"),
|
||||
widgetClass: (widget?.className || "").slice(0, 120),
|
||||
wrapClass: (wrap?.className || "").slice(0, 140),
|
||||
articles: art.length,
|
||||
gridDisplay: cs.display,
|
||||
gridCols: (grid.getAttribute("style") || "").slice(0, 160),
|
||||
swiper: !!widget?.querySelector(".swiper"),
|
||||
articleW: art.length ? +art[0].getBoundingClientRect().width.toFixed(1) : 0,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
await browser.close();
|
||||
return data;
|
||||
}
|
||||
|
||||
const [local, live] = await Promise.all([measure(LOCAL), measure(LIVE)]);
|
||||
console.log("=== 本地 ===");
|
||||
for (const d of local) console.log(JSON.stringify(d));
|
||||
console.log("=== 原站 ===");
|
||||
for (const d of live) console.log(JSON.stringify(d));
|
||||
Reference in New Issue
Block a user