Compare commits
7 Commits
a25145638f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21f7cc7ff6 | ||
|
|
bbe3b9eb9d | ||
|
|
f037897590 | ||
|
|
a5c5b0b38c | ||
|
|
696abd2e05 | ||
|
|
dd97df9ede | ||
|
|
a6b86934af |
299
components/autoLoader.ts
Normal file
299
components/autoLoader.ts
Normal 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 放在 params,cursor 值由 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 };
|
||||||
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;
|
||||||
|
}
|
||||||
212
components/pin-collector.ts
Normal file
212
components/pin-collector.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* 花瓣网 Pin 收集器 —— 自动提取页面中所有 Pin 卡片信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PinData {
|
||||||
|
/** 相对路径,如 "/pins/7118240882" */
|
||||||
|
url: string;
|
||||||
|
/** 图片缩略图地址 */
|
||||||
|
imgSmallSrc: 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');
|
||||||
|
|
||||||
|
const originalSrc = img.src.replace(/_fw\d+(webp)?/, '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: href,
|
||||||
|
imgSmallSrc: img.src,
|
||||||
|
imgSrc: originalSrc,
|
||||||
|
imgWidth: width,
|
||||||
|
imgHeight: height,
|
||||||
|
alt: altText,
|
||||||
|
title: titleText,
|
||||||
|
author,
|
||||||
|
time,
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷函数:直接返回当前页面的所有 Pin 数据
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,6 +1,95 @@
|
|||||||
|
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({
|
export default defineContentScript({
|
||||||
matches: ['*://*.google.com/*'],
|
matches: ['*://*.huaban.com/*'],
|
||||||
|
runAt: 'document_idle',
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
console.log('Hello content.');
|
sayHello();
|
||||||
|
|
||||||
|
const collector = new PinCollector();
|
||||||
|
|
||||||
|
// 允许 popup 主动触发重新收集
|
||||||
|
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
|
if (msg.type === 'COLLECT_PINS') {
|
||||||
|
const pins = collector.collect();
|
||||||
|
sendResponse({ success: true, count: pins.length, pins });
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,131 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import typescriptLogo from '@/assets/typescript.svg';
|
import type { PinData } from '../../components/pin-collector';
|
||||||
import wxtLogo from '/wxt.svg';
|
import { startBatchDownload } from '../background';
|
||||||
import { setupCounter } from '@/components/counter';
|
|
||||||
|
|
||||||
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
|
||||||
<div>
|
<div class="popup">
|
||||||
<a href="https://wxt.dev" target="_blank">
|
<h2>🎨 画板收集器</h2>
|
||||||
<img src="${wxtLogo}" class="logo" alt="WXT logo" />
|
<div class="actions">
|
||||||
</a>
|
<button id="collectBtn">📥 收集</button>
|
||||||
<a href="https://www.typescriptlang.org/" target="_blank">
|
<button id="clearBtn">🗑 清空</button>
|
||||||
<img src="${typescriptLogo}" class="logo vanilla" alt="TypeScript logo" />
|
<button id="start-btn">▶ 开始加载</button>
|
||||||
</a>
|
<button id="stop-btn">⏹ 停止</button>
|
||||||
<h1>WXT + TypeScript</h1>
|
|
||||||
<div class="card">
|
|
||||||
<button id="counter" type="button"></button>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="read-the-docs">
|
<div id="status"></div>
|
||||||
Click on the WXT and TypeScript logos to learn more
|
<div id="pinList"></div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!);
|
// 按钮事件
|
||||||
|
document.getElementById('collectBtn')!.addEventListener('click', async () => {
|
||||||
|
showStatus('收集中...', 'info');
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
showStatus(`错误: ${errMsg}`, '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.imgSmallSrc}" 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>
|
||||||
|
<a class="pin-link" href="${escapeHtml(pin.imgSrc)}" 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 = ''), 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 {
|
body {
|
||||||
|
width: 360px;
|
||||||
margin: 0;
|
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;
|
display: flex;
|
||||||
place-items: center;
|
gap: 8px;
|
||||||
min-width: 320px;
|
margin-bottom: 10px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border-radius: 8px;
|
flex: 1;
|
||||||
border: 1px solid transparent;
|
padding: 6px 0;
|
||||||
padding: 0.6em 1.2em;
|
border: none;
|
||||||
font-size: 1em;
|
border-radius: 4px;
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.25s;
|
font-size: 13px;
|
||||||
}
|
font-weight: 500;
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
#collectBtn {
|
||||||
:root {
|
background: #4caf50;
|
||||||
color: #213547;
|
color: #fff;
|
||||||
background-color: #ffffff;
|
}
|
||||||
}
|
#collectBtn:hover {
|
||||||
a:hover {
|
background: #43a047;
|
||||||
color: #747bff;
|
}
|
||||||
}
|
|
||||||
button {
|
#clearBtn {
|
||||||
background-color: #f9f9f9;
|
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,18 @@
|
|||||||
import { defineConfig } from 'wxt';
|
import { defineConfig } from 'wxt';
|
||||||
|
|
||||||
// See https://wxt.dev/api/config.html
|
// See https://wxt.dev/api/config.html
|
||||||
export default defineConfig({});
|
export default defineConfig({
|
||||||
|
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'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user