v0.5.0 release

This commit is contained in:
johnathan 2025-07-04 11:47:53 +08:00
parent 3aef704fa5
commit a883f49d53
28 changed files with 814 additions and 729 deletions

2
.gitignore vendored
View File

@ -16,6 +16,8 @@ node_modules
src/auto-imports.d.ts
src/components.d.ts
.eslintcache
build/*
!build/.gitkeep
**/test_data.ts
**/TestPanel.vue

View File

@ -7,5 +7,6 @@
"*.css": "postcss"
},
"prettier.tabWidth": 2,
"prettier.printWidth": 100
"prettier.printWidth": 100,
"editor.tabSize": 2
}

View File

@ -20,9 +20,9 @@
"pack": "cross-env NODE_ENV=production run-p pack:*",
"pack:zip": "rimraf extension.zip && jszip-cli add extension/* -o ./extension.zip",
"pack:crx": "crx pack extension -o ./extension.crx",
"pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./extension --filename extension.xpi --overwrite-dest",
"start:chromium": "web-ext run --source-dir ./extension --target=chromium",
"start:firefox": "web-ext run --source-dir ./extension --target=firefox-desktop",
"pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./extension-firefox --filename extension.xpi --overwrite-dest",
"start:chromium": "web-ext run --source-dir ./extension --target=chromium --chromium-binary 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'",
"start:firefox": "web-ext run --source-dir ./extension-firefox --target=firefox-desktop",
"clear": "rimraf --glob extension/dist extension/manifest.json extension.* ",
"clear-firefox": "rimraf --glob extension-firefox/dist extension-firefox/manifest.json extension.*",
"test": "vitest test",
@ -39,6 +39,7 @@
"@vitejs/plugin-vue-jsx": "^4.2.0",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^12.3.0",
"alova": "^3.3.4",
"chokidar": "^4.0.3",
"cross-env": "^7.0.3",
"crx": "^5.0.1",
@ -66,7 +67,7 @@
"vue": "^3.5.13",
"vue-demi": "^0.14.10",
"vue-router": "^4.5.1",
"web-ext": "^8.5.0",
"web-ext": "^8.8.0",
"webext-bridge": "link:webext-bridge",
"webextension-polyfill": "^0.12.0"
},

442
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,12 @@ import { useExcelHelper } from '~/composables/useExcelHelper';
const excelHelper = useExcelHelper();
const emit = defineEmits<{ ['export']: [opt: 'local' | 'cloud'] }>();
const emit = defineEmits<{ exportFile: [opt: 'local' | 'cloud'] }>();
</script>
<template>
<ul v-if="!excelHelper.isRunning.value" class="exporter-menu">
<li @click="emit('export', 'local')">
<li @click="emit('exportFile', 'local')">
<n-tooltip :delay="1000" placement="right">
<template #trigger>
<div class="menu-item">
@ -19,7 +19,7 @@ const emit = defineEmits<{ ['export']: [opt: 'local' | 'cloud'] }>();
不包含图片
</n-tooltip>
</li>
<li @click="emit('export', 'cloud')">
<li @click="emit('exportFile', 'cloud')">
<n-tooltip :delay="1000" placement="right">
<template #trigger>
<div class="menu-item">

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
const page = reactive({ current: 1, size: 10 });
import type { EllipsisProps } from 'naive-ui';
export type TableColumn =
| {
@ -7,6 +7,7 @@ export type TableColumn =
key: string;
minWidth?: number;
hidden?: boolean;
ellipsis?: boolean | EllipsisProps;
render?: (row: any) => VNode;
}
| {
@ -16,10 +17,16 @@ export type TableColumn =
renderExpand: (row: any) => VNode;
};
const props = defineProps<{
const props = withDefaults(
defineProps<{
records: Record<string, unknown>[];
columns: TableColumn[];
}>();
defaultPageSize?: number;
}>(),
{ defaultPageSize: 10 },
);
const page = reactive({ current: 1, size: props.defaultPageSize });
const itemView = computed(() => {
const { current, size } = page;

View File

@ -8,8 +8,8 @@ defineProps<{ model: AmazonDetailItem }>();
<n-descriptions-item label="ASIN">
{{ model.asin }}
</n-descriptions-item>
<n-descriptions-item label="获取日期">
{{ model.timestamp }}
<n-descriptions-item label="销量信息">
{{ model.broughtInfo || '-' }}
</n-descriptions-item>
<n-descriptions-item label="评价">
{{ model.rating || '-' }}
@ -32,9 +32,12 @@ defineProps<{ model: AmazonDetailItem }>();
<n-descriptions-item label="图片链接" :span="4">
<image-link v-for="link in model.imageUrls" :url="link" />
</n-descriptions-item>
<n-descriptions-item v-if="model.aplus" label="A+" :span="4">
<n-descriptions-item v-if="model.aplus" label="A+" :span="2">
<image-link :url="model.aplus" />
</n-descriptions-item>
<n-descriptions-item label="获取日期" :span="2">
{{ model.timestamp }}
</n-descriptions-item>
</n-descriptions>
</div>
</template>

View File

@ -1,11 +1,13 @@
<script lang="ts" setup>
import { useElementSize } from '@vueuse/core';
import { exportToXLSX, Header, importFromXLSX } from '~/logic/excel';
import { useExcelHelper } from '~/composables/useExcelHelper';
import type { Header } from '~/logic/excel';
import { reviewItems } from '~/storages/amazon';
const props = defineProps<{ asin: string }>();
const message = useMessage();
const excelHelper = useExcelHelper();
const containerRef = useTemplateRef('review-list');
const { height } = useElementSize(containerRef);
@ -23,33 +25,32 @@ const page = reactive({
});
const view = computed(() => {
const filteredData = filterData(allReviews);
const offset = (page.current - 1) * page.pageSize;
if (offset >= filteredData.length && page.current > 1) {
if (offset >= filteredData.value.length && page.current > 1) {
page.current = 1;
}
const data = filteredData.slice(offset, offset + page.pageSize);
const pageCount = ~~(filteredData.length / page.pageSize);
return { data, pageCount, total: filteredData.length };
const data = filteredData.value.slice(offset, offset + page.pageSize);
const pageCount = ~~(filteredData.value.length / page.pageSize);
return { data, pageCount, total: filteredData.value.length };
});
const filterData = (data: AmazonReview[]) => {
let filteredData = data;
const filteredData = computed(() => {
let data = allReviews;
if (filter.keywords) {
filteredData = data.filter((item) => {
data = data.filter((item) => {
const keywords = filter.keywords.toLowerCase();
return (
item.title.toLowerCase().includes(keywords) ||
item.content.toLowerCase().includes(keywords) ||
item.username.toLowerCase().includes(keywords)
);
return [
item.title.toLowerCase(),
item.content.toLowerCase(),
item.content.toLowerCase(),
].some((s) => s.includes(keywords));
});
}
if (filter.rating) {
filteredData = filteredData.filter((item) => item.rating === filter.rating);
data = data.filter((item) => item.rating === filter.rating);
}
return filteredData;
};
return data;
});
const handleClearData = () => {
reviewItems.value.delete(props.asin);
@ -72,7 +73,7 @@ const headers: Header[] = [
];
const handleImport = async (file: File) => {
const importedData = await importFromXLSX<AmazonReview>(file, { headers });
const importedData = (await excelHelper.importFile(file, [headers]))[0].data as AmazonReview[];
if (importedData.length === 0) {
return;
}
@ -88,10 +89,18 @@ const handleImport = async (file: File) => {
}
};
const handleExport = () => {
const fileName = `${props.asin}Reviews${dayjs().format('YYYY-MM-DD')}.xlsx`;
exportToXLSX(allReviews, { headers, fileName });
message.info('导出完成');
const handleExport = async (opt: 'local' | 'cloud') => {
await excelHelper.exportFile(
[
{
data: allReviews,
headers,
name: `${props.asin}Reviews${dayjs().format('YYYY-MM-DD')}.xlsx`,
imageColumn: '图片链接',
},
],
{ cloud: opt === 'cloud' },
);
};
</script>
@ -122,7 +131,11 @@ const handleExport = () => {
}))
"
/>
<control-strip @import="handleImport" @export="handleExport" @clear="handleClearData" />
<control-strip @import="handleImport" @clear="handleClearData">
<template #exporter>
<export-panel @export-file="handleExport" />
</template>
</control-strip>
</n-space>
</div>
</div>

View File

@ -14,5 +14,5 @@ export function isForbiddenUrl(url: string): boolean {
export const isFirefox = navigator.userAgent.includes('Firefox');
// export const remoteHost = __DEV__ ? '127.0.0.1:8000' : '47.251.4.191:8000';
export const remoteHost = '47.251.4.191:8000';
export const remoteHost = __DEV__ ? '127.0.0.1:8000' : '47.251.4.191:8000';
// export const remoteHost = '47.251.4.191:8000';

53
src/global.d.ts vendored
View File

@ -11,6 +11,8 @@ declare module '*.vue' {
declare type AppContext = 'options' | 'sidepanel' | 'background' | 'content script';
declare type Website = 'amazon' | 'homedepot';
declare const appContext: AppContext;
declare interface Chrome {
@ -32,40 +34,77 @@ declare interface Chrome {
};
}
/**
*
*/
declare type AmazonSearchItem = {
/** 搜索关键词 */
keywords: string;
page: number;
link: string;
title: string;
asin: string;
/** 商品排名 */
rank: number;
/** 当前页码 */
page: number;
/** 商品链接 */
link: string;
/** 商品标题 */
title: string;
/** 商品的 ASIN亚马逊标准识别号 */
asin: string;
/** 商品价格(可选) */
price?: string;
/** 商品图片链接 */
imageSrc: string;
/** 创建时间 */
createTime: string;
};
declare type AmazonDetailItem = {
/** 商品的 ASIN亚马逊标准识别号 */
asin: string;
/** 商品标题 */
title: string;
/** 时间戳,表示数据的创建或更新时间 */
timestamp: string;
/** 销量信息 */
broughtInfo?: string;
/** 商品价格 */
price?: string;
/** 商品评分 */
rating?: number;
/** 评分数量 */
ratingCount?: number;
category1?: { name: string; rank: number };
category2?: { name: string; rank: number };
/** 大类排名 */
category1?: {
name: string;
rank: number;
};
/** 小类排名 */
category2?: {
name: string;
rank: number;
};
/** 商品图片链接数组 */
imageUrls?: string[];
/** A+截图链接 */
aplus?: string;
topReviews?: AmazonReview[];
// /** 顶部评论数组 */
// topReviews?: AmazonReview[];
};
declare type AmazonReview = {
/** 评论的唯一标识符 */
id: string;
/** 评论者用户名 */
username: string;
/** 评论标题 */
title: string;
/** 评论评分 */
rating: string;
/** 评论日期信息 */
dateInfo: string;
/** 评论内容 */
content: string;
/** 评论中包含的图片链接 */
imageSrc: string[];
};

24
src/logic/convert.ts Normal file
View File

@ -0,0 +1,24 @@
export function flattenObject(obj: Record<string, unknown>) {
const mappedEnties: [string[], unknown][] = [];
const stack: string[][] = Object.keys(obj).map((k) => [k]);
while (stack.length > 0) {
const keys = stack.shift()!;
let value: any = obj;
for (const key of keys) {
value = value[key];
}
if (typeof value === 'object' && value !== null) {
stack.push(...Object.keys(value).map((k) => keys.concat([k])));
} else {
mappedEnties.push([keys, value]);
}
}
return Object.fromEntries(
mappedEnties.map(([keys, value]) => {
const key = [keys[0]]
.concat(keys.slice(1).map((s) => `${s[0].toUpperCase()}${s.slice(1)}`))
.join('');
return [key, value];
}),
);
}

View File

@ -60,7 +60,11 @@ class Worksheet {
const rowData: Record<string, unknown> = {};
row.eachCell((cell, colNumber) => {
const header = this._ws.getRow(1).getCell(colNumber).value?.toString()!;
if (cell.value && typeof cell.value === 'object' && 'text' in cell.value) {
rowData[header] = cell.value.text;
} else {
rowData[header] = cell.value;
}
});
jsonData.push(rowData);
});

View File

@ -46,7 +46,7 @@ export async function exec<T, P extends Record<string, unknown>>(
): Promise<T> {
const { timeout = 30000 } = options;
return new Promise<T>(async (resolve, reject) => {
while (true) {
for (let i = 0; i < 50; i++) {
await new Promise<void>((r) => setTimeout(r, 200));
const tab = await browser.tabs.get(tabId);
if (tab.status === 'complete') {

View File

@ -1,13 +1,12 @@
<script setup lang="tsx">
import { NButton, NSpace } from 'naive-ui';
import type { TableColumn } from '~/components/ResultTable.vue';
import { useCloudExporter } from '~/composables/useCloudExporter';
import { formatRecords, createWorkbook, Header, importFromXLSX } from '~/logic/excel';
import { allItems, itemColumnSettings, reviewItems } from '~/storages/amazon';
import { useExcelHelper } from '~/composables/useExcelHelper';
import { Header } from '~/logic/excel';
import { allDetailItems, allItems, itemColumnSettings, reviewItems } from '~/storages/amazon';
const message = useMessage();
const modal = useModal();
const cloudExporter = useCloudExporter();
const excelHelper = useExcelHelper();
const filter = ref<{
keywords?: string;
@ -132,6 +131,7 @@ const extraHeaders: Header<AmazonItem>[] = [
formatOutputValue: (val: boolean) => (val ? '是' : '否'),
parseImportValue: (val: string) => val === '是',
},
{ prop: 'broughtInfo', label: '销量信息' },
{ prop: 'rating', label: '评分' },
{ prop: 'ratingCount', label: '评论数' },
{ prop: 'category1.name', label: '大类' },
@ -150,21 +150,6 @@ const extraHeaders: Header<AmazonItem>[] = [
},
];
const reviewHeaders: Header<AmazonReview>[] = [
{ prop: 'asin', label: 'ASIN' },
{ prop: 'username', label: '用户名' },
{ prop: 'title', label: '标题' },
{ prop: 'rating', label: '评分' },
{ prop: 'content', label: '内容' },
{ prop: 'dateInfo', label: '日期' },
{
prop: 'imageSrc',
label: '图片链接',
formatOutputValue: (val?: string[]) => val?.join(';'),
parseImportValue: (val?: string) => val?.split(';'),
},
];
const getItemHeaders = () => {
return columns.value
.filter((col: Record<string, any>) => col.key !== 'actions')
@ -182,7 +167,7 @@ const getItemHeaders = () => {
const filteredData = computed(() => {
const { search, detailOnly, keywords, searchDateRange, detailDateRange } = filter.value;
let data = toRaw(allItems.value);
let data = toRaw(detailOnly ? allDetailItems.value : allItems.value);
if (search && search.trim() !== '') {
data = data.filter((r) => {
return [r.title, r.asin, r.keywords].some((field) =>
@ -190,9 +175,6 @@ const filteredData = computed(() => {
);
});
}
if (detailOnly) {
data = data.filter((r) => r.hasDetail);
}
if (keywords) {
data = data.filter((r) => r.keywords === keywords);
}
@ -213,81 +195,28 @@ const filteredData = computed(() => {
return data;
});
const handleLocalExport = async () => {
const itemHeaders = getItemHeaders();
const handleExport = async (opt: 'local' | 'cloud') => {
const headers = getItemHeaders();
const items = toRaw(filteredData.value);
const asins = new Set(items.map((e) => e.asin));
const reviews = toRaw(reviewItems.value)
.entries()
.filter(([asin]) => asins.has(asin))
.reduce<(AmazonReview & { asin: string })[]>((a, [asin, reviews]) => {
a.push(...reviews.map((r) => ({ asin, ...r })));
return a;
}, []);
const wb = createWorkbook();
const sheet1 = wb.addSheet('items');
await sheet1.readJson(items, { headers: itemHeaders });
const sheet2 = wb.addSheet('reviews');
await sheet2.readJson(reviews, { headers: reviewHeaders });
await wb.exportFile(`Items ${dayjs().format('YYYY-MM-DD')}.xlsx`);
message.info('导出完成');
};
const handleCloudExport = async () => {
message.warning('正在导出,请勿关闭当前页面!', { duration: 2000 });
const itemHeaders = getItemHeaders();
const items = toRaw(filteredData.value);
const asins = new Set(items.map((e) => e.asin));
const reviews = toRaw(reviewItems.value)
.entries()
.filter(([asin]) => asins.has(asin))
.reduce<(AmazonReview & { asin: string })[]>((a, [asin, reviews]) => {
a.push(...reviews.map((r) => ({ asin, ...r })));
return a;
}, []);
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' },
{
data: items,
headers: headers,
imageColumn: ['A+截图', '商品图片链接'],
name: 'items',
},
];
const filename = await cloudExporter.doExport(fragments);
filename && message.info(`导出完成`);
await excelHelper.exportFile(fragments, { cloud: opt === 'cloud' });
};
const handleImport = async (file: File) => {
const itemHeaders = getItemHeaders();
const wb = await importFromXLSX(file, { asWorkBook: true });
const sheet1 = wb.getSheet(0)!;
const items = await sheet1.toJson<AmazonItem>({ headers: itemHeaders });
allItems.value = items;
if (wb.sheetCount > 1) {
const sheet2 = wb.getSheet(1)!;
const reviews = await sheet2.toJson<AmazonReview & { asin?: string }>({
headers: reviewHeaders,
});
reviewItems.value = reviews.reduce((m, r) => {
const asin = r.asin!;
delete r.asin;
m.has(asin) || m.set(asin, []);
const arr = m.get(asin)!;
arr.push(r);
return m;
}, new Map<string, AmazonReview[]>());
}
message.info(`成功导入 ${file.name} 文件`);
const headers = getItemHeaders();
const [dataFragment] = await excelHelper.importFile(file, [headers]);
allItems.value = dataFragment.data as typeof allItems.value;
};
const handleClearData = async () => {
allItems.value = [];
reviewItems.value = new Map();
};
</script>
@ -295,8 +224,8 @@ const handleClearData = async () => {
<div class="result-table">
<result-table :columns="columns" :records="filteredData">
<template #header>
<n-space>
<div style="padding-right: 10px">Amazon数据</div>
<n-space align="center">
<h3 class="header-text">Amazon Items</h3>
<n-switch size="small" class="filter-switch" v-model:value="filter.detailOnly">
<template #checked> 详情 </template>
<template #unchecked> 全部</template>
@ -307,51 +236,14 @@ const handleClearData = async () => {
<n-space size="small">
<n-input
v-model:value="filter.search"
size="small"
placeholder="输入文本过滤结果"
round
clearable
style="min-width: 230px"
/>
<control-strip round size="small" @clear="handleClearData" @import="handleImport">
<control-strip round @clear="handleClearData" @import="handleImport">
<template #exporter>
<ul v-if="!cloudExporter.isRunning.value" class="exporter-menu">
<li @click="handleLocalExport">
<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="handleCloudExport">
<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="
(cloudExporter.progress.current * 100) / cloudExporter.progress.total
"
>
<span>
{{ cloudExporter.progress.current }}/{{ cloudExporter.progress.total }}
</span>
</n-progress>
<n-button @click="cloudExporter.stop()">停止</n-button>
</div>
<export-panel @export-file="handleExport" />
</template>
<template #filter>
<div class="filter-section">
@ -360,7 +252,7 @@ const handleClearData = async () => {
:model="filter"
label-placement="left"
label-align="center"
label-width="100"
:label-width="95"
>
<n-form-item label="关键词">
<n-select
@ -420,55 +312,17 @@ const handleClearData = async () => {
<style scoped lang="scss">
.result-table {
width: 100%;
.header-text {
padding: 0px;
margin: 0px;
}
}
:deep(.filter-switch) {
font-size: 15px;
}
.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;
}
.filter-section {
max-width: 500px;

View File

@ -1,35 +1,226 @@
<script setup lang="tsx">
import { useCloudExporter } from '~/composables/useCloudExporter';
import type { TableColumn } from '~/components/ResultTable.vue';
import { allReviews } from '~/storages/amazon';
import { useExcelHelper } from '~/composables/useExcelHelper';
import type { Header } from '~/logic/excel';
const message = useMessage();
const cloudExporter = useCloudExporter();
const excelHelper = useExcelHelper();
const filter = ref<{ keywords?: string; rating?: string }>({});
const filter = ref<{
keywords: string;
rating: string | undefined;
asins: string[];
dateRange: [number, number] | undefined;
}>({
keywords: '',
rating: undefined,
asins: [],
dateRange: undefined,
});
const columns = computed<TableColumn[]>(() => {
return [
{
title: 'ASIN',
key: 'asin',
minWidth: 120,
},
{
title: 'ID',
key: 'id',
hidden: true,
},
{
title: '标题',
key: 'title',
minWidth: 200,
ellipsis: {
tooltip: { placement: 'top' },
},
},
{
title: '用户名',
key: 'username',
minWidth: 150,
},
{
title: '评分',
key: 'rating',
render(row: AmazonReview) {
return <n-rate readonly size="small" value={Number(row.rating.split('.')[0])} />;
},
},
{
title: '内容',
key: 'content',
minWidth: 500,
ellipsis: {
tooltip: { placement: 'top', contentStyle: { maxWidth: '60vw' } },
},
},
{
title: '日期信息',
key: 'dateInfo',
minWidth: 120,
render(row: AmazonReview) {
return <span>{dayjs(row.dateInfo).format('YYYY/M/D')}</span>;
},
},
];
});
const extraHeaders: Header[] = [
{
prop: 'imageSrc',
label: '图片链接',
formatOutputValue: (val?: string[]) => val?.join(';'),
parseImportValue: (val?: string) => val?.split(';'),
},
];
const getHeaders = () => {
return columns.value
.map((col: Record<string, any>) => ({ prop: col.key, label: col.title }) as Header)
.concat(extraHeaders);
};
const filteredData = computed(() => {
return allReviews.value;
let reviews = toRaw(allReviews.value);
if (filter.value.rating) {
reviews = reviews.filter((r) => r.rating === filter.value.rating);
}
if (filter.value.keywords) {
reviews = reviews.filter((r) => {
return [
r.content.toLocaleLowerCase(),
r.title.toLocaleLowerCase(),
r.username.toLocaleLowerCase(),
].some((s) => s.includes(filter.value.keywords.toLocaleLowerCase()));
});
}
if (filter.value.asins.length > 0) {
reviews = reviews.filter((r) => filter.value.asins.includes(r.asin));
}
if (filter.value.dateRange) {
reviews = reviews.filter((r) => {
const date = dayjs(r.dateInfo);
const start = dayjs(filter.value.dateRange![0]);
const end = dayjs(filter.value.dateRange![1]);
return date.diff(start, 'date') >= 0 && date.diff(end, 'date') <= 0;
});
}
return reviews;
});
const handleImport = async (file: File) => {
const [importedData] = await excelHelper.importFile(file, [getHeaders()]);
allReviews.value = importedData.data as typeof allReviews.value;
};
const handleExport = (opt: 'local' | 'cloud') => {
excelHelper.exportFile(
[{ data: filteredData.value, headers: getHeaders(), imageColumn: '图片链接' }],
{
cloud: opt === 'cloud',
},
);
};
const handleFilterReset = () => {
filter.value = {
keywords: '',
rating: undefined,
asins: [],
dateRange: undefined,
};
};
const handleClear = () => {
allReviews.value = [];
};
</script>
<template>
<div class="result-table">
<result-table :columns="columns" :records="filteredData"> </result-table>
<result-table :columns="columns" :records="filteredData">
<template #header>
<h3 class="header-text">Amazon Reviews</h3>
</template>
<template #header-extra>
<control-strip round @import="handleImport" @clear="handleClear">
<template #exporter>
<export-panel @export-file="handleExport" />
</template>
<template #filter>
<div class="filter-panel">
<div>筛选器</div>
<n-form :label-width="80" label-align="center" label-placement="left">
<n-form-item label="评分">
<n-select
v-model:value="filter.rating"
style="width: 200px"
round
placeholder="选择评分"
clearable
:options="
Array.from({ length: 5 }).map((_, i) => ({
label: `${i + 1}${'★'.repeat(i + 1)}`,
value: `${i + 1}.0 out of 5 stars`,
}))
"
/>
</n-form-item>
<n-form-item label="关键词">
<n-input clearable placeholder="请输入关键词" v-model:value="filter.keywords" />
</n-form-item>
<n-form-item label="ASIN">
<n-dynamic-tags v-model:value="filter.asins" />
</n-form-item>
<n-form-item label="日期范围">
<n-date-picker
type="daterange"
clearable
:value="filter.dateRange"
@update:value="
(newRange) => {
if (Array.isArray(newRange)) {
filter.dateRange = [newRange[0], newRange[1] + (24 * 3600 * 1000 - 1)];
} else {
filter.dateRange = newRange;
}
}
"
/>
</n-form-item>
</n-form>
<n-space justify="end">
<n-button @click="handleFilterReset">重置</n-button>
</n-space>
</div>
</template>
</control-strip>
</template>
</result-table>
</div>
</template>
<style lang="scss" scoped>
.result-table {
width: 100%;
.header-text {
padding: 0px;
margin: 0px;
}
}
.filter-panel {
min-width: 100px;
max-width: 500px;
& > div:first-of-type {
font-size: 20px;
margin-bottom: 20px;
}
}
</style>

View File

@ -1,11 +1,11 @@
<script setup lang="tsx">
import type { TableColumn } from '~/components/ResultTable.vue';
import { useCloudExporter } from '~/composables/useCloudExporter';
import { formatRecords, exportToXLSX, Header, importFromXLSX } from '~/logic/excel';
import { useExcelHelper } from '~/composables/useExcelHelper';
import type { Header } from '~/logic/excel';
import { allItems } from '~/storages/homedepot';
const message = useMessage();
const cloudExporter = useCloudExporter();
const excelHelper = useExcelHelper();
const columns: TableColumn[] = [
{
@ -100,23 +100,15 @@ const handleClearData = () => {
const handleImport = async (file: File) => {
const itemHeaders = getItemHeaders();
allItems.value = await importFromXLSX<(typeof allItems.value)[0]>(file, { headers: itemHeaders });
const [dataFragment] = await excelHelper.importFile(file, [itemHeaders]);
allItems.value = dataFragment.data as typeof allItems.value;
message.info(`成功导入 ${file.name} 文件`);
};
const handleLocalExport = async () => {
const handleExport = async (opt: 'cloud' | 'local') => {
const itemHeaders = getItemHeaders();
await exportToXLSX(filteredData.value, { headers: itemHeaders });
message.info(`导出完成`);
};
const handleCloudExport = async () => {
message.warning('正在导出,请勿关闭当前页面!', { duration: 2000 });
const itemHeaders = getItemHeaders();
const mappedData = await formatRecords(filteredData.value, itemHeaders);
const fragments = [{ data: mappedData, imageColumn: '主图链接' }];
const filename = await cloudExporter.doExport(fragments);
filename && message.info(`导出完成`);
const fragments = [{ data: filteredData.value, imageColumn: '主图链接', headers: itemHeaders }];
await excelHelper.exportFile(fragments, { cloud: opt === 'cloud' });
};
</script>
@ -124,48 +116,14 @@ const handleCloudExport = async () => {
<div class="result-table">
<result-table :records="filteredData" :columns="columns">
<template #header>
<n-space>
<div style="padding-right: 10px">Homedepot数据</div>
<n-space align="center">
<h3 class="header-text">Homedepot 数据</h3>
</n-space>
</template>
<template #header-extra>
<control-strip round size="small" @clear="handleClearData" @import="handleImport">
<control-strip round @clear="handleClearData" @import="handleImport">
<template #exporter>
<ul v-if="!cloudExporter.isRunning.value" class="exporter-menu">
<li @click="handleLocalExport">
<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="handleCloudExport">
<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="(cloudExporter.progress.current * 100) / cloudExporter.progress.total"
>
<span>
{{ cloudExporter.progress.current }}/{{ cloudExporter.progress.total }}
</span>
</n-progress>
<n-button @click="cloudExporter.stop()">停止</n-button>
</div>
<export-panel @export-file="handleExport" />
</template>
</control-strip>
</template>
@ -176,37 +134,10 @@ const handleCloudExport = async () => {
<style scoped lang="scss">
.result-table {
width: 100%;
}
.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;
}
.header-text {
padding: 0px;
margin: 0px;
}
}

View File

@ -192,10 +192,12 @@ class AmazonPageWorkerImpl
}
@withErrorHandling
public async wanderReviewPage(asin: string) {
public async wanderReviewPage(asin: string, options: { recent?: boolean } = {}) {
const { recent } = options;
const url = new URL(
`https://www.amazon.com/product-reviews/${asin}/ref=cm_cr_dp_d_show_all_btm?ie=UTF8&reviewerType=all_reviews`,
);
recent && url.searchParams.set('sortBy', 'recent');
const tab = await this.createNewTab(url.toString());
const injector = new AmazonReviewPageInjector(tab);
await injector.waitForPageLoad();
@ -254,7 +256,7 @@ class AmazonPageWorkerImpl
public async runReviewPageTask(
asins: string[],
options: LanchTaskBaseOptions = {},
options: LanchTaskBaseOptions & { recent?: boolean } = {},
): Promise<void> {
const { progress } = options;
const remains = [...asins];
@ -264,7 +266,7 @@ class AmazonPageWorkerImpl
});
while (remains.length > 0 && !interrupt) {
const asin = remains.shift()!;
await this.wanderReviewPage(asin);
await this.wanderReviewPage(asin, options);
progress && progress(remains);
}
unsubscribe();

View File

@ -1,12 +1,10 @@
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 { detailItems, reviewItems, searchItems } from '~/storages/amazon';
import { createGlobalState } from '@vueuse/core';
import { useAmazonService } from '~/services/amazon';
import { LanchTaskBaseOptions } from '../types';
export interface AmazonPageWorkerSettings {
objects?: ('search' | 'detail' | 'review')[];
@ -16,6 +14,7 @@ export interface AmazonPageWorkerSettings {
function buildAmazonPageWorker() {
const settings = shallowRef<AmazonPageWorkerSettings>({});
const { isRunning, startTask } = useLongTask();
const service = useAmazonService();
const worker = amazon.getAmazonPageWorker();
@ -47,6 +46,42 @@ function buildAmazonPageWorker() {
reviewCache.set(asin, values);
};
const commitChange = async () => {
const { objects } = settings.value;
if (objects?.includes('search')) {
searchItems.value = searchItems.value.concat(searchCache);
await service.commitSearchItems(searchCache);
searchCache.splice(0, searchCache.length);
}
if (objects?.includes('detail')) {
for (const [k, v] of detailCache.entries()) {
if (detailItems.value.has(k)) {
const item = detailItems.value.get(k)!;
detailItems.value.set(k, { ...item, ...v });
} else {
detailItems.value.set(k, v);
}
}
await service.commitDetailItems(detailCache);
detailCache.clear();
}
if (objects?.includes('review')) {
for (const [asin, reviews] of reviewCache.entries()) {
if (reviewItems.value.has(asin)) {
const addIds = new Set(reviews.map((x) => x.id));
const origin = reviewItems.value.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.value.set(asin, newReviews);
} else {
reviewItems.value.set(asin, reviews);
}
}
await service.commitReviews(reviewCache);
reviewCache.clear();
}
};
const unsubscribeFuncs = [] as (() => void)[];
onMounted(() => {
@ -67,9 +102,9 @@ function buildAmazonPageWorker() {
worker.on('item-images-collected', (ev) => {
updateDetailCache(ev);
}),
worker.on('item-top-reviews-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 });
@ -86,44 +121,8 @@ function buildAmazonPageWorker() {
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) => {
/**Commit change by interval time */
const taskWrapper1 = <T extends (...params: any[]) => Promise<void>>(func: T) => {
const { commitChangeIngerval = 10000 } = settings.value;
searchCache.splice(0, searchCache.length);
detailCache.clear();
@ -133,13 +132,41 @@ function buildAmazonPageWorker() {
const interval = setInterval(() => commitChange(), commitChangeIngerval);
await func(...params);
clearInterval(interval);
commitChange();
await commitChange();
});
};
const runDetailPageTask = taskWrapper(worker.runDetailPageTask.bind(worker));
const runSearchPageTask = taskWrapper(worker.runSearchPageTask.bind(worker));
const runReviewPageTask = taskWrapper(worker.runReviewPageTask.bind(worker));
/**Commit changes in the end of task unit */
const taskWrapper2 = <T extends (input: any, options?: LanchTaskBaseOptions) => Promise<void>>(
func: T,
) => {
searchCache.splice(0, searchCache.length);
detailCache.clear();
reviewCache.clear();
return (...params: Parameters<T>) =>
startTask(async () => {
if (!params?.[1]) {
params[1] = {};
}
const progressReporter = params[1].progress;
if (progressReporter) {
params[1].progress = async (...p: Parameters<typeof progressReporter>) => {
await commitChange();
return progressReporter(...p);
};
} else {
params[1].progress = async () => {
await commitChange();
};
}
await func(params[0], params[1]);
await commitChange();
});
};
const runSearchPageTask = taskWrapper1(worker.runSearchPageTask.bind(worker));
const runDetailPageTask = taskWrapper2(worker.runDetailPageTask.bind(worker));
const runReviewPageTask = taskWrapper1(worker.runReviewPageTask.bind(worker));
return {
settings,

View File

@ -9,7 +9,7 @@ export function usePageWorker(
type: 'homedepot',
settings?: HomedepotWorkerSettings,
): ReturnType<typeof useHomedepotWorker>;
export function usePageWorker(type: 'amazon' | 'homedepot', settings: any) {
export function usePageWorker(type: Website, settings: any) {
let worker = null;
switch (type) {
case 'amazon':

View File

@ -27,7 +27,7 @@ export interface AmazonPageWorkerEvents {
/**
* The event is fired when top reviews collected in detail page
*/
['item-top-reviews-collected']: Pick<AmazonDetailItem, 'asin' | 'topReviews'>;
// ['item-top-reviews-collected']: Pick<AmazonDetailItem, 'asin' | 'topReviews'>;
/**
* The event is fired when aplus screenshot-collect
*/
@ -65,7 +65,10 @@ export interface AmazonPageWorker extends Listener<AmazonPageWorkerEvents> {
* @param asins Amazon Standard Identification Numbers.
* @param options The Options Specify Behaviors.
*/
runReviewPageTask(asins: string[], options?: LanchTaskBaseOptions): Promise<void>;
runReviewPageTask(
asins: string[],
options?: LanchTaskBaseOptions & { recent?: boolean },
): Promise<void>;
/**
* Stop the worker.

View File

@ -145,7 +145,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
window.scrollBy(0, ~~(Math.random() * 500) + 500);
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 100) + 200));
const targetNode = document.querySelector(
'#prodDetails:has(td), #detailBulletsWrapper_feature_div:has(li), .av-page-desktop',
'#prodDetails:has(td), #detailBulletsWrapper_feature_div:has(li), .av-page-desktop, #productDescription_feature_div',
);
const exceptionalNodeSelectors = ['.music-detail-header', '.avu-retail-page'];
for (const selector of exceptionalNodeSelectors) {
@ -455,7 +455,7 @@ export class AmazonReviewPageInjector extends BaseInjector {
document.body,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLAnchorElement;
).singleNodeValue as HTMLElement;
starNode.click();
await new Promise((resolve) => setTimeout(resolve, 100));
},

41
src/services/amazon.ts Normal file
View File

@ -0,0 +1,41 @@
import { flattenObject } from '~/logic/convert';
import { BaseService } from './base';
class AmazonService extends BaseService {
async commitSearchItems(items: AmazonSearchItem[]) {
await this.client.Post<Response>('/amazon/search_items', items);
}
async commitDetailItems(items: Map<string, AmazonDetailItem>) {
await this.client.Post<Response>(
'/amazon/detail_items',
Array.from(
items
.values()
.map((item) => {
return item.imageUrls
? {
...item,
imageUrls: item.imageUrls.join(';'),
}
: item;
})
.map(flattenObject),
),
);
}
async commitReviews(items: Map<string, AmazonReview[]>) {
await this.client.Post<Response>(
'/amazon/reviews',
items.entries().reduce<any[]>((allReviews, [asin, reviews]) => {
allReviews.push(...reviews.map((r) => ({ asin, ...r, imageSrc: r.imageSrc?.join(';') })));
return allReviews;
}, []),
);
}
}
const service = new AmazonService();
export const useAmazonService = () => service;

24
src/services/base.ts Normal file
View File

@ -0,0 +1,24 @@
import { createAlova } from 'alova';
import adapterFetch from 'alova/fetch';
import { remoteHost } from '~/env';
import VueHook from 'alova/vue';
const httpClient = createAlova({
baseURL: `http://${remoteHost}`,
requestAdapter: adapterFetch(),
timeout: 10000,
statesHook: VueHook,
responded: {
onSuccess: (response) => response.json(),
onError: (response: Response) => {
const message = useMessage();
message.error(`HTTP Error: ${response.status}`);
},
},
});
export abstract class BaseService {
get client() {
return httpClient;
}
}

View File

@ -47,14 +47,14 @@ worker.on('item-images-collected', (ev) => {
content: `图片数: ${ev.imageUrls!.length}`,
});
});
worker.on('item-top-reviews-collected', (ev) => {
timelines.value.push({
type: 'success',
title: `商品${ev.asin}精选评论`,
time: new Date().toLocaleString(),
content: `精选评论数: ${ev.topReviews!.length}`,
});
});
// worker.on('item-top-reviews-collected', (ev) => {
// timelines.value.push({
// type: 'success',
// title: `${ev.asin}`,
// time: new Date().toLocaleString(),
// content: ` ${ev.topReviews!.length}`,
// });
// });
worker.on('item-aplus-screenshot-collect', (ev) => {
timelines.value.push({
type: 'success',

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { Timeline } from '~/components/ProgressReport.vue';
import { usePageWorker } from '~/page-worker';
import { reviewAsinInput } from '~/storages/amazon';
import { reviewAsinInput, reviewWorkerSettings } from '~/storages/amazon';
const worker = usePageWorker('amazon', { objects: ['review'] });
worker.on('error', ({ message: msg }) => {
@ -41,6 +41,7 @@ const launch = async () => {
progress: (remains) => {
reviewAsinInput.value = remains.join('\n');
},
recent: reviewWorkerSettings.value.recent,
});
timelines.value.push({
type: 'info',
@ -64,7 +65,20 @@ const handleInterrupt = () => {
<div class="review-page-entry">
<header-title>Amazon Review</header-title>
<div class="interative-section">
<ids-input v-model="reviewAsinInput" :disabled="worker.isRunning.value" ref="asin-input" />
<ids-input v-model="reviewAsinInput" :disabled="worker.isRunning.value" ref="asin-input">
<template #extra-settings>
<div class="setting-panel">
<n-form label-placement="left">
<n-form-item label="模式:" :feedback-style="{ display: 'none' }">
<n-radio-group v-model:value="reviewWorkerSettings.recent">
<n-radio :value="true" key="Recent" label="Recent" />
<n-radio :value="false" key="Top" label="Top" />
</n-radio-group>
</n-form-item>
</n-form>
</div>
</template>
</ids-input>
<n-button
v-if="!worker.isRunning.value"
round
@ -120,4 +134,8 @@ const handleInterrupt = () => {
.progress-report {
width: 90%;
}
.setting-panel {
padding: 7px 10px;
}
</style>

View File

@ -10,13 +10,18 @@ export const itemColumnSettings = useWebExtensionStorage<
Set<keyof Pick<AmazonItem, 'keywords' | 'page' | 'rank' | 'createTime' | 'timestamp'>>
>('itemColumnSettings', new Set(['keywords', 'page', 'rank', 'createTime']));
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []);
export const detailWorkerSettings = useWebExtensionStorage<{ aplus: boolean }>(
'amazon-detail-worker-settings',
{ aplus: false },
);
export const reviewWorkerSettings = useWebExtensionStorage<{ recent: boolean }>(
'amazon-review-worker-settings',
{ recent: true },
);
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []);
export const detailItems = useWebExtensionStorage<Map<string, AmazonDetailItem>>(
'detailItems',
new Map(),
@ -33,7 +38,79 @@ export const reviewItems = useWebExtensionStorage<Map<string, AmazonReview[]>>(
},
);
export const allReviews = computed({
export const allItems = computed<AmazonItem[]>({
get() {
const sItems = toRaw(searchItems.value);
const dItems = toRaw(detailItems.value);
const sItemSet = new Set(sItems.map((si) => si.asin));
const baseAllItems = sItems.map<AmazonItem>((si) => {
const asin = si.asin;
const dItem = dItems.get(asin);
return dItems.has(asin) ? { ...si, ...dItem, hasDetail: true } : { ...si, hasDetail: false };
});
const additionalItems = Array.from(dItems.values())
.filter((di) => !sItemSet.has(di.asin))
.map((di) => ({ ...di, link: `https://www.amazon.com/dp/${di.asin}`, hasDetail: true }));
return baseAllItems.concat(additionalItems);
},
set(newValue) {
const searchItemKeys: (keyof AmazonSearchItem)[] = [
'keywords',
'asin',
'page',
'title',
'imageSrc',
'price',
'link',
'rank',
'createTime',
];
searchItems.value = newValue
.filter((row) => row.keywords)
.map((row) => {
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
searchItemKeys.includes(key as keyof AmazonSearchItem),
);
return Object.fromEntries(entries) as AmazonSearchItem;
});
const detailItemExcludedKeys = [
'hasDetail',
...searchItemKeys.filter((k) => !['price', 'title', 'asin'].includes(k)),
];
detailItems.value = newValue
.filter((row) => row.hasDetail)
.reduce<Map<string, AmazonDetailItem>>((m, row) => {
const entries = Object.entries(row).filter(
([key]) => !detailItemExcludedKeys.includes(key),
);
const obj = Object.fromEntries(entries) as AmazonDetailItem;
m.set(obj.asin, obj);
return m;
}, new Map());
},
});
export const allDetailItems = computed<AmazonItem[]>(() => {
const sItems = toRaw(searchItems.value)
.toSorted((a, b) => dayjs(b.createTime).diff(dayjs(a.createTime)))
.reduce((m, r) => {
m.has(r.asin) || m.set(r.asin, r);
return m;
}, new Map<string, AmazonSearchItem>());
return Array.from(
toRaw(detailItems.value)
.values()
.map((di) => {
if (sItems.has(di.asin)) {
return { ...sItems.get(di.asin)!, ...di, hasDetail: true };
} else {
return { ...di, hasDetail: true };
}
}),
);
});
export const allReviews = computed<({ asin: string } & AmazonReview)[]>({
get() {
const reviews: ({ asin: string } & AmazonReview)[] = [];
for (const [asin, values] of reviewItems.value) {
@ -58,68 +135,3 @@ export const allReviews = computed({
reviewItems.value = reviews;
},
});
export const allItems = computed({
get() {
const sItems = unref(searchItems);
const sItemSet = new Set(sItems.map((si) => si.asin));
const dItems = unref(detailItems);
return sItems
.map<AmazonItem>((si) => {
const asin = si.asin;
const dItem = dItems.get(asin);
return dItems.has(asin)
? { ...si, ...dItem, hasDetail: true }
: { ...si, hasDetail: false };
})
.concat(
Array.from(dItems.values())
.filter((di) => !sItemSet.has(di.asin))
.map((di) => ({ ...di, link: `https://www.amazon.com/dp/${di.asin}`, hasDetail: true })),
);
},
set(newValue) {
const searchItemProps: (keyof AmazonSearchItem)[] = [
'keywords',
'asin',
'page',
'title',
'imageSrc',
'price',
'link',
'rank',
'createTime',
];
searchItems.value = newValue
.filter((row) => row.keywords)
.map((row) => {
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
searchItemProps.includes(key as keyof AmazonSearchItem),
);
return Object.fromEntries(entries) as AmazonSearchItem;
});
const detailItemsProps: (keyof AmazonDetailItem)[] = [
'asin',
'title',
'timestamp',
'price',
'category1',
'category2',
'imageUrls',
'rating',
'ratingCount',
'topReviews',
'aplus',
];
detailItems.value = newValue
.filter((row) => row.hasDetail)
.reduce<Map<string, AmazonDetailItem>>((m, row) => {
const entries = Object.entries(row).filter(([key]) =>
detailItemsProps.includes(key as keyof AmazonDetailItem),
);
const obj = Object.fromEntries(entries) as AmazonDetailItem;
m.set(obj.asin, obj);
return m;
}, new Map());
},
});

View File

@ -1,3 +1,3 @@
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
export const site = useWebExtensionStorage<'amazon' | 'homedepot'>('site', 'amazon');
export const site = useWebExtensionStorage<Website>('site', 'amazon');