mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-28 01:43:29 +08:00
Update
This commit is contained in:
parent
9aeb12c7bf
commit
5ac0f7ad64
@ -39,6 +39,7 @@
|
||||
"chokidar": "^4.0.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"crx": "^5.0.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"emittery": "^1.1.0",
|
||||
"esno": "^4.8.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@ -44,6 +44,9 @@ importers:
|
||||
crx:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
emittery:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
@ -2073,6 +2076,12 @@ packages:
|
||||
integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==,
|
||||
}
|
||||
|
||||
dayjs@1.11.13:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==,
|
||||
}
|
||||
|
||||
debounce@1.2.1:
|
||||
resolution:
|
||||
{
|
||||
@ -7364,6 +7373,8 @@ snapshots:
|
||||
|
||||
date-fns@3.6.0: {}
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
|
||||
debounce@1.2.1: {}
|
||||
|
||||
debug@2.6.9:
|
||||
|
||||
@ -1,57 +1,73 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AmazonDetailItem } from '~/logic/page-worker/types';
|
||||
import { reviewItems } from '~/logic/storage';
|
||||
import ReviewList from './ReviewList.vue';
|
||||
|
||||
const props = defineProps<{ model: AmazonDetailItem }>();
|
||||
|
||||
const modal = useModal();
|
||||
const handleLoadMore = () => {
|
||||
modal.create({
|
||||
title: '评论',
|
||||
preset: 'card',
|
||||
style: {
|
||||
width: '85vw',
|
||||
height: '85vh',
|
||||
},
|
||||
content: () =>
|
||||
h(ReviewList, {
|
||||
asin: props.model.asin,
|
||||
}),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-description">
|
||||
<n-descriptions label-placement="left" bordered :column="4" label-style="min-width: 100px">
|
||||
<n-descriptions-item label="ASIN" :span="2">
|
||||
{{ props.model.asin }}
|
||||
{{ model.asin }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="评价">
|
||||
{{ props.model.rating || '-' }}
|
||||
{{ model.rating || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="评论数">
|
||||
{{ props.model.ratingCount || '-' }}
|
||||
{{ model.ratingCount || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="大类">
|
||||
{{ props.model.category1?.name || '-' }}
|
||||
{{ model.category1?.name || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="排名">
|
||||
{{ props.model.category1?.rank || '-' }}
|
||||
{{ model.category1?.rank || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="小类">
|
||||
{{ props.model.category2?.name || '-' }}
|
||||
{{ model.category2?.name || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="排名">
|
||||
{{ props.model.category2?.rank || '-' }}
|
||||
{{ model.category2?.rank || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="图片链接" :span="4">
|
||||
<div v-for="link in props.model.imageUrls">
|
||||
<div v-for="link in model.imageUrls">
|
||||
{{ link }}
|
||||
</div>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item
|
||||
v-if="props.model.topReviews && props.model.topReviews.length > 0"
|
||||
v-if="model.topReviews && model.topReviews.length > 0"
|
||||
label="精选评论"
|
||||
:span="4"
|
||||
>
|
||||
<n-scrollbar style="max-height: 500px">
|
||||
<n-scrollbar style="max-height: 350px">
|
||||
<div class="review-item-cotent">
|
||||
<div v-for="review in props.model.topReviews" style="margin-bottom: 5px">
|
||||
<h3 style="margin: 0">{{ review.username }}: {{ review.title }}</h3>
|
||||
<div style="color: gray; font-size: smaller">{{ review.rating }}</div>
|
||||
<div v-for="paragraph in review.content.split('\n')">
|
||||
{{ paragraph }}
|
||||
</div>
|
||||
<div style="color: gray; font-size: smaller">{{ review.dateInfo }}</div>
|
||||
</div>
|
||||
<template v-for="review in model.topReviews">
|
||||
<review-card :model="review" />
|
||||
<div style="height: 7px"></div>
|
||||
</template>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
<div class="review-item-footer">
|
||||
<n-button size="small">Load More</n-button>
|
||||
<n-button :disabled="!reviewItems.has(model.asin)" @click="handleLoadMore" size="small">
|
||||
更多评论
|
||||
</n-button>
|
||||
</div>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
21
src/components/ReviewCard.vue
Normal file
21
src/components/ReviewCard.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AmazonReview } from '~/logic/page-worker/types';
|
||||
|
||||
defineProps<{
|
||||
model: AmazonReview;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="review-card">
|
||||
<h3 style="margin: 0">{{ model.username }}: {{ model.title }}</h3>
|
||||
<n-rate readonly size="small" :default-value="Number(model.rating.split(' ')[0])" />
|
||||
<div v-for="paragraph in model.content.split('\n')">
|
||||
{{ paragraph }}
|
||||
</div>
|
||||
<template v-if="model.imageSrc">
|
||||
<n-code :code="model.imageSrc.join('\n')" />
|
||||
</template>
|
||||
<div style="color: gray; font-size: smaller">{{ model.dateInfo }}</div>
|
||||
</div>
|
||||
</template>
|
||||
120
src/components/ReviewList.vue
Normal file
120
src/components/ReviewList.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<script lang="ts" setup>
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import type { AmazonReview } from '~/logic/page-worker/types';
|
||||
import { reviewItems } from '~/logic/storage';
|
||||
|
||||
const props = defineProps<{ asin: string }>();
|
||||
|
||||
const containerRef = useTemplateRef('review-list');
|
||||
const { height } = useElementSize(containerRef);
|
||||
|
||||
const allReviews = reviewItems.value.get(props.asin) || [];
|
||||
|
||||
const filter = reactive({
|
||||
keywords: '',
|
||||
});
|
||||
|
||||
const page = reactive({
|
||||
current: 1,
|
||||
pageSize: 5,
|
||||
});
|
||||
|
||||
const view = computed(() => {
|
||||
const offset = (page.current - 1) * page.pageSize;
|
||||
const filteredData = filterData(allReviews);
|
||||
const data = filteredData.slice(offset, offset + page.pageSize);
|
||||
const pageCount = ~~(filteredData.length / page.pageSize);
|
||||
return { data, pageCount, total: filteredData.length };
|
||||
});
|
||||
|
||||
const filterData = (data: AmazonReview[]) => {
|
||||
let filteredData = data;
|
||||
if (filter.keywords) {
|
||||
filteredData = data.filter((item) => {
|
||||
const keywords = filter.keywords.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(keywords) ||
|
||||
item.content.toLowerCase().includes(keywords) ||
|
||||
item.username.toLowerCase().includes(keywords)
|
||||
);
|
||||
});
|
||||
}
|
||||
return filteredData;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="review-list" ref="review-list">
|
||||
<div class="header">
|
||||
<div class="header-section">
|
||||
<n-space justify="start">
|
||||
<n-input
|
||||
v-model:value="filter.keywords"
|
||||
style="width: 500px"
|
||||
round
|
||||
placeholder="输入关键词筛选评论"
|
||||
/>
|
||||
</n-space>
|
||||
</div>
|
||||
<div class="header-section"></div>
|
||||
</div>
|
||||
<n-scrollbar :style="{ maxHeight: `${height * 0.85}px`, minHeight: `${height * 0.85}px` }">
|
||||
<div class="review-list-container" :style="{ minHeight: `${height * 0.8}px` }">
|
||||
<template v-for="review in view.data">
|
||||
<review-card :model="review" />
|
||||
<div style="height: 7px" />
|
||||
</template>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
<div>
|
||||
<n-space align="center">
|
||||
<n-pagination
|
||||
v-model:page="page.current"
|
||||
v-model:page-size="page.pageSize"
|
||||
:page-count="view.pageCount"
|
||||
/>
|
||||
<n-text>{{ view.total }}条评论</n-text>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.review-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
justify-content: flex-start;
|
||||
min-height: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
& > div:last-child {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.review-list-container {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@ -1,32 +1,2 @@
|
||||
/* eslint-disable no-console */
|
||||
import App from './views/App.vue';
|
||||
import { setupApp } from '~/logic/common-setup';
|
||||
|
||||
// 是否在ContentScript挂载Vue APP
|
||||
const MOUNT_COMPONENT = false;
|
||||
|
||||
/**
|
||||
* mount component to context window
|
||||
*/
|
||||
const mountComponent = () => {
|
||||
const container = document.createElement('div');
|
||||
container.id = __NAME__;
|
||||
const root = document.createElement('div');
|
||||
const styleEl = document.createElement('link');
|
||||
const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container;
|
||||
styleEl.setAttribute('rel', 'stylesheet');
|
||||
styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/index.css'));
|
||||
shadowDOM.appendChild(styleEl);
|
||||
shadowDOM.appendChild(root);
|
||||
document.body.appendChild(container);
|
||||
const app = createApp(App);
|
||||
setupApp(app);
|
||||
app.mount(root);
|
||||
};
|
||||
|
||||
// Firefox `browser.tabs.executeScript()` requires scripts return a primitive value
|
||||
(() => {
|
||||
if (MOUNT_COMPONENT) {
|
||||
mountComponent();
|
||||
}
|
||||
})();
|
||||
(() => {})();
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
const [show, toggle] = useToggle(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="injected-content">
|
||||
<button @click="toggle()">
|
||||
<pixelarticons-power />
|
||||
</button>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.injected-content {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@ -200,16 +200,23 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
|
||||
@withErrorHandling
|
||||
public async wanderReviewPage(asin: string) {
|
||||
const baseUrl = `https://www.amazon.com/product-reviews/${asin}/ref=cm_cr_dp_d_show_all_btm?ie=UTF8&reviewerType=all_reviews`;
|
||||
const tab = await this.createNewTab(baseUrl);
|
||||
const url = new URL(
|
||||
`https://www.amazon.com/product-reviews/${asin}/ref=cm_cr_dp_d_show_all_btm?ie=UTF8&reviewerType=all_reviews`,
|
||||
);
|
||||
const tab = await this.createNewTab(url.toString());
|
||||
const injector = new AmazonReviewPageInjector(tab);
|
||||
while (true) {
|
||||
await injector.waitForPageLoad();
|
||||
const reviews = await injector.getSinglePageReviews();
|
||||
reviews.length > 0 && this.channel.emit('item-review-collected', { asin, reviews });
|
||||
const hasNextPage = await injector.jumpToNextPageIfExist();
|
||||
if (!hasNextPage) {
|
||||
break;
|
||||
await injector.waitForPageLoad();
|
||||
for (let star = 1; star <= 5; star++) {
|
||||
await injector.showStarsDropDownMenu();
|
||||
await injector.selectStar(star);
|
||||
while (true) {
|
||||
await injector.waitForPageLoad();
|
||||
const reviews = await injector.getSinglePageReviews();
|
||||
reviews.length > 0 && this.channel.emit('item-review-collected', { asin, reviews });
|
||||
const hasNextPage = await injector.jumpToNextPageIfExist();
|
||||
if (!hasNextPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
setTimeout(() => browser.tabs.remove(tab.id!), 1000);
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
||||
import type { AmazonDetailItem, AmazonItem, AmazonSearchItem } from './page-worker/types';
|
||||
import type {
|
||||
AmazonDetailItem,
|
||||
AmazonItem,
|
||||
AmazonReview,
|
||||
AmazonSearchItem,
|
||||
} from './page-worker/types';
|
||||
|
||||
export const keywordsList = useWebExtensionStorage<string[]>('keywordsList', ['']);
|
||||
|
||||
@ -17,15 +22,32 @@ export const detailItems = useWebExtensionStorage<Map<string, AmazonDetailItem>>
|
||||
},
|
||||
);
|
||||
|
||||
export const reviewItems = useWebExtensionStorage<Map<string, AmazonReview[]>>(
|
||||
'reviewItems',
|
||||
new Map(),
|
||||
{
|
||||
listenToStorageChanges: false,
|
||||
},
|
||||
);
|
||||
|
||||
export const allItems = computed({
|
||||
get() {
|
||||
const sItems = unref(searchItems);
|
||||
const sItemSet = new Set(sItems.map((si) => si.asin));
|
||||
const dItems = unref(detailItems);
|
||||
return sItems.map<AmazonItem>((si) => {
|
||||
const asin = si.asin;
|
||||
const dItem = dItems.get(asin);
|
||||
return dItems.has(asin) ? { ...si, ...dItem, hasDetail: true } : { ...si, hasDetail: false };
|
||||
});
|
||||
return sItems
|
||||
.map<AmazonItem>((si) => {
|
||||
const asin = si.asin;
|
||||
const dItem = dItems.get(asin);
|
||||
return dItems.has(asin)
|
||||
? { ...si, ...dItem, hasDetail: true }
|
||||
: { ...si, hasDetail: false };
|
||||
})
|
||||
.concat(
|
||||
Array.from(dItems.values())
|
||||
.filter((di) => !sItemSet.has(di.asin))
|
||||
.map((di) => ({ ...di, link: `https://www.amazon.com/dp/${di.asin}`, hasDetail: true })),
|
||||
);
|
||||
},
|
||||
set(newValue) {
|
||||
const searchItemProps: (keyof AmazonSearchItem)[] = [
|
||||
@ -39,12 +61,14 @@ export const allItems = computed({
|
||||
'rank',
|
||||
'createTime',
|
||||
];
|
||||
searchItems.value = newValue.map((row) => {
|
||||
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
|
||||
searchItemProps.includes(key as keyof AmazonSearchItem),
|
||||
);
|
||||
return Object.fromEntries(entries) as AmazonSearchItem;
|
||||
});
|
||||
searchItems.value = newValue
|
||||
.filter((row) => row.keywords)
|
||||
.map((row) => {
|
||||
const entries: [string, unknown][] = Object.entries(row).filter(([key]) =>
|
||||
searchItemProps.includes(key as keyof AmazonSearchItem),
|
||||
);
|
||||
return Object.fromEntries(entries) as AmazonSearchItem;
|
||||
});
|
||||
const detailItemsProps: (keyof AmazonDetailItem)[] = [
|
||||
'asin',
|
||||
'title',
|
||||
|
||||
@ -9,8 +9,11 @@ class BaseInjector {
|
||||
this._tab = tab;
|
||||
}
|
||||
|
||||
run<T>(func: (payload?: any) => Promise<T>, payload?: any): Promise<T> {
|
||||
return exec(this._tab.id!, func, payload);
|
||||
run<T, P extends Record<string, unknown>>(
|
||||
func: (payload: P) => Promise<T>,
|
||||
payload?: P,
|
||||
): Promise<T> {
|
||||
return exec(this._tab.id!, func, payload as P);
|
||||
}
|
||||
}
|
||||
|
||||
@ -324,7 +327,7 @@ export class AmazonReviewPageInjector extends BaseInjector {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
while (true) {
|
||||
const targetNode = document.querySelector(
|
||||
'#cm_cr-review_list .reviews-content,ul[role="list"]:not(.histogram)',
|
||||
'.reviews-content, #cm_cr-review_list ul[role="list"]:not(.histogram)',
|
||||
);
|
||||
targetNode?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (
|
||||
@ -411,4 +414,34 @@ export class AmazonReviewPageInjector extends BaseInjector {
|
||||
return ret;
|
||||
});
|
||||
}
|
||||
|
||||
public async showStarsDropDownMenu() {
|
||||
return this.run(async () => {
|
||||
while (true) {
|
||||
const dropdown = document.querySelector<HTMLDivElement>('#star-count-dropdown')!;
|
||||
dropdown.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
dropdown.click();
|
||||
if (dropdown.getAttribute('aria-expanded') === 'true') {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async selectStar(star: number) {
|
||||
return this.run(
|
||||
async ({ star }) => {
|
||||
const starNode = document.evaluate(
|
||||
`//ul[@role='listbox']/li/a[text()="${star} star only"]`,
|
||||
document.body,
|
||||
null,
|
||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||
).singleNodeValue as HTMLAnchorElement;
|
||||
starNode.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
},
|
||||
{ star },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import pageWorker from '~/logic/page-worker';
|
||||
import { reviewAsinInput } from '~/logic/storage';
|
||||
import type { AmazonReview } from '~/logic/page-worker/types';
|
||||
import { reviewAsinInput, reviewItems } from '~/logic/storage';
|
||||
|
||||
const worker = pageWorker.useAmazonPageWorker();
|
||||
worker.channel.on('error', ({ message: msg }) => {
|
||||
@ -11,6 +12,7 @@ worker.channel.on('error', ({ message: msg }) => {
|
||||
time: new Date().toLocaleString(),
|
||||
content: msg,
|
||||
});
|
||||
worker.stop();
|
||||
});
|
||||
worker.channel.on('item-review-collected', (ev) => {
|
||||
timelines.value.push({
|
||||
@ -19,6 +21,7 @@ worker.channel.on('item-review-collected', (ev) => {
|
||||
time: new Date().toLocaleString(),
|
||||
content: `获取到 ${ev.reviews.length} 条评价`,
|
||||
});
|
||||
updateReviews(ev);
|
||||
});
|
||||
|
||||
const { isRunning, startTask } = useLongTask();
|
||||
@ -65,6 +68,18 @@ const handleInterrupt = () => {
|
||||
worker.stop();
|
||||
message.info('已触发中断,正在等待当前任务完成。', { duration: 2000 });
|
||||
};
|
||||
|
||||
const updateReviews = (params: { asin: string; reviews: AmazonReview[] }) => {
|
||||
const { asin, reviews } = params;
|
||||
const values = toRaw(reviewItems.value).get(asin) || [];
|
||||
const ids = new Set(values.map((item) => item.id));
|
||||
for (const review of reviews) {
|
||||
ids.has(review.id) || values.push(review);
|
||||
}
|
||||
values.sort((a, b) => dayjs(b.dateInfo).valueOf() - dayjs(a.dateInfo).valueOf());
|
||||
reviewItems.value.delete(asin);
|
||||
reviewItems.value.set(asin, values);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@ -29,11 +29,14 @@ export const sharedConfig: UserConfig = {
|
||||
AutoImport({
|
||||
imports: [
|
||||
'vue',
|
||||
{
|
||||
dayjs: [['=', 'dayjs']],
|
||||
},
|
||||
{
|
||||
'webextension-polyfill': [['=', 'browser']],
|
||||
},
|
||||
{
|
||||
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
|
||||
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar', 'useModal'],
|
||||
},
|
||||
],
|
||||
dts: r('src/auto-imports.d.ts'),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user