mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-19 13:13:22 +08:00
Adjust page worker struture
This commit is contained in:
parent
d66c40b9e7
commit
3aef704fa5
@ -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": {
|
||||
|
||||
88
src/components/ExportPanel.vue
Normal file
88
src/components/ExportPanel.vue
Normal 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>
|
||||
@ -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 }>();
|
||||
|
||||
|
||||
82
src/composables/useExcelHelper.ts
Normal file
82
src/composables/useExcelHelper.ts
Normal 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);
|
||||
@ -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>[]> {
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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="关键词" />
|
||||
|
||||
@ -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>
|
||||
@ -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(`导出完成`);
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
157
src/page-worker/composables/amazon.ts
Normal file
157
src/page-worker/composables/amazon.ts
Normal 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);
|
||||
85
src/page-worker/composables/homedepot.ts
Normal file
85
src/page-worker/composables/homedepot.ts
Normal 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);
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)));
|
||||
@ -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: [
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
@ -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(),
|
||||
Loading…
x
Reference in New Issue
Block a user