Files
mnote/scripts/wolai_help_center/download_images_playwright.js
T

194 lines
5.9 KiB
JavaScript
Raw Normal View History

2026-02-01 08:47:40 +08:00
// -*- coding: utf-8 -*-
/**
* 使用 Playwright 访问 Wolai 页面,抓取其实际请求的「带 auth_key」图片 URL
* 并将图片下载到本地,随后把 pages/*.md 中的图片链接替换为本地相对路径。
*
* 背景:Wostatic CDN 对未签名(缺少 auth_key)的 static 资源会返回 403。
* Wolai 前端会在渲染时生成/请求带 auth_key 的图片链接,因此需要借助浏览器抓包。
*
* 用法:
* node scripts/wolai_help_center/download_images_playwright.js --out artifacts/wolai-help-center-v4
*/
const fs = require("fs/promises");
const path = require("path");
const crypto = require("crypto");
const { chromium } = require("playwright");
function parseArgs(argv) {
const args = { out: "", maxPages: 0 };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === "--out") args.out = argv[++i] || "";
else if (a === "--max-pages") args.maxPages = Number(argv[++i] || "0") || 0;
}
return args;
}
function sha256Hex(text) {
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
}
function guessExt(contentType, urlPathname) {
const ct = String(contentType || "").split(";")[0].trim().toLowerCase();
if (ct === "image/png") return ".png";
if (ct === "image/jpeg") return ".jpg";
if (ct === "image/webp") return ".webp";
if (ct === "image/gif") return ".gif";
if (ct === "image/svg+xml") return ".svg";
const lower = urlPathname.toLowerCase();
for (const ext of [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"]) {
if (lower.endsWith(ext)) return ext === ".jpeg" ? ".jpg" : ext;
}
return ".bin";
}
function getBaseUrl(fullUrl) {
const u = new URL(fullUrl);
return `${u.origin}${u.pathname}`;
}
function getFileSizeHint(fullUrl) {
try {
const u = new URL(fullUrl);
const raw = u.searchParams.get("file_size");
const n = raw ? Number(raw) : 0;
return Number.isFinite(n) ? n : 0;
} catch {
return 0;
}
}
async function fileExists(p) {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function replaceInMarkdown(pagesDir, baseToLocal) {
const files = await fs.readdir(pagesDir);
const mdFiles = files.filter((f) => f.endsWith(".md"));
for (const f of mdFiles) {
const full = path.join(pagesDir, f);
const raw = await fs.readFile(full, "utf8");
let next = raw;
for (const [baseUrl, localRel] of Object.entries(baseToLocal)) {
if (next.includes(baseUrl)) {
next = next.split(baseUrl).join(localRel);
}
}
if (next !== raw) {
await fs.writeFile(full, next, "utf8");
}
}
}
async function main() {
const args = parseArgs(process.argv);
if (!args.out) {
console.error("缺少参数:--out <artifacts目录>");
process.exit(2);
}
const outDir = path.resolve(args.out);
const indexPath = path.join(outDir, "index.json");
const pagesDir = path.join(outDir, "pages");
const imagesDir = path.join(outDir, "images");
const mapPath = path.join(outDir, "image_map.json");
const index = JSON.parse(await fs.readFile(indexPath, "utf8"));
const results = Array.isArray(index.results) ? index.results : [];
const targets = args.maxPages > 0 ? results.slice(0, args.maxPages) : results;
await fs.mkdir(imagesDir, { recursive: true });
// baseUrl -> { localRel, bestFileSize }
const baseToMeta = new Map();
const browser = await chromium.launch();
const context = await browser.newContext();
for (const item of targets) {
const url = item.source;
if (!url) continue;
const page = await context.newPage();
const pending = [];
page.on("response", (resp) => {
const u = resp.url();
if (!u.startsWith("https://secure2.wostatic.cn/") && !u.startsWith("https://api.wolai.com/v1/icon")) return;
pending.push(resp);
});
await page.goto(url, { waitUntil: "networkidle" }).catch(() => null);
await page.waitForTimeout(1500);
// 去重:同一个 response 可能重复进入队列
const seenResponseUrl = new Set();
for (const resp of pending) {
const respUrl = resp.url();
if (seenResponseUrl.has(respUrl)) continue;
seenResponseUrl.add(respUrl);
const status = resp.status();
if (status !== 200) continue;
const headers = resp.headers();
const contentType = headers["content-type"] || "";
if (!String(contentType).toLowerCase().startsWith("image/") && !respUrl.includes("image_process=")) {
// 少数图片可能返回 octet-stream,但这里尽量保守
continue;
}
const baseUrl = getBaseUrl(respUrl);
const fileSize = getFileSizeHint(respUrl);
const u = new URL(respUrl);
const ext = guessExt(contentType, u.pathname);
const digest = sha256Hex(baseUrl).slice(0, 24);
const filename = `${digest}${ext}`;
const filePath = path.join(imagesDir, filename);
const localRel = `images/${filename}`;
const prev = baseToMeta.get(baseUrl);
const shouldWrite = !prev || fileSize > (prev.bestFileSize || 0) || !(await fileExists(filePath));
if (!shouldWrite) {
baseToMeta.set(baseUrl, { localRel, bestFileSize: prev.bestFileSize || 0 });
continue;
}
try {
const body = await resp.body();
await fs.writeFile(filePath, body);
baseToMeta.set(baseUrl, { localRel, bestFileSize: fileSize });
} catch {
// 忽略单个图片失败
}
}
await page.close();
}
await browser.close();
const baseToLocal = {};
for (const [k, v] of baseToMeta.entries()) {
baseToLocal[k] = v.localRel;
}
await fs.writeFile(mapPath, JSON.stringify({ baseToLocal }, null, 2), "utf8");
await replaceInMarkdown(pagesDir, baseToLocal);
console.log(`图片抓取完成:${Object.keys(baseToLocal).length} 个 baseUrl,输出:${imagesDir}`);
console.log(`映射表:${mapPath}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});