Refactor storage management and error handling; implement task queue for better task execution control

This commit is contained in:
johnathan 2025-05-14 11:48:27 +08:00
parent 804c2f60c6
commit 74c4f8bb28
8 changed files with 329 additions and 149 deletions

View File

@ -1,10 +1,9 @@
import { StorageSerializers } from '@vueuse/core';
import { pausableWatch, toValue, tryOnScopeDispose } from '@vueuse/shared';
import { debounceFilter, pausableWatch, tryOnScopeDispose } from '@vueuse/shared';
import { ref, shallowRef } from 'vue-demi';
import { storage } from 'webextension-polyfill';
import type { StorageLikeAsync, UseStorageAsyncOptions } from '@vueuse/core';
import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/shared';
import type { RemovableRef, StorageLikeAsync, UseStorageAsyncOptions } from '@vueuse/core';
import type { Ref } from 'vue-demi';
import type { Storage } from 'webextension-polyfill';
@ -50,95 +49,79 @@ const storageInterface: StorageLikeAsync = {
/**
* https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorageAsync/index.ts
*
* @param key
* @param initialValue
* @param options
* A custom hook for managing state with Web Extension storage.
* This function allows you to synchronize a reactive state with the Web Extension storage API.
*
* @param key - The key under which the value is stored in the Web Extension storage.
* @param initialValue - The initial value to be used if no value is found in storage.
* This can be a reactive reference or a plain value.
* @param options - Optional settings for the storage behavior.
*
* @returns A reactive reference to the stored value. The reference can be
* removed from the storage by calling its `remove` method.
*
* @example
* const myValue = useWebExtensionStorage2('myKey', 'defaultValue', {
* shallow: true,
* listenToStorageChanges: true,
* });
*
* // myValue is now a reactive reference that syncs with the Web Extension storage.
*/
export function useWebExtensionStorage<T>(
key: string,
initialValue: MaybeRefOrGetter<T>,
options: WebExtensionStorageOptions<T> = {},
initialValue: MaybeRef<T>,
options: Pick<
WebExtensionStorageOptions<T>,
'shallow' | 'serializer' | 'listenToStorageChanges' | 'flush' | 'deep' | 'eventFilter'
> = {},
): RemovableRef<T> {
const {
shallow = false,
listenToStorageChanges = true,
flush = 'pre',
deep = true,
listenToStorageChanges = true,
writeDefaults = true,
mergeDefaults = false,
shallow,
eventFilter,
onError = (e) => {
console.error(e);
},
eventFilter = debounceFilter(1000),
} = options;
const rawInit: T = toValue(initialValue);
const rawInit = unref(initialValue);
const type = guessSerializerType(rawInit);
const data = (shallow ? shallowRef : ref)(rawInit) as Ref<T>;
const data = (shallow ? shallowRef : ref)(initialValue) as Ref<T>;
const serializer = options.serializer ?? StorageSerializers[type];
async function read(event?: { key: string; newValue: string | null }) {
if (event && event.key !== key) return;
try {
const rawValue = event
? event.newValue
: await storageInterface.getItem(key);
if (rawValue == null) {
data.value = rawInit;
if (writeDefaults && rawInit !== null)
await storageInterface.setItem(key, await serializer.write(rawInit));
} else if (mergeDefaults) {
const value = (await serializer.read(rawValue)) as T;
if (typeof mergeDefaults === 'function')
data.value = mergeDefaults(value, rawInit);
else if (type === 'object' && !Array.isArray(value))
data.value = {
...(rawInit as Record<keyof unknown, unknown>),
...(value as Record<keyof unknown, unknown>),
} as T;
else data.value = value;
} else {
data.value = (await serializer.read(rawValue)) as T;
}
} catch (error) {
onError(error);
const pullFromStorage = async () => {
const rawItem = await storageInterface.getItem(key);
if (rawItem) {
const item = serializer.read(rawItem) as T;
data.value = item;
}
}
};
void read();
async function write() {
try {
await (data.value == null
? storageInterface.removeItem(key)
: storageInterface.setItem(key, await serializer.write(data.value)));
} catch (error) {
onError(error);
const pushToStorage = async () => {
const newVal = toRaw(unref(data));
if (newVal === null) {
await storageInterface.removeItem(key);
} else {
const item = await serializer.write(newVal);
await storageInterface.setItem(key, item);
}
}
};
const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(
data,
write,
{
flush,
deep,
eventFilter,
},
);
const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(data, pushToStorage, {
flush,
deep,
eventFilter,
});
if (listenToStorageChanges) {
const listener = async (changes: Record<string, Storage.StorageChange>) => {
if (!(key in changes)) {
return;
}
try {
pauseWatch();
for (const [key, change] of Object.entries(changes)) {
await read({
key,
newValue: change.newValue as string | null,
});
}
await pullFromStorage();
} finally {
resumeWatch();
}
@ -151,5 +134,7 @@ export function useWebExtensionStorage<T>(
});
}
return data as RemovableRef<T>;
pullFromStorage(); // Init
return data;
}

View File

@ -0,0 +1,27 @@
import Emittery from 'emittery';
export interface ErrorChannelContainer {
channel: Emittery<{ error: { message: string } }>;
}
/**
* Process unknown errors.
*/
export function withErrorHandling(
target: (this: ErrorChannelContainer, ...args: any[]) => Promise<any>,
_context: ClassMethodDecoratorContext,
): (this: ErrorChannelContainer, ...args: any[]) => Promise<any> {
// target 就是当前被装饰的 class 方法
const originalMethod = target;
// 定义一个新方法
const decoratedMethod = async function (this: ErrorChannelContainer, ...args: any[]) {
try {
return await originalMethod.call(this, ...args); // 调用原有方法
} catch (error) {
this.channel.emit('error', { message: `发生未知错误:${error}` });
throw error;
}
};
// 返回装饰后的方法
return decoratedMethod;
}

View File

@ -2,34 +2,14 @@ import Emittery from 'emittery';
import type { AmazonDetailItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types';
import type { Tabs } from 'webextension-polyfill';
import { exec } from '../execute-script';
/**
* Process unknown errors.
*/
function withErrorHandling(
target: (this: AmazonPageWorker, ...args: any[]) => Promise<any>,
_context: ClassMethodDecoratorContext,
): (this: AmazonPageWorker, ...args: any[]) => Promise<any> {
// target 就是当前被装饰的 class 方法
const originalMethod = target;
// 定义一个新方法
const decoratedMethod = async function (this: AmazonPageWorker, ...args: any[]) {
try {
return await originalMethod.call(this, ...args); // 调用原有方法
} catch (error) {
this.channel.emit('error', { message: `发生未知错误:${error}` });
throw error;
}
};
// 返回装饰后的方法
return decoratedMethod;
}
import { TaskController, TaskQueue, taskUnit } from '../task-queue';
import { withErrorHandling } from '../error-handler';
/**
* AmazonPageWorkerImpl can run on background & sidepanel & popup,
* **can't** run on content script!
*/
class AmazonPageWorkerImpl implements AmazonPageWorker {
class AmazonPageWorkerImpl implements AmazonPageWorker, TaskController {
//#region Singleton
private static _instance: AmazonPageWorker | null = null;
public static getInstance() {
@ -47,9 +27,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
readonly channel = new Emittery<AmazonPageWorkerEvents>();
/**
* The signal to interrupt the current operation.
* The Task queue
*/
private _isCancel = false;
readonly taskQueue = new TaskQueue();
private async getCurrentTab(): Promise<Tabs.Tab> {
const tab = await browser.tabs
@ -173,6 +153,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}
@withErrorHandling
@taskUnit
public async doSearch(keywords: string): Promise<string> {
const url = new URL('https://www.amazon.com/s');
url.searchParams.append('k', keywords);
@ -189,14 +170,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}
@withErrorHandling
@taskUnit
public async wanderSearchPage(): Promise<void> {
const tab = await this.getCurrentTab();
this._isCancel = false;
const stop = this.channel.on('error', async (_: unknown): Promise<void> => {
this._isCancel = true;
});
let offset = 0;
while (!this._isCancel) {
while (true) {
const { hasNextPage, data } = await this.wanderSearchSinglePage(tab);
const keywords = new URL(tab.url!).searchParams.get('k')!;
const objs = data.map((r, i) => ({
@ -212,12 +190,12 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
break;
}
}
this._isCancel = false;
this.channel.off('error', stop);
return new Promise((resolve) => setTimeout(resolve, 1000));
}
@withErrorHandling
@taskUnit
public async wanderDetailPage(entry: string): Promise<void> {
//#region Initial Meta Info
const params = { asin: '', url: '' };
@ -244,7 +222,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
window.scrollBy(0, ~~(Math.random() * 500) + 500);
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50));
const targetNode = document.querySelector(
'#prodDetails:has(td), #detailBulletsWrapper_feature_div:has(li)',
'#prodDetails:has(td), #detailBulletsWrapper_feature_div:has(li), .av-page-desktop',
);
if (targetNode && document.readyState !== 'loading') {
targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
@ -303,19 +281,19 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
});
if (rawRankingText) {
const info: Pick<AmazonDetailItem, 'category1' | 'category2'> = {};
let statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)?.[0];
let statement = /#[0-9,]+\sin\s\S[\s\w',\.&\(\)]+/.exec(rawRankingText)?.[0];
if (statement) {
const name = /(?<=in\s).+(?=\s\(See)/.exec(statement)?.[0] || null;
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null;
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replaceAll(',', '')) || null;
if (name && rank) {
info['category1'] = { name, rank };
}
rawRankingText = rawRankingText.replace(statement, '');
}
statement = /#[0-9,]+\sin\s\S[\s\w&\(\)]+/.exec(rawRankingText)?.[0];
statement = /#[0-9,]+\sin\s\S[\s\w',\.&\(\)]+/.exec(rawRankingText)?.[0];
if (statement) {
const name = /(?<=in\s).+/.exec(statement)?.[0].replace(/[\s]+$/, '') || null;
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replace(',', '')) || null;
const rank = Number(/(?<=#)[0-9,]+/.exec(statement)?.[0].replaceAll(',', '')) || null;
if (name && rank) {
info['category2'] = { name, rank };
}
@ -376,7 +354,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
}
public async stop(): Promise<void> {
this._isCancel = true;
this.taskQueue.clear();
}
}

View File

@ -1,4 +1,5 @@
import type Emittery from 'emittery';
import { TaskQueue } from '../task-queue';
type AmazonSearchItem = {
keywords: string;
@ -16,7 +17,7 @@ type AmazonDetailItem = {
ratingCount?: number;
category1?: { name: string; rank: number };
category2?: { name: string; rank: number };
imageUrls: string[];
imageUrls?: string[];
};
type AmazonItem = AmazonSearchItem & Partial<AmazonDetailItem> & { hasDetail: boolean };
@ -71,7 +72,7 @@ interface AmazonPageWorker {
* Browsing goods detail page and collect target information.
* @param entry Product link or Amazon Standard Identification Number.
*/
wanderDetailPage(entry: string): Promise<void>;
wanderDetailPage(entry: string | string[]): Promise<void>;
/**
* Stop the worker.

View File

@ -5,58 +5,54 @@ export const keywordsList = useWebExtensionStorage<string[]>('keywordsList', [''
export const asinInputText = useWebExtensionStorage<string>('asinInputText', '');
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('itemList', []);
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []);
export const detailItems = useWebExtensionStorage<{ [asin: string]: AmazonDetailItem }>(
'detailItems',
{},
);
export const detailItems = useWebExtensionStorage<AmazonDetailItem[]>('detailItems', []);
export const allItems = computed({
get() {
const sItems = searchItems.value;
const dItems = detailItems.value;
const dItems = detailItems.value.reduce<Map<string, AmazonDetailItem>>(
(m, c) => (m.set(c.asin, c), m),
new Map(),
);
return sItems.map<AmazonItem>((si) => {
const asin = si.asin;
return asin in dItems
? { ...si, ...dItems[asin], hasDetail: true }
: { ...si, hasDetail: false };
const dItem = dItems.get(asin);
return dItem ? { ...si, ...dItem, hasDetail: true } : { ...si, hasDetail: false };
});
},
set(newValue) {
const searchItemProps: (keyof AmazonSearchItem)[] = [
'keywords',
'asin',
'title',
'imageSrc',
'link',
'rank',
'createTime',
];
searchItems.value = newValue.map((row) => {
const props: (keyof AmazonSearchItem)[] = [
'keywords',
'asin',
'title',
'imageSrc',
'link',
'rank',
'createTime',
];
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
props.includes(key as keyof AmazonSearchItem),
searchItemProps.includes(key as keyof AmazonSearchItem),
);
return Object.fromEntries(entries) as AmazonSearchItem;
});
const detailItemsProps: (keyof AmazonDetailItem)[] = [
'asin',
'category1',
'category2',
'imageUrls',
'rating',
'ratingCount',
];
detailItems.value = newValue
.filter((row) => row.hasDetail)
.reduce<Record<string, AmazonDetailItem>>((o, row) => {
const { asin } = row;
const props: (keyof AmazonDetailItem)[] = [
'asin',
'category1',
'category2',
'imageUrls',
'rating',
'ratingCount',
];
.map((row) => {
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
props.includes(key as keyof AmazonDetailItem),
detailItemsProps.includes(key as keyof AmazonDetailItem),
);
const item = Object.fromEntries(entries) as AmazonDetailItem;
o[asin] = item;
return o;
}, {});
return Object.fromEntries(entries) as AmazonSearchItem;
});
},
});

181
src/logic/task-queue.ts Normal file
View File

@ -0,0 +1,181 @@
import Emittery from 'emittery';
export type TaskExecutionResult<T = undefined> =
| {
name: string;
status: 'success';
result: T;
}
| {
name: string;
status: 'failure';
message: string;
};
export interface TaskInit<
T = undefined,
F extends (...args: unknown[]) => Promise<T> = (...args: unknown[]) => Promise<T>,
> {
func: F;
args?: Parameters<F>;
callback?: (result: TaskExecutionResult<T>) => Promise<void> | void;
}
export class Task<
T = undefined,
F extends (...args: unknown[]) => Promise<T> = (...args: unknown[]) => Promise<T>,
> {
private _name: string;
private _func: F;
private _args: Parameters<F>;
private _status: 'initialization' | 'running' | 'success' | 'failure' = 'initialization';
private _result: TaskExecutionResult<T> | null = null;
private _callback: ((result: TaskExecutionResult<T>) => Promise<void> | void) | undefined;
public get name() {
return this._name;
}
public get status() {
return this._status;
}
public get result() {
return this._result;
}
constructor(name: string, init: TaskInit<T, F>) {
this._name = name;
this._func = init.func;
this._args = init.args ?? ([] as unknown as Parameters<F>);
this._callback = init.callback;
}
public async execute(): Promise<TaskExecutionResult<T>> {
const ret = await new Promise<TaskExecutionResult<T>>((resolve) => {
this._status = 'running';
const task = this._func(...this._args);
task
.then((ret) => {
this._status = 'success';
this._result = {
name: this.name,
status: 'success',
result: ret,
};
resolve(this._result);
})
.catch((reason) => {
this._status = 'failure';
this._result = {
name: this.name,
status: 'failure',
message: `${reason}`,
};
resolve(this._result);
});
});
this._callback && this._callback(ret);
return ret;
}
}
export class TaskQueue {
private _queue: Task<any>[] = [];
private _running = false;
private _channel: Emittery<{ interrupt: undefined; start: undefined; stop: undefined }> =
new Emittery();
constructor() {
this._channel.on('start', () => {
this._running = true;
});
this._channel.on('stop', () => {
this._running = false;
});
}
public get running() {
return this._running;
}
public add<T>(task: Task<T>) {
this._queue.push(task);
}
public async start() {
this._channel.emit('start');
let stopSignal = false;
const unsubscribe = this._channel.on('interrupt', () => {
stopSignal = true;
});
while (this._queue.length > 0 && !stopSignal) {
const task = this._queue.shift()!;
await task.execute();
}
unsubscribe();
this._channel.emit('stop');
}
public async stop() {
if (!this._running) {
return;
}
return new Promise((resolve) => {
this._channel.once('stop').then(resolve);
this._channel.emit('interrupt');
});
}
public clear() {
this._queue.length = 0;
}
}
/**
* Represents a controller for managing tasks within a task queue.
*/
export interface TaskController {
/**
* The queue that manages the tasks for this controller.
*/
readonly taskQueue: TaskQueue;
}
/**
* A decorator function that wraps a method to manage its execution as a task in a task queue.
*
* This function takes a method and returns a new method that, when called, will create a
* `Task` and add it to the `taskQueue` of the `TaskController`. The original method will be
* executed asynchronously, and the result will be resolved or rejected based on the task's
* outcome.
*/
export function taskUnit<T>(
target: (this: TaskController, ...args: any[]) => Promise<T>,
context: ClassMethodDecoratorContext,
): (this: TaskController, ...args: any[]) => Promise<T> {
// target 就是当前被装饰的 class 方法
const originalMethod = target;
// 定义一个新方法
const decoratedMethod = async function (this: TaskController, ...args: any[]) {
return new Promise<T>((resolve, reject) => {
const task = new Task<T, typeof originalMethod>(context.name.toString(), {
func: (o, ...a) => originalMethod.call(o, ...a),
args: [this, ...args],
callback: (r) => {
if (r.status === 'success') {
resolve(r.result);
} else if (r.status === 'failure') {
reject(r.message);
}
},
});
this.taskQueue.add(task);
if (!this.taskQueue.running) {
this.taskQueue.start();
}
});
};
// 返回装饰后的方法
return decoratedMethod;
}

View File

@ -33,7 +33,7 @@ export async function getManifest() {
48: './assets/icon-512.png',
128: './assets/icon-512.png',
},
permissions: ['tabs', 'storage', 'activeTab', 'sidePanel', 'scripting'],
permissions: ['tabs', 'storage', 'activeTab', 'sidePanel', 'scripting', 'unlimitedStorage'],
host_permissions: ['*://*/*'],
content_scripts: [
{

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { FormItemRule, UploadOnChange } from 'naive-ui';
import pageWorkerFactory from '~/logic/page-worker';
import { AmazonDetailItem } from '~/logic/page-worker/types';
import { asinInputText, detailItems } from '~/logic/storage';
const message = useMessage();
@ -44,7 +45,7 @@ worker.channel.on('item-rating-collected', (ev) => {
time: new Date().toLocaleString(),
content: `评分: ${ev.rating};评价数:${ev.ratingCount}`,
});
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
createOrUpdateDetailItem(ev);
});
worker.channel.on('item-category-rank-collected', (ev) => {
timelines.value.push({
@ -56,16 +57,16 @@ worker.channel.on('item-category-rank-collected', (ev) => {
ev.category2 ? `#${ev.category2.rank} in ${ev.category2.name}` : '',
].join('\n'),
});
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
createOrUpdateDetailItem(ev);
});
worker.channel.on('item-images-collected', (ev) => {
timelines.value.push({
type: 'success',
title: `商品${ev.asin}图像`,
time: new Date().toLocaleString(),
content: `图片数: ${ev.imageUrls.length}`,
content: `图片数: ${ev.imageUrls!.length}`,
});
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
createOrUpdateDetailItem(ev);
});
const handleImportAsin: UploadOnChange = ({ fileList }) => {
@ -133,6 +134,17 @@ const handleFetchInfoFromPage = () => {
},
});
};
const createOrUpdateDetailItem = (info: AmazonDetailItem) => {
const targetIndex = detailItems.value.findLastIndex((item) => info.asin === item.asin);
if (targetIndex > -1) {
const origin = detailItems.value[targetIndex];
const updatedItem = { ...origin, ...info };
detailItems.value.splice(targetIndex, 1, updatedItem);
} else {
detailItems.value.push(info);
}
};
</script>
<template>