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
296f6687df
commit
2bafb403ea
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "azon-seeker",
|
"name": "azon-seeker",
|
||||||
"displayName": "Azon Seeker",
|
"displayName": "Azon Seeker",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Starter modify by honestfox101",
|
"description": "Starter modify by honestfox101",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const options: { label: string; value: string }[] = [
|
|||||||
{ label: 'Amazon Review', value: '/amazon-reviews' },
|
{ label: 'Amazon Review', value: '/amazon-reviews' },
|
||||||
{ label: 'Homedepot', value: '/homedepot' },
|
{ label: 'Homedepot', value: '/homedepot' },
|
||||||
{ label: 'Homedepot Review', value: '/homedepot-reviews' },
|
{ label: 'Homedepot Review', value: '/homedepot-reviews' },
|
||||||
|
{ label: 'Lowes', value: '/lowes' },
|
||||||
];
|
];
|
||||||
|
|
||||||
watch(opt, (val) => {
|
watch(opt, (val) => {
|
||||||
@ -30,6 +31,9 @@ watch(opt, (val) => {
|
|||||||
case '/homedepot-reviews':
|
case '/homedepot-reviews':
|
||||||
site.value = 'homedepot';
|
site.value = 'homedepot';
|
||||||
break;
|
break;
|
||||||
|
case '/lowes':
|
||||||
|
site.value = 'lowes';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,7 +94,7 @@ const extraHeaders: Header[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const filteredData = computed(() => {
|
const filteredData = computed(() => {
|
||||||
let data = allItems.value;
|
let data = toRaw(allItems.value);
|
||||||
if (filter.value.timeRange) {
|
if (filter.value.timeRange) {
|
||||||
const start = dayjs(filter.value.timeRange[0]);
|
const start = dayjs(filter.value.timeRange[0]);
|
||||||
const end = dayjs(filter.value.timeRange[1]);
|
const end = dayjs(filter.value.timeRange[1]);
|
||||||
|
|||||||
201
src/options/views/LowesResultTable.vue
Normal file
201
src/options/views/LowesResultTable.vue
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import type { TableColumn } from '~/components/ResultTable.vue';
|
||||||
|
import { useExcelHelper } from '~/composables/useExcelHelper';
|
||||||
|
import type { Header } from '~/logic/excel';
|
||||||
|
import { allItems } from '~/storages/lowes';
|
||||||
|
|
||||||
|
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: '品牌名称',
|
||||||
|
key: 'brandName',
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Item #',
|
||||||
|
key: 'itemSeries',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Model #',
|
||||||
|
key: 'modelSeries',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
key: 'title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '价格',
|
||||||
|
key: 'price',
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '评分',
|
||||||
|
key: 'rate',
|
||||||
|
minWidth: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '评论数',
|
||||||
|
key: 'reviewCount',
|
||||||
|
minWidth: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '获取日期',
|
||||||
|
key: 'timestamp',
|
||||||
|
minWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '销量信息',
|
||||||
|
key: 'boughtInfo',
|
||||||
|
minWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
render(row: (typeof allItems.value)[0]) {
|
||||||
|
return (
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
text
|
||||||
|
onClick={() => {
|
||||||
|
browser.tabs.create({
|
||||||
|
active: true,
|
||||||
|
url: row.link,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
前往
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const extraHeaders: Header[] = [
|
||||||
|
{
|
||||||
|
label: '商品链接',
|
||||||
|
prop: 'link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '主图链接',
|
||||||
|
prop: 'mainImageUrl',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredData = computed(() => {
|
||||||
|
let data = toRaw(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 Header[])
|
||||||
|
.concat(extraHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearData = () => {
|
||||||
|
allItems.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (file: File) => {
|
||||||
|
const itemHeaders = getItemHeaders();
|
||||||
|
const [dataFragment] = await excelHelper.importFile(file, [itemHeaders]);
|
||||||
|
allItems.value = dataFragment.data as typeof allItems.value;
|
||||||
|
message.info(`成功导入 ${file.name} 文件`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async (opt: 'cloud' | 'local') => {
|
||||||
|
const itemHeaders = getItemHeaders();
|
||||||
|
const fragments = [{ data: filteredData.value, imageColumn: '主图链接', headers: itemHeaders }];
|
||||||
|
await excelHelper.exportFile(fragments, { cloud: opt === 'cloud' });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="result-table">
|
||||||
|
<result-table :records="filteredData" :columns="columns">
|
||||||
|
<template #header>
|
||||||
|
<n-space align="center">
|
||||||
|
<h3 class="header-text">Lowes 数据</h3>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<control-strip round @clear="handleClearData" @import="handleImport">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.result-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
padding: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expoter-progress-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 10px;
|
||||||
|
gap: 15px;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 500px;
|
||||||
|
|
||||||
|
& > div:first-of-type {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -49,11 +49,11 @@ class HomedepotWorkerImpl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await injector.waitForReviewLoad();
|
await injector.waitForReviewLoad();
|
||||||
const reviews = await injector.getReviews();
|
let reviews = await injector.getReviews();
|
||||||
await this.emit('review-collected', { OSMID, reviews });
|
reviews.length > 0 && (await this.emit('review-collected', { OSMID, reviews }));
|
||||||
while (await injector.tryJumpToNextPage()) {
|
while ((await injector.tryJumpToNextPage()) && reviews.length > 0) {
|
||||||
const reviews = await injector.getReviews();
|
reviews = await injector.getReviews();
|
||||||
await this.emit('review-collected', { OSMID, reviews });
|
reviews.length > 0 && (await this.emit('review-collected', { OSMID, reviews }));
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
browser.tabs.remove(tab.id!);
|
browser.tabs.remove(tab.id!);
|
||||||
|
|||||||
@ -31,8 +31,12 @@ class LowesWorkerImpl
|
|||||||
const injector = new LowesDetailPageInjector(tab);
|
const injector = new LowesDetailPageInjector(tab);
|
||||||
await injector.waitForPageLoad();
|
await injector.waitForPageLoad();
|
||||||
const baseInfo = await injector.getBaseInfo();
|
const baseInfo = await injector.getBaseInfo();
|
||||||
await this.emit('detail-item-collected', { item: { ...baseInfo, link: url } });
|
baseInfo &&
|
||||||
|
(await this.emit('detail-item-collected', {
|
||||||
|
item: { ...baseInfo, timestamp: dayjs().format('YYYY/M/D HH:mm:ss'), link: url },
|
||||||
|
}));
|
||||||
progress && progress(remains);
|
progress && progress(remains);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
setTimeout(() => browser.tabs.remove(tab.id!), 1500);
|
setTimeout(() => browser.tabs.remove(tab.id!), 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export function usePageWorker(
|
|||||||
settings?: HomedepotWorkerSettings,
|
settings?: HomedepotWorkerSettings,
|
||||||
): ReturnType<typeof useHomedepotWorker>;
|
): ReturnType<typeof useHomedepotWorker>;
|
||||||
export function usePageWorker(
|
export function usePageWorker(
|
||||||
type: 'homedepot',
|
type: 'lowes',
|
||||||
settings?: LowesWorkerSettings,
|
settings?: LowesWorkerSettings,
|
||||||
): ReturnType<typeof useLowesWorker>;
|
): ReturnType<typeof useLowesWorker>;
|
||||||
export function usePageWorker(type: Website, settings: any) {
|
export function usePageWorker(type: Website, settings: any) {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
|
|||||||
}
|
}
|
||||||
while (true) {
|
while (true) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
|
await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random())));
|
||||||
const spins = Array.from(document.querySelectorAll<HTMLDivElement>('.a-spinner')).filter(
|
const spins = Array.from(document.querySelectorAll<HTMLElement>('.a-spinner')).filter(
|
||||||
(e) => e.getClientRects().length > 0,
|
(e) => e.getClientRects().length > 0,
|
||||||
);
|
);
|
||||||
if (spins.length === 0) {
|
if (spins.length === 0) {
|
||||||
@ -28,7 +28,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
|
|||||||
public async getPagePattern() {
|
public async getPagePattern() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
document.querySelectorAll<HTMLDivElement>(
|
document.querySelectorAll<HTMLElement>(
|
||||||
'.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))',
|
'.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))',
|
||||||
),
|
),
|
||||||
).filter((e) => e.getClientRects().length > 0).length > 0
|
).filter((e) => e.getClientRects().length > 0).length > 0
|
||||||
@ -44,7 +44,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
|
|||||||
case 'pattern-1':
|
case 'pattern-1':
|
||||||
data = await this.run(async () => {
|
data = await this.run(async () => {
|
||||||
const items = Array.from(
|
const items = Array.from(
|
||||||
document.querySelectorAll<HTMLDivElement>(
|
document.querySelectorAll<HTMLElement>(
|
||||||
'.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))',
|
'.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))',
|
||||||
),
|
),
|
||||||
).filter((e) => e.getClientRects().length > 0);
|
).filter((e) => e.getClientRects().length > 0);
|
||||||
@ -77,9 +77,9 @@ export class AmazonSearchPageInjector extends BaseInjector {
|
|||||||
case 'pattern-2':
|
case 'pattern-2':
|
||||||
data = await this.run(async () => {
|
data = await this.run(async () => {
|
||||||
const items = Array.from(
|
const items = Array.from(
|
||||||
document.querySelectorAll<HTMLDivElement>(
|
document.querySelectorAll<HTMLElement>(
|
||||||
'.puis-card-container',
|
'.puis-card-container',
|
||||||
) as unknown as HTMLDivElement[],
|
) as unknown as HTMLElement[],
|
||||||
).filter((e) => e.getClientRects().length > 0);
|
).filter((e) => e.getClientRects().length > 0);
|
||||||
const linkObjs = items.reduce<
|
const linkObjs = items.reduce<
|
||||||
Pick<AmazonSearchItem, 'link' | 'title' | 'imageSrc' | 'price'>[]
|
Pick<AmazonSearchItem, 'link' | 'title' | 'imageSrc' | 'price'>[]
|
||||||
@ -113,9 +113,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
|
|||||||
|
|
||||||
public async getCurrentPage() {
|
public async getCurrentPage() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
const node = document.querySelector<HTMLDivElement>(
|
const node = document.querySelector<HTMLElement>('.s-pagination-item.s-pagination-selected');
|
||||||
'.s-pagination-item.s-pagination-selected',
|
|
||||||
);
|
|
||||||
return node ? Number(node.innerText) : 1;
|
return node ? Number(node.innerText) : 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -228,7 +226,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
|||||||
null,
|
null,
|
||||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||||
null,
|
null,
|
||||||
).singleNodeValue as HTMLDivElement | null;
|
).singleNodeValue as HTMLElement | null;
|
||||||
if (targetNode) {
|
if (targetNode) {
|
||||||
targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
return targetNode.innerText;
|
return targetNode.innerText;
|
||||||
@ -241,9 +239,9 @@ 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<HTMLElement>('.overlayRestOfImages');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
if (document.querySelector<HTMLDivElement>('#ivThumbs')!.getClientRects().length === 0) {
|
if (document.querySelector<HTMLElement>('#ivThumbs')!.getClientRects().length === 0) {
|
||||||
overlay.click();
|
overlay.click();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
@ -272,7 +270,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<HTMLElement>('.cr-widget-FocalReviews');
|
||||||
if (!targetNode) {
|
if (!targetNode) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -288,22 +286,22 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
|||||||
);
|
);
|
||||||
const items: AmazonReview[] = [];
|
const items: AmazonReview[] = [];
|
||||||
for (let i = 0; i < xResult.snapshotLength; i++) {
|
for (let i = 0; i < xResult.snapshotLength; i++) {
|
||||||
const commentNode = xResult.snapshotItem(i) as HTMLDivElement | null;
|
const commentNode = xResult.snapshotItem(i) as HTMLElement | null;
|
||||||
if (!commentNode) {
|
if (!commentNode) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const id = commentNode.id.split('-')[0];
|
const id = commentNode.id.split('-')[0];
|
||||||
const username = commentNode.querySelector<HTMLDivElement>('.a-profile-name')!.innerText;
|
const username = commentNode.querySelector<HTMLElement>('.a-profile-name')!.innerText;
|
||||||
const title = commentNode.querySelector<HTMLDivElement>(
|
const title = commentNode.querySelector<HTMLElement>(
|
||||||
'[data-hook="review-title"] > span:not(.a-letter-space)',
|
'[data-hook="review-title"] > span:not(.a-letter-space)',
|
||||||
)!.innerText;
|
)!.innerText;
|
||||||
const rating = commentNode.querySelector<HTMLDivElement>(
|
const rating = commentNode.querySelector<HTMLElement>(
|
||||||
'[data-hook*="review-star-rating"]',
|
'[data-hook*="review-star-rating"]',
|
||||||
)!.innerText;
|
)!.innerText;
|
||||||
const dateInfo = commentNode.querySelector<HTMLDivElement>(
|
const dateInfo = commentNode.querySelector<HTMLElement>(
|
||||||
'[data-hook="review-date"]',
|
'[data-hook="review-date"]',
|
||||||
)!.innerText;
|
)!.innerText;
|
||||||
const content = commentNode.querySelector<HTMLDivElement>(
|
const content = commentNode.querySelector<HTMLElement>(
|
||||||
'[data-hook="review-body"]',
|
'[data-hook="review-body"]',
|
||||||
)!.innerText;
|
)!.innerText;
|
||||||
const imageSrc = Array.from(
|
const imageSrc = Array.from(
|
||||||
@ -446,22 +444,22 @@ export class AmazonReviewPageInjector extends BaseInjector {
|
|||||||
);
|
);
|
||||||
const items: AmazonReview[] = [];
|
const items: AmazonReview[] = [];
|
||||||
for (let i = 0; i < xResult.snapshotLength; i++) {
|
for (let i = 0; i < xResult.snapshotLength; i++) {
|
||||||
const commentNode = xResult.snapshotItem(i) as HTMLDivElement;
|
const commentNode = xResult.snapshotItem(i) as HTMLElement;
|
||||||
if (!commentNode) {
|
if (!commentNode) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const id = commentNode.id.split('-')[0];
|
const id = commentNode.id.split('-')[0];
|
||||||
const username = commentNode.querySelector<HTMLDivElement>('.a-profile-name')!.innerText;
|
const username = commentNode.querySelector<HTMLElement>('.a-profile-name')!.innerText;
|
||||||
const title = commentNode.querySelector<HTMLDivElement>(
|
const title = commentNode.querySelector<HTMLElement>(
|
||||||
'[data-hook="review-title"] > span:not(.a-letter-space)',
|
'[data-hook="review-title"] > span:not(.a-letter-space)',
|
||||||
)!.innerText;
|
)!.innerText;
|
||||||
const rating = commentNode.querySelector<HTMLDivElement>(
|
const rating = commentNode.querySelector<HTMLElement>(
|
||||||
'[data-hook*="review-star-rating"]',
|
'[data-hook*="review-star-rating"]',
|
||||||
)!.innerText;
|
)!.innerText;
|
||||||
const dateInfo = commentNode.querySelector<HTMLDivElement>(
|
const dateInfo = commentNode.querySelector<HTMLElement>(
|
||||||
'[data-hook="review-date"]',
|
'[data-hook="review-date"]',
|
||||||
)!.innerText;
|
)!.innerText;
|
||||||
const content = commentNode.querySelector<HTMLDivElement>(
|
const content = commentNode.querySelector<HTMLElement>(
|
||||||
'[data-hook="review-body"]',
|
'[data-hook="review-body"]',
|
||||||
)!.innerText;
|
)!.innerText;
|
||||||
const imageSrc = Array.from(
|
const imageSrc = Array.from(
|
||||||
@ -490,7 +488,7 @@ export class AmazonReviewPageInjector extends BaseInjector {
|
|||||||
).singleNodeValue as HTMLElement | null;
|
).singleNodeValue as HTMLElement | null;
|
||||||
latestReview?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
latestReview?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
const nextPageNode = document.querySelector<HTMLDivElement>(
|
const nextPageNode = document.querySelector<HTMLElement>(
|
||||||
'[data-hook="pagination-bar"] .a-pagination > *:nth-of-type(2)',
|
'[data-hook="pagination-bar"] .a-pagination > *:nth-of-type(2)',
|
||||||
);
|
);
|
||||||
nextPageNode?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
nextPageNode?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
@ -504,7 +502,7 @@ export class AmazonReviewPageInjector extends BaseInjector {
|
|||||||
public async showStarsDropDownMenu() {
|
public async showStarsDropDownMenu() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
const dropdown = document.querySelector<HTMLDivElement>('#star-count-dropdown')!;
|
const dropdown = document.querySelector<HTMLElement>('#star-count-dropdown')!;
|
||||||
dropdown.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
dropdown.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
dropdown.click();
|
dropdown.click();
|
||||||
if (dropdown.getAttribute('aria-expanded') === 'true') {
|
if (dropdown.getAttribute('aria-expanded') === 'true') {
|
||||||
|
|||||||
@ -63,23 +63,23 @@ export class HomedepotDetailPageInjector extends BaseInjector {
|
|||||||
public getInfo() {
|
public getInfo() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
const link = document.location.toString();
|
const link = document.location.toString();
|
||||||
const brandName = document.querySelector<HTMLDivElement>(
|
const brandName = document.querySelector<HTMLElement>(
|
||||||
`[data-component^="product-details:ProductDetailsBrandCollection"]`,
|
`[data-component^="product-details:ProductDetailsBrandCollection"]`,
|
||||||
)?.innerText;
|
)?.innerText;
|
||||||
const title = document.querySelector<HTMLDivElement>(
|
const title = document.querySelector<HTMLElement>(
|
||||||
`[data-component^="product-details:ProductDetailsTitle"]`,
|
`[data-component^="product-details:ProductDetailsTitle"]`,
|
||||||
)!.innerText;
|
)!.innerText;
|
||||||
const price = document
|
const price = document
|
||||||
.querySelector<HTMLDivElement>(`#standard-price`)!
|
.querySelector<HTMLElement>(`#standard-price`)!
|
||||||
.innerText.replaceAll('\n', '');
|
.innerText.replaceAll('\n', '');
|
||||||
const rateEl = document.querySelector<HTMLDivElement>(
|
const rateEl = document.querySelector<HTMLElement>(
|
||||||
`[data-component^="ratings-and-reviews"] .sui-mr-1`,
|
`[data-component^="ratings-and-reviews"] .sui-mr-1`,
|
||||||
);
|
);
|
||||||
const rate = rateEl ? /\d(\.\d)?/.exec(rateEl.innerText)![0] : undefined;
|
const rate = rateEl ? /\d(\.\d)?/.exec(rateEl.innerText)![0] : undefined;
|
||||||
const reviewCount = rateEl
|
const reviewCount = rateEl
|
||||||
? Number(
|
? Number(
|
||||||
/\d+/.exec(
|
/\d+/.exec(
|
||||||
document.querySelector<HTMLDivElement>(
|
document.querySelector<HTMLElement>(
|
||||||
`[data-component^="ratings-and-reviews"] [name="simple-rating"] + span`,
|
`[data-component^="ratings-and-reviews"] [name="simple-rating"] + span`,
|
||||||
)!.innerText,
|
)!.innerText,
|
||||||
)![0],
|
)![0],
|
||||||
@ -93,7 +93,7 @@ export class HomedepotDetailPageInjector extends BaseInjector {
|
|||||||
document,
|
document,
|
||||||
null,
|
null,
|
||||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||||
).singleNodeValue as HTMLDivElement | null;
|
).singleNodeValue as HTMLElement | null;
|
||||||
const [modelInfo] = /(?<=#\s).+/.exec(modelInfoEl?.innerText || '') || [];
|
const [modelInfo] = /(?<=#\s).+/.exec(modelInfoEl?.innerText || '') || [];
|
||||||
return {
|
return {
|
||||||
link,
|
link,
|
||||||
@ -138,6 +138,17 @@ export class HomedepotDetailPageInjector extends BaseInjector {
|
|||||||
|
|
||||||
public getReviews() {
|
public getReviews() {
|
||||||
return this.run(async () => {
|
return this.run(async () => {
|
||||||
|
const noReview = Boolean(
|
||||||
|
document.evaluate(
|
||||||
|
`//span[text() = 'Write the First Review']`,
|
||||||
|
document,
|
||||||
|
null,
|
||||||
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||||
|
).singleNodeValue,
|
||||||
|
);
|
||||||
|
if (noReview) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const elements = document.querySelectorAll('.review_item');
|
const elements = document.querySelectorAll('.review_item');
|
||||||
return Array.from(elements).map((root) => {
|
return Array.from(elements).map((root) => {
|
||||||
const title = root.querySelector<HTMLElement>('.review-content__title')!.innerText;
|
const title = root.querySelector<HTMLElement>('.review-content__title')!.innerText;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export class LowesDetailPageInjector extends BaseInjector {
|
|||||||
if (document.readyState === 'complete') {
|
if (document.readyState === 'complete') {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -24,8 +25,8 @@ export class LowesDetailPageInjector extends BaseInjector {
|
|||||||
document,
|
document,
|
||||||
null,
|
null,
|
||||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||||
).singleNodeValue as HTMLDivElement | null;
|
).singleNodeValue as HTMLElement | null;
|
||||||
const itemSeries = itemNumberEl?.innerText.replace('Item #', '').trim();
|
const itemSeries = itemNumberEl?.innerText.replace('Item #', '').replace('|', '').trim();
|
||||||
|
|
||||||
// 获取Model #
|
// 获取Model #
|
||||||
const modelNumberEl = document.evaluate(
|
const modelNumberEl = document.evaluate(
|
||||||
@ -33,35 +34,33 @@ export class LowesDetailPageInjector extends BaseInjector {
|
|||||||
document,
|
document,
|
||||||
null,
|
null,
|
||||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||||
).singleNodeValue as HTMLDivElement | null;
|
).singleNodeValue as HTMLElement | null;
|
||||||
const modelSeries = modelNumberEl?.innerText.replace('Model #', '').trim();
|
const modelSeries = modelNumberEl?.innerText.replace('Model #', '').trim();
|
||||||
|
|
||||||
// 获取品牌名称
|
// 获取品牌名称
|
||||||
const brandName = (
|
const brandName = document.querySelector<HTMLElement>(
|
||||||
document.evaluate(
|
'[data-component-name="RatingsNLinks"] a .label',
|
||||||
`//h1[contains(@class, "product-brand-description")]/parent::*/parent::*/following-sibling::*[1]//a`,
|
)?.innerText;
|
||||||
document,
|
|
||||||
null,
|
// 销量信息
|
||||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
const boughtInfo = document.querySelector<HTMLElement>(
|
||||||
).singleNodeValue as HTMLDivElement
|
'[data-component-name="ExclusiveBadge"]',
|
||||||
).innerText;
|
)?.innerText;
|
||||||
|
|
||||||
// 获取标题
|
// 获取标题
|
||||||
const title = document.querySelector<HTMLDivElement>(
|
const title = document.querySelector<HTMLElement>(`h1.product-brand-description`)!.innerText;
|
||||||
`h1.product-brand-description`,
|
|
||||||
)!.innerText;
|
|
||||||
|
|
||||||
// 获取价格
|
// 获取价格
|
||||||
const price = document
|
const price = document
|
||||||
.querySelector<HTMLDivElement>(`.screen-reader`)!
|
.querySelector<HTMLElement>(`.screen-reader`)!
|
||||||
.innerText.replaceAll('\n', '');
|
.innerText.replaceAll('\n', '');
|
||||||
|
|
||||||
// 获取评分
|
// 获取评分
|
||||||
const rate = document.querySelector<HTMLDivElement>(`.avgrating`)?.innerText;
|
const rate = document.querySelector<HTMLElement>(`.avgrating`)?.innerText;
|
||||||
|
|
||||||
// 获取评价数量
|
// 获取评价数量
|
||||||
const reviewCount = Number(
|
const reviewCount = Number(
|
||||||
document.querySelector<HTMLDivElement>(`[data-testid="rating-trigger"] > div > div > span`)
|
document.querySelector<HTMLElement>(`[data-testid="rating-trigger"] > div > div > span`)
|
||||||
?.innerText || '0',
|
?.innerText || '0',
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -77,6 +76,7 @@ export class LowesDetailPageInjector extends BaseInjector {
|
|||||||
rate,
|
rate,
|
||||||
reviewCount,
|
reviewCount,
|
||||||
mainImageUrl,
|
mainImageUrl,
|
||||||
|
boughtInfo,
|
||||||
itemSeries,
|
itemSeries,
|
||||||
modelSeries,
|
modelSeries,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,12 +14,14 @@ const routeObj: Record<'sidepanel' | 'options', RouteRecordRaw[]> = {
|
|||||||
{ path: '/amazon-reviews', component: () => import('~/options/views/AmazonReviews.vue') },
|
{ path: '/amazon-reviews', component: () => import('~/options/views/AmazonReviews.vue') },
|
||||||
{ path: '/homedepot', component: () => import('~/options/views/HomedepotResultTable.vue') },
|
{ path: '/homedepot', component: () => import('~/options/views/HomedepotResultTable.vue') },
|
||||||
{ path: '/homedepot-reviews', component: () => import('~/options/views/HomedepotReviews.vue') },
|
{ path: '/homedepot-reviews', component: () => import('~/options/views/HomedepotReviews.vue') },
|
||||||
|
{ path: '/lowes', component: () => import('~/options/views/LowesResultTable.vue') },
|
||||||
{ path: '/help', component: () => import('~/options/views/help/guide.md') },
|
{ path: '/help', component: () => import('~/options/views/help/guide.md') },
|
||||||
],
|
],
|
||||||
sidepanel: [
|
sidepanel: [
|
||||||
{ path: '/', redirect: `/${site.value}` },
|
{ path: '/', redirect: `/${site.value}` },
|
||||||
{ path: '/amazon', component: () => import('~/sidepanel/views/AmazonSidepanel.vue') },
|
{ path: '/amazon', component: () => import('~/sidepanel/views/AmazonSidepanel.vue') },
|
||||||
{ path: '/homedepot', component: () => import('~/sidepanel/views/HomedepotSidepanel.vue') },
|
{ path: '/homedepot', component: () => import('~/sidepanel/views/HomedepotSidepanel.vue') },
|
||||||
|
{ path: '/lowes', component: () => import('~/sidepanel/views/LowesSidepanel.vue') },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -29,11 +31,11 @@ export const router: Plugin = {
|
|||||||
case 'sidepanel':
|
case 'sidepanel':
|
||||||
case 'options':
|
case 'options':
|
||||||
const routes = routeObj[appContext];
|
const routes = routeObj[appContext];
|
||||||
const router = createRouter({
|
const vueRouter = createRouter({
|
||||||
history: appContext === 'sidepanel' ? createMemoryHistory() : createWebHashHistory(),
|
history: appContext === 'sidepanel' ? createMemoryHistory() : createWebHashHistory(),
|
||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
app.use(router);
|
app.use(vueRouter);
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,9 @@ watch(currentUrl, (newVal) => {
|
|||||||
case 'www.homedepot.com':
|
case 'www.homedepot.com':
|
||||||
site.value = 'homedepot';
|
site.value = 'homedepot';
|
||||||
break;
|
break;
|
||||||
|
case 'www.lowes.com':
|
||||||
|
site.value = 'lowes';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -41,6 +44,9 @@ watch(site, (newVal) => {
|
|||||||
case 'homedepot':
|
case 'homedepot':
|
||||||
router.push('/homedepot');
|
router.push('/homedepot');
|
||||||
break;
|
break;
|
||||||
|
case 'lowes':
|
||||||
|
router.push('/lowes');
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,16 @@
|
|||||||
<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 { detailInputText } from '~/storages/lowes';
|
||||||
import { detailWorkerSettings } from '~/storages/homedepot';
|
|
||||||
|
|
||||||
const idInputRef = useTemplateRef('id-input');
|
const idInputRef = useTemplateRef('id-input');
|
||||||
|
|
||||||
const worker = usePageWorker('homedepot', { objects: ['detail'] });
|
const worker = usePageWorker('lowes', { objects: ['detail'] });
|
||||||
worker.on('detail-item-collected', ({ item }) => {
|
worker.on('detail-item-collected', ({ item }) => {
|
||||||
timelines.value.push({
|
timelines.value.push({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: `成功`,
|
title: `信息采集`,
|
||||||
content: `成功获取到${item.OSMID}的商品信息`,
|
content: `成功获取到${item.link}的商品信息`,
|
||||||
time: new Date().toLocaleString(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
worker.on('review-collected', ({ OSMID, reviews }) => {
|
|
||||||
timelines.value.push({
|
|
||||||
type: 'success',
|
|
||||||
title: `成功`,
|
|
||||||
content: `成功获取到${OSMID}的${reviews.length}条评论`,
|
|
||||||
time: new Date().toLocaleString(),
|
time: new Date().toLocaleString(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -50,7 +41,6 @@ const handleStart = async () => {
|
|||||||
}
|
}
|
||||||
detailInputText.value = remains.join('\n');
|
detailInputText.value = remains.join('\n');
|
||||||
},
|
},
|
||||||
review: detailWorkerSettings.value.review,
|
|
||||||
});
|
});
|
||||||
timelines.value.push({
|
timelines.value.push({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
@ -68,8 +58,8 @@ const handleInterrupt = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="homedepot-sidepanel">
|
<div class="lowes-sidepanel">
|
||||||
<header-title>Lowes</header-title>
|
<header-title>Lowes Detail</header-title>
|
||||||
<div class="interative-section">
|
<div class="interative-section">
|
||||||
<id-input
|
<id-input
|
||||||
v-model="detailInputText"
|
v-model="detailInputText"
|
||||||
@ -81,19 +71,16 @@ const handleInterrupt = () => {
|
|||||||
placeholder="输入URL"
|
placeholder="输入URL"
|
||||||
validate-message="请输入格式正确的URL"
|
validate-message="请输入格式正确的URL"
|
||||||
/>
|
/>
|
||||||
<optional-button v-if="!worker.isRunning.value" round size="large" @click="handleStart">
|
<n-button
|
||||||
<template #popover>
|
v-if="!worker.isRunning.value"
|
||||||
<div class="setting-panel">
|
type="primary"
|
||||||
<n-form :label-width="50" label-placement="left" :show-feedback="false">
|
round
|
||||||
<n-form-item label="评论:">
|
size="large"
|
||||||
<n-switch v-model:value="detailWorkerSettings.review" />
|
@click="handleStart"
|
||||||
</n-form-item>
|
>
|
||||||
</n-form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<n-icon :size="20"><ant-design-thunderbolt-outlined /></n-icon>
|
<n-icon :size="20"><ant-design-thunderbolt-outlined /></n-icon>
|
||||||
开始
|
开始
|
||||||
</optional-button>
|
</n-button>
|
||||||
<n-button v-else round size="large" type="primary" @click="handleInterrupt">
|
<n-button v-else round size="large" type="primary" @click="handleInterrupt">
|
||||||
<n-icon :size="20"><ant-design-thunderbolt-outlined /></n-icon>
|
<n-icon :size="20"><ant-design-thunderbolt-outlined /></n-icon>
|
||||||
停止
|
停止
|
||||||
@ -107,7 +94,7 @@ const handleInterrupt = () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.homedepot-sidepanel {
|
.lowes-sidepanel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -1,6 +1,20 @@
|
|||||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
||||||
|
|
||||||
|
export const detailInputText = useWebExtensionStorage('lowes-detail-input-text', '');
|
||||||
|
|
||||||
export const detailItems = useWebExtensionStorage<Map<string, LowesDetailItem>>(
|
export const detailItems = useWebExtensionStorage<Map<string, LowesDetailItem>>(
|
||||||
'lowes-details',
|
'lowes-details',
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const allItems = computed({
|
||||||
|
get() {
|
||||||
|
return Array.from(detailItems.value.values());
|
||||||
|
},
|
||||||
|
set(newValue) {
|
||||||
|
detailItems.value = newValue.reduce((m, c) => {
|
||||||
|
m.set(c.link, c);
|
||||||
|
return m;
|
||||||
|
}, new Map());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
4
src/types/lowes.d.ts
vendored
4
src/types/lowes.d.ts
vendored
@ -1,8 +1,12 @@
|
|||||||
declare type LowesDetailItem = {
|
declare type LowesDetailItem = {
|
||||||
link: string;
|
link: string;
|
||||||
|
itemSeries?: string;
|
||||||
|
modelSeries?: string;
|
||||||
brandName?: string;
|
brandName?: string;
|
||||||
|
boughtInfo?: string;
|
||||||
title: string;
|
title: string;
|
||||||
price: string;
|
price: string;
|
||||||
|
timestamp: string;
|
||||||
rate?: string;
|
rate?: string;
|
||||||
reviewCount?: number;
|
reviewCount?: number;
|
||||||
mainImageUrl: string;
|
mainImageUrl: string;
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
/// <reference types="vitest" />
|
|
||||||
|
|
||||||
import { dirname, relative } from 'node:path';
|
import { dirname, relative } from 'node:path';
|
||||||
import type { UserConfig } from 'vite';
|
import type { UserConfig } from 'vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user