mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-19 13:13:22 +08:00
v0.5.0 release
This commit is contained in:
parent
3aef704fa5
commit
a883f49d53
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -7,5 +7,6 @@
|
|||||||
"*.css": "postcss"
|
"*.css": "postcss"
|
||||||
},
|
},
|
||||||
"prettier.tabWidth": 2,
|
"prettier.tabWidth": 2,
|
||||||
"prettier.printWidth": 100
|
"prettier.printWidth": 100,
|
||||||
|
"editor.tabSize": 2
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
442
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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">
|
||||||
|
|||||||
@ -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(
|
||||||
|
defineProps<{
|
||||||
records: Record<string, unknown>[];
|
records: Record<string, unknown>[];
|
||||||
columns: TableColumn[];
|
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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
53
src/global.d.ts
vendored
@ -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
24
src/logic/convert.ts
Normal 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];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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()!;
|
||||||
|
if (cell.value && typeof cell.value === 'object' && 'text' in cell.value) {
|
||||||
|
rowData[header] = cell.value.text;
|
||||||
|
} else {
|
||||||
rowData[header] = cell.value;
|
rowData[header] = cell.value;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
jsonData.push(rowData);
|
jsonData.push(rowData);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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':
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
41
src/services/amazon.ts
Normal 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
24
src/services/base.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user