This commit is contained in:
johnathan 2025-06-06 13:49:27 +08:00
parent 9f296e337b
commit 127fb5866a
9 changed files with 407 additions and 128 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "azon-seeker", "name": "azon-seeker",
"displayName": "Azon Seeker", "displayName": "Azon Seeker",
"version": "0.0.1", "version": "0.2.0",
"private": true, "private": true,
"description": "Starter modify by honestfox101", "description": "Starter modify by honestfox101",
"scripts": { "scripts": {
@ -12,6 +12,7 @@
"dev:web": "vite", "dev:web": "vite",
"dev:js": "npm run build:js -- --mode development", "dev:js": "npm run build:js -- --mode development",
"build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:background build:js", "build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:background build:js",
"build:firefox": "cross-env NODE_ENV=production EXTENSION=firefox run-s clear build:web build:prepare build:background build:js",
"build:prepare": "esno scripts/prepare.ts", "build:prepare": "esno scripts/prepare.ts",
"build:background": "vite build --config vite.config.background.mts", "build:background": "vite build --config vite.config.background.mts",
"build:web": "vite build", "build:web": "vite build",

View File

@ -7,8 +7,9 @@ const props = defineProps<{ model: AmazonDetailItem }>();
const modal = useModal(); const modal = useModal();
const handleLoadMore = () => { const handleLoadMore = () => {
const asin = props.model.asin;
modal.create({ modal.create({
title: `${props.model.asin}全部评论`, title: `${asin}评论`,
preset: 'card', preset: 'card',
style: { style: {
width: '80vw', width: '80vw',
@ -16,7 +17,7 @@ const handleLoadMore = () => {
}, },
content: () => content: () =>
h(ReviewPreview, { h(ReviewPreview, {
asin: props.model.asin, asin,
}), }),
}); });
}; };
@ -47,11 +48,9 @@ const handleLoadMore = () => {
{{ model.category2?.rank || '-' }} {{ model.category2?.rank || '-' }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="图片链接" :span="4"> <n-descriptions-item label="图片链接" :span="4">
<div v-for="link in model.imageUrls"> <image-link v-for="link in model.imageUrls" :url="link" />
{{ link }}
</div>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item <!-- <n-descriptions-item
v-if="model.topReviews && model.topReviews.length > 0" v-if="model.topReviews && model.topReviews.length > 0"
label="精选评论" label="精选评论"
:span="4" :span="4"
@ -69,7 +68,7 @@ const handleLoadMore = () => {
更多评论 更多评论
</n-button> </n-button>
</div> </div>
</n-descriptions-item> </n-descriptions-item> -->
</n-descriptions> </n-descriptions>
</div> </div>
</template> </template>

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
const props = defineProps<{
url: string;
}>();
const message = useMessage();
const cached = ref<string | undefined>();
watch(
() => props.url,
(newVal, oldVal) => {
if (newVal !== oldVal && cached.value) {
URL.revokeObjectURL(cached.value);
cached.value = undefined;
}
},
);
const loadImage = () => {
if (cached.value) {
return;
}
fetch(props.url)
.then((response) => {
if (!response.ok) {
throw new Error('加载失败');
}
return response.blob();
})
.then((blob) => {
cached.value = URL.createObjectURL(blob);
})
.catch((_error) => {
message.error(`加载图片失败`);
});
};
onUnmounted(() => {
if (cached.value) {
URL.revokeObjectURL(cached.value);
cached.value = undefined;
}
});
</script>
<template>
<div>
<n-popover
@update:show="(v) => v && loadImage()"
trigger="hover"
placement="right"
:delay="1000"
:duration="1000"
>
<template #trigger>
<span class="link-text">{{ url }}</span>
</template>
<img v-if="cached" :src="cached" />
<n-text v-else>加载中...</n-text>
</n-popover>
</div>
</template>
<style lang="scss" scoped>
.link-text {
cursor: default;
font-family: v-mono;
&:hover {
text-decoration: underline;
}
}
img {
max-width: 50vw;
max-height: 50vh;
}
</style>

View File

@ -1,20 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton } from 'naive-ui'; import { NButton, NSpace } from 'naive-ui';
import type { TableColumn } from 'naive-ui/es/data-table/src/interface'; import type { TableColumn } from 'naive-ui/es/data-table/src/interface';
import { exportToXLSX, Header, importFromXLSX } from '~/logic/data-io'; import { exportToXLSX, Header, importFromXLSX } from '~/logic/data-io';
import type { AmazonDetailItem, AmazonItem } from '~/logic/page-worker/types'; import type { AmazonDetailItem, AmazonItem, AmazonReview } from '~/logic/page-worker/types';
import { allItems } from '~/logic/storage'; import { allItems, reviewItems } from '~/logic/storage';
import DetailDescription from './DetailDescription.vue'; import DetailDescription from './DetailDescription.vue';
import ReviewPreview from './ReviewPreview.vue';
const message = useMessage(); const message = useMessage();
const modal = useModal();
const page = reactive({ current: 1, size: 10 }); const page = reactive({ current: 1, size: 10 });
const filter = reactive({ const defaultFilter = {
keywords: null as string | null, keywords: undefined as string | undefined,
search: '', search: '',
detailOnly: false, detailOnly: false,
}); };
const filter = reactive(defaultFilter);
const filterFormItems = computed(() => { const filterFormItems = computed(() => {
const records = allItems.value; const records = allItems.value;
return [ return [
@ -38,11 +41,7 @@ const filterFormItems = computed(() => {
}); });
const onFilterReset = () => { const onFilterReset = () => {
Object.assign(filter, { Object.assign(filter, defaultFilter);
keywords: null as string | null,
search: '',
detailOnly: false,
});
}; };
const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
@ -96,23 +95,53 @@ const columns: (TableColumn<AmazonItem> & { hidden?: boolean })[] = [
minWidth: 160, minWidth: 160,
}, },
{ {
title: '链接', title: '查看',
key: 'link', key: 'actions',
minWidth: 100,
render(row) { render(row) {
return h( return h(NSpace, {}, () =>
NButton, [
{ {
type: 'primary', text: '评论',
text: true, disabled: !reviewItems.value.has(row.asin),
size: 'small', onClick: () => {
onClick: async () => { const asin = row.asin;
await browser.tabs.create({ modal.create({
active: true, title: `${asin}评论`,
url: row.link, preset: 'card',
}); style: {
width: '80vw',
height: '85vh',
},
content: () =>
h(ReviewPreview, {
asin,
}),
});
},
}, },
}, {
() => '前往', text: '链接',
onClick: () => {
browser.tabs.create({
active: true,
url: row.link,
});
},
},
].map(({ text, onClick, disabled }) =>
h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
disabled: disabled,
onClick: onClick,
},
() => text,
),
),
); );
}, },
}, },
@ -133,6 +162,7 @@ const itemView = computed<{ records: AmazonItem[]; pageCount: number; origin: Am
); );
const extraHeaders: Header[] = [ const extraHeaders: Header[] = [
{ prop: 'link', label: '商品链接' },
{ {
prop: 'hasDetail', prop: 'hasDetail',
label: '有详情', label: '有详情',
@ -153,6 +183,36 @@ const extraHeaders: Header[] = [
}, },
]; ];
const reviewHeaders: Header[] = [
{ prop: 'asin', label: 'ASIN' },
{ prop: 'username', label: '用户名' },
{ prop: 'title', label: '标题' },
{ prop: 'rating', label: '评分' },
{ prop: 'content', label: '内容' },
{ prop: 'dateInfo', label: '日期' },
{
prop: 'imageSrc',
label: '图片链接',
formatOutputValue: (val?: string[]) => val?.join(';'),
parseImportValue: (val?: string) => val?.split(';'),
},
];
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 { label: string; prop: string }[],
)
.concat(extraHeaders) as Header[];
};
const filterItemData = (data: AmazonItem[]): AmazonItem[] => { const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
const { search, detailOnly, keywords } = filter; const { search, detailOnly, keywords } = filter;
if (search.trim() !== '') { if (search.trim() !== '') {
@ -172,41 +232,52 @@ const filterItemData = (data: AmazonItem[]): AmazonItem[] => {
}; };
const handleExport = async () => { const handleExport = async () => {
const headers: Header[] = columns const itemHeaders = getItemHeaders();
.reduce( const items = toRaw(itemView.value).origin;
(p, v: Record<string, any>) => { const asins = new Set(items.map((e) => e.asin));
if ('key' in v && 'title' in v) { const reviews = toRaw(reviewItems.value)
p.push({ label: v.title, prop: v.key }); .entries()
} .filter(([asin]) => asins.has(asin))
return p; .reduce<(AmazonReview & { asin: string })[]>((a, [asin, reviews]) => {
}, a.push(...reviews.map((r) => ({ asin, ...r })));
[] as { label: string; prop: string }[], return a;
) }, []);
.concat(extraHeaders);
const { origin: data } = itemView.value; const sheet1 = exportToXLSX(items, { headers: itemHeaders, asWorkSheet: true });
exportToXLSX(data, { headers }); const wb = sheet1.toWorkbook('items');
const sheet2 = exportToXLSX(reviews, { headers: reviewHeaders, asWorkSheet: true });
wb.addSheet('reviews', sheet2);
wb.exportFile(`Items ${dayjs().format('YYYY-MM-DD')}.xlsx`);
message.info('导出完成'); message.info('导出完成');
}; };
const handleImport = async (file: File) => { const handleImport = async (file: File) => {
const headers: Header[] = columns const itemHeaders = getItemHeaders();
.reduce( const wb = await importFromXLSX(file, { asWorkBook: true });
(p, v: Record<string, any>) => {
if ('key' in v && 'title' in v) { const sheet1 = wb.getSheet(0);
p.push({ label: v.title, prop: v.key }); const items = sheet1.toJson<AmazonItem>({ headers: itemHeaders });
} allItems.value = items;
return p;
}, if (wb.sheetCount > 1) {
[] as { label: string; prop: string }[], const sheet2 = wb.getSheet(1);
) const reviews = sheet2.toJson<AmazonReview & { asin?: string }>({ headers: reviewHeaders });
.concat(extraHeaders); reviewItems.value = reviews.reduce((m, r) => {
const importedData = await importFromXLSX<AmazonItem>(file, { headers }); const asin = r.asin!;
allItems.value = importedData; // delete r.asin;
message.info(`成功导入 ${file.name} 文件 ${importedData.length} 条数据`); m.has(asin) || m.set(asin, []);
const arr = m.get(asin)!;
arr.push(r);
return m;
}, new Map<string, AmazonReview[]>());
}
message.info(`成功导入 ${file.name} 文件`);
}; };
const handleClearData = async () => { const handleClearData = async () => {
allItems.value = []; allItems.value = [];
reviewItems.value = new Map();
}; };
</script> </script>
@ -229,6 +300,7 @@ const handleClearData = async () => {
size="small" size="small"
placeholder="输入文本过滤结果" placeholder="输入文本过滤结果"
round round
clearable
style="min-width: 230px" style="min-width: 230px"
/> />
<control-strip <control-strip

View File

@ -13,9 +13,16 @@ defineProps<{
<div v-for="paragraph in model.content.split('\n')"> <div v-for="paragraph in model.content.split('\n')">
{{ paragraph }} {{ paragraph }}
</div> </div>
<template v-if="model.imageSrc"> <div class="image-link-container" v-if="model.imageSrc">
<n-code :code="model.imageSrc.join('\n')" /> <image-link v-for="link in model.imageSrc" :url="link" />
</template> </div>
<div style="color: gray; font-size: smaller">{{ model.dateInfo }}</div> <div style="color: gray; font-size: smaller">{{ model.dateInfo }}</div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.image-link-container {
display: flex;
flex-direction: column;
}
</style>

View File

@ -90,7 +90,8 @@ const handleImport = async (file: File) => {
}; };
const handleExport = () => { const handleExport = () => {
exportToXLSX(allReviews, { headers }); const fileName = `${props.asin}Reviews${dayjs().format('YYYY-MM-DD')}.xlsx`;
exportToXLSX(allReviews, { headers, fileName });
message.info('导出完成'); message.info('导出完成');
}; };
</script> </script>

View File

@ -1,4 +1,99 @@
import { utils, read, writeFileXLSX } from 'xlsx'; import { utils, read, writeFileXLSX, WorkSheet, WorkBook } from 'xlsx';
class Worksheet {
readonly _raw: WorkSheet;
constructor(ws: WorkSheet) {
this._raw = ws;
}
static fromJson(data: Record<string, unknown>[], options: { headers?: Header[] } = {}) {
const {
headers = data.length > 0
? Object.keys(data[0]).map((k) => ({ label: k, prop: k }) as Header)
: [],
} = options;
const rows = data.map((item) => {
const row: Record<string, unknown> = {};
headers.forEach((header) => {
const value = getAttribute(item, header.prop);
if (header.formatOutputValue) {
row[header.label] = header.formatOutputValue(value);
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
row[header.label] = value;
} else {
row[header.label] = JSON.stringify(value);
}
});
return row;
});
const ws = utils.json_to_sheet(rows, {
header: headers.map((h) => h.label),
});
ws['!autofilter'] = {
ref: utils.encode_range({ c: 0, r: 0 }, { c: headers.length - 1, r: rows.length }),
}; // Use Auto Filter https://github.com/SheetJS/sheetjs/issues/472#issuecomment-292852308
return new Worksheet(ws);
}
toJson<T>(options: { headers?: Header[] } = {}) {
const { headers } = options;
let jsonData = utils.sheet_to_json<Record<string, unknown>>(this._raw);
if (headers) {
jsonData = jsonData.map((item) => {
const mappedItem: Record<string, unknown> = {};
headers.forEach((header) => {
const value = header.parseImportValue
? header.parseImportValue(item[header.label])
: item[header.label];
setAttribute(mappedItem, header.prop, value);
});
return mappedItem;
});
}
return jsonData as T[];
}
toWorkbook(sheetName?: string) {
const wb = new Workbook(utils.book_new());
wb.addSheet(sheetName || 'Sheet1', this);
return wb;
}
}
class Workbook {
readonly _raw: WorkBook;
constructor(wb: WorkBook) {
this._raw = wb;
}
get sheetCount() {
return this._raw.SheetNames.length;
}
static fromArrayBuffer(bf: ArrayBuffer) {
const data = new Uint8Array(bf);
const wb = read(data, { type: 'array' });
return new Workbook(wb);
}
getSheet(index: number) {
const sheetName = this._raw.SheetNames[index];
return new Worksheet(this._raw.Sheets[sheetName]);
}
addSheet(name: string, sheet: Worksheet) {
utils.book_append_sheet(this._raw, sheet._raw, name);
}
exportFile(fileName: string) {
writeFileXLSX(this._raw, fileName, { bookType: 'xlsx', type: 'binary', compression: true });
}
}
function getAttribute<T extends unknown>( function getAttribute<T extends unknown>(
obj: Record<string, unknown>, obj: Record<string, unknown>,
@ -40,49 +135,45 @@ export type Header = {
formatOutputValue?: (val: any) => any; formatOutputValue?: (val: any) => any;
}; };
export type ExportBaseOptions = {
fileName?: string;
headers?: Header[];
};
export type ImportBaseOptions = {
headers?: Header[];
};
/** /**
* XLSX文件 * XLSX
* @param data * @param data
* @param options * @param options
*/ */
export function exportToXLSX( export function exportToXLSX(
data: Record<string, unknown>[], data: Record<string, unknown>[],
options: { options?: ExportBaseOptions & { asWorkSheet?: false },
fileName?: string; ): void;
headers?: Header[]; export function exportToXLSX(
} = {}, data: Record<string, unknown>[],
): void { options: Omit<ExportBaseOptions, 'fileName'> & { asWorkSheet: true },
if (!data.length) { ): Worksheet;
return; export function exportToXLSX(
data: Record<string, unknown>[],
options: ExportBaseOptions & { asWorkSheet?: boolean } = {},
) {
const {
headers,
fileName = `export_${new Date().toISOString().slice(0, 10)}.xlsx`,
asWorkSheet,
} = options;
const worksheet = Worksheet.fromJson(data, { headers: headers });
if (asWorkSheet) {
return worksheet;
} }
const workbook = worksheet.toWorkbook();
const headers: Header[] = workbook.exportFile(fileName);
options.headers || Object.keys(data[0]).map((k) => ({ label: k, prop: k }));
const rows = data.map((item) => {
const row: Record<string, unknown> = {};
headers.forEach((header) => {
const value = getAttribute(item, header.prop);
if (header.formatOutputValue) {
row[header.label] = header.formatOutputValue(value);
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
row[header.label] = value;
} else {
row[header.label] = JSON.stringify(value);
}
});
return row;
});
const worksheet = utils.json_to_sheet(rows, {
header: headers.map((h) => h.label),
});
worksheet['!autofilter'] = {
ref: utils.encode_range({ c: 0, r: 0 }, { c: headers.length - 1, r: rows.length }),
}; // Use Auto Filter https://github.com/SheetJS/sheetjs/issues/472#issuecomment-292852308
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const fileName = options.fileName || `export_${new Date().toISOString().slice(0, 10)}.xlsx`;
writeFileXLSX(workbook, fileName, { bookType: 'xlsx', type: 'binary', compression: true });
} }
/** /**
@ -93,41 +184,34 @@ export function exportToXLSX(
*/ */
export async function importFromXLSX<T extends Record<string, unknown>>( export async function importFromXLSX<T extends Record<string, unknown>>(
file: File, file: File,
options: { headers?: Header[] } = {}, options?: ImportBaseOptions,
): Promise<T[]> { ): Promise<T[]>;
export async function importFromXLSX(file: File, options: { asWorkBook: true }): Promise<Workbook>;
export async function importFromXLSX<T extends Record<string, unknown>>(
file: File,
options: ImportBaseOptions & { asWorkBook?: boolean } = {},
) {
const { headers, asWorkBook } = options;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
try { try {
const data = new Uint8Array(event.target?.result as ArrayBuffer); const wb = Workbook.fromArrayBuffer(event.target?.result as ArrayBuffer);
const workbook = read(data, { type: 'array' }); if (asWorkBook) {
const sheetName = workbook.SheetNames[0]; // 默认读取第一个工作表 resolve(wb);
const worksheet = workbook.Sheets[sheetName];
let jsonData = utils.sheet_to_json<T>(worksheet);
if (options.headers) {
jsonData = jsonData.map((item) => {
const mappedItem: Record<string, unknown> = {};
options.headers?.forEach((header) => {
const value = header.parseImportValue
? header.parseImportValue(item[header.label])
: item[header.label];
setAttribute(mappedItem, header.prop, value);
});
return mappedItem as T;
});
} }
const ws = wb.getSheet(0); // 默认读取第一个工作表
const jsonData = ws.toJson<T>({ headers });
resolve(jsonData); resolve(jsonData);
} catch (error) { } catch (error) {
reject(error); reject(error);
} }
}; };
reader.onerror = (error) => { reader.onerror = (error) => {
reject(error); reject(error);
}; };
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
}); });
} }

View File

@ -95,7 +95,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
data = await this.run(async () => { data = await this.run(async () => {
const items = Array.from( const items = Array.from(
document.querySelectorAll<HTMLDivElement>( document.querySelectorAll<HTMLDivElement>(
'.puis-card-container:has(.a-section.a-spacing-small.puis-padding-left-small)', '.puis-card-container',
) as unknown as HTMLDivElement[], ) as unknown as HTMLDivElement[],
).filter((e) => e.getClientRects().length > 0); ).filter((e) => e.getClientRects().length > 0);
const linkObjs = items.reduce< const linkObjs = items.reduce<
@ -124,7 +124,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
default: default:
break; break;
} }
data = data && data.filter((r) => new URL(r.link).pathname.includes('/dp/')); data = data && data.filter((r) => new URL(r.link).pathname.includes('/dp/')); // No advertisement only
return data; return data;
} }
@ -183,7 +183,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
return this.run(async () => { return this.run(async () => {
const title = document.querySelector<HTMLElement>('#title')!.innerText; const title = document.querySelector<HTMLElement>('#title')!.innerText;
const price = document.querySelector<HTMLElement>( const price = document.querySelector<HTMLElement>(
'.a-price:not(.a-text-price) .a-offscreen', '.aok-offscreen, .a-price:not(.a-text-price) .a-offscreen',
)?.innerText; )?.innerText;
return { title, price }; return { title, price };
}); });
@ -313,7 +313,14 @@ export class AmazonDetailPageInjector extends BaseInjector {
commentNode.querySelectorAll<HTMLImageElement>( commentNode.querySelectorAll<HTMLImageElement>(
'.review-image-tile-section img[src] img[src]', '.review-image-tile-section img[src] img[src]',
), ),
).map((e) => e.getAttribute('src')!); ).map((e) => {
const url = new URL(e.getAttribute('src')!);
const paths = url.pathname.split('/');
const chunks = paths[paths.length - 1].split('.');
paths[paths.length - 1] = `${chunks[0]}.${chunks[chunks.length - 1]}`;
url.pathname = paths.join('/');
return url.toString();
});
items.push({ id, username, title, rating, dateInfo, content, imageSrc }); items.push({ id, username, title, rating, dateInfo, content, imageSrc });
} }
return items; return items;
@ -387,7 +394,14 @@ export class AmazonReviewPageInjector extends BaseInjector {
)!.innerText; )!.innerText;
const imageSrc = Array.from( const imageSrc = Array.from(
commentNode.querySelectorAll<HTMLImageElement>('.review-image-tile-section img[src]'), commentNode.querySelectorAll<HTMLImageElement>('.review-image-tile-section img[src]'),
).map((e) => e.getAttribute('src')!); ).map((e) => {
const url = new URL(e.getAttribute('src')!);
const paths = url.pathname.split('/');
const chunks = paths[paths.length - 1].split('.');
paths[paths.length - 1] = `${chunks[0]}.${chunks[chunks.length - 1]}`;
url.pathname = paths.join('/');
return url.toString();
});
items.push({ id, username, title, rating, dateInfo, content, imageSrc }); items.push({ id, username, title, rating, dateInfo, content, imageSrc });
} }
return items; return items;

View File

@ -18,3 +18,26 @@ body,
hover:opacity-100 hover:text-teal-600; hover:opacity-100 hover:text-teal-600;
font-size: 0.9em; font-size: 0.9em;
} }
* {
scrollbar-width: thin;
scrollbar-color: #e2e8f0 #f7fafc;
}
/* For Webkit browsers */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
background: #f7fafc;
}
*::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #cbd5e1 40%, #a0aec0 100%);
border-radius: 8px;
border: 2px solid #f7fafc;
}
*::-webkit-scrollbar-track {
background: #f7fafc;
border-radius: 8px;
}