├── .github ├── ISSUE_TEMPLATE │ └── new-issue.md ├── auto_assign.yml ├── pull_request_template.md └── workflows │ ├── assign_action.yml │ ├── auto-test.be.yml │ ├── auto-test.fe.yml │ ├── deploy_dev.yml │ ├── deploy_prod.be.yml │ └── deploy_prod.fe.yml ├── .gitignore ├── README.md ├── backend ├── .dockerignore ├── .env.sample ├── .eslintrc.js ├── .gitignore ├── .prettierrc.json ├── Dockerfile ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── src │ ├── app.module.ts │ ├── batch │ │ ├── batch.config.ts │ │ ├── batch.queue.ts │ │ ├── batch.service.ts │ │ ├── batcher.abstract.ts │ │ ├── batcher.doi.ts │ │ ├── batcher.search.ts │ │ └── tests │ │ │ ├── batch.service.spec.ts │ │ │ ├── batcher.mock.ts │ │ │ └── batcher.spec.ts │ ├── envLayer.ts │ ├── main.ts │ ├── ranking │ │ ├── entities │ │ │ ├── ranking.dto.ts │ │ │ └── ranking.entity.ts │ │ ├── ranking.controller.ts │ │ ├── ranking.module.ts │ │ ├── ranking.service.ts │ │ └── tests │ │ │ ├── ranking.controller.spec.ts │ │ │ ├── ranking.service.mock.ts │ │ │ └── rankingData.mock.ts │ ├── search │ │ ├── entities │ │ │ ├── crossRef.entity.ts │ │ │ └── search.dto.ts │ │ ├── search.controller.ts │ │ ├── search.module.ts │ │ ├── search.service.ts │ │ └── tests │ │ │ ├── crossref.mock.ts │ │ │ ├── search.controller.spec.ts │ │ │ ├── search.service.mock.ts │ │ │ └── searchdata.mock.ts │ └── util.ts ├── tsconfig.build.json └── tsconfig.json ├── docker-compose ├── docker-compose.be.prod.yml ├── docker-compose.dev.yml ├── docker-compose.elasticsearch.yml └── docker-compose.fe.prod.yml └── frontend ├── .dockerignore ├── .env.sample ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── Dockerfile ├── README.md ├── config-overrides.js ├── nginx.conf ├── package-lock.json ├── package.json ├── public ├── assets │ ├── moon.png │ └── prv-image.png ├── favicon.ico ├── index.html └── robots.txt ├── src ├── App.test.tsx ├── App.tsx ├── api │ └── api.ts ├── components │ ├── Footer.tsx │ ├── IconButton.tsx │ ├── Pagination.tsx │ ├── index.ts │ ├── loader │ │ ├── LoaderWrapper.tsx │ │ └── MoonLoader.tsx │ └── search │ │ ├── AutoCompletedList.tsx │ │ ├── RecentKeywordsList.tsx │ │ └── Search.tsx ├── constants │ └── path.ts ├── custom.d.ts ├── error │ ├── ErrorBoundary.tsx │ ├── GlobalErrorFallback.tsx │ ├── RankingErrorFallback.tsx │ └── index.ts ├── hooks │ ├── graph │ │ ├── useGraph.ts │ │ ├── useGraphData.ts │ │ ├── useGraphEmphasize.ts │ │ └── useGraphZoom.ts │ ├── index.ts │ ├── useDebouncedValue.ts │ └── useInterval.ts ├── icons │ ├── ClockIcon.tsx │ ├── DropdownIcon.tsx │ ├── DropdownReverseIcon.tsx │ ├── GithubLogoIcon.tsx │ ├── InfoIcon.tsx │ ├── LogoIcon.tsx │ ├── MagnifyingGlassIcon.tsx │ ├── PreviousButtonIcon.tsx │ ├── XIcon.tsx │ └── index.ts ├── index.tsx ├── pages │ ├── Main │ │ ├── Main.tsx │ │ └── components │ │ │ ├── KeywordRanking.tsx │ │ │ ├── RankingSlide.tsx │ │ │ └── StarLayer.tsx │ ├── PaperDetail │ │ ├── PaperDetail.tsx │ │ ├── components │ │ │ ├── ColorRangeBar.tsx │ │ │ ├── InfoTooltip.tsx │ │ │ ├── PaperInfo.tsx │ │ │ └── ReferenceGraph.tsx │ │ └── workers │ │ │ └── forceSimulation.worker.ts │ └── SearchList │ │ ├── SearchList.tsx │ │ └── components │ │ ├── Paper.tsx │ │ ├── SearchBarHeader.tsx │ │ └── SearchResults.tsx ├── queries │ ├── queries.ts │ └── query-key.ts ├── react-app-env.d.ts ├── style │ ├── GlobalStyle.tsx │ ├── styleUtils.ts │ └── theme.ts └── utils │ ├── createQueryString.ts │ ├── format.tsx │ └── storage.ts ├── tsconfig.json └── tsconfig.paths.json /.github/ISSUE_TEMPLATE/new-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New Issue 3 | about: 이슈 템플릿 입니다. 해결과정 / 질문 / 의견은 댓글로 남깁니다. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 이슈 내용 11 | 무슨 이슈인지에 대한 설명을 적어주세요. 12 | 13 | ## 기대 결과 14 | - 원하는 결과물에 대한 내용을 적어주세요. 15 | -------------------------------------------------------------------------------- /.github/auto_assign.yml: -------------------------------------------------------------------------------- 1 | # Set to true to add reviewers to pull requests 2 | addReviewers: true 3 | 4 | # Set to true to add assignees to pull requests 5 | addAssignees: false 6 | 7 | reviewers: 8 | - leesungbin 9 | - yeynii 10 | - Palwol 11 | - JunYupK 12 | 13 | numberOfReviewers: 2 14 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 개요 2 | 여기에 기능에 대한 간략한 설명을 적어주세요. 3 | 4 | ## 작업사항 5 | - 여기에 기능에 대한 작업 사항을 적어주세요 6 | 7 | ## 리뷰 요청사항 8 | - N/A 9 | -------------------------------------------------------------------------------- /.github/workflows/assign_action.yml: -------------------------------------------------------------------------------- 1 | name: 'Auto Assign Reviewers' 2 | on: 3 | pull_request: 4 | types: [opened, ready_for_review] 5 | 6 | jobs: 7 | add-reviews: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: kentaro-m/auto-assign-action@v1.2.4 11 | -------------------------------------------------------------------------------- /.github/workflows/auto-test.be.yml: -------------------------------------------------------------------------------- 1 | name: "Backend Tester" 2 | on: 3 | pull_request: 4 | types: [opened, ready_for_review, synchronize] 5 | paths: 6 | - "backend/**" 7 | 8 | jobs: 9 | run-test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [18.x] 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: BE Test - ${{ matrix.node-version }} 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: | 21 | cd backend 22 | npm ci 23 | npm test 24 | -------------------------------------------------------------------------------- /.github/workflows/auto-test.fe.yml: -------------------------------------------------------------------------------- 1 | name: "Frontend Tester" 2 | on: 3 | pull_request: 4 | types: [opened, ready_for_review, synchronize] 5 | paths: 6 | - "frontend/**" 7 | 8 | jobs: 9 | run-test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [18.x] 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: FE Test - ${{ matrix.node-version }} 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: | 21 | cd frontend 22 | npm ci 23 | npm test 24 | -------------------------------------------------------------------------------- /.github/workflows/deploy_dev.yml: -------------------------------------------------------------------------------- 1 | name: Auto deploy to NCP 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | 8 | jobs: 9 | changes: 10 | name: Diff check 11 | runs-on: ubuntu-latest 12 | permissions: 13 | pull-requests: read 14 | outputs: 15 | backend: ${{ steps.filter.outputs.backend }} 16 | frontend: ${{ steps.filter.outputs.frontend }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: dorny/paths-filter@v2 20 | id: filter 21 | with: 22 | filters: | 23 | backend: 24 | - 'backend/**' 25 | frontend: 26 | - 'frontend/**' 27 | push_to_registry_be: 28 | name: (BE) Build & Push 29 | needs: changes 30 | if: ${{ needs.changes.outputs.backend == 'true' }} 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v3 35 | - name: Set up Docker Buildx 1 36 | uses: docker/setup-buildx-action@v2 37 | - name: Login to NCP Container Registry 38 | uses: docker/login-action@v2 39 | with: 40 | registry: ${{ secrets.NCP_CONTAINER_REGISTRY }} 41 | username: ${{ secrets.NCP_ACCESS_KEY }} 42 | password: ${{ secrets.NCP_SECRET_KEY }} 43 | - name: build and push 44 | uses: docker/build-push-action@v3 45 | with: 46 | context: ./backend 47 | file: ./backend/Dockerfile 48 | push: true 49 | tags: | 50 | ${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-backend:latest 51 | ${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-backend:${{ github.run_number }} 52 | cache-from: type=registry,ref=${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-backend:latest 53 | cache-to: type=inline 54 | 55 | push_to_registry_fe: 56 | name: (FE) Build & Push 57 | needs: changes 58 | if: ${{ needs.changes.outputs.frontend == 'true' }} 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Checkout 62 | uses: actions/checkout@v3 63 | - name: Set up Docker Buildx 1 64 | uses: docker/setup-buildx-action@v2 65 | - name: Login to NCP Container Registry 66 | uses: docker/login-action@v2 67 | with: 68 | registry: ${{ secrets.NCP_CONTAINER_REGISTRY }} 69 | username: ${{ secrets.NCP_ACCESS_KEY }} 70 | password: ${{ secrets.NCP_SECRET_KEY }} 71 | - name: build and push 72 | uses: docker/build-push-action@v3 73 | with: 74 | context: ./frontend 75 | file: ./frontend/Dockerfile 76 | push: true 77 | tags: | 78 | ${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-frontend:latest 79 | ${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-frontend:${{ github.run_number }} 80 | cache-from: type=registry,ref=${{ secrets.NCP_CONTAINER_REGISTRY }}/prv-frontend:latest 81 | cache-to: type=inline 82 | secrets: | 83 | "REACT_APP_BASE_URL=${{ secrets.REACT_APP_BASE_URL_DEV }}" 84 | 85 | pull_from_registry: 86 | name: Connect server ssh and pull from container registry 87 | needs: [push_to_registry_be, push_to_registry_fe] 88 | if: | 89 | always() && 90 | (needs.push_to_registry_be.result == 'success' || needs.push_to_registry_fe.result == 'success') 91 | runs-on: ubuntu-latest 92 | steps: 93 | - name: connect ssh 94 | uses: appleboy/ssh-action@master 95 | with: 96 | host: ${{ secrets.DEV_HOST }} 97 | username: ${{ secrets.DEV_USERNAME }} 98 | password: ${{ secrets.DEV_PASSWORD }} 99 | port: ${{ secrets.DEV_PORT }} 100 | script: | 101 | docker compose --env-file ${{ secrets.ENV_FILENAME_DOCKER_COMPOSE_DEV }} pull 102 | docker compose --env-file ${{ secrets.ENV_FILENAME_DOCKER_COMPOSE_DEV }} up -d 103 | docker image prune -f 104 | -------------------------------------------------------------------------------- /.github/workflows/deploy_prod.be.yml: -------------------------------------------------------------------------------- 1 | name: Production Deploy(BE) 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'backend/**' 9 | 10 | jobs: 11 | push_to_registry_be: 12 | name: (BE) Build & Push 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | - name: Set up Docker Buildx 1 18 | uses: docker/setup-buildx-action@v2 19 | - name: Login to NCP Container Registry 20 | uses: docker/login-action@v2 21 | with: 22 | registry: ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }} 23 | username: ${{ secrets.PRODUCTION_ACCESS_KEY }} 24 | password: ${{ secrets.PRODUCTION_SECRET_KEY }} 25 | - name: build and push 26 | uses: docker/build-push-action@v3 27 | with: 28 | context: ./backend 29 | file: ./backend/Dockerfile 30 | push: true 31 | tags: | 32 | ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-backend:latest 33 | ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-backend:${{ github.run_number }} 34 | cache-from: type=registry,ref=${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-backend:latest 35 | cache-to: type=inline 36 | 37 | pull_from_registry: 38 | name: Connect server ssh and pull from container registry 39 | needs: push_to_registry_be 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: connect ssh 43 | uses: appleboy/ssh-action@master 44 | with: 45 | host: ${{ secrets.PRODUCTION_BE_HOST }} 46 | username: ${{ secrets.PRODUCTION_BE_USERNAME }} 47 | password: ${{ secrets.PRODUCTION_BE_PASSWORD }} 48 | port: ${{ secrets.PRODUCTION_BE_PORT }} 49 | script: | 50 | docker-compose --env-file ${{ secrets.ENV_FILENAME_BACKEND }} -f docker-compose.be.prod.yml pull 51 | docker-compose --env-file ${{ secrets.ENV_FILENAME_BACKEND }} -f docker-compose.be.prod.yml up -d 52 | docker image prune -f 53 | -------------------------------------------------------------------------------- /.github/workflows/deploy_prod.fe.yml: -------------------------------------------------------------------------------- 1 | name: Production Deploy(FE) 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'frontend/**' 9 | 10 | jobs: 11 | push_to_registry_fe: 12 | name: (FE) Build & Push 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | - name: Set up Docker Buildx 1 18 | uses: docker/setup-buildx-action@v2 19 | - name: Login to NCP Container Registry 20 | uses: docker/login-action@v2 21 | with: 22 | registry: ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }} 23 | username: ${{ secrets.PRODUCTION_ACCESS_KEY }} 24 | password: ${{ secrets.PRODUCTION_SECRET_KEY }} 25 | - name: build and push 26 | uses: docker/build-push-action@v3 27 | with: 28 | context: ./frontend 29 | file: ./frontend/Dockerfile 30 | push: true 31 | tags: | 32 | ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-frontend:latest 33 | ${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-frontend:${{ github.run_number }} 34 | cache-from: type=registry,ref=${{ secrets.PRODUCTION_CONTAINER_REGISTRY }}/prv-frontend:latest 35 | cache-to: type=inline 36 | secrets: | 37 | REACT_APP_BASE_URL=${{ secrets.REACT_APP_BASE_URL_PROD }} 38 | 39 | pull_from_registry: 40 | name: Connect server ssh and pull from container registry 41 | needs: push_to_registry_fe 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: connect ssh 45 | uses: appleboy/ssh-action@master 46 | with: 47 | host: ${{ secrets.PRODUCTION_FE_HOST }} 48 | username: ${{ secrets.PRODUCTION_FE_USERNAME }} 49 | password: ${{ secrets.PRODUCTION_FE_PASSWORD }} 50 | port: ${{ secrets.PRODUCTION_FE_PORT }} 51 | script: | 52 | docker-compose --env-file ${{ secrets.ENV_FILENAME_FRONTEND }} -f docker-compose.fe.prod.yml pull 53 | docker-compose --env-file ${{ secrets.ENV_FILENAME_FRONTEND }} -f docker-compose.fe.prod.yml up -d 54 | docker image prune -f 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .secrets 3 | .czrc 4 | .coverage -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # 🌟 PRV(Paper Reference Visualization) 4 | 5 | **Paper Reference Visualization** 6 | 7 | 논문 검색 & 레퍼런스 시각화 사이트 8 | 9 | ![Javascript](https://img.shields.io/badge/javascript-ES6+-yellow?logo=javascript) 10 | ![NodeJS](https://img.shields.io/badge/node.js-v18-green?logo=node.js) 11 | ![데모](https://user-images.githubusercontent.com/25934842/207359316-f7056911-d26a-4671-bc3c-2a80e46f24b8.gif) 12 | 13 |
14 | 15 | ### 기능 목록 16 | 17 | #### 인기 검색어 18 | 19 | ![1](https://user-images.githubusercontent.com/25934842/207813681-bade76c7-49f5-47dc-a712-4cb9dfb87cd9.gif) 20 | 21 | - 검색량이 많은 검색어를 1~10위까지 보여줍니다. 22 | - 검색어를 클릭하면 해당 키워드로 검색된 리스트 페이지로 이동합니다. 23 | 24 | #### 논문 검색 25 | 26 | ![2](https://user-images.githubusercontent.com/25934842/207813670-5dc3ed09-8d44-44ef-853e-20362fab92d1.gif) 27 | 28 | - 검색창에 포커스하면 최근 검색어 목록을 5개까지 보여줍니다. 29 | - 키워드를 2자이상 입력하면 자동완성 검색어 목록을 보여줍니다. 30 | - 저자, 제목, 키워드를 입력하여 검색버튼을 누르면 검색 리스트로 이동합니다. 31 | - DOI로 검색하면 바로 해당 논문의 시각화 페이지로 이동합니다. 32 | - 최근 검색어 목록이나 자동완성 검색어는 mouse-over, 방향키 이벤트로 커서를 이동시킬 수 있습니다. 33 | 34 | #### 논문 리스트 35 | 36 | ![3](https://user-images.githubusercontent.com/25934842/207813649-d23bc237-71da-48d7-98f1-6cf10b6139da.gif) 37 | 38 | - 키워드와 유사성이 높은 논문 목록을 보여줍니다. 39 | - 리스트는 20개 단위로 페이지네이션 됩니다. 40 | 41 | #### 논문 시각화 페이지 42 | 43 | ![4](https://user-images.githubusercontent.com/25934842/207815534-0b2cc38b-88cb-4ff6-af14-6ff48b50dee8.gif) 44 | 45 | - 좌측에서는 선택한 논문의 정보(제목, 저자, DOI, 인용논문 목록)을 보여줍니다. 46 | - 인용 논문 목록에 포함된 논문제목을 hovering하면 오른쪽 그래프에서 해당하는 논문node를 강조합니다. 47 | - 우측에서는 선택한 논문의 데이터로 시각화된 네트워크 차트를 보여줍니다. 48 | - 논문은 node, 논문간 인용관계는 line으로 표현됩니다. 49 | - 주위 node를 클릭하면 해당 논문node의 인용관계가 추가로 시각화 됩니다. 50 | - node에 호버링하면 해당 논문과 해당 논문이 인용한 논문들의 nodes, lines가 강조됩니다. 51 | - 마우스 드래그로 그래프 위치를 옮길 수 있습니다. 52 | - 스크롤로 그래프를 zoom-in, zoom-out 할 수 있습니다. 53 | 54 | ### 팀원 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 70 | 72 | 74 | 76 |
J053J073J143J205
김준엽 69 | 박미림 71 | 이성빈 73 | 최예윤 75 |
77 | 78 | ### 개발 환경 세팅 79 | 80 | > 환경변수는 `/frontend`, `/backend` 폴더에 있는 `.env.sample` 파일을 참고해주시기 바랍니다. 81 | 82 | #### Front-end 83 | 84 | ```bash 85 | cd frontend 86 | npm install 87 | npm start 88 | ``` 89 | 90 | #### Back-end 91 | 92 | ```bash 93 | cd backend 94 | npm install 95 | npm start 96 | ``` 97 | 98 | ## 기술스택 99 | ![techstack](https://user-images.githubusercontent.com/25934842/283773241-2f8a6c59-0f52-4425-9f29-c6b9ac8bb9ab.png) 100 | 101 | ## 데이터 수집 정책 102 | 103 | - PRV 서비스에서 사용되는 모든 논문 정보는 Crossref API를 통해 수집됩니다. 104 | - 사용자로부터 수집되는 정보는 다음과 같습니다. 105 | - 검색 키워드 106 | - 수집되는 정보는 다음과 같은 목적으로 이용합니다. 107 | - 인기 검색어 서비스 제공 108 | - 키워드 자동완성 검색 서비스 제공 109 | - 키워드 검색 서비스 제공 110 | - 논문 DOI를 통한 인용관계 시각화 서비스 제공 111 | - 사용자는 키워드 검색시 PRV 데이터베이스에 있는 정보 혹은 Crossref API를 통해 요청한 정보를 조회할 수 있으며, 데이터베이스에 없는 논문에 대한 데이터 수집은 Request batch에 의해 처리되므로 검색 결과를 즉시 받아보지 못할 수 있습니다. 112 | - Request batch에 의해 수집된 결과는 데이터베이스에 저장됩니다. 113 | - 추가 문의사항은 viewpoint.prv@gmail.com 로 연락바랍니다. 114 | 115 | ### [Crossref](https://www.crossref.org/) API 116 | 117 | - Crossref : Official digital object identifier Registration Agency of the International DOI Foundation. 118 | - 22.12.08. 기준 140,229,346개의 논문 메타데이터를 보유 중 119 | - License - Creative Commons Attribution 4.0 International (CC BY 4.0) 120 |

121 | 123 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /backend/.env.sample: -------------------------------------------------------------------------------- 1 | PORT= 2 | REDIS_POPULAR_KEY= 3 | REDIS_PREVRANKING= 4 | REDIS_HOST= 5 | REDIS_PORT= 6 | REDIS_PASSWORD= 7 | ELASTIC_INDEX= 8 | ELASTIC_HOST= 9 | ELASTIC_USER= 10 | ELASTIC_PASSWORD= 11 | MAIL_TO= 12 | ALLOW_UPDATE= 13 | SHOULD_RUN_BATCH= -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true, 14 | }, 15 | ignorePatterns: ['.eslintrc.js'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # env 38 | *.env -------------------------------------------------------------------------------- /backend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "printWidth": 120, 8 | "bracketSpacing": true, 9 | "useTabs": false 10 | } -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm ci 5 | COPY . . 6 | RUN npm run build 7 | CMD npm run start:prod -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:mac": "env $(cat be.env | grep -v '#' | xargs) npm start", 14 | "start:dev": "nest start --watch", 15 | "start:debug": "nest start --debug --watch", 16 | "start:prod": "node dist/main", 17 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 18 | "test": "jest", 19 | "test:watch": "jest --watch", 20 | "test:cov": "jest --coverage", 21 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 22 | "test:e2e": "jest --config ./test/jest-e2e.json" 23 | }, 24 | "dependencies": { 25 | "@elastic/elasticsearch": "^8.5.0", 26 | "@liaoliaots/nestjs-redis": "^9.0.4", 27 | "@nestjs/axios": "^1.0.0", 28 | "@nestjs/common": "^9.0.0", 29 | "@nestjs/config": "^2.2.0", 30 | "@nestjs/core": "^9.0.0", 31 | "@nestjs/platform-express": "^9.0.0", 32 | "@nestjs/schedule": "^2.1.0", 33 | "cache-manager": "^5.1.3", 34 | "class-transformer": "^0.5.1", 35 | "class-validator": "^0.13.2", 36 | "ioredis": "^5.2.4", 37 | "ioredis-mock": "^8.2.2", 38 | "reflect-metadata": "^0.1.13", 39 | "rimraf": "^3.0.2", 40 | "rxjs": "^7.2.0" 41 | }, 42 | "devDependencies": { 43 | "@nestjs/cli": "^9.0.0", 44 | "@nestjs/elasticsearch": "^9.0.0", 45 | "@nestjs/schematics": "^9.0.0", 46 | "@nestjs/swagger": "^6.1.3", 47 | "@nestjs/testing": "^9.0.0", 48 | "@types/cron": "^2.0.0", 49 | "@types/express": "^4.17.13", 50 | "@types/ioredis-mock": "^8.2.0", 51 | "@types/jest": "28.1.8", 52 | "@types/node": "^16.0.0", 53 | "@types/supertest": "^2.0.11", 54 | "@typescript-eslint/eslint-plugin": "^5.0.0", 55 | "@typescript-eslint/parser": "^5.0.0", 56 | "axios": "^1.1.3", 57 | "eslint": "^8.0.1", 58 | "eslint-config-prettier": "^8.3.0", 59 | "eslint-plugin-prettier": "^4.0.0", 60 | "jest": "28.1.3", 61 | "prettier": "^2.3.2", 62 | "redis-mock": "^0.56.3", 63 | "source-map-support": "^0.5.20", 64 | "supertest": "^6.1.3", 65 | "ts-jest": "28.0.8", 66 | "ts-loader": "^9.2.3", 67 | "ts-node": "^10.0.0", 68 | "tsconfig-paths": "4.1.0", 69 | "typescript": "^4.7.4" 70 | }, 71 | "jest": { 72 | "moduleFileExtensions": [ 73 | "js", 74 | "json", 75 | "ts" 76 | ], 77 | "roots": [ 78 | "" 79 | ], 80 | "modulePaths": [ 81 | "" 82 | ], 83 | "moduleDirectories": [ 84 | "node_modules" 85 | ], 86 | "testRegex": ".*\\.spec\\.ts$", 87 | "transform": { 88 | "^.+\\.(t|j)s$": "ts-jest" 89 | }, 90 | "collectCoverageFrom": [ 91 | "**/*.(t|j)s" 92 | ], 93 | "coverageDirectory": "../coverage", 94 | "testEnvironment": "node", 95 | "testTimeout": 50000 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RankingModule } from './ranking/ranking.module'; 3 | import { SearchModule } from './search/search.module'; 4 | 5 | @Module({ 6 | imports: [RankingModule, SearchModule], 7 | }) 8 | export class AppModule {} 9 | -------------------------------------------------------------------------------- /backend/src/batch/batch.config.ts: -------------------------------------------------------------------------------- 1 | export const TIME_INTERVAL = 1 * 1000; 2 | export const RESTART_INTERVAL = 2 * 1000 * 60; 3 | export const MAX_RETRY = 3; 4 | export const MAX_DEPTH = 2; 5 | export const SEARCH_BATCH_SIZE = 5; 6 | export const DOI_BATCH_SIZE = 20; 7 | export const DOI_REGEXP = new RegExp(/^[\d]{2}\.[\d]{1,}\/.*/); 8 | -------------------------------------------------------------------------------- /backend/src/batch/batch.queue.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | 3 | export class RedisQueue { 4 | constructor(private redis: Redis, public name: string) {} 5 | async push(value: string, pushLeft = false) { 6 | if (pushLeft) { 7 | await this.redis.lpush(this.name, value); 8 | } else { 9 | await this.redis.rpush(this.name, value); 10 | } 11 | 12 | return value; 13 | } 14 | async pop(count = 1) { 15 | return await this.redis.lpop(this.name, count); 16 | } 17 | async size() { 18 | return await this.redis.llen(this.name); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/batch/batch.service.ts: -------------------------------------------------------------------------------- 1 | import { InjectRedis } from '@liaoliaots/nestjs-redis'; 2 | import { HttpService } from '@nestjs/axios'; 3 | import { Interval } from '@nestjs/schedule'; 4 | import Redis from 'ioredis'; 5 | import { SHOULD_RUN_BATCH } from 'src/envLayer'; 6 | import { SearchService } from 'src/search/search.service'; 7 | import { TIME_INTERVAL, SEARCH_BATCH_SIZE, DOI_BATCH_SIZE } from './batch.config'; 8 | import { DoiBatcher } from './batcher.doi'; 9 | import { SearchBatcher } from './batcher.search'; 10 | 11 | export class BatchService { 12 | searchBatcher: SearchBatcher; 13 | doiBatcher: DoiBatcher; 14 | constructor( 15 | @InjectRedis() private readonly redis: Redis, 16 | private readonly httpService: HttpService, 17 | private readonly searchService: SearchService, 18 | ) { 19 | this.searchBatcher = new SearchBatcher( 20 | this.redis, 21 | this.httpService.axiosRef, 22 | this.searchService, 23 | 'url batch queue', 24 | ); 25 | this.doiBatcher = new DoiBatcher(this.redis, this.httpService.axiosRef, this.searchService, 'paper batch queue'); 26 | } 27 | keywordToRedisKey(keyword: string) { 28 | return `s:${keyword}`; 29 | } 30 | async keywordExist(keyword: string) { 31 | const lowercased = keyword.toLowerCase(); 32 | const key = this.keywordToRedisKey(lowercased); 33 | return (await this.redis.ttl(key)) >= 0; 34 | } 35 | async setKeyword(keyword: string) { 36 | if (!SHOULD_RUN_BATCH) return false; 37 | const lowercased = keyword.toLowerCase(); 38 | if (await this.keywordExist(lowercased)) return false; 39 | const key = this.keywordToRedisKey(lowercased); 40 | this.redis.set(key, 1); 41 | this.redis.expire(key, 60 * 60 * 24); 42 | return true; 43 | } 44 | 45 | @Interval(TIME_INTERVAL) 46 | batchSearchQueue(batchSize = SEARCH_BATCH_SIZE) { 47 | this.searchBatcher.runBatch(batchSize); 48 | } 49 | 50 | @Interval(TIME_INTERVAL) 51 | async batchDoiQueue(batchSize = DOI_BATCH_SIZE) { 52 | const referencesDoiWithDepth = await this.doiBatcher.runBatch(batchSize); 53 | referencesDoiWithDepth?.forEach((v) => { 54 | this.doiBatcher.pushToQueue(0, v.depth + 1, -1, false, v.doi); 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /backend/src/batch/batcher.abstract.ts: -------------------------------------------------------------------------------- 1 | import { GetGetResult } from '@elastic/elasticsearch/lib/api/types'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; 4 | import Redis from 'ioredis'; 5 | import { 6 | CrossRefItem, 7 | CrossRefPaperResponse, 8 | CrossRefResponse, 9 | PaperInfoDetail, 10 | ReferenceInfo, 11 | } from 'src/search/entities/crossRef.entity'; 12 | import { SearchService } from 'src/search/search.service'; 13 | import { ALLOW_UPDATE, SHOULD_RUN_BATCH } from 'src/envLayer'; 14 | import { MAX_DEPTH, RESTART_INTERVAL } from './batch.config'; 15 | import { RedisQueue } from './batch.queue'; 16 | 17 | export interface QueueItemParsed { 18 | retries: number; 19 | depth: number; 20 | pagesLeft: number; 21 | url: string; 22 | } 23 | export interface UrlParams { 24 | doi?: string; 25 | keyword?: string; 26 | cursor?: string; 27 | } 28 | 29 | type CrossRefAble = CrossRefResponse | CrossRefPaperResponse; 30 | 31 | @Injectable() 32 | export abstract class Batcher { 33 | static blocked = false; 34 | running = false; 35 | 36 | queue: RedisQueue; 37 | // failedQueue: RedisQueue; 38 | constructor( 39 | private readonly redis: Redis, 40 | private readonly axios: AxiosInstance, 41 | readonly searchService: SearchService, 42 | readonly name: string, 43 | ) { 44 | this.queue = new RedisQueue(this.redis, name); 45 | // this.failedQueue = new RedisQueue(this.redis, name); 46 | } 47 | abstract makeUrl(...params: string[]): string; 48 | abstract getParamsFromUrl(url: string): UrlParams; 49 | abstract onFulfilled( 50 | item: QueueItemParsed, 51 | params: UrlParams, 52 | res: AxiosResponse, 53 | i?: number, 54 | ): { papers: PaperInfoDetail[]; referenceDOIs: string[] }; // paper들의 reference들에 대한 doi 목록 55 | abstract onRejected( 56 | item: QueueItemParsed, 57 | params: UrlParams, 58 | shouldPushLeft?: boolean, 59 | res?: PromiseRejectedResult, 60 | i?: number, 61 | ): any; 62 | abstract validateBatchItem(item: QueueItemParsed): boolean; 63 | 64 | parseQueueItem(value: string) { 65 | const splits = value.split(':'); 66 | return { 67 | retries: parseInt(splits[0]), 68 | depth: parseInt(splits[1]), 69 | pagesLeft: parseInt(splits[2]), 70 | url: splits.slice(3).join(':'), 71 | } as QueueItemParsed; 72 | } 73 | 74 | pushToQueue(retries = 0, depth = 0, page = -1, shouldPushLeft = false, ...params: string[]) { 75 | if (!SHOULD_RUN_BATCH) return; 76 | const url = this.makeUrl(...params); 77 | this.queue.push(`${retries}:${depth}:${page}:${url}`, shouldPushLeft); 78 | } 79 | async batchLog(queue: RedisQueue, batched: string[]) { 80 | const queueSize = await queue.size(); 81 | const batchedSize = batched?.length || 0; 82 | (queueSize || batchedSize) && console.log(`${queue.name} size`, queueSize, ', batch size ', batchedSize); 83 | } 84 | fetchCrossRef(url: string) { 85 | return this.axios.get(url); 86 | } 87 | 88 | async runBatch(batchSize: number) { 89 | if (Batcher.blocked || this.running) return; 90 | const queue = this.queue; 91 | // const failedQueue = this.failedQueue; 92 | const batched = await queue.pop(batchSize); 93 | // await this.batchLog(queue, batched); 94 | if (!batched) return; 95 | this.running = true; 96 | 97 | const items = batched.map((item) => this.parseQueueItem(item)).filter((item) => this.validateBatchItem(item)); 98 | const responses = await this.batchRequest(items); 99 | const { papers, doiWithDepth } = this.responsesParser(items, responses); 100 | const bulkPapers = await this.makeBulkIndex(papers); 101 | this.doBulkInsert(bulkPapers); 102 | 103 | this.running = false; 104 | return doiWithDepth; 105 | } 106 | batchRequest(items: QueueItemParsed[]) { 107 | return Promise.allSettled(items.map((item) => this.fetchCrossRef(item.url))); 108 | } 109 | 110 | responsesParser(items: QueueItemParsed[], responses: PromiseSettledResult>[]) { 111 | return responses 112 | .map((res, i) => { 113 | const params = this.getParamsFromUrl(items[i].url); 114 | if (res.status === 'fulfilled') { 115 | return this.onFulfilled(items[i], params, res.value, i); 116 | } else { 117 | if (Batcher.blocked) { 118 | this.onRejected(items[i], params, true, res, i); 119 | return; 120 | } 121 | 122 | const error = res.reason as AxiosError; 123 | // Resource not found. 124 | if (error.response?.status === 404) { 125 | return; 126 | } 127 | 128 | // Too many request 129 | if (error.response?.status === 429) { 130 | this.stopBatch(); 131 | } 132 | 133 | // Timeout exceeded 134 | this.onRejected(items[i], params, false, res, i); 135 | } 136 | }) 137 | .reduce( 138 | (acc, cur, i) => { 139 | if (cur?.papers) Array.prototype.push.apply(acc.papers, cur.papers); 140 | if (cur?.referenceDOIs) { 141 | const doiWithDepth = cur.referenceDOIs.map((doi) => { 142 | return { doi, depth: items[i].depth }; 143 | }); 144 | Array.prototype.push.apply(acc.doiWithDepth, doiWithDepth); 145 | } 146 | return acc; 147 | }, 148 | { papers: [], doiWithDepth: [] as { doi: string; depth: number }[] }, 149 | ); 150 | } 151 | 152 | async makeBulkIndex(papers: PaperInfoDetail[]): Promise { 153 | if (ALLOW_UPDATE) return papers; 154 | const dois = papers.map((paper) => { 155 | return paper.doi; 156 | }); 157 | const { docs } = await this.searchService.multiGet(dois); 158 | const indexes = docs 159 | .map((doc, i) => { 160 | if ((doc as GetGetResult).found) return; 161 | return papers[i]; 162 | }) 163 | .filter(Boolean); 164 | 165 | // console.log(`${this.queue.name} skipped papers:`, papers.length - indexes.length); 166 | return indexes; 167 | } 168 | doBulkInsert(papers: PaperInfoDetail[]) { 169 | return this.searchService.bulkInsert(papers); 170 | } 171 | 172 | getPapersReferences(item: CrossRefItem, depth: number) { 173 | const hasHope = this.paperHasInformation(item); 174 | const dois: string[] = []; 175 | if (hasHope && depth + 1 < MAX_DEPTH) { 176 | item.reference?.forEach((ref) => { 177 | // doi가 있는 reference는 다음 paperQueue에 집어넣는다. 178 | if (this.referenceHasInformation(ref)) { 179 | dois.push(ref.DOI); 180 | } 181 | }); 182 | } 183 | return dois; 184 | } 185 | 186 | paperHasInformation(paper: CrossRefItem) { 187 | // DOI와 제목이 있는 논문만 db에 저장한다. 188 | return paper.DOI && paper.title; 189 | } 190 | 191 | referenceHasInformation(reference: ReferenceInfo) { 192 | return reference['DOI']; 193 | } 194 | 195 | stopBatch() { 196 | Batcher.blocked = true; 197 | console.log(`${new Date()} Too many request.`); 198 | setTimeout(() => { 199 | Batcher.blocked = false; 200 | console.log(`${new Date()} Batch Restarted`); 201 | }, RESTART_INTERVAL); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /backend/src/batch/batcher.doi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance, AxiosResponse } from 'axios'; 2 | import Redis from 'ioredis'; 3 | import { CrossRefPaperResponse, PaperInfoDetail } from 'src/search/entities/crossRef.entity'; 4 | import { SearchService } from 'src/search/search.service'; 5 | import { CROSSREF_API_PAPER_URL } from 'src/util'; 6 | import { DOI_REGEXP, MAX_RETRY } from './batch.config'; 7 | import { Batcher, UrlParams, QueueItemParsed } from './batcher.abstract'; 8 | 9 | export class DoiBatcher extends Batcher { 10 | constructor(redis: Redis, axios: AxiosInstance, searchService: SearchService, name: string) { 11 | super(redis, axios, searchService, name); 12 | } 13 | makeUrl(doi: string) { 14 | return CROSSREF_API_PAPER_URL(doi); 15 | } 16 | getParamsFromUrl(url: string): UrlParams { 17 | const u = new URL(url); 18 | const doi = u.pathname.replace(/\/works\//, ''); 19 | return { doi }; 20 | } 21 | validateBatchItem(item: QueueItemParsed): boolean { 22 | const { doi } = this.getParamsFromUrl(item.url); 23 | // DOI 대문자일 경우 검색 안 되는 경우 발생 24 | item.url = item.url.toLowerCase(); 25 | return DOI_REGEXP.test(doi); 26 | } 27 | onFulfilled( 28 | item: QueueItemParsed, 29 | params: UrlParams, 30 | res: AxiosResponse, 31 | ): { papers: PaperInfoDetail[]; referenceDOIs: string[] } { 32 | const paper = res.data.message; 33 | const { depth } = item; 34 | const referenceDOIs = this.getPapersReferences(paper, depth); 35 | const p = this.searchService.parsePaperInfoDetail(paper); 36 | return { papers: [p], referenceDOIs }; 37 | } 38 | 39 | onRejected(item: QueueItemParsed, params: UrlParams, shouldPushLeft: boolean) { 40 | const { doi } = params; 41 | if (item.retries + 1 > MAX_RETRY) { 42 | // this.failedQueue.push(item.url); 43 | return; 44 | } 45 | console.log('error', item.url); 46 | item.retries++; 47 | this.pushToQueue(item.retries + 1, item.depth, item.pagesLeft - 1, shouldPushLeft, doi); 48 | return; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backend/src/batch/batcher.search.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance, AxiosResponse } from 'axios'; 2 | import Redis from 'ioredis'; 3 | import { CrossRefResponse } from 'src/search/entities/crossRef.entity'; 4 | import { SearchService } from 'src/search/search.service'; 5 | import { CROSSREF_API_URL_CURSOR, MAX_ROWS } from 'src/util'; 6 | import { MAX_RETRY } from './batch.config'; 7 | import { Batcher, QueueItemParsed, UrlParams } from './batcher.abstract'; 8 | 9 | export class SearchBatcher extends Batcher { 10 | constructor(redis: Redis, axios: AxiosInstance, searchService: SearchService, name: string) { 11 | super(redis, axios, searchService, name); 12 | } 13 | 14 | makeUrl(keyword: string, cursor: string) { 15 | return CROSSREF_API_URL_CURSOR(keyword, cursor); 16 | } 17 | 18 | getParamsFromUrl(url: string): UrlParams { 19 | const u = new URL(url); 20 | const params = new URLSearchParams(u.search); 21 | const keyword = params.get('query'); 22 | const cursor = params.get('cursor') || '*'; 23 | return { keyword, cursor }; 24 | } 25 | validateBatchItem(item: QueueItemParsed): boolean { 26 | return true; 27 | } 28 | onFulfilled(item: QueueItemParsed, params: UrlParams, res: AxiosResponse) { 29 | const { cursor: presentCursor, keyword } = params; 30 | if (presentCursor === '*') { 31 | const cursor = res.data.message['next-cursor']; 32 | const maxPage = Math.floor(res.data.message['total-results'] / MAX_ROWS); 33 | this.pushToQueue(0, item.depth + 1, maxPage, true, keyword, cursor); 34 | } else if (item.pagesLeft > 0) { 35 | this.pushToQueue(0, item.depth, item.pagesLeft - 1, false, keyword, presentCursor); 36 | } 37 | const referenceDOIs = res.data.message.items.flatMap((paper) => { 38 | return this.getPapersReferences(paper, item.depth); 39 | }); 40 | const papers = res.data.message.items.map((paper) => { 41 | return this.searchService.parsePaperInfoDetail(paper); 42 | }); 43 | return { papers, referenceDOIs }; 44 | } 45 | 46 | onRejected(item: QueueItemParsed, params: UrlParams, shouldPushLeft: boolean) { 47 | const { keyword, cursor } = params; 48 | if (item.retries + 1 > MAX_RETRY) { 49 | // this.failedQueue.push(item.url); 50 | return; 51 | } 52 | console.log('error', item.url); 53 | item.retries++; 54 | this.pushToQueue(item.retries + 1, item.depth, item.pagesLeft - 1, shouldPushLeft, keyword, cursor); 55 | return; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /backend/src/batch/tests/batch.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { SearchService } from 'src/search/search.service'; 2 | import { mockHttpService, mockElasticService } from 'src/search/tests/search.service.mock'; 3 | import { BatchService } from '../batch.service'; 4 | import { mockRedisQueue, popDoiItem } from './batcher.mock'; 5 | 6 | describe('doiBatcher', () => { 7 | let service: BatchService; 8 | 9 | beforeEach(() => { 10 | const httpService = mockHttpService(); 11 | const elasticService = mockElasticService(); 12 | service = new BatchService(mockRedisQueue(popDoiItem), httpService, new SearchService(elasticService, httpService)); 13 | }); 14 | it('run doi batch', async () => { 15 | const doiBatcher__runBatch = jest.spyOn(service.doiBatcher, 'runBatch'); 16 | const doiBatcher__pushToQueue = jest.spyOn(service.doiBatcher, 'pushToQueue'); 17 | 18 | await service.batchDoiQueue(10); 19 | expect(doiBatcher__runBatch).toBeCalledTimes(1); 20 | expect(doiBatcher__pushToQueue).toBeCalledTimes(20); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /backend/src/batch/tests/batcher.mock.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | 3 | export function mockRedisQueue(popItem: () => string) { 4 | const lpush = jest.fn().mockResolvedValue(true); 5 | const rpush = jest.fn().mockResolvedValue(true); 6 | const lpop = jest 7 | .fn() 8 | .mockImplementation((key: string, size: number) => Array.from({ length: size }, () => popItem())); 9 | const llen = jest.fn().mockResolvedValue(10); 10 | return { 11 | lpush, 12 | rpush, 13 | lpop, 14 | llen, 15 | } as unknown as Redis; 16 | } 17 | 18 | export const popDoiItem = () => `0:0:-1:https://api.crossref.org/works/10.1234/asdf`; 19 | export const popSearchItem = () => `0:0:-1:https://api.crossref.org/works?query=keyword&rows=1000&cursor=examplecursor`; 20 | -------------------------------------------------------------------------------- /backend/src/batch/tests/batcher.spec.ts: -------------------------------------------------------------------------------- 1 | import { SearchService } from 'src/search/search.service'; 2 | import { mockHttpService, mockElasticService } from 'src/search/tests/search.service.mock'; 3 | import { DOI_REGEXP } from '../batch.config'; 4 | import { DoiBatcher } from '../batcher.doi'; 5 | import { SearchBatcher } from '../batcher.search'; 6 | import { mockRedisQueue, popDoiItem, popSearchItem } from './batcher.mock'; 7 | 8 | describe('doiBatcher', () => { 9 | let batcher: DoiBatcher; 10 | 11 | beforeEach(async () => { 12 | const redisService = mockRedisQueue(popDoiItem); 13 | const httpService = mockHttpService(); 14 | const elasticService = mockElasticService(); 15 | batcher = new DoiBatcher( 16 | redisService, 17 | httpService.axiosRef, 18 | new SearchService(elasticService, httpService), 19 | 'doi batcher', 20 | ); 21 | }); 22 | it('getParamsFromUrl', async () => { 23 | const items = await batcher.queue.pop(1); 24 | const { url } = batcher.parseQueueItem(items[0]); 25 | const { doi } = batcher.getParamsFromUrl(url); 26 | expect(DOI_REGEXP.test(doi)).toBe(true); 27 | }); 28 | }); 29 | 30 | describe('searchBatcher', () => { 31 | let batcher: SearchBatcher; 32 | 33 | beforeEach(async () => { 34 | const redisService = mockRedisQueue(popSearchItem); 35 | const httpService = mockHttpService(); 36 | const elasticService = mockElasticService(); 37 | batcher = new SearchBatcher( 38 | redisService, 39 | httpService.axiosRef, 40 | new SearchService(elasticService, httpService), 41 | 'doi batcher', 42 | ); 43 | }); 44 | it('getParamsFromUrl', async () => { 45 | const items = await batcher.queue.pop(1); 46 | const { url } = batcher.parseQueueItem(items[0]); 47 | const { keyword, cursor } = batcher.getParamsFromUrl(url); 48 | expect(typeof keyword).toBe('string'); 49 | expect(typeof cursor).toBe('string'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /backend/src/envLayer.ts: -------------------------------------------------------------------------------- 1 | export const PORT = process.env.PORT || 4000; 2 | export const REDIS_POPULAR_KEY = process.env.REDIS_POPULAR_KEY; 3 | export const REDIS_PREVRANKING = process.env.REDIS_PREVRANKING; 4 | export const REDIS_HOST = process.env.REDIS_HOST || 'localhost'; 5 | export const REDIS_PORT = process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT, 10) : 6379; 6 | export const REDIS_PASSWORD = process.env.REDIS_PASSWORD; 7 | export const ELASTIC_INDEX = process.env.ELASTIC_INDEX; 8 | export const ELASTIC_HOST = process.env.ELASTIC_HOST; 9 | export const ELASTIC_USER = process.env.ELASTIC_USER; 10 | export const ELASTIC_PASSWORD = process.env.ELASTIC_PASSWORD; 11 | export const MAIL_TO = process.env.MAIL_TO; 12 | export const ALLOW_UPDATE = process.env.ALLOW_UPDATE ? (eval(process.env.ALLOW_UPDATE) as boolean) : false; 13 | export const SHOULD_RUN_BATCH = process.env.SHOULD_RUN_BATCH ? (eval(process.env.SHOULD_RUN_BATCH) as boolean) : true; 14 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 5 | import { PORT } from './envLayer'; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | app.useGlobalPipes(new ValidationPipe()); 10 | app.enableCors(); 11 | const config = new DocumentBuilder() 12 | .setTitle('PRV API Documentation') 13 | .setDescription('논문 간 인용관계 시각화 서비스 API 문서') 14 | .setVersion('1.0.0') 15 | .addTag('prv') 16 | .build(); 17 | const document = SwaggerModule.createDocument(app, config); 18 | SwaggerModule.setup('api', app, document); 19 | await app.listen(PORT); 20 | } 21 | bootstrap(); 22 | -------------------------------------------------------------------------------- /backend/src/ranking/entities/ranking.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | 3 | export class RankingDto {} 4 | -------------------------------------------------------------------------------- /backend/src/ranking/entities/ranking.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | export class Ranking { 3 | @ApiProperty() 4 | keyword: string; 5 | 6 | @ApiProperty() 7 | changeRanking: number; 8 | } 9 | export class redisRanking { 10 | keyword: string; 11 | count: number; 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/ranking/ranking.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common'; 2 | import { RankingService } from './ranking.service'; 3 | import { ApiResponse, ApiRequestTimeoutResponse } from '@nestjs/swagger'; 4 | import { Ranking } from './entities/ranking.entity'; 5 | 6 | @Controller('keyword-ranking') 7 | export class RankingController { 8 | constructor(private readonly rankingService: RankingService) {} 9 | @ApiResponse({ status: 200, description: '검색 결과', type: Ranking, isArray: true }) 10 | @ApiRequestTimeoutResponse({ description: '검색 timeout' }) 11 | @Get() 12 | async getTen() { 13 | return await this.rankingService.getTen(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/ranking/ranking.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RedisModule } from '@liaoliaots/nestjs-redis'; 3 | import { RankingController } from './ranking.controller'; 4 | import { RankingService } from './ranking.service'; 5 | import { ConfigModule } from '@nestjs/config'; 6 | import { ScheduleModule } from '@nestjs/schedule'; 7 | import { REDIS_HOST, REDIS_PASSWORD, REDIS_PORT } from 'src/envLayer'; 8 | 9 | @Module({ 10 | imports: [ 11 | ConfigModule.forRoot({ isGlobal: true, envFilePath: `.dev.env` }), 12 | RedisModule.forRoot({ 13 | config: { 14 | host: REDIS_HOST, 15 | port: REDIS_PORT, 16 | password: REDIS_PASSWORD, 17 | }, 18 | }), 19 | ScheduleModule.forRoot(), 20 | ], 21 | controllers: [RankingController], 22 | providers: [RankingService], 23 | }) 24 | export class RankingModule {} 25 | -------------------------------------------------------------------------------- /backend/src/ranking/ranking.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { InjectRedis } from '@liaoliaots/nestjs-redis'; 3 | import Redis from 'ioredis'; 4 | import { Ranking } from './entities/ranking.entity'; 5 | import { Interval } from '@nestjs/schedule'; 6 | import { urlRegex } from 'src/util'; 7 | import { REDIS_POPULAR_KEY, REDIS_PREVRANKING } from 'src/envLayer'; 8 | 9 | @Injectable() 10 | export class RankingService { 11 | constructor(@InjectRedis() private readonly redis: Redis) {} 12 | async getTen() { 13 | const redisSearchData = await this.redis.zrevrangebyscore(REDIS_POPULAR_KEY, '+inf', 1); 14 | const topTen = redisSearchData.slice(0, 10); 15 | const result: Ranking[] = []; 16 | await Promise.all( 17 | topTen.map(async (v, i) => { 18 | const tmp: Ranking = { keyword: '', changeRanking: 0 }; 19 | tmp.keyword = v; 20 | const prevrank = await this.redis.zscore(REDIS_PREVRANKING, v); 21 | prevrank ? (tmp.changeRanking = Number(prevrank) - i) : (tmp.changeRanking = null); 22 | result.push(tmp); 23 | }), 24 | ); 25 | return result; 26 | } 27 | async insertRedis(data: string) { 28 | if (data === '' || data.length < 2) return; 29 | if (data.match(urlRegex)) return; 30 | const encodeData = decodeURI(data); 31 | try { 32 | const isRanking: string = await this.redis.zscore(REDIS_POPULAR_KEY, encodeData); 33 | isRanking 34 | ? await this.redis.zadd(REDIS_POPULAR_KEY, Number(isRanking) + 1, encodeData) 35 | : await this.redis.zadd(REDIS_POPULAR_KEY, 1, encodeData); 36 | } catch (error) { 37 | throw new HttpException('Internal Server Error', HttpStatus.INTERNAL_SERVER_ERROR); 38 | } 39 | } 40 | @Interval('update-ranking', 600000) 41 | async updateRanking() { 42 | const redisSearchData = await this.redis.zrevrangebyscore(REDIS_POPULAR_KEY, '+inf', 1); 43 | const topTen = redisSearchData.slice(0, 100); 44 | await this.redis.del(REDIS_PREVRANKING); 45 | await Promise.all( 46 | topTen.map(async (v, i) => { 47 | await this.redis.zadd(REDIS_PREVRANKING, i, v); 48 | }), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/ranking/tests/ranking.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { RedisModule } from '@liaoliaots/nestjs-redis'; 2 | import { BadRequestException, INestApplication } from '@nestjs/common'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { Test, TestingModule } from '@nestjs/testing'; 5 | import e from 'express'; 6 | import { RankingController } from '../ranking.controller'; 7 | import { RankingService } from '../ranking.service'; 8 | import { mockRedisService } from './ranking.service.mock'; 9 | describe('RankingServiceTest', () => { 10 | let controller: RankingController; 11 | let service: RankingService; 12 | beforeEach(async () => { 13 | const rankingService = mockRedisService(); 14 | const module: TestingModule = await Test.createTestingModule({ 15 | controllers: [RankingController], 16 | providers: [RankingService, { provide: RankingService, useValue: rankingService }], 17 | }).compile(); 18 | controller = module.get(RankingController); 19 | service = module.get(RankingService); 20 | }); 21 | describe('/keyword-ranking', () => { 22 | it('redis date가 10개 이하인 경우', async () => { 23 | //Case 1. redis date가 10개 이하인 경우 24 | const topTen = await service.getTen(); 25 | expect(topTen.length).toBeLessThanOrEqual(10); 26 | }); 27 | it('데이터 삽입 후 topTen 체크', async () => { 28 | //Case 2. 데이터 삽입 후 topTen 체크 29 | const flag = await service.insertRedis('9번째 데이터'); 30 | expect(flag).toBe('new'); 31 | const topTen = await controller.getTen(); 32 | expect(topTen.length).toBe(9); 33 | const flag2 = await service.insertRedis('10번째 데이터'); 34 | expect(flag2).toBe('new'); 35 | const topTen2 = await controller.getTen(); 36 | expect(topTen2.length).toBe(10); 37 | }); 38 | it('2위인 "사랑해요" 데이터가 한번 더 검색시 1위로 업데이트', async () => { 39 | //Case 3. 2위인 "사랑해요" 데이터가 한번 더 검색시 1위로 업데이트 40 | const flag = await service.insertRedis('사랑해요'); 41 | expect(flag).toBe('update'); 42 | const topTen = await controller.getTen(); 43 | expect(topTen[0].keyword).toBe('부스트캠프'); 44 | }); 45 | }); 46 | describe('/keyword-ranking/insert', () => { 47 | // Case1. 기존 redis에 없던 데이터 삽입 48 | it('기존 redis에 없던 데이터 삽입', async () => { 49 | const result = await service.insertRedis('newData'); 50 | expect(result).toBe('new'); 51 | }); 52 | // Case2. 기존 redis에 있던 데이터 삽입 53 | it('기존 redis에 있던 데이터 삽입', async () => { 54 | const result = await service.insertRedis('부스트캠프'); 55 | expect(result).toBe('update'); 56 | }); 57 | //Case3. redis에 빈 검색어 입력 58 | it('빈 검색어 redis에 삽입', async () => { 59 | await expect(service.insertRedis('')).rejects.toEqual( 60 | new BadRequestException({ status: 400, error: 'bad request' }), 61 | ); 62 | }); 63 | //Case4. insert 실패시 타임 아웃 TimeOut 64 | it('insert 실패시 타임 아웃 TimeOut', async () => { 65 | await expect(service.insertRedis('')).rejects.toEqual( 66 | new BadRequestException({ status: 400, error: 'bad request' }), 67 | ); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /backend/src/ranking/tests/ranking.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | import { redisRanking } from '../entities/ranking.entity'; 3 | import redisMockData from './rankingData.mock'; 4 | export function mockRedisService() { 5 | const insertRedis = jest.fn(async (keyword: string) => { 6 | if (keyword === '') throw new BadRequestException({ status: 400, error: 'bad request' }); 7 | for (const data of redisMockData) { 8 | if (data.keyword === keyword) { 9 | data[keyword]++; 10 | redisMockData.sort(compare); 11 | return 'update'; 12 | } 13 | } 14 | redisMockData.push({ keyword: keyword, count: 1 }); 15 | redisMockData.sort(compare); 16 | return 'new'; 17 | }); 18 | const getTen = jest.fn().mockResolvedValue(redisMockData); 19 | const redisService = { insertRedis, getTen }; 20 | return redisService; 21 | } 22 | 23 | function compare(a: redisRanking, b: redisRanking) { 24 | if (a.count < b.count) return 1; 25 | else if (a.count > b.count) return -1; 26 | return 0; 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/ranking/tests/rankingData.mock.ts: -------------------------------------------------------------------------------- 1 | import { redisRanking } from '../entities/ranking.entity'; 2 | 3 | export default [ 4 | { 5 | keyword: '부스트캠프', 6 | count: 150, 7 | }, 8 | { 9 | keyword: '사랑해요', 10 | count: 150, 11 | }, 12 | { 13 | keyword: 'boostcamp', 14 | count: 130, 15 | }, 16 | { 17 | keyword: 'web, mobile', 18 | count: 129, 19 | }, 20 | { 21 | keyword: 'ios', 22 | count: 40, 23 | }, 24 | { 25 | keyword: 'android', 26 | count: 37, 27 | }, 28 | { 29 | keyword: 'black hole', 30 | count: 23, 31 | }, 32 | { 33 | keyword: 'stone', 34 | count: 12, 35 | }, 36 | ] as redisRanking[]; 37 | -------------------------------------------------------------------------------- /backend/src/search/entities/crossRef.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 2 | export class PaperInfo { 3 | @ApiProperty() 4 | title: string; 5 | 6 | @ApiPropertyOptional() 7 | authors?: string[]; 8 | 9 | @ApiProperty() 10 | doi?: string; 11 | 12 | @ApiProperty() 13 | key?: string; 14 | 15 | constructor(body: PaperInfo) { 16 | this.title = body.title; 17 | this.authors = body.authors; 18 | this.doi = this.key = body.doi.toLowerCase(); 19 | } 20 | } 21 | export class PaperInfoExtended extends PaperInfo { 22 | @ApiPropertyOptional() 23 | publishedAt?: string; 24 | 25 | @ApiPropertyOptional() 26 | citations?: number; 27 | 28 | @ApiPropertyOptional() 29 | references?: number; 30 | 31 | constructor(body: PaperInfoExtended) { 32 | super(body); 33 | this.publishedAt = body.publishedAt; 34 | this.citations = body.citations; 35 | this.references = body.references; 36 | } 37 | } 38 | export class PaperInfoDetail extends PaperInfoExtended { 39 | @ApiPropertyOptional({ type: PaperInfoExtended, isArray: true }) 40 | referenceList?: PaperInfoExtended[]; 41 | 42 | constructor(body: PaperInfoDetail) { 43 | super(body); 44 | this.referenceList = body.referenceList; 45 | } 46 | } 47 | export class ReferenceInfo { 48 | issn?: string; 49 | 'standards-body'?: string; 50 | issue?: string; 51 | key?: string; 52 | 'series-title'?: string; 53 | 'isbn-type'?: string; 54 | 'doi-asserted-by'?: string; 55 | 'first-page'?: string; 56 | isbn?: string; 57 | DOI?: string; 58 | component?: string; 59 | 'article-title'?: string; 60 | 'volume-title'?: string; 61 | volume?: string; 62 | author?: string; 63 | 'standard-designator'?: string; 64 | year?: string; 65 | unstructured?: string; 66 | edition?: string; 67 | 'journal-title'?: string; 68 | 'issn-type'?: string; 69 | } 70 | export interface CrossRefResponse { 71 | message: { 72 | 'next-cursor'?: string; 73 | 'total-results': number; 74 | items: CrossRefItem[]; 75 | }; 76 | } 77 | 78 | export interface CrossRefItem { 79 | title?: string[]; 80 | author?: { 81 | given?: string; 82 | family?: string; 83 | name?: string; 84 | }[]; 85 | DOI?: string; 86 | created?: { 87 | 'date-time': string; 88 | }; 89 | 'is-referenced-by-count'?: number; 90 | 'reference-count'?: number; 91 | reference?: ReferenceInfo[]; 92 | } 93 | export interface CrossRefPaperResponse { 94 | message: CrossRefItem & { 'next-cursor'?: string; 'total-results'?: number }; 95 | } 96 | -------------------------------------------------------------------------------- /backend/src/search/entities/search.dto.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-inferrable-types */ 2 | import { Transform } from 'class-transformer'; 3 | import { IsOptional, IsPositive, IsString, Matches, MinLength } from 'class-validator'; 4 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 5 | import { DOI_REGEXP } from 'src/batch/batch.config'; 6 | 7 | export class SearchDto { 8 | @ApiProperty({ 9 | example: 'einstein', 10 | description: '검색 키워드 (2글자 이상)', 11 | required: true, 12 | }) 13 | @Transform((params) => encodeURI(params.value).trim()) 14 | @IsString() 15 | @MinLength(2) 16 | keyword: string; 17 | 18 | @ApiPropertyOptional({ 19 | example: 20, 20 | description: '페이지별 검색결과 갯수', 21 | default: 20, 22 | }) 23 | @IsOptional() 24 | @Transform((params) => parseInt(params.value)) 25 | @IsPositive() 26 | rows: number = 20; 27 | 28 | @ApiPropertyOptional({ 29 | example: 1, 30 | description: '페이지 번호', 31 | default: 1, 32 | }) 33 | @IsOptional() 34 | @Transform((params) => parseInt(params.value)) 35 | @IsPositive() 36 | page: number = 1; 37 | } 38 | export class AutoCompleteDto { 39 | @ApiProperty({ 40 | example: 'einstein', 41 | description: '검색 키워드 (2글자 이상)', 42 | }) 43 | @IsString() 44 | @MinLength(2) 45 | keyword: string; 46 | } 47 | export class GetPaperDto { 48 | @ApiProperty({ 49 | example: '10.1234/qwer.asdf', 50 | description: '논문의 DOI', 51 | }) 52 | @Matches(DOI_REGEXP, { message: 'DOI 형식이 올바르지 않습니다.' }) 53 | @Transform((params) => params.value.toLowerCase()) 54 | doi: string; 55 | } 56 | -------------------------------------------------------------------------------- /backend/src/search/search.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, NotFoundException, Query, UsePipes, ValidationPipe } from '@nestjs/common'; 2 | import { SearchService } from './search.service'; 3 | import { AutoCompleteDto, GetPaperDto, SearchDto } from './entities/search.dto'; 4 | import { GetGetResult, SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'; 5 | import { RankingService } from 'src/ranking/ranking.service'; 6 | import { BatchService } from 'src/batch/batch.service'; 7 | import { ApiResponse, ApiRequestTimeoutResponse, ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger'; 8 | import { PaperInfo, PaperInfoDetail, PaperInfoExtended } from './entities/crossRef.entity'; 9 | 10 | @Controller('search') 11 | export class SearchController { 12 | constructor( 13 | private readonly searchService: SearchService, 14 | private readonly rankingService: RankingService, 15 | private readonly batchService: BatchService, 16 | ) {} 17 | 18 | @ApiResponse({ status: 200, description: '자동검색 성공', type: PaperInfo, isArray: true }) 19 | @ApiRequestTimeoutResponse({ description: '검색 timeout' }) 20 | @ApiBadRequestResponse({ description: '유효하지 않은 키워드' }) 21 | @ApiNotFoundResponse({ description: '검색 결과가 존재하지 않습니다. 정보를 수집중입니다.' }) 22 | @Get('auto-complete') 23 | @UsePipes(new ValidationPipe({ transform: true })) 24 | async getAutoCompletePapers(@Query() query: AutoCompleteDto) { 25 | const { keyword } = query; 26 | const data = await this.searchService.getElasticSearch(keyword); 27 | const papers = data.hits.hits.map((paper) => new PaperInfo(paper._source)); 28 | return papers; 29 | } 30 | 31 | @ApiResponse({ status: 200, description: '검색 결과', type: PaperInfoExtended, isArray: true }) 32 | @ApiRequestTimeoutResponse({ description: '검색 timeout' }) 33 | @ApiBadRequestResponse({ description: '유효하지 않은 keyword | rows | page' }) 34 | @ApiNotFoundResponse({ description: '검색 결과가 존재하지 않습니다. 정보를 수집중입니다.' }) 35 | @Get() 36 | @UsePipes(new ValidationPipe({ transform: true })) 37 | async getPapers(@Query() query: SearchDto) { 38 | const { keyword, rows, page } = query; 39 | const data = await this.searchService.getElasticSearch(keyword, rows, rows * (page - 1)); 40 | const totalItems = (data.hits.total as SearchTotalHits).value; 41 | const totalPages = Math.ceil(totalItems / rows); 42 | if (page > totalPages && totalPages !== 0) { 43 | throw new NotFoundException(`page(${page})는 ${totalPages} 보다 클 수 없습니다.`); 44 | } 45 | this.rankingService.insertRedis(keyword); 46 | const keywordHasSet = await this.batchService.setKeyword(keyword); 47 | if (keywordHasSet) this.batchService.searchBatcher.pushToQueue(0, 0, -1, true, keyword); 48 | 49 | // Elasticsearch 검색 결과가 없을 경우, Crossref 검색 50 | if (totalItems === 0) { 51 | const { items: papers, totalItems } = await this.searchService.getPapersFromCrossref(keyword, rows, page); 52 | return { 53 | papers, 54 | pageInfo: { 55 | totalItems, 56 | totalPages: Math.ceil(totalItems / rows), 57 | }, 58 | }; 59 | } 60 | 61 | const papers = data.hits.hits.map((paper) => new PaperInfoExtended(paper._source)); 62 | return { 63 | papers, 64 | pageInfo: { 65 | totalItems, 66 | totalPages, 67 | }, 68 | }; 69 | } 70 | 71 | @ApiResponse({ status: 200, description: '논문 상세정보 검색 결과', type: PaperInfoDetail }) 72 | @ApiRequestTimeoutResponse({ description: '검색 timeout' }) 73 | @ApiBadRequestResponse({ description: '유효하지 않은 doi' }) 74 | @ApiNotFoundResponse({ description: '해당 doi는 존재하지 않습니다. 정보를 수집중입니다.' }) 75 | @Get('paper') 76 | @UsePipes(new ValidationPipe({ transform: true })) 77 | async getPaper(@Query() query: GetPaperDto) { 78 | const { doi } = query; 79 | 80 | const keywordHasSet = await this.batchService.setKeyword(doi); 81 | if (keywordHasSet) this.batchService.doiBatcher.pushToQueue(0, 0, -1, false, doi); 82 | 83 | const paper = await this.searchService.getPaper(doi); 84 | try { 85 | if (paper) { 86 | const origin = new PaperInfoDetail(paper._source); 87 | // 기존에 넣어놨던 데이터에 referenceList라는 key가 없을 수 있다.. 88 | if (!origin.referenceList?.length) { 89 | this.batchService.doiBatcher.pushToQueue(0, 1, -1, false, origin.doi || origin.key); 90 | throw new Error('SHOULD_CALL_CROSSREF'); 91 | // return { ...origin, referenceList: [] }; 92 | } 93 | 94 | const references = await this.searchService.multiGet( 95 | origin.referenceList.map((ref) => ref.key).filter(Boolean), 96 | ); 97 | 98 | const referenceList = references.docs.map((doc) => { 99 | const _source = (doc as GetGetResult)._source; 100 | if (!!_source.title || !!_source.authors?.length) throw new Error('SHOULD_CALL_CROSSREF'); 101 | return { key: doc._id, ..._source }; 102 | }); 103 | if (referenceList.length !== origin.references) throw new Error('SHOULD_CALL_CROSSREF'); 104 | return { ...origin, referenceList }; 105 | } 106 | throw new Error('SHOULD_CALL_CROSSREF'); 107 | } catch (err) { 108 | const res = await this.searchService.getPaperFromCrossref(doi); 109 | return res; 110 | // throw new NotFoundException('해당 doi는 존재하지 않습니다. 정보를 수집중입니다.'); 111 | } 112 | } 113 | 114 | @Get('stat') 115 | async getStats() { 116 | const es = await this.searchService.esStat(); 117 | const searchBatch = await this.batchService.searchBatcher.queue.size(); 118 | const doiBatch = await this.batchService.doiBatcher.queue.size(); 119 | return { 120 | es, 121 | searchBatch, 122 | doiBatch, 123 | }; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /backend/src/search/search.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SearchController } from './search.controller'; 3 | import { SearchService } from './search.service'; 4 | import { HttpModule } from '@nestjs/axios'; 5 | import { ElasticsearchModule } from '@nestjs/elasticsearch'; 6 | import { HttpConnection } from '@elastic/elasticsearch'; 7 | import { ScheduleModule } from '@nestjs/schedule'; 8 | import { RankingService } from 'src/ranking/ranking.service'; 9 | import { BatchService } from 'src/batch/batch.service'; 10 | import { MAIL_TO, ELASTIC_HOST, ELASTIC_USER, ELASTIC_PASSWORD } from 'src/envLayer'; 11 | @Module({ 12 | imports: [ 13 | HttpModule.register({ 14 | timeout: 1000 * 60, 15 | headers: { 16 | 'User-Agent': `Axios/1.1.3(mailto:${MAIL_TO})`, 17 | }, 18 | }), 19 | ElasticsearchModule.registerAsync({ 20 | useFactory: () => ({ 21 | node: ELASTIC_HOST, 22 | headers: { 23 | Accept: 'application/json', 24 | 'Content-Type': 'application/json', 25 | }, 26 | auth: { 27 | username: ELASTIC_USER, 28 | password: ELASTIC_PASSWORD, 29 | }, 30 | Connection: HttpConnection, 31 | }), 32 | }), 33 | ScheduleModule.forRoot(), 34 | ], 35 | controllers: [SearchController], 36 | providers: [SearchService, RankingService, BatchService], 37 | }) 38 | export class SearchModule {} 39 | -------------------------------------------------------------------------------- /backend/src/search/search.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException, RequestTimeoutException } from '@nestjs/common'; 2 | import { 3 | CrossRefItem, 4 | PaperInfoExtended, 5 | PaperInfo, 6 | PaperInfoDetail, 7 | CrossRefPaperResponse, 8 | CrossRefResponse, 9 | } from './entities/crossRef.entity'; 10 | import { ElasticsearchService } from '@nestjs/elasticsearch'; 11 | import { MgetOperation, SearchHit } from '@elastic/elasticsearch/lib/api/types'; 12 | import { HttpService } from '@nestjs/axios'; 13 | import { CROSSREF_API_PAPER_URL, CROSSREF_API_URL } from '../util'; 14 | import { ELASTIC_INDEX } from 'src/envLayer'; 15 | 16 | @Injectable() 17 | export class SearchService { 18 | constructor(private readonly esService: ElasticsearchService, private readonly httpService: HttpService) {} 19 | 20 | parsePaperInfo = (item: CrossRefItem) => { 21 | const data = { 22 | title: item.title?.[0], 23 | authors: item.author?.reduce((acc, cur) => { 24 | const authorName = `${cur.name ? cur.name : cur.given ? cur.given + ' ' : ''}${cur.family || ''}`; 25 | authorName && acc.push(authorName); 26 | return acc; 27 | }, []), 28 | doi: item.DOI, 29 | key: item.DOI, 30 | }; 31 | 32 | return new PaperInfo(data); 33 | }; 34 | parsePaperInfoExtended = (item: CrossRefItem) => { 35 | const data = { 36 | ...this.parsePaperInfo(item), 37 | publishedAt: item.created?.['date-time'], 38 | citations: item['is-referenced-by-count'], 39 | references: item['references-count'], 40 | }; 41 | 42 | return new PaperInfoExtended(data); 43 | }; 44 | parsePaperInfoDetail = (item: CrossRefItem) => { 45 | const keysTable: { [key: string]: boolean } = {}; 46 | const referenceList = 47 | item['reference'] 48 | ?.map((reference) => { 49 | const key = reference['DOI'] || reference.key || reference.unstructured; 50 | const title = 51 | reference['article-title'] || 52 | reference['journal-title'] || 53 | reference['series-title'] || 54 | reference['volume-title'] || 55 | reference.unstructured; 56 | const doi = reference['DOI']; 57 | const authors = reference['author'] ? [reference['author']] : undefined; 58 | return { 59 | key, 60 | title, 61 | doi, 62 | authors, 63 | }; 64 | }) 65 | .filter((reference) => { 66 | if (!keysTable[reference.key]) { 67 | keysTable[reference.key] = true; 68 | return true; 69 | } 70 | return false; 71 | }) || []; 72 | const data = { 73 | ...this.parsePaperInfoExtended(item), 74 | referenceList, 75 | }; 76 | 77 | return new PaperInfoDetail(data); 78 | }; 79 | async getPapersFromCrossref(keyword: string, rows: number, page: number, selects?: string[]) { 80 | const crossRefdata = await this.httpService.axiosRef 81 | .get(CROSSREF_API_URL(keyword, rows, page, selects)) 82 | .catch((err) => { 83 | throw new RequestTimeoutException(err.message); 84 | }); 85 | const items = crossRefdata.data.message.items.map((item) => this.parsePaperInfoExtended(item)); 86 | const totalItems = crossRefdata.data.message['total-results']; 87 | return { items, totalItems }; 88 | } 89 | 90 | async getPaperFromCrossref(doi: string) { 91 | try { 92 | const item = await this.httpService.axiosRef.get(CROSSREF_API_PAPER_URL(doi)); 93 | return this.parsePaperInfoDetail(item.data.message); 94 | } catch (error) { 95 | throw new NotFoundException('해당 doi는 존재하지 않습니다. 정보를 수집중입니다.'); 96 | } 97 | } 98 | async getPaper(doi: string) { 99 | try { 100 | const paper = await this.esService.get({ index: ELASTIC_INDEX, id: doi }); 101 | return paper; 102 | } catch (_) { 103 | return false; 104 | } 105 | } 106 | async putElasticSearch(paper: PaperInfoExtended) { 107 | return await this.esService.index({ 108 | index: ELASTIC_INDEX, 109 | id: paper.doi, 110 | document: { 111 | ...paper, 112 | }, 113 | }); 114 | } 115 | async getElasticSearch(keyword: string, size = 5, from = 0) { 116 | const key = decodeURIComponent(keyword); 117 | const query = { 118 | bool: { 119 | should: [ 120 | { 121 | match_phrase_prefix: { 122 | title: key, 123 | }, 124 | }, 125 | { 126 | match_phrase_prefix: { 127 | authors: key, 128 | }, 129 | }, 130 | ], 131 | }, 132 | }; 133 | return await this.esService 134 | .search({ 135 | index: ELASTIC_INDEX, 136 | from, 137 | size, 138 | sort: ['_score', { citations: 'desc' }], 139 | query, 140 | }) 141 | .catch(() => { 142 | return { hits: { hits: [] as SearchHit[], total: 0 } }; 143 | }); 144 | } 145 | 146 | async bulkInsert(papers: PaperInfoDetail[]) { 147 | const dataset = papers.map((paper) => { 148 | return { id: paper.doi, ...paper }; 149 | }); 150 | if (dataset.length <= 0) return; 151 | const operations = dataset.flatMap((doc) => [{ index: { _index: ELASTIC_INDEX, _id: doc.id } }, doc]); 152 | const bulkResponse = await this.esService.bulk({ refresh: true, operations }); 153 | // console.log(`bulk insert response : ${bulkResponse.items.length}`); 154 | } 155 | async multiGet(ids: string[]) { 156 | if (ids.length === 0) return { docs: [] }; 157 | const docs: MgetOperation[] = ids.map((id) => { 158 | return { 159 | _index: ELASTIC_INDEX, 160 | _id: id, 161 | _source: { include: ['key', 'title', 'authors', 'doi', 'publishedAt', 'citations', 'references'] }, 162 | }; 163 | }); 164 | return await this.esService.mget({ docs }); 165 | } 166 | esStat() { 167 | return this.esService.cat.indices(); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /backend/src/search/tests/search.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { SearchController } from '../search.controller'; 2 | import { SearchService } from '../search.service'; 3 | import { HttpService } from '@nestjs/axios'; 4 | import { PaperInfo, PaperInfoDetail, PaperInfoExtended } from '../entities/crossRef.entity'; 5 | import { Test, TestingModule } from '@nestjs/testing'; 6 | import { ElasticsearchService } from '@nestjs/elasticsearch'; 7 | import { mockBatchService, mockElasticService, mockHttpService, mockRankingService } from './search.service.mock'; 8 | import { RankingService } from '../../ranking/ranking.service'; 9 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 10 | import * as request from 'supertest'; 11 | import { BatchService } from 'src/batch/batch.service'; 12 | 13 | describe('SearchController', () => { 14 | let controller: SearchController; 15 | let service: SearchService; 16 | let app: INestApplication; 17 | 18 | let spyGetElasticSearch: jest.SpyInstance; 19 | const keyword = 'coffee'; 20 | 21 | beforeEach(async () => { 22 | const httpService = mockHttpService(); 23 | const rankingService = mockRankingService(); 24 | const elasticService = mockElasticService(); 25 | const batchService = mockBatchService(); 26 | 27 | const module: TestingModule = await Test.createTestingModule({ 28 | controllers: [SearchController], 29 | providers: [ 30 | SearchService, 31 | { 32 | provide: RankingService, 33 | useValue: rankingService, 34 | }, 35 | { 36 | provide: HttpService, 37 | useValue: httpService, 38 | }, 39 | { 40 | provide: ElasticsearchService, 41 | useValue: elasticService, 42 | }, 43 | { 44 | provide: BatchService, 45 | useValue: batchService, 46 | }, 47 | ], 48 | }).compile(); 49 | controller = module.get(SearchController); 50 | service = module.get(SearchService); 51 | spyGetElasticSearch = jest.spyOn(service, 'getElasticSearch'); 52 | // spyGetCrossRefData = jest.spyOn(service, 'getCrossRefData'); 53 | 54 | app = module.createNestApplication(); 55 | app.useGlobalPipes(new ValidationPipe()); 56 | await app.init(); 57 | }); 58 | afterEach(() => app.close()); 59 | 60 | describe('/search/auto-complete', () => { 61 | it('getAutoCompletePapers - keyword=coffee 일 때 PaperInfo[]를 return', async () => { 62 | // Case 1. elasticsearch에 data가 없을 경우 63 | const itemsByCrossRef = await controller.getAutoCompletePapers({ keyword }); 64 | expect(itemsByCrossRef.length).toBe(5); 65 | itemsByCrossRef.forEach((item) => { 66 | expect(item instanceof PaperInfo).toBe(true); 67 | }); 68 | 69 | // Case 2. elasticsearch에 data가 있는 경우 70 | const itemsByElasticsearch = await controller.getAutoCompletePapers({ keyword }); 71 | expect(itemsByElasticsearch.length).toBe(5); 72 | itemsByElasticsearch.forEach((item) => { 73 | expect(item instanceof PaperInfo).toBe(true); 74 | }); 75 | 76 | expect(spyGetElasticSearch).toBeCalledTimes(2); 77 | }); 78 | it('keyword 미포함시 error - GET /search/auto-complete?keyword=', () => { 79 | const url = (keyword: string) => `/search/auto-complete?keyword=${keyword}`; 80 | request(app.getHttpServer()).get(url('')).expect(400); 81 | }); 82 | }); 83 | describe('/search', () => { 84 | const DEFAULT_ROWS = 20; 85 | const TOTAL_ITEMS = 28810; 86 | it(`getPapers - keyword='coffee' 일 때 PaperInfoExtended[]를 return`, async () => { 87 | const keyword = 'coffee'; 88 | const { papers: items, pageInfo } = await controller.getPapers({ keyword, rows: 20, page: 1 }); 89 | expect(items.length).toBe(DEFAULT_ROWS); 90 | expect(pageInfo.totalItems).toBe(TOTAL_ITEMS); 91 | items.forEach((item) => { 92 | expect(item).toBeInstanceOf(PaperInfoExtended); 93 | }); 94 | expect(spyGetElasticSearch).toBeCalledTimes(1); 95 | }); 96 | it('keyword 미포함시 error - GET /search?keyword=', () => { 97 | const url = (keyword: string) => `/search?keyword=${keyword}`; 98 | request(app.getHttpServer()).get(url('')).expect(400); 99 | }); 100 | it('rows<=0 이거나, rows 값이 integer가 아닐 경우 error - GET /search?keyword=coffee&rows=', () => { 101 | const url = (rows: string | number) => `/search?keyword=${keyword}&rows=${rows}`; 102 | const rowsNotAvailables = [-1, -0.1, '0', 'value']; 103 | rowsNotAvailables.forEach((value) => { 104 | request(app.getHttpServer()).get(url(value)).expect(400); 105 | }); 106 | }); 107 | it('page<=0 이거나, page 값이 integer가 아닐 경우 error - GET /search?keyword=coffee&page=', () => { 108 | const url = (page: string | number) => `/search?keyword=${keyword}&page=${page}`; 109 | const pageNotAvailables = [-1, -0.1, '0', 'value']; 110 | pageNotAvailables.forEach((value) => { 111 | request(app.getHttpServer()).get(url(value)).expect(400); 112 | }); 113 | }); 114 | it('page>max 이면 error - GET /search?keyword=coffee&page=', () => { 115 | const url = (page: string | number) => `/search?keyword=${keyword}&page=${page}`; 116 | const maxPage = Math.ceil(TOTAL_ITEMS / DEFAULT_ROWS); 117 | request(app.getHttpServer()) 118 | .get(url(maxPage + 1)) 119 | .expect(404); 120 | }); 121 | }); 122 | 123 | describe('/search/paper', () => { 124 | it(`getPaper - doi=10.1234/some_doi 일 때 PaperInfoDetail을 return`, async () => { 125 | const doi = '10.1234/some_doi'; 126 | const paper = await controller.getPaper({ doi }); 127 | expect(paper.references).toBe(5); 128 | expect(paper.referenceList.length).toBe(5); 129 | expect(() => new PaperInfoDetail(paper)).not.toThrow(); 130 | }); 131 | it('doi가 입력되지 않을 경우 error - GET /search/paper?doi=', () => { 132 | const url = (keyword: string) => `/search/paper?doi=${keyword}`; 133 | request(app.getHttpServer()).get(url('')).expect(400); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /backend/src/search/tests/search.service.mock.ts: -------------------------------------------------------------------------------- 1 | import mockCrossRefData from './crossref.mock'; 2 | import mockSearchData from './searchdata.mock'; 3 | import { HttpService } from '@nestjs/axios'; 4 | import { CrossRefPaperResponse, CrossRefResponse, PaperInfo, PaperInfoDetail } from '../entities/crossRef.entity'; 5 | import { ElasticsearchService } from '@nestjs/elasticsearch'; 6 | 7 | export function mockHttpService() { 8 | const httpService = new HttpService(); 9 | jest.spyOn(httpService['axiosRef'], 'get').mockImplementation((url: string) => { 10 | const params = new URLSearchParams(new URL(url).search); 11 | const keyword = params.get('query'); 12 | if (keyword === null) { 13 | const item = mockCrossRefData.message.items[0]; 14 | return Promise.resolve<{ data: CrossRefPaperResponse }>({ 15 | data: { message: item }, 16 | }); 17 | } 18 | const rows = parseInt(params.get('rows')); 19 | const selects = params.get('select').split(','); 20 | const items = mockCrossRefData.message.items.slice(0, rows).map((item, i) => { 21 | return selects.reduce((acc, select) => { 22 | if (select === 'title') item[select] = [`${keyword}-${i}`]; 23 | acc[select] = item[select]; 24 | return acc; 25 | }, {}); 26 | }); 27 | 28 | return Promise.resolve<{ data: CrossRefResponse }>({ 29 | data: { message: { 'total-results': mockCrossRefData.message['total-results'], items } }, 30 | }); 31 | }); 32 | return httpService; 33 | } 34 | 35 | export function mockElasticService() { 36 | // TODO: should mockup index? 37 | const index = jest.fn().mockResolvedValue(() => { 38 | return true; 39 | }); 40 | const search = jest.fn().mockImplementation(({ size }) => { 41 | return Promise.resolve({ 42 | hits: { 43 | total: { 44 | value: 28810, 45 | }, 46 | hits: mockSearchData 47 | .map((data, key) => { 48 | return { 49 | _source: { ...new PaperInfo(data), key }, 50 | }; 51 | }) 52 | .slice(0, size), 53 | }, 54 | }); 55 | }); 56 | const get = jest.fn().mockResolvedValue({ 57 | _source: { 58 | ...mockSearchData[0], 59 | referenceList: Array.from({ length: mockSearchData[0].references || 0 }, (_, i) => { 60 | return { key: mockSearchData[i].doi }; 61 | }), 62 | }, 63 | }); 64 | const mget = jest.fn().mockResolvedValue({ 65 | docs: Array.from({ length: mockSearchData[0].references || 0 }, (_, i) => { 66 | const data = mockSearchData[i]; 67 | return { _id: data.doi, found: Math.random() > 0.5, _source: data }; 68 | }), 69 | }); 70 | const bulk = jest.fn().mockResolvedValue([]); 71 | const elasticService = { index, search, get, mget, bulk }; 72 | return elasticService as unknown as ElasticsearchService; 73 | } 74 | 75 | export function mockRankingService() { 76 | const insertRedis = jest.fn().mockResolvedValue(() => { 77 | console.log('insertRedis 사용했다하하ㅏㅎ하ㅏ'); 78 | return true; 79 | }); 80 | const rankingService = { insertRedis }; 81 | return rankingService; 82 | } 83 | 84 | export function mockBatchService() { 85 | const setKeyword = jest.fn().mockResolvedValue(() => { 86 | return true; 87 | }); 88 | const doiBatcher = { 89 | pushToQueue: jest.fn(), 90 | }; 91 | const searchBatcher = { 92 | pushToQueue: jest.fn(), 93 | }; 94 | const batchService = { setKeyword, doiBatcher, searchBatcher }; 95 | return batchService; 96 | } 97 | -------------------------------------------------------------------------------- /backend/src/search/tests/searchdata.mock.ts: -------------------------------------------------------------------------------- 1 | import { PaperInfoExtended } from '../entities/crossRef.entity'; 2 | 3 | export default [ 4 | { 5 | title: 6 | 'Thermodynamic Properties of Systems Comprising Esters: Experimental Data and Modeling with PC-SAFT and SAFT Mie', 7 | doi: '10.1021/acs.iecr.9b00714.s001', 8 | publishedAt: '2020-04-09T15:47:35Z', 9 | citations: 0, 10 | references: 10, 11 | }, 12 | { 13 | title: 14 | 'Comparison of CP-PC-SAFT and PC-SAFT with k12 = 0 and PPR78 in Predicting Binary Systems of Hydrocarbons with Squalane, ndodecylbenzene, cis-decalin, Tetralin, and Naphthalene at High Pressures', 15 | doi: '10.1021/acs.iecr.1c03486.s001', 16 | publishedAt: '2021-10-25T13:26:30Z', 17 | citations: 0, 18 | references: 0, 19 | }, 20 | { 21 | title: 'Boyle temperature from SAFT, PC-SAFT and SAFT-VR equations of state', 22 | authors: ['Samane Zarei', 'Farzaneh Feyzi'], 23 | doi: '10.1016/j.molliq.2013.06.010', 24 | publishedAt: '2013-07-04T20:46:55Z', 25 | citations: 15, 26 | references: 44, 27 | }, 28 | { 29 | title: 'Comparison of SAFT-VR-Mie and CP-PC-SAFT in Estimating the Phase Behavior of Acetone + nAlkane Systems', 30 | doi: '10.1021/acs.iecr.0c04435.s001', 31 | publishedAt: '2020-11-25T06:50:17Z', 32 | citations: 0, 33 | references: 0, 34 | }, 35 | { 36 | title: 'Modeling of carbon dioxide and water sorption in glassy polymers through PC-SAFT and NET PC-SAFT', 37 | authors: ['Liang Liu', 'Sandra E. Kentish'], 38 | doi: '10.1016/j.polymer.2016.10.002', 39 | publishedAt: '2016-10-09T04:06:52Z', 40 | citations: 10, 41 | references: 60, 42 | }, 43 | { 44 | title: 'Thermodynamic Modeling of Triglycerides using PC-SAFT', 45 | doi: '10.1021/acs.jced.8b01046.s001', 46 | publishedAt: '2020-04-09T21:21:54Z', 47 | citations: 0, 48 | references: 0, 49 | }, 50 | { 51 | title: 'Vle Property Measurements and Pc-Saft/ Cp- Pc-Saft/ E-Ppr78 Modeling of the Co2 + N-Tetradecane Mixture', 52 | authors: [ 53 | 'Vener Khairutdinov', 54 | 'Farid Gumerov', 55 | 'Ilnar Khabriev', 56 | 'Talgat Akhmetzyanov', 57 | 'Ilfat Salikhov', 58 | 'Ilya Polishuk', 59 | 'ilmutdin abdulagatov', 60 | ], 61 | doi: '10.2139/ssrn.4188488', 62 | publishedAt: '2022-08-13T13:32:29Z', 63 | citations: 0, 64 | references: 0, 65 | }, 66 | { 67 | title: 'Application of PC-SAFT to glycol containing systems – PC-SAFT towards a predictive approach', 68 | authors: ['Andreas Grenner', 'Georgios M. Kontogeorgis', 'Nicolas von Solms', 'Michael L. Michelsen'], 69 | doi: '10.1016/j.fluid.2007.04.025', 70 | publishedAt: '2007-04-30T14:49:55Z', 71 | citations: 37, 72 | references: 37, 73 | }, 74 | { 75 | title: 'PC-SAFT Modeling of CO2 Solubilities in Deep Eutectic Solvents', 76 | doi: '10.1021/acs.jpcb.5b07888.s001', 77 | publishedAt: '2020-04-08T16:19:35Z', 78 | citations: 0, 79 | references: 0, 80 | }, 81 | { 82 | title: 83 | 'IPC-SAFT: An Industrialized Version of the Volume-Translated PC-SAFT Equation of State for Pure Components, Resulting from Experience Acquired All through the Years on the Parameterization of SAFT-Type and Cubic Models', 84 | doi: '10.1021/acs.iecr.9b04660.s001', 85 | publishedAt: '2020-04-07T21:03:41Z', 86 | citations: 0, 87 | references: 0, 88 | }, 89 | { 90 | title: 91 | 'Parameterization of SAFT Models: Analysis of Different Parameter Estimation Strategies and Application to the Development of a Comprehensive Database of PC-SAFT Molecular Parameters', 92 | doi: '10.1021/acs.jced.0c00792.s001', 93 | publishedAt: '2020-11-30T23:27:16Z', 94 | citations: 0, 95 | references: 0, 96 | }, 97 | { 98 | title: 99 | 'Implementation of CP-PC-SAFT and CS-SAFT-VR-Mie for Predicting Thermodynamic Properties of C1C3 Halocarbon Systems. I. Pure Compounds and Mixtures with Nonassociating Compounds', 100 | doi: '10.1021/acs.iecr.1c01700.s001', 101 | publishedAt: '2021-06-28T13:45:30Z', 102 | citations: 0, 103 | references: 0, 104 | }, 105 | { 106 | title: 'Predicting the Solubility of CO2 in Toluene + Ionic Liquid Mixtures with PC-SAFT', 107 | doi: '10.1021/acs.iecr.7b01497.s001', 108 | publishedAt: '2020-04-06T18:59:00Z', 109 | citations: 0, 110 | references: 0, 111 | }, 112 | { 113 | title: 114 | 'Evaluation of SAFT and PC-SAFT models for the description of homo- and co-polymer solution phase equilibria', 115 | authors: ['Theodora Spyriouni', 'Ioannis G. Economou'], 116 | doi: '10.1016/j.polymer.2005.09.001', 117 | publishedAt: '2005-09-23T13:48:14Z', 118 | citations: 19, 119 | references: 27, 120 | }, 121 | { 122 | title: 'VLE property measurements and PC-SAFT/ CP- PC-SAFT/ E-PPR78 modeling of the CO2 + n-tetradecane mixture', 123 | authors: [ 124 | 'Vener F. Khairutdinov', 125 | 'Farid M. Gumerov', 126 | 'Ilnar Sh. Khabriev', 127 | 'Talgat R. Akhmetzyanov', 128 | 'Ilfat Z. Salikhov', 129 | 'Ilya Polishuk', 130 | 'Ilmutdin M. Abdulagatov', 131 | ], 132 | doi: '10.1016/j.fluid.2022.113615', 133 | publishedAt: '2022-09-20T01:54:20Z', 134 | citations: 0, 135 | references: 79, 136 | }, 137 | { 138 | title: 'Thermodynamic Properties of 1Hexyl-3-methylimidazolium Nitrate and 1Alkanols Mixtures: PC-SAFT Model', 139 | doi: '10.1021/acs.jced.9b00507.s001', 140 | publishedAt: '2020-04-08T11:44:12Z', 141 | citations: 0, 142 | references: 0, 143 | }, 144 | { 145 | title: 'Integrated Working Fluids and Process Optimization for Refrigeration Systems Using Polar PC-SAFT', 146 | doi: '10.1021/acs.iecr.1c03624.s001', 147 | publishedAt: '2021-11-29T13:45:54Z', 148 | citations: 0, 149 | references: 0, 150 | }, 151 | { 152 | title: 'Thermodynamic and Transport Properties of Formic Acid and 2Alkanol Mixtures: PC-SAFT Model', 153 | doi: '10.1021/acs.jced.2c00496.s001', 154 | publishedAt: '2022-11-09T21:00:10Z', 155 | citations: 0, 156 | references: 0, 157 | }, 158 | { 159 | title: '5Hydroxymethylfurfural Synthesis in Nonaqueous Two-Phase Systems (NTPS)PC-SAFT Predictions and Validation', 160 | doi: '10.1021/acs.oprd.0c00072.s001', 161 | publishedAt: '2020-05-26T16:46:36Z', 162 | citations: 0, 163 | references: 0, 164 | }, 165 | { 166 | title: 'Investigating Various Parametrization Strategies for Pharmaceuticals within the PC-SAFT Equation of State', 167 | doi: '10.1021/acs.jced.0c00707.s001', 168 | publishedAt: '2020-09-30T13:48:49Z', 169 | citations: 0, 170 | references: 0, 171 | }, 172 | ] as PaperInfoExtended[]; 173 | -------------------------------------------------------------------------------- /backend/src/util.ts: -------------------------------------------------------------------------------- 1 | import { MAIL_TO } from './envLayer'; 2 | 3 | const BASE_URL = 'https://api.crossref.org/works'; 4 | export const CROSSREF_API_URL = ( 5 | keyword: string, 6 | rows = 5, 7 | page = 1, 8 | selects: string[] = ['title', 'author', 'created', 'is-referenced-by-count', 'references-count', 'DOI'], 9 | ) => 10 | `${BASE_URL}?query=${keyword}&rows=${rows}&select=${selects.join(',')}&offset=${rows * (page - 1)}&mailto=${MAIL_TO}`; 11 | 12 | export const MAX_ROWS = 1000; 13 | export const CROSSREF_API_URL_CURSOR = ( 14 | keyword: string, 15 | cursor = '*', 16 | rows = MAX_ROWS, 17 | selects: string[] = ['title', 'author', 'created', 'is-referenced-by-count', 'references-count', 'DOI', 'reference'], 18 | ) => 19 | `${BASE_URL}?query=${keyword}&rows=${rows}&select=${selects.join( 20 | ',', 21 | )}&mailto=${MAIL_TO}&sort=is-referenced-by-count&cursor=${cursor}`; 22 | export const CROSSREF_API_PAPER_URL = (doi: string) => `${BASE_URL}/${doi}`; 23 | export class Queue { 24 | data: Set; 25 | constructor() { 26 | this.data = new Set(); 27 | } 28 | pop() { 29 | const firstValue = this.data[Symbol.iterator]().next().value as T; 30 | this.data.delete(firstValue); 31 | return firstValue; 32 | } 33 | push(value: T) { 34 | if (!this.data.has(value)) this.data.add(value); 35 | } 36 | isEmpty() { 37 | if (this.data.size == 0) return true; 38 | else return false; 39 | } 40 | } 41 | export const CROSSREF_CACHE_QUEUE = new Queue(); 42 | export const urlRegex = /(https?:\/\/[^\s]+)/gm; 43 | -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose/docker-compose.be.prod.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | prv: 5 | restart: always 6 | env_file: 7 | - ${ENV_FILE_BE} 8 | image: ${NCP_CONTAINER_REGISTRY}/prv-backend 9 | links: 10 | - redis 11 | ports: 12 | - 4000:4000 13 | 14 | redis: 15 | image: redis:7.0.5 16 | expose: 17 | - 6379 18 | command: redis-server --save 60 1000 --loglevel notice --requirepass ${REDIS_PASSWORD} -------------------------------------------------------------------------------- /docker-compose/docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | prv: 5 | restart: always 6 | env_file: 7 | - ${ENV_FILE_BE} 8 | image: ${NCP_CONTAINER_REGISTRY}/prv-backend 9 | ports: 10 | - 4000:4000 11 | 12 | 13 | redis: 14 | image: redis:7.0.5 15 | expose: 16 | - 6379 17 | command: redis-server --save 60 1000 --loglevel notice --requirepass ${REDIS_PASSWORD} 18 | 19 | client: 20 | env_file: 21 | - ${ENV_FILE_FE} 22 | image: ${NCP_CONTAINER_REGISTRY}/prv-frontend 23 | ports: 24 | - 3000:80 -------------------------------------------------------------------------------- /docker-compose/docker-compose.elasticsearch.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | setup: 5 | container_name: setup 6 | image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION} 7 | volumes: 8 | - certs:/usr/share/elasticsearch/config/certs 9 | user: "0" 10 | command: > 11 | bash -c ' 12 | if [ ! -f config/certs/ca.zip ]; then 13 | echo "Creating CA"; 14 | bin/elasticsearch-certutil ca --silent --pem -out config/certs/ca.zip; 15 | unzip config/certs/ca.zip -d config/certs; 16 | fi; 17 | if [ ! -f config/certs/certs.zip ]; then 18 | echo "Creating certs"; 19 | echo -ne \ 20 | "instances:\n"\ 21 | " - name: esmaster\n"\ 22 | " dns:\n"\ 23 | " - esmaster\n"\ 24 | " - localhost\n"\ 25 | " ip:\n"\ 26 | " - 127.0.0.1\n"\ 27 | > config/certs/instances.yml; 28 | bin/elasticsearch-certutil cert --silent --pem -out config/certs/certs.zip --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key; 29 | unzip -o config/certs/certs.zip -d config/certs; 30 | fi; 31 | echo "Setting file permissions" 32 | chown -R root:root config/certs; 33 | find . -type d -exec chmod 750 \{\} \;; 34 | find . -type f -exec chmod 640 \{\} \;; 35 | echo "Waiting for Elasticsearch availability"; 36 | until curl -s --cacert config/certs/ca/ca.crt https://esmaster:9200 | grep -q "missing authentication credentials"; do sleep 30; done; 37 | echo "Setting kibana_system password"; 38 | until curl -s -X POST --cacert config/certs/ca/ca.crt -u "elastic:${ELASTIC_PASSWORD}" -H "Content-Type: application/json" https://esmaster:9200/_security/user/kibana_system/_password -d "{\"password\":\"${KIBANA_PASSWORD}\"}" | grep -q "^{}"; do sleep 10; done; 39 | echo "Setup Finished"; 40 | ' 41 | esmaster: 42 | container_name: esmaster 43 | depends_on: 44 | - setup 45 | image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION} 46 | volumes: 47 | - certs:/usr/share/elasticsearch/config/certs 48 | - esmasterdata:/usr/share/elasticsearch/data 49 | ports: 50 | - ${ES_PORT}:9200 51 | environment: 52 | - node.name=esmaster 53 | - ELASTIC_PASSWORD=${ELASTIC_PASSWORD} 54 | - bootstrap.memory_lock=true 55 | - discovery.type=single-node 56 | - xpack.security.enabled=true 57 | - xpack.security.http.ssl.enabled=true 58 | - xpack.security.http.ssl.key=certs/esmaster/esmaster.key 59 | - xpack.security.http.ssl.certificate=certs/esmaster/esmaster.crt 60 | - xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt 61 | - xpack.security.http.ssl.verification_mode=certificate 62 | - xpack.security.transport.ssl.enabled=true 63 | - xpack.security.transport.ssl.key=certs/esmaster/esmaster.key 64 | - xpack.security.transport.ssl.certificate=certs/esmaster/esmaster.crt 65 | - xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt 66 | - xpack.security.transport.ssl.verification_mode=certificate 67 | - xpack.license.self_generated.type=${LICENSE} 68 | ulimits: 69 | memlock: 70 | soft: -1 71 | hard: -1 72 | 73 | kibana: 74 | container_name: kibana 75 | depends_on: 76 | - esmaster 77 | image: docker.elastic.co/kibana/kibana:${STACK_VERSION} 78 | volumes: 79 | - certs:/usr/share/kibana/config/certs 80 | - kibana-data:/usr/share/kibana/data 81 | ports: 82 | - ${KIBANA_PORT}:5601 83 | environment: 84 | - SERVERNAME=kibana 85 | - ELASTICSEARCH_HOSTS=https://esmaster:${ES_PORT} 86 | - ELASTICSEARCH_USERNAME=kibana_system 87 | - ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD} 88 | - ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES=config/certs/ca/ca.crt 89 | ulimits: 90 | memlock: 91 | soft: -1 92 | hard: -1 93 | 94 | volumes: 95 | certs: 96 | driver: local 97 | esmasterdata: 98 | driver: local 99 | kibana-data: 100 | driver: local -------------------------------------------------------------------------------- /docker-compose/docker-compose.fe.prod.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | client: 5 | image: ${NCP_CONTAINER_REGISTRY}/prv-frontend 6 | ports: 7 | - 80:80 8 | - 443:443 9 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /frontend/.env.sample: -------------------------------------------------------------------------------- 1 | REACT_APP_BASE_URL= -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "prettier"], 7 | "overrides": [], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaVersion": "latest", 11 | "sourceType": "module" 12 | }, 13 | "plugins": ["react", "@typescript-eslint", "prettier", "react-hooks"], 14 | "rules": { 15 | "react/react-in-jsx-scope": "off", 16 | "react-hooks/rules-of-hooks": "error", 17 | "react-hooks/exhaustive-deps": "warn" 18 | }, 19 | "settings": { 20 | "import/resolver": { 21 | "typescript": {} 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | .DS_Store 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "printWidth": 120, 8 | "bracketSpacing": true, 9 | "useTabs": false 10 | } -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 as builder 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm ci 5 | COPY . . 6 | RUN --mount=type=secret,id=REACT_APP_BASE_URL \ 7 | export REACT_APP_BASE_URL=$(cat /run/secrets/REACT_APP_BASE_URL) && \ 8 | echo "REACT_APP_BASE_URL=$REACT_APP_BASE_URL" >> .env.production 9 | RUN ls -al 10 | RUN npm run build 11 | 12 | FROM nginx:alpine 13 | WORKDIR /usr/share/nginx/statics 14 | RUN rm /etc/nginx/conf.d/default.conf 15 | COPY ./nginx.conf /etc/nginx/conf.d 16 | COPY --from=builder /app/build . 17 | EXPOSE 80 18 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /frontend/config-overrides.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable no-undef */ 3 | const { override, addWebpackAlias } = require('customize-cra'); 4 | const path = require('path'); 5 | 6 | module.exports = override( 7 | addWebpackAlias({ 8 | '@': path.resolve(__dirname, 'src'), 9 | }), 10 | ); 11 | -------------------------------------------------------------------------------- /frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | location / { 4 | root /usr/share/nginx/statics; 5 | index index.html; 6 | try_files $uri $uri/ /index.html; 7 | } 8 | } -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.18.3", 11 | "@types/react": "^18.0.25", 12 | "@types/react-dom": "^18.0.8", 13 | "axios": "^1.1.3", 14 | "d3": "^7.6.1", 15 | "lodash-es": "^4.17.21", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "react-query": "^3.39.2", 19 | "react-router-dom": "^6.4.3", 20 | "react-scripts": "5.0.1", 21 | "styled-components": "^5.3.6", 22 | "styled-reset": "^4.4.2", 23 | "typescript": "^4.8.4", 24 | "web-vitals": "^2.1.4" 25 | }, 26 | "scripts": { 27 | "start": "react-app-rewired start", 28 | "build": "react-app-rewired build", 29 | "test": "react-app-rewired test", 30 | "eject": "react-scripts eject" 31 | }, 32 | "eslintConfig": { 33 | "extends": [ 34 | "react-app", 35 | "react-app/jest" 36 | ] 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | }, 50 | "devDependencies": { 51 | "@types/d3": "^7.4.0", 52 | "@types/lodash-es": "^4.17.6", 53 | "@types/styled-components": "^5.1.26", 54 | "@typescript-eslint/eslint-plugin": "^5.42.1", 55 | "@typescript-eslint/parser": "^5.42.1", 56 | "customize-cra": "^1.0.0", 57 | "eslint": "^8.27.0", 58 | "eslint-config-prettier": "^8.5.0", 59 | "eslint-plugin-prettier": "^4.2.1", 60 | "eslint-plugin-react": "^7.31.10", 61 | "eslint-plugin-react-hooks": "^4.6.0", 62 | "react-app-rewired": "^2.2.1", 63 | "prettier": "^2.7.1" 64 | }, 65 | "jest": { 66 | "moduleNameMapper": { 67 | "@/(.*)": "/src/$1" 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /frontend/public/assets/moon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web18-PRV/431c9f47b1ff229d44f37c3dba7e717e0f9e07fb/frontend/public/assets/moon.png -------------------------------------------------------------------------------- /frontend/public/assets/prv-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web18-PRV/431c9f47b1ff229d44f37c3dba7e717e0f9e07fb/frontend/public/assets/prv-image.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web18-PRV/431c9f47b1ff229d44f37c3dba7e717e0f9e07fb/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | PRV 33 | 34 | 35 | 36 | 37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { act } from 'react-dom/test-utils'; 4 | import { ThemeProvider } from 'styled-components'; 5 | import theme from './style/theme'; 6 | import Footer from './components/Footer'; 7 | 8 | let container: HTMLDivElement; 9 | beforeEach(() => { 10 | container = document.createElement('div'); 11 | document.body.appendChild(container); 12 | }); 13 | afterEach(() => { 14 | document.body.removeChild(container); 15 | }); 16 | 17 | it('Footer 렌더링 테스트', () => { 18 | act(() => { 19 | ReactDOM.createRoot(container).render( 20 | 21 |