212 lines
5.8 KiB
TypeScript
212 lines
5.8 KiB
TypeScript
/**
|
||
* 花瓣网 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;
|
||
} |