mirror of
https://github.com/primedigitaltech/azon_seeker.git
synced 2026-01-19 13:13:22 +08:00
Update
This commit is contained in:
parent
2d29e5c95a
commit
4a12fc7ed3
@ -1,6 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AmazonDetailItem } from '~/logic/page-worker/types';
|
||||
|
||||
defineProps<{ model: AmazonDetailItem }>();
|
||||
</script>
|
||||
|
||||
@ -31,6 +29,9 @@ defineProps<{ model: AmazonDetailItem }>();
|
||||
<n-descriptions-item label="图片链接" :span="4">
|
||||
<image-link v-for="link in model.imageUrls" :url="link" />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="model.aplus" label="A+" :span="4">
|
||||
<image-link :url="model.aplus" />
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
@ -82,20 +82,38 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<div class="ids-input">
|
||||
<n-space>
|
||||
<n-button @click="fileDialog.open()" :disabled="disabled" round size="small">
|
||||
<template #icon>
|
||||
<gg-import />
|
||||
</template>
|
||||
导入
|
||||
</n-button>
|
||||
<n-button :disabled="disabled" @click="handleExportIds" round size="small">
|
||||
<template #icon>
|
||||
<ion-arrow-up-right-box-outline />
|
||||
</template>
|
||||
导出
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div class="header">
|
||||
<span>
|
||||
<n-button @click="fileDialog.open()" :disabled="disabled" round size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<gg-import />
|
||||
</n-icon>
|
||||
</template>
|
||||
导入
|
||||
</n-button>
|
||||
<n-button :disabled="disabled" @click="handleExportIds" round size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ion-arrow-up-right-box-outline />
|
||||
</n-icon>
|
||||
</template>
|
||||
导出
|
||||
</n-button>
|
||||
</span>
|
||||
<span>
|
||||
<n-popover v-if="$slots['extra-settings']" placement="bottom-end" trigger="click">
|
||||
<template #trigger>
|
||||
<n-button :disabled="disabled" circle size="small">
|
||||
<template #icon>
|
||||
<n-icon size="18px"><solar-settings-linear /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
<slot name="extra-settings" />
|
||||
</n-popover>
|
||||
</span>
|
||||
</div>
|
||||
<div style="height: 7px" />
|
||||
<n-form-item ref="detail-form-item" label-placement="left" :rule="formItemRule">
|
||||
<n-input
|
||||
@ -109,10 +127,22 @@ defineExpose({
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style scoped lang="scss">
|
||||
.asin-input {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
> *:first-of-type {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -35,7 +35,7 @@ defineProps<{
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
<style scoped lang="scss">
|
||||
.progress-report {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AmazonReview } from '~/logic/page-worker/types';
|
||||
|
||||
defineProps<{
|
||||
model: AmazonReview;
|
||||
}>();
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import { exportToXLSX, Header, importFromXLSX } from '~/logic/data-io';
|
||||
import type { AmazonReview } from '~/logic/page-worker/types';
|
||||
import { exportToXLSX, Header, importFromXLSX } from '~/logic/excel';
|
||||
import { reviewItems } from '~/logic/storages/amazon';
|
||||
|
||||
const props = defineProps<{ asin: string }>();
|
||||
|
||||
64
src/global.d.ts
vendored
64
src/global.d.ts
vendored
@ -8,3 +8,67 @@ declare module '*.vue' {
|
||||
const component: any;
|
||||
export default component;
|
||||
}
|
||||
|
||||
declare type AmazonSearchItem = {
|
||||
keywords: string;
|
||||
page: number;
|
||||
link: string;
|
||||
title: string;
|
||||
asin: string;
|
||||
rank: number;
|
||||
price?: string;
|
||||
imageSrc: string;
|
||||
createTime: string;
|
||||
};
|
||||
|
||||
declare type AmazonDetailItem = {
|
||||
asin: string;
|
||||
title: string;
|
||||
price?: string;
|
||||
rating?: number;
|
||||
ratingCount?: number;
|
||||
category1?: { name: string; rank: number };
|
||||
category2?: { name: string; rank: number };
|
||||
imageUrls?: string[];
|
||||
aplus?: string;
|
||||
topReviews?: AmazonReview[];
|
||||
};
|
||||
|
||||
declare type AmazonReview = {
|
||||
id: string;
|
||||
username: string;
|
||||
title: string;
|
||||
rating: string;
|
||||
dateInfo: string;
|
||||
content: string;
|
||||
imageSrc: string[];
|
||||
};
|
||||
|
||||
declare type AmazonItem = Pick<AmazonSearchItem, 'asin'> &
|
||||
Partial<AmazonSearchItem> &
|
||||
Partial<AmazonDetailItem> & { hasDetail: boolean };
|
||||
|
||||
declare type HomedepotDetailItem = {
|
||||
OSMID: string;
|
||||
link: string;
|
||||
brandName?: string;
|
||||
title: string;
|
||||
price: string;
|
||||
rate?: string;
|
||||
reviewCount?: number;
|
||||
mainImageUrl: string;
|
||||
modelInfo?: string;
|
||||
};
|
||||
|
||||
declare type LowesDetailItem = {
|
||||
OSMID: string;
|
||||
link: string;
|
||||
brandName?: string;
|
||||
title: string;
|
||||
price: string;
|
||||
rate?: string;
|
||||
innerText: string;
|
||||
reviewCount?: number;
|
||||
mainImageUrl: string;
|
||||
modelInfo?: string;
|
||||
};
|
||||
|
||||
@ -9,7 +9,7 @@ class Worksheet {
|
||||
this.workbook = wb;
|
||||
}
|
||||
|
||||
async readJson(data: Record<string, unknown>[], options: { headers?: Header[] } = {}) {
|
||||
async readJson(data: Record<string, unknown>[], options: { headers?: Header<any>[] } = {}) {
|
||||
const {
|
||||
headers = data.length > 0
|
||||
? Object.keys(data[0]).map((k) => ({ label: k, prop: k }) as Header)
|
||||
@ -22,9 +22,9 @@ class Worksheet {
|
||||
const cols = headers.filter((h) => h.ignore?.out !== true);
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
const header = cols[j];
|
||||
const value = getAttribute(item, header.prop);
|
||||
const value = getAttribute(item, header.prop as string);
|
||||
if (header.formatOutputValue) {
|
||||
record[header.label] = await header.formatOutputValue(value, i);
|
||||
record[header.label] = await header.formatOutputValue(value, i, item);
|
||||
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
|
||||
record[header.label] = value;
|
||||
} else {
|
||||
@ -76,7 +76,7 @@ class Worksheet {
|
||||
const value = header.parseImportValue
|
||||
? await header.parseImportValue(item[header.label], i)
|
||||
: item[header.label];
|
||||
setAttribute(mappedItem, header.prop, value);
|
||||
setAttribute(mappedItem, header.prop as string, value);
|
||||
}
|
||||
return mappedItem;
|
||||
}),
|
||||
@ -172,11 +172,11 @@ function setAttribute(obj: Record<string, unknown>, path: string, value: unknown
|
||||
current[finalKey] = value;
|
||||
}
|
||||
|
||||
export type Header = {
|
||||
export type Header<T = any> = {
|
||||
label: string;
|
||||
prop: string;
|
||||
prop: keyof T | string;
|
||||
parseImportValue?: (val: any, index: number) => any;
|
||||
formatOutputValue?: (val: any, index: number) => any;
|
||||
formatOutputValue?: (val: any, index: number, rowData: T) => any;
|
||||
ignore?: {
|
||||
in?: boolean;
|
||||
out?: boolean;
|
||||
@ -193,26 +193,26 @@ export type ImportBaseOptions = {
|
||||
headers?: Header[];
|
||||
};
|
||||
|
||||
export function castRecordsByHeaders<T = Record<string, unknown>>(
|
||||
export function castRecordsByHeaders(
|
||||
jsonData: Record<string, unknown>[],
|
||||
headers: Omit<Header, 'parseImportValue'>[],
|
||||
): Promise<T[]> {
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return Promise.all(
|
||||
jsonData.map(async (item, i) => {
|
||||
const record: Record<string, unknown> = {};
|
||||
const cols = headers.filter((h) => h.ignore?.out !== true);
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
const header = cols[j];
|
||||
const value = getAttribute(item, header.prop);
|
||||
const value = getAttribute(item, header.prop as string);
|
||||
if (header.formatOutputValue) {
|
||||
record[header.label] = await header.formatOutputValue(value, i);
|
||||
record[header.label] = await header.formatOutputValue(value, i, item);
|
||||
} else if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
|
||||
record[header.label] = value;
|
||||
} else {
|
||||
record[header.label] = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
return record as T;
|
||||
return record;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import Emittery from 'emittery';
|
||||
import type { AmazonDetailItem, AmazonPageWorker, AmazonPageWorkerEvents } from './types';
|
||||
import type { AmazonPageWorker, AmazonPageWorkerEvents, LanchTaskBaseOptions } from './types';
|
||||
import type { Tabs } from 'webextension-polyfill';
|
||||
import { withErrorHandling } from '../error-handler';
|
||||
import {
|
||||
@ -22,11 +22,11 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
private constructor() {}
|
||||
//#endregion
|
||||
|
||||
private constructor() {}
|
||||
|
||||
private readonly _controlChannel = new Emittery<{ interrupt: undefined }>();
|
||||
|
||||
public readonly channel = new Emittery<AmazonPageWorkerEvents>();
|
||||
|
||||
private async getCurrentTab(): Promise<Tabs.Tab> {
|
||||
@ -103,7 +103,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
}
|
||||
|
||||
@withErrorHandling
|
||||
public async wanderDetailPage(entry: string) {
|
||||
public async wanderDetailPage(entry: string, aplus: boolean = false) {
|
||||
//#region Initial Meta Info
|
||||
const params = { asin: '', url: '' };
|
||||
if (entry.match(/^https?:\/\/www\.amazon\.com.*\/dp\/[A-Z0-9]{10}/)) {
|
||||
@ -182,7 +182,10 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
// });
|
||||
//#endregion
|
||||
// #region Get APlus Sreen shot
|
||||
await injector.scanAPlus();
|
||||
if (aplus && (await injector.scanAPlus())) {
|
||||
const { b64: base64data } = await injector.captureAPlus();
|
||||
this.channel.emit('item-aplus-screenshot-collect', { asin: params.asin, base64data });
|
||||
}
|
||||
// #endregion
|
||||
}
|
||||
|
||||
@ -212,8 +215,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
|
||||
public async runSearchPageTask(
|
||||
keywordsList: string[],
|
||||
progress?: (remains: string[]) => Promise<void>,
|
||||
options: LanchTaskBaseOptions = {},
|
||||
): Promise<void> {
|
||||
const { progress } = options;
|
||||
let remains = [...keywordsList];
|
||||
let interrupt = false;
|
||||
const unsubscribe = this._controlChannel.on('interrupt', () => {
|
||||
@ -230,8 +234,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
|
||||
public async runDetaiPageTask(
|
||||
asins: string[],
|
||||
progress?: (remains: string[]) => Promise<void>,
|
||||
options: LanchTaskBaseOptions & { aplus?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { progress, aplus = false } = options;
|
||||
const remains = [...asins];
|
||||
let interrupt = false;
|
||||
const unsubscribe = this._controlChannel.on('interrupt', () => {
|
||||
@ -239,7 +244,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
});
|
||||
while (remains.length > 0 && !interrupt) {
|
||||
const asin = remains.shift()!;
|
||||
await this.wanderDetailPage(asin);
|
||||
await this.wanderDetailPage(asin, aplus);
|
||||
progress && progress(remains);
|
||||
}
|
||||
unsubscribe();
|
||||
@ -247,8 +252,9 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
|
||||
public async runReviewPageTask(
|
||||
asins: string[],
|
||||
progress?: (remains: string[]) => Promise<void>,
|
||||
options: LanchTaskBaseOptions = {},
|
||||
): Promise<void> {
|
||||
const { progress } = options;
|
||||
const remains = [...asins];
|
||||
let interrupt = false;
|
||||
const unsubscribe = this._controlChannel.on('interrupt', () => {
|
||||
@ -268,7 +274,7 @@ class AmazonPageWorkerImpl implements AmazonPageWorker {
|
||||
}
|
||||
|
||||
export default {
|
||||
useAmazonPageWorker(): AmazonPageWorker {
|
||||
getAmazonPageWorker(): AmazonPageWorker {
|
||||
return AmazonPageWorkerImpl.getInstance();
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import Emittery from 'emittery';
|
||||
import { HomedepotEvents, HomedepotWorker } from './types';
|
||||
import type { HomedepotEvents, HomedepotWorker, LanchTaskBaseOptions } from './types';
|
||||
import { Tabs } from 'webextension-polyfill';
|
||||
import { withErrorHandling } from '../error-handler';
|
||||
import { HomedepotDetailPageInjector } from '~/logic/web-injectors/homedepot';
|
||||
@ -12,7 +12,7 @@ class HomedepotWorkerImpl implements HomedepotWorker {
|
||||
}
|
||||
return HomedepotWorkerImpl._instance as HomedepotWorker;
|
||||
}
|
||||
private constructor() {}
|
||||
protected constructor() {}
|
||||
|
||||
readonly channel: Emittery<HomedepotEvents> = new Emittery();
|
||||
|
||||
@ -36,10 +36,8 @@ class HomedepotWorkerImpl implements HomedepotWorker {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async runDetailPageTask(
|
||||
OSMIDs: string[],
|
||||
progress?: (remains: string[]) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
async runDetailPageTask(OSMIDs: string[], options: LanchTaskBaseOptions = {}): Promise<void> {
|
||||
const { progress } = options;
|
||||
const remains = [...OSMIDs];
|
||||
let interrupt = false;
|
||||
const unsubscribe = this._controlChannel.on('interrupt', () => {
|
||||
|
||||
@ -1,45 +1,8 @@
|
||||
import type Emittery from 'emittery';
|
||||
import { TaskQueue } from '../task-queue';
|
||||
|
||||
type AmazonSearchItem = {
|
||||
keywords: string;
|
||||
page: number;
|
||||
link: string;
|
||||
title: string;
|
||||
asin: string;
|
||||
rank: number;
|
||||
price?: string;
|
||||
imageSrc: string;
|
||||
createTime: string;
|
||||
};
|
||||
export type LanchTaskBaseOptions = { progress?: (remains: string[]) => Promise<void> | void };
|
||||
|
||||
type AmazonDetailItem = {
|
||||
asin: string;
|
||||
title: string;
|
||||
price?: string;
|
||||
rating?: number;
|
||||
ratingCount?: number;
|
||||
category1?: { name: string; rank: number };
|
||||
category2?: { name: string; rank: number };
|
||||
imageUrls?: string[];
|
||||
topReviews?: AmazonReview[];
|
||||
};
|
||||
|
||||
type AmazonReview = {
|
||||
id: string;
|
||||
username: string;
|
||||
title: string;
|
||||
rating: string;
|
||||
dateInfo: string;
|
||||
content: string;
|
||||
imageSrc: string[];
|
||||
};
|
||||
|
||||
type AmazonItem = Pick<AmazonSearchItem, 'asin'> &
|
||||
Partial<AmazonSearchItem> &
|
||||
Partial<AmazonDetailItem> & { hasDetail: boolean };
|
||||
|
||||
interface AmazonPageWorkerEvents {
|
||||
export interface AmazonPageWorkerEvents {
|
||||
/**
|
||||
* The event is fired when worker collected links to items on the Amazon search page.
|
||||
*/
|
||||
@ -63,6 +26,10 @@ interface AmazonPageWorkerEvents {
|
||||
* The event is fired when top reviews collected in detail page
|
||||
*/
|
||||
['item-top-reviews-collected']: Pick<AmazonDetailItem, 'asin' | 'topReviews'>;
|
||||
/**
|
||||
* The event is fired when aplus screenshot-collect
|
||||
*/
|
||||
['item-aplus-screenshot-collect']: { asin: string; base64data: string };
|
||||
/**
|
||||
* The event is fired when reviews collected in all review page
|
||||
*/
|
||||
@ -73,7 +40,7 @@ interface AmazonPageWorkerEvents {
|
||||
['error']: { message: string; url?: string };
|
||||
}
|
||||
|
||||
interface AmazonPageWorker {
|
||||
export interface AmazonPageWorker {
|
||||
/**
|
||||
* The channel for communication with the Amazon page worker.
|
||||
* This is an instance of Emittery, which allows for event-based communication.
|
||||
@ -83,29 +50,26 @@ interface AmazonPageWorker {
|
||||
/**
|
||||
* Browsing goods search page and collect links to those goods.
|
||||
* @param keywordsList - The keywords list to search for on Amazon.
|
||||
* @param progress The callback that receive remaining keywords as the parameter.
|
||||
* @param options The Options Specify Behaviors.
|
||||
*/
|
||||
runSearchPageTask(
|
||||
keywordsList: string[],
|
||||
progress?: (remains: string[]) => Promise<void>,
|
||||
): Promise<void>;
|
||||
runSearchPageTask(keywordsList: string[], options?: LanchTaskBaseOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Browsing goods detail page and collect target information.
|
||||
* @param asins Amazon Standard Identification Numbers.
|
||||
* @param progress The callback that receive remaining asins as the parameter.
|
||||
* @param options The Options Specify Behaviors.
|
||||
*/
|
||||
runDetaiPageTask(asins: string[], progress?: (remains: string[]) => Promise<void>): Promise<void>;
|
||||
runDetaiPageTask(
|
||||
asins: string[],
|
||||
options?: LanchTaskBaseOptions & { aplus?: boolean },
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Browsing goods review page and collect target information.
|
||||
* @param asins Amazon Standard Identification Numbers.
|
||||
* @param progress The callback that receive remaining asins as the parameter.
|
||||
* @param options The Options Specify Behaviors.
|
||||
*/
|
||||
runReviewPageTask(
|
||||
asins: string[],
|
||||
progress?: (remains: string[]) => Promise<void>,
|
||||
): Promise<void>;
|
||||
runReviewPageTask(asins: string[], options?: LanchTaskBaseOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop the worker.
|
||||
@ -113,19 +77,7 @@ interface AmazonPageWorker {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
type HomedepotDetailItem = {
|
||||
OSMID: string;
|
||||
link: string;
|
||||
brandName?: string;
|
||||
title: string;
|
||||
price: string;
|
||||
rate?: string;
|
||||
reviewCount?: number;
|
||||
mainImageUrl: string;
|
||||
modelInfo?: string;
|
||||
};
|
||||
|
||||
interface HomedepotEvents {
|
||||
export interface HomedepotEvents {
|
||||
/**
|
||||
* The event is fired when detail items collect
|
||||
*/
|
||||
@ -136,7 +88,7 @@ interface HomedepotEvents {
|
||||
['error']: { message: string; url?: string };
|
||||
}
|
||||
|
||||
interface HomedepotWorker {
|
||||
export interface HomedepotWorker {
|
||||
/**
|
||||
* The channel for communication with the Homedepot page worker.
|
||||
*/
|
||||
@ -145,10 +97,7 @@ interface HomedepotWorker {
|
||||
/**
|
||||
* Browsing goods detail page and collect target information
|
||||
*/
|
||||
runDetailPageTask(
|
||||
OSMIDs: string[],
|
||||
progress?: (remains: string[]) => Promise<void> | void,
|
||||
): Promise<void>;
|
||||
runDetailPageTask(OSMIDs: string[], options?: LanchTaskBaseOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop the worker.
|
||||
@ -156,20 +105,7 @@ interface HomedepotWorker {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
type LowesDetailItem = {
|
||||
OSMID: string;
|
||||
link: string;
|
||||
brandName?: string;
|
||||
title: string;
|
||||
price: string;
|
||||
rate?: string;
|
||||
innerText: string;
|
||||
reviewCount?: number;
|
||||
mainImageUrl: string;
|
||||
modelInfo?: string;
|
||||
};
|
||||
|
||||
interface LowesEvents {
|
||||
export interface LowesEvents {
|
||||
/**
|
||||
* The event is fired when detail items collect
|
||||
*/
|
||||
@ -180,7 +116,7 @@ interface LowesEvents {
|
||||
['error']: { message: string; url?: string };
|
||||
}
|
||||
|
||||
interface LowesWorker {
|
||||
export interface LowesWorker {
|
||||
/**
|
||||
* The channel for communication with the Lowes page worker.
|
||||
*/
|
||||
@ -189,10 +125,7 @@ interface LowesWorker {
|
||||
/**
|
||||
* Browsing goods detail page and collect target information
|
||||
*/
|
||||
runDetailPageTask(
|
||||
urls: string[],
|
||||
progress?: (remains: string[]) => Promise<void> | void,
|
||||
): Promise<void>;
|
||||
runDetailPageTask(urls: string[], options?: LanchTaskBaseOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop the worker.
|
||||
@ -1,10 +1,4 @@
|
||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
||||
import type {
|
||||
AmazonDetailItem,
|
||||
AmazonItem,
|
||||
AmazonReview,
|
||||
AmazonSearchItem,
|
||||
} from '~/logic/page-worker/types';
|
||||
|
||||
export const keywordsList = useWebExtensionStorage<string[]>('keywordsList', ['']);
|
||||
|
||||
@ -14,6 +8,11 @@ export const reviewAsinInput = useWebExtensionStorage<string>('reviewAsinInputTe
|
||||
|
||||
export const searchItems = useWebExtensionStorage<AmazonSearchItem[]>('searchItems', []);
|
||||
|
||||
export const detailWorkerSettings = useWebExtensionStorage<{ aplus: boolean }>(
|
||||
'amazon-detail-worker-settings',
|
||||
{ aplus: false },
|
||||
);
|
||||
|
||||
export const detailItems = useWebExtensionStorage<Map<string, AmazonDetailItem>>(
|
||||
'detailItems',
|
||||
new Map(),
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage';
|
||||
import { HomedepotDetailItem } from '../page-worker/types';
|
||||
|
||||
export const detailItems = useWebExtensionStorage<Map<string, HomedepotDetailItem>>(
|
||||
'homedepot-details',
|
||||
new Map(),
|
||||
|
||||
31
src/logic/upload.ts
Normal file
31
src/logic/upload.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { remoteHost } from '~/env';
|
||||
|
||||
export async function uploadImage(
|
||||
base64String: string,
|
||||
filename: string,
|
||||
): Promise<string | undefined> {
|
||||
// Remove the data URL prefix if present
|
||||
const base64Data = base64String.replace(/^data:image\/png;base64,/, '');
|
||||
// Convert base64 to binary
|
||||
const byteCharacters = atob(base64Data);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
// Create a Blob from the byte array
|
||||
const blob = new Blob([byteArray], { type: 'image/png' });
|
||||
// Create FormData and append the file
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, filename);
|
||||
|
||||
const url = `http://${remoteHost}/upload/image/${encodeURIComponent(filename)}`;
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
return data.file ? `http://${remoteHost}${data.file}` : undefined;
|
||||
});
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import { BaseInjector } from './base';
|
||||
import { AmazonReview, AmazonSearchItem } from '../page-worker/types';
|
||||
|
||||
export class AmazonSearchPageInjector extends BaseInjector {
|
||||
public waitForPageLoaded() {
|
||||
@ -242,6 +241,7 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
/(?<="large":")https:\/\/m.media-amazon.com\/images\/I\/[\w\d\.\-+]+(?=")/g,
|
||||
);
|
||||
}
|
||||
document.querySelector<HTMLElement>('header > [data-action="a-popover-close"]')?.click();
|
||||
return urls;
|
||||
});
|
||||
}
|
||||
@ -302,7 +302,10 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
|
||||
public async scanAPlus() {
|
||||
return this.run(async () => {
|
||||
const aplusEl = document.querySelector<HTMLElement>('#aplus')!;
|
||||
const aplusEl = document.querySelector<HTMLElement>('#aplus_feature_div');
|
||||
if (!aplusEl) {
|
||||
return false;
|
||||
}
|
||||
while (aplusEl.getClientRects().length === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
@ -315,8 +318,13 @@ export class AmazonDetailPageInjector extends BaseInjector {
|
||||
window.scrollBy({ top: 100, behavior: 'smooth' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 + ~~(100 * Math.random())));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public async captureAPlus() {
|
||||
return this.screenshot({ type: 'CSS', selector: '#aplus_feature_div' });
|
||||
}
|
||||
}
|
||||
|
||||
export class AmazonReviewPageInjector extends BaseInjector {
|
||||
|
||||
@ -1,17 +1,42 @@
|
||||
import { Tabs } from 'webextension-polyfill';
|
||||
import { exec } from '~/logic/execute-script';
|
||||
import type { ProtocolMap } from 'webext-bridge';
|
||||
|
||||
export class BaseInjector {
|
||||
readonly _tab: Tabs.Tab;
|
||||
|
||||
readonly _timeout: number;
|
||||
readonly _appContext: AppContext;
|
||||
|
||||
constructor(tab: Tabs.Tab, timeout: number = 30000) {
|
||||
constructor(tab: Tabs.Tab, options: { timeout?: number; appContext?: AppContext } = {}) {
|
||||
const { timeout = 30000, appContext = 'sidepanel' } = options;
|
||||
this._tab = tab;
|
||||
this._timeout = timeout;
|
||||
this._appContext = appContext;
|
||||
}
|
||||
|
||||
run<T, P extends Record<string, unknown>>(
|
||||
protected async getMessageSender() {
|
||||
let sender = null;
|
||||
switch (this._appContext) {
|
||||
case 'sidepanel':
|
||||
sender = await import('webext-bridge/sidepanel');
|
||||
return { sendMessage: sender.sendMessage };
|
||||
case 'options':
|
||||
sender = await import('webext-bridge/options');
|
||||
return { sendMessage: sender.sendMessage };
|
||||
}
|
||||
}
|
||||
|
||||
protected async screenshot(params: ProtocolMap['html-to-image']['data']) {
|
||||
const sender = await this.getMessageSender();
|
||||
return Promise.resolve<ProtocolMap['html-to-image']['return']>(
|
||||
sender.sendMessage('html-to-image', params, {
|
||||
context: 'content-script',
|
||||
tabId: this._tab.id!,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected run<T, P extends Record<string, unknown>>(
|
||||
func: (payload: P) => Promise<T>,
|
||||
payload?: P,
|
||||
): Promise<T> {
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { Tabs } from 'webextension-polyfill';
|
||||
import { BaseInjector } from './base';
|
||||
import { HomedepotDetailItem } from '../page-worker/types';
|
||||
|
||||
export class HomedepotDetailPageInjector extends BaseInjector {
|
||||
constructor(tab: Tabs.Tab) {
|
||||
super(tab, 60000);
|
||||
super(tab, { timeout: 60000 });
|
||||
}
|
||||
|
||||
public waitForPageLoad() {
|
||||
|
||||
@ -3,7 +3,7 @@ import { BaseInjector } from './base';
|
||||
|
||||
export class LowesDetailPageInjector extends BaseInjector {
|
||||
constructor(tab: Tabs.Tab) {
|
||||
super(tab, 60000);
|
||||
super(tab, { timeout: 60000 });
|
||||
}
|
||||
|
||||
public waitForPageLoad() {
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
import { NButton, NSpace } from 'naive-ui';
|
||||
import type { TableColumn } from '~/components/ResultTable.vue';
|
||||
import { useCloudExporter } from '~/composables/useCloudExporter';
|
||||
import { castRecordsByHeaders, createWorkbook, Header, importFromXLSX } from '~/logic/data-io';
|
||||
import type { AmazonItem, AmazonReview } from '~/logic/page-worker/types';
|
||||
import { castRecordsByHeaders, createWorkbook, Header, importFromXLSX } from '~/logic/excel';
|
||||
import { allItems, reviewItems } from '~/logic/storages/amazon';
|
||||
|
||||
const message = useMessage();
|
||||
@ -47,7 +46,7 @@ const columns: TableColumn[] = [
|
||||
type: 'expand',
|
||||
expandable: (row) => row.hasDetail,
|
||||
renderExpand(row) {
|
||||
return <detail-description model={row} />;
|
||||
return <amazon-detail-description model={row} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -133,7 +132,7 @@ const columns: TableColumn[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const extraHeaders: Header[] = [
|
||||
const extraHeaders: Header<AmazonItem>[] = [
|
||||
{ prop: 'link', label: '商品链接' },
|
||||
{
|
||||
prop: 'hasDetail',
|
||||
@ -150,12 +149,15 @@ const extraHeaders: Header[] = [
|
||||
{
|
||||
prop: 'imageUrls',
|
||||
label: '商品图片链接',
|
||||
formatOutputValue: (val?: string[]) => val?.join(';'),
|
||||
formatOutputValue: (val: string[] | undefined, _i, rowData) => {
|
||||
if (!val) return undefined;
|
||||
return rowData.aplus ? val.concat([rowData.aplus]).join(';') : val.join(';');
|
||||
},
|
||||
parseImportValue: (val?: string) => val?.split(';'),
|
||||
},
|
||||
];
|
||||
|
||||
const reviewHeaders: Header[] = [
|
||||
const reviewHeaders: Header<AmazonReview>[] = [
|
||||
{ prop: 'asin', label: 'ASIN' },
|
||||
{ prop: 'username', label: '用户名' },
|
||||
{ prop: 'title', label: '标题' },
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="tsx">
|
||||
import type { TableColumn } from '~/components/ResultTable.vue';
|
||||
import { useCloudExporter } from '~/composables/useCloudExporter';
|
||||
import { castRecordsByHeaders, exportToXLSX, Header, importFromXLSX } from '~/logic/data-io';
|
||||
import { castRecordsByHeaders, exportToXLSX, Header, importFromXLSX } from '~/logic/excel';
|
||||
import { allItems } from '~/logic/storages/homedepot';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
@ -29,7 +29,6 @@ watch(currentUrl, (newVal) => {
|
||||
site.value = 'homedepot';
|
||||
break;
|
||||
default:
|
||||
router.push(`/${site.value}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
import type { Timeline } from '~/components/ProgressReport.vue';
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import { amazon as pageWorker } from '~/logic/page-worker';
|
||||
import { AmazonDetailItem } from '~/logic/page-worker/types';
|
||||
import { detailAsinInput, detailItems } from '~/logic/storages/amazon';
|
||||
import { detailAsinInput, detailItems, detailWorkerSettings } from '~/logic/storages/amazon';
|
||||
import { uploadImage } from '~/logic/upload';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
@ -23,7 +23,7 @@ watch(isRunning, (newVal) => {
|
||||
const asinInputRef = useTemplateRef('asin-input');
|
||||
|
||||
//#region Page Worker 初始化Code
|
||||
const worker = pageWorker.useAmazonPageWorker(); // 获取Page Worker单例
|
||||
const worker = pageWorker.getAmazonPageWorker(); // 获取Page Worker单例
|
||||
worker.channel.on('error', ({ message: msg }) => {
|
||||
timelines.value.push({
|
||||
type: 'error',
|
||||
@ -73,6 +73,18 @@ worker.channel.on('item-top-reviews-collected', (ev) => {
|
||||
});
|
||||
updateDetailItems(ev);
|
||||
});
|
||||
worker.channel.on('item-aplus-screenshot-collect', (ev) => {
|
||||
timelines.value.push({
|
||||
type: 'success',
|
||||
title: `商品${ev.asin}A+`,
|
||||
time: new Date().toLocaleString(),
|
||||
content: `获取到A+`,
|
||||
});
|
||||
uploadImage(ev.base64data, `${ev.asin}.png`).then((url) => {
|
||||
url && updateDetailItems({ asin: ev.asin, aplus: url });
|
||||
});
|
||||
});
|
||||
|
||||
const updateDetailItems = (row: { asin: string } & Partial<AmazonDetailItem>) => {
|
||||
const asin = row.asin;
|
||||
if (detailItems.value.has(row.asin)) {
|
||||
@ -94,8 +106,11 @@ const task = async () => {
|
||||
content: '开始数据采集',
|
||||
},
|
||||
];
|
||||
await worker.runDetaiPageTask(asinList, async (remains) => {
|
||||
detailAsinInput.value = remains.join('\n');
|
||||
await worker.runDetaiPageTask(asinList, {
|
||||
progress: (remains) => {
|
||||
detailAsinInput.value = remains.join('\n');
|
||||
},
|
||||
aplus: detailWorkerSettings.value.aplus,
|
||||
});
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
@ -120,7 +135,17 @@ const handleInterrupt = () => {
|
||||
<div class="detail-page-entry">
|
||||
<header-title>Amazon Detail</header-title>
|
||||
<div class="interative-section">
|
||||
<ids-input v-model="detailAsinInput" :disabled="isRunning" ref="asin-input" />
|
||||
<ids-input v-model="detailAsinInput" :disabled="isRunning" ref="asin-input">
|
||||
<template #extra-settings>
|
||||
<div class="setting-panel">
|
||||
<n-form label-placement="left">
|
||||
<n-form-item label="Aplus: " :feedback-style="{ display: 'none' }">
|
||||
<n-switch v-model:value="detailWorkerSettings.aplus" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</template>
|
||||
</ids-input>
|
||||
<n-button v-if="!isRunning" round size="large" type="primary" @click="handleStart">
|
||||
<template #icon>
|
||||
<ant-design-thunderbolt-outlined />
|
||||
@ -173,4 +198,8 @@ const handleInterrupt = () => {
|
||||
margin-top: 10px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.setting-panel {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
import type { Timeline } from '~/components/ProgressReport.vue';
|
||||
import { useLongTask } from '~/composables/useLongTask';
|
||||
import { amazon as pageWorker } from '~/logic/page-worker';
|
||||
import type { AmazonReview } from '~/logic/page-worker/types';
|
||||
import { reviewAsinInput, reviewItems } from '~/logic/storages/amazon';
|
||||
|
||||
const { isRunning, startTask } = useLongTask();
|
||||
@ -16,7 +15,7 @@ watch(isRunning, (newVal) => {
|
||||
newVal ? emit('start') : emit('stop');
|
||||
});
|
||||
|
||||
const worker = pageWorker.useAmazonPageWorker();
|
||||
const worker = pageWorker.getAmazonPageWorker();
|
||||
worker.channel.on('error', ({ message: msg }) => {
|
||||
timelines.value.push({
|
||||
type: 'error',
|
||||
@ -52,8 +51,10 @@ const task = async () => {
|
||||
content: '开始数据采集',
|
||||
},
|
||||
];
|
||||
await worker.runReviewPageTask(asinList, async (remains) => {
|
||||
reviewAsinInput.value = remains.join('\n');
|
||||
await worker.runReviewPageTask(asinList, {
|
||||
progress: (remains) => {
|
||||
reviewAsinInput.value = remains.join('\n');
|
||||
},
|
||||
});
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
|
||||
@ -19,7 +19,7 @@ watch(isRunning, (newVal) => {
|
||||
});
|
||||
|
||||
//#region Initial Page Worker
|
||||
const worker = pageWorker.useAmazonPageWorker();
|
||||
const worker = pageWorker.getAmazonPageWorker();
|
||||
worker.channel.on('error', ({ message: msg }) => {
|
||||
timelines.value.push({
|
||||
type: 'error',
|
||||
@ -54,16 +54,18 @@ const task = async () => {
|
||||
},
|
||||
];
|
||||
timelines.value.push();
|
||||
await worker.runSearchPageTask(kws, async (remains) => {
|
||||
if (remains.length > 0) {
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
title: '开始',
|
||||
time: new Date().toLocaleString(),
|
||||
content: `关键词: ${remains[0]} 数据采集开始`,
|
||||
});
|
||||
keywordsList.value = remains;
|
||||
}
|
||||
await worker.runSearchPageTask(kws, {
|
||||
progress: (remains) => {
|
||||
if (remains.length > 0) {
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
title: '开始',
|
||||
time: new Date().toLocaleString(),
|
||||
content: `关键词: ${remains[0]} 数据采集开始`,
|
||||
});
|
||||
keywordsList.value = remains;
|
||||
}
|
||||
},
|
||||
});
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
|
||||
@ -28,7 +28,14 @@ const handleStart = () =>
|
||||
content: '任务开始',
|
||||
time: new Date().toLocaleString(),
|
||||
});
|
||||
await worker.runDetailPageTask(inputText.value.split('\n').filter((id) => /\d+/.exec(id)));
|
||||
await worker.runDetailPageTask(
|
||||
inputText.value.split('\n').filter((id) => /\d+/.exec(id)),
|
||||
{
|
||||
progress: (remains) => {
|
||||
inputText.value = remains.join('\n');
|
||||
},
|
||||
},
|
||||
);
|
||||
timelines.value.push({
|
||||
type: 'info',
|
||||
title: `结束`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user