feat: 加入 web-to-pixso 插件源码
Chrome 扩展,用于从网页提取 DOM 树为 JSON 格式。 核心文件:capture.js(76KB,提取逻辑)
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
const CAPTURE_FILE = "capture.js";
|
||||
const RUNNER_FILE = "runner.js";
|
||||
const POPUP_PANEL_FILE = "popup-panel.js";
|
||||
const ELEMENT_PICKER_FILE = "element-picker.js";
|
||||
const SETTINGS_KEY = "webToPixsoSettings";
|
||||
const DEFAULT_SETTINGS = {
|
||||
useProxy: false,
|
||||
concurrency: "8",
|
||||
captureMode: "mixed",
|
||||
captureWidth: null
|
||||
};
|
||||
const MIN_CAPTURE_WIDTH = 320;
|
||||
const MAX_CAPTURE_WIDTH = 3840;
|
||||
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
function normalizeSettings(value = {}) {
|
||||
const concurrency = String(value.concurrency || DEFAULT_SETTINGS.concurrency);
|
||||
const captureWidth = normalizeCaptureWidth(value.captureWidth, null);
|
||||
return {
|
||||
useProxy: Boolean(value.useProxy),
|
||||
concurrency: ["4", "6", "8", "10", "12", "16", "20", "infinite"].includes(concurrency)
|
||||
? concurrency
|
||||
: DEFAULT_SETTINGS.concurrency,
|
||||
captureMode: value.captureMode === "editable" ? "editable" : "mixed",
|
||||
captureWidth
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCaptureWidth(value, fallback = null) {
|
||||
const number = Number.parseInt(String(value || "").replace(/\D+/g, ""), 10);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.max(MIN_CAPTURE_WIDTH, Math.min(MAX_CAPTURE_WIDTH, number));
|
||||
}
|
||||
|
||||
function definedWindowBounds(bounds = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(bounds).filter(([, value]) => Number.isFinite(value))
|
||||
);
|
||||
}
|
||||
|
||||
function assertCaptureableTab(tab) {
|
||||
if (!tab?.id || !tab.url) {
|
||||
throw new Error("没有可采集的当前标签页");
|
||||
}
|
||||
|
||||
if (/^(chrome|edge|about|devtools|chrome-extension):/i.test(tab.url)) {
|
||||
throw new Error("浏览器内置页面不支持采集,请切换到普通网页");
|
||||
}
|
||||
}
|
||||
|
||||
async function getActiveTab() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
assertCaptureableTab(tab);
|
||||
return tab;
|
||||
}
|
||||
|
||||
async function runCapture(tabId, options) {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: [CAPTURE_FILE]
|
||||
});
|
||||
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: [RUNNER_FILE]
|
||||
});
|
||||
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: captureOptions => window.__webToPixsoRunCapture(captureOptions),
|
||||
args: [options]
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error("页面没有返回采集结果");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getTabViewportWidth(tabId) {
|
||||
try {
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => Math.round(window.innerWidth || document.documentElement.clientWidth || 0)
|
||||
});
|
||||
return normalizeCaptureWidth(result, null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareCaptureViewport(tab, requestedWidth) {
|
||||
const targetWidth = normalizeCaptureWidth(requestedWidth, null);
|
||||
const beforeViewportWidth = await getTabViewportWidth(tab.id);
|
||||
const noop = async () => {};
|
||||
if (!targetWidth || !beforeViewportWidth || Math.abs(beforeViewportWidth - targetWidth) <= 2) {
|
||||
return {
|
||||
restore: noop,
|
||||
requestedWidth: targetWidth || beforeViewportWidth,
|
||||
beforeViewportWidth,
|
||||
actualViewportWidth: beforeViewportWidth,
|
||||
resizedWindow: false
|
||||
};
|
||||
}
|
||||
|
||||
if (!tab.windowId || !chrome.windows?.get || !chrome.windows?.update) {
|
||||
throw new Error("当前浏览器不支持临时调整采集视口宽度");
|
||||
}
|
||||
|
||||
const originalWindow = await chrome.windows.get(tab.windowId);
|
||||
const originalState = originalWindow.state || "normal";
|
||||
const originalBounds = {
|
||||
left: originalWindow.left,
|
||||
top: originalWindow.top,
|
||||
width: originalWindow.width,
|
||||
height: originalWindow.height
|
||||
};
|
||||
|
||||
const restore = async () => {
|
||||
try {
|
||||
if (originalState !== "normal") {
|
||||
await chrome.windows.update(tab.windowId, { state: "normal" });
|
||||
await delay(120);
|
||||
}
|
||||
const restoreBounds = definedWindowBounds(originalBounds);
|
||||
if (Object.keys(restoreBounds).length) {
|
||||
await chrome.windows.update(tab.windowId, restoreBounds);
|
||||
}
|
||||
if (originalState !== "normal") {
|
||||
await delay(120);
|
||||
await chrome.windows.update(tab.windowId, { state: originalState });
|
||||
}
|
||||
await delay(250);
|
||||
} catch {
|
||||
// Restoring the user's window is best effort only.
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (originalState !== "normal") {
|
||||
await chrome.windows.update(tab.windowId, { state: "normal" });
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
let currentViewportWidth = beforeViewportWidth;
|
||||
let currentWindow = await chrome.windows.get(tab.windowId);
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const delta = targetWidth - currentViewportWidth;
|
||||
const nextWidth = Math.max(360, Math.round((currentWindow.width || targetWidth) + delta));
|
||||
await chrome.windows.update(tab.windowId, { width: nextWidth });
|
||||
await delay(650);
|
||||
currentViewportWidth = await getTabViewportWidth(tab.id) || currentViewportWidth;
|
||||
if (Math.abs(currentViewportWidth - targetWidth) <= 2) break;
|
||||
currentWindow = await chrome.windows.get(tab.windowId);
|
||||
}
|
||||
|
||||
if (Math.abs(currentViewportWidth - targetWidth) > 2) {
|
||||
throw new Error(`采集视口未生效:目标 ${targetWidth}px,实际 ${currentViewportWidth}px。请退出全屏或手动放宽浏览器窗口后重试。`);
|
||||
}
|
||||
|
||||
return {
|
||||
restore,
|
||||
requestedWidth: targetWidth,
|
||||
beforeViewportWidth,
|
||||
actualViewportWidth: currentViewportWidth,
|
||||
resizedWindow: true
|
||||
};
|
||||
} catch (error) {
|
||||
await restore();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function captureCurrentTab(tab, settings) {
|
||||
const viewport = await prepareCaptureViewport(tab, settings.captureWidth);
|
||||
try {
|
||||
const data = await runCapture(tab.id, {
|
||||
...settings,
|
||||
captureWidth: viewport.requestedWidth
|
||||
});
|
||||
data.capture = {
|
||||
...(data.capture || {}),
|
||||
resizedWindow: viewport.resizedWindow,
|
||||
usedTemporaryWindow: viewport.resizedWindow,
|
||||
requestedWidth: viewport.requestedWidth || data.source?.actualViewportWidth || data.canvas?.width,
|
||||
beforeViewportWidth: viewport.beforeViewportWidth,
|
||||
actualViewportWidth: data.source?.actualViewportWidth || viewport.actualViewportWidth
|
||||
};
|
||||
return data;
|
||||
} finally {
|
||||
await viewport.restore();
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadCapture(data) {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const encodedJson = arrayBufferToBase64(new TextEncoder().encode(json));
|
||||
const url = `data:application/json;charset=utf-8;base64,${encodedJson}`;
|
||||
const title = data.source?.title || "webpage";
|
||||
const safeTitle = title
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.slice(0, 64) || "webpage";
|
||||
const filename = `web-to-pixso/${safeTitle}-${Date.now()}.json`;
|
||||
|
||||
await chrome.downloads.download({
|
||||
url,
|
||||
filename,
|
||||
saveAs: true
|
||||
});
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
async function startCapture(options) {
|
||||
const settings = normalizeSettings(options);
|
||||
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
|
||||
const tab = await getActiveTab();
|
||||
const data = await captureCurrentTab(tab, settings);
|
||||
const filename = await downloadCapture(data);
|
||||
return {
|
||||
ok: true,
|
||||
filename,
|
||||
actualViewportWidth: data.source?.actualViewportWidth,
|
||||
requestedViewportWidth: data.source?.requestedViewportWidth,
|
||||
usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow)
|
||||
};
|
||||
}
|
||||
|
||||
async function startCaptureFromSender(options, sender) {
|
||||
const settings = normalizeSettings(options);
|
||||
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
|
||||
const tab = sender?.tab || await getActiveTab();
|
||||
assertCaptureableTab(tab);
|
||||
const data = await captureCurrentTab(tab, settings);
|
||||
const filename = await downloadCapture(data);
|
||||
return {
|
||||
ok: true,
|
||||
filename,
|
||||
actualViewportWidth: data.source?.actualViewportWidth,
|
||||
requestedViewportWidth: data.source?.requestedViewportWidth,
|
||||
usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow)
|
||||
};
|
||||
}
|
||||
|
||||
async function captureClipboardFromSender(options, sender) {
|
||||
const settings = normalizeSettings(options);
|
||||
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
|
||||
const tab = sender?.tab || await getActiveTab();
|
||||
assertCaptureableTab(tab);
|
||||
const data = await captureCurrentTab(tab, settings);
|
||||
return {
|
||||
ok: true,
|
||||
json: JSON.stringify(data, null, 2),
|
||||
actualViewportWidth: data.source?.actualViewportWidth,
|
||||
requestedViewportWidth: data.source?.requestedViewportWidth,
|
||||
usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow)
|
||||
};
|
||||
}
|
||||
|
||||
async function startElementCapture(options, sender) {
|
||||
const settings = {
|
||||
...normalizeSettings(options),
|
||||
captureWidth: null,
|
||||
selectionId: options?.selectionId,
|
||||
selectionWidth: Math.max(1, Math.round(Number(options?.selectionWidth || 0))),
|
||||
captureMode: "mixed"
|
||||
};
|
||||
const tab = sender?.tab || await getActiveTab();
|
||||
assertCaptureableTab(tab);
|
||||
const data = await runCapture(tab.id, settings);
|
||||
const filename = await downloadCapture(data);
|
||||
return {
|
||||
ok: true,
|
||||
filename,
|
||||
actualViewportWidth: data.source?.actualViewportWidth,
|
||||
requestedViewportWidth: data.source?.requestedViewportWidth,
|
||||
selectionWidth: data.import?.defaultWidth
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, timeout = 10000) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
credentials: "include",
|
||||
cache: "force-cache"
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const chunkSize = 0x8000;
|
||||
let binary = "";
|
||||
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function proxyFetchAsset(url) {
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
return { ok: false, error: "仅支持 http/https 图片代理" };
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(url, 12000);
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: response.status, error: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "application/octet-stream";
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
contentType,
|
||||
base64: arrayBufferToBase64(buffer)
|
||||
};
|
||||
}
|
||||
|
||||
async function captureVisibleTab(sender) {
|
||||
if (!sender?.tab?.windowId || !chrome.tabs?.captureVisibleTab) {
|
||||
return { ok: false, error: "当前标签页截图不可用" };
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(sender.tab.windowId, {
|
||||
format: "png"
|
||||
});
|
||||
return { ok: true, dataUrl };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error.message || String(error),
|
||||
nonFatal: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function showInPagePanel(tab) {
|
||||
assertCaptureableTab(tab);
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
files: [ELEMENT_PICKER_FILE, POPUP_PANEL_FILE]
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => window.__webToPixsoShowPanel?.()
|
||||
});
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS }).then(result => {
|
||||
chrome.storage.local.set({ [SETTINGS_KEY]: normalizeSettings(result[SETTINGS_KEY]) });
|
||||
});
|
||||
});
|
||||
|
||||
chrome.action.onClicked.addListener(tab => {
|
||||
showInPagePanel(tab).catch(error => {
|
||||
console.warn("[Web to Pixso] Failed to open panel", error);
|
||||
});
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message?.type === "PIXSO_CAPTURE_START") {
|
||||
startCapture(message.options)
|
||||
.then(sendResponse)
|
||||
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message?.type === "PIXSO_CAPTURE_ELEMENT_START") {
|
||||
startElementCapture(message.options, sender)
|
||||
.then(sendResponse)
|
||||
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message?.type === "PIXSO_CAPTURE_CURRENT_TAB") {
|
||||
startCaptureFromSender(message.options, sender)
|
||||
.then(sendResponse)
|
||||
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message?.type === "PIXSO_CAPTURE_CLIPBOARD") {
|
||||
captureClipboardFromSender(message.options, sender)
|
||||
.then(sendResponse)
|
||||
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message?.type === "PIXSO_CAPTURE_FETCH_ASSET") {
|
||||
proxyFetchAsset(message.url)
|
||||
.then(sendResponse)
|
||||
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message?.type === "PIXSO_CAPTURE_VISIBLE_TAB") {
|
||||
captureVisibleTab(sender)
|
||||
.then(sendResponse)
|
||||
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
Reference in New Issue
Block a user