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/auto-imports.d.ts
src/components.d.ts src/components.d.ts
.eslintcache .eslintcache
build/*
!build/.gitkeep
**/test_data.ts **/test_data.ts
**/TestPanel.vue **/TestPanel.vue

View File

@ -7,5 +7,6 @@
"*.css": "postcss" "*.css": "postcss"
}, },
"prettier.tabWidth": 2, "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": "cross-env NODE_ENV=production run-p pack:*",
"pack:zip": "rimraf extension.zip && jszip-cli add extension/* -o ./extension.zip", "pack:zip": "rimraf extension.zip && jszip-cli add extension/* -o ./extension.zip",
"pack:crx": "crx pack extension -o ./extension.crx", "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", "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", "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 --target=firefox-desktop", "start:firefox": "web-ext run --source-dir ./extension-firefox --target=firefox-desktop",
"clear": "rimraf --glob extension/dist extension/manifest.json extension.* ", "clear": "rimraf --glob extension/dist extension/manifest.json extension.* ",
"clear-firefox": "rimraf --glob extension-firefox/dist extension-firefox/manifest.json extension.*", "clear-firefox": "rimraf --glob extension-firefox/dist extension-firefox/manifest.json extension.*",
"test": "vitest test", "test": "vitest test",
@ -39,6 +39,7 @@
"@vitejs/plugin-vue-jsx": "^4.2.0", "@vitejs/plugin-vue-jsx": "^4.2.0",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"@vueuse/core": "^12.3.0", "@vueuse/core": "^12.3.0",
"alova": "^3.3.4",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"crx": "^5.0.1", "crx": "^5.0.1",
@ -66,7 +67,7 @@
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-demi": "^0.14.10", "vue-demi": "^0.14.10",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"web-ext": "^8.5.0", "web-ext": "^8.8.0",
"webext-bridge": "link:webext-bridge", "webext-bridge": "link:webext-bridge",
"webextension-polyfill": "^0.12.0" "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 excelHelper = useExcelHelper();
const emit = defineEmits<{ ['export']: [opt: 'local' | 'cloud'] }>(); const emit = defineEmits<{ exportFile: [opt: 'local' | 'cloud'] }>();
</script> </script>
<template> <template>
<ul v-if="!excelHelper.isRunning.value" class="exporter-menu"> <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"> <n-tooltip :delay="1000" placement="right">
<template #trigger> <template #trigger>
<div class="menu-item"> <div class="menu-item">
@ -19,7 +19,7 @@ const emit = defineEmits<{ ['export']: [opt: 'local' | 'cloud'] }>();
不包含图片 不包含图片
</n-tooltip> </n-tooltip>
</li> </li>
<li @click="emit('export', 'cloud')"> <li @click="emit('exportFile', 'cloud')">
<n-tooltip :delay="1000" placement="right"> <n-tooltip :delay="1000" placement="right">
<template #trigger> <template #trigger>
<div class="menu-item"> <div class="menu-item">

View File

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

View File

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

View File

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

View File

@ -14,5 +14,5 @@ export function isForbiddenUrl(url: string): boolean {
export const isFirefox = navigator.userAgent.includes('Firefox'); export const isFirefox = navigator.userAgent.includes('Firefox');
// export const remoteHost = __DEV__ ? '127.0.0.1:8000' : '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'; // 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 AppContext = 'options' | 'sidepanel' | 'background' | 'content script';
declare type Website = 'amazon' | 'homedepot';
declare const appContext: AppContext; declare const appContext: AppContext;
declare interface Chrome { declare interface Chrome {
@ -32,40 +34,77 @@ declare interface Chrome {
}; };
} }
/**
*
*/
declare type AmazonSearchItem = { declare type AmazonSearchItem = {
/** 搜索关键词 */
keywords: string; keywords: string;
page: number; /** 商品排名 */
link: string;
title: string;
asin: string;
rank: number; rank: number;
/** 当前页码 */
page: number;
/** 商品链接 */
link: string;
/** 商品标题 */
title: string;
/** 商品的 ASIN亚马逊标准识别号 */
asin: string;
/** 商品价格(可选) */
price?: string; price?: string;
/** 商品图片链接 */
imageSrc: string; imageSrc: string;
/** 创建时间 */
createTime: string; createTime: string;
}; };
declare type AmazonDetailItem = { declare type AmazonDetailItem = {
/** 商品的 ASIN亚马逊标准识别号 */
asin: string; asin: string;
/** 商品标题 */
title: string; title: string;
/** 时间戳,表示数据的创建或更新时间 */
timestamp: string; timestamp: string;
/** 销量信息 */
broughtInfo?: string; broughtInfo?: string;
/** 商品价格 */
price?: string; price?: string;
/** 商品评分 */
rating?: number; rating?: number;
/** 评分数量 */
ratingCount?: 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[]; imageUrls?: string[];
/** A+截图链接 */
aplus?: string; aplus?: string;
topReviews?: AmazonReview[]; // /** 顶部评论数组 */
// topReviews?: AmazonReview[];
}; };
declare type AmazonReview = { declare type AmazonReview = {
/** 评论的唯一标识符 */
id: string; id: string;
/** 评论者用户名 */
username: string; username: string;
/** 评论标题 */
title: string; title: string;
/** 评论评分 */
rating: string; rating: string;
/** 评论日期信息 */
dateInfo: string; dateInfo: string;
/** 评论内容 */
content: string; content: string;
/** 评论中包含的图片链接 */
imageSrc: 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> = {}; const rowData: Record<string, unknown> = {};
row.eachCell((cell, colNumber) => { row.eachCell((cell, colNumber) => {
const header = this._ws.getRow(1).getCell(colNumber).value?.toString()!; const header = this._ws.getRow(1).getCell(colNumber).value?.toString()!;
rowData[header] = cell.value; if (cell.value && typeof cell.value === 'object' && 'text' in cell.value) {
rowData[header] = cell.value.text;
} else {
rowData[header] = cell.value;
}
}); });
jsonData.push(rowData); jsonData.push(rowData);
}); });

View File

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

View File

@ -1,13 +1,12 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { NButton, NSpace } from 'naive-ui'; import { NButton, NSpace } from 'naive-ui';
import type { TableColumn } from '~/components/ResultTable.vue'; import type { TableColumn } from '~/components/ResultTable.vue';
import { useCloudExporter } from '~/composables/useCloudExporter'; import { useExcelHelper } from '~/composables/useExcelHelper';
import { formatRecords, createWorkbook, Header, importFromXLSX } from '~/logic/excel'; import { Header } from '~/logic/excel';
import { allItems, itemColumnSettings, reviewItems } from '~/storages/amazon'; import { allDetailItems, allItems, itemColumnSettings, reviewItems } from '~/storages/amazon';
const message = useMessage();
const modal = useModal(); const modal = useModal();
const cloudExporter = useCloudExporter(); const excelHelper = useExcelHelper();
const filter = ref<{ const filter = ref<{
keywords?: string; keywords?: string;
@ -132,6 +131,7 @@ const extraHeaders: Header<AmazonItem>[] = [
formatOutputValue: (val: boolean) => (val ? '是' : '否'), formatOutputValue: (val: boolean) => (val ? '是' : '否'),
parseImportValue: (val: string) => val === '是', parseImportValue: (val: string) => val === '是',
}, },
{ prop: 'broughtInfo', label: '销量信息' },
{ prop: 'rating', label: '评分' }, { prop: 'rating', label: '评分' },
{ prop: 'ratingCount', label: '评论数' }, { prop: 'ratingCount', label: '评论数' },
{ prop: 'category1.name', 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 = () => { const getItemHeaders = () => {
return columns.value return columns.value
.filter((col: Record<string, any>) => col.key !== 'actions') .filter((col: Record<string, any>) => col.key !== 'actions')
@ -182,7 +167,7 @@ const getItemHeaders = () => {
const filteredData = computed(() => { const filteredData = computed(() => {
const { search, detailOnly, keywords, searchDateRange, detailDateRange } = filter.value; 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() !== '') { if (search && search.trim() !== '') {
data = data.filter((r) => { data = data.filter((r) => {
return [r.title, r.asin, r.keywords].some((field) => 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) { if (keywords) {
data = data.filter((r) => r.keywords === keywords); data = data.filter((r) => r.keywords === keywords);
} }
@ -213,81 +195,28 @@ const filteredData = computed(() => {
return data; return data;
}); });
const handleLocalExport = async () => { const handleExport = async (opt: 'local' | 'cloud') => {
const itemHeaders = getItemHeaders(); const headers = getItemHeaders();
const items = toRaw(filteredData.value); 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 = [ 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); await excelHelper.exportFile(fragments, { cloud: opt === 'cloud' });
filename && message.info(`导出完成`);
}; };
const handleImport = async (file: File) => { const handleImport = async (file: File) => {
const itemHeaders = getItemHeaders(); const headers = getItemHeaders();
const wb = await importFromXLSX(file, { asWorkBook: true }); const [dataFragment] = await excelHelper.importFile(file, [headers]);
allItems.value = dataFragment.data as typeof allItems.value;
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 handleClearData = async () => { const handleClearData = async () => {
allItems.value = []; allItems.value = [];
reviewItems.value = new Map();
}; };
</script> </script>
@ -295,8 +224,8 @@ const handleClearData = async () => {
<div class="result-table"> <div class="result-table">
<result-table :columns="columns" :records="filteredData"> <result-table :columns="columns" :records="filteredData">
<template #header> <template #header>
<n-space> <n-space align="center">
<div style="padding-right: 10px">Amazon数据</div> <h3 class="header-text">Amazon Items</h3>
<n-switch size="small" class="filter-switch" v-model:value="filter.detailOnly"> <n-switch size="small" class="filter-switch" v-model:value="filter.detailOnly">
<template #checked> 详情 </template> <template #checked> 详情 </template>
<template #unchecked> 全部</template> <template #unchecked> 全部</template>
@ -307,51 +236,14 @@ const handleClearData = async () => {
<n-space size="small"> <n-space size="small">
<n-input <n-input
v-model:value="filter.search" v-model:value="filter.search"
size="small"
placeholder="输入文本过滤结果" placeholder="输入文本过滤结果"
round round
clearable clearable
style="min-width: 230px" style="min-width: 230px"
/> />
<control-strip round size="small" @clear="handleClearData" @import="handleImport"> <control-strip round @clear="handleClearData" @import="handleImport">
<template #exporter> <template #exporter>
<ul v-if="!cloudExporter.isRunning.value" class="exporter-menu"> <export-panel @export-file="handleExport" />
<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>
</template> </template>
<template #filter> <template #filter>
<div class="filter-section"> <div class="filter-section">
@ -360,7 +252,7 @@ const handleClearData = async () => {
:model="filter" :model="filter"
label-placement="left" label-placement="left"
label-align="center" label-align="center"
label-width="100" :label-width="95"
> >
<n-form-item label="关键词"> <n-form-item label="关键词">
<n-select <n-select
@ -420,55 +312,17 @@ const handleClearData = async () => {
<style scoped lang="scss"> <style scoped lang="scss">
.result-table { .result-table {
width: 100%; width: 100%;
.header-text {
padding: 0px;
margin: 0px;
}
} }
:deep(.filter-switch) { :deep(.filter-switch) {
font-size: 15px; 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 { .filter-section {
max-width: 500px; max-width: 500px;

View File

@ -1,35 +1,226 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { useCloudExporter } from '~/composables/useCloudExporter';
import type { TableColumn } from '~/components/ResultTable.vue'; import type { TableColumn } from '~/components/ResultTable.vue';
import { allReviews } from '~/storages/amazon'; import { allReviews } from '~/storages/amazon';
import { useExcelHelper } from '~/composables/useExcelHelper';
import type { Header } from '~/logic/excel';
const message = useMessage(); const excelHelper = useExcelHelper();
const cloudExporter = useCloudExporter();
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[]>(() => { const columns = computed<TableColumn[]>(() => {
return [ return [
{ {
title: 'ASIN', title: 'ASIN',
key: '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(() => { 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> </script>
<template> <template>
<div class="result-table"> <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> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.result-table { .result-table {
width: 100%; 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> </style>

View File

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

View File

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

View File

@ -1,12 +1,10 @@
import { useLongTask } from '~/composables/useLongTask'; import { useLongTask } from '~/composables/useLongTask';
import amazon from '../amazon'; import amazon from '../amazon';
import { uploadImage } from '~/logic/upload'; import { uploadImage } from '~/logic/upload';
import { import { detailItems, reviewItems, searchItems } from '~/storages/amazon';
detailItems as amazonDetailItems,
reviewItems as amazonReviewItems,
searchItems as amazonSearchItems,
} from '~/storages/amazon';
import { createGlobalState } from '@vueuse/core'; import { createGlobalState } from '@vueuse/core';
import { useAmazonService } from '~/services/amazon';
import { LanchTaskBaseOptions } from '../types';
export interface AmazonPageWorkerSettings { export interface AmazonPageWorkerSettings {
objects?: ('search' | 'detail' | 'review')[]; objects?: ('search' | 'detail' | 'review')[];
@ -16,6 +14,7 @@ export interface AmazonPageWorkerSettings {
function buildAmazonPageWorker() { function buildAmazonPageWorker() {
const settings = shallowRef<AmazonPageWorkerSettings>({}); const settings = shallowRef<AmazonPageWorkerSettings>({});
const { isRunning, startTask } = useLongTask(); const { isRunning, startTask } = useLongTask();
const service = useAmazonService();
const worker = amazon.getAmazonPageWorker(); const worker = amazon.getAmazonPageWorker();
@ -47,6 +46,42 @@ function buildAmazonPageWorker() {
reviewCache.set(asin, values); 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)[]; const unsubscribeFuncs = [] as (() => void)[];
onMounted(() => { onMounted(() => {
@ -67,9 +102,9 @@ function buildAmazonPageWorker() {
worker.on('item-images-collected', (ev) => { worker.on('item-images-collected', (ev) => {
updateDetailCache(ev); updateDetailCache(ev);
}), }),
worker.on('item-top-reviews-collected', (ev) => { // worker.on('item-top-reviews-collected', (ev) => {
updateDetailCache(ev); // updateDetailCache(ev);
}), // }),
worker.on('item-aplus-screenshot-collect', async (ev) => { worker.on('item-aplus-screenshot-collect', async (ev) => {
const url = await uploadImage(ev.base64data, `${ev.asin}.png`); const url = await uploadImage(ev.base64data, `${ev.asin}.png`);
url && updateDetailCache({ asin: ev.asin, aplus: url }); url && updateDetailCache({ asin: ev.asin, aplus: url });
@ -86,44 +121,8 @@ function buildAmazonPageWorker() {
unsubscribeFuncs.splice(0, unsubscribeFuncs.length); unsubscribeFuncs.splice(0, unsubscribeFuncs.length);
}); });
const commitChange = () => { /**Commit change by interval time */
const { objects } = settings.value; const taskWrapper1 = <T extends (...params: any[]) => Promise<void>>(func: T) => {
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; const { commitChangeIngerval = 10000 } = settings.value;
searchCache.splice(0, searchCache.length); searchCache.splice(0, searchCache.length);
detailCache.clear(); detailCache.clear();
@ -133,13 +132,41 @@ function buildAmazonPageWorker() {
const interval = setInterval(() => commitChange(), commitChangeIngerval); const interval = setInterval(() => commitChange(), commitChangeIngerval);
await func(...params); await func(...params);
clearInterval(interval); clearInterval(interval);
commitChange(); await commitChange();
}); });
}; };
const runDetailPageTask = taskWrapper(worker.runDetailPageTask.bind(worker)); /**Commit changes in the end of task unit */
const runSearchPageTask = taskWrapper(worker.runSearchPageTask.bind(worker)); const taskWrapper2 = <T extends (input: any, options?: LanchTaskBaseOptions) => Promise<void>>(
const runReviewPageTask = taskWrapper(worker.runReviewPageTask.bind(worker)); 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 { return {
settings, settings,

View File

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

View File

@ -27,7 +27,7 @@ export interface AmazonPageWorkerEvents {
/** /**
* The event is fired when top reviews collected in detail page * 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 * 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 asins Amazon Standard Identification Numbers.
* @param options The Options Specify Behaviors. * @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. * Stop the worker.

View File

@ -145,7 +145,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
window.scrollBy(0, ~~(Math.random() * 500) + 500); window.scrollBy(0, ~~(Math.random() * 500) + 500);
await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 100) + 200)); await new Promise((resolve) => setTimeout(resolve, ~~(Math.random() * 100) + 200));
const targetNode = document.querySelector( 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']; const exceptionalNodeSelectors = ['.music-detail-header', '.avu-retail-page'];
for (const selector of exceptionalNodeSelectors) { for (const selector of exceptionalNodeSelectors) {
@ -455,7 +455,7 @@ export class AmazonReviewPageInjector extends BaseInjector {
document.body, document.body,
null, null,
XPathResult.FIRST_ORDERED_NODE_TYPE, XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLAnchorElement; ).singleNodeValue as HTMLElement;
starNode.click(); starNode.click();
await new Promise((resolve) => setTimeout(resolve, 100)); 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}`, content: `图片数: ${ev.imageUrls!.length}`,
}); });
}); });
worker.on('item-top-reviews-collected', (ev) => { // worker.on('item-top-reviews-collected', (ev) => {
timelines.value.push({ // timelines.value.push({
type: 'success', // type: 'success',
title: `商品${ev.asin}精选评论`, // title: `${ev.asin}`,
time: new Date().toLocaleString(), // time: new Date().toLocaleString(),
content: `精选评论数: ${ev.topReviews!.length}`, // content: ` ${ev.topReviews!.length}`,
}); // });
}); // });
worker.on('item-aplus-screenshot-collect', (ev) => { worker.on('item-aplus-screenshot-collect', (ev) => {
timelines.value.push({ timelines.value.push({
type: 'success', type: 'success',
@ -108,7 +108,7 @@ const handleInterrupt = () => {
<template #extra-settings> <template #extra-settings>
<div class="setting-panel"> <div class="setting-panel">
<n-form label-placement="left"> <n-form label-placement="left">
<n-form-item label="Aplus: " :feedback-style="{ display: 'none' }"> <n-form-item label="Aplus:" :feedback-style="{ display: 'none' }">
<n-switch v-model:value="detailWorkerSettings.aplus" /> <n-switch v-model:value="detailWorkerSettings.aplus" />
</n-form-item> </n-form-item>
</n-form> </n-form>

View File

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Timeline } from '~/components/ProgressReport.vue'; import type { Timeline } from '~/components/ProgressReport.vue';
import { usePageWorker } from '~/page-worker'; import { usePageWorker } from '~/page-worker';
import { reviewAsinInput } from '~/storages/amazon'; import { reviewAsinInput, reviewWorkerSettings } from '~/storages/amazon';
const worker = usePageWorker('amazon', { objects: ['review'] }); const worker = usePageWorker('amazon', { objects: ['review'] });
worker.on('error', ({ message: msg }) => { worker.on('error', ({ message: msg }) => {
@ -41,6 +41,7 @@ const launch = async () => {
progress: (remains) => { progress: (remains) => {
reviewAsinInput.value = remains.join('\n'); reviewAsinInput.value = remains.join('\n');
}, },
recent: reviewWorkerSettings.value.recent,
}); });
timelines.value.push({ timelines.value.push({
type: 'info', type: 'info',
@ -64,7 +65,20 @@ const handleInterrupt = () => {
<div class="review-page-entry"> <div class="review-page-entry">
<header-title>Amazon Review</header-title> <header-title>Amazon Review</header-title>
<div class="interative-section"> <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 <n-button
v-if="!worker.isRunning.value" v-if="!worker.isRunning.value"
round round
@ -120,4 +134,8 @@ const handleInterrupt = () => {
.progress-report { .progress-report {
width: 90%; width: 90%;
} }
.setting-panel {
padding: 7px 10px;
}
</style> </style>

View File

@ -10,13 +10,18 @@ export const itemColumnSettings = useWebExtensionStorage<
Set<keyof Pick<AmazonItem, 'keywords' | 'page' | 'rank' | 'createTime' | 'timestamp'>> Set<keyof Pick<AmazonItem, 'keywords' | 'page' | 'rank' | 'createTime' | 'timestamp'>>
>('itemColumnSettings', new Set(['keywords', 'page', 'rank', 'createTime'])); >('itemColumnSettings', new Set(['keywords', 'page', 'rank', 'createTime']));
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []);
export const detailWorkerSettings = useWebExtensionStorage<{ aplus: boolean }>( export const detailWorkerSettings = useWebExtensionStorage<{ aplus: boolean }>(
'amazon-detail-worker-settings', 'amazon-detail-worker-settings',
{ aplus: false }, { 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>>( export const detailItems = useWebExtensionStorage<Map<string, AmazonDetailItem>>(
'detailItems', 'detailItems',
new Map(), 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() { get() {
const reviews: ({ asin: string } & AmazonReview)[] = []; const reviews: ({ asin: string } & AmazonReview)[] = [];
for (const [asin, values] of reviewItems.value) { for (const [asin, values] of reviewItems.value) {
@ -58,68 +135,3 @@ export const allReviews = computed({
reviewItems.value = reviews; 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'; import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
export const site = useWebExtensionStorage<'amazon' | 'homedepot'>('site', 'amazon'); export const site = useWebExtensionStorage<Website>('site', 'amazon');