This commit is contained in:
johnathan 2025-06-24 16:21:18 +08:00
parent 2d29e5c95a
commit 4a12fc7ed3
24 changed files with 314 additions and 185 deletions

View File

@ -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>

View File

@ -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>

View File

@ -35,7 +35,7 @@ defineProps<{
</n-card>
</template>
<style>
<style scoped lang="scss">
.progress-report {
width: 100%;
height: 100%;

View File

@ -1,6 +1,4 @@
<script lang="ts" setup>
import type { AmazonReview } from '~/logic/page-worker/types';
defineProps<{
model: AmazonReview;
}>();

View File

@ -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
View File

@ -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;
};

View File

@ -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;
}),
);
}

View File

@ -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();
},
};

View File

@ -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', () => {

View File

@ -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.

View File

@ -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(),

View File

@ -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
View 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;
});
}

View File

@ -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 {

View File

@ -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> {

View File

@ -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() {

View File

@ -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() {

View File

@ -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: '标题' },

View File

@ -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();

View File

@ -29,7 +29,6 @@ watch(currentUrl, (newVal) => {
site.value = 'homedepot';
break;
default:
router.push(`/${site.value}`);
break;
}
}

View File

@ -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>

View File

@ -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',

View File

@ -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',

View File

@ -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: `结束`,