mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-02-07 07:43:12 +08:00
update
This commit is contained in:
parent
9f296e337b
commit
127fb5866a
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
78
src/components/ImageLink.vue
Normal file
78
src/components/ImageLink.vue
Normal 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>
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user