diff --git a/src/composables/useWebExtensionStorage.ts b/src/composables/useWebExtensionStorage.ts index d3e0b8a..67b54c3 100644 --- a/src/composables/useWebExtensionStorage.ts +++ b/src/composables/useWebExtensionStorage.ts @@ -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( key: string, - initialValue: MaybeRefOrGetter, - options: WebExtensionStorageOptions = {}, + initialValue: MaybeRef, + options: Pick< + WebExtensionStorageOptions, + 'shallow' | 'serializer' | 'listenToStorageChanges' | 'flush' | 'deep' | 'eventFilter' + > = {}, ): RemovableRef { 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; - const data = (shallow ? shallowRef : ref)(initialValue) as Ref; 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), - ...(value as Record), - } 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) => { + 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( }); } - return data as RemovableRef; + pullFromStorage(); // Init + + return data; } diff --git a/src/logic/error-handler.ts b/src/logic/error-handler.ts new file mode 100644 index 0000000..7cae595 --- /dev/null +++ b/src/logic/error-handler.ts @@ -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, + _context: ClassMethodDecoratorContext, +): (this: ErrorChannelContainer, ...args: any[]) => Promise { + // 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; +} diff --git a/src/logic/page-worker/index.ts b/src/logic/page-worker/index.ts index b948102..fdfc7e7 100644 --- a/src/logic/page-worker/index.ts +++ b/src/logic/page-worker/index.ts @@ -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, - _context: ClassMethodDecoratorContext, -): (this: AmazonPageWorker, ...args: any[]) => Promise { - // 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(); /** - * The signal to interrupt the current operation. + * The Task queue */ - private _isCancel = false; + readonly taskQueue = new TaskQueue(); private async getCurrentTab(): Promise { const tab = await browser.tabs @@ -173,6 +153,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker { } @withErrorHandling + @taskUnit public async doSearch(keywords: string): Promise { 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 { const tab = await this.getCurrentTab(); - this._isCancel = false; - const stop = this.channel.on('error', async (_: unknown): Promise => { - 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 { //#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 = {}; - 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 { - this._isCancel = true; + this.taskQueue.clear(); } } diff --git a/src/logic/page-worker/types.d.ts b/src/logic/page-worker/types.d.ts index 89e5465..0f5567f 100644 --- a/src/logic/page-worker/types.d.ts +++ b/src/logic/page-worker/types.d.ts @@ -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 & { 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; + wanderDetailPage(entry: string | string[]): Promise; /** * Stop the worker. diff --git a/src/logic/storage.ts b/src/logic/storage.ts index f4d97f0..f06ca1d 100644 --- a/src/logic/storage.ts +++ b/src/logic/storage.ts @@ -5,58 +5,54 @@ export const keywordsList = useWebExtensionStorage('keywordsList', ['' export const asinInputText = useWebExtensionStorage('asinInputText', ''); -export const searchItems = useWebExtensionStorage('itemList', []); +export const searchItems = useWebExtensionStorage('searchItems', []); -export const detailItems = useWebExtensionStorage<{ [asin: string]: AmazonDetailItem }>( - 'detailItems', - {}, -); +export const detailItems = useWebExtensionStorage('detailItems', []); export const allItems = computed({ get() { const sItems = searchItems.value; - const dItems = detailItems.value; + const dItems = detailItems.value.reduce>( + (m, c) => (m.set(c.asin, c), m), + new Map(), + ); return sItems.map((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>((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; + }); }, }); diff --git a/src/logic/task-queue.ts b/src/logic/task-queue.ts new file mode 100644 index 0000000..1b6468f --- /dev/null +++ b/src/logic/task-queue.ts @@ -0,0 +1,181 @@ +import Emittery from 'emittery'; + +export type TaskExecutionResult = + | { + name: string; + status: 'success'; + result: T; + } + | { + name: string; + status: 'failure'; + message: string; + }; + +export interface TaskInit< + T = undefined, + F extends (...args: unknown[]) => Promise = (...args: unknown[]) => Promise, +> { + func: F; + args?: Parameters; + callback?: (result: TaskExecutionResult) => Promise | void; +} + +export class Task< + T = undefined, + F extends (...args: unknown[]) => Promise = (...args: unknown[]) => Promise, +> { + private _name: string; + private _func: F; + private _args: Parameters; + private _status: 'initialization' | 'running' | 'success' | 'failure' = 'initialization'; + private _result: TaskExecutionResult | null = null; + private _callback: ((result: TaskExecutionResult) => Promise | 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) { + this._name = name; + this._func = init.func; + this._args = init.args ?? ([] as unknown as Parameters); + this._callback = init.callback; + } + + public async execute(): Promise> { + const ret = await new Promise>((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[] = []; + 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(task: Task) { + 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( + target: (this: TaskController, ...args: any[]) => Promise, + context: ClassMethodDecoratorContext, +): (this: TaskController, ...args: any[]) => Promise { + // target 就是当前被装饰的 class 方法 + const originalMethod = target; + // 定义一个新方法 + const decoratedMethod = async function (this: TaskController, ...args: any[]) { + return new Promise((resolve, reject) => { + const task = new Task(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; +} diff --git a/src/manifest.ts b/src/manifest.ts index bac2a21..63a5d0c 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -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: [ { diff --git a/src/sidepanel/DetailPageWorker.vue b/src/sidepanel/DetailPageWorker.vue index 7748c4b..f73521e 100644 --- a/src/sidepanel/DetailPageWorker.vue +++ b/src/sidepanel/DetailPageWorker.vue @@ -1,6 +1,7 @@