Compare commits
2 Commits
696abd2e05
...
f037897590
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f037897590 | ||
|
|
a5c5b0b38c |
@@ -146,3 +146,67 @@ export function collectPins(): PinData[] {
|
|||||||
const collector = new PinCollector();
|
const collector = new PinCollector();
|
||||||
return collector.collect();
|
return collector.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从花瓣 API 响应中的单个 pin 对象提取 PinData
|
||||||
|
* @param pin - /v3/boards/{id}/pins 返回的 pins 数组元素
|
||||||
|
*/
|
||||||
|
export function extractPinFromApi(pin: any): PinData {
|
||||||
|
return {
|
||||||
|
url: `/pins/${pin.pin_id}/`,
|
||||||
|
imgSmallSrc: pin.file?.url ?? '',
|
||||||
|
imgSrc: (pin.file?.url ?? '').replace(/_fw\d+webp|_png/, ''),
|
||||||
|
imgWidth: pin.file?.width ?? 0,
|
||||||
|
imgHeight: pin.file?.height ?? 0,
|
||||||
|
alt: pin.raw_text ?? '',
|
||||||
|
title: pin.raw_text ?? '',
|
||||||
|
author: parseAuthorFromRawText(pin.raw_text) ?? '',
|
||||||
|
time: formatTimestamp(pin.created_at) ?? '',
|
||||||
|
tags: extractTagsFromText(pin.raw_text),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: number): string {
|
||||||
|
if (!ts) return '';
|
||||||
|
const date = new Date(ts * 1000); // API 给的是秒,JS 需要毫秒
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - date.getTime();
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
if (hours < 24) return `${hours}小时`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days}天`;
|
||||||
|
return date.toLocaleDateString('zh-CN'); // 超过30天直接显示日期
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 raw_text 中解析作者名
|
||||||
|
*/
|
||||||
|
function parseAuthorFromRawText(rawText: string): string {
|
||||||
|
if (!rawText) return '';
|
||||||
|
const lines = rawText.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (lines.length === 0) return '';
|
||||||
|
const firstLine = lines[0];
|
||||||
|
// 格式: "北道(きたみち)かえる@Aiart" → 去掉 @xxx 后缀
|
||||||
|
const atIndex = firstLine.indexOf('@');
|
||||||
|
if (atIndex > 0) return firstLine.slice(0, atIndex).trim();
|
||||||
|
// 格式: "@无硫火花 的个人主页 - 微博" → 取 @ 后到空格前的用户名
|
||||||
|
if (atIndex === 0) {
|
||||||
|
const username = firstLine.slice(1).split(/\s/)[0];
|
||||||
|
return username || firstLine;
|
||||||
|
}
|
||||||
|
// 格式: "Z3zz_的照片 - 微相册"
|
||||||
|
return firstLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本中提取 #标签
|
||||||
|
*/
|
||||||
|
export function extractTagsFromText(text: string): string[] {
|
||||||
|
const tags: string[] = [];
|
||||||
|
const tagRegex = /#([\w\u4e00-\u9fff]+)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = tagRegex.exec(text)) !== null) {
|
||||||
|
tags.push(match[1]);
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
@@ -1,3 +1,88 @@
|
|||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
console.log('Hello background!', { id: browser.runtime.id });
|
console.log('Hello background!', { id: browser.runtime.id });
|
||||||
|
|
||||||
|
// 监听下载请求
|
||||||
|
browser.runtime.onMessage.addListener(async (msg) => {
|
||||||
|
if (msg.type === 'DOWNLOAD_PINS') {
|
||||||
|
const pins: PinData[] = msg.pins;
|
||||||
|
const concurrency: number = msg.concurrency || 3;
|
||||||
|
const result = await startBatchDownload(pins, concurrency);
|
||||||
|
// 通知 popup 下载完成
|
||||||
|
browser.runtime.sendMessage({
|
||||||
|
type: 'DOWNLOAD_DONE',
|
||||||
|
success: result.success,
|
||||||
|
failed: result.failed,
|
||||||
|
total: result.total,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 定义单个下载任务 (封装成 Promise)
|
||||||
|
async function downloadOne(pin: PinData): Promise<void> {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
// 处理文件名非法字符(清理文件名)
|
||||||
|
const safeName = (pin.title || 'image').replace(/[\n\r\t]/g, ' ').replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100);
|
||||||
|
const filename = `Pins/${safeName}.jpg`;
|
||||||
|
|
||||||
|
browser.downloads.download({
|
||||||
|
url: pin.imgSrc,
|
||||||
|
filename: filename,
|
||||||
|
headers: [{ name: 'Referer', value: 'https://huaban.com/' }],
|
||||||
|
conflictAction: 'uniquify'
|
||||||
|
}, (id) => {
|
||||||
|
if (!id) {
|
||||||
|
console.warn(`跳过: ${pin.title} (可能 URL 无效)`);
|
||||||
|
return resolve(); // 即使失败也要 resolve,否则会阻塞队列
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听下载状态,只有当下载真正完成(或中断)时才释放资源
|
||||||
|
const listener = (delta: Browser.downloads.DownloadDelta) => {
|
||||||
|
if (delta.id !== id) return;
|
||||||
|
|
||||||
|
if (delta.state && delta.state.current === 'complete') {
|
||||||
|
browser.downloads.onChanged.removeListener(listener);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
// 处理中断或取消的情况,防止队列卡死
|
||||||
|
else if (delta.state && delta.state.current === 'interrupted') {
|
||||||
|
browser.downloads.onChanged.removeListener(listener);
|
||||||
|
console.warn(`下载中断: ${pin.title}`, JSON.stringify(delta));
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
browser.downloads.onChanged.addListener(listener);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发调度器
|
||||||
|
export async function startBatchDownload(pins: PinData[], limit: number = 3) {
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const executing: Promise<void>[] = []; // 正在运行的任务池
|
||||||
|
|
||||||
|
for (const pin of pins) {
|
||||||
|
// 创建任务
|
||||||
|
const task = downloadOne(pin);
|
||||||
|
|
||||||
|
// 加入任务池
|
||||||
|
const wrapper = task.finally(() => {
|
||||||
|
const idx = executing.indexOf(wrapper);
|
||||||
|
if (idx !== -1) executing.splice(idx, 1);
|
||||||
|
});
|
||||||
|
executing.push(wrapper);
|
||||||
|
|
||||||
|
// 关键逻辑:如果池子满了(达到 limit),等待任意一个任务完成
|
||||||
|
if (executing.length >= limit) {
|
||||||
|
await Promise.race(executing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待剩余任务全部完成
|
||||||
|
await Promise.all(executing);
|
||||||
|
console.log("所有下载任务已处理完毕");
|
||||||
|
|
||||||
|
return { success, failed, total: pins.length };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { showPageMarker } from "@/components/page-marker";
|
|||||||
import { PinCollector } from "#imports";
|
import { PinCollector } from "#imports";
|
||||||
import { AutoLoader } from '@/components/autoLoader';
|
import { AutoLoader } from '@/components/autoLoader';
|
||||||
import type { PinData } from '#imports';
|
import type { PinData } from '#imports';
|
||||||
|
import { extractPinFromApi } from "@/components/pin-collector";
|
||||||
|
|
||||||
let loader: AutoLoader | null = null;
|
let loader: AutoLoader | null = null;
|
||||||
|
|
||||||
@@ -57,12 +58,12 @@ export default defineContentScript({
|
|||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
},
|
},
|
||||||
onData: async (items) => {
|
onData: async (items) => {
|
||||||
const newPins: PinData[] = items.map((pin: any) => extractPinData(pin));
|
const newPins: PinData[] = items.map((pin: any) => extractPinFromApi(pin));
|
||||||
// 读取已有数据,按 pin_id 去重合并
|
// 读取已有数据,按 pin_id 去重合并
|
||||||
const stored = await browser.storage.local.get('collectedPins') as { collectedPins?: PinData[] };
|
const stored = await browser.storage.local.get('collectedPins') as { collectedPins?: PinData[] };
|
||||||
const existing: PinData[] = stored.collectedPins || [];
|
const existing: PinData[] = stored.collectedPins || [];
|
||||||
const existingIds = new Set(existing.map((p) => p.pinId));
|
const existingUrls = new Set(existing.map((p) => p.url));
|
||||||
const merged = [...existing, ...newPins.filter((p) => !existingIds.has(p.pinId))];
|
const merged = [...existing, ...newPins.filter((p) => !existingUrls.has(p.url))];
|
||||||
await browser.storage.local.set({ collectedPins: merged });
|
await browser.storage.local.set({ collectedPins: merged });
|
||||||
console.log(`[content] 已写入 storage,累计 ${merged.length} 条`);
|
console.log(`[content] 已写入 storage,累计 ${merged.length} 条`);
|
||||||
},
|
},
|
||||||
@@ -92,16 +93,3 @@ function sayHello() {
|
|||||||
autoRemoveSeconds: 10,
|
autoRemoveSeconds: 10,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPinData(pin: any): PinData {
|
|
||||||
return {
|
|
||||||
pinId: pin.pin_id ?? '',
|
|
||||||
imgSmallSrc: pin.file?.url ?? '',
|
|
||||||
imgSrc: (pin.file?.url ?? '').replace(/_fw\d+webp|_png/, ''),
|
|
||||||
alt: pin.raw_text ?? '',
|
|
||||||
author: pin.user?.username ?? '',
|
|
||||||
time: pin.created_at ?? '',
|
|
||||||
tags: pin.tags?.map((t: any) => t.tag ?? t) ?? [],
|
|
||||||
url: `/pins/${pin.pin_id}/`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import type { PinData } from '../../components/pin-collector';
|
import type { PinData } from '../../components/pin-collector';
|
||||||
|
import { startBatchDownload } from '../background';
|
||||||
|
|
||||||
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
||||||
<div class="popup">
|
<div class="popup">
|
||||||
@@ -7,8 +8,8 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="collectBtn">📥 收集</button>
|
<button id="collectBtn">📥 收集</button>
|
||||||
<button id="clearBtn">🗑 清空</button>
|
<button id="clearBtn">🗑 清空</button>
|
||||||
<button id="start-btn">start-btn</button>
|
<button id="start-btn">▶ 开始加载</button>
|
||||||
<button id="stop-btn">stop-btn</button>
|
<button id="stop-btn">⏹ 停止</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
<div id="pinList"></div>
|
<div id="pinList"></div>
|
||||||
@@ -19,19 +20,18 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
|||||||
document.getElementById('collectBtn')!.addEventListener('click', async () => {
|
document.getElementById('collectBtn')!.addEventListener('click', async () => {
|
||||||
showStatus('收集中...', 'info');
|
showStatus('收集中...', 'info');
|
||||||
try {
|
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 browser.storage.local.set({ collectedPins: response.pins });
|
|
||||||
// renderPins(response.pins as PinData[]);
|
|
||||||
await loadAndRender();
|
await loadAndRender();
|
||||||
showStatus(`✅ 已收集 ${response.count} 个 Pin`, 'success');
|
|
||||||
|
// 下载
|
||||||
|
const result = (await browser.storage.local.get('collectedPins')) as {
|
||||||
|
collectedPins?: PinData[];
|
||||||
|
};
|
||||||
|
const pins: PinData[] = result.collectedPins || [];
|
||||||
|
if (pins.length > 0) {
|
||||||
|
showStatus(`开始下载 ${pins.length} 张...`, 'info');
|
||||||
|
browser.runtime.sendMessage({ type: 'DOWNLOAD_PINS', pins, concurrency: 3 });
|
||||||
|
} else {
|
||||||
|
showStatus('没有可下载的图片', 'error');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errMsg = e instanceof Error ? e.message : String(e);
|
const errMsg = e instanceof Error ? e.message : String(e);
|
||||||
@@ -121,4 +121,11 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
|||||||
`✅ 加载完成 · 共 ${msg.totalItems} 条`;
|
`✅ 加载完成 · 共 ${msg.totalItems} 条`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'DOWNLOAD_DONE') {
|
||||||
|
showStatus(
|
||||||
|
`✅ 下载完成: ${msg.success}/${msg.total}` +
|
||||||
|
(msg.failed > 0 ? ` (${msg.failed} 失败)` : ''),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,6 @@ import { defineConfig } from 'wxt';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
browser: "firefox",
|
browser: "firefox",
|
||||||
manifest: {
|
manifest: {
|
||||||
permissions: ['storage'],
|
permissions: ['storage', 'downloads'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user