Compare commits

..

5 Commits

Author SHA1 Message Date
meishibiezb
21f7cc7ff6 feat: 修改config使其能够提交到Chrome 2026-05-05 03:55:34 +08:00
meishibiezb
bbe3b9eb9d feat: 修改config使其能够提交到FireFox 2026-05-05 03:24:12 +08:00
meishibiezb
f037897590 feat: 实现批量下载功能 2026-05-05 02:47:36 +08:00
meishibiezb
a5c5b0b38c fixed: 现在能显示作者名称 2026-05-04 22:36:37 +08:00
meishibiezb
696abd2e05 feat: 增加了自动加载所有图片的逻辑 2026-05-02 04:19:32 +08:00
6 changed files with 574 additions and 18 deletions

299
components/autoLoader.ts Normal file
View File

@@ -0,0 +1,299 @@
// ============================================================
// AutoLoader v2 — 支持页码分页 + 游标分页
// ============================================================
// ---------- 类型定义 ----------
/** 分页模式 */
type PaginationMode = 'page' | 'cursor';
/** 游标配置 */
interface CursorConfig {
/** 游标参数名,如 "max" */
paramName: string;
/** 从响应项中提取游标值的路径,如 "pin_id" */
valuePath: string;
/** 首次请求的游标初始值(可选,不传则请求不带该参数) */
initialValue?: string;
}
interface AutoLoadConfig {
endpoint: string;
method?: 'GET' | 'POST';
/** 分页模式,默认 "page" */
paginationMode?: PaginationMode;
/** 页码模式下的参数paginationMode='page' 时生效) */
pageParamName?: string;
limitParamName?: string;
limit?: number;
maxPages?: number;
/** 游标模式下的配置paginationMode='cursor' 时生效) */
cursor?: CursorConfig;
/** 固定参数(每页都带) */
baseParams?: Record<string, string>;
/** 每次请求间隔(毫秒),默认 1000 */
interval?: number;
headers?: Record<string, string>;
credentials?: RequestCredentials;
itemsPath?: string;
onProgress?: (progress: LoadProgress) => void;
onError?: (error: Error, pageOrCursor: number | string) => void;
/** 每次请求拿到原始 items 后回调(可在回调中写入 storage */
onData?: (items: any[]) => void | Promise<void>;
}
interface LoadProgress {
requestCount: number;
totalItems: number;
newItems: number;
done: boolean;
/** 仅游标模式:上一页最后一个游标值 */
lastCursor?: string;
}
// ---------- 主类 ----------
export class AutoLoader {
private config: Required<Omit<AutoLoadConfig, 'cursor'>> & { cursor?: CursorConfig };
private requestCount: number;
private totalItems: number;
private active: boolean;
private abortController: AbortController | null;
private currentCursor: string | undefined;
constructor(config: AutoLoadConfig) {
this.config = {
endpoint: config.endpoint,
method: config.method ?? 'GET',
paginationMode: config.paginationMode ?? 'page',
pageParamName: config.pageParamName ?? 'page',
limitParamName: config.limitParamName ?? 'limit',
limit: config.limit ?? 20,
maxPages: config.maxPages ?? 100,
cursor: config.cursor,
baseParams: config.baseParams ?? {},
interval: config.interval ?? 1000,
headers: config.headers ?? {},
credentials: config.credentials ?? 'same-origin',
itemsPath: config.itemsPath ?? 'pins',
onProgress: config.onProgress ?? (() => {}),
onError: config.onError ?? (() => {}),
onData: config.onData ?? (() => {}),
};
this.requestCount = 0;
this.totalItems = 0;
this.active = false;
this.abortController = null;
this.currentCursor = this.config.cursor?.initialValue;
}
// -------- 公开方法 --------
async start(): Promise<void> {
if (this.active) return;
this.active = true;
this.abortController = new AbortController();
console.log('[AutoLoader] 开始加载...');
this.config.onProgress({ requestCount: 0, totalItems: 0, newItems: 0, done: false });
const isCursor = this.config.paginationMode === 'cursor';
const maxIterations = isCursor ? 9999 : this.config.maxPages;
for (let i = 1; i <= maxIterations; i++) {
if (!this.active) break;
try {
const url = this.buildUrl(i);
const newItems = await this.fetchOnce(url);
this.requestCount = i;
this.totalItems += newItems;
const done = newItems < (this.config.limit ?? 20);
this.config.onProgress({
requestCount: i,
totalItems: this.totalItems,
newItems,
done,
lastCursor: this.currentCursor,
});
if (newItems === 0) {
console.log(`[AutoLoader] 无数据,加载完成。共 ${this.totalItems}`);
break;
}
console.log(`[AutoLoader] 第 ${i} 次: +${newItems},累计 ${this.totalItems}`);
} catch (err) {
if (!this.active) break;
this.config.onError(
err instanceof Error ? err : new Error(String(err)),
isCursor ? (this.currentCursor ?? 'initial') : i
);
this.config.onProgress({
requestCount: i,
totalItems: this.totalItems,
newItems: 0,
done: false,
lastCursor: this.currentCursor,
});
}
if (this.active && i < maxIterations) {
await delay(this.config.interval);
}
}
this.active = false;
this.abortController = null;
console.log('[AutoLoader] 结束');
}
stop(): void {
this.active = false;
this.abortController?.abort();
this.abortController = null;
console.log('[AutoLoader] 已停止');
}
get isRunning(): boolean {
return this.active;
}
// -------- 内部方法 --------
private buildUrl(pageOrIndex: number): string {
const { endpoint, method, baseParams, paginationMode, pageParamName, limitParamName, limit, cursor } =
this.config;
const params: Record<string, string> = {
...baseParams,
};
if (paginationMode === 'page') {
params[pageParamName] = String(pageOrIndex);
params[limitParamName] = String(limit);
} else if (paginationMode === 'cursor') {
// 游标模式limit 放在 paramscursor 值由 buildUrl 时注入
params[limitParamName] = String(limit);
if (this.currentCursor) {
params[cursor!.paramName] = this.currentCursor;
}
}
if (method === 'GET') {
const qs = new URLSearchParams(params).toString();
return `${endpoint}${endpoint.includes('?') ? '&' : '?'}${qs}`;
}
return endpoint;
}
private async fetchOnce(url: string): Promise<number> {
const { method, headers, credentials, itemsPath, paginationMode, cursor } = this.config;
const fetchOptions: RequestInit = {
method,
headers: method === 'POST'
? { 'Content-Type': 'application/json', ...headers }
: headers,
credentials,
signal: this.abortController?.signal,
};
if (method === 'POST' && paginationMode === 'page') {
// POST 页码模式才传 body游标模式参数已在 URL
const qs = new URLSearchParams(url.split('?')[1] ?? '').toString();
const bodyParams: Record<string, string> = {};
new URLSearchParams(qs).forEach((v, k) => { bodyParams[k] = v; });
fetchOptions.body = JSON.stringify(bodyParams);
}
const response = await fetch(url, fetchOptions);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
const items = getByPath(json, itemsPath);
if (!Array.isArray(items)) {
console.warn(`[AutoLoader] 路径 "${itemsPath}" 不是数组,已停止`, json);
return 0;
}
// ★ 游标模式:更新 cursor 为最后一项的值
if (paginationMode === 'cursor' && cursor && items.length > 0) {
const lastItem = items[items.length - 1];
this.currentCursor = String(getByPath(lastItem, cursor.valuePath));
}
// 把原始 items 传出去
if (items.length > 0) {
await this.config.onData(items);
}
return items.length;
}
}
// ---------- 便捷工厂方法 ----------
/**
* 花瓣画板游标分页
*
* @param endpoint API 路径,如 "/v3/boards/75606715/pins"
* @param extra 额外固定参数(字段会自动 URL encode
*
* 使用示例:
* const loader = AutoLoader.forHuabanBoardCursor('/v3/boards/75606715/pins', {
* sort: 'seq',
* fields: 'pins:PIN|board:BOARD_DETAIL|check',
* });
* loader.start();
*/
export function forHuabanBoardCursor(
endpoint: string,
extra?: Record<string, string>
): AutoLoader {
return new AutoLoader({
endpoint,
method: 'GET',
paginationMode: 'cursor',
limitParamName: 'limit',
limit: 40,
interval: 1000,
credentials: 'same-origin',
itemsPath: 'pins',
cursor: {
paramName: 'max',
valuePath: 'pin_id',
// 首次不带 max
},
baseParams: {
sort: 'seq',
fields: 'pins:PIN|board:BOARD_DETAIL|check',
...extra,
},
});
}
// ---------- 工具函数 ----------
const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
function getByPath(obj: any, path: string): any {
return path.split('.').reduce((acc, key) => acc?.[key], obj);
}
export type { AutoLoadConfig, LoadProgress, PaginationMode, CursorConfig };

View File

@@ -146,3 +146,67 @@ export function collectPins(): PinData[] {
const collector = new PinCollector();
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;
}

View File

@@ -1,3 +1,88 @@
export default defineBackground(() => {
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 };
}

View File

@@ -1,15 +1,20 @@
import { showPageMarker } from "@/components/page-marker";
import { PinCollector } from "#imports";
import { AutoLoader } from '@/components/autoLoader';
import type { PinData } from '#imports';
import { extractPinFromApi } from "@/components/pin-collector";
let loader: AutoLoader | null = null;
export default defineContentScript({
matches: ['*://*.huaban.com/*'],
runAt: 'document_idle',
main() {
sayHello();
const collector = new PinCollector();
// 首次加载自动收集并存储
// 允许 popup 主动触发重新收集
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'COLLECT_PINS') {
@@ -18,6 +23,62 @@ export default defineContentScript({
return true; // 异步响应
}
});
// TODO: 首次加载自动收集并存储
browser.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.action === 'start') {
const id = location.pathname.match(/\/boards\/(\d+)/)?.[1] ?? '';
if (!id) {
console.warn('[content] 当前页面不是画板页,无法启动');
sendResponse({ ok: false, error: '不在画板页' });
return true;
}
loader = new AutoLoader({
endpoint: `https://huaban.com/v3/boards/${id}/pins`,
paginationMode: 'cursor',
limit: 40,
cursor: { paramName: 'max', valuePath: 'pin_id' },
interval: 1000,
itemsPath: 'pins',
baseParams: { sort: 'seq', fields: 'pins:PIN|board:BOARD_DETAIL|check' },
onProgress: (p) => {
browser.runtime.sendMessage({ type: 'progress', ...p });
},
onError: (err, pageOrCursor) => {
browser.runtime.sendMessage({
type: 'progress',
requestCount: -1,
totalItems: -1,
newItems: 0,
done: false,
error: err.message,
}).catch(() => { });
},
onData: async (items) => {
const newPins: PinData[] = items.map((pin: any) => extractPinFromApi(pin));
// 读取已有数据,按 pin_id 去重合并
const stored = await browser.storage.local.get('collectedPins') as { collectedPins?: PinData[] };
const existing: PinData[] = stored.collectedPins || [];
const existingUrls = new Set(existing.map((p) => p.url));
const merged = [...existing, ...newPins.filter((p) => !existingUrls.has(p.url))];
await browser.storage.local.set({ collectedPins: merged });
console.log(`[content] 已写入 storage累计 ${merged.length}`);
},
});
loader.start();
sendResponse({ ok: true });
return true;
}
if (msg.action === 'stop') {
loader?.stop();
sendResponse({ ok: true });
return true;
}
});
},
});
@@ -31,4 +92,4 @@ function sayHello() {
closable: true, // 用户可点击 × 关闭
autoRemoveSeconds: 10,
});
}
}

View File

@@ -1,5 +1,6 @@
import './style.css';
import type { PinData } from '../../components/pin-collector';
import { startBatchDownload } from '../background';
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div class="popup">
@@ -7,6 +8,8 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div class="actions">
<button id="collectBtn">📥 收集</button>
<button id="clearBtn">🗑 清空</button>
<button id="start-btn">▶ 开始加载</button>
<button id="stop-btn">⏹ 停止</button>
</div>
<div id="status"></div>
<div id="pinList"></div>
@@ -17,18 +20,18 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
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 browser.storage.local.set({ collectedPins: response.pins });
renderPins(response.pins as PinData[]);
showStatus(`✅ 已收集 ${response.count} 个 Pin`, 'success');
await loadAndRender();
// 下载
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) {
const errMsg = e instanceof Error ? e.message : String(e);
@@ -91,3 +94,38 @@ function escapeHtml(str: string): string {
div.textContent = str;
return div.innerHTML;
}
let activeTabId: number | null = null;
// 获取当前标签页 ID
browser.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
activeTabId = tab.id!;
document.getElementById('start-btn')?.addEventListener('click', () => {
browser.tabs.sendMessage(activeTabId!, { action: 'start' });
});
document.getElementById('stop-btn')?.addEventListener('click', () => {
browser.tabs.sendMessage(activeTabId!, { action: 'stop' });
});
});
// 接收进度回调
browser.runtime.onMessage.addListener(async (msg) => {
if (msg.type === 'progress') {
if (msg.error) {
document.getElementById('status')!.textContent = `❌ 错误: ${msg.error}`;
} else {
document.getElementById('status')!.textContent =
`${msg.requestCount} 次 · 共 ${msg.totalItems}`;
}
if (msg.done) {
await loadAndRender();
document.getElementById('status')!.textContent =
`✅ 加载完成 · 共 ${msg.totalItems}`;
}
}
if (msg.type === 'DOWNLOAD_DONE') {
showStatus(
`✅ 下载完成: ${msg.success}/${msg.total}` +
(msg.failed > 0 ? ` (${msg.failed} 失败)` : ''),
'success'
);
}
});

View File

@@ -2,8 +2,17 @@ import { defineConfig } from 'wxt';
// See https://wxt.dev/api/config.html
export default defineConfig({
browser: "firefox",
manifest: {
permissions: ['storage'],
browser: "firefox",
manifest: {
name: "Picture Collector",
description : "",
version: "0.0.1",
permissions: ['storage', 'downloads'],
browser_specific_settings: {
gecko: {
id: 'picture-collector@meishibiezb.xyz',
data_collection_permissions: { required: ['none'] }
}
}
},
});