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"
},
"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

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 {
// 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;

View File

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

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 }}
</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>

View File

@ -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) => {
const file = await pipeline.exportExcel((current, total) => {
progress.current = current;
progress.total = total;
})
.catch(() => null);
});
await pipeline.close();
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';
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 };
});

View File

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

View File

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

View File

@ -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>) => {
.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[];
}, [] 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>

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
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",
"types": ["vite/client"],
"resolveJsonModule": true,
"baseUrl": "."
"baseUrl": ".",
"strict": true
},
"include": ["vite.config.*", "scripts/*"]
}