Adjust page worker struture

This commit is contained in:
johnathan 2025-06-28 13:51:44 +08:00
parent d66c40b9e7
commit 3aef704fa5
28 changed files with 549 additions and 296 deletions

View File

@ -1,7 +1,7 @@
{
"name": "azon-seeker",
"displayName": "Azon Seeker",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"description": "Starter modify by honestfox101",
"scripts": {

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import { useExcelHelper } from '~/composables/useExcelHelper';
const excelHelper = useExcelHelper();
const emit = defineEmits<{ ['export']: [opt: 'local' | 'cloud'] }>();
</script>
<template>
<ul v-if="!excelHelper.isRunning.value" class="exporter-menu">
<li @click="emit('export', 'local')">
<n-tooltip :delay="1000" placement="right">
<template #trigger>
<div class="menu-item">
<n-icon><lucide-sheet /></n-icon>
<span>本地导出</span>
</div>
</template>
不包含图片
</n-tooltip>
</li>
<li @click="emit('export', 'cloud')">
<n-tooltip :delay="1000" placement="right">
<template #trigger>
<div class="menu-item">
<n-icon><ic-outline-cloud /></n-icon>
<span>云端导出</span>
</div>
</template>
包含图片
</n-tooltip>
</li>
</ul>
<div v-else class="expoter-progress-panel">
<n-progress
type="circle"
:percentage="(excelHelper.progress.current * 100) / excelHelper.progress.total"
>
<span> {{ excelHelper.progress.current }}/{{ excelHelper.progress.total }} </span>
</n-progress>
<n-button @click="excelHelper.stop()">停止</n-button>
</div>
</template>
<style lang="scss" scoped>
.exporter-menu {
display: flex;
flex-direction: column;
align-items: center;
background: #fff;
padding: 0;
margin: 0;
list-style: none;
font-size: 15px;
li {
padding: 5px 10px;
cursor: pointer;
transition: background 0.15s;
color: #222;
user-select: none;
border-radius: 6px;
&:hover {
background: #f0f6fa;
color: #007bff;
}
.menu-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
}
}
}
.expoter-progress-panel {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
font-size: 18px;
padding: 10px;
gap: 15px;
cursor: wait;
}
</style>

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useElementSize } from '@vueuse/core';
import { exportToXLSX, Header, importFromXLSX } from '~/logic/excel';
import { reviewItems } from '~/logic/storages/amazon';
import { reviewItems } from '~/storages/amazon';
const props = defineProps<{ asin: string }>();

View File

@ -0,0 +1,82 @@
import { createGlobalState } from '@vueuse/core';
import {
useCloudExporter,
type DataFragment as CloudDataFragment,
} from '~/composables/useCloudExporter';
import { formatRecords, createWorkbook, importFromXLSX, type Header } from '~/logic/excel';
export type InputDataFragment = {
headers: Header[];
} & CloudDataFragment;
export type OutputDataFragment<T = Record<string, unknown>> = {
headers: Header[];
data: T[];
};
function buildExcelHelper() {
const cloudExporter = useCloudExporter();
const message = useMessage();
const exportFile = async (
dataFragments: InputDataFragment[],
options: { cloud?: boolean } = {},
) => {
dataFragments = toRaw(dataFragments);
const { cloud = false } = options;
if (cloud) {
message.warning('正在导出,请勿关闭当前页面!', { duration: 2000 });
const fragments = await Promise.all(
dataFragments.map(async (fragment) => {
const { data, headers } = fragment;
fragment.data = await formatRecords(data, headers);
return fragment;
}),
);
const filename = await cloudExporter.doExport(fragments);
filename && message.info(`导出完成`);
} else {
const wb = createWorkbook();
for (const fragment of dataFragments) {
const { data, headers, name } = fragment;
const sheet = wb.addSheet(name);
await sheet.readJson(data, { headers });
}
await wb.exportFile(`${dayjs().format('YYYY-MM-DD')}.xlsx`);
message.info('导出完成');
}
};
const importFile = async (
file: File,
options: { [sheetname: string]: Header[] } | Header[][],
) => {
const wb = await importFromXLSX(file, { asWorkBook: true });
const output: OutputDataFragment[] = [];
if (Array.isArray(options)) {
for (let i = 0; i < options.length; i++) {
const sheet = wb.getSheet(i)!;
const headers = options[i];
const data = await sheet.toJson({ headers });
output.push({ data, headers });
}
} else {
for (const [sheetname, headers] of Object.entries(options)) {
const sheet = wb.getSheet(sheetname)!;
const data = await sheet.toJson({ headers });
output.push({ data, headers });
}
}
return output;
};
return {
exportFile,
importFile,
isRunning: cloudExporter.isRunning,
progress: cloudExporter.progress,
stop: cloudExporter.stop.bind(cloudExporter),
};
}
export const useExcelHelper = createGlobalState(buildExcelHelper);

View File

@ -52,7 +52,7 @@ class Worksheet {
};
}
async toJson<T>(options: { headers?: Header[] } = {}) {
async toJson<T = Record<string, unknown>>(options: { headers?: Header[] } = {}) {
const { headers } = options;
let jsonData: Record<string, unknown>[] = [];
@ -113,8 +113,11 @@ class Workbook {
this._wb = await this._wb.xlsx.load(bf);
}
getSheet(index: number): Worksheet | undefined {
const ws = this._wb.getWorksheet(index + 1); // Align the index
getSheet(indexOrName: number | string): Worksheet | undefined {
if (typeof indexOrName === 'number') {
indexOrName += 1; // Align the index
}
const ws = this._wb.getWorksheet(indexOrName);
return ws ? new Worksheet(ws, this) : undefined;
}
@ -193,7 +196,7 @@ export type ImportBaseOptions = {
headers?: Header[];
};
export function castRecordsByHeaders(
export function formatRecords(
jsonData: Record<string, unknown>[],
headers: Omit<Header, 'parseImportValue'>[],
): Promise<Record<string, unknown>[]> {

View File

@ -1,4 +1,6 @@
import { isFirefox } from '~/env';
type ExecOptions = {
timeout?: number;
};
/**
* Executes a provided asynchronous function in the context of a specific browser tab.
@ -6,6 +8,7 @@ import { isFirefox } from '~/env';
* @param func - The asynchronous function to execute in the tab's context. This function
* should be serializable and must not rely on external closures.
* @param payload - An optional payload object to pass as an argument to the executed function.
* @param options - The options apply to execute
*
* @returns A promise that resolves to the result of the executed function, or `null` if an error occurs.
*
@ -23,10 +26,6 @@ import { isFirefox } from '~/env';
* console.log(result); // Outputs: 42
* ```
*/
type ExecOptions = {
timeout?: number;
};
export async function exec<T>(
tabId: number,
func: () => Promise<T>,

View File

@ -1,17 +1,18 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { site } from '~/logic/storages/global';
import { site } from '~/storages/global';
import { useRouter } from 'vue-router';
const router = useRouter();
const options: { label: string; value: 'amazon' | 'homedepot' }[] = [
{ label: 'Amazon Search&Detail', value: 'amazon' },
{ label: 'Homedepot Detail', value: 'homedepot' },
const options: { label: string; value: string }[] = [
{ label: 'Amazon', value: 'amazon' },
{ label: 'Amazon Review', value: 'amazon-reviews' },
{ label: 'Homedepot', value: 'homedepot' },
];
watch(site, (newVal) => {
router.push(`/${newVal}`);
watch(site, (val) => {
router.push(val);
});
</script>

View File

@ -2,8 +2,8 @@
import { NButton, NSpace } from 'naive-ui';
import type { TableColumn } from '~/components/ResultTable.vue';
import { useCloudExporter } from '~/composables/useCloudExporter';
import { castRecordsByHeaders, createWorkbook, Header, importFromXLSX } from '~/logic/excel';
import { allItems, itemColumnSettings, reviewItems } from '~/logic/storages/amazon';
import { formatRecords, createWorkbook, Header, importFromXLSX } from '~/logic/excel';
import { allItems, itemColumnSettings, reviewItems } from '~/storages/amazon';
const message = useMessage();
const modal = useModal();
@ -138,7 +138,6 @@ const extraHeaders: Header<AmazonItem>[] = [
{ prop: 'category1.rank', label: '大类排行' },
{ prop: 'category2.name', label: '小类' },
{ prop: 'category2.rank', label: '小类排行' },
{ prop: 'timestamp', label: '获取日期(详情页)' },
{
prop: 'imageUrls',
label: '商品图片链接',
@ -249,8 +248,8 @@ const handleCloudExport = async () => {
a.push(...reviews.map((r) => ({ asin, ...r })));
return a;
}, []);
const mappedData1 = await castRecordsByHeaders(items, itemHeaders);
const mappedData2 = await castRecordsByHeaders(reviews, reviewHeaders);
const mappedData1 = await formatRecords(items, itemHeaders);
const mappedData2 = await formatRecords(reviews, reviewHeaders);
const fragments = [
{ data: mappedData1, imageColumn: ['A+截图', '商品图片链接'], name: 'items' },
{ data: mappedData2, imageColumn: '图片链接', name: 'reviews' },
@ -393,10 +392,10 @@ const handleClearData = async () => {
v-model:value="filter.detailDateRange"
/>
</n-form-item>
<n-form-item label="列筛选">
<n-form-item label="列展示">
<n-checkbox-group
:value="Array.from(itemColumnSettings)"
@update:value="(val) => (itemColumnSettings = new Set(val) as any)"
@update:value="(val: any) => (itemColumnSettings = new Set(val) as any)"
>
<n-space item-style="display: flex;">
<n-checkbox value="keywords" label="关键词" />

View File

@ -0,0 +1,35 @@
<script setup lang="tsx">
import { useCloudExporter } from '~/composables/useCloudExporter';
import type { TableColumn } from '~/components/ResultTable.vue';
import { allReviews } from '~/storages/amazon';
const message = useMessage();
const cloudExporter = useCloudExporter();
const filter = ref<{ keywords?: string; rating?: string }>({});
const columns = computed<TableColumn[]>(() => {
return [
{
title: 'ASIN',
key: 'asin',
},
];
});
const filteredData = computed(() => {
return allReviews.value;
});
</script>
<template>
<div class="result-table">
<result-table :columns="columns" :records="filteredData"> </result-table>
</div>
</template>
<style lang="scss" scoped>
.result-table {
width: 100%;
}
</style>

View File

@ -1,8 +1,8 @@
<script setup lang="tsx">
import type { TableColumn } from '~/components/ResultTable.vue';
import { useCloudExporter } from '~/composables/useCloudExporter';
import { castRecordsByHeaders, exportToXLSX, Header, importFromXLSX } from '~/logic/excel';
import { allItems } from '~/logic/storages/homedepot';
import { formatRecords, exportToXLSX, Header, importFromXLSX } from '~/logic/excel';
import { allItems } from '~/storages/homedepot';
const message = useMessage();
const cloudExporter = useCloudExporter();
@ -11,10 +11,12 @@ const columns: TableColumn[] = [
{
title: 'OSMID',
key: 'OSMID',
minWidth: 100,
},
{
title: '品牌名称',
key: 'brandName',
minWidth: 120,
},
{
title: '型号信息',
@ -27,6 +29,7 @@ const columns: TableColumn[] = [
{
title: '价格',
key: 'price',
minWidth: 80,
},
{
title: '评分',
@ -110,7 +113,7 @@ const handleLocalExport = async () => {
const handleCloudExport = async () => {
message.warning('正在导出,请勿关闭当前页面!', { duration: 2000 });
const itemHeaders = getItemHeaders();
const mappedData = await castRecordsByHeaders(filteredData.value, itemHeaders);
const mappedData = await formatRecords(filteredData.value, itemHeaders);
const fragments = [{ data: mappedData, imageColumn: '主图链接' }];
const filename = await cloudExporter.doExport(fragments);
filename && message.info(`导出完成`);

View File

@ -5,7 +5,7 @@ import {
AmazonDetailPageInjector,
AmazonReviewPageInjector,
AmazonSearchPageInjector,
} from '~/logic/web-injectors/amazon';
} from './web-injectors/amazon';
import { isForbiddenUrl } from '~/env';
import { BaseWorker } from './base';

View File

@ -0,0 +1,157 @@
import { useLongTask } from '~/composables/useLongTask';
import amazon from '../amazon';
import { uploadImage } from '~/logic/upload';
import {
detailItems as amazonDetailItems,
reviewItems as amazonReviewItems,
searchItems as amazonSearchItems,
} from '~/storages/amazon';
import { createGlobalState } from '@vueuse/core';
export interface AmazonPageWorkerSettings {
objects?: ('search' | 'detail' | 'review')[];
commitChangeIngerval?: number;
}
function buildAmazonPageWorker() {
const settings = shallowRef<AmazonPageWorkerSettings>({});
const { isRunning, startTask } = useLongTask();
const worker = amazon.getAmazonPageWorker();
const searchCache = [] as AmazonSearchItem[];
const detailCache = new Map<string, AmazonDetailItem>();
const reviewCache = new Map<string, AmazonReview[]>();
const updateSearchCache = (data: AmazonSearchItem[]) => {
searchCache.push(...data);
};
const updateDetailCache = (data: { asin: string } & Partial<AmazonDetailItem>) => {
const asin = data.asin;
if (detailCache.has(data.asin)) {
const origin = detailCache.get(data.asin);
detailCache.set(asin, { ...origin, ...data } as AmazonDetailItem);
} else {
detailCache.set(asin, data as AmazonDetailItem);
}
};
const updateReviews = (data: { asin: string; reviews: AmazonReview[] }) => {
const { asin, reviews } = data;
const values = reviewCache.get(asin) || [];
const ids = new Set(values.map((item) => item.id));
for (const review of reviews) {
ids.has(review.id) || values.push(review);
}
reviewCache.set(asin, values);
};
const unsubscribeFuncs = [] as (() => void)[];
onMounted(() => {
unsubscribeFuncs.push(
...[
worker.on('error', () => {
worker.stop();
}),
worker.on('item-links-collected', ({ objs }) => {
updateSearchCache(objs);
}),
worker.on('item-base-info-collected', (ev) => {
updateDetailCache(ev);
}),
worker.on('item-category-rank-collected', (ev) => {
updateDetailCache(ev);
}),
worker.on('item-images-collected', (ev) => {
updateDetailCache(ev);
}),
worker.on('item-top-reviews-collected', (ev) => {
updateDetailCache(ev);
}),
worker.on('item-aplus-screenshot-collect', async (ev) => {
const url = await uploadImage(ev.base64data, `${ev.asin}.png`);
url && updateDetailCache({ asin: ev.asin, aplus: url });
}),
worker.on('item-review-collected', (ev) => {
updateReviews(ev);
}),
],
);
});
onUnmounted(() => {
unsubscribeFuncs.forEach((unsubscribe) => unsubscribe());
unsubscribeFuncs.splice(0, unsubscribeFuncs.length);
});
const commitChange = () => {
const { objects } = settings.value;
if (objects?.includes('search')) {
amazonSearchItems.value = amazonSearchItems.value.concat(searchCache);
searchCache.splice(0, searchCache.length);
}
if (objects?.includes('detail')) {
const detailItems = toRaw(amazonDetailItems.value);
for (const [k, v] of detailCache.entries()) {
if (detailItems.has(k)) {
const item = detailItems.get(k)!;
detailItems.set(k, { ...item, ...v });
} else {
detailItems.set(k, v);
}
}
amazonDetailItems.value = detailItems;
detailCache.clear();
}
if (objects?.includes('review')) {
const reviewItems = toRaw(amazonReviewItems.value);
for (const [asin, reviews] of reviewCache.entries()) {
if (reviewItems.has(asin)) {
const addIds = new Set(reviews.map((x) => x.id));
const origin = reviewItems.get(asin)!;
const newReviews = origin.filter((x) => !addIds.has(x.id)).concat(reviews);
newReviews.sort((a, b) => dayjs(b.dateInfo).diff(dayjs(a.dateInfo)));
reviewItems.set(asin, newReviews);
} else {
reviewItems.set(asin, reviews);
}
}
amazonReviewItems.value = reviewItems;
reviewCache.clear();
}
};
const taskWrapper = <T extends (...params: any) => any>(func: T) => {
const { commitChangeIngerval = 10000 } = settings.value;
searchCache.splice(0, searchCache.length);
detailCache.clear();
reviewCache.clear();
return (...params: Parameters<T>) =>
startTask(async () => {
const interval = setInterval(() => commitChange(), commitChangeIngerval);
await func(...params);
clearInterval(interval);
commitChange();
});
};
const runDetailPageTask = taskWrapper(worker.runDetailPageTask.bind(worker));
const runSearchPageTask = taskWrapper(worker.runSearchPageTask.bind(worker));
const runReviewPageTask = taskWrapper(worker.runReviewPageTask.bind(worker));
return {
settings,
isRunning,
runSearchPageTask,
runDetailPageTask,
runReviewPageTask,
on: worker.on.bind(worker),
off: worker.off.bind(worker),
once: worker.once.bind(worker),
stop: worker.stop.bind(worker),
};
}
export const useAmazonWorker = createGlobalState(buildAmazonPageWorker);

View File

@ -0,0 +1,85 @@
import { useLongTask } from '~/composables/useLongTask';
import { detailItems as homedepotDetailItems } from '~/storages/homedepot';
import homedepot from '../homedepot';
import { createGlobalState } from '@vueuse/core';
export interface HomedepotWorkerSettings {
objects?: 'detail'[];
commitChangeIngerval?: number;
}
function buildHomedepotWorker() {
const settings = shallowRef<HomedepotWorkerSettings>({});
const worker = homedepot.getHomedepotWorker();
const { isRunning, startTask } = useLongTask();
const detailCache = new Map<string, HomedepotDetailItem>();
const unsubscribeFuncs = [] as (() => void)[];
onMounted(() => {
unsubscribeFuncs.push(
...[
worker.on('error', () => {
worker.stop();
}),
worker.on('detail-item-collected', (ev) => {
const { item } = ev;
if (detailCache.has(item.OSMID)) {
const origin = detailCache.get(item.OSMID);
detailCache.set(item.OSMID, { ...origin, ...item });
} else {
detailCache.set(item.OSMID, item);
}
}),
],
);
});
onUnmounted(() => {
unsubscribeFuncs.forEach((unsubscribe) => unsubscribe());
unsubscribeFuncs.splice(0, unsubscribeFuncs.length);
});
const commitChange = () => {
const { objects } = settings.value;
if (objects?.includes('detail')) {
const detailItems = toRaw(homedepotDetailItems.value);
for (const [k, v] of detailCache.entries()) {
if (detailItems.has(k)) {
const origin = detailItems.get(k)!;
detailItems.set(k, { ...origin, ...v });
} else {
detailItems.set(k, v);
}
}
homedepotDetailItems.value = detailItems;
detailCache.clear();
}
};
const taskWrapper = <T extends (...params: any) => any>(func: T) => {
const { commitChangeIngerval = 10000 } = settings.value;
return (...params: Parameters<T>) =>
startTask(async () => {
const interval = setInterval(() => commitChange(), commitChangeIngerval);
await func(...params);
clearInterval(interval);
commitChange();
});
};
const runDetailPageTask = taskWrapper(worker.runDetailPageTask.bind(worker));
return {
settings,
isRunning,
runDetailPageTask,
on: worker.on.bind(worker),
off: worker.off.bind(worker),
once: worker.once.bind(worker),
stop: worker.stop.bind(worker),
};
}
export const useHomedepotWorker = createGlobalState(buildHomedepotWorker);

View File

@ -1,7 +1,7 @@
import type { HomedepotEvents, HomedepotWorker, LanchTaskBaseOptions } from './types';
import { Tabs } from 'webextension-polyfill';
import { withErrorHandling } from './error-handler';
import { HomedepotDetailPageInjector } from '~/logic/web-injectors/homedepot';
import { HomedepotDetailPageInjector } from './web-injectors/homedepot';
import { BaseWorker } from './base';
class HomedepotWorkerImpl

View File

@ -1,267 +1,28 @@
import amazon from './amazon';
import homedepot from './homedepot';
import { uploadImage } from '~/logic/upload';
import { useLongTask } from '~/composables/useLongTask';
export interface AmazonPageWorkerSettings {
searchItems?: Ref<AmazonSearchItem[]>;
detailItems?: Ref<Map<string, AmazonDetailItem>>;
reviewItems?: Ref<Map<string, AmazonReview[]>>;
commitChangeIngerval?: number;
}
class AmazonPageWorkerFactory {
public amazonWorker: ReturnType<typeof this.buildAmazonPageWorker> | null = null;
public amazonWorkerSettings: AmazonPageWorkerSettings = {};
public buildAmazonPageWorker() {
const { isRunning, startTask } = useLongTask();
const worker = amazon.getAmazonPageWorker();
const searchCache = [] as AmazonSearchItem[];
const detailCache = new Map<string, AmazonDetailItem>();
const reviewCache = new Map<string, AmazonReview[]>();
const unsubscribeFuncs = [] as (() => void)[];
onMounted(() => {
unsubscribeFuncs.push(
...[
worker.on('error', () => {
worker.stop();
}),
worker.on('item-links-collected', ({ objs }) => {
updateSearchCache(objs);
}),
worker.on('item-base-info-collected', (ev) => {
updateDetailCache(ev);
}),
worker.on('item-category-rank-collected', (ev) => {
updateDetailCache(ev);
}),
worker.on('item-images-collected', (ev) => {
updateDetailCache(ev);
}),
worker.on('item-top-reviews-collected', (ev) => {
updateDetailCache(ev);
}),
worker.on('item-aplus-screenshot-collect', async (ev) => {
const url = await uploadImage(ev.base64data, `${ev.asin}.png`);
url && updateDetailCache({ asin: ev.asin, aplus: url });
}),
worker.on('item-review-collected', (ev) => {
updateReviews(ev);
}),
],
);
});
onUnmounted(() => {
unsubscribeFuncs.forEach((unsubscribe) => unsubscribe());
unsubscribeFuncs.splice(0, unsubscribeFuncs.length);
});
const updateSearchCache = (data: AmazonSearchItem[]) => {
searchCache.push(...data);
};
const updateDetailCache = (data: { asin: string } & Partial<AmazonDetailItem>) => {
const asin = data.asin;
if (detailCache.has(data.asin)) {
const origin = detailCache.get(data.asin);
detailCache.set(asin, { ...origin, ...data } as AmazonDetailItem);
} else {
detailCache.set(asin, data as AmazonDetailItem);
}
};
const updateReviews = (data: { asin: string; reviews: AmazonReview[] }) => {
const { asin, reviews } = data;
const values = reviewCache.get(asin) || [];
const ids = new Set(values.map((item) => item.id));
for (const review of reviews) {
ids.has(review.id) || values.push(review);
}
reviewCache.set(asin, values);
};
const commitChange = () => {
const { searchItems, detailItems, reviewItems } = this.amazonWorkerSettings;
if (typeof searchItems !== 'undefined') {
searchItems.value = searchItems.value.concat(searchCache);
}
if (typeof detailItems !== 'undefined') {
for (const [k, v] of detailCache.entries()) {
detailItems.value.delete(k); // Trigger update
detailItems.value.set(k, v);
}
}
if (typeof reviewItems !== 'undefined') {
for (const [asin, reviews] of reviewCache.entries()) {
if (reviewItems.value.has(asin)) {
const adds = new Set(reviews.map((x) => x.id));
const newReviews = reviewItems.value
.get(asin)!
.filter((x) => !adds.has(x.id))
.concat(reviews);
newReviews.sort((a, b) => dayjs(b.dateInfo).diff(dayjs(a.dateInfo)));
reviewItems.value.delete(asin); // Trigger update
reviewItems.value.set(asin, newReviews);
} else {
reviewItems.value.set(asin, reviews);
}
}
}
};
const taskWrapper = <T extends (...params: any) => any>(func: T) => {
const { commitChangeIngerval = 10000 } = this.amazonWorkerSettings;
searchCache.splice(0, searchCache.length);
detailCache.clear();
reviewCache.clear();
return (...params: Parameters<T>) =>
startTask(async () => {
const interval = setInterval(() => commitChange(), commitChangeIngerval);
await func(...params);
clearInterval(interval);
commitChange();
});
};
const runDetailPageTask = taskWrapper(worker.runDetailPageTask.bind(worker));
const runSearchPageTask = taskWrapper(worker.runSearchPageTask.bind(worker));
const runReviewPageTask = taskWrapper(worker.runReviewPageTask.bind(worker));
return {
isRunning,
runSearchPageTask,
runDetailPageTask,
runReviewPageTask,
on: worker.on.bind(worker),
off: worker.off.bind(worker),
once: worker.once.bind(worker),
stop: worker.stop.bind(worker),
};
}
loadAmazonPageWorker(settings?: AmazonPageWorkerSettings) {
if (settings) {
this.amazonWorkerSettings = { ...this.amazonWorkerSettings, ...settings };
}
if (!this.amazonWorker) {
this.amazonWorker = this.buildAmazonPageWorker();
}
return this.amazonWorker;
}
}
export interface HomedepotWorkerSettings {
detailItems?: Ref<Map<string, HomedepotDetailItem>>;
commitChangeIngerval?: number;
}
class HomedepotWorkerFactory {
public homedepotWorkerSettings: HomedepotWorkerSettings = {};
public homedepotWorker: ReturnType<typeof this.buildHomedepotWorker> | null = null;
public buildHomedepotWorker() {
const worker = homedepot.getHomedepotWorker();
const { isRunning, startTask } = useLongTask();
const detailCache = new Map<string, HomedepotDetailItem>();
const unsubscribeFuncs = [] as (() => void)[];
onMounted(() => {
unsubscribeFuncs.push(
...[
worker.on('error', () => {
worker.stop();
}),
worker.on('detail-item-collected', (ev) => {
const { item } = ev;
if (detailCache.has(item.OSMID)) {
const origin = detailCache.get(item.OSMID);
detailCache.set(item.OSMID, { ...origin, ...item });
} else {
detailCache.set(item.OSMID, item);
}
}),
],
);
});
onUnmounted(() => {
unsubscribeFuncs.forEach((unsubscribe) => unsubscribe());
unsubscribeFuncs.splice(0, unsubscribeFuncs.length);
});
const commitChange = () => {
const { detailItems } = this.homedepotWorkerSettings;
if (typeof detailItems !== 'undefined') {
for (const [k, v] of detailCache.entries()) {
detailItems.value.delete(k); // Trigger update
detailItems.value.set(k, v);
}
detailCache.clear();
}
};
const taskWrapper = <T extends (...params: any) => any>(func: T) => {
const { commitChangeIngerval = 10000 } = this.homedepotWorkerSettings;
return (...params: Parameters<T>) =>
startTask(async () => {
const interval = setInterval(() => commitChange(), commitChangeIngerval);
await func(...params);
clearInterval(interval);
commitChange();
});
};
const runDetailPageTask = taskWrapper(worker.runDetailPageTask.bind(worker));
return {
isRunning,
runDetailPageTask,
on: worker.on.bind(worker),
off: worker.off.bind(worker),
once: worker.once.bind(worker),
stop: worker.stop.bind(worker),
};
}
loadHomedepotWorker(settings?: HomedepotWorkerSettings) {
if (settings) {
this.homedepotWorkerSettings = { ...this.homedepotWorkerSettings, ...settings };
}
if (!this.homedepotWorker) {
this.homedepotWorker = this.buildHomedepotWorker();
}
return this.homedepotWorker;
}
}
const amazonfacotry = new AmazonPageWorkerFactory();
const homedepotfactory = new HomedepotWorkerFactory();
import { AmazonPageWorkerSettings, useAmazonWorker } from './composables/amazon';
import { HomedepotWorkerSettings, useHomedepotWorker } from './composables/homedepot';
export function usePageWorker(
type: 'amazon',
settings?: AmazonPageWorkerSettings,
): ReturnType<typeof amazonfacotry.loadAmazonPageWorker>;
): ReturnType<typeof useAmazonWorker>;
export function usePageWorker(
type: 'homedepot',
settings?: HomedepotWorkerSettings,
): ReturnType<typeof homedepotfactory.loadHomedepotWorker>;
export function usePageWorker(type: 'amazon' | 'homedepot', settings?: any) {
): ReturnType<typeof useHomedepotWorker>;
export function usePageWorker(type: 'amazon' | 'homedepot', settings: any) {
let worker = null;
switch (type) {
case 'amazon':
return amazonfacotry.loadAmazonPageWorker(settings);
worker = useAmazonWorker();
break;
case 'homedepot':
return homedepotfactory.loadHomedepotWorker(settings);
worker = useHomedepotWorker();
break;
default:
throw new Error(`Unsupported page worker type: ${type}`);
}
if (!!settings && !!worker.settings) {
worker.settings.value = settings;
}
return worker;
}

View File

@ -11,10 +11,22 @@ export class HomedepotDetailPageInjector extends BaseInjector {
let timeout = false;
setTimeout(() => (timeout = true), 15000);
const isLoaded = () => {
const reviewPlaceholderEl = document.querySelector(
`#product-section-rr div[role='button'][aria-expanded='true']`,
const reviewSuffix = document.evaluate(
`//*[@id='product-section-rr']//p/text()[starts-with(., ' out of 5')]`,
document,
null,
XPathResult.STRING_TYPE,
);
const writeFirstReviewButton = document.evaluate(
`//section[@id='product-section-ratings-reviews']//span[starts-with(text(), 'Write the First Review')]`,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
);
return (
(!!writeFirstReviewButton.singleNodeValue || !!reviewSuffix.stringValue) &&
(document.readyState == 'complete' || timeout)
);
return reviewPlaceholderEl && (document.readyState == 'complete' || timeout);
};
while (true) {
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(Math.random() * 500)));

View File

@ -5,12 +5,13 @@ import {
createMemoryHistory,
RouteRecordRaw,
} from 'vue-router';
import { site } from '~/logic/storages/global';
import { site } from '~/storages/global';
const routeObj: Record<'sidepanel' | 'options', RouteRecordRaw[]> = {
options: [
{ path: '/', redirect: `/${site.value}` },
{ path: '/amazon', component: () => import('~/options/views/AmazonResultTable.vue') },
{ path: '/amazon-reviews', component: () => import('~/options/views/AmazonReviews.vue') },
{ path: '/homedepot', component: () => import('~/options/views/HomedepotResultTable.vue') },
],
sidepanel: [

View File

@ -2,7 +2,7 @@
import type { GlobalThemeOverrides } from 'naive-ui';
import { useRouter } from 'vue-router';
import { useCurrentUrl } from '~/composables/useCurrentUrl';
import { site } from '~/logic/storages/global';
import { site } from '~/storages/global';
import { zhCN, dateZhCN } from 'naive-ui';
const theme: GlobalThemeOverrides = {

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Timeline } from '~/components/ProgressReport.vue';
import { usePageWorker } from '~/page-worker';
import { detailAsinInput, detailItems, detailWorkerSettings } from '~/logic/storages/amazon';
import { detailAsinInput, detailWorkerSettings } from '~/storages/amazon';
const message = useMessage();
@ -10,7 +10,7 @@ const timelines = ref<Timeline[]>([]);
const asinInputRef = useTemplateRef('asin-input');
//#region Page Worker Code
const worker = usePageWorker('amazon', { detailItems });
const worker = usePageWorker('amazon', { objects: ['detail'] });
worker.on('error', ({ message: msg }) => {
timelines.value.push({
type: 'error',

View File

@ -1,9 +1,9 @@
<script lang="ts" setup>
import type { Timeline } from '~/components/ProgressReport.vue';
import { usePageWorker } from '~/page-worker';
import { reviewAsinInput, reviewItems } from '~/logic/storages/amazon';
import { reviewAsinInput } from '~/storages/amazon';
const worker = usePageWorker('amazon', { reviewItems });
const worker = usePageWorker('amazon', { objects: ['review'] });
worker.on('error', ({ message: msg }) => {
timelines.value.push({
type: 'error',

View File

@ -1,13 +1,12 @@
<script setup lang="ts">
import { keywordsList } from '~/logic/storages/amazon';
import { searchItems } from '~/logic/storages/amazon';
import { keywordsList } from '~/storages/amazon';
import type { Timeline } from '~/components/ProgressReport.vue';
import { usePageWorker } from '~/page-worker';
const message = useMessage();
//#region Initial Page Worker
const worker = usePageWorker('amazon', { searchItems });
const worker = usePageWorker('amazon', { objects: ['search'] });
worker.on('error', ({ message: msg }) => {
timelines.value.push({
type: 'error',

View File

@ -1,12 +1,11 @@
<script setup lang="ts">
import type { Timeline } from '~/components/ProgressReport.vue';
import { usePageWorker } from '~/page-worker';
import { detailItems } from '~/logic/storages/homedepot';
const inputText = ref('');
const idInputRef = useTemplateRef('id-input');
const worker = usePageWorker('homedepot', { detailItems });
const worker = usePageWorker('homedepot', { objects: ['detail'] });
worker.on('detail-item-collected', ({ item }) => {
timelines.value.push({
type: 'success',

View File

@ -33,6 +33,32 @@ export const reviewItems = useWebExtensionStorage<Map<string, AmazonReview[]>>(
},
);
export const allReviews = computed({
get() {
const reviews: ({ asin: string } & AmazonReview)[] = [];
for (const [asin, values] of reviewItems.value) {
for (const review of values) {
reviews.push({ asin, ...review });
}
}
return reviews;
},
set(newVal) {
const reviews = newVal.reduce<Map<string, AmazonReview[]>>((m, r) => {
if (!m.has(r.asin)) {
m.set(r.asin, []);
}
const reviews = m.get(r.asin)!;
reviews.push(r);
return m;
}, new Map());
for (const [_, values] of reviews) {
values.sort((a, b) => dayjs(b.dateInfo).diff(a.dateInfo));
}
reviewItems.value = reviews;
},
});
export const allItems = computed({
get() {
const sItems = unref(searchItems);

View File

@ -1,4 +1,7 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
export const detailInputText = useWebExtensionStorage('homedepot-detail-input-text', '');
export const detailItems = useWebExtensionStorage<Map<string, HomedepotDetailItem>>(
'homedepot-details',
new Map(),