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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.2.293",
|
||||
"@iconify/json": "^2.2.356",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/webextension-polyfill": "^0.12.3",
|
||||
"@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-jsx": "^5.0.1",
|
||||
"@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",
|
||||
"chokidar": "^4.0.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"emittery": "^1.1.0",
|
||||
"emittery": "^1.2.0",
|
||||
"esno": "^4.8.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"fs-extra": "^11.3.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"kolorist": "^1.8.0",
|
||||
"lint-staged": "^15.5.0",
|
||||
"naive-ui": "^2.41.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"naive-ui": "^2.42.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "3.5.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"sass-embedded": "^1.86.2",
|
||||
"typescript": "^5.8.2",
|
||||
"unplugin-auto-import": "^19.1.2",
|
||||
"sass-embedded": "^1.89.2",
|
||||
"typescript": "^5.8.3",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-icons": "^22.1.0",
|
||||
"unplugin-vue-components": "^28.4.1",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vite": "^7.0.2",
|
||||
"vitest": "^3.1.1",
|
||||
"vue": "^3.5.13",
|
||||
"vitest": "^3.2.4",
|
||||
"vue": "^3.5.17",
|
||||
"vue-demi": "^0.14.10",
|
||||
"vue-router": "^4.5.1",
|
||||
"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 {
|
||||
// define message protocol types
|
||||
// see https://github.com/antfu/webext-bridge#type-safe-protocols
|
||||
'html-to-image': ProtocolWithReturn<
|
||||
'dom-to-image': ProtocolWithReturn<
|
||||
| {
|
||||
type: 'CSS';
|
||||
selector: string;
|
||||
|
||||
@ -24,10 +24,10 @@ const message = useMessage();
|
||||
const formItemRef = useTemplateRef('detail-form-item');
|
||||
const formItemRule: FormItemRule = {
|
||||
required: true,
|
||||
trigger: ['submit'],
|
||||
trigger: ['submit', 'blur'],
|
||||
message: props.validateMessage,
|
||||
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 }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="销量信息">
|
||||
{{ model.broughtInfo || '-' }}
|
||||
{{ model.boughtInfo || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="评价">
|
||||
{{ model.rating || '-' }}
|
||||
@ -17,6 +17,12 @@ defineProps<{ model: AmazonDetailItem }>();
|
||||
<n-descriptions-item label="评论数">
|
||||
{{ model.ratingCount || '-' }}
|
||||
</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="大类">
|
||||
{{ model.category1?.name || '-' }}
|
||||
</n-descriptions-item>
|
||||
|
||||
@ -61,12 +61,12 @@ class ExportExcelPipeline {
|
||||
|
||||
public exportExcel(progress?: (current: number, total: number) => Promise<void> | void) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
this.socket.onmessage = (ev) => {
|
||||
this.socket.onmessage = async (ev) => {
|
||||
const response: WebSocketResponse = JSON.parse(ev.data);
|
||||
switch (response.type) {
|
||||
case 'progress':
|
||||
const { current, total } = response;
|
||||
progress && progress(current, total);
|
||||
progress && (await progress(current, total));
|
||||
break;
|
||||
case 'result':
|
||||
this.socket!.onmessage = null;
|
||||
@ -111,12 +111,10 @@ export const useCloudExporter = () => {
|
||||
pipeline = new ExportExcelPipeline();
|
||||
await pipeline.load();
|
||||
pipeline.addFragments(...fragments);
|
||||
const file = await pipeline
|
||||
.exportExcel((current, total) => {
|
||||
progress.current = current;
|
||||
progress.total = total;
|
||||
})
|
||||
.catch(() => null);
|
||||
const file = await pipeline.exportExcel((current, total) => {
|
||||
progress.current = current;
|
||||
progress.total = total;
|
||||
});
|
||||
await pipeline.close();
|
||||
|
||||
if (file) {
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { toPng } from 'html-to-image';
|
||||
import { snapdom } from '@zumer/snapdom';
|
||||
import { onMessage } from 'webext-bridge/content-script';
|
||||
|
||||
onMessage('html-to-image', async (ev) => {
|
||||
onMessage('dom-to-image', async (ev) => {
|
||||
const params = ev.data;
|
||||
const targetNode =
|
||||
params.type == 'CSS'
|
||||
? document.querySelector<HTMLElement>(params.selector)!
|
||||
: (document.evaluate(params.xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE)
|
||||
.singleNodeValue as HTMLElement);
|
||||
const imgData = await toPng(targetNode);
|
||||
return { b64: imgData };
|
||||
const result = await snapdom.toPng(targetNode, { compress: true });
|
||||
return { b64: result.src };
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
|
||||
(() => {
|
||||
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];
|
||||
}
|
||||
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 {
|
||||
mappedEnties.push([keys, value]);
|
||||
}
|
||||
|
||||
@ -131,7 +131,9 @@ const extraHeaders: Header<AmazonItem>[] = [
|
||||
formatOutputValue: (val: boolean) => (val ? '是' : '否'),
|
||||
parseImportValue: (val: string) => val === '是',
|
||||
},
|
||||
{ prop: 'broughtInfo', label: '销量信息' },
|
||||
{ prop: 'boughtInfo', label: '销量信息' },
|
||||
{ prop: 'categories', label: '分类信息' },
|
||||
{ prop: 'availableDate', label: '上架日期' },
|
||||
{ prop: 'rating', label: '评分' },
|
||||
{ prop: 'ratingCount', label: '评论数' },
|
||||
{ prop: 'category1.name', label: '大类' },
|
||||
|
||||
@ -7,6 +7,12 @@ import { allItems } from '~/storages/homedepot';
|
||||
const message = useMessage();
|
||||
const excelHelper = useExcelHelper();
|
||||
|
||||
const filter = ref({ timeRange: null as [number, number] | null });
|
||||
|
||||
const resetFilter = () => {
|
||||
filter.value = { timeRange: null };
|
||||
};
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'OSMID',
|
||||
@ -42,14 +48,9 @@ const columns: TableColumn[] = [
|
||||
minWidth: 75,
|
||||
},
|
||||
{
|
||||
title: '商品链接',
|
||||
key: 'link',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
title: '主图链接',
|
||||
key: 'mainImageUrl',
|
||||
hidden: true,
|
||||
title: '获取日期',
|
||||
key: 'timestamp',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
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(() => {
|
||||
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 = () => {
|
||||
return columns
|
||||
.filter((col: Record<string, any>) => col.key !== 'actions')
|
||||
.reduce(
|
||||
(p, v: Record<string, any>) => {
|
||||
if ('key' in v && 'title' in v) {
|
||||
p.push({ label: v.title, prop: v.key });
|
||||
}
|
||||
return p;
|
||||
},
|
||||
[] as { label: string; prop: string }[],
|
||||
)
|
||||
.concat([]) as Header[];
|
||||
.reduce((p, v: Record<string, any>) => {
|
||||
if ('key' in v && 'title' in v) {
|
||||
p.push({ label: v.title, prop: v.key });
|
||||
}
|
||||
return p;
|
||||
}, [] as Header[])
|
||||
.concat(extraHeaders);
|
||||
};
|
||||
|
||||
const handleClearData = () => {
|
||||
@ -125,6 +148,22 @@ const handleExport = async (opt: 'cloud' | 'local') => {
|
||||
<template #exporter>
|
||||
<export-panel @export-file="handleExport" />
|
||||
</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>
|
||||
</template>
|
||||
</result-table>
|
||||
@ -151,4 +190,14 @@ const handleExport = async (opt: 'cloud' | 'local') => {
|
||||
gap: 15px;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
min-width: 200px;
|
||||
max-width: 500px;
|
||||
|
||||
& > div:first-of-type {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import amazon from '../amazon';
|
||||
import amazon from '../impls/amazon';
|
||||
import { uploadImage } from '~/logic/upload';
|
||||
import { detailItems, reviewItems, searchItems } from '~/storages/amazon';
|
||||
import { createGlobalState } from '@vueuse/core';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import { detailItems as homedepotDetailItems } from '~/storages/homedepot';
|
||||
import homedepot from '../homedepot';
|
||||
import homedepot from '../impls/homedepot';
|
||||
import { createGlobalState } from '@vueuse/core';
|
||||
|
||||
export interface HomedepotWorkerSettings {
|
||||
@ -44,21 +44,19 @@ function buildHomedepotWorker() {
|
||||
const commitChange = () => {
|
||||
const { objects } = settings.value;
|
||||
if (objects?.includes('detail')) {
|
||||
const detailItems = toRaw(homedepotDetailItems.value);
|
||||
for (const [k, v] of detailCache.entries()) {
|
||||
if (detailItems.has(k)) {
|
||||
const origin = detailItems.get(k)!;
|
||||
detailItems.set(k, { ...origin, ...v });
|
||||
if (homedepotDetailItems.value.has(k)) {
|
||||
const origin = homedepotDetailItems.value.get(k)!;
|
||||
homedepotDetailItems.value.set(k, { ...origin, ...v });
|
||||
} else {
|
||||
detailItems.set(k, v);
|
||||
homedepotDetailItems.value.set(k, v);
|
||||
}
|
||||
}
|
||||
homedepotDetailItems.value = detailItems;
|
||||
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;
|
||||
return (...params: Parameters<T>) =>
|
||||
startTask(async () => {
|
||||
@ -69,7 +67,7 @@ function buildHomedepotWorker() {
|
||||
});
|
||||
};
|
||||
|
||||
const runDetailPageTask = taskWrapper(worker.runDetailPageTask.bind(worker));
|
||||
const runDetailPageTask = taskWrapper1(worker.runDetailPageTask.bind(worker));
|
||||
|
||||
return {
|
||||
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 { withErrorHandling } from './error-handler';
|
||||
import { withErrorHandling } from '../error-handler';
|
||||
import {
|
||||
AmazonDetailPageInjector,
|
||||
AmazonReviewPageInjector,
|
||||
AmazonSearchPageInjector,
|
||||
} from './web-injectors/amazon';
|
||||
} from '../web-injectors/amazon';
|
||||
import { isForbiddenUrl } from '~/env';
|
||||
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 { withErrorHandling } from './error-handler';
|
||||
import { HomedepotDetailPageInjector } from './web-injectors/homedepot';
|
||||
import { withErrorHandling } from '../error-handler';
|
||||
import { HomedepotDetailPageInjector } from '../web-injectors/homedepot';
|
||||
import { BaseWorker } from './base';
|
||||
|
||||
class HomedepotWorkerImpl
|
||||
@ -25,20 +25,45 @@ class HomedepotWorkerImpl
|
||||
}
|
||||
|
||||
@withErrorHandling
|
||||
private async wanderingDetailPage(OSMID: string) {
|
||||
private async wanderingDetailPage(OSMID: string, review?: boolean) {
|
||||
const url = `https://www.homedepot.com/p/${OSMID}`;
|
||||
const tab = await this.createNewTab(url);
|
||||
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();
|
||||
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(() => {
|
||||
browser.tabs.remove(tab.id!);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async runDetailPageTask(OSMIDs: string[], options: LanchTaskBaseOptions = {}): Promise<void> {
|
||||
const { progress } = options;
|
||||
async runDetailPageTask(
|
||||
OSMIDs: string[],
|
||||
options: LanchTaskBaseOptions & { review?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { progress, review } = options;
|
||||
const remains = [...OSMIDs];
|
||||
let interrupt = false;
|
||||
const unsubscribe = this.on('interrupt', () => {
|
||||
@ -46,7 +71,7 @@ class HomedepotWorkerImpl
|
||||
});
|
||||
while (remains.length > 0 && !interrupt) {
|
||||
const OSMIDs = remains.shift()!;
|
||||
await this.wanderingDetailPage(OSMIDs);
|
||||
await this.wanderingDetailPage(OSMIDs, review);
|
||||
progress && progress(remains);
|
||||
}
|
||||
unsubscribe();
|
||||
@ -14,7 +14,14 @@ export interface AmazonPageWorkerEvents {
|
||||
*/
|
||||
['item-base-info-collected']: Pick<
|
||||
AmazonDetailItem,
|
||||
'asin' | 'title' | 'broughtInfo' | 'price' | 'rating' | 'ratingCount' | 'timestamp'
|
||||
| 'asin'
|
||||
| 'title'
|
||||
| 'boughtInfo'
|
||||
| 'price'
|
||||
| 'rating'
|
||||
| 'ratingCount'
|
||||
| 'categories'
|
||||
| 'timestamp'
|
||||
>;
|
||||
/**
|
||||
* The event is fired when worker
|
||||
@ -81,6 +88,10 @@ export interface HomedepotEvents {
|
||||
* The event is fired when detail items collect
|
||||
*/
|
||||
['detail-item-collected']: { item: HomedepotDetailItem };
|
||||
/**
|
||||
* The event is fired when reviews collect
|
||||
*/
|
||||
['review-collected']: { reviews: HomedepotReview[] };
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
runDetailPageTask(OSMIDs: string[], options?: LanchTaskBaseOptions): Promise<void>;
|
||||
runDetailPageTask(
|
||||
OSMIDs: string[],
|
||||
options?: LanchTaskBaseOptions & { review?: boolean },
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop the worker.
|
||||
|
||||
@ -139,6 +139,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
|
||||
}
|
||||
|
||||
export class AmazonDetailPageInjector extends BaseInjector {
|
||||
/**等待页面加载完成 */
|
||||
public async waitForPageLoaded() {
|
||||
return this.run(async () => {
|
||||
while (true) {
|
||||
@ -162,19 +163,32 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
});
|
||||
}
|
||||
|
||||
/**获取基本信息 */
|
||||
public async getBaseInfo() {
|
||||
return this.run(async () => {
|
||||
const title = document.querySelector<HTMLElement>('#title')!.innerText;
|
||||
const price = document.querySelector<HTMLElement>(
|
||||
'.aok-offscreen, .a-price:not(.a-text-price) .a-offscreen',
|
||||
)?.innerText;
|
||||
const broughtInfo = document.querySelector<HTMLElement>(
|
||||
const boughtInfo = document.querySelector<HTMLElement>(
|
||||
`#social-proofing-faceout-title-tk_bought`,
|
||||
)?.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() {
|
||||
return this.run(async () => {
|
||||
const review = document.querySelector('#averageCustomerReviews');
|
||||
@ -195,6 +209,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
});
|
||||
}
|
||||
|
||||
/**获取排名信息 */
|
||||
public async getRankText() {
|
||||
return this.run(async () => {
|
||||
const xpathExps = [
|
||||
@ -219,6 +234,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
});
|
||||
}
|
||||
|
||||
/**获取图像链接 */
|
||||
public async getImageUrls() {
|
||||
return this.run(async () => {
|
||||
const overlay = document.querySelector<HTMLDivElement>('.overlayRestOfImages');
|
||||
@ -249,6 +265,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
});
|
||||
}
|
||||
|
||||
/**获取精选评论 */
|
||||
public async getTopReviews() {
|
||||
return this.run(async () => {
|
||||
const targetNode = document.querySelector<HTMLDivElement>('.cr-widget-FocalReviews');
|
||||
@ -303,6 +320,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
});
|
||||
}
|
||||
|
||||
/**滑动扫描A+界面 */
|
||||
public async scanAPlus() {
|
||||
return this.run(async () => {
|
||||
const aplusEl = document.querySelector<HTMLElement>('#aplus_feature_div');
|
||||
@ -329,9 +347,60 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
});
|
||||
}
|
||||
|
||||
/**获取A+截图 */
|
||||
public async captureAPlus() {
|
||||
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 {
|
||||
|
||||
@ -27,10 +27,10 @@ export class BaseInjector {
|
||||
}
|
||||
|
||||
protected async screenshot(
|
||||
params: ProtocolMap['html-to-image']['data'],
|
||||
): Promise<ProtocolMap['html-to-image']['return']> {
|
||||
data: ProtocolMap['dom-to-image']['data'],
|
||||
): Promise<ProtocolMap['dom-to-image']['return']> {
|
||||
const sender = await this.getMessageSender();
|
||||
return sender!.sendMessage('html-to-image', params, {
|
||||
return sender!.sendMessage('dom-to-image', data, {
|
||||
context: 'content-script',
|
||||
tabId: this._tab.id!,
|
||||
});
|
||||
|
||||
@ -28,6 +28,14 @@ export class HomedepotDetailPageInjector extends BaseInjector {
|
||||
(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) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(Math.random() * 500)));
|
||||
document
|
||||
@ -41,10 +49,14 @@ export class HomedepotDetailPageInjector extends BaseInjector {
|
||||
: document
|
||||
.querySelector('[data-component^="product-details:ProductDetailsTitle"]')
|
||||
?.scrollIntoView({ behavior: 'smooth' });
|
||||
if (needToSkip()) {
|
||||
return false;
|
||||
}
|
||||
if (isLoaded()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@ -92,7 +104,86 @@ export class HomedepotDetailPageInjector extends BaseInjector {
|
||||
reviewCount,
|
||||
mainImageUrl,
|
||||
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">
|
||||
import type { Timeline } from '~/components/ProgressReport.vue';
|
||||
import { usePageWorker } from '~/page-worker';
|
||||
import { detailInputText } from '~/storages/homedepot';
|
||||
import { detailWorkerSettings } from '~/storages/homedepot';
|
||||
|
||||
const inputText = ref('');
|
||||
const idInputRef = useTemplateRef('id-input');
|
||||
|
||||
const worker = usePageWorker('homedepot', { objects: ['detail'] });
|
||||
@ -20,7 +21,7 @@ const timelines = ref<Timeline[]>([]);
|
||||
const handleStart = async () => {
|
||||
idInputRef.value?.validate().then(async (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 = [
|
||||
{
|
||||
type: 'info',
|
||||
@ -36,11 +37,12 @@ const handleStart = async () => {
|
||||
type: 'info',
|
||||
title: '继续',
|
||||
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({
|
||||
type: 'info',
|
||||
@ -62,29 +64,29 @@ const handleInterrupt = () => {
|
||||
<header-title>Homedepot</header-title>
|
||||
<div class="interative-section">
|
||||
<ids-input
|
||||
v-model="inputText"
|
||||
v-model="detailInputText"
|
||||
:disabled="worker.isRunning.value"
|
||||
ref="id-input"
|
||||
:match-pattern="/^\d+(\n\d+)*\n?$/g"
|
||||
:match-pattern="/^\d{9}(\n\d{9})*\n?$/g"
|
||||
placeholder="输入OSMID"
|
||||
validate-message="请输入格式正确的OSMID"
|
||||
/>
|
||||
<n-button
|
||||
v-if="!worker.isRunning.value"
|
||||
round
|
||||
size="large"
|
||||
type="primary"
|
||||
@click="handleStart"
|
||||
>
|
||||
<template #icon>
|
||||
<ant-design-thunderbolt-outlined />
|
||||
<optional-button v-if="!worker.isRunning.value" round size="large" @click="handleStart">
|
||||
<template #popover>
|
||||
<div class="setting-panel">
|
||||
<div>设置</div>
|
||||
<n-form :label-width="50" label-placement="left" :show-feedback="false">
|
||||
<n-form-item label="评论:">
|
||||
<n-switch v-model:value="detailWorkerSettings.review" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</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">
|
||||
<template #icon>
|
||||
<ant-design-thunderbolt-outlined />
|
||||
</template>
|
||||
<n-icon :size="20"><ant-design-thunderbolt-outlined /></n-icon>
|
||||
停止
|
||||
</n-button>
|
||||
</div>
|
||||
@ -126,4 +128,11 @@ const handleInterrupt = () => {
|
||||
margin-top: 10px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.setting-panel {
|
||||
> *:first-of-type {
|
||||
font-size: larger;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,6 +2,10 @@ import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
||||
|
||||
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>>(
|
||||
'homedepot-details',
|
||||
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",
|
||||
"types": ["vite/client"],
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": "."
|
||||
"baseUrl": ".",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.*", "scripts/*"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user