diff --git a/README.md b/README.md
index f6f60bb..184aa75 100644
--- a/README.md
+++ b/README.md
@@ -8,14 +8,11 @@
![Javascript](https://img.shields.io/badge/javascript-ES6+-yellow?logo=javascript)
![NodeJS](https://img.shields.io/badge/node.js-v18-green?logo=node.js)
+![데모](https://user-images.githubusercontent.com/25934842/207359316-f7056911-d26a-4671-bc3c-2a80e46f24b8.gif)
-### 서비스 배포
-
-- Dev 서버 : http://49.50.172.204:3000/
-
-- Production 서버 : http://101.101.217.49:3000/
+### 서비스 링크 : https://paperef.com
### 팀원
@@ -39,7 +36,6 @@
최예윤
- |
### 개발 환경 세팅
@@ -84,6 +80,7 @@ ELASTIC_USER=
ELASTIC_PASSWORD=
ALLOW_UPDATE=
MAIL_TO=
+SHOULD_RUN_BATCH=
```
## 기술스택
@@ -100,7 +97,7 @@ MAIL_TO=
- 키워드 자동완성 검색 서비스 제공
- 키워드 검색 서비스 제공
- 논문 DOI를 통한 인용관계 시각화 서비스 제공
-- 사용자는 키워드 검색시 PRV 데이터베이스에 있는 정보만 조회할 수 있으며, 데이터베이스에 없는 논문에 대한 데이터 수집은 Request batch에 의해 처리되므로 검색 결과를 즉시 받아보지 못할 수 있습니다.
+- 사용자는 키워드 검색시 PRV 데이터베이스에 있는 정보 혹은 Crossref API를 통해 요청한 정보를 조회할 수 있으며, 데이터베이스에 없는 논문에 대한 데이터 수집은 Request batch에 의해 처리되므로 검색 결과를 즉시 받아보지 못할 수 있습니다.
- Request batch에 의해 수집된 결과는 데이터베이스에 저장됩니다.
- 추가 문의사항은 viewpoint.prv@gmail.com 로 연락바랍니다.
diff --git a/backend/src/ranking/ranking.controller.ts b/backend/src/ranking/ranking.controller.ts
index 16d7d3f..816155d 100644
--- a/backend/src/ranking/ranking.controller.ts
+++ b/backend/src/ranking/ranking.controller.ts
@@ -12,8 +12,4 @@ export class RankingController {
async getTen() {
return await this.rankingService.getTen();
}
- @Get('/insert')
- async insertCache(@Query('keyword') searchStr: string) {
- return this.rankingService.insertRedis(searchStr);
- }
}
diff --git a/backend/src/ranking/tests/ranking.controller.spec.ts b/backend/src/ranking/tests/ranking.controller.spec.ts
index ee0fcc5..a329a4d 100644
--- a/backend/src/ranking/tests/ranking.controller.spec.ts
+++ b/backend/src/ranking/tests/ranking.controller.spec.ts
@@ -21,23 +21,23 @@ describe('RankingServiceTest', () => {
describe('/keyword-ranking', () => {
it('redis date가 10개 이하인 경우', async () => {
//Case 1. redis date가 10개 이하인 경우
- const topTen = await controller.getTen();
+ const topTen = await service.getTen();
expect(topTen.length).toBeLessThanOrEqual(10);
});
it('데이터 삽입 후 topTen 체크', async () => {
//Case 2. 데이터 삽입 후 topTen 체크
- const flag = await controller.insertCache('9번째 데이터');
+ const flag = await service.insertRedis('9번째 데이터');
expect(flag).toBe('new');
const topTen = await controller.getTen();
expect(topTen.length).toBe(9);
- const flag2 = await controller.insertCache('10번째 데이터');
+ const flag2 = await service.insertRedis('10번째 데이터');
expect(flag2).toBe('new');
const topTen2 = await controller.getTen();
expect(topTen2.length).toBe(10);
});
it('2위인 "사랑해요" 데이터가 한번 더 검색시 1위로 업데이트', async () => {
//Case 3. 2위인 "사랑해요" 데이터가 한번 더 검색시 1위로 업데이트
- const flag = await controller.insertCache('사랑해요');
+ const flag = await service.insertRedis('사랑해요');
expect(flag).toBe('update');
const topTen = await controller.getTen();
expect(topTen[0].keyword).toBe('부스트캠프');
@@ -46,23 +46,23 @@ describe('RankingServiceTest', () => {
describe('/keyword-ranking/insert', () => {
// Case1. 기존 redis에 없던 데이터 삽입
it('기존 redis에 없던 데이터 삽입', async () => {
- const result = await controller.insertCache('newData');
+ const result = await service.insertRedis('newData');
expect(result).toBe('new');
});
// Case2. 기존 redis에 있던 데이터 삽입
it('기존 redis에 있던 데이터 삽입', async () => {
- const result = await controller.insertCache('부스트캠프');
+ const result = await service.insertRedis('부스트캠프');
expect(result).toBe('update');
});
//Case3. redis에 빈 검색어 입력
it('빈 검색어 redis에 삽입', async () => {
- await expect(controller.insertCache('')).rejects.toEqual(
+ await expect(service.insertRedis('')).rejects.toEqual(
new BadRequestException({ status: 400, error: 'bad request' }),
);
});
//Case4. insert 실패시 타임 아웃 TimeOut
it('insert 실패시 타임 아웃 TimeOut', async () => {
- await expect(controller.insertCache('')).rejects.toEqual(
+ await expect(service.insertRedis('')).rejects.toEqual(
new BadRequestException({ status: 400, error: 'bad request' }),
);
});
diff --git a/backend/src/search/search.controller.ts b/backend/src/search/search.controller.ts
index 2a2cf85..5d6ab75 100644
--- a/backend/src/search/search.controller.ts
+++ b/backend/src/search/search.controller.ts
@@ -46,6 +46,18 @@ export class SearchController {
const keywordHasSet = await this.batchService.setKeyword(keyword);
if (keywordHasSet) this.batchService.searchBatcher.pushToQueue(0, 0, -1, true, keyword);
+ // Elasticsearch 검색 결과가 없을 경우, Crossref 검색
+ if (totalItems === 0) {
+ const { items: papers, totalItems } = await this.searchService.getPapersFromCrossref(keyword, rows, page);
+ return {
+ papers,
+ pageInfo: {
+ totalItems,
+ totalPages: Math.ceil(totalItems / rows),
+ },
+ };
+ }
+
const papers = data.hits.hits.map((paper) => new PaperInfoExtended(paper._source));
return {
papers,
diff --git a/backend/src/search/search.service.ts b/backend/src/search/search.service.ts
index 007c227..b2e2b9a 100644
--- a/backend/src/search/search.service.ts
+++ b/backend/src/search/search.service.ts
@@ -1,15 +1,16 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { Injectable, NotFoundException, RequestTimeoutException } from '@nestjs/common';
import {
CrossRefItem,
PaperInfoExtended,
PaperInfo,
PaperInfoDetail,
CrossRefPaperResponse,
+ CrossRefResponse,
} from './entities/crossRef.entity';
import { ElasticsearchService } from '@nestjs/elasticsearch';
import { MgetOperation, SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { HttpService } from '@nestjs/axios';
-import { CROSSREF_API_PAPER_URL } from '../util';
+import { CROSSREF_API_PAPER_URL, CROSSREF_API_URL } from '../util';
import { ELASTIC_INDEX } from 'src/envLayer';
@Injectable()
@@ -75,6 +76,17 @@ export class SearchService {
return new PaperInfoDetail(data);
};
+ async getPapersFromCrossref(keyword: string, rows: number, page: number, selects?: string[]) {
+ const crossRefdata = await this.httpService.axiosRef
+ .get(CROSSREF_API_URL(keyword, rows, page, selects))
+ .catch((err) => {
+ throw new RequestTimeoutException(err.message);
+ });
+ const items = crossRefdata.data.message.items.map((item) => this.parsePaperInfoExtended(item));
+ const totalItems = crossRefdata.data.message['total-results'];
+ return { items, totalItems };
+ }
+
async getPaperFromCrossref(doi: string) {
try {
const item = await this.httpService.axiosRef.get(CROSSREF_API_PAPER_URL(doi));
diff --git a/backend/src/util.ts b/backend/src/util.ts
index 18ac270..d4a99fe 100644
--- a/backend/src/util.ts
+++ b/backend/src/util.ts
@@ -1,7 +1,12 @@
import { MAIL_TO } from './envLayer';
const BASE_URL = 'https://api.crossref.org/works';
-export const CROSSREF_API_URL = (keyword: string, rows = 5, page = 1, selects: string[] = ['author', 'title', 'DOI']) =>
+export const CROSSREF_API_URL = (
+ keyword: string,
+ rows = 5,
+ page = 1,
+ selects: string[] = ['title', 'author', 'created', 'is-referenced-by-count', 'references-count', 'DOI'],
+) =>
`${BASE_URL}?query=${keyword}&rows=${rows}&select=${selects.join(',')}&offset=${rows * (page - 1)}&mailto=${MAIL_TO}`;
export const MAX_ROWS = 1000;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 3e5c5a9..118b2f7 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -23,5 +23,5 @@ it('Footer 렌더링 테스트', () => {
);
});
const span = container?.querySelector('span');
- expect(span?.textContent).toBe('문의사항, 버그제보: vp.prv@gmail.com');
+ expect(span?.textContent).toBe('문의사항, 버그제보: viewpoint.prv@gmail.com');
});
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx
index 72e20f6..90d3733 100644
--- a/frontend/src/components/Footer.tsx
+++ b/frontend/src/components/Footer.tsx
@@ -9,7 +9,7 @@ interface FooterProps {
const Footer = ({ bgColor, contentColor }: FooterProps) => {
return (
- 문의사항, 버그제보: vp.prv@gmail.com
+ 문의사항, 버그제보: viewpoint.prv@gmail.com
{
};
const Button = styled.button`
+ display: flex;
+ align-items: center;
background-color: transparent;
cursor: pointer;
`;
diff --git a/frontend/src/components/search/AutoCompletedList.tsx b/frontend/src/components/search/AutoCompletedList.tsx
index e9fb8d3..02a4cbb 100644
--- a/frontend/src/components/search/AutoCompletedList.tsx
+++ b/frontend/src/components/search/AutoCompletedList.tsx
@@ -79,6 +79,7 @@ const AutoCompleted = styled.li<{ hovered: boolean }>`
const Title = styled.div`
${({ theme }) => theme.TYPO.body1}
+ line-height: 1.1em;
`;
const Author = styled.div`
diff --git a/frontend/src/components/search/RecentKeywordsList.tsx b/frontend/src/components/search/RecentKeywordsList.tsx
index 2b83dc3..f981934 100644
--- a/frontend/src/components/search/RecentKeywordsList.tsx
+++ b/frontend/src/components/search/RecentKeywordsList.tsx
@@ -1,5 +1,6 @@
import { IconButton } from '@/components';
import { ClockIcon, XIcon } from '@/icons';
+import { Ellipsis } from '@/style/styleUtils';
import { setLocalStorage } from '@/utils/storage';
import { isEmpty } from 'lodash-es';
import { Dispatch, SetStateAction, useEffect } from 'react';
@@ -42,7 +43,7 @@ const RecentKeywordsList = ({
onMouseDown={() => handleMouseDown(keyword)}
>
- {keyword}
+ {keyword}
}
onMouseDown={(e) => handleRecentKeywordRemove(e, keyword)}
@@ -71,6 +72,11 @@ const Keyword = styled.li<{ hovered: boolean }>`
background-color: ${({ theme, hovered }) => (hovered ? theme.COLOR.gray1 : 'auto')};
`;
+const KeywordText = styled(Ellipsis)`
+ width: 100%;
+ display: block;
+`;
+
const NoResult = styled.div`
padding-top: 25px;
text-align: center;
diff --git a/frontend/src/hooks/graph/useNodeUpdate.ts b/frontend/src/hooks/graph/useGraph.ts
similarity index 57%
rename from frontend/src/hooks/graph/useNodeUpdate.ts
rename to frontend/src/hooks/graph/useGraph.ts
index f525058..6c3ffda 100644
--- a/frontend/src/hooks/graph/useNodeUpdate.ts
+++ b/frontend/src/hooks/graph/useGraph.ts
@@ -1,17 +1,33 @@
+import { Link, Node } from '@/pages/PaperDetail/components/ReferenceGraph';
import theme from '@/style/theme';
import * as d3 from 'd3';
import { useCallback } from 'react';
-const NORMAL_SYMBOL_SIZE = 20;
-const STAR_SYMBOL_SIZE = 100;
-
-export default function useNodeUpdate(
- nodes: any[],
- changeHoveredNode: (key: string) => void,
+const useGraph = (
+ nodeSelector: SVGGElement | null,
+ linkSelector: SVGGElement | null,
addChildrensNodes: (doi: string) => void,
-) {
- return useCallback(
- (nodesSelector: SVGGElement) => {
+ changeHoveredNode: (doi: string) => void,
+) => {
+ const drawLink = useCallback(
+ (links: Link[]) => {
+ d3.select(linkSelector)
+ .selectAll('line')
+ .data(links)
+ .join('line')
+ .attr('x1', (d) => (d.source as Node).x || null)
+ .attr('y1', (d) => (d.source as Node).y || null)
+ .attr('x2', (d) => (d.target as Node).x || null)
+ .attr('y2', (d) => (d.target as Node).y || null);
+ },
+ [linkSelector],
+ );
+
+ const drawNode = useCallback(
+ (nodes: Node[]) => {
+ const NORMAL_SYMBOL_SIZE = 20;
+ const STAR_SYMBOL_SIZE = 100;
+
const normalSymbol = d3.symbol().type(d3.symbolSquare).size(NORMAL_SYMBOL_SIZE)();
const starSymbol = d3.symbol().type(d3.symbolStar).size(STAR_SYMBOL_SIZE)();
@@ -20,7 +36,7 @@ export default function useNodeUpdate(
return d3.scaleLinear([0, 4], ['white', theme.COLOR.secondary2]).interpolate(d3.interpolateRgb)(loged);
};
- d3.select(nodesSelector)
+ d3.select(nodeSelector)
.selectAll('path')
.data(nodes)
.join('path')
@@ -32,18 +48,23 @@ export default function useNodeUpdate(
.on('mouseout', () => changeHoveredNode(''))
.on('click', (_, d) => d.doi && addChildrensNodes(d.doi));
- d3.select(nodesSelector)
+ d3.select(nodeSelector)
.selectAll('text')
.data(nodes)
.join('text')
.text((d) => `${d.author} ${d.publishedYear ? `(${d.publishedYear})` : ''}`)
- .attr('x', (d) => d.x)
- .attr('y', (d) => d.y + 10)
+ .attr('x', (d) => d.x || null)
+ .attr('y', (d) => (d.y ? d.y + 10 : null))
.attr('dy', 5)
+ .style('font-weight', 700)
.on('mouseover', (_, d) => d.doi && changeHoveredNode(d.key))
.on('mouseout', () => changeHoveredNode(''))
.on('click', (_, d) => d.doi && addChildrensNodes(d.doi));
},
- [nodes, addChildrensNodes, changeHoveredNode],
+ [nodeSelector, addChildrensNodes, changeHoveredNode],
);
-}
+
+ return { drawLink, drawNode };
+};
+
+export default useGraph;
diff --git a/frontend/src/hooks/graph/useGraphData.ts b/frontend/src/hooks/graph/useGraphData.ts
index a2967ad..5e7de6b 100644
--- a/frontend/src/hooks/graph/useGraphData.ts
+++ b/frontend/src/hooks/graph/useGraphData.ts
@@ -1,9 +1,10 @@
import { IPaperDetail } from '@/api/api';
+import { Link, Node } from '@/pages/PaperDetail/components/ReferenceGraph';
import { useEffect, useRef, useState } from 'react';
-export default function useGraphData(data: IPaperDetail) {
- const [links, setLinks] = useState([]);
- const nodes = useRef([]);
+export default function useGraphData(data: IPaperDetail) {
+ const [links, setLinks] = useState([]);
+ const nodes = useRef([]);
const doiMap = useRef