mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-19 13:13:22 +08:00
Update
This commit is contained in:
parent
5efc112f4b
commit
6b0aef185f
32
package.json
32
package.json
@ -27,40 +27,40 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/json": "^2.2.293",
|
"@iconify/json": "^2.2.356",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/node": "^22.14.0",
|
|
||||||
"@types/webextension-polyfill": "^0.12.3",
|
|
||||||
"@types/gulp-terser": "^1.2.6",
|
"@types/gulp-terser": "^1.2.6",
|
||||||
|
"@types/node": "^22.16.0",
|
||||||
|
"@types/webextension-polyfill": "^0.12.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.0.1",
|
"@vitejs/plugin-vue-jsx": "^5.0.1",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vueuse/core": "^12.3.0",
|
"@vueuse/core": "^12.8.2",
|
||||||
|
"@zumer/snapdom": "^1.8.0",
|
||||||
"alova": "^3.3.4",
|
"alova": "^3.3.4",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"emittery": "^1.1.0",
|
"emittery": "^1.2.0",
|
||||||
"esno": "^4.8.0",
|
"esno": "^4.8.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.3.0",
|
||||||
"html-to-image": "^1.11.13",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.1.0",
|
||||||
"kolorist": "^1.8.0",
|
"kolorist": "^1.8.0",
|
||||||
"lint-staged": "^15.5.0",
|
"lint-staged": "^16.1.2",
|
||||||
"naive-ui": "^2.41.0",
|
"naive-ui": "^2.42.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"sass-embedded": "^1.86.2",
|
"sass-embedded": "^1.89.2",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.3",
|
||||||
"unplugin-auto-import": "^19.1.2",
|
"unplugin-auto-import": "^19.3.0",
|
||||||
"unplugin-icons": "^22.1.0",
|
"unplugin-icons": "^22.1.0",
|
||||||
"unplugin-vue-components": "^28.4.1",
|
"unplugin-vue-components": "^28.8.0",
|
||||||
"vite": "^7.0.2",
|
"vite": "^7.0.2",
|
||||||
"vitest": "^3.1.1",
|
"vitest": "^3.2.4",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.17",
|
||||||
"vue-demi": "^0.14.10",
|
"vue-demi": "^0.14.10",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"web-ext": "^8.8.0",
|
"web-ext": "^8.8.0",
|
||||||
|
|||||||
3152
pnpm-lock.yaml
generated
3152
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
shim.d.ts
vendored
2
shim.d.ts
vendored
@ -4,7 +4,7 @@ declare module 'webext-bridge' {
|
|||||||
export interface ProtocolMap {
|
export interface ProtocolMap {
|
||||||
// define message protocol types
|
// define message protocol types
|
||||||
// see https://github.com/antfu/webext-bridge#type-safe-protocols
|
// see https://github.com/antfu/webext-bridge#type-safe-protocols
|
||||||
'html-to-image': ProtocolWithReturn<
|
'dom-to-image': ProtocolWithReturn<
|
||||||
| {
|
| {
|
||||||
type: 'CSS';
|
type: 'CSS';
|
||||||
selector: string;
|
selector: string;
|
||||||
|
|||||||
@ -24,10 +24,10 @@ const message = useMessage();
|
|||||||
const formItemRef = useTemplateRef('detail-form-item');
|
const formItemRef = useTemplateRef('detail-form-item');
|
||||||
const formItemRule: FormItemRule = {
|
const formItemRule: FormItemRule = {
|
||||||
required: true,
|
required: true,
|
||||||
trigger: ['submit'],
|
trigger: ['submit', 'blur'],
|
||||||
message: props.validateMessage,
|
message: props.validateMessage,
|
||||||
validator: () => {
|
validator: () => {
|
||||||
return props.matchPattern && props.matchPattern.exec(modelValue.value) !== null;
|
return props.matchPattern.exec(modelValue.value) !== null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
66
src/components/OptionalButton.vue
Normal file
66
src/components/OptionalButton.vue
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Size } from 'naive-ui/es/button/src/interface';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ disabled?: boolean; round?: boolean; size?: Size }>(), {});
|
||||||
|
|
||||||
|
const emit = defineEmits<{ click: [ev: MouseEvent] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="optional-button">
|
||||||
|
<n-button-group class="button-group">
|
||||||
|
<n-button
|
||||||
|
:disabled="disabled"
|
||||||
|
:round="round"
|
||||||
|
:size="size"
|
||||||
|
@click="(ev) => emit('click', ev)"
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</n-button>
|
||||||
|
<n-popover trigger="click" placement="bottom-end">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button :disabled="disabled" :round="round" :size="size">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<solar-settings-linear />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<slot name="popover"></slot>
|
||||||
|
</n-popover>
|
||||||
|
</n-button-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.optional-button {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
> button:first-of-type,
|
||||||
|
> button:last-of-type {
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button:first-of-type {
|
||||||
|
width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button:last-of-type {
|
||||||
|
width: 15%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(> button:last-of-type:hover) > button:first-of-type {
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -9,7 +9,7 @@ defineProps<{ model: AmazonDetailItem }>();
|
|||||||
{{ model.asin }}
|
{{ model.asin }}
|
||||||
</n-descriptions-item>
|
</n-descriptions-item>
|
||||||
<n-descriptions-item label="销量信息">
|
<n-descriptions-item label="销量信息">
|
||||||
{{ model.broughtInfo || '-' }}
|
{{ model.boughtInfo || '-' }}
|
||||||
</n-descriptions-item>
|
</n-descriptions-item>
|
||||||
<n-descriptions-item label="评价">
|
<n-descriptions-item label="评价">
|
||||||
{{ model.rating || '-' }}
|
{{ model.rating || '-' }}
|
||||||
@ -17,6 +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">
|
||||||
|
{{ model.categories || '-' }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="上架日期">
|
||||||
|
{{ model.availableDate || '-' }}
|
||||||
|
</n-descriptions-item>
|
||||||
<n-descriptions-item label="大类">
|
<n-descriptions-item label="大类">
|
||||||
{{ model.category1?.name || '-' }}
|
{{ model.category1?.name || '-' }}
|
||||||
</n-descriptions-item>
|
</n-descriptions-item>
|
||||||
|
|||||||
@ -61,12 +61,12 @@ class ExportExcelPipeline {
|
|||||||
|
|
||||||
public exportExcel(progress?: (current: number, total: number) => Promise<void> | void) {
|
public exportExcel(progress?: (current: number, total: number) => Promise<void> | void) {
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
this.socket.onmessage = (ev) => {
|
this.socket.onmessage = async (ev) => {
|
||||||
const response: WebSocketResponse = JSON.parse(ev.data);
|
const response: WebSocketResponse = JSON.parse(ev.data);
|
||||||
switch (response.type) {
|
switch (response.type) {
|
||||||
case 'progress':
|
case 'progress':
|
||||||
const { current, total } = response;
|
const { current, total } = response;
|
||||||
progress && progress(current, total);
|
progress && (await progress(current, total));
|
||||||
break;
|
break;
|
||||||
case 'result':
|
case 'result':
|
||||||
this.socket!.onmessage = null;
|
this.socket!.onmessage = null;
|
||||||
@ -111,12 +111,10 @@ export const useCloudExporter = () => {
|
|||||||
pipeline = new ExportExcelPipeline();
|
pipeline = new ExportExcelPipeline();
|
||||||
await pipeline.load();
|
await pipeline.load();
|
||||||
pipeline.addFragments(...fragments);
|
pipeline.addFragments(...fragments);
|
||||||
const file = await pipeline
|
const file = await pipeline.exportExcel((current, total) => {
|
||||||
.exportExcel((current, total) => {
|
progress.current = current;
|
||||||
progress.current = current;
|
progress.total = total;
|
||||||
progress.total = total;
|
});
|
||||||
})
|
|
||||||
.catch(() => null);
|
|
||||||
await pipeline.close();
|
await pipeline.close();
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { toPng } from 'html-to-image';
|
import { snapdom } from '@zumer/snapdom';
|
||||||
import { onMessage } from 'webext-bridge/content-script';
|
import { onMessage } from 'webext-bridge/content-script';
|
||||||
|
|
||||||
onMessage('html-to-image', async (ev) => {
|
onMessage('dom-to-image', async (ev) => {
|
||||||
const params = ev.data;
|
const params = ev.data;
|
||||||
const targetNode =
|
const targetNode =
|
||||||
params.type == 'CSS'
|
params.type == 'CSS'
|
||||||
? document.querySelector<HTMLElement>(params.selector)!
|
? document.querySelector<HTMLElement>(params.selector)!
|
||||||
: (document.evaluate(params.xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE)
|
: (document.evaluate(params.xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE)
|
||||||
.singleNodeValue as HTMLElement);
|
.singleNodeValue as HTMLElement);
|
||||||
const imgData = await toPng(targetNode);
|
const result = await snapdom.toPng(targetNode, { compress: true });
|
||||||
return { b64: imgData };
|
return { b64: result.src };
|
||||||
});
|
});
|
||||||
@ -1,5 +1,5 @@
|
|||||||
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
|
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
|
||||||
(() => {
|
(() => {
|
||||||
Object.assign(self, { appContext: 'content script' });
|
Object.assign(self, { appContext: 'content script' });
|
||||||
import('./html-to-image');
|
import('./dom-to-image');
|
||||||
})();
|
})();
|
||||||
|
|||||||
138
src/global.d.ts
vendored
138
src/global.d.ts
vendored
@ -1,138 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
declare const __DEV__: boolean;
|
|
||||||
/** Extension name, defined in packageJson.name */
|
|
||||||
declare const __NAME__: string;
|
|
||||||
|
|
||||||
declare module '*.vue' {
|
|
||||||
const component: any;
|
|
||||||
export default component;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare type AppContext = 'options' | 'sidepanel' | 'background' | 'content script';
|
|
||||||
|
|
||||||
declare type Website = 'amazon' | 'homedepot';
|
|
||||||
|
|
||||||
declare const appContext: AppContext;
|
|
||||||
|
|
||||||
declare interface Chrome {
|
|
||||||
sidePanel?: {
|
|
||||||
setPanelBehavior: (options: { openPanelOnActionClick: boolean }) => void;
|
|
||||||
setOptions: (options: { path?: string }) => void;
|
|
||||||
onShown: {
|
|
||||||
addListener: (callback: () => void) => void;
|
|
||||||
removeListener: (callback: () => void) => void;
|
|
||||||
hasListener: (callback: () => void) => boolean;
|
|
||||||
};
|
|
||||||
onHidden: {
|
|
||||||
addListener: (callback: () => void) => void;
|
|
||||||
removeListener: (callback: () => void) => void;
|
|
||||||
hasListener: (callback: () => void) => boolean;
|
|
||||||
};
|
|
||||||
// V3 还支持指定页面的侧边栏配置
|
|
||||||
getOptions: (options: { tabId?: number }) => Promise<{ path?: string }>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 亚马逊搜索页信息
|
|
||||||
*/
|
|
||||||
declare type AmazonSearchItem = {
|
|
||||||
/** 搜索关键词 */
|
|
||||||
keywords: string;
|
|
||||||
/** 商品排名 */
|
|
||||||
rank: number;
|
|
||||||
/** 当前页码 */
|
|
||||||
page: number;
|
|
||||||
/** 商品链接 */
|
|
||||||
link: string;
|
|
||||||
/** 商品标题 */
|
|
||||||
title: string;
|
|
||||||
/** 商品的 ASIN(亚马逊标准识别号) */
|
|
||||||
asin: string;
|
|
||||||
/** 商品价格(可选) */
|
|
||||||
price?: string;
|
|
||||||
/** 商品图片链接 */
|
|
||||||
imageSrc: string;
|
|
||||||
/** 创建时间 */
|
|
||||||
createTime: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare type AmazonDetailItem = {
|
|
||||||
/** 商品的 ASIN(亚马逊标准识别号) */
|
|
||||||
asin: string;
|
|
||||||
/** 商品标题 */
|
|
||||||
title: string;
|
|
||||||
/** 时间戳,表示数据的创建或更新时间 */
|
|
||||||
timestamp: string;
|
|
||||||
/** 销量信息 */
|
|
||||||
broughtInfo?: string;
|
|
||||||
/** 商品价格 */
|
|
||||||
price?: string;
|
|
||||||
/** 商品评分 */
|
|
||||||
rating?: number;
|
|
||||||
/** 评分数量 */
|
|
||||||
ratingCount?: number;
|
|
||||||
/** 大类排名 */
|
|
||||||
category1?: {
|
|
||||||
name: string;
|
|
||||||
rank: number;
|
|
||||||
};
|
|
||||||
/** 小类排名 */
|
|
||||||
category2?: {
|
|
||||||
name: string;
|
|
||||||
rank: number;
|
|
||||||
};
|
|
||||||
/** 商品图片链接数组 */
|
|
||||||
imageUrls?: string[];
|
|
||||||
/** A+截图链接 */
|
|
||||||
aplus?: string;
|
|
||||||
// /** 顶部评论数组 */
|
|
||||||
// topReviews?: AmazonReview[];
|
|
||||||
};
|
|
||||||
|
|
||||||
declare type AmazonReview = {
|
|
||||||
/** 评论的唯一标识符 */
|
|
||||||
id: string;
|
|
||||||
/** 评论者用户名 */
|
|
||||||
username: string;
|
|
||||||
/** 评论标题 */
|
|
||||||
title: string;
|
|
||||||
/** 评论评分 */
|
|
||||||
rating: string;
|
|
||||||
/** 评论日期信息 */
|
|
||||||
dateInfo: string;
|
|
||||||
/** 评论内容 */
|
|
||||||
content: string;
|
|
||||||
/** 评论中包含的图片链接 */
|
|
||||||
imageSrc: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
declare type AmazonItem = Pick<AmazonSearchItem, 'asin'> &
|
|
||||||
Partial<AmazonSearchItem> &
|
|
||||||
Partial<AmazonDetailItem> & { hasDetail: boolean };
|
|
||||||
|
|
||||||
declare type HomedepotDetailItem = {
|
|
||||||
OSMID: string;
|
|
||||||
link: string;
|
|
||||||
brandName?: string;
|
|
||||||
title: string;
|
|
||||||
price: string;
|
|
||||||
rate?: string;
|
|
||||||
reviewCount?: number;
|
|
||||||
mainImageUrl: string;
|
|
||||||
modelInfo?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare type LowesDetailItem = {
|
|
||||||
OSMID: string;
|
|
||||||
link: string;
|
|
||||||
brandName?: string;
|
|
||||||
title: string;
|
|
||||||
price: string;
|
|
||||||
rate?: string;
|
|
||||||
innerText: string;
|
|
||||||
reviewCount?: number;
|
|
||||||
mainImageUrl: string;
|
|
||||||
modelInfo?: string;
|
|
||||||
};
|
|
||||||
@ -8,7 +8,7 @@ export function flattenObject(obj: Record<string, unknown>) {
|
|||||||
value = value[key];
|
value = value[key];
|
||||||
}
|
}
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (typeof value === 'object' && value !== null) {
|
||||||
stack.push(...Object.keys(value).map((k) => keys.concat([k])));
|
stack.unshift(...Object.keys(value).map((k) => keys.concat([k])));
|
||||||
} else {
|
} else {
|
||||||
mappedEnties.push([keys, value]);
|
mappedEnties.push([keys, value]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,7 +131,9 @@ const extraHeaders: Header<AmazonItem>[] = [
|
|||||||
formatOutputValue: (val: boolean) => (val ? '是' : '否'),
|
formatOutputValue: (val: boolean) => (val ? '是' : '否'),
|
||||||
parseImportValue: (val: string) => val === '是',
|
parseImportValue: (val: string) => val === '是',
|
||||||
},
|
},
|
||||||
{ prop: 'broughtInfo', label: '销量信息' },
|
{ prop: 'boughtInfo', label: '销量信息' },
|
||||||
|
{ prop: 'categories', label: '分类信息' },
|
||||||
|
{ prop: 'availableDate', label: '上架日期' },
|
||||||
{ prop: 'rating', label: '评分' },
|
{ prop: 'rating', label: '评分' },
|
||||||
{ prop: 'ratingCount', label: '评论数' },
|
{ prop: 'ratingCount', label: '评论数' },
|
||||||
{ prop: 'category1.name', label: '大类' },
|
{ prop: 'category1.name', label: '大类' },
|
||||||
|
|||||||
@ -7,6 +7,12 @@ import { allItems } from '~/storages/homedepot';
|
|||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const excelHelper = useExcelHelper();
|
const excelHelper = useExcelHelper();
|
||||||
|
|
||||||
|
const filter = ref({ timeRange: null as [number, number] | null });
|
||||||
|
|
||||||
|
const resetFilter = () => {
|
||||||
|
filter.value = { timeRange: null };
|
||||||
|
};
|
||||||
|
|
||||||
const columns: TableColumn[] = [
|
const columns: TableColumn[] = [
|
||||||
{
|
{
|
||||||
title: 'OSMID',
|
title: 'OSMID',
|
||||||
@ -42,14 +48,9 @@ const columns: TableColumn[] = [
|
|||||||
minWidth: 75,
|
minWidth: 75,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '商品链接',
|
title: '获取日期',
|
||||||
key: 'link',
|
key: 'timestamp',
|
||||||
hidden: true,
|
minWidth: 150,
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '主图链接',
|
|
||||||
key: 'mainImageUrl',
|
|
||||||
hidden: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@ -75,23 +76,45 @@ const columns: TableColumn[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const extraHeaders: Header[] = [
|
||||||
|
{
|
||||||
|
label: '商品链接',
|
||||||
|
prop: 'link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '主图链接',
|
||||||
|
prop: 'mainImageUrl',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '图片链接',
|
||||||
|
prop: 'imageUrls',
|
||||||
|
formatOutputValue: (val?: string[]) => val?.join(';'),
|
||||||
|
parseImportValue: (val?: string) => val?.split(';'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const filteredData = computed(() => {
|
const filteredData = computed(() => {
|
||||||
return allItems.value;
|
let data = allItems.value;
|
||||||
|
if (filter.value.timeRange) {
|
||||||
|
const start = dayjs(filter.value.timeRange[0]);
|
||||||
|
const end = dayjs(filter.value.timeRange[1]);
|
||||||
|
data = data.filter(
|
||||||
|
(r) => dayjs(r.timestamp).diff(start) >= 0 && dayjs(r.timestamp).diff(end) <= 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
});
|
});
|
||||||
|
|
||||||
const getItemHeaders = () => {
|
const getItemHeaders = () => {
|
||||||
return columns
|
return columns
|
||||||
.filter((col: Record<string, any>) => col.key !== 'actions')
|
.filter((col: Record<string, any>) => col.key !== 'actions')
|
||||||
.reduce(
|
.reduce((p, v: Record<string, any>) => {
|
||||||
(p, v: Record<string, any>) => {
|
if ('key' in v && 'title' in v) {
|
||||||
if ('key' in v && 'title' in v) {
|
p.push({ label: v.title, prop: v.key });
|
||||||
p.push({ label: v.title, prop: v.key });
|
}
|
||||||
}
|
return p;
|
||||||
return p;
|
}, [] as Header[])
|
||||||
},
|
.concat(extraHeaders);
|
||||||
[] as { label: string; prop: string }[],
|
|
||||||
)
|
|
||||||
.concat([]) as Header[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearData = () => {
|
const handleClearData = () => {
|
||||||
@ -125,6 +148,22 @@ const handleExport = async (opt: 'cloud' | 'local') => {
|
|||||||
<template #exporter>
|
<template #exporter>
|
||||||
<export-panel @export-file="handleExport" />
|
<export-panel @export-file="handleExport" />
|
||||||
</template>
|
</template>
|
||||||
|
<template #filter>
|
||||||
|
<div class="filter-panel">
|
||||||
|
<div>过滤条件</div>
|
||||||
|
<n-form label-placement="left" :label-width="80" label-align="center">
|
||||||
|
<n-form-item label="采集时间:">
|
||||||
|
<n-date-picker
|
||||||
|
type="datetimerange"
|
||||||
|
v-model:value="filter.timeRange"
|
||||||
|
></n-date-picker>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-space justify="end">
|
||||||
|
<n-button @click="resetFilter">重置</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</control-strip>
|
</control-strip>
|
||||||
</template>
|
</template>
|
||||||
</result-table>
|
</result-table>
|
||||||
@ -151,4 +190,14 @@ const handleExport = async (opt: 'cloud' | 'local') => {
|
|||||||
gap: 15px;
|
gap: 15px;
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 500px;
|
||||||
|
|
||||||
|
& > div:first-of-type {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useLongTask } from '~/composables/useLongTask';
|
import { useLongTask } from '~/composables/useLongTask';
|
||||||
import amazon from '../amazon';
|
import amazon from '../impls/amazon';
|
||||||
import { uploadImage } from '~/logic/upload';
|
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';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useLongTask } from '~/composables/useLongTask';
|
import { useLongTask } from '~/composables/useLongTask';
|
||||||
import { detailItems as homedepotDetailItems } from '~/storages/homedepot';
|
import { detailItems as homedepotDetailItems } from '~/storages/homedepot';
|
||||||
import homedepot from '../homedepot';
|
import homedepot from '../impls/homedepot';
|
||||||
import { createGlobalState } from '@vueuse/core';
|
import { createGlobalState } from '@vueuse/core';
|
||||||
|
|
||||||
export interface HomedepotWorkerSettings {
|
export interface HomedepotWorkerSettings {
|
||||||
@ -44,21 +44,19 @@ function buildHomedepotWorker() {
|
|||||||
const commitChange = () => {
|
const commitChange = () => {
|
||||||
const { objects } = settings.value;
|
const { objects } = settings.value;
|
||||||
if (objects?.includes('detail')) {
|
if (objects?.includes('detail')) {
|
||||||
const detailItems = toRaw(homedepotDetailItems.value);
|
|
||||||
for (const [k, v] of detailCache.entries()) {
|
for (const [k, v] of detailCache.entries()) {
|
||||||
if (detailItems.has(k)) {
|
if (homedepotDetailItems.value.has(k)) {
|
||||||
const origin = detailItems.get(k)!;
|
const origin = homedepotDetailItems.value.get(k)!;
|
||||||
detailItems.set(k, { ...origin, ...v });
|
homedepotDetailItems.value.set(k, { ...origin, ...v });
|
||||||
} else {
|
} else {
|
||||||
detailItems.set(k, v);
|
homedepotDetailItems.value.set(k, v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
homedepotDetailItems.value = detailItems;
|
|
||||||
detailCache.clear();
|
detailCache.clear();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskWrapper = <T extends (...params: any) => any>(func: T) => {
|
const taskWrapper1 = <T extends (...params: any) => any>(func: T) => {
|
||||||
const { commitChangeIngerval = 10000 } = settings.value;
|
const { commitChangeIngerval = 10000 } = settings.value;
|
||||||
return (...params: Parameters<T>) =>
|
return (...params: Parameters<T>) =>
|
||||||
startTask(async () => {
|
startTask(async () => {
|
||||||
@ -69,7 +67,7 @@ function buildHomedepotWorker() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const runDetailPageTask = taskWrapper(worker.runDetailPageTask.bind(worker));
|
const runDetailPageTask = taskWrapper1(worker.runDetailPageTask.bind(worker));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import type { AmazonPageWorker, AmazonPageWorkerEvents, LanchTaskBaseOptions } from './types';
|
import type { AmazonPageWorker, AmazonPageWorkerEvents, LanchTaskBaseOptions } from '../types';
|
||||||
import type { Tabs } from 'webextension-polyfill';
|
import type { Tabs } from 'webextension-polyfill';
|
||||||
import { withErrorHandling } from './error-handler';
|
import { withErrorHandling } from '../error-handler';
|
||||||
import {
|
import {
|
||||||
AmazonDetailPageInjector,
|
AmazonDetailPageInjector,
|
||||||
AmazonReviewPageInjector,
|
AmazonReviewPageInjector,
|
||||||
AmazonSearchPageInjector,
|
AmazonSearchPageInjector,
|
||||||
} from './web-injectors/amazon';
|
} from '../web-injectors/amazon';
|
||||||
import { isForbiddenUrl } from '~/env';
|
import { isForbiddenUrl } from '~/env';
|
||||||
import { BaseWorker } from './base';
|
import { BaseWorker } from './base';
|
||||||
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { HomedepotEvents, HomedepotWorker, LanchTaskBaseOptions } from './types';
|
import type { HomedepotEvents, HomedepotWorker, LanchTaskBaseOptions } from '../types';
|
||||||
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';
|
||||||
import { BaseWorker } from './base';
|
import { BaseWorker } from './base';
|
||||||
|
|
||||||
class HomedepotWorkerImpl
|
class HomedepotWorkerImpl
|
||||||
@ -25,20 +25,45 @@ class HomedepotWorkerImpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
@withErrorHandling
|
@withErrorHandling
|
||||||
private async wanderingDetailPage(OSMID: string) {
|
private async wanderingDetailPage(OSMID: string, review?: boolean) {
|
||||||
const url = `https://www.homedepot.com/p/${OSMID}`;
|
const url = `https://www.homedepot.com/p/${OSMID}`;
|
||||||
const tab = await this.createNewTab(url);
|
const tab = await this.createNewTab(url);
|
||||||
const injector = new HomedepotDetailPageInjector(tab);
|
const injector = new HomedepotDetailPageInjector(tab);
|
||||||
await injector.waitForPageLoad();
|
const available = await injector.waitForPageLoad();
|
||||||
|
if (!available) {
|
||||||
|
setTimeout(() => {
|
||||||
|
browser.tabs.remove(tab.id!);
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const info = await injector.getInfo();
|
const info = await injector.getInfo();
|
||||||
await this.emit('detail-item-collected', { item: { OSMID, ...info } });
|
const imageUrls = await injector.getImageUrls();
|
||||||
|
await this.emit('detail-item-collected', {
|
||||||
|
item: { OSMID, ...info, imageUrls, timestamp: dayjs().format('YYYY/M/D HH:mm:ss') },
|
||||||
|
});
|
||||||
|
if (!review) {
|
||||||
|
setTimeout(() => {
|
||||||
|
browser.tabs.remove(tab.id!);
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await injector.waitForReviewLoad();
|
||||||
|
const reviews = await injector.getReviews();
|
||||||
|
await this.emit('review-collected', { reviews });
|
||||||
|
while (await injector.tryJumpToNextPage()) {
|
||||||
|
const reviews = await injector.getReviews();
|
||||||
|
await this.emit('review-collected', { reviews });
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
browser.tabs.remove(tab.id!);
|
browser.tabs.remove(tab.id!);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async runDetailPageTask(OSMIDs: string[], options: LanchTaskBaseOptions = {}): Promise<void> {
|
async runDetailPageTask(
|
||||||
const { progress } = options;
|
OSMIDs: string[],
|
||||||
|
options: LanchTaskBaseOptions & { review?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const { progress, review } = options;
|
||||||
const remains = [...OSMIDs];
|
const remains = [...OSMIDs];
|
||||||
let interrupt = false;
|
let interrupt = false;
|
||||||
const unsubscribe = this.on('interrupt', () => {
|
const unsubscribe = this.on('interrupt', () => {
|
||||||
@ -46,7 +71,7 @@ class HomedepotWorkerImpl
|
|||||||
});
|
});
|
||||||
while (remains.length > 0 && !interrupt) {
|
while (remains.length > 0 && !interrupt) {
|
||||||
const OSMIDs = remains.shift()!;
|
const OSMIDs = remains.shift()!;
|
||||||
await this.wanderingDetailPage(OSMIDs);
|
await this.wanderingDetailPage(OSMIDs, review);
|
||||||
progress && progress(remains);
|
progress && progress(remains);
|
||||||
}
|
}
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
@ -14,7 +14,14 @@ export interface AmazonPageWorkerEvents {
|
|||||||
*/
|
*/
|
||||||
['item-base-info-collected']: Pick<
|
['item-base-info-collected']: Pick<
|
||||||
AmazonDetailItem,
|
AmazonDetailItem,
|
||||||
'asin' | 'title' | 'broughtInfo' | 'price' | 'rating' | 'ratingCount' | 'timestamp'
|
| 'asin'
|
||||||
|
| 'title'
|
||||||
|
| 'boughtInfo'
|
||||||
|
| 'price'
|
||||||
|
| 'rating'
|
||||||
|
| 'ratingCount'
|
||||||
|
| 'categories'
|
||||||
|
| 'timestamp'
|
||||||
>;
|
>;
|
||||||
/**
|
/**
|
||||||
* The event is fired when worker
|
* The event is fired when worker
|
||||||
@ -81,6 +88,10 @@ 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
|
||||||
|
*/
|
||||||
|
['review-collected']: { reviews: HomedepotReview[] };
|
||||||
/**
|
/**
|
||||||
* The event is fired when error occurs.
|
* The event is fired when error occurs.
|
||||||
*/
|
*/
|
||||||
@ -91,7 +102,10 @@ export interface HomedepotWorker extends Listener<HomedepotEvents> {
|
|||||||
/**
|
/**
|
||||||
* Browsing goods detail page and collect target information
|
* Browsing goods detail page and collect target information
|
||||||
*/
|
*/
|
||||||
runDetailPageTask(OSMIDs: string[], options?: LanchTaskBaseOptions): Promise<void>;
|
runDetailPageTask(
|
||||||
|
OSMIDs: string[],
|
||||||
|
options?: LanchTaskBaseOptions & { review?: boolean },
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the worker.
|
* Stop the worker.
|
||||||
|
|||||||
@ -139,6 +139,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AmazonDetailPageInjector extends BaseInjector {
|
export class AmazonDetailPageInjector extends BaseInjector {
|
||||||
|
/**等待页面加载完成 */
|
||||||
public async waitForPageLoaded() {
|
public async waitForPageLoaded() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
@ -162,19 +163,32 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**获取基本信息 */
|
||||||
public async getBaseInfo() {
|
public async getBaseInfo() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
const title = document.querySelector<HTMLElement>('#title')!.innerText;
|
const title = document.querySelector<HTMLElement>('#title')!.innerText;
|
||||||
const price = document.querySelector<HTMLElement>(
|
const price = document.querySelector<HTMLElement>(
|
||||||
'.aok-offscreen, .a-price:not(.a-text-price) .a-offscreen',
|
'.aok-offscreen, .a-price:not(.a-text-price) .a-offscreen',
|
||||||
)?.innerText;
|
)?.innerText;
|
||||||
const broughtInfo = document.querySelector<HTMLElement>(
|
const boughtInfo = document.querySelector<HTMLElement>(
|
||||||
`#social-proofing-faceout-title-tk_bought`,
|
`#social-proofing-faceout-title-tk_bought`,
|
||||||
)?.innerText;
|
)?.innerText;
|
||||||
return { title, price, broughtInfo };
|
const availableDate = (
|
||||||
|
document.evaluate(
|
||||||
|
`//span[contains(text(), 'Date First Available')]/following-sibling::*[1]`,
|
||||||
|
document,
|
||||||
|
null,
|
||||||
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||||
|
).singleNodeValue as HTMLElement | undefined
|
||||||
|
)?.innerText;
|
||||||
|
const categories = document
|
||||||
|
.querySelector<HTMLElement>('#wayfinding-breadcrumbs_feature_div')
|
||||||
|
?.innerText.replaceAll('\n', '');
|
||||||
|
return { title, price, boughtInfo, availableDate, categories };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**获取评价信息 */
|
||||||
public async getRatingInfo() {
|
public async getRatingInfo() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
const review = document.querySelector('#averageCustomerReviews');
|
const review = document.querySelector('#averageCustomerReviews');
|
||||||
@ -195,6 +209,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**获取排名信息 */
|
||||||
public async getRankText() {
|
public async getRankText() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
const xpathExps = [
|
const xpathExps = [
|
||||||
@ -219,6 +234,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**获取图像链接 */
|
||||||
public async getImageUrls() {
|
public async getImageUrls() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
const overlay = document.querySelector<HTMLDivElement>('.overlayRestOfImages');
|
const overlay = document.querySelector<HTMLDivElement>('.overlayRestOfImages');
|
||||||
@ -249,6 +265,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**获取精选评论 */
|
||||||
public async getTopReviews() {
|
public async getTopReviews() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
const targetNode = document.querySelector<HTMLDivElement>('.cr-widget-FocalReviews');
|
const targetNode = document.querySelector<HTMLDivElement>('.cr-widget-FocalReviews');
|
||||||
@ -303,6 +320,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**滑动扫描A+界面 */
|
||||||
public async scanAPlus() {
|
public async scanAPlus() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
const aplusEl = document.querySelector<HTMLElement>('#aplus_feature_div');
|
const aplusEl = document.querySelector<HTMLElement>('#aplus_feature_div');
|
||||||
@ -329,9 +347,60 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**获取A+截图 */
|
||||||
public async captureAPlus() {
|
public async captureAPlus() {
|
||||||
return this.screenshot({ type: 'CSS', selector: '#aplus_feature_div' });
|
return this.screenshot({ type: 'CSS', selector: '#aplus_feature_div' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**获取额外商品信息 */
|
||||||
|
public async getExtraInfo() {
|
||||||
|
return this.run(async () => {
|
||||||
|
const $x = <T extends HTMLElement>(xpath: string): T[] | undefined => {
|
||||||
|
const result = document.evaluate(
|
||||||
|
xpath,
|
||||||
|
document,
|
||||||
|
null,
|
||||||
|
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const nodes: T[] = [];
|
||||||
|
for (let i = 0; i < result.snapshotLength; i++) {
|
||||||
|
nodes.push(result.snapshotItem(i)! as T);
|
||||||
|
}
|
||||||
|
return nodes.length > 0 ? nodes : undefined;
|
||||||
|
};
|
||||||
|
const shipFrom = document.querySelector<HTMLElement>(
|
||||||
|
'#fulfillerInfoFeature_feature_div > *:last-of-type',
|
||||||
|
)?.innerText;
|
||||||
|
const abouts = $x(
|
||||||
|
`//*[normalize-space(text())='About this item']/following-sibling::ul[1]/li`,
|
||||||
|
)?.map((el) => el.innerText);
|
||||||
|
const brand = $x(`//*[./span[normalize-space(text())='Brand']]/following-sibling::*[1]`)?.[0]
|
||||||
|
.innerText;
|
||||||
|
const flavor = $x(
|
||||||
|
`//*[./span[normalize-space(text())='Flavor']]/following-sibling::*[1]`,
|
||||||
|
)?.[0].innerText;
|
||||||
|
const unitCount = $x(
|
||||||
|
`//*[./span[normalize-space(text())='Unit Count']]/following-sibling::*[1]`,
|
||||||
|
)?.[0].innerText;
|
||||||
|
const itemForm = $x(
|
||||||
|
`//*[./span[normalize-space(text())='Item Form']]/following-sibling::*[1]`,
|
||||||
|
)?.[0].innerText;
|
||||||
|
const productDemensions = $x(
|
||||||
|
`//span[contains(text(), 'Dimensions')]/following-sibling::*[1]`,
|
||||||
|
)?.[0].innerText;
|
||||||
|
|
||||||
|
return {
|
||||||
|
abouts,
|
||||||
|
shipFrom,
|
||||||
|
brand,
|
||||||
|
flavor,
|
||||||
|
unitCount,
|
||||||
|
itemForm,
|
||||||
|
productDemensions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AmazonReviewPageInjector extends BaseInjector {
|
export class AmazonReviewPageInjector extends BaseInjector {
|
||||||
|
|||||||
@ -27,10 +27,10 @@ export class BaseInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async screenshot(
|
protected async screenshot(
|
||||||
params: ProtocolMap['html-to-image']['data'],
|
data: ProtocolMap['dom-to-image']['data'],
|
||||||
): Promise<ProtocolMap['html-to-image']['return']> {
|
): Promise<ProtocolMap['dom-to-image']['return']> {
|
||||||
const sender = await this.getMessageSender();
|
const sender = await this.getMessageSender();
|
||||||
return sender!.sendMessage('html-to-image', params, {
|
return sender!.sendMessage('dom-to-image', data, {
|
||||||
context: 'content-script',
|
context: 'content-script',
|
||||||
tabId: this._tab.id!,
|
tabId: this._tab.id!,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -28,6 +28,14 @@ export class HomedepotDetailPageInjector extends BaseInjector {
|
|||||||
(document.readyState == 'complete' || timeout)
|
(document.readyState == 'complete' || timeout)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const needToSkip = () => {
|
||||||
|
return !!document.evaluate(
|
||||||
|
`//p[text() = 'The product you are trying to view is not currently available.']`,
|
||||||
|
document,
|
||||||
|
null,
|
||||||
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||||
|
).singleNodeValue;
|
||||||
|
};
|
||||||
while (true) {
|
while (true) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(Math.random() * 500)));
|
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(Math.random() * 500)));
|
||||||
document
|
document
|
||||||
@ -41,10 +49,14 @@ export class HomedepotDetailPageInjector extends BaseInjector {
|
|||||||
: document
|
: document
|
||||||
.querySelector('[data-component^="product-details:ProductDetailsTitle"]')
|
.querySelector('[data-component^="product-details:ProductDetailsTitle"]')
|
||||||
?.scrollIntoView({ behavior: 'smooth' });
|
?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
if (needToSkip()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (isLoaded()) {
|
if (isLoaded()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,7 +104,86 @@ export class HomedepotDetailPageInjector extends BaseInjector {
|
|||||||
reviewCount,
|
reviewCount,
|
||||||
mainImageUrl,
|
mainImageUrl,
|
||||||
modelInfo,
|
modelInfo,
|
||||||
} as Omit<HomedepotDetailItem, 'OSMID'>;
|
} as Omit<HomedepotDetailItem, 'OSMID' | 'imageUrls' | 'timestamp'>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getImageUrls() {
|
||||||
|
return this.run(async () => {
|
||||||
|
const text = document.querySelector<HTMLElement>(
|
||||||
|
'script#thd-helmet__script--productStructureData',
|
||||||
|
)!.innerText;
|
||||||
|
const obj = JSON.parse(text);
|
||||||
|
return obj['image'] as string[];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public waitForReviewLoad() {
|
||||||
|
return this.run(async () => {
|
||||||
|
while (true) {
|
||||||
|
const el = document.querySelector('.review_item');
|
||||||
|
document
|
||||||
|
.querySelector("#product-section-rr div[role='button']")
|
||||||
|
?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
if (el && el.getClientRects().length > 0 && el.getClientRects()[0].height > 0) {
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getReviews() {
|
||||||
|
return this.run(async () => {
|
||||||
|
const elements = document.querySelectorAll('.review_item');
|
||||||
|
return Array.from(elements).map((root) => {
|
||||||
|
const title = root.querySelector<HTMLElement>('.review-content__title')!.innerText;
|
||||||
|
const content = root.querySelector<HTMLElement>('.review-content-body')!.innerText;
|
||||||
|
const username = root.querySelector<HTMLElement>(
|
||||||
|
'.review-content__no-padding > button',
|
||||||
|
)!.innerText;
|
||||||
|
const dateInfo = root.querySelector<HTMLElement>('.review-content__date')!.innerText;
|
||||||
|
const rating = root
|
||||||
|
.querySelector<HTMLElement>('[name="simple-rating"]')!
|
||||||
|
.getAttribute('aria-label')!;
|
||||||
|
const badges = Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>('.review-status-icons__list, li.review-badge > *'),
|
||||||
|
)
|
||||||
|
.map((el) => el.innerText)
|
||||||
|
.filter((t) => !["(What's this?)"].includes(t));
|
||||||
|
const imageUrls = Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>('.media-carousel__media > button'),
|
||||||
|
).map((el) => el.style.backgroundImage.split(/[\(\)]/, 3)[1]);
|
||||||
|
return { title, content, username, dateInfo, rating, badges, imageUrls } as HomedepotReview;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public tryJumpToNextPage() {
|
||||||
|
return this.run(async () => {
|
||||||
|
const final = document.querySelector<HTMLElement>(
|
||||||
|
'.pager__summary--bold:nth-last-of-type(2)',
|
||||||
|
)!.innerText;
|
||||||
|
const anchor = document.querySelector<HTMLElement>(
|
||||||
|
'.pager__summary--bold + .pager__summary--bold',
|
||||||
|
)!.innerText;
|
||||||
|
if (final === anchor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const button = document.querySelector<HTMLElement>('[data-testid="pagination-Next"]');
|
||||||
|
button!.click();
|
||||||
|
while (true) {
|
||||||
|
const newAnchor = document.querySelector<HTMLElement>(
|
||||||
|
'.pager__summary--bold + .pager__summary--bold',
|
||||||
|
)!.innerText;
|
||||||
|
if (newAnchor !== anchor) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 { detailInputText } from '~/storages/homedepot';
|
||||||
|
import { detailWorkerSettings } from '~/storages/homedepot';
|
||||||
|
|
||||||
const inputText = ref('');
|
|
||||||
const idInputRef = useTemplateRef('id-input');
|
const idInputRef = useTemplateRef('id-input');
|
||||||
|
|
||||||
const worker = usePageWorker('homedepot', { objects: ['detail'] });
|
const worker = usePageWorker('homedepot', { objects: ['detail'] });
|
||||||
@ -20,7 +21,7 @@ const timelines = ref<Timeline[]>([]);
|
|||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
idInputRef.value?.validate().then(async (success) => {
|
idInputRef.value?.validate().then(async (success) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
const ids = inputText.value.split(/\n|\s|,|;/).filter((item) => item.length > 0);
|
const ids = detailInputText.value.split(/\n|\s|,|;/).filter((item) => item.length > 0);
|
||||||
timelines.value = [
|
timelines.value = [
|
||||||
{
|
{
|
||||||
type: 'info',
|
type: 'info',
|
||||||
@ -36,11 +37,12 @@ const handleStart = async () => {
|
|||||||
type: 'info',
|
type: 'info',
|
||||||
title: '继续',
|
title: '继续',
|
||||||
time: new Date().toLocaleString(),
|
time: new Date().toLocaleString(),
|
||||||
content: `继续采集OSMID: ${remains.join(', ')}`,
|
content: `剩余: ${remains.length}`,
|
||||||
});
|
});
|
||||||
inputText.value = remains.join('\n');
|
|
||||||
}
|
}
|
||||||
|
detailInputText.value = remains.join('\n');
|
||||||
},
|
},
|
||||||
|
review: detailWorkerSettings.value.review,
|
||||||
});
|
});
|
||||||
timelines.value.push({
|
timelines.value.push({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
@ -62,29 +64,29 @@ const handleInterrupt = () => {
|
|||||||
<header-title>Homedepot</header-title>
|
<header-title>Homedepot</header-title>
|
||||||
<div class="interative-section">
|
<div class="interative-section">
|
||||||
<ids-input
|
<ids-input
|
||||||
v-model="inputText"
|
v-model="detailInputText"
|
||||||
:disabled="worker.isRunning.value"
|
:disabled="worker.isRunning.value"
|
||||||
ref="id-input"
|
ref="id-input"
|
||||||
:match-pattern="/^\d+(\n\d+)*\n?$/g"
|
:match-pattern="/^\d{9}(\n\d{9})*\n?$/g"
|
||||||
placeholder="输入OSMID"
|
placeholder="输入OSMID"
|
||||||
validate-message="请输入格式正确的OSMID"
|
validate-message="请输入格式正确的OSMID"
|
||||||
/>
|
/>
|
||||||
<n-button
|
<optional-button v-if="!worker.isRunning.value" round size="large" @click="handleStart">
|
||||||
v-if="!worker.isRunning.value"
|
<template #popover>
|
||||||
round
|
<div class="setting-panel">
|
||||||
size="large"
|
<div>设置</div>
|
||||||
type="primary"
|
<n-form :label-width="50" label-placement="left" :show-feedback="false">
|
||||||
@click="handleStart"
|
<n-form-item label="评论:">
|
||||||
>
|
<n-switch v-model:value="detailWorkerSettings.review" />
|
||||||
<template #icon>
|
</n-form-item>
|
||||||
<ant-design-thunderbolt-outlined />
|
</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 /></n-icon>
|
||||||
<ant-design-thunderbolt-outlined />
|
|
||||||
</template>
|
|
||||||
停止
|
停止
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
@ -126,4 +128,11 @@ 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>
|
||||||
|
|||||||
@ -2,6 +2,10 @@ import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
|||||||
|
|
||||||
export const detailInputText = useWebExtensionStorage('homedepot-detail-input-text', '');
|
export const detailInputText = useWebExtensionStorage('homedepot-detail-input-text', '');
|
||||||
|
|
||||||
|
export const detailWorkerSettings = useWebExtensionStorage('homedepot-detail-worker-settings', {
|
||||||
|
review: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const detailItems = useWebExtensionStorage<Map<string, HomedepotDetailItem>>(
|
export const detailItems = useWebExtensionStorage<Map<string, HomedepotDetailItem>>(
|
||||||
'homedepot-details',
|
'homedepot-details',
|
||||||
new Map(),
|
new Map(),
|
||||||
|
|||||||
70
src/types/amazon.d.ts
vendored
Normal file
70
src/types/amazon.d.ts
vendored
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
declare type AmazonSearchItem = {
|
||||||
|
/** 搜索关键词 */
|
||||||
|
keywords: string;
|
||||||
|
/** 商品排名 */
|
||||||
|
rank: number;
|
||||||
|
/** 当前页码 */
|
||||||
|
page: number;
|
||||||
|
/** 商品链接 */
|
||||||
|
link: string;
|
||||||
|
/** 商品标题 */
|
||||||
|
title: string;
|
||||||
|
/** 商品的 ASIN(亚马逊标准识别号) */
|
||||||
|
asin: string;
|
||||||
|
/** 商品价格(可选) */
|
||||||
|
price?: string;
|
||||||
|
/** 商品图片链接 */
|
||||||
|
imageSrc: string;
|
||||||
|
/** 创建时间 */
|
||||||
|
createTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare type AmazonDetailItem = {
|
||||||
|
/** 商品的 ASIN(亚马逊标准识别号) */
|
||||||
|
asin: string;
|
||||||
|
/** 商品标题 */
|
||||||
|
title: string;
|
||||||
|
/** 时间戳,表示数据的创建或更新时间 */
|
||||||
|
timestamp: string;
|
||||||
|
/** 销量信息 */
|
||||||
|
boughtInfo?: string;
|
||||||
|
/** 商品价格 */
|
||||||
|
price?: string;
|
||||||
|
/** 商品评分 */
|
||||||
|
rating?: number;
|
||||||
|
/** 评分数量 */
|
||||||
|
ratingCount?: number;
|
||||||
|
/** 分类信息*/
|
||||||
|
categories?: string;
|
||||||
|
/** 上架日期 */
|
||||||
|
availableDate?: string;
|
||||||
|
/** 大类排名 */
|
||||||
|
category1?: {
|
||||||
|
name: string;
|
||||||
|
rank: number;
|
||||||
|
};
|
||||||
|
/** 小类排名 */
|
||||||
|
category2?: {
|
||||||
|
name: string;
|
||||||
|
rank: number;
|
||||||
|
};
|
||||||
|
/** 商品图片链接数组 */
|
||||||
|
imageUrls?: string[];
|
||||||
|
/** A+截图链接 */
|
||||||
|
aplus?: string;
|
||||||
|
// /** 顶部评论数组 */
|
||||||
|
// topReviews?: AmazonReview[];
|
||||||
|
/**关于信息 */
|
||||||
|
abouts?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
declare type AmazonReview = BaseReview & {
|
||||||
|
/** 评论的唯一标识符 */
|
||||||
|
id: string;
|
||||||
|
/** 评论中包含的图片链接 */
|
||||||
|
imageSrc: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
declare type AmazonItem = Pick<AmazonSearchItem, 'asin'> &
|
||||||
|
Partial<AmazonSearchItem> &
|
||||||
|
Partial<AmazonDetailItem> & { hasDetail: boolean };
|
||||||
12
src/types/base.d.ts
vendored
Normal file
12
src/types/base.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
declare type BaseReview = {
|
||||||
|
/** 评论者用户名 */
|
||||||
|
username: string;
|
||||||
|
/** 评论标题 */
|
||||||
|
title: string;
|
||||||
|
/** 评论评分 */
|
||||||
|
rating: string;
|
||||||
|
/** 评论日期信息 */
|
||||||
|
dateInfo: string;
|
||||||
|
/** 评论内容 */
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
31
src/types/homedepot.d.ts
vendored
Normal file
31
src/types/homedepot.d.ts
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
declare type HomedepotDetailItem = {
|
||||||
|
/** The unique OSM identifier for the item.*/
|
||||||
|
OSMID: string;
|
||||||
|
/** The URL link to the item's page. */
|
||||||
|
link: string;
|
||||||
|
/** The brand name of the item (optional).*/
|
||||||
|
brandName?: string;
|
||||||
|
/** The title or name of the item.*/
|
||||||
|
title: string;
|
||||||
|
/** The price of the item as a string. */
|
||||||
|
price: string;
|
||||||
|
/** The rating of the item (optional).*/
|
||||||
|
rate?: string;
|
||||||
|
/** The number of reviews for the item (optional).*/
|
||||||
|
reviewCount?: number;
|
||||||
|
/** The main image URL of the item.*/
|
||||||
|
mainImageUrl: string;
|
||||||
|
/** All urls of images*/
|
||||||
|
imageUrls?: string[];
|
||||||
|
/** Additional model information for the item (optional).*/
|
||||||
|
modelInfo?: string;
|
||||||
|
/** Timestamp */
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare type HomedepotReview = BaseReview & {
|
||||||
|
/**Review's image urls */
|
||||||
|
imageUrls?: string[];
|
||||||
|
/** Review's badges*/
|
||||||
|
badges?: string[];
|
||||||
|
};
|
||||||
12
src/types/lowes.d.ts
vendored
Normal file
12
src/types/lowes.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
declare type LowesDetailItem = {
|
||||||
|
OSMID: string;
|
||||||
|
link: string;
|
||||||
|
brandName?: string;
|
||||||
|
title: string;
|
||||||
|
price: string;
|
||||||
|
rate?: string;
|
||||||
|
innerText: string;
|
||||||
|
reviewCount?: number;
|
||||||
|
mainImageUrl: string;
|
||||||
|
modelInfo?: string;
|
||||||
|
};
|
||||||
35
src/types/misc.ts
Normal file
35
src/types/misc.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __DEV__: boolean;
|
||||||
|
/** Extension name, defined in packageJson.name */
|
||||||
|
declare const __NAME__: string;
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
const component: any;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare type AppContext = 'options' | 'sidepanel' | 'background' | 'content script';
|
||||||
|
|
||||||
|
declare type Website = 'amazon' | 'homedepot';
|
||||||
|
|
||||||
|
declare const appContext: AppContext;
|
||||||
|
|
||||||
|
declare interface Chrome {
|
||||||
|
sidePanel?: {
|
||||||
|
setPanelBehavior: (options: { openPanelOnActionClick: boolean }) => void;
|
||||||
|
setOptions: (options: { path?: string }) => void;
|
||||||
|
onShown: {
|
||||||
|
addListener: (callback: () => void) => void;
|
||||||
|
removeListener: (callback: () => void) => void;
|
||||||
|
hasListener: (callback: () => void) => boolean;
|
||||||
|
};
|
||||||
|
onHidden: {
|
||||||
|
addListener: (callback: () => void) => void;
|
||||||
|
removeListener: (callback: () => void) => void;
|
||||||
|
hasListener: (callback: () => void) => boolean;
|
||||||
|
};
|
||||||
|
// V3 还支持指定页面的侧边栏配置
|
||||||
|
getOptions: (options: { tabId?: number }) => Promise<{ path?: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -5,7 +5,8 @@
|
|||||||
"moduleResolution": "node16",
|
"moduleResolution": "node16",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"baseUrl": "."
|
"baseUrl": ".",
|
||||||
|
"strict": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.*", "scripts/*"]
|
"include": ["vite.config.*", "scripts/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user