feat: 初步开发一些特性
This commit is contained in:
144
components/page-marker.ts
Normal file
144
components/page-marker.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 页面标记组件 —— 在网页上显示一个固定位置的“已锁定”标记
|
||||
*/
|
||||
|
||||
export interface PageMarkerOptions {
|
||||
/** 标记显示的文本,默认为“🔒 已锁定” */
|
||||
text?: string;
|
||||
/** 位置预设:'top-right' | 'top-left' | 'bottom-right' | 'bottom-left',默认 'top-right' */
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
||||
/** 背景色,默认 rgba(0,0,0,0.75) */
|
||||
backgroundColor?: string;
|
||||
/** 字体颜色,默认 #fff */
|
||||
color?: string;
|
||||
/** 自定义样式,会与默认样式合并 */
|
||||
customStyle?: Partial<CSSStyleDeclaration>;
|
||||
/** 几秒后自动消失,设为 0 则永久存在,默认 0 */
|
||||
autoRemoveSeconds?: number;
|
||||
/** 是否允许用户点击关闭,默认 false */
|
||||
closable?: boolean;
|
||||
}
|
||||
|
||||
export class PageMarker {
|
||||
private element: HTMLDivElement | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private options: PageMarkerOptions = {}) {}
|
||||
|
||||
/**
|
||||
* 在页面上显示标记
|
||||
*/
|
||||
show(): void {
|
||||
if (this.element) this.hide();
|
||||
|
||||
const {
|
||||
text = '🔒 已锁定',
|
||||
position = 'top-right',
|
||||
backgroundColor = 'rgba(0,0,0,0.75)',
|
||||
color = '#fff',
|
||||
autoRemoveSeconds = 0,
|
||||
closable = false,
|
||||
customStyle = {},
|
||||
} = this.options;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.textContent = text;
|
||||
el.title = '此页面已被插件锁定';
|
||||
|
||||
const baseStyle: Record<string, string> = {
|
||||
position: 'fixed',
|
||||
zIndex: '99999',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
fontWeight: '600',
|
||||
backgroundColor,
|
||||
color,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
pointerEvents: closable ? 'auto' : 'none',
|
||||
userSelect: 'none',
|
||||
transition: 'opacity 0.2s ease',
|
||||
...positionStyles(position),
|
||||
...(customStyle as Record<string, string>),
|
||||
};
|
||||
|
||||
Object.assign(el.style, baseStyle);
|
||||
|
||||
// 可关闭按钮
|
||||
if (closable) {
|
||||
const closeBtn = document.createElement('span');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.style.cssText = 'margin-left:8px;cursor:pointer;font-weight:bold;opacity:0.7';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.hide();
|
||||
});
|
||||
el.appendChild(closeBtn);
|
||||
}
|
||||
|
||||
document.body.appendChild(el);
|
||||
this.element = el;
|
||||
|
||||
if (autoRemoveSeconds > 0) {
|
||||
this.timer = setTimeout(() => this.hide(), autoRemoveSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏/移除标记
|
||||
*/
|
||||
hide(): void {
|
||||
if (this.element) {
|
||||
this.element.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
this.element?.remove();
|
||||
this.element = null;
|
||||
}, 200);
|
||||
}
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新标记文本
|
||||
*/
|
||||
updateText(text: string): void {
|
||||
if (!this.element) return;
|
||||
// 保留关闭按钮
|
||||
const closeBtn = this.element.querySelector('span');
|
||||
this.element.childNodes.forEach(node => {
|
||||
if (node !== closeBtn) node.remove();
|
||||
});
|
||||
this.element.prepend(document.createTextNode(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记当前是否已显示
|
||||
*/
|
||||
get isVisible(): boolean {
|
||||
return !!this.element;
|
||||
}
|
||||
}
|
||||
|
||||
function positionStyles(pos: string): Record<string, string> {
|
||||
const offset = '12px';
|
||||
switch (pos) {
|
||||
case 'top-right': return { top: offset, right: offset };
|
||||
case 'top-left': return { top: offset, left: offset };
|
||||
case 'bottom-right': return { bottom: offset, right: offset };
|
||||
case 'bottom-left': return { bottom: offset, left: offset };
|
||||
default: return { top: offset, right: offset };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷函数:直接显示一个简单标记
|
||||
*/
|
||||
export function showPageMarker(options?: PageMarkerOptions): PageMarker {
|
||||
const marker = new PageMarker(options);
|
||||
marker.show();
|
||||
return marker;
|
||||
}
|
||||
143
components/pin-collector.ts
Normal file
143
components/pin-collector.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 花瓣网 Pin 收集器 —— 自动提取页面中所有 Pin 卡片信息
|
||||
*/
|
||||
|
||||
export interface PinData {
|
||||
/** 相对路径,如 "/pins/7118240882" */
|
||||
url: string;
|
||||
/** 图片源地址 */
|
||||
imgSrc: string;
|
||||
/** 图片原始宽度 */
|
||||
imgWidth: number;
|
||||
/** 图片原始高度 */
|
||||
imgHeight: number;
|
||||
/** alt 原始文本 */
|
||||
alt: string;
|
||||
/** title 原始文本 */
|
||||
title: string;
|
||||
/** 提取的作者名 */
|
||||
author?: string;
|
||||
/** 提取的发布时间,如 "15小时" */
|
||||
time?: string;
|
||||
/** 提取的标签,不含 # 号 */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export class PinCollector {
|
||||
private pins: PinData[] = [];
|
||||
|
||||
/**
|
||||
* 扫描当前文档,收集所有 Pin 数据
|
||||
* @returns 收集到的 Pin 数组
|
||||
*/
|
||||
collect(): PinData[] {
|
||||
this.pins = [];
|
||||
const anchors = document.querySelectorAll<HTMLAnchorElement>(
|
||||
'a.__7D5D_BHJ'
|
||||
);
|
||||
|
||||
anchors.forEach((anchor) => {
|
||||
try {
|
||||
const pin = this.parsePin(anchor);
|
||||
if (pin) this.pins.push(pin);
|
||||
} catch (e) {
|
||||
console.warn('解析 Pin 失败', e);
|
||||
}
|
||||
});
|
||||
|
||||
return this.pins;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近一次收集的结果(不重新扫描)
|
||||
*/
|
||||
getPins(): PinData[] {
|
||||
return this.pins;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将收集到的 Pin 保存到 browser.storage.local
|
||||
* @param key 存储键名,默认 "collectedPins"
|
||||
*/
|
||||
async saveToStorage(key = 'collectedPins'): Promise<void> {
|
||||
await browser.storage.local.set({ [key]: this.pins });
|
||||
console.log(`已保存 ${this.pins.length} 个 Pin 到存储`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 browser.storage.local 加载之前保存的 Pin
|
||||
* @param key 存储键名
|
||||
* @returns 加载到的 Pin 数组
|
||||
*/
|
||||
async loadFromStorage(key = 'collectedPins'): Promise<PinData[]> {
|
||||
const result = await browser.storage.local.get(key);
|
||||
if (result[key]) {
|
||||
this.pins = result[key] as PinData[];
|
||||
}
|
||||
return this.pins;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单个 a 元素为 PinData
|
||||
*/
|
||||
private parsePin(anchor: HTMLAnchorElement): PinData | null {
|
||||
const href = anchor.getAttribute('href');
|
||||
if (!href) return null;
|
||||
|
||||
const img = anchor.querySelector<HTMLImageElement>('img.hb-image');
|
||||
if (!img) return null;
|
||||
|
||||
const altText = img.getAttribute('alt') || '';
|
||||
const titleText = img.getAttribute('title') || '';
|
||||
const fullText = altText || titleText;
|
||||
const parts = fullText
|
||||
.split('\n')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// 解析作者和发布时间
|
||||
let author: string | undefined;
|
||||
let time: string | undefined;
|
||||
|
||||
if (parts.length >= 1) {
|
||||
// 第一行格式通常为:作者名 @username
|
||||
const authorMatch = parts[0].match(/^(.+?)\s*@/);
|
||||
author = authorMatch ? authorMatch[1].trim() : parts[0];
|
||||
}
|
||||
|
||||
if (parts.length >= 2 && parts[1].startsWith('·')) {
|
||||
time = parts[1].replace(/^·\s*/, '').trim();
|
||||
}
|
||||
|
||||
// 提取标签 #xxx (支持中文)
|
||||
const tags: string[] = [];
|
||||
const tagRegex = /#([\w\u4e00-\u9fff]+)/g;
|
||||
let match;
|
||||
while ((match = tagRegex.exec(fullText)) !== null) {
|
||||
tags.push(match[1]);
|
||||
}
|
||||
|
||||
const width = parseFloat(img.getAttribute('width') || '0');
|
||||
const height = parseFloat(img.getAttribute('height') || '0');
|
||||
|
||||
return {
|
||||
url: href,
|
||||
imgSrc: img.src,
|
||||
imgWidth: width,
|
||||
imgHeight: height,
|
||||
alt: altText,
|
||||
title: titleText,
|
||||
author,
|
||||
time,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷函数:直接返回当前页面的所有 Pin 数据
|
||||
*/
|
||||
export function collectPins(): PinData[] {
|
||||
const collector = new PinCollector();
|
||||
return collector.collect();
|
||||
}
|
||||
@@ -1,6 +1,37 @@
|
||||
import { showPageMarker } from "@/components/page-marker";
|
||||
import { PinCollector } from "#imports";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['*://*.google.com/*'],
|
||||
matches: ['*://*.huaban.com/*'],
|
||||
runAt: 'document_idle',
|
||||
main() {
|
||||
console.log('Hello content.');
|
||||
sayHello();
|
||||
|
||||
const collector = new PinCollector();
|
||||
// 首次加载自动收集并存储
|
||||
const pins = collector.collect();
|
||||
if (pins.length > 0) {
|
||||
collector.saveToStorage();
|
||||
}
|
||||
// 允许 popup 主动触发重新收集
|
||||
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === 'COLLECT_PINS') {
|
||||
const updatedPins = collector.collect();
|
||||
sendResponse({ success: true, count: updatedPins.length });
|
||||
return true; // 异步响应
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function sayHello() {
|
||||
console.log('检测到花瓣网!', window.location.href);
|
||||
console.log('这个插件是一个用于自动收集图片的插件。');
|
||||
showPageMarker({
|
||||
text: '检测到花瓣网!插件已启动',
|
||||
position: 'top-right',
|
||||
backgroundColor: 'rgba(34, 139, 34, 0.9)',
|
||||
closable: true, // 用户可点击 × 关闭
|
||||
autoRemoveSeconds: 10,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,24 +1,90 @@
|
||||
import './style.css';
|
||||
import typescriptLogo from '@/assets/typescript.svg';
|
||||
import wxtLogo from '/wxt.svg';
|
||||
import { setupCounter } from '@/components/counter';
|
||||
import type { PinData } from '../../components/pin-collector';
|
||||
|
||||
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
||||
<div>
|
||||
<a href="https://wxt.dev" target="_blank">
|
||||
<img src="${wxtLogo}" class="logo" alt="WXT logo" />
|
||||
</a>
|
||||
<a href="https://www.typescriptlang.org/" target="_blank">
|
||||
<img src="${typescriptLogo}" class="logo vanilla" alt="TypeScript logo" />
|
||||
</a>
|
||||
<h1>WXT + TypeScript</h1>
|
||||
<div class="card">
|
||||
<button id="counter" type="button"></button>
|
||||
<div class="popup">
|
||||
<h2>🎨 画板收集器</h2>
|
||||
<div class="actions">
|
||||
<button id="collectBtn">📥 收集</button>
|
||||
<button id="clearBtn">🗑 清空</button>
|
||||
</div>
|
||||
<p class="read-the-docs">
|
||||
Click on the WXT and TypeScript logos to learn more
|
||||
</p>
|
||||
<div id="status"></div>
|
||||
<div id="pinList"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!);
|
||||
// 按钮事件
|
||||
document.getElementById('collectBtn')!.addEventListener('click', async () => {
|
||||
showStatus('收集中...', 'info');
|
||||
try {
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab?.id) {
|
||||
showStatus('无法获取当前标签页', 'error');
|
||||
return;
|
||||
}
|
||||
const response = await browser.tabs.sendMessage(tab.id, {
|
||||
type: 'COLLECT_PINS',
|
||||
});
|
||||
if (response?.success) {
|
||||
await loadAndRender();
|
||||
showStatus(`✅ 已收集 ${response.count} 个 Pin`, 'success');
|
||||
}
|
||||
} catch {
|
||||
showStatus('请在花瓣网页面使用此功能', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('clearBtn')!.addEventListener('click', async () => {
|
||||
await browser.storage.local.remove('collectedPins');
|
||||
renderPins([]);
|
||||
showStatus('已清空', 'info');
|
||||
});
|
||||
|
||||
// 首次打开 popup 时加载并渲染
|
||||
loadAndRender();
|
||||
|
||||
// ---- 辅助函数 ----
|
||||
|
||||
async function loadAndRender(): Promise<void> {
|
||||
const result = (await browser.storage.local.get('collectedPins')) as {
|
||||
collectedPins?: PinData[];
|
||||
};
|
||||
const pins: PinData[] = result.collectedPins || [];
|
||||
renderPins(pins);
|
||||
}
|
||||
|
||||
|
||||
function renderPins(pins: PinData[]): void {
|
||||
const listEl = document.getElementById('pinList')!;
|
||||
if (pins.length === 0) {
|
||||
listEl.innerHTML = '<p class="empty">暂无收集的图片</p>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = pins
|
||||
.map(
|
||||
(pin) => `
|
||||
<div class="pin-item">
|
||||
<img src="${pin.imgSrc}" loading="lazy" alt="${escapeHtml(pin.alt)}" />
|
||||
<div class="pin-info">
|
||||
<div class="pin-author">${escapeHtml(pin.author || '未知作者')} · ${escapeHtml(pin.time || '')}</div>
|
||||
<div class="pin-tags">${pin.tags.map(t => `#${escapeHtml(t)}`).join(' ')}</div>
|
||||
<a class="pin-link" href="https://huaban.com${pin.url}" target="_blank">查看原图</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function showStatus(msg: string, type: 'info' | 'success' | 'error'): void {
|
||||
const statusEl = document.getElementById('status')!;
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = `status ${type}`;
|
||||
setTimeout(() => (statusEl.textContent = ''), 3000);
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
@@ -1,97 +1,128 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 360px;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #54bc4ae0);
|
||||
}
|
||||
.logo.vanilla:hover {
|
||||
filter: drop-shadow(0 0 2em #3178c6aa);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
#collectBtn {
|
||||
background: #4caf50;
|
||||
color: #fff;
|
||||
}
|
||||
#collectBtn:hover {
|
||||
background: #43a047;
|
||||
}
|
||||
|
||||
#clearBtn {
|
||||
background: #eee;
|
||||
color: #555;
|
||||
}
|
||||
#clearBtn:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.status {
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.status.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.status.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
.status.info {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
#pinList {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.pin-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.pin-item img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pin-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pin-author {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pin-tags {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.pin-link {
|
||||
font-size: 11px;
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
}
|
||||
.pin-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { defineConfig } from 'wxt';
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({});
|
||||
export default defineConfig({
|
||||
browser: "firefox",
|
||||
manifest: {
|
||||
permissions: ['storage'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user