diff --git a/components/page-marker.ts b/components/page-marker.ts new file mode 100644 index 0000000..2bb470f --- /dev/null +++ b/components/page-marker.ts @@ -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; + /** 几秒后自动消失,设为 0 则永久存在,默认 0 */ + autoRemoveSeconds?: number; + /** 是否允许用户点击关闭,默认 false */ + closable?: boolean; +} + +export class PageMarker { + private element: HTMLDivElement | null = null; + private timer: ReturnType | 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 = { + 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), + }; + + 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 { + 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; +} diff --git a/components/pin-collector.ts b/components/pin-collector.ts new file mode 100644 index 0000000..b5be84e --- /dev/null +++ b/components/pin-collector.ts @@ -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( + '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 { + 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 { + 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('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(); +} diff --git a/entrypoints/content.ts b/entrypoints/content.ts index 264a528..3f63259 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -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, + }); +} diff --git a/entrypoints/popup/main.ts b/entrypoints/popup/main.ts index 6266bf3..d1229b0 100644 --- a/entrypoints/popup/main.ts +++ b/entrypoints/popup/main.ts @@ -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('#app')!.innerHTML = ` -
- - - - - - -

WXT + TypeScript

-
- + `; -setupCounter(document.querySelector('#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 { + 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 = '

暂无收集的图片

'; + return; + } + listEl.innerHTML = pins + .map( + (pin) => ` +
+ ${escapeHtml(pin.alt)} +
+
${escapeHtml(pin.author || '未知作者')} · ${escapeHtml(pin.time || '')}
+
${pin.tags.map(t => `#${escapeHtml(t)}`).join(' ')}
+ 查看原图 +
+
+ ` + ) + .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; +} diff --git a/entrypoints/popup/style.css b/entrypoints/popup/style.css index e3160a8..53e221e 100644 --- a/entrypoints/popup/style.css +++ b/entrypoints/popup/style.css @@ -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; } diff --git a/wxt.config.ts b/wxt.config.ts index 1e2f53d..ddfe1d6 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -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'], + }, +});