feat: 加入 web-to-pixso 插件源码

Chrome 扩展,用于从网页提取 DOM 树为 JSON 格式。
核心文件:capture.js(76KB,提取逻辑)
This commit is contained in:
李进
2026-07-27 12:22:27 +08:00
parent a10969b562
commit 330f4e96ce
22 changed files with 6185 additions and 0 deletions
+521
View File
@@ -0,0 +1,521 @@
(function () {
"use strict";
const ROOT_ID = "__web_to_pixso_panel_root__";
const SETTINGS_KEY = "webToPixsoSettings";
const MIN_CAPTURE_WIDTH = 320;
const MAX_CAPTURE_WIDTH = 3840;
const DEFAULT_SETTINGS = {
useProxy: false,
concurrency: "8",
captureMode: "mixed",
captureWidth: null
};
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 normalizeSettings(value = {}) {
const concurrency = String(value.concurrency || DEFAULT_SETTINGS.concurrency);
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: normalizeCaptureWidth(value.captureWidth, null)
};
}
function css() {
return `
:host {
all: initial;
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
.backdrop {
background: transparent;
inset: 0;
pointer-events: none;
position: fixed;
z-index: 2147483647;
}
.panel {
background: #fff;
border: 1px solid rgba(10, 10, 18, 0.14);
border-radius: 22px;
box-shadow: 0 18px 46px rgba(10, 10, 18, 0.18);
box-sizing: border-box;
color: #1a1a2e;
overflow: hidden;
pointer-events: auto;
position: fixed;
right: 28px;
top: 24px;
width: 320px;
}
.header {
align-items: center;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
padding: 16px 20px 15px;
}
.logo-title {
align-items: center;
display: flex;
gap: 10px;
}
.logo {
border-radius: 10px;
box-shadow: 0 4px 12px rgba(10, 10, 18, 0.12);
display: block;
height: 28px;
object-fit: cover;
width: 28px;
}
.title {
color: #1a1a2e;
font-size: 16px;
font-weight: 650;
line-height: 1;
}
.version-badge {
background: #f2f4ff;
border: 1px solid #dfe5ff;
border-radius: 999px;
color: #2450ff;
font-size: 10px;
font-weight: 600;
line-height: 1;
padding: 3px 6px;
white-space: nowrap;
}
button, select, input {
font: inherit;
}
.close-btn {
align-items: center;
background: transparent;
border: 0;
border-radius: 12px;
color: #999;
cursor: pointer;
display: flex;
font-size: 22px;
height: 28px;
justify-content: center;
line-height: 1;
transition: background 0.2s, color 0.2s;
width: 28px;
}
.close-btn:hover {
background: #f4f4f6;
color: #666;
}
.content {
padding: 20px;
}
.setting-row {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
.setting-label {
color: #1a1a2e;
font-size: 14px;
}
.setting-select {
appearance: none;
background: #fff;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-position: right 10px center;
background-repeat: no-repeat;
border: 1px solid #e4e4e4;
border-radius: 12px;
color: #1a1a2e;
cursor: pointer;
font-size: 14px;
min-width: 80px;
outline: none;
padding: 8px 30px 8px 13px;
}
.mode-select {
min-width: 116px;
}
.toggle-switch {
display: inline-block;
height: 26px;
position: relative;
width: 48px;
}
.toggle-switch input {
height: 0;
opacity: 0;
width: 0;
}
.toggle-slider {
background-color: #e4e4e4;
border-radius: 999px;
bottom: 0;
cursor: pointer;
left: 0;
position: absolute;
right: 0;
top: 0;
transition: 0.3s;
}
.toggle-slider::before {
background-color: #fff;
border-radius: 50%;
bottom: 3px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
content: "";
height: 20px;
left: 3px;
position: absolute;
transition: 0.3s;
width: 20px;
}
input:checked + .toggle-slider {
background-color: #1a1a2e;
}
input:checked + .toggle-slider::before {
transform: translateX(22px);
}
.width-input-wrap {
align-items: center;
background: #fff;
border: 1px solid #e4e4e4;
border-radius: 12px;
display: flex;
height: 36px;
min-width: 116px;
padding: 0 10px 0 12px;
}
.width-input {
background: transparent;
border: 0;
color: #1a1a2e;
font-size: 14px;
min-width: 0;
outline: none;
text-align: right;
width: 68px;
}
.width-input.invalid {
color: #ef4444;
}
.width-unit {
color: #999;
font-size: 12px;
margin-left: 5px;
}
.description {
color: #999;
font-size: 12px;
line-height: 1.6;
margin-bottom: 20px;
}
.capture-btn,
.select-btn {
border: 0;
border-radius: 12px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
padding: 14px 24px;
transition: background 0.2s, transform 0.2s;
width: 100%;
}
.capture-btn {
background: #1a1a2e;
color: #fff;
}
.capture-btn:hover {
background: #2d2d44;
transform: translateY(-1px);
}
.select-btn {
background: #f4f4f6;
color: #1a1a2e;
margin-top: 10px;
}
.select-btn:hover {
background: #ececf1;
transform: translateY(-1px);
}
.capture-btn:disabled,
.select-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
transform: none;
}
.capture-btn.success {
background: #10b981;
}
.capture-btn.error {
background: #ef4444;
}
.status {
margin-top: 16px;
padding: 12px 0 0;
}
.progress-bar {
background: #f0f0f0;
border-radius: 999px;
height: 4px;
margin-bottom: 8px;
overflow: hidden;
}
.progress-fill {
background: linear-gradient(90deg, #00d2ff, #7c3aed);
border-radius: 999px;
height: 100%;
transition: width 0.3s ease;
width: 0;
}
.status-text {
color: #666;
font-size: 12px;
}
.help-link {
align-items: center;
border: 1px solid #e6e8f2;
border-radius: 12px;
color: #2450ff;
display: flex;
font-size: 12px;
font-weight: 600;
justify-content: center;
margin-top: 16px;
padding: 10px 12px;
text-decoration: none;
transition: background 0.18s, border-color 0.18s, color 0.18s;
width: 100%;
}
.help-link:hover {
background: #f6f8ff;
border-color: #dfe5ff;
}
.footer {
align-items: center;
background: #fff;
border-top: 1px solid #f0f0f0;
color: #666;
display: flex;
font-size: 12px;
gap: 8px;
justify-content: space-between;
padding: 12px 20px;
}
.support-email {
color: #888;
font-size: 10px;
line-height: 1.35;
min-width: 0;
text-align: right;
text-decoration: none;
}
.support-email:hover {
color: #2450ff;
}
`;
}
function panelHtml() {
return `
<div class="backdrop">
<main class="panel" role="dialog" aria-label="Web to Pixso">
<div class="header">
<div class="logo-title">
<img src="${chrome.runtime.getURL("logo/plugin-logo.png")}" alt="" class="logo">
<span class="title">Web to Pixso</span>
<span class="version-badge">v1.1.1</span>
</div>
<button class="close-btn" type="button" aria-label="关闭">×</button>
</div>
<div class="content">
<div class="setting-row">
<span class="setting-label">采集模式</span>
<select class="setting-select mode-select" data-field="captureMode">
<option value="mixed">混合高保真</option>
<option value="editable">可编辑优先</option>
</select>
</div>
<div class="setting-row">
<span class="setting-label">跨域图片代理模式</span>
<label class="toggle-switch">
<input type="checkbox" data-field="useProxy">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">页面采集宽度</span>
<label class="width-input-wrap">
<input class="width-input" data-field="captureWidth" type="text" inputmode="numeric" autocomplete="off">
<span class="width-unit">px</span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">图片采集并发</span>
<select class="setting-select" data-field="concurrency">
<option value="4">4</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="10">10</option>
<option value="12">12</option>
<option value="16">16</option>
<option value="20">20</option>
<option value="infinite">无限</option>
</select>
</div>
<p class="description">页面采集宽度默认使用当前窗口宽度,可输入 320-3840px 触发响应式布局后采集。</p>
<button class="capture-btn" type="button">开始采集</button>
<button class="select-btn" type="button">打开页面浮窗</button>
<div class="status" hidden>
<div class="progress-bar" aria-hidden="true"><div class="progress-fill"></div></div>
<span class="status-text">准备中...</span>
</div>
<a class="help-link" href="https://z8qrcvi3n5.feishu.cn/wiki/RV8TwlhFyiGsEekQXk8cX5SHn6f" target="_blank" rel="noopener noreferrer">使用说明</a>
</div>
<div class="footer">
<span>by 大非</span>
<a class="support-email" href="mailto:270310136@qq.com">270310136@qq.com 给我发邮件哦,我光速改</a>
</div>
</main>
</div>
`;
}
function getField(root, name) {
return root.shadowRoot.querySelector(`[data-field="${name}"]`);
}
function sanitizeWidth(input, clamp = false) {
const digits = input.value.replace(/\D+/g, "");
input.value = digits;
const raw = Number.parseInt(digits, 10);
const invalid = Boolean(digits) && Number.isFinite(raw) && (raw < MIN_CAPTURE_WIDTH || raw > MAX_CAPTURE_WIDTH);
input.classList.toggle("invalid", invalid);
const normalized = normalizeCaptureWidth(digits, null);
if (clamp && digits) {
input.value = String(normalized);
input.classList.remove("invalid");
}
return normalized;
}
async function getSettings() {
const result = await chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS });
return normalizeSettings(result[SETTINGS_KEY]);
}
async function saveSettings(settings) {
await chrome.storage.local.set({ [SETTINGS_KEY]: normalizeSettings(settings) });
}
function readSettings(root, options = {}) {
const { clampWidth = false } = options;
return normalizeSettings({
captureMode: getField(root, "captureMode").value,
useProxy: getField(root, "useProxy").checked,
concurrency: getField(root, "concurrency").value,
captureWidth: sanitizeWidth(getField(root, "captureWidth"), clampWidth)
});
}
function setProgress(root, percent, text) {
const status = root.shadowRoot.querySelector(".status");
const fill = root.shadowRoot.querySelector(".progress-fill");
const statusText = root.shadowRoot.querySelector(".status-text");
status.hidden = false;
fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
statusText.textContent = text;
}
function setBusy(root, isBusy) {
const captureButton = root.shadowRoot.querySelector(".capture-btn");
const selectButton = root.shadowRoot.querySelector(".select-btn");
captureButton.disabled = isBusy;
selectButton.disabled = isBusy;
captureButton.classList.remove("success", "error");
captureButton.textContent = isBusy ? "采集中..." : "开始采集";
selectButton.textContent = isBusy ? "处理中..." : "打开页面浮窗";
}
function setResult(root, kind, text) {
const captureButton = root.shadowRoot.querySelector(".capture-btn");
captureButton.classList.remove("success", "error");
captureButton.classList.add(kind);
captureButton.textContent = text;
}
async function startCapture(root) {
setBusy(root, true);
setProgress(root, 12, "准备当前网页...");
const settings = readSettings(root, { clampWidth: true });
await saveSettings(settings);
try {
setProgress(root, 28, "注入采集脚本...");
const response = await chrome.runtime.sendMessage({
type: "PIXSO_CAPTURE_CURRENT_TAB",
options: settings
});
if (!response?.ok) throw new Error(response?.error || "采集失败");
const widthText = response.actualViewportWidth ? `已按 ${response.actualViewportWidth}px 采集` : "采集完成";
setProgress(root, 100, `${widthText}${response.filename || ""}`);
setResult(root, "success", "采集完成");
setTimeout(() => root.remove(), 900);
} catch (error) {
setProgress(root, 0, error.message || String(error));
setResult(root, "error", "采集失败");
setTimeout(() => setBusy(root, false), 2600);
}
}
async function openToolbar(root) {
const settings = readSettings(root, { clampWidth: true });
await saveSettings(settings);
root.remove();
window.__webToPixsoShowCaptureToolbar?.(settings);
}
async function bind(root) {
const settings = await getSettings();
getField(root, "captureMode").value = settings.captureMode;
getField(root, "useProxy").checked = settings.useProxy;
getField(root, "concurrency").value = settings.concurrency;
getField(root, "captureWidth").value = String(settings.captureWidth || Math.round(window.innerWidth || document.documentElement.clientWidth || 1440));
for (const field of ["captureMode", "useProxy", "concurrency"]) {
getField(root, field).addEventListener("change", () => saveSettings(readSettings(root)));
}
getField(root, "captureWidth").addEventListener("input", event => {
sanitizeWidth(event.currentTarget);
saveSettings(readSettings(root, { clampWidth: false }));
});
getField(root, "captureWidth").addEventListener("blur", event => {
sanitizeWidth(event.currentTarget, true);
saveSettings(readSettings(root, { clampWidth: true }));
});
root.shadowRoot.querySelector(".close-btn").addEventListener("click", () => root.remove());
root.shadowRoot.querySelector(".capture-btn").addEventListener("click", () => startCapture(root));
root.shadowRoot.querySelector(".select-btn").addEventListener("click", () => openToolbar(root));
}
window.__webToPixsoShowPanel = async function showPanel() {
document.getElementById(ROOT_ID)?.remove();
const root = document.createElement("div");
root.id = ROOT_ID;
const shadow = root.attachShadow({ mode: "open" });
shadow.innerHTML = `<style>${css()}</style>${panelHtml()}`;
document.documentElement.appendChild(root);
await bind(root);
};
})();