mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-02-07 07:43:12 +08:00
Refactor storage management and error handling; implement task queue for better task execution control
This commit is contained in:
parent
804c2f60c6
commit
74c4f8bb28
@ -1,10 +1,9 @@
|
|||||||
import { StorageSerializers } from '@vueuse/core';
|
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 { ref, shallowRef } from 'vue-demi';
|
||||||
import { storage } from 'webextension-polyfill';
|
import { storage } from 'webextension-polyfill';
|
||||||
|
|
||||||
import type { StorageLikeAsync, UseStorageAsyncOptions } from '@vueuse/core';
|
import type { RemovableRef, StorageLikeAsync, UseStorageAsyncOptions } from '@vueuse/core';
|
||||||
import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/shared';
|
|
||||||
import type { Ref } from 'vue-demi';
|
import type { Ref } from 'vue-demi';
|
||||||
import type { Storage } from 'webextension-polyfill';
|
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
|
* https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorageAsync/index.ts
|
||||||
*
|
*
|
||||||
* @param key
|
* A custom hook for managing state with Web Extension storage.
|
||||||
* @param initialValue
|
* This function allows you to synchronize a reactive state with the Web Extension storage API.
|
||||||
* @param options
|
*
|
||||||
|
* @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>(
|
export function useWebExtensionStorage<T>(
|
||||||
key: string,
|
key: string,
|
||||||
initialValue: MaybeRefOrGetter<T>,
|
initialValue: MaybeRef<T>,
|
||||||
options: WebExtensionStorageOptions<T> = {},
|
options: Pick<
|
||||||
|
WebExtensionStorageOptions<T>,
|
||||||
|
'shallow' | 'serializer' | 'listenToStorageChanges' | 'flush' | 'deep' | 'eventFilter'
|
||||||
|
> = {},
|
||||||
): RemovableRef<T> {
|
): RemovableRef<T> {
|
||||||
const {
|
const {
|
||||||
|
shallow = false,
|
||||||
|
listenToStorageChanges = true,
|
||||||
flush = 'pre',
|
flush = 'pre',
|
||||||
deep = true,
|
deep = true,
|
||||||
listenToStorageChanges = true,
|
eventFilter = debounceFilter(1000),
|
||||||
writeDefaults = true,
|
|
||||||
mergeDefaults = false,
|
|
||||||
shallow,
|
|
||||||
eventFilter,
|
|
||||||
onError = (e) => {
|
|
||||||
console.error(e);
|
|
||||||
},
|
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const rawInit: T = toValue(initialValue);
|
const rawInit = unref(initialValue);
|
||||||
const type = guessSerializerType(rawInit);
|
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];
|
const serializer = options.serializer ?? StorageSerializers[type];
|
||||||
|
|
||||||
async function read(event?: { key: string; newValue: string | null }) {
|
const pullFromStorage = async () => {
|
||||||
if (event && event.key !== key) return;
|
const rawItem = await storageInterface.getItem(key);
|
||||||
|
if (rawItem) {
|
||||||
try {
|
const item = serializer.read(rawItem) as T;
|
||||||
const rawValue = event
|
data.value = item;
|
||||||
? 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);
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
void read();
|
const pushToStorage = async () => {
|
||||||
|
const newVal = toRaw(unref(data));
|
||||||
async function write() {
|
if (newVal === null) {
|
||||||
try {
|
await storageInterface.removeItem(key);
|
||||||
await (data.value == null
|
} else {
|
||||||
? storageInterface.removeItem(key)
|
const item = await serializer.write(newVal);
|
||||||
: storageInterface.setItem(key, await serializer.write(data.value)));
|
await storageInterface.setItem(key, item);
|
||||||
} catch (error) {
|
|
||||||
onError(error);
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(
|
const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(data, pushToStorage, {
|
||||||
data,
|
flush,
|
||||||
write,
|
deep,
|
||||||
{
|
eventFilter,
|
||||||
flush,
|
});
|
||||||
deep,
|
|
||||||
eventFilter,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (listenToStorageChanges) {
|
if (listenToStorageChanges) {
|
||||||
const listener = async (changes: Record<string, Storage.StorageChange>) => {
|
const listener = async (changes: Record<string, Storage.StorageChange>) => {
|
||||||
|
if (!(key in changes)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
pauseWatch();
|
pauseWatch();
|
||||||
for (const [key, change] of Object.entries(changes)) {
|
await pullFromStorage();
|
||||||
await read({
|
|
||||||
key,
|
|
||||||
newValue: change.newValue as string | null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
resumeWatch();
|
resumeWatch();
|
||||||
}
|
}
|
||||||
@ -151,5 +134,7 @@ export function useWebExtensionStorage<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as RemovableRef<T>;
|
pullFromStorage(); // Init
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/logic/error-handler.ts
Normal file
27
src/logic/error-handler.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -2,34 +2,14 @@ import Emittery from 'emittery';
|
|||||||
import type { AmazonDetailItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types';
|
import type { AmazonDetailItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types';
|
||||||
import type { Tabs } from 'webextension-polyfill';
|
import type { Tabs } from 'webextension-polyfill';
|
||||||
import { exec } from '../execute-script';
|
import { exec } from '../execute-script';
|
||||||
|
import { TaskController, TaskQueue, taskUnit } from '../task-queue';
|
||||||
/**
|
import { withErrorHandling } from '../error-handler';
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AmazonPageWorkerImpl can run on background & sidepanel & popup,
|
* AmazonPageWorkerImpl can run on background & sidepanel & popup,
|
||||||
* **can't** run on content script!
|
* **can't** run on content script!
|
||||||
*/
|
*/
|
||||||
class AmazonPageWorkerImpl implements AmazonPageWorker {
|
class AmazonPageWorkerImpl implements AmazonPageWorker, TaskController {
|
||||||
//#region Singleton
|
//#region Singleton
|
||||||
private static _instance: AmazonPageWorker | null = null;
|
private static _instance: AmazonPageWorker | null = null;
|
||||||
public static getInstance() {
|
public static getInstance() {
|
||||||
@ -47,9 +27,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
readonly channel = new Emittery<AmazonPageWorkerEvents>();
|
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> {
|
private async getCurrentTab(): Promise<Tabs.Tab> {
|
||||||
const tab = await browser.tabs
|
const tab = await browser.tabs
|
||||||
@ -173,6 +153,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@withErrorHandling
|
@withErrorHandling
|
||||||
|
@taskUnit
|
||||||
public async doSearch(keywords: string): Promise<string> {
|
public async doSearch(keywords: string): Promise<string> {
|
||||||
const url = new URL('https://www.amazon.com/s');
|
const url = new URL('https://www.amazon.com/s');
|
||||||
url.searchParams.append('k', keywords);
|
url.searchParams.append('k', keywords);
|
||||||
@ -189,14 +170,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@withErrorHandling
|
@withErrorHandling
|
||||||
|
@taskUnit
|
||||||
public async wanderSearchPage(): Promise<void> {
|
public async wanderSearchPage(): Promise<void> {
|
||||||
const tab = await this.getCurrentTab();
|
const tab = await this.getCurrentTab();
|
||||||
this._isCancel = false;
|
|
||||||
const stop = this.channel.on('error', async (_: unknown): Promise<void> => {
|
|
||||||
this._isCancel = true;
|
|
||||||
});
|
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (!this._isCancel) {
|
while (true) {
|
||||||
const { hasNextPage, data } = await this.wanderSearchSinglePage(tab);
|
const { hasNextPage, data } = await this.wanderSearchSinglePage(tab);
|
||||||
const keywords = new URL(tab.url!).searchParams.get('k')!;
|
const keywords = new URL(tab.url!).searchParams.get('k')!;
|
||||||
const objs = data.map((r, i) => ({
|
const objs = data.map((r, i) => ({
|
||||||
@ -212,12 +190,12 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._isCancel = false;
|
|
||||||
this.channel.off('error', stop);
|
this.channel.off('error', stop);
|
||||||
return new Promise((resolve) => setTimeout(resolve, 1000));
|
return new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
@withErrorHandling
|
@withErrorHandling
|
||||||
|
@taskUnit
|
||||||
public async wanderDetailPage(entry: string): Promise<void> {
|
public async wanderDetailPage(entry: string): Promise<void> {
|
||||||
//#region Initial Meta Info
|
//#region Initial Meta Info
|
||||||
const params = { asin: '', url: '' };
|
const params = { asin: '', url: '' };
|
||||||
@ -244,7 +222,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
window.scrollBy(0, ~~(Math.random() * 500) + 500);
|
||||||
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50));
|
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 50) + 50));
|
||||||
const targetNode = document.querySelector(
|
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') {
|
if (targetNode && document.readyState !== 'loading') {
|
||||||
targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
@ -303,19 +281,19 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
});
|
});
|
||||||
if (rawRankingText) {
|
if (rawRankingText) {
|
||||||
const info: Pick<AmazonDetailItem, 'category1' | 'category2'> = {};
|
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) {
|
if (statement) {
|
||||||
const name = /(?<=in\s).+(?=\s\(See)/.exec(statement)?.[0] || null;
|
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) {
|
if (name && rank) {
|
||||||
info['category1'] = { name, rank };
|
info['category1'] = { name, rank };
|
||||||
}
|
}
|
||||||
rawRankingText = rawRankingText.replace(statement, '');
|
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) {
|
if (statement) {
|
||||||
const name = /(?<=in\s).+/.exec(statement)?.[0].replace(/[\s]+$/, '') || null;
|
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) {
|
if (name && rank) {
|
||||||
info['category2'] = { name, rank };
|
info['category2'] = { name, rank };
|
||||||
}
|
}
|
||||||
@ -376,7 +354,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
this._isCancel = true;
|
this.taskQueue.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
src/logic/page-worker/types.d.ts
vendored
5
src/logic/page-worker/types.d.ts
vendored
@ -1,4 +1,5 @@
|
|||||||
import type Emittery from 'emittery';
|
import type Emittery from 'emittery';
|
||||||
|
import { TaskQueue } from '../task-queue';
|
||||||
|
|
||||||
type AmazonSearchItem = {
|
type AmazonSearchItem = {
|
||||||
keywords: string;
|
keywords: string;
|
||||||
@ -16,7 +17,7 @@ type AmazonDetailItem = {
|
|||||||
ratingCount?: number;
|
ratingCount?: number;
|
||||||
category1?: { name: string; rank: number };
|
category1?: { name: string; rank: number };
|
||||||
category2?: { name: string; rank: number };
|
category2?: { name: string; rank: number };
|
||||||
imageUrls: string[];
|
imageUrls?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type AmazonItem = AmazonSearchItem & Partial<AmazonDetailItem> & { hasDetail: boolean };
|
type AmazonItem = AmazonSearchItem & Partial<AmazonDetailItem> & { hasDetail: boolean };
|
||||||
@ -71,7 +72,7 @@ interface AmazonPageWorker {
|
|||||||
* Browsing goods detail page and collect target information.
|
* Browsing goods detail page and collect target information.
|
||||||
* @param entry Product link or Amazon Standard Identification Number.
|
* @param entry Product link or Amazon Standard Identification Number.
|
||||||
*/
|
*/
|
||||||
wanderDetailPage(entry: string): Promise<void>;
|
wanderDetailPage(entry: string | string[]): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the worker.
|
* Stop the worker.
|
||||||
|
|||||||
@ -5,58 +5,54 @@ export const keywordsList = useWebExtensionStorage<string[]>('keywordsList', [''
|
|||||||
|
|
||||||
export const asinInputText = useWebExtensionStorage<string>('asinInputText', '');
|
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 }>(
|
export const detailItems = useWebExtensionStorage<AmazonDetailItem[]>('detailItems', []);
|
||||||
'detailItems',
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const allItems = computed({
|
export const allItems = computed({
|
||||||
get() {
|
get() {
|
||||||
const sItems = searchItems.value;
|
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) => {
|
return sItems.map<AmazonItem>((si) => {
|
||||||
const asin = si.asin;
|
const asin = si.asin;
|
||||||
return asin in dItems
|
const dItem = dItems.get(asin);
|
||||||
? { ...si, ...dItems[asin], hasDetail: true }
|
return dItem ? { ...si, ...dItem, hasDetail: true } : { ...si, hasDetail: false };
|
||||||
: { ...si, hasDetail: false };
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
set(newValue) {
|
set(newValue) {
|
||||||
|
const searchItemProps: (keyof AmazonSearchItem)[] = [
|
||||||
|
'keywords',
|
||||||
|
'asin',
|
||||||
|
'title',
|
||||||
|
'imageSrc',
|
||||||
|
'link',
|
||||||
|
'rank',
|
||||||
|
'createTime',
|
||||||
|
];
|
||||||
searchItems.value = newValue.map((row) => {
|
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]) =>
|
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;
|
return Object.fromEntries(entries) as AmazonSearchItem;
|
||||||
});
|
});
|
||||||
|
const detailItemsProps: (keyof AmazonDetailItem)[] = [
|
||||||
|
'asin',
|
||||||
|
'category1',
|
||||||
|
'category2',
|
||||||
|
'imageUrls',
|
||||||
|
'rating',
|
||||||
|
'ratingCount',
|
||||||
|
];
|
||||||
detailItems.value = newValue
|
detailItems.value = newValue
|
||||||
.filter((row) => row.hasDetail)
|
.filter((row) => row.hasDetail)
|
||||||
.reduce<Record<string, AmazonDetailItem>>((o, row) => {
|
.map((row) => {
|
||||||
const { asin } = row;
|
|
||||||
const props: (keyof AmazonDetailItem)[] = [
|
|
||||||
'asin',
|
|
||||||
'category1',
|
|
||||||
'category2',
|
|
||||||
'imageUrls',
|
|
||||||
'rating',
|
|
||||||
'ratingCount',
|
|
||||||
];
|
|
||||||
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
|
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;
|
return Object.fromEntries(entries) as AmazonSearchItem;
|
||||||
o[asin] = item;
|
});
|
||||||
return o;
|
|
||||||
}, {});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
181
src/logic/task-queue.ts
Normal file
181
src/logic/task-queue.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -33,7 +33,7 @@ export async function getManifest() {
|
|||||||
48: './assets/icon-512.png',
|
48: './assets/icon-512.png',
|
||||||
128: './assets/icon-512.png',
|
128: './assets/icon-512.png',
|
||||||
},
|
},
|
||||||
permissions: ['tabs', 'storage', 'activeTab', 'sidePanel', 'scripting'],
|
permissions: ['tabs', 'storage', 'activeTab', 'sidePanel', 'scripting', 'unlimitedStorage'],
|
||||||
host_permissions: ['*://*/*'],
|
host_permissions: ['*://*/*'],
|
||||||
content_scripts: [
|
content_scripts: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormItemRule, UploadOnChange } from 'naive-ui';
|
import type { FormItemRule, UploadOnChange } from 'naive-ui';
|
||||||
import pageWorkerFactory from '~/logic/page-worker';
|
import pageWorkerFactory from '~/logic/page-worker';
|
||||||
|
import { AmazonDetailItem } from '~/logic/page-worker/types';
|
||||||
import { asinInputText, detailItems } from '~/logic/storage';
|
import { asinInputText, detailItems } from '~/logic/storage';
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
@ -44,7 +45,7 @@ worker.channel.on('item-rating-collected', (ev) => {
|
|||||||
time: new Date().toLocaleString(),
|
time: new Date().toLocaleString(),
|
||||||
content: `评分: ${ev.rating};评价数:${ev.ratingCount}`,
|
content: `评分: ${ev.rating};评价数:${ev.ratingCount}`,
|
||||||
});
|
});
|
||||||
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
|
createOrUpdateDetailItem(ev);
|
||||||
});
|
});
|
||||||
worker.channel.on('item-category-rank-collected', (ev) => {
|
worker.channel.on('item-category-rank-collected', (ev) => {
|
||||||
timelines.value.push({
|
timelines.value.push({
|
||||||
@ -56,16 +57,16 @@ worker.channel.on('item-category-rank-collected', (ev) => {
|
|||||||
ev.category2 ? `#${ev.category2.rank} in ${ev.category2.name}` : '',
|
ev.category2 ? `#${ev.category2.rank} in ${ev.category2.name}` : '',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
});
|
});
|
||||||
detailItems.value[ev.asin] = { ...detailItems.value[ev.asin], ...ev };
|
createOrUpdateDetailItem(ev);
|
||||||
});
|
});
|
||||||
worker.channel.on('item-images-collected', (ev) => {
|
worker.channel.on('item-images-collected', (ev) => {
|
||||||
timelines.value.push({
|
timelines.value.push({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: `商品${ev.asin}图像`,
|
title: `商品${ev.asin}图像`,
|
||||||
time: new Date().toLocaleString(),
|
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 }) => {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user