This commit is contained in:
Johnathan 2025-07-25 16:55:43 +08:00
parent 296f6687df
commit 2bafb403ea
16 changed files with 319 additions and 90 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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