This commit is contained in:
johnathan 2025-07-08 17:45:41 +08:00
parent 5efc112f4b
commit 6b0aef185f
30 changed files with 1674 additions and 2316 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

2
shim.d.ts vendored
View File

@ -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;

View File

@ -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;
}, },
}; };

View 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>

View File

@ -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>

View File

@ -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) {

View 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 };
}); });

View File

@ -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
View File

@ -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;
};

View File

@ -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]);
} }

View File

@ -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: '大类' },

View File

@ -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>

View File

@ -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';

View File

@ -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,

View File

@ -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';

View File

@ -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();

View File

@ -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.

View File

@ -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 {

View File

@ -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!,
}); });

View File

@ -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;
}); });
} }
} }

View File

@ -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>

View File

@ -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
View 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
View 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
View 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
View 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
View 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 }>;
};
}

View File

@ -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/*"]
} }