This commit is contained in:
johnathan 2025-07-10 15:45:31 +08:00
parent 6b0aef185f
commit 219b34661a
31 changed files with 1656 additions and 239 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "azon-seeker", "name": "azon-seeker",
"displayName": "Azon Seeker", "displayName": "Azon Seeker",
"version": "0.5.0", "version": "0.6.0",
"private": true, "private": true,
"description": "Starter modify by honestfox101", "description": "Starter modify by honestfox101",
"scripts": { "scripts": {
@ -45,6 +45,7 @@
"esno": "^4.8.0", "esno": "^4.8.0",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"github-markdown-css": "^5.8.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"kolorist": "^1.8.0", "kolorist": "^1.8.0",
@ -59,6 +60,7 @@
"unplugin-icons": "^22.1.0", "unplugin-icons": "^22.1.0",
"unplugin-vue-components": "^28.8.0", "unplugin-vue-components": "^28.8.0",
"vite": "^7.0.2", "vite": "^7.0.2",
"vite-plugin-md": "^0.21.5",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"vue": "^3.5.17", "vue": "^3.5.17",
"vue-demi": "^0.14.10", "vue-demi": "^0.14.10",

1137
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Size } from 'naive-ui/es/button/src/interface'; import { Size } from 'naive-ui/es/button/src/interface';
const props = withDefaults(defineProps<{ disabled?: boolean; round?: boolean; size?: Size }>(), {}); withDefaults(defineProps<{ disabled?: boolean; round?: boolean; size?: Size }>(), {});
const emit = defineEmits<{ click: [ev: MouseEvent] }>(); const emit = defineEmits<{ click: [ev: MouseEvent] }>();
</script> </script>
@ -48,6 +48,10 @@ const emit = defineEmits<{ click: [ev: MouseEvent] }>();
> button:first-of-type { > button:first-of-type {
width: 85%; width: 85%;
&:hover {
width: 88%;
}
} }
> button:last-of-type { > button:last-of-type {
@ -61,6 +65,10 @@ const emit = defineEmits<{ click: [ev: MouseEvent] }>();
&:has(> button:last-of-type:hover) > button:first-of-type { &:has(> button:last-of-type:hover) > button:first-of-type {
width: 80%; width: 80%;
} }
&:has(> button:first-of-type:hover) > button:last-of-type {
width: 12%;
}
} }
} }
</style> </style>

View File

@ -1,11 +0,0 @@
## Components
Components in this dir will be auto-registered and on-demand, powered by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components).
Components can be shared in all views.
### Icons
You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/).
It will only bundle the icons you use. Check out [unplugin-icons](https://github.com/unplugin/unplugin-icons) for more details.

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useElementBounding, useParentElement } from '@vueuse/core';
import type { EllipsisProps } from 'naive-ui'; import type { EllipsisProps } from 'naive-ui';
export type TableColumn = export type TableColumn =
@ -17,6 +18,17 @@ export type TableColumn =
renderExpand: (row: any) => VNode; renderExpand: (row: any) => VNode;
}; };
const parentEl = useParentElement();
const currentRootEl = useTemplateRef('result-table');
const { height: parentHeight } = useElementBounding(parentEl);
const tableHeight = computed(() => {
let contentHeight = 0;
currentRootEl.value?.childNodes.forEach((element) => {
contentHeight += (element as Element).getBoundingClientRect().height;
});
return ~~Math.max(parentHeight.value, contentHeight);
});
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
records: Record<string, unknown>[]; records: Record<string, unknown>[];
@ -46,7 +58,7 @@ function generateUUID() {
</script> </script>
<template> <template>
<div class="result-table"> <div class="result-table" ref="result-table" :style="{ height: `${tableHeight}px` }">
<n-card class="result-content-container"> <n-card class="result-content-container">
<template #header><slot name="header" /></template> <template #header><slot name="header" /></template>
<template #header-extra><slot name="header-extra" /></template> <template #header-extra><slot name="header-extra" /></template>
@ -81,13 +93,8 @@ function generateUUID() {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.result-table {
width: 100%;
height: 100%;
}
.result-content-container { .result-content-container {
min-height: 100%; height: 100%;
:deep(.n-card__content:has(.n-empty)) { :deep(.n-card__content:has(.n-empty)) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

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.boughtInfo || '-' }} {{ model.timestamp }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="评价"> <n-descriptions-item label="评价">
{{ model.rating || '-' }} {{ model.rating || '-' }}
@ -17,9 +17,12 @@ defineProps<{ model: AmazonDetailItem }>();
<n-descriptions-item label="评论数"> <n-descriptions-item label="评论数">
{{ model.ratingCount || '-' }} {{ model.ratingCount || '-' }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="分类信息" :span="3"> <n-descriptions-item label="分类信息" :span="2">
{{ model.categories || '-' }} {{ model.categories || '-' }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="销量信息">
{{ model.boughtInfo || '-' }}
</n-descriptions-item>
<n-descriptions-item label="上架日期"> <n-descriptions-item label="上架日期">
{{ model.availableDate || '-' }} {{ model.availableDate || '-' }}
</n-descriptions-item> </n-descriptions-item>
@ -36,13 +39,43 @@ defineProps<{ model: AmazonDetailItem }>();
{{ model.category2?.rank || '-' }} {{ model.category2?.rank || '-' }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="图片链接" :span="4"> <n-descriptions-item label="图片链接" :span="4">
<image-link v-for="link in model.imageUrls" :url="link" /> <ul>
<li v-for="link in model.imageUrls">
<image-link :url="link" />
</li>
</ul>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item v-if="model.aplus" label="A+" :span="2"> <n-descriptions-item v-if="model.abouts && model.abouts.length" label="About" :span="4">
<image-link :url="model.aplus" /> <ul>
<li v-for="(about, idx) in model.abouts" :key="idx">{{ about }}</li>
</ul>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="获取日期" :span="2"> <n-descriptions-item label="A+" :span="4">
{{ model.timestamp }} <ul v-if="model.aplus">
<li><image-link :url="model.aplus" /></li>
</ul>
<template v-else>-</template>
</n-descriptions-item>
<n-descriptions-item label="发货快递">
{{ model.shipFrom || '-' }}
</n-descriptions-item>
<n-descriptions-item label="卖家">
{{ model.soldBy || '-' }}
</n-descriptions-item>
<n-descriptions-item label="品牌名称">
{{ model.brand || '-' }}
</n-descriptions-item>
<n-descriptions-item label="口味/风味">
{{ model.flavor || '-' }}
</n-descriptions-item>
<n-descriptions-item label="单位数量">
{{ model.unitCount || '-' }}
</n-descriptions-item>
<n-descriptions-item label="形态/剂型">
{{ model.itemForm || '-' }}
</n-descriptions-item>
<n-descriptions-item label="商品尺寸" :span="2">
{{ model.productDimensions || '-' }}
</n-descriptions-item> </n-descriptions-item>
</n-descriptions> </n-descriptions>
</div> </div>

View File

@ -5,24 +5,48 @@ import { useRouter } from 'vue-router';
const router = useRouter(); const router = useRouter();
const headerText = ref('采集结果');
const version = __VERSION__;
const opt = ref<string | undefined>(`/${site.value}`);
const options: { label: string; value: string }[] = [ const options: { label: string; value: string }[] = [
{ label: 'Amazon', value: 'amazon' }, { label: 'Amazon', value: '/amazon' },
{ label: 'Amazon Review', value: 'amazon-reviews' }, { label: 'Amazon Review', value: '/amazon-reviews' },
{ label: 'Homedepot', value: 'homedepot' }, { label: 'Homedepot', value: '/homedepot' },
]; ];
watch(site, (val) => { watch(opt, (val) => {
if (val) {
router.push(val); router.push(val);
headerText.value = '采集结果';
}
switch (val) {
case '/amazon':
case '/amazon-reviews':
site.value = 'amazon';
break;
case '/homedepot':
site.value = 'homedepot';
break;
default:
break;
}
}); });
const handleHelpButtonClick = () => {
opt.value = undefined;
router.push('/help');
headerText.value = '帮助';
};
</script> </script>
<template> <template>
<header> <header>
<span> <span>
<n-popselect v-model:value="site" :options="options" placement="bottom-start"> <n-popselect v-model:value="opt" :options="options" placement="bottom-start">
<n-button> <n-button>
<template #icon> <template #icon>
<n-icon size="20"> <n-icon :size="20">
<garden-menu-fill-12 /> <garden-menu-fill-12 />
</n-icon> </n-icon>
</template> </template>
@ -30,13 +54,27 @@ watch(site, (val) => {
</n-popselect> </n-popselect>
</span> </span>
<span> <span>
<h1 class="header-title">采集结果</h1> <h1 class="header-title">{{ headerText }}</h1>
</span>
<span>
<n-button @click="handleHelpButtonClick" round>
<template #icon>
<n-icon :size="20">
<ion-help />
</n-icon>
</template>
</n-button>
</span> </span>
<span> </span>
</header> </header>
<main> <main>
<router-view /> <router-view />
</main> </main>
<footer>
<span
>Azon Seeker v{{ version }} powered by
<a href="https://github.com/honestfox101">@HonestFox101</a></span
>
</footer>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@ -49,6 +87,8 @@ header {
.header-title { .header-title {
cursor: default; cursor: default;
} }
height: 8vh;
} }
main { main {
@ -56,7 +96,27 @@ main {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
height: 90vh;
width: 95vw; width: 95vw;
min-height: 87vh;
}
footer {
color: rgba(128, 128, 128, 0.68);
height: 5vh;
display: flex;
flex-direction: row;
justify-content: center;
align-items: end;
a {
color: rgba(128, 128, 128, 0.68);
text-decoration: none;
transition: color 0.2s;
&:hover {
color: #1a73e8;
text-decoration: underline;
}
}
} }
</style> </style>

View File

@ -1,6 +1,6 @@
import App from './App.vue'; import App from './App.vue';
import { setupApp } from '~/logic/common-setup'; import { setupApp } from '~/logic/common-setup';
import '../styles'; import '~/styles';
// This is the options page of the extension. // This is the options page of the extension.
Object.assign(self, { appContext: 'options' }); Object.assign(self, { appContext: 'options' });

View File

@ -150,6 +150,19 @@ const extraHeaders: Header<AmazonItem>[] = [
prop: 'aplus', prop: 'aplus',
label: 'A+截图', label: 'A+截图',
}, },
{ prop: 'shipFrom', label: '发货快递' },
{ prop: 'soldBy', label: '卖家' },
{ prop: 'brand', label: '品牌名称' },
{ prop: 'flavor', label: '商品口味' },
{ prop: 'unitCount', label: '商品单位数量' },
{ prop: 'itemForm', label: '商品形态' },
{ prop: 'productDimensions', label: '商品尺寸' },
{
prop: 'abouts',
label: '关于',
formatOutputValue: (val?: string[]) => val?.join('\n'),
parseImportValue: (val?: string) => val?.split('\n'),
},
]; ];
const getItemHeaders = () => { const getItemHeaders = () => {
@ -223,7 +236,6 @@ const handleClearData = async () => {
</script> </script>
<template> <template>
<div class="result-table">
<result-table :columns="columns" :records="filteredData"> <result-table :columns="columns" :records="filteredData">
<template #header> <template #header>
<n-space align="center"> <n-space align="center">
@ -250,12 +262,7 @@ const handleClearData = async () => {
<template #filter> <template #filter>
<div class="filter-section"> <div class="filter-section">
<div class="filter-title">筛选器</div> <div class="filter-title">筛选器</div>
<n-form <n-form :model="filter" label-placement="left" label-align="center" :label-width="95">
:model="filter"
label-placement="left"
label-align="center"
:label-width="95"
>
<n-form-item label="关键词"> <n-form-item label="关键词">
<n-select <n-select
placeholder="" placeholder=""
@ -308,17 +315,17 @@ const handleClearData = async () => {
</n-space> </n-space>
</template> </template>
</result-table> </result-table>
</div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.result-table { .result-table {
width: 100%; width: 100%;
height: 100%;
}
.header-text { .header-text {
padding: 0px; padding: 0px;
margin: 0px; margin: 0px;
}
} }
:deep(.filter-switch) { :deep(.filter-switch) {

View File

@ -207,11 +207,11 @@ const handleClear = () => {
<style lang="scss" scoped> <style lang="scss" scoped>
.result-table { .result-table {
width: 100%; width: 100%;
}
.header-text { .header-text {
padding: 0px; padding: 0px;
margin: 0px; margin: 0px;
}
} }
.filter-panel { .filter-panel {

View File

@ -173,11 +173,11 @@ const handleExport = async (opt: 'cloud' | 'local') => {
<style scoped lang="scss"> <style scoped lang="scss">
.result-table { .result-table {
width: 100%; width: 100%;
}
.header-text { .header-text {
padding: 0px; padding: 0px;
margin: 0px; margin: 0px;
}
} }
.expoter-progress-panel { .expoter-progress-panel {

View File

@ -0,0 +1,123 @@
# 软件目的
本软件的开发旨在自动采集Amazon电商平台的数据并提供导出和预览数据的功能。
# 安装步骤
下载好插件解压缩,然后在浏览器中按照一下步骤操作。
<img width="955" alt="Image" src="https://github.com/user-attachments/assets/95c02ca5-3139-49bb-88fe-1fa57415e650" />
选择包含有**`manifest.json`**文件的文件夹,然后点击确定即可安装完成。
# 界面预览
## 侧边栏
<img width="201" alt="Image" src="https://github.com/user-attachments/assets/ba28ca54-40d3-45bf-90e3-bb5fd8d454bc" />
侧边栏布局整体如上图所示。从上往下的布局单元介绍如下。
- **a. 切换栏**,切换可选择执行不同的采集任务。
- **b. 标题栏**,其中右上角的“数据”按钮可前往结果页。
- **c. 互动栏**,上半部分为输入,下半部分为开始执行按钮。
- **d. 状态栏**,报告采集数据中途的数据采集情况。
## 结果页
<img width="1250" alt="Image" src="https://github.com/user-attachments/assets/c7df21e6-dc75-4355-bcb9-f23ffc66d9b6" />
- **a. 菜单栏**,切换展示不同的数据。
- **b. 表头功能栏**,对数据作操作包含清空数据、导入导出、以及筛选数据。
- **c. 数据表**,用于展示获取到数据。
# 功能介绍
## 数据采集
打开**侧边栏**,可以根据输入自动运行程序采集网站数据。
**需要注意的是,当插件的采集任务运行时,请保持执行窗口在前台。**
### 搜索页
![Image](https://github.com/user-attachments/assets/6163c055-64e3-4bab-b3f8-e09aebd15ac0)
位于搜索页面板,可以根据输入的关键词采集搜索页中呈现的商品信息,主要为**关键词排名**。
输入:关键词(最多十个)
输出:商品搜索页信息,包含关键词、页码、排位、 ASIN、标题、 价格、 封面图、获取日期。
### 详情页
<img width="376" height="317" alt="Image" src="https://github.com/user-attachments/assets/a3e7211a-61b7-4fc4-b38f-0d4d1d8f2489" />
位于详情页面板可以根据输入的多个ASIN采集商品详情页的商品信息。
输入ASIN每行输入一个可以输入多个
输出商品详情页信息包含ASIN、标题、 价格、 封面图、获取日期、评分、评论数、 大类、大类排行、小类、 小类排行、 商品图片链接、A+截图等等。
### 评论页
<img width="365" height="308" alt="Image" src="https://github.com/user-attachments/assets/0b0b1d48-ed87-409a-8625-7b06d3ed7d9b" />
位于评论页面板可以根据输入的多个ASIN采集商品详情页的评论信息。
输入ASIN每行输入一个可以输入多个
输出:用户对商品的评论,包含用户名、标题 、评分、内容、日期、图片链接。
## 数据展示
商品信息采集结果展示在软件的结果页。视图如下图所示。
![Image](https://github.com/user-attachments/assets/5bf7c929-790a-412b-a144-ebe7a9b913d9)
可以在数据行最右侧的操作栏打开评论。商品评论的视图如下所示。
![Image](https://github.com/user-attachments/assets/1c61d4d3-3055-4769-bc64-5eaf651969ad)
全部评论采集结果信息如下图所示。
<img width="1474" height="743" alt="Image" src="https://github.com/user-attachments/assets/99293fef-5618-48d4-b2be-b922c9a50aef" />
## 数据导出
数据导出功能在结果页数据导出会将采集到的数据导出到Excel表格。
分为本地导出和云端导出。
Items表记录商品信息表头信息如下。
| 名称 | 描述 |
|--------------------|--------------------------|
| 关键词 | 产品的关键词 |
| 页码 | 产品在关键词搜索列表中的页码 |
| 排位 | 产品的排名 |
| ASIN | 亚马逊标准识别号 |
| 标题 | 产品的标题 |
| 价格 | 产品的价格 |
| 封面图 | 产品的封面图片链接 |
| 获取日期 | 数据获取的日期 |
| 商品链接 | 产品的链接 |
| 有详情 | 是否有详细信息 |
| 评分 | 产品的评分 |
| 评论数 | 产品的评论数量 |
| 大类 | 产品所属的大类 |
| 大类排行 | 产品在大类中的排名 |
| 小类 | 产品所属的小类 |
| 小类排行 | 产品在小类中的排名 |
| 获取日期(详情页) | 详情页数据获取的日期 |
| 商品图片链接 | 产品的图片链接,以分号作为间隔 |
| A+截图 | 产品的A+内容截图链接 |
| 发货快递 | 物流方式如FBA/FBM |
| 卖家 |销售该产品的卖家名称 |
| 关于信息 | 商品详情页的"About this item"信息 |
| 品牌名称 | 产品的品牌名 |
| 商品口味 | 药品的商品的风味属性 |
| 商品单位数量 | 包装内包含的单位数量(如"100片" |
| 商品形态| 产品形态(如胶囊/液体/粉末) |
| 商品尺寸 | 产品的物理尺寸或规格 |
Reviews表记录商品的评论信息表头信息如下所示。
| 名称 | 描述 |
|--------------|--------------------------|
| ASIN | 亚马逊标准识别号 |
| 用户名 | 用户的名称 |
| 标题 | 评论的标题 |
| 评分 | 评论的评分 |
| 内容 | 评论的具体内容 |
| 日期 | 评论的日期 |
| 图片链接 | 图片的链接(多个链接用分号分隔) |

View File

@ -4,7 +4,7 @@ import { uploadImage } from '~/logic/upload';
import { detailItems, reviewItems, searchItems } from '~/storages/amazon'; import { detailItems, reviewItems, searchItems } from '~/storages/amazon';
import { createGlobalState } from '@vueuse/core'; import { createGlobalState } from '@vueuse/core';
import { useAmazonService } from '~/services/amazon'; import { useAmazonService } from '~/services/amazon';
import { LanchTaskBaseOptions } from '../types'; import { LanchTaskBaseOptions } from '../interfaces';
export interface AmazonPageWorkerSettings { export interface AmazonPageWorkerSettings {
objects?: ('search' | 'detail' | 'review')[]; objects?: ('search' | 'detail' | 'review')[];
@ -109,6 +109,9 @@ function buildAmazonPageWorker() {
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 });
}), }),
worker.on('item-extra-info-collect', (ev) => {
updateDetailCache({ asin: ev.asin, ...ev.info });
}),
worker.on('item-review-collected', (ev) => { worker.on('item-review-collected', (ev) => {
updateReviews(ev); updateReviews(ev);
}), }),

View File

@ -1,4 +1,4 @@
import type { AmazonPageWorker, AmazonPageWorkerEvents, LanchTaskBaseOptions } from '../types'; import type { AmazonPageWorker, AmazonPageWorkerEvents, LanchTaskBaseOptions } from '../interfaces';
import type { Tabs } from 'webextension-polyfill'; import type { Tabs } from 'webextension-polyfill';
import { withErrorHandling } from '../error-handler'; import { withErrorHandling } from '../error-handler';
import { import {
@ -104,8 +104,12 @@ class AmazonPageWorkerImpl
} }
@withErrorHandling @withErrorHandling
public async wanderDetailPage(entry: string, aplus: boolean = false) { public async wanderDetailPage(
entry: string,
options: Parameters<typeof this.runDetailPageTask>[1] = {},
) {
//#region Initial Meta Info //#region Initial Meta Info
const { aplus = false, extra = false } = options;
const params = { asin: '', url: '' }; const params = { asin: '', url: '' };
if (entry.match(/^https?:\/\/www\.amazon\.com.*\/dp\/[A-Z0-9]{10}/)) { if (entry.match(/^https?:\/\/www\.amazon\.com.*\/dp\/[A-Z0-9]{10}/)) {
const [asin] = /\/\/dp\/[A-Z0-9]{10}/.exec(entry)!; const [asin] = /\/\/dp\/[A-Z0-9]{10}/.exec(entry)!;
@ -189,6 +193,12 @@ class AmazonPageWorkerImpl
await this.emit('item-aplus-screenshot-collect', { asin: params.asin, base64data }); await this.emit('item-aplus-screenshot-collect', { asin: params.asin, base64data });
} }
// #endregion // #endregion
//#region Get Extra Info
if (extra) {
const extraInfo = await injector.getExtraInfo();
this.emit('item-extra-info-collect', { asin: params.asin, info: extraInfo });
}
//#endregion
} }
@withErrorHandling @withErrorHandling
@ -238,7 +248,7 @@ class AmazonPageWorkerImpl
public async runDetailPageTask( public async runDetailPageTask(
asins: string[], asins: string[],
options: LanchTaskBaseOptions & { aplus?: boolean } = {}, options: LanchTaskBaseOptions & { aplus?: boolean; extra?: boolean } = {},
): Promise<void> { ): Promise<void> {
const { progress, aplus = false } = options; const { progress, aplus = false } = options;
const remains = [...asins]; const remains = [...asins];
@ -248,7 +258,7 @@ class AmazonPageWorkerImpl
}); });
while (remains.length > 0 && !interrupt) { while (remains.length > 0 && !interrupt) {
const asin = remains.shift()!; const asin = remains.shift()!;
await this.wanderDetailPage(asin, aplus); await this.wanderDetailPage(asin, options);
progress && progress(remains); progress && progress(remains);
} }
unsubscribe(); unsubscribe();

View File

@ -1,4 +1,4 @@
import type { HomedepotEvents, HomedepotWorker, LanchTaskBaseOptions } from '../types'; import type { HomedepotEvents, HomedepotWorker, LanchTaskBaseOptions } from '../interfaces';
import { Tabs } from 'webextension-polyfill'; import { Tabs } from 'webextension-polyfill';
import { withErrorHandling } from '../error-handler'; import { withErrorHandling } from '../error-handler';
import { HomedepotDetailPageInjector } from '../web-injectors/homedepot'; import { HomedepotDetailPageInjector } from '../web-injectors/homedepot';

View File

@ -5,13 +5,10 @@ type Listener<T> = Pick<Emittery<T>, 'on' | 'off' | 'once'>;
export type LanchTaskBaseOptions = { progress?: (remains: string[]) => Promise<void> | void }; export type LanchTaskBaseOptions = { progress?: (remains: string[]) => Promise<void> | void };
export interface AmazonPageWorkerEvents { export interface AmazonPageWorkerEvents {
/** /** The event is fired when worker collected links to items on the Amazon search page. */
* The event is fired when worker collected links to items on the Amazon search page.
*/
['item-links-collected']: { objs: AmazonSearchItem[] }; ['item-links-collected']: { objs: AmazonSearchItem[] };
/**
* The event is fired when worker collected goods' base info on the Amazon detail page. /** The event is fired when worker collected goods' base info on the Amazon detail page.*/
*/
['item-base-info-collected']: Pick< ['item-base-info-collected']: Pick<
AmazonDetailItem, AmazonDetailItem,
| 'asin' | 'asin'
@ -22,30 +19,35 @@ export interface AmazonPageWorkerEvents {
| 'ratingCount' | 'ratingCount'
| 'categories' | 'categories'
| 'timestamp' | 'timestamp'
| 'shipFrom'
| 'soldBy'
>; >;
/**
* The event is fired when worker /** The event is fired when worker */
*/
['item-category-rank-collected']: Pick<AmazonDetailItem, 'asin' | 'category1' | 'category2'>; ['item-category-rank-collected']: Pick<AmazonDetailItem, 'asin' | 'category1' | 'category2'>;
/**
* The event is fired when images collected /** The event is fired when images collected */
*/
['item-images-collected']: Pick<AmazonDetailItem, 'asin' | 'imageUrls'>; ['item-images-collected']: Pick<AmazonDetailItem, 'asin' | 'imageUrls'>;
/**
* 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 */
*/
['item-aplus-screenshot-collect']: { asin: string; base64data: string }; ['item-aplus-screenshot-collect']: { asin: string; base64data: string };
/**
* The event is fired when reviews collected in all review page /** The event is fired when extra amazon info collected*/
*/ ['item-extra-info-collect']: {
asin: string;
info: Pick<
AmazonDetailItem,
'abouts' | 'brand' | 'flavor' | 'unitCount' | 'itemForm' | 'productDimensions'
>;
};
/** The event is fired when reviews collected in all review page */
['item-review-collected']: { asin: string; reviews: AmazonReview[] }; ['item-review-collected']: { asin: string; reviews: AmazonReview[] };
/**
* Error event that occurs when there is an issue with the Amazon page worker /** Error event that occurs when there is an issue with the Amazon page worker*/
*/
['error']: { message: string; url?: string }; ['error']: { message: string; url?: string };
} }
@ -64,7 +66,7 @@ export interface AmazonPageWorker extends Listener<AmazonPageWorkerEvents> {
*/ */
runDetailPageTask( runDetailPageTask(
asins: string[], asins: string[],
options?: LanchTaskBaseOptions & { aplus?: boolean }, options?: LanchTaskBaseOptions & { aplus?: boolean; extra?: boolean },
): Promise<void>; ): Promise<void>;
/** /**
@ -84,17 +86,13 @@ export interface AmazonPageWorker extends Listener<AmazonPageWorkerEvents> {
} }
export interface HomedepotEvents { export interface HomedepotEvents {
/** /** The event is fired when detail items collect */
* The event is fired when detail items collect
*/
['detail-item-collected']: { item: HomedepotDetailItem }; ['detail-item-collected']: { item: HomedepotDetailItem };
/**
* The event is fired when reviews collect /** The event is fired when reviews collect */
*/
['review-collected']: { reviews: HomedepotReview[] }; ['review-collected']: { reviews: HomedepotReview[] };
/**
* The event is fired when error occurs. /** The event is fired when error occurs. */
*/
['error']: { message: string; url?: string }; ['error']: { message: string; url?: string };
} }
@ -114,13 +112,10 @@ export interface HomedepotWorker extends Listener<HomedepotEvents> {
} }
export interface LowesEvents { export interface LowesEvents {
/** /** The event is fired when detail items collect */
* The event is fired when detail items collect
*/
['detail-item-collected']: { item: LowesDetailItem }; ['detail-item-collected']: { item: LowesDetailItem };
/**
* The event is fired when error occurs. /** The event is fired when error occurs. */
*/
['error']: { message: string; url?: string }; ['error']: { message: string; url?: string };
} }

View File

@ -184,7 +184,11 @@ export class AmazonDetailPageInjector extends BaseInjector {
const categories = document const categories = document
.querySelector<HTMLElement>('#wayfinding-breadcrumbs_feature_div') .querySelector<HTMLElement>('#wayfinding-breadcrumbs_feature_div')
?.innerText.replaceAll('\n', ''); ?.innerText.replaceAll('\n', '');
return { title, price, boughtInfo, availableDate, categories }; const shipFrom = document.querySelector<HTMLElement>(
'#fulfillerInfoFeature_feature_div > *:last-of-type',
)?.innerText;
const soldBy = document.querySelector<HTMLElement>(`#sellerProfileTriggerId`)?.innerText;
return { title, price, boughtInfo, availableDate, categories, shipFrom, soldBy };
}); });
} }
@ -369,9 +373,6 @@ export class AmazonDetailPageInjector extends BaseInjector {
} }
return nodes.length > 0 ? nodes : undefined; return nodes.length > 0 ? nodes : undefined;
}; };
const shipFrom = document.querySelector<HTMLElement>(
'#fulfillerInfoFeature_feature_div > *:last-of-type',
)?.innerText;
const abouts = $x( const abouts = $x(
`//*[normalize-space(text())='About this item']/following-sibling::ul[1]/li`, `//*[normalize-space(text())='About this item']/following-sibling::ul[1]/li`,
)?.map((el) => el.innerText); )?.map((el) => el.innerText);
@ -386,18 +387,16 @@ export class AmazonDetailPageInjector extends BaseInjector {
const itemForm = $x( const itemForm = $x(
`//*[./span[normalize-space(text())='Item Form']]/following-sibling::*[1]`, `//*[./span[normalize-space(text())='Item Form']]/following-sibling::*[1]`,
)?.[0].innerText; )?.[0].innerText;
const productDemensions = $x( const productDimensions = $x(
`//span[contains(text(), 'Dimensions')]/following-sibling::*[1]`, `//span[contains(text(), 'Dimensions')]/following-sibling::*[1]`,
)?.[0].innerText; )?.[0].innerText;
return { return {
abouts, abouts,
shipFrom,
brand, brand,
flavor, flavor,
unitCount, unitCount,
itemForm, itemForm,
productDemensions, productDimensions,
}; };
}); });
} }

View File

@ -13,6 +13,7 @@ const routeObj: Record<'sidepanel' | 'options', RouteRecordRaw[]> = {
{ path: '/amazon', component: () => import('~/options/views/AmazonResultTable.vue') }, { path: '/amazon', component: () => import('~/options/views/AmazonResultTable.vue') },
{ path: '/amazon-reviews', component: () => import('~/options/views/AmazonReviews.vue') }, { path: '/amazon-reviews', component: () => import('~/options/views/AmazonReviews.vue') },
{ path: '/homedepot', component: () => import('~/options/views/HomedepotResultTable.vue') }, { path: '/homedepot', component: () => import('~/options/views/HomedepotResultTable.vue') },
{ path: '/help', component: () => import('~/options/views/help/guide.md') },
], ],
sidepanel: [ sidepanel: [
{ path: '/', redirect: `/${site.value}` }, { path: '/', redirect: `/${site.value}` },

View File

@ -23,17 +23,28 @@ watch(currentUrl, (newVal) => {
switch (url.hostname) { switch (url.hostname) {
case 'www.amazon.com': case 'www.amazon.com':
site.value = 'amazon'; site.value = 'amazon';
router.push({ path: '/amazon' });
break; break;
case 'www.homedepot.com': case 'www.homedepot.com':
site.value = 'homedepot'; site.value = 'homedepot';
router.push({ path: '/homedepot' });
break; break;
default: default:
break; break;
} }
} }
}); });
watch(site, (newVal) => {
switch (newVal) {
case 'amazon':
router.push('/amazon');
break;
case 'homedepot':
router.push('/homedepot');
break;
default:
break;
}
});
</script> </script>
<template> <template>

View File

@ -1,6 +1,6 @@
import App from './App.vue'; import App from './App.vue';
import { setupApp } from '~/logic/common-setup'; import { setupApp } from '~/logic/common-setup';
import '../styles'; import '~/styles';
// This is the sidepanel page of the extension. // This is the sidepanel page of the extension.
Object.assign(self, { appContext: 'sidepanel' }); Object.assign(self, { appContext: 'sidepanel' });

View File

@ -58,9 +58,17 @@ worker.on('item-images-collected', (ev) => {
worker.on('item-aplus-screenshot-collect', (ev) => { worker.on('item-aplus-screenshot-collect', (ev) => {
timelines.value.push({ timelines.value.push({
type: 'success', type: 'success',
title: `商品${ev.asin} A+信息`, title: `商品${ev.asin}的A+截图`,
time: new Date().toLocaleString(), time: new Date().toLocaleString(),
content: `获取到A+信息`, content: `获取到A+截图`,
});
});
worker.on('item-extra-info-collect', (ev) => {
timelines.value.push({
type: 'success',
title: `商品${ev.asin}额外信息`,
time: new Date().toLocaleString(),
content: `获取商品的额外信息`,
}); });
}); });
//#endregion //#endregion
@ -80,6 +88,7 @@ const launch = async () => {
detailAsinInput.value = remains.join('\n'); detailAsinInput.value = remains.join('\n');
}, },
aplus: detailWorkerSettings.value.aplus, aplus: detailWorkerSettings.value.aplus,
extra: detailWorkerSettings.value.extra,
}); });
timelines.value.push({ timelines.value.push({
type: 'info', type: 'info',
@ -104,33 +113,40 @@ const handleInterrupt = () => {
<div class="detail-page-entry"> <div class="detail-page-entry">
<header-title>Amazon Detail</header-title> <header-title>Amazon Detail</header-title>
<div class="interative-section"> <div class="interative-section">
<ids-input v-model="detailAsinInput" :disabled="worker.isRunning.value" ref="asin-input"> <ids-input v-model="detailAsinInput" :disabled="worker.isRunning.value" ref="asin-input" />
<template #extra-settings> <optional-button
<div class="setting-panel">
<n-form label-placement="left">
<n-form-item label="Aplus:" :feedback-style="{ display: 'none' }">
<n-switch v-model:value="detailWorkerSettings.aplus" />
</n-form-item>
</n-form>
</div>
</template>
</ids-input>
<n-button
v-if="!worker.isRunning.value" v-if="!worker.isRunning.value"
round round
size="large" size="large"
type="primary" type="primary"
@click="handleStart" @click="handleStart"
> >
<template #icon> <template #popover>
<ant-design-thunderbolt-outlined /> <div class="setting-panel">
<n-form
label-placement="left"
:label-width="60"
label-align="center"
:show-feedback="false"
>
<n-form-item label="Aplus:">
<n-switch v-model:value="detailWorkerSettings.aplus" />
</n-form-item>
<n-form-item label="Extra:">
<n-switch v-model:value="detailWorkerSettings.extra" />
</n-form-item>
</n-form>
</div>
</template> </template>
<n-icon :size="20">
<ant-design-thunderbolt-outlined />
</n-icon>
开始 开始
</n-button> </optional-button>
<n-button v-else round size="large" type="primary" @click="handleInterrupt"> <n-button v-else round size="large" type="primary" @click="handleInterrupt">
<template #icon> <n-icon :size="20">
<ant-design-thunderbolt-outlined /> <ant-design-thunderbolt-outlined />
</template> </n-icon>
停止 停止
</n-button> </n-button>
</div> </div>
@ -175,6 +191,6 @@ const handleInterrupt = () => {
} }
.setting-panel { .setting-panel {
padding: 7px 10px; padding: 7px 5px;
} }
</style> </style>

View File

@ -65,8 +65,15 @@ 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> <optional-button
v-if="!worker.isRunning.value"
round
size="large"
type="primary"
@click="handleStart"
>
<template #popover>
<div class="setting-panel"> <div class="setting-panel">
<n-form label-placement="left"> <n-form label-placement="left">
<n-form-item label="模式:" :feedback-style="{ display: 'none' }"> <n-form-item label="模式:" :feedback-style="{ display: 'none' }">
@ -78,19 +85,11 @@ const handleInterrupt = () => {
</n-form> </n-form>
</div> </div>
</template> </template>
</ids-input> <n-icon :size="20">
<n-button
v-if="!worker.isRunning.value"
round
size="large"
type="primary"
@click="handleStart"
>
<template #icon>
<ant-design-thunderbolt-outlined /> <ant-design-thunderbolt-outlined />
</template> </n-icon>
开始 开始
</n-button> </optional-button>
<n-button v-else round size="large" type="primary" @click="handleInterrupt"> <n-button v-else round size="large" type="primary" @click="handleInterrupt">
<template #icon> <template #icon>
<ant-design-thunderbolt-outlined /> <ant-design-thunderbolt-outlined />

View File

@ -74,7 +74,6 @@ const handleInterrupt = () => {
<optional-button v-if="!worker.isRunning.value" round size="large" @click="handleStart"> <optional-button v-if="!worker.isRunning.value" round size="large" @click="handleStart">
<template #popover> <template #popover>
<div class="setting-panel"> <div class="setting-panel">
<div>设置</div>
<n-form :label-width="50" label-placement="left" :show-feedback="false"> <n-form :label-width="50" label-placement="left" :show-feedback="false">
<n-form-item label="评论:"> <n-form-item label="评论:">
<n-switch v-model:value="detailWorkerSettings.review" /> <n-switch v-model:value="detailWorkerSettings.review" />
@ -128,11 +127,4 @@ const handleInterrupt = () => {
margin-top: 10px; margin-top: 10px;
width: 95%; width: 95%;
} }
.setting-panel {
> *:first-of-type {
font-size: larger;
margin-bottom: 5px;
}
}
</style> </style>

View File

@ -10,15 +10,14 @@ 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 detailWorkerSettings = useWebExtensionStorage<{ aplus: boolean }>( export const detailWorkerSettings = useWebExtensionStorage('amazon-detail-worker-settings', {
'amazon-detail-worker-settings', aplus: false,
{ aplus: false }, extra: false,
); });
export const reviewWorkerSettings = useWebExtensionStorage<{ recent: boolean }>( export const reviewWorkerSettings = useWebExtensionStorage('amazon-review-worker-settings', {
'amazon-review-worker-settings', recent: true,
{ recent: true }, });
);
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []); export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []);

View File

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

View File

@ -1 +1,2 @@
import './main.scss'; import './main.scss';
import 'github-markdown-css';

14
src/types/amazon.d.ts vendored
View File

@ -54,8 +54,22 @@ declare type AmazonDetailItem = {
aplus?: string; aplus?: string;
// /** 顶部评论数组 */ // /** 顶部评论数组 */
// topReviews?: AmazonReview[]; // topReviews?: AmazonReview[];
/** 发货快递 */
shipFrom?: string;
/** 卖家 */
soldBy?: string;
/**关于信息 */ /**关于信息 */
abouts?: string[]; abouts?: string[];
/** 品牌名称 */
brand?: string;
/** 商品口味/风味 */
flavor?: string;
/** 商品单位数量 */
unitCount?: string;
/** 商品形态/剂型 */
itemForm?: string;
/** 商品尺寸 */
productDimensions?: string;
}; };
declare type AmazonReview = BaseReview & { declare type AmazonReview = BaseReview & {

View File

@ -4,11 +4,19 @@ declare const __DEV__: boolean;
/** Extension name, defined in packageJson.name */ /** Extension name, defined in packageJson.name */
declare const __NAME__: string; declare const __NAME__: string;
declare const __VERSION__: string;
declare module '*.vue' { declare module '*.vue' {
const component: any; const component: any;
export default component; export default component;
} }
declare module '*.md' {
import type { ComponentOptions } from 'vue';
const Component: ComponentOptions;
export default Component;
}
declare type AppContext = 'options' | 'sidepanel' | 'background' | 'content script'; declare type AppContext = 'options' | 'sidepanel' | 'background' | 'content script';
declare type Website = 'amazon' | 'homedepot'; declare type Website = 'amazon' | 'homedepot';

View File

@ -9,6 +9,7 @@ export default defineConfig({
define: { define: {
__DEV__: isDev, __DEV__: isDev,
__NAME__: JSON.stringify(packageJson.name), __NAME__: JSON.stringify(packageJson.name),
__VERSION__: JSON.stringify(packageJson.version),
// https://github.com/vitejs/vite/issues/9320 // https://github.com/vitejs/vite/issues/9320
// https://github.com/vitejs/vite/issues/9186 // https://github.com/vitejs/vite/issues/9186
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),

View File

@ -9,6 +9,7 @@ export default defineConfig({
define: { define: {
__DEV__: isDev, __DEV__: isDev,
__NAME__: JSON.stringify(packageJson.name), __NAME__: JSON.stringify(packageJson.name),
__VERSION__: JSON.stringify(packageJson.version),
// https://github.com/vitejs/vite/issues/9320 // https://github.com/vitejs/vite/issues/9320
// https://github.com/vitejs/vite/issues/9186 // https://github.com/vitejs/vite/issues/9186
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),

View File

@ -12,6 +12,7 @@ import AutoImport from 'unplugin-auto-import/vite';
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'; import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
import { isDev, outputDir, port, r } from './scripts/utils.js'; import { isDev, outputDir, port, r } from './scripts/utils.js';
import packageJson from './package.json'; import packageJson from './package.json';
import Markdown from 'vite-plugin-md';
export const sharedConfig: UserConfig = { export const sharedConfig: UserConfig = {
root: r('src'), root: r('src'),
@ -23,9 +24,13 @@ export const sharedConfig: UserConfig = {
define: { define: {
__DEV__: isDev, __DEV__: isDev,
__NAME__: JSON.stringify(packageJson.name), __NAME__: JSON.stringify(packageJson.name),
__VERSION__: JSON.stringify(packageJson.version),
}, },
plugins: [ plugins: [
Vue(), Vue({
include: [/\.vue$/, /\.md$/],
}),
Markdown(),
VueJsx(), VueJsx(),
AutoImport({ AutoImport({
imports: [ imports: [