Added fine-grained control over search for server use.
This commit is contained in:
@ -0,0 +1,34 @@
|
||||
class GoogleSearchContext extends SearchContext {
|
||||
constructor(searchQuery: string, params: { [key: string]: string }) {
|
||||
super('google', searchQuery, params);
|
||||
}
|
||||
|
||||
|
||||
generateQueryParams() {
|
||||
const filterParams = ['maxResults', 'startIndex'];
|
||||
const searchParams = ['intitle', 'inauthor', 'inpublisher', 'subject', 'isbn'];
|
||||
|
||||
const queryParams = filterParams
|
||||
.map(p => p in this.params ? p + '=' + this.params[p] : undefined)
|
||||
.filter(p => p !== undefined)
|
||||
.join('&');
|
||||
|
||||
const search = this.search.trim();
|
||||
const searchQueryParam = [
|
||||
search.length > 0 ? search + ' ' : '',
|
||||
...searchParams.map(p => this.params[p] ? p + ':"' + this.params[p] + '"' : ''),
|
||||
].filter(p => p.length > 0).join('');
|
||||
|
||||
return queryParams + '&' + searchQueryParam;
|
||||
}
|
||||
|
||||
next() {
|
||||
const resultsPerPage = parseInt(this.params['maxResults']) ?? 10;
|
||||
const index = parseInt(this.params['startIndex']) ?? 0;
|
||||
|
||||
const data = { ...this.params };
|
||||
data['startIndex'] = (index + resultsPerPage).toString();
|
||||
|
||||
return new GoogleSearchContext(this.search, data);
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
abstract class SearchContext {
|
||||
provider: string;
|
||||
search: string;
|
||||
params: { [key: string]: string };
|
||||
|
||||
constructor(provider: string, search: string, params: { [key: string]: string }) {
|
||||
this.provider = provider;
|
||||
this.search = search;
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
abstract generateQueryParams();
|
||||
abstract next();
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsAlpha, IsIn, IsNotEmpty, IsString, Length } from "class-validator";
|
||||
|
||||
export class BookSearchInputDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsAlpha()
|
||||
@IsIn(['google'])
|
||||
@Transform(({ value }) => value.toLowerCase())
|
||||
provider!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(1, 64)
|
||||
query!: string;
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsArray, IsDate, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
export class BookSearchResultDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
providerBookId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
providerSeriesId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
desc: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
volume: number|null;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
publisher: string;
|
||||
|
||||
@IsArray()
|
||||
@IsNotEmpty()
|
||||
@IsString({ each: true })
|
||||
authors: string[];
|
||||
|
||||
@IsObject()
|
||||
@IsNotEmpty()
|
||||
industryIdentifiers: { [key: string]: string };
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
provider: string;
|
||||
|
||||
@IsDate()
|
||||
@IsNotEmpty()
|
||||
@Transform(({ value }) => new Date(value))
|
||||
publishedAt: Date;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
language: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
categories: string[];
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
maturityRating: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
thumbnail: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
url: string;
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { GoogleService } from './google.service';
|
||||
|
||||
describe('GoogleService', () => {
|
||||
let service: GoogleService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [GoogleService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<GoogleService>(GoogleService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BookSearchResultDto } from '../dto/book-search-result.dto';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom, map, timeout } from 'rxjs';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleService {
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
) { }
|
||||
|
||||
async searchRaw(searchQuery: string): Promise<BookSearchResultDto[]> {
|
||||
const queryParams = 'printType=books&maxResults=10&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))&q=';
|
||||
|
||||
return await firstValueFrom(
|
||||
this.http.get('https://www.googleapis.com/books/v1/volumes?' + queryParams + searchQuery)
|
||||
.pipe(
|
||||
timeout({ first: 5000 }),
|
||||
map(this.transform),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async search(context: GoogleSearchContext): Promise<BookSearchResultDto[]> {
|
||||
const defaultQueryParams = 'printType=books&fields=items(kind,id,volumeInfo(title,description,authors,publisher,publishedDate,industryIdentifiers,language,categories,maturityRating,imageLinks,canonicalVolumeLink,seriesInfo))';
|
||||
const customQueryParams = context.generateQueryParams();
|
||||
|
||||
return await firstValueFrom(
|
||||
this.http.get('https://www.googleapis.com/books/v1/volumes?' + defaultQueryParams + '&' + customQueryParams)
|
||||
.pipe(
|
||||
timeout({ first: 5000 }),
|
||||
map(this.transform),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private transform(response: AxiosResponse): BookSearchResultDto[] {
|
||||
if (!response.data?.items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: any[] = response.data.items;
|
||||
return items.map((item: any) => {
|
||||
const result: BookSearchResultDto = {
|
||||
providerBookId: item.id,
|
||||
providerSeriesId: item.volumeInfo.seriesInfo?.volumeSeries[0].seriesId,
|
||||
title: item.volumeInfo.title,
|
||||
desc: item.volumeInfo.description,
|
||||
volume: parseInt(item.volumeInfo.seriesInfo?.bookDisplayNumber),
|
||||
publisher: item.volumeInfo.publisher,
|
||||
authors: item.volumeInfo.authors,
|
||||
categories: item.volumeInfo.categories,
|
||||
maturityRating: item.volumeInfo.maturityRating,
|
||||
industryIdentifiers: Object.assign({}, ...item.volumeInfo.industryIdentifiers.map(i => ({ [i.type]: i.identifier }))),
|
||||
publishedAt: new Date(item.volumeInfo.publishedDate),
|
||||
language: item.volumeInfo.language,
|
||||
thumbnail: item.volumeInfo.imageLinks.thumbnail,
|
||||
url: item.volumeInfo.canonicalVolumeLink,
|
||||
provider: 'google'
|
||||
}
|
||||
|
||||
if (result.providerSeriesId) {
|
||||
let regex = null;
|
||||
switch (result.publisher) {
|
||||
case 'J-Novel Club':
|
||||
regex = new RegExp(/(?<title>.+?):?\sVolume\s(?<volume>\d+)/);
|
||||
case 'Yen Press LLC':
|
||||
regex = new RegExp(/(?<title>.+?),?\sVol\.\s(?<volume>\d+)\s\((?<media_type>\w+)\)/);
|
||||
default:
|
||||
regex = new RegExp(/(?<title>.+?)(?:,|:|\s\-)?\s(?:Vol(?:\.|ume)?)?\s(?<volume>\d+)/);
|
||||
}
|
||||
|
||||
const match = result.title.match(regex);
|
||||
if (match?.groups) {
|
||||
result.title = match.groups['title'].trim();
|
||||
if (!result.volume) {
|
||||
result.volume = parseInt(match.groups['volume']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ProvidersController } from './providers.controller';
|
||||
|
||||
describe('ProvidersController', () => {
|
||||
let controller: ProvidersController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ProvidersController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<ProvidersController>(ProvidersController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
@ -0,0 +1,19 @@
|
||||
import { Body, Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ProvidersService } from './providers.service';
|
||||
import { BookSearchInputDto } from './dto/book-search-input.dto';
|
||||
import { JwtAccessGuard } from 'src/auth/guards/jwt-access.guard';
|
||||
|
||||
@Controller('providers')
|
||||
export class ProvidersController {
|
||||
constructor(
|
||||
private providers: ProvidersService,
|
||||
) { }
|
||||
|
||||
@UseGuards(JwtAccessGuard)
|
||||
@Get('search')
|
||||
async Search(
|
||||
@Body() body: BookSearchInputDto,
|
||||
) {
|
||||
return await this.providers.searchRaw(body.provider, body.query);
|
||||
}
|
||||
}
|
27
backend/nestjs-seshat-api/src/providers/providers.module.ts
Normal file
27
backend/nestjs-seshat-api/src/providers/providers.module.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoogleService } from './google/google.service';
|
||||
import { ProvidersService } from './providers.service';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ProvidersController } from './providers.controller';
|
||||
import { BooksService } from 'src/books/books/books.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (config: ConfigService) => ({
|
||||
timeout: config.get('HTTP_TIMEOUT') ?? 5000,
|
||||
maxRedirects: config.get('HTTP_MAX_REDIRECTS') ?? 5,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [
|
||||
ProvidersService,
|
||||
GoogleService,
|
||||
],
|
||||
providers: [GoogleService, ProvidersService],
|
||||
controllers: [ProvidersController]
|
||||
})
|
||||
export class ProvidersModule {}
|
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ProvidersService } from './providers.service';
|
||||
|
||||
describe('ProvidersService', () => {
|
||||
let service: ProvidersService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ProvidersService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ProvidersService>(ProvidersService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
36
backend/nestjs-seshat-api/src/providers/providers.service.ts
Normal file
36
backend/nestjs-seshat-api/src/providers/providers.service.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { GoogleService } from './google/google.service';
|
||||
import { BookSearchResultDto } from './dto/book-search-result.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProvidersService {
|
||||
constructor(
|
||||
private readonly google: GoogleService,
|
||||
) { }
|
||||
|
||||
generateSearchContext(providerName: string, searchQuery: string): SearchContext | null {
|
||||
let params: { [key: string]: string } = {};
|
||||
if (providerName == 'google') {
|
||||
return new GoogleSearchContext(searchQuery, params);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async searchRaw(providerName: string, searchQuery: string): Promise<BookSearchResultDto[]> {
|
||||
switch (providerName.toLowerCase()) {
|
||||
case 'google':
|
||||
return await this.google.searchRaw(searchQuery);
|
||||
default:
|
||||
throw Error('Invalid provider name.');
|
||||
}
|
||||
}
|
||||
|
||||
async search(context: SearchContext): Promise<BookSearchResultDto[]> {
|
||||
switch (context.provider.toLowerCase()) {
|
||||
case 'google':
|
||||
return await this.google.search(context);
|
||||
default:
|
||||
throw Error('Invalid provider name.');
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user