diff --git a/package.json b/package.json index e6d48ec..db2739d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "azon-seeker", "displayName": "Azon Seeker", - "version": "0.6.0", + "version": "0.7.0", "private": true, "description": "Starter modify by honestfox101", "scripts": { diff --git a/src/options/Options.vue b/src/options/Options.vue index ec8af2f..1a15fb6 100644 --- a/src/options/Options.vue +++ b/src/options/Options.vue @@ -14,6 +14,7 @@ const options: { label: string; value: string }[] = [ { label: 'Amazon Review', value: '/amazon-reviews' }, { label: 'Homedepot', value: '/homedepot' }, { label: 'Homedepot Review', value: '/homedepot-reviews' }, + { label: 'Lowes', value: '/lowes' }, ]; watch(opt, (val) => { @@ -30,6 +31,9 @@ watch(opt, (val) => { case '/homedepot-reviews': site.value = 'homedepot'; break; + case '/lowes': + site.value = 'lowes'; + break; default: break; } diff --git a/src/options/views/HomedepotResultTable.vue b/src/options/views/HomedepotResultTable.vue index 521b274..91e6291 100644 --- a/src/options/views/HomedepotResultTable.vue +++ b/src/options/views/HomedepotResultTable.vue @@ -94,7 +94,7 @@ const extraHeaders: Header[] = [ ]; const filteredData = computed(() => { - let data = allItems.value; + let data = toRaw(allItems.value); if (filter.value.timeRange) { const start = dayjs(filter.value.timeRange[0]); const end = dayjs(filter.value.timeRange[1]); diff --git a/src/options/views/LowesResultTable.vue b/src/options/views/LowesResultTable.vue new file mode 100644 index 0000000..d42bfd7 --- /dev/null +++ b/src/options/views/LowesResultTable.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/src/page-worker/impls/homedepot.ts b/src/page-worker/impls/homedepot.ts index 2787a5c..661cbcb 100644 --- a/src/page-worker/impls/homedepot.ts +++ b/src/page-worker/impls/homedepot.ts @@ -49,11 +49,11 @@ class HomedepotWorkerImpl return; } await injector.waitForReviewLoad(); - const reviews = await injector.getReviews(); - await this.emit('review-collected', { OSMID, reviews }); - while (await injector.tryJumpToNextPage()) { - const reviews = await injector.getReviews(); - await this.emit('review-collected', { OSMID, reviews }); + let reviews = await injector.getReviews(); + reviews.length > 0 && (await this.emit('review-collected', { OSMID, reviews })); + while ((await injector.tryJumpToNextPage()) && reviews.length > 0) { + reviews = await injector.getReviews(); + reviews.length > 0 && (await this.emit('review-collected', { OSMID, reviews })); } setTimeout(() => { browser.tabs.remove(tab.id!); diff --git a/src/page-worker/impls/lowes.ts b/src/page-worker/impls/lowes.ts index 42c4f05..75ff1f7 100644 --- a/src/page-worker/impls/lowes.ts +++ b/src/page-worker/impls/lowes.ts @@ -31,8 +31,12 @@ class LowesWorkerImpl const injector = new LowesDetailPageInjector(tab); await injector.waitForPageLoad(); 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); + await new Promise((resolve) => setTimeout(resolve, 1000)); setTimeout(() => browser.tabs.remove(tab.id!), 1500); } } diff --git a/src/page-worker/index.ts b/src/page-worker/index.ts index c4e78bb..ccebe6c 100644 --- a/src/page-worker/index.ts +++ b/src/page-worker/index.ts @@ -11,7 +11,7 @@ export function usePageWorker( settings?: HomedepotWorkerSettings, ): ReturnType; export function usePageWorker( - type: 'homedepot', + type: 'lowes', settings?: LowesWorkerSettings, ): ReturnType; export function usePageWorker(type: Website, settings: any) { diff --git a/src/page-worker/web-injectors/amazon.ts b/src/page-worker/web-injectors/amazon.ts index 0f40c23..4a2a591 100644 --- a/src/page-worker/web-injectors/amazon.ts +++ b/src/page-worker/web-injectors/amazon.ts @@ -15,7 +15,7 @@ export class AmazonSearchPageInjector extends BaseInjector { } while (true) { await new Promise((resolve) => setTimeout(resolve, 500 + ~~(500 * Math.random()))); - const spins = Array.from(document.querySelectorAll('.a-spinner')).filter( + const spins = Array.from(document.querySelectorAll('.a-spinner')).filter( (e) => e.getClientRects().length > 0, ); if (spins.length === 0) { @@ -28,7 +28,7 @@ export class AmazonSearchPageInjector extends BaseInjector { public async getPagePattern() { return this.run(async () => { return Array.from( - document.querySelectorAll( + document.querySelectorAll( '.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))', ), ).filter((e) => e.getClientRects().length > 0).length > 0 @@ -44,7 +44,7 @@ export class AmazonSearchPageInjector extends BaseInjector { case 'pattern-1': data = await this.run(async () => { const items = Array.from( - document.querySelectorAll( + document.querySelectorAll( '.puisg-row:has(.a-section.a-spacing-small.a-spacing-top-small:not(.a-text-right))', ), ).filter((e) => e.getClientRects().length > 0); @@ -77,9 +77,9 @@ export class AmazonSearchPageInjector extends BaseInjector { case 'pattern-2': data = await this.run(async () => { const items = Array.from( - document.querySelectorAll( + document.querySelectorAll( '.puis-card-container', - ) as unknown as HTMLDivElement[], + ) as unknown as HTMLElement[], ).filter((e) => e.getClientRects().length > 0); const linkObjs = items.reduce< Pick[] @@ -113,9 +113,7 @@ export class AmazonSearchPageInjector extends BaseInjector { public async getCurrentPage() { return this.run(async () => { - const node = document.querySelector( - '.s-pagination-item.s-pagination-selected', - ); + const node = document.querySelector('.s-pagination-item.s-pagination-selected'); return node ? Number(node.innerText) : 1; }); } @@ -228,7 +226,7 @@ export class AmazonDetailPageInjector extends BaseInjector { null, XPathResult.FIRST_ORDERED_NODE_TYPE, null, - ).singleNodeValue as HTMLDivElement | null; + ).singleNodeValue as HTMLElement | null; if (targetNode) { targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' }); return targetNode.innerText; @@ -241,9 +239,9 @@ export class AmazonDetailPageInjector extends BaseInjector { /**获取图像链接 */ public async getImageUrls() { return this.run(async () => { - const overlay = document.querySelector('.overlayRestOfImages'); + const overlay = document.querySelector('.overlayRestOfImages'); if (overlay) { - if (document.querySelector('#ivThumbs')!.getClientRects().length === 0) { + if (document.querySelector('#ivThumbs')!.getClientRects().length === 0) { overlay.click(); await new Promise((resolve) => setTimeout(resolve, 1000)); } @@ -272,7 +270,7 @@ export class AmazonDetailPageInjector extends BaseInjector { /**获取精选评论 */ public async getTopReviews() { return this.run(async () => { - const targetNode = document.querySelector('.cr-widget-FocalReviews'); + const targetNode = document.querySelector('.cr-widget-FocalReviews'); if (!targetNode) { return []; } @@ -288,22 +286,22 @@ export class AmazonDetailPageInjector extends BaseInjector { ); const items: AmazonReview[] = []; 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) { continue; } const id = commentNode.id.split('-')[0]; - const username = commentNode.querySelector('.a-profile-name')!.innerText; - const title = commentNode.querySelector( + const username = commentNode.querySelector('.a-profile-name')!.innerText; + const title = commentNode.querySelector( '[data-hook="review-title"] > span:not(.a-letter-space)', )!.innerText; - const rating = commentNode.querySelector( + const rating = commentNode.querySelector( '[data-hook*="review-star-rating"]', )!.innerText; - const dateInfo = commentNode.querySelector( + const dateInfo = commentNode.querySelector( '[data-hook="review-date"]', )!.innerText; - const content = commentNode.querySelector( + const content = commentNode.querySelector( '[data-hook="review-body"]', )!.innerText; const imageSrc = Array.from( @@ -446,22 +444,22 @@ export class AmazonReviewPageInjector extends BaseInjector { ); const items: AmazonReview[] = []; for (let i = 0; i < xResult.snapshotLength; i++) { - const commentNode = xResult.snapshotItem(i) as HTMLDivElement; + const commentNode = xResult.snapshotItem(i) as HTMLElement; if (!commentNode) { continue; } const id = commentNode.id.split('-')[0]; - const username = commentNode.querySelector('.a-profile-name')!.innerText; - const title = commentNode.querySelector( + const username = commentNode.querySelector('.a-profile-name')!.innerText; + const title = commentNode.querySelector( '[data-hook="review-title"] > span:not(.a-letter-space)', )!.innerText; - const rating = commentNode.querySelector( + const rating = commentNode.querySelector( '[data-hook*="review-star-rating"]', )!.innerText; - const dateInfo = commentNode.querySelector( + const dateInfo = commentNode.querySelector( '[data-hook="review-date"]', )!.innerText; - const content = commentNode.querySelector( + const content = commentNode.querySelector( '[data-hook="review-body"]', )!.innerText; const imageSrc = Array.from( @@ -490,7 +488,7 @@ export class AmazonReviewPageInjector extends BaseInjector { ).singleNodeValue as HTMLElement | null; latestReview?.scrollIntoView({ behavior: 'smooth', block: 'center' }); await new Promise((resolve) => setTimeout(resolve, 1000)); - const nextPageNode = document.querySelector( + const nextPageNode = document.querySelector( '[data-hook="pagination-bar"] .a-pagination > *:nth-of-type(2)', ); nextPageNode?.scrollIntoView({ behavior: 'smooth', block: 'center' }); @@ -504,7 +502,7 @@ export class AmazonReviewPageInjector extends BaseInjector { public async showStarsDropDownMenu() { return this.run(async () => { while (true) { - const dropdown = document.querySelector('#star-count-dropdown')!; + const dropdown = document.querySelector('#star-count-dropdown')!; dropdown.scrollIntoView({ behavior: 'smooth', block: 'center' }); dropdown.click(); if (dropdown.getAttribute('aria-expanded') === 'true') { diff --git a/src/page-worker/web-injectors/homedepot.ts b/src/page-worker/web-injectors/homedepot.ts index 8a2e4a6..0ea81e0 100644 --- a/src/page-worker/web-injectors/homedepot.ts +++ b/src/page-worker/web-injectors/homedepot.ts @@ -63,23 +63,23 @@ export class HomedepotDetailPageInjector extends BaseInjector { public getInfo() { return this.run(async () => { const link = document.location.toString(); - const brandName = document.querySelector( + const brandName = document.querySelector( `[data-component^="product-details:ProductDetailsBrandCollection"]`, )?.innerText; - const title = document.querySelector( + const title = document.querySelector( `[data-component^="product-details:ProductDetailsTitle"]`, )!.innerText; const price = document - .querySelector(`#standard-price`)! + .querySelector(`#standard-price`)! .innerText.replaceAll('\n', ''); - const rateEl = document.querySelector( + const rateEl = document.querySelector( `[data-component^="ratings-and-reviews"] .sui-mr-1`, ); const rate = rateEl ? /\d(\.\d)?/.exec(rateEl.innerText)![0] : undefined; const reviewCount = rateEl ? Number( /\d+/.exec( - document.querySelector( + document.querySelector( `[data-component^="ratings-and-reviews"] [name="simple-rating"] + span`, )!.innerText, )![0], @@ -93,7 +93,7 @@ export class HomedepotDetailPageInjector extends BaseInjector { document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, - ).singleNodeValue as HTMLDivElement | null; + ).singleNodeValue as HTMLElement | null; const [modelInfo] = /(?<=#\s).+/.exec(modelInfoEl?.innerText || '') || []; return { link, @@ -138,6 +138,17 @@ export class HomedepotDetailPageInjector extends BaseInjector { public getReviews() { 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'); return Array.from(elements).map((root) => { const title = root.querySelector('.review-content__title')!.innerText; diff --git a/src/page-worker/web-injectors/lowes.ts b/src/page-worker/web-injectors/lowes.ts index c5162af..87ac509 100644 --- a/src/page-worker/web-injectors/lowes.ts +++ b/src/page-worker/web-injectors/lowes.ts @@ -12,6 +12,7 @@ export class LowesDetailPageInjector extends BaseInjector { if (document.readyState === 'complete') { break; } + await new Promise((resolve) => setTimeout(resolve, 1000)); } }); } @@ -24,8 +25,8 @@ export class LowesDetailPageInjector extends BaseInjector { document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, - ).singleNodeValue as HTMLDivElement | null; - const itemSeries = itemNumberEl?.innerText.replace('Item #', '').trim(); + ).singleNodeValue as HTMLElement | null; + const itemSeries = itemNumberEl?.innerText.replace('Item #', '').replace('|', '').trim(); // 获取Model # const modelNumberEl = document.evaluate( @@ -33,35 +34,33 @@ export class LowesDetailPageInjector extends BaseInjector { document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, - ).singleNodeValue as HTMLDivElement | null; + ).singleNodeValue as HTMLElement | null; const modelSeries = modelNumberEl?.innerText.replace('Model #', '').trim(); // 获取品牌名称 - const brandName = ( - document.evaluate( - `//h1[contains(@class, "product-brand-description")]/parent::*/parent::*/following-sibling::*[1]//a`, - document, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - ).singleNodeValue as HTMLDivElement - ).innerText; + const brandName = document.querySelector( + '[data-component-name="RatingsNLinks"] a .label', + )?.innerText; + + // 销量信息 + const boughtInfo = document.querySelector( + '[data-component-name="ExclusiveBadge"]', + )?.innerText; // 获取标题 - const title = document.querySelector( - `h1.product-brand-description`, - )!.innerText; + const title = document.querySelector(`h1.product-brand-description`)!.innerText; // 获取价格 const price = document - .querySelector(`.screen-reader`)! + .querySelector(`.screen-reader`)! .innerText.replaceAll('\n', ''); // 获取评分 - const rate = document.querySelector(`.avgrating`)?.innerText; + const rate = document.querySelector(`.avgrating`)?.innerText; // 获取评价数量 const reviewCount = Number( - document.querySelector(`[data-testid="rating-trigger"] > div > div > span`) + document.querySelector(`[data-testid="rating-trigger"] > div > div > span`) ?.innerText || '0', ); @@ -77,6 +76,7 @@ export class LowesDetailPageInjector extends BaseInjector { rate, reviewCount, mainImageUrl, + boughtInfo, itemSeries, modelSeries, }; diff --git a/src/router/index.ts b/src/router/index.ts index 9679001..6a220df 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -14,12 +14,14 @@ const routeObj: Record<'sidepanel' | 'options', RouteRecordRaw[]> = { { path: '/amazon-reviews', component: () => import('~/options/views/AmazonReviews.vue') }, { path: '/homedepot', component: () => import('~/options/views/HomedepotResultTable.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') }, ], sidepanel: [ { path: '/', redirect: `/${site.value}` }, { path: '/amazon', component: () => import('~/sidepanel/views/AmazonSidepanel.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 'options': const routes = routeObj[appContext]; - const router = createRouter({ + const vueRouter = createRouter({ history: appContext === 'sidepanel' ? createMemoryHistory() : createWebHashHistory(), routes, }); - app.use(router); + app.use(vueRouter); default: break; } diff --git a/src/sidepanel/App.vue b/src/sidepanel/App.vue index 601d429..0a8461a 100644 --- a/src/sidepanel/App.vue +++ b/src/sidepanel/App.vue @@ -27,6 +27,9 @@ watch(currentUrl, (newVal) => { case 'www.homedepot.com': site.value = 'homedepot'; break; + case 'www.lowes.com': + site.value = 'lowes'; + break; default: break; } @@ -41,6 +44,9 @@ watch(site, (newVal) => { case 'homedepot': router.push('/homedepot'); break; + case 'lowes': + router.push('/lowes'); + break; default: break; } diff --git a/src/sidepanel/views/LowesSidepanel.vue b/src/sidepanel/views/LowesSidepanel.vue index 4b877a4..bd0c8ed 100644 --- a/src/sidepanel/views/LowesSidepanel.vue +++ b/src/sidepanel/views/LowesSidepanel.vue @@ -1,25 +1,16 @@