From 15607e4e0d7d1e96d2bccb3c639eec842817c8d3 Mon Sep 17 00:00:00 2001 From: Matteo Cacciola Date: Tue, 4 Apr 2023 20:42:10 +0200 Subject: [PATCH] removed usage of env variables --- README.md | 2 +- docs/sentiment.md | 148 ++++++++---------- package-lock.json | 18 +-- package.json | 3 +- src/clients/__tests__/facebook.test.ts | 7 +- src/clients/__tests__/instagram.test.ts | 7 +- src/clients/__tests__/news.test.ts | 7 +- src/clients/__tests__/tiktok.test.ts | 9 +- src/clients/__tests__/twitter.test.ts | 17 +- src/clients/__tests__/youtube.test.ts | 47 +++--- src/clients/facebook.ts | 9 +- src/clients/instagram.ts | 9 +- src/clients/news.ts | 9 +- src/clients/tiktok.ts | 17 +- src/clients/twitter.ts | 20 ++- src/clients/types.ts | 23 +++ src/clients/youtube.ts | 17 +- src/constants.ts | 48 ------ .../getCompanyMediaSentiment.test.ts | 73 ++++++++- src/helpers/getCompanyMediaSentiment.ts | 15 +- src/methods/sentiment.ts | 4 +- src/providers/facebook.ts | 10 +- src/providers/factory.ts | 4 +- src/providers/instagram.ts | 10 +- src/providers/news.ts | 10 +- src/providers/tiktok.ts | 11 +- src/providers/twitter.ts | 11 +- src/providers/youtube.ts | 16 +- .../__tests__/getAnalysisResults.test.ts | 12 +- src/strategies/helpers/getAnalysisResults.ts | 3 +- src/types.ts | 37 ++++- src/utils/openai.ts | 7 +- 32 files changed, 365 insertions(+), 275 deletions(-) create mode 100644 src/clients/types.ts delete mode 100644 src/constants.ts diff --git a/README.md b/README.md index ebaf0b9..3f15818 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sentiment Analysis for Node.js -[![Coverage Status](https://coveralls.io/repos/github/matteocacciola/sentiment/badge.svg?branch=v1.0.4)](https://coveralls.io/github/matteocacciola/sentiment?branch=v1.0.4) +[![Coverage Status](https://coveralls.io/repos/github/matteocacciola/sentiment/badge.svg?branch=v1.1.0)](https://coveralls.io/github/matteocacciola/sentiment?branch=v1.1.0) This library aims to provide a support for the analysis of texts, like the evaluation of Sentiment or Text Matching. Please, consult the various methods here provided in order to have a wide overview of the possible features provided diff --git a/docs/sentiment.md b/docs/sentiment.md index 35b95ec..d281614 100644 --- a/docs/sentiment.md +++ b/docs/sentiment.md @@ -13,13 +13,75 @@ The usage is really simple ```typescript import { sentiment } from '@matteocacciola/sentiment' -const results: Record[] = sentiment('yourCompany', media); -const results: Record[] = sentiment('yourCompany', media, options); +const results: Record[] = sentiment('yourCompany', media, configuration); +const results: Record[] = sentiment('yourCompany', media, configuration, options); ``` +This method is fully configurable. + +### Media The `media` parameter is an array listing all the source media you wish to use for the evaluation of the Sentiment. As explained in the [Usage](#usage), you can list here one or more sources among `'facebook'`, `'instagram'`, `'news'`, `'tiktok'`, `'twitter'`, `'youtube'`. +### Configuration +The `configuration` parameter has some mandatory and some optional elements. Configuration for OpenAI API is mandatory. +It is used to have a qualitative summary of the sentiments retrieved from the media information. +```typescript +configuration = { openai: { apiKey: 'theOpenAIApiKey' } }; +``` +Please, follow the instructions from the [official documentation](https://platform.openai.com/account/api-keys) +in order to create an API key. +In addition to the `openai` attribute and according to the `media` you want to use, you can set more elements in `configuration`: +```typescript +configuration = { + facebook: { accessToken: 'theFacebookAccessToken' }, + instagram: { accessToken: 'theInstagramAccessToken' }, + news: { apiKey: 'theNewsApiKey' }, + tiktok: { accessToken: 'theTikTokAccessToken', videos: theNumberOfVideosToConsider ?? 200 }, + twitter: { + appKey: 'theTwitterAppKey', + appSecret: 'theTwitterAppSecret', + accessToken: 'theTwitterAccessToken', + accessSecret: 'theTwitterAccessSecret', + tweets: theNumberOfTweetsToConsider ?? 100, + }, + youtube: { + apiKey: 'theYouTubeAppKey', + videos: theNumberOfVideosToConsider ?? 100, + comments: theNumberOfCommentsPerVideoToConsider ?? 100; + }, +}; +``` + +#### Facebook +Please, consider this library uses the `/search` endpoint to retrieve posts. Therefore, you need an app tied to a +Facebook's Workplace account. Please, consult the [official documentation](https://developers.facebook.com/docs/graph-api/) +in order to obtain the proper access token for the `configuration`. + +#### Instagram +Similarly to [Facebook](#facebook), you need to set an access token in `configuration`, if you want to use this media as +source of your sentiment analysis. In this case, the `/media` endpoint is used. Please, refer to +[official documentation](https://developers.facebook.com/docs/instagram) for more information. + +#### News API +[News API](https://newsapi.org/) is an innovative service used to retrieve the information about the Sentiment from the +Web pages. Please, use the link above in order to obtain an API key to set into the `configuration` parameter. + +#### TikTok +Even TikTok can be used to retrieve information about the sentiment. This library uses the `v1` API, specifically the +`/search` endpoint, to retrieve suitable data. Please, paste your access token within the `configuration` parameter. +Official documentation available [here](https://developers.tiktok.com/doc/overview/). + +#### Twitter +If you want to use Twitter as media, `config` parameter requires some settings to be used, representing the app key, +app secret, access token and access token secret, respectively. You can consult the +[official documentation](https://developer.twitter.com/en/docs/twitter-api) for more details. + +#### YouTube +In order to use YouTube as well, you need to set your API key to the `configuration` parameter. +Please, login to the [Google Developers Console](https://console.cloud.google.com/apis/dashboard) for more details. + +### Options The `options` has the format `{ strategy: StrategyType, scanPeriodDays: number; scoreThreshold: number; strategyOptions: ScoreStrategyOptions }`, where: @@ -63,85 +125,3 @@ type Score = { type SentimentsType = 'positive' | 'negative' | 'neutral' | 'undefined'; ``` -The method is fully configurable. - -## Configuration -You can use environment variables to configure the library. - -### Package configuration - -#### SENTIMENT_TWITTER_TWEET_COUNT -In case you want to use Twitter as one of your media to collect information about the Sentiment, this key can be used -to set the number of tweets to retrieve in the spanned time range. The tweets are ordered by the descending number of -interactions. - -**Default is 100**. Example: -```dotenv -SENTIMENT_TWITTER_TWEET_COUNT=100 -``` - -#### SENTIMENT_YOUTUBE_VIDEO_COUNT -In case you want to use YouTube as one of your media to collect information about the Sentiment, this key can be used -to set the number of videos to retrieve in the spanned time range. The videos are ordered by the descending number of -interactions. - -**Default is 100**. Example: -```dotenv -SENTIMENT_YOUTUBE_VIDEO_COUNT=100 -``` - -#### SENTIMENT_YOUTUBE_COMMENTS_PER_VIDEO_COUNT -Together with [SENTIMENT_YOUTUBE_VIDEO_COUNT](#sentimentyoutubevideocount), you can use this key to establish the -number of comments per video to retrieve. The comments are ordered by the descending number of interactions. - -**Default is 100**. Example: -```dotenv -SENTIMENT_YOUTUBE_COMMENTS_PER_VIDEO_COUNT=100 -``` - -#### SENTIMENT_TIKTOK_VIDEO_COUNT -Similarly to [YouTube](#sentimentyoutubevideocount), this key can be used with TikTok in order to set the number of videos -used to retrieve the available information (i.e., captions) to evaluate the Sentiment. The videos are ordered by the -descending number of interactions. - -**Default is 200**. Example: -```dotenv -SENTIMENT_TIKTOK_VIDEO_COUNT=200 -``` - -### OpenAI Configuration -OpenAI API key is used to have a qualitative summary of the sentiments retrieved from the media information. Please, -follow the instructions from the [official documentation](https://platform.openai.com/account/api-keys) in order to -create an API key, and store it within the environment variable named `OPENAI_API_KEY`. - -### Media source configuration -The different media sources you want to use should be properly configured. Please, follow the instructions below. - -#### Facebook -You have to set a proper `FACEBOOK_ACCESS_TOKEN` able to host the access token for the GraphQL queries. Please, consider -this library uses the `/search` endpoint to retrieve posts. Therefore, you need an app tied to a Facebook's Workplace -account. Please, consult the [official documentation](https://developers.facebook.com/docs/graph-api/). - -#### Instagram -Similarly to [Facebook](#facebook), you need to set an access token to the environment variable `INSTAGRAM_ACCESS_TOKEN`. -In this case, the `/media` endpoint is used. Please, refer to [official documentation](https://developers.facebook.com/docs/instagram) -for more information. - -#### News API -[News API](https://newsapi.org/) is an innovative service used to retrieve the information about the Sentiment from the -Web pages. Please, use the link above in order to otain an API key, which you will then copy and paste to `NEWS_API_KEY`. - -#### TikTok -Even TikTok can be used to retrieve information about the sentiment. This library uses the `v1` API, specifically the -`/search` endpoint, to retrieve suitable data. Please, paste your access token to the `TIKTOK_ACCESS_TOKEN` environment -variable. Official documentation available [here](https://developers.tiktok.com/doc/overview/). - -#### Twitter -Twitter requires some settings to be used. Specifically: `TWITTER_APP_KEY`, `TWITTER_APP_SECRET`, `TWITTER_ACCESS_TOKEN`, -`TWITTER_ACCESS_TOKEN_SECRET`, representing the app key, app secret, access token and access token secret, respectively. - -You can consult the [official documentation](https://developer.twitter.com/en/docs/twitter-api) for more details. - -#### YouTube -In order to use YouTube as well, you need to set your API key to the `YOUTUBE_API_KEY` environment variables. -Please, login to the [Google Developers Console](https://console.cloud.google.com/apis/dashboard) for more details. diff --git a/package-lock.json b/package-lock.json index 4e768c4..3ed4f32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,16 @@ { "name": "@matteocacciola/sentiment", - "version": "1.0.4", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@matteocacciola/sentiment", - "version": "1.0.4", + "version": "1.1.0", "license": "MIT", "dependencies": { "@google-cloud/language": "^5.2.1", "axios": "^1.3.4", - "dotenv": "^16.0.3", "google-gax": "^3.5.8", "googleapis": "^113.0.0", "lodash": "^4.17.21", @@ -2234,14 +2233,6 @@ "node": ">=6.0.0" } }, - "node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", - "engines": { - "node": ">=12" - } - }, "node_modules/duplexify": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", @@ -7623,11 +7614,6 @@ "esutils": "^2.0.2" } }, - "dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==" - }, "duplexify": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", diff --git a/package.json b/package.json index 3a4dae7..c915269 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "dependencies": { "@google-cloud/language": "^5.2.1", "axios": "^1.3.4", - "dotenv": "^16.0.3", "google-gax": "^3.5.8", "googleapis": "^113.0.0", "lodash": "^4.17.21", @@ -75,5 +74,5 @@ "prepublishOnly": "npm run build", "test": "TZ=utc NODE_ENV=test vitest run --coverage" }, - "version": "1.0.4" + "version": "1.1.0" } diff --git a/src/clients/__tests__/facebook.test.ts b/src/clients/__tests__/facebook.test.ts index 8b47588..2d580f8 100644 --- a/src/clients/__tests__/facebook.test.ts +++ b/src/clients/__tests__/facebook.test.ts @@ -8,6 +8,7 @@ vitest.mock('../../utils/axios'); const company = 'company'; const timerange: DateRange = { since: '2022-01-01', until: '2022-01-31' }; +const configuration = { accessToken: 'aToken' }; describe('FacebookClient.getPosts', () => { beforeEach(() => { @@ -17,7 +18,7 @@ describe('FacebookClient.getPosts', () => { it('should return an array of posts', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockResolvedValueOnce(mockedSearchPosts); - const actualPosts = await FacebookClient.getPosts(company, timerange); + const actualPosts = await FacebookClient.getPosts(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualPosts).toEqual(mockedPostTexts); @@ -26,7 +27,7 @@ describe('FacebookClient.getPosts', () => { it('should return an empty array if no post is found', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockResolvedValueOnce(mockedEmptyPostTexts); - const actualPosts = await FacebookClient.getPosts(company, timerange); + const actualPosts = await FacebookClient.getPosts(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualPosts).toEqual(mockedEmptyPostTexts); @@ -35,7 +36,7 @@ describe('FacebookClient.getPosts', () => { it('should throw an error if the Facebook API returns an error', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockRejectedValueOnce(new Error('API error')); - const actualPosts = await FacebookClient.getPosts(company, timerange); + const actualPosts = await FacebookClient.getPosts(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualPosts).toEqual(mockedEmptyPostTexts); diff --git a/src/clients/__tests__/instagram.test.ts b/src/clients/__tests__/instagram.test.ts index 04188b9..30cb433 100644 --- a/src/clients/__tests__/instagram.test.ts +++ b/src/clients/__tests__/instagram.test.ts @@ -8,6 +8,7 @@ vitest.mock('../../utils/axios'); const company = 'company'; const timerange: DateRange = { since: '2022-01-01', until: '2022-01-31' }; +const configuration = { accessToken: 'aToken' }; describe('InstagramClient.getInsta', () => { beforeEach(() => { @@ -17,7 +18,7 @@ describe('InstagramClient.getInsta', () => { it('should return an array of posts', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockResolvedValueOnce(mockedSearchInsta); - const actualPosts = await InstagramClient.getInsta(company, timerange); + const actualPosts = await InstagramClient.getInsta(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualPosts).toEqual(mockedInstaTexts); @@ -26,7 +27,7 @@ describe('InstagramClient.getInsta', () => { it('should return an empty array if no post is found', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockResolvedValueOnce(mockedEmptyInstaTexts); - const actualPosts = await InstagramClient.getInsta(company, timerange); + const actualPosts = await InstagramClient.getInsta(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualPosts).toEqual(mockedEmptyInstaTexts); @@ -35,7 +36,7 @@ describe('InstagramClient.getInsta', () => { it('should throw an error if the Instagram API returns an error', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockRejectedValueOnce(new Error('API error')); - const actualPosts = await InstagramClient.getInsta(company, timerange); + const actualPosts = await InstagramClient.getInsta(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualPosts).toEqual(mockedEmptyInstaTexts); diff --git a/src/clients/__tests__/news.test.ts b/src/clients/__tests__/news.test.ts index 49b9148..1aa7d53 100644 --- a/src/clients/__tests__/news.test.ts +++ b/src/clients/__tests__/news.test.ts @@ -8,6 +8,7 @@ vitest.mock('../../utils/axios'); const company = 'company'; const timerange: DateRange = { since: '2022-01-01', until: '2022-01-31' }; +const configuration = { apiKey: 'aKey' }; describe('NewsClient.getNews', () => { beforeEach(() => { @@ -17,7 +18,7 @@ describe('NewsClient.getNews', () => { it('should return an array of news', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockResolvedValueOnce(mockedNews); - const actualNews = await NewsClient.getNews(company, timerange); + const actualNews = await NewsClient.getNews(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualNews).toEqual(mockedNewsTexts); @@ -26,7 +27,7 @@ describe('NewsClient.getNews', () => { it('should return an empty array if no result is found', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockResolvedValueOnce(mockedEmptyNewsTexts); - const actualNews = await NewsClient.getNews(company, timerange); + const actualNews = await NewsClient.getNews(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualNews).toEqual(mockedEmptyNewsTexts); @@ -35,7 +36,7 @@ describe('NewsClient.getNews', () => { it('should throw an error if the News API returns an error', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockRejectedValueOnce(new Error('API error')); - const actualNews = await NewsClient.getNews(company, timerange); + const actualNews = await NewsClient.getNews(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualNews).toEqual(mockedEmptyNewsTexts); diff --git a/src/clients/__tests__/tiktok.test.ts b/src/clients/__tests__/tiktok.test.ts index 18b419a..f634bcc 100644 --- a/src/clients/__tests__/tiktok.test.ts +++ b/src/clients/__tests__/tiktok.test.ts @@ -3,12 +3,13 @@ import { Axios } from '../../utils/axios'; import { TiktokClient } from '../tiktok'; import { mockedEmptyVideosCaptions, mockedVideos, mockedVideosCaptions } from './mocks/tiktok'; import { DateRange } from '../../types'; +import { omit } from 'lodash'; vitest.mock('../../utils/axios'); const company = 'company'; const timerange: DateRange = { since: '2022-01-01', until: '2022-01-31' }; -const count = 200; +const configuration = { accessToken: 'aToken', count: 300 }; describe('TikTok.getCaptions', () => { beforeEach(() => { @@ -18,7 +19,7 @@ describe('TikTok.getCaptions', () => { it('should return an array of captions', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockResolvedValueOnce(mockedVideos); - const actualCaptions = await TiktokClient.getCaptions(company, timerange, count); + const actualCaptions = await TiktokClient.getCaptions(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualCaptions).toEqual(mockedVideosCaptions); @@ -27,7 +28,7 @@ describe('TikTok.getCaptions', () => { it('should return an empty array if no video is found', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockResolvedValueOnce(mockedEmptyVideosCaptions); - const actualCaptions = await TiktokClient.getCaptions(company, timerange, count); + const actualCaptions = await TiktokClient.getCaptions(company, timerange, omit(configuration, 'count')); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualCaptions).toEqual(mockedEmptyVideosCaptions); @@ -36,7 +37,7 @@ describe('TikTok.getCaptions', () => { it('should return an empty array if the TikTok API returns an error', async () => { const mockedAxiosGet = vitest.spyOn(Axios, 'get').mockRejectedValueOnce(new Error('API error')); - const actualCaptions = await TiktokClient.getCaptions(company, timerange, count); + const actualCaptions = await TiktokClient.getCaptions(company, timerange, configuration); expect(mockedAxiosGet).toHaveBeenCalledTimes(1); expect(actualCaptions).toEqual(mockedEmptyVideosCaptions); diff --git a/src/clients/__tests__/twitter.test.ts b/src/clients/__tests__/twitter.test.ts index 15e10aa..16183c2 100644 --- a/src/clients/__tests__/twitter.test.ts +++ b/src/clients/__tests__/twitter.test.ts @@ -3,6 +3,7 @@ import { TwitterApi } from 'twitter-api-v2'; import { TwitterClient } from '../twitter'; import { DateRange } from '../../types'; import { mockedData, mockedDataTexts, mockedEmptyData } from './mocks/twitter'; +import { omit } from 'lodash'; vitest.mock('twitter-api-v2'); @@ -11,7 +12,13 @@ const timerange: DateRange = { since: '2022-01-01T00:00:00.000Z', until: '2022-01-02T00:00:00.000Z', }; -const count = 100; +const configuration = { + appKey: 'aKey', + appSecret: 'aSecret', + accessToken: 'aToken', + accessSecret: 'anotherSecret', + tweets: 1000, +}; describe('TwitterClient', () => { describe('getTweets', () => { @@ -28,13 +35,13 @@ describe('TwitterClient', () => { }; (TwitterApi as unknown as Mock).mockImplementation(() => client); - const result = await TwitterClient.getTweets(company, timerange, count); + const result = await TwitterClient.getTweets(company, timerange, configuration); expect(client.appLogin).toHaveBeenCalledTimes(1); expect(client.v2.get).toHaveBeenCalledTimes(1); expect(client.v2.get).toHaveBeenCalledWith('tweets/search/recent', { query: company, - max_results: count, + max_results: configuration.tweets, start_time: timerange.since, end_time: timerange.until, fields: 'text', @@ -52,7 +59,7 @@ describe('TwitterClient', () => { }; (TwitterApi as unknown as Mock).mockImplementation(() => client); - const result = await TwitterClient.getTweets(company, timerange, count); + const result = await TwitterClient.getTweets(company, timerange, omit(configuration, 'tweets')); expect(result).toEqual([]); }); @@ -67,7 +74,7 @@ describe('TwitterClient', () => { }; (TwitterApi as unknown as Mock).mockImplementation(() => client); - const result = await TwitterClient.getTweets(company, timerange, count); + const result = await TwitterClient.getTweets(company, timerange, configuration); expect(result).toEqual([]); }); diff --git a/src/clients/__tests__/youtube.test.ts b/src/clients/__tests__/youtube.test.ts index a13b16e..aa386d4 100644 --- a/src/clients/__tests__/youtube.test.ts +++ b/src/clients/__tests__/youtube.test.ts @@ -8,6 +8,7 @@ import { mockedEmptySearchResult, mockedEmptyVideoCommentsTexts, mockedSearchResult, mockedVideoCommentsTexts, } from './mocks/youtube'; +import { omit } from 'lodash'; vitest.mock('googleapis'); @@ -16,8 +17,11 @@ const timerange: DateRange = { since: '2022-01-01T00:00:00.000Z', until: '2022-01-02T00:00:00.000Z', }; -const videoCount = 10; -const commentsCount = 20; +const configuration = { + apiKey: 'aKey', + videos: 10, + comments: 20, +}; // eslint-disable-next-line max-lines-per-function describe('YoutubeClient', () => { @@ -28,13 +32,14 @@ describe('YoutubeClient', () => { describe('getSearchResult', () => { it('should return a comma-separated string of video IDs', async () => { const client = { search: { list: vitest.fn().mockResolvedValue(mockedSearchResult) } }; - const result = await YoutubeClient.getSearchResult(client as any, company, timerange, videoCount); + const videos = configuration.videos; + const result = await YoutubeClient.getSearchResult(client as any, company, timerange, videos); expect(client.search.list).toHaveBeenCalledWith({ part: ['id'], q: `${company} video`, type: ['video'], - maxResults: videoCount, + maxResults: videos, publishedAfter: timerange.since, publishedBefore: timerange.until, }); @@ -43,13 +48,14 @@ describe('YoutubeClient', () => { it('should return an empty string if no video IDs are found', async () => { const client = { search: { list: vitest.fn().mockResolvedValue(mockedEmptySearchResult) } }; - const result = await YoutubeClient.getSearchResult(client as any, company, timerange, videoCount); + const videos = configuration.videos; + const result = await YoutubeClient.getSearchResult(client as any, company, timerange, videos); expect(client.search.list).toHaveBeenCalledWith({ part: ['id'], q: `${company} video`, type: ['video'], - maxResults: videoCount, + maxResults: videos, publishedAfter: timerange.since, publishedBefore: timerange.until, }); @@ -58,15 +64,15 @@ describe('YoutubeClient', () => { it('should catch errors and return an empty string', async () => { const client = { search: { list: vitest.fn().mockRejectedValue(new Error('API error')) } }; - - await expect(() => YoutubeClient.getSearchResult(client as any, company, timerange, videoCount)) + const videos = configuration.videos; + await expect(() => YoutubeClient.getSearchResult(client as any, company, timerange, videos)) .rejects.toThrowError('API error'); expect(client.search.list).toHaveBeenCalledWith({ part: ['id'], q: `${company} video`, type: ['video'], - maxResults: videoCount, + maxResults: videos, publishedAfter: timerange.since, publishedBefore: timerange.until, }); @@ -86,15 +92,16 @@ describe('YoutubeClient', () => { list: commentResultMock, }, }); + const { videos, comments } = configuration; - const result = await YoutubeClient.getComments(company, timerange, videoCount, commentsCount); + const result = await YoutubeClient.getComments(company, timerange, configuration); expect(searchResultMock).toHaveBeenCalledTimes(1); expect(searchResultMock).toHaveBeenCalledWith({ part: ['id'], q: `${company} video`, type: ['video'], - maxResults: videoCount, + maxResults: videos, publishedAfter: timerange.since, publishedBefore: timerange.until, }); @@ -103,7 +110,7 @@ describe('YoutubeClient', () => { expect(commentResultMock).toHaveBeenCalledWith({ part: ['snippet'], videoId: 'abc123,def456', - maxResults: commentsCount, + maxResults: comments, }); expect(result).toEqual(mockedVideoCommentsTexts); @@ -122,14 +129,14 @@ describe('YoutubeClient', () => { }, }); - const result = await YoutubeClient.getComments(company, timerange, videoCount, commentsCount); + const result = await YoutubeClient.getComments(company, timerange, omit(configuration, ['videos', 'comments'])); expect(searchResultMock).toHaveBeenCalledTimes(1); expect(searchResultMock).toHaveBeenCalledWith({ part: ['id'], q: `${company} video`, type: ['video'], - maxResults: videoCount, + maxResults: 100, publishedAfter: timerange.since, publishedBefore: timerange.until, }); @@ -138,7 +145,7 @@ describe('YoutubeClient', () => { expect(commentResultMock).toHaveBeenCalledWith({ part: ['snippet'], videoId: 'abc123,def456', - maxResults: commentsCount, + maxResults: 100, }); expect(result).toEqual(mockedEmptyVideoCommentsTexts); @@ -154,15 +161,16 @@ describe('YoutubeClient', () => { list: vitest.fn(), }, }); + const { videos } = configuration; - const result = await YoutubeClient.getComments(company, timerange, videoCount, commentsCount); + const result = await YoutubeClient.getComments(company, timerange, configuration); expect(searchResultMock).toHaveBeenCalledTimes(1); expect(searchResultMock).toHaveBeenCalledWith({ part: ['id'], q: `${company} video`, type: ['video'], - maxResults: videoCount, + maxResults: videos, publishedAfter: timerange.since, publishedBefore: timerange.until, }); @@ -181,15 +189,16 @@ describe('YoutubeClient', () => { list: vitest.fn(), }, }); + const { videos } = configuration; - const result = await YoutubeClient.getComments(company, timerange, videoCount, commentsCount); + const result = await YoutubeClient.getComments(company, timerange, configuration); expect(searchResultMock).toHaveBeenCalledTimes(1); expect(searchResultMock).toHaveBeenCalledWith({ part: ['id'], q: `${company} video`, type: ['video'], - maxResults: videoCount, + maxResults: videos, publishedAfter: timerange.since, publishedBefore: timerange.until, }); diff --git a/src/clients/facebook.ts b/src/clients/facebook.ts index 6a1d4ae..4c508db 100644 --- a/src/clients/facebook.ts +++ b/src/clients/facebook.ts @@ -1,12 +1,15 @@ -import { FACEBOOK } from '../constants'; import { Axios } from '../utils/axios'; import { DateRange } from '../types'; +import { FacebookClientType } from './types'; export namespace FacebookClient { const baseUrl = 'https://graph.facebook.com/search'; - const accessToken = FACEBOOK.ACCESS_TOKEN; - export const getPosts = async (company: string, { since, until }: DateRange): Promise => { + export const getPosts = async ( + company: string, + { since, until }: DateRange, + { accessToken }: FacebookClientType, + ): Promise => { try { const data = await Axios.get(baseUrl, { q: company, diff --git a/src/clients/instagram.ts b/src/clients/instagram.ts index 1b7501d..517e967 100644 --- a/src/clients/instagram.ts +++ b/src/clients/instagram.ts @@ -1,12 +1,15 @@ -import { INSTAGRAM } from '../constants'; import { Axios } from '../utils/axios'; import { DateRange } from '../types'; +import { InstagramClientType } from './types'; export namespace InstagramClient { const baseUrl = 'https://graph.instagram.com/media'; - const accessToken = INSTAGRAM.ACCESS_TOKEN; - export const getInsta = async (company: string, { since, until }: DateRange): Promise => { + export const getInsta = async ( + company: string, + { since, until }: DateRange, + { accessToken }: InstagramClientType, + ): Promise => { try { const data = await Axios.get(baseUrl, { fields: 'caption,id,media_type,permalink,timestamp,username', diff --git a/src/clients/news.ts b/src/clients/news.ts index 0b93290..8ee4945 100644 --- a/src/clients/news.ts +++ b/src/clients/news.ts @@ -1,6 +1,6 @@ -import { NEWS } from '../constants'; import { Axios } from '../utils/axios'; import { DateRange } from '../types'; +import { NewsClientType } from './types'; export namespace NewsClient { type NewsApiResponse = { @@ -14,9 +14,12 @@ export namespace NewsClient { } const baseUrl = 'https://newsapi.org/v2/everything'; - const apiKey = NEWS.API_KEY; - export const getNews = async (company: string, { since, until }: DateRange): Promise => { + export const getNews = async ( + company: string, + { since, until }: DateRange, + { apiKey }: NewsClientType, + ): Promise => { try { const { articles } = await Axios.get(baseUrl, { q: company, diff --git a/src/clients/tiktok.ts b/src/clients/tiktok.ts index c92d08e..281a083 100644 --- a/src/clients/tiktok.ts +++ b/src/clients/tiktok.ts @@ -1,13 +1,16 @@ -import { TIKTOK } from '../constants'; import { Axios } from '../utils/axios'; import { DateRange } from '../types'; +import { TiktokClientType } from './types'; export namespace TiktokClient { const baseUrl = 'https://api.tiktok.com'; const apiVersion = 'v1'; - const accessToken = TIKTOK.ACCESS_TOKEN; - async function getCompanyVideos(company: string, { since, until }: DateRange, count: number): Promise { + async function getCompanyVideos( + company: string, + { since, until }: DateRange, + { accessToken, videos: count = 200 }: TiktokClientType, + ): Promise { try { const { videos } = await Axios.get(`${baseUrl}/${apiVersion}/search/`, { params: { @@ -28,8 +31,12 @@ export namespace TiktokClient { } } - export const getCaptions = async (company: string, timerange: DateRange, count: number): Promise => { - const videos = await getCompanyVideos(company, timerange, count); + export const getCaptions = async ( + company: string, + timerange: DateRange, + configuration: TiktokClientType, + ): Promise => { + const videos = await getCompanyVideos(company, timerange, configuration); if (!videos) { return []; } diff --git a/src/clients/twitter.ts b/src/clients/twitter.ts index 0c65ac1..45882fb 100644 --- a/src/clients/twitter.ts +++ b/src/clients/twitter.ts @@ -1,23 +1,21 @@ import { TwitterApi } from 'twitter-api-v2'; -import { TWITTER } from '../constants'; import { DateRange } from '../types'; +import { omit } from 'lodash'; +import { TwitterClientType } from './types'; export namespace TwitterClient { - const twitterConfig = { - appKey: TWITTER.APP_KEY, - appSecret: TWITTER.APP_SECRET, - accessToken: TWITTER.ACCESS_TOKEN, - accessSecret: TWITTER.ACCESS_TOKEN_SECRET, - }; - - export const getTweets = async (company: string, { since, until }: DateRange, count: number ): Promise => { + export const getTweets = async ( + company: string, + { since, until }: DateRange, + configuration: TwitterClientType, + ): Promise => { try { - const client = new TwitterApi(twitterConfig); + const client = new TwitterApi(omit(configuration, 'tweets')); await client.appLogin(); const { data } = await client.v2.get('tweets/search/recent', { query: company, - max_results: count, + max_results: configuration.tweets ?? 100, start_time: since, end_time: until, fields: 'text', diff --git a/src/clients/types.ts b/src/clients/types.ts new file mode 100644 index 0000000..1e3913b --- /dev/null +++ b/src/clients/types.ts @@ -0,0 +1,23 @@ +import { WithRequiredAndNotNullProperty } from '../types'; + +type AccessTokenConfigurationType = { + accessToken: string; + accessSecret?: string; +}; +type ApiKeyConfigurationType = { + apiKey: string; +}; +type AppKeyConfigurationType = { + appKey: string; + appSecret: string; +}; + +export type FacebookClientType = AccessTokenConfigurationType; +export type InstagramClientType = AccessTokenConfigurationType; +export type NewsClientType = ApiKeyConfigurationType; +export type TiktokClientType = AccessTokenConfigurationType & { videos?: number; }; +export type TwitterClientType = AppKeyConfigurationType & + WithRequiredAndNotNullProperty & + { tweets?: number }; +export type YoutubeClientType = ApiKeyConfigurationType & { videos?: number; comments?: number; }; +export type OpenAiClientType = ApiKeyConfigurationType; diff --git a/src/clients/youtube.ts b/src/clients/youtube.ts index 987a739..62189c0 100644 --- a/src/clients/youtube.ts +++ b/src/clients/youtube.ts @@ -1,19 +1,19 @@ import { google } from 'googleapis'; -import { YOUTUBE } from '../constants'; import { DateRange } from '../types'; +import { YoutubeClientType } from './types'; export namespace YoutubeClient { export const getSearchResult = async ( client: ReturnType, company: string, { since, until }: DateRange, - videoCount: number, - ) => { + videos: number, + ): Promise => { const searchResult = await client.search.list({ part: ['id'], q: `${company} video`, type: ['video'], - maxResults: videoCount, + maxResults: videos, publishedAfter: since, publishedBefore: until, }); @@ -24,17 +24,16 @@ export namespace YoutubeClient { export const getComments = async ( company: string, timerange: DateRange, - videoCount: number, - commentsCount: number, + { apiKey, videos = 100, comments = 100 }: YoutubeClientType, ): Promise => { try { const client: ReturnType = google.youtube({ version: 'v3', - auth: YOUTUBE.API_KEY, + auth: apiKey, }); - const videoIds = await getSearchResult(client, company, timerange, videoCount); + const videoIds = await getSearchResult(client, company, timerange, videos); if (!videoIds) { return []; } @@ -42,7 +41,7 @@ export namespace YoutubeClient { const commentResult = await client.commentThreads.list({ part: ['snippet'], videoId: videoIds, - maxResults: commentsCount, + maxResults: comments, }); return (commentResult.data.items || []).map((item: any) => item.snippet?.topLevelComment?.snippet?.textDisplay); diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 5fcc922..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,48 +0,0 @@ -import process from 'process'; - -export const FACEBOOK = { - ACCESS_TOKEN: `${process.env.FACEBOOK_ACCESS_TOKEN}`, -}; - -export const INSTAGRAM = { - ACCESS_TOKEN: `${process.env.INSTAGRAM_ACCESS_TOKEN}`, -}; - -export const TWITTER = { - APP_KEY: `${process.env.TWITTER_APP_KEY}`, - APP_SECRET: `${process.env.TWITTER_APP_SECRET}`, - ACCESS_TOKEN: `${process.env.TWITTER_ACCESS_TOKEN}`, - ACCESS_TOKEN_SECRET: `${process.env.TWITTER_ACCESS_TOKEN_SECRET}`, -}; - -export const YOUTUBE = { - API_KEY: `${process.env.YOUTUBE_API_KEY}`, -}; - -export const TIKTOK = { - ACCESS_TOKEN: `${process.env.TIKTOK_ACCESS_TOKEN}`, -}; - -export const NEWS = { - API_KEY: `${process.env.NEWS_API_KEY}`, -}; - -export const OPENAI = { - API_KEY: `${process.env.OPENAI_API_KEY}`, -}; - -export const CONFIG = { - MEDIA_ENABLED: process.env.SENTIMENT_MEDIA_ENABLED, - TWITTER: { - COUNT: Number(process.env.SENTIMENT_TWITTER_TWEET_COUNT ?? 100), - }, - YOUTUBE: { - COUNT: { - VIDEO: Number(process.env.SENTIMENT_YOUTUBE_VIDEO_COUNT ?? 100), - COMMENTS: Number(process.env.SENTIMENT_YOUTUBE_COMMENTS_PER_VIDEO_COUNT ?? 100), - }, - }, - TIKTOK: { - COUNT: Number(process.env.SENTIMENT_TIKTOK_VIDEO_COUNT ?? 200), - }, -}; diff --git a/src/helpers/__tests__/getCompanyMediaSentiment.test.ts b/src/helpers/__tests__/getCompanyMediaSentiment.test.ts index 8445f9c..c527b68 100644 --- a/src/helpers/__tests__/getCompanyMediaSentiment.test.ts +++ b/src/helpers/__tests__/getCompanyMediaSentiment.test.ts @@ -23,14 +23,46 @@ const scoreThreshold = 0.3; const mockedOpenAISummary = 'This is a summary from ChatGPT'; const now = '2023-01-01T00:00:00.000Z'; -vitest.useFakeTimers().setSystemTime(new Date(now)); +const errorConfiguration = { openai: { apiKey: 'aToken' } }; +const successConfiguration = { + ...errorConfiguration, + facebook: { accessToken: 'aToken' }, + instagram: { accessToken: 'aToken' }, + news: { apiKey: 'aKey' }, + tiktok: { accessToken: 'aToken', count: 300 }, + twitter: { + appKey: 'aKey', + appSecret: 'aSecret', + accessToken: 'aToken', + accessSecret: 'anotherSecret', + tweets: 1000, + }, + youtube: { + apiKey: 'aKey', + videos: 10, + comments: 20, + }, +}; +vitest.useFakeTimers().setSystemTime(new Date(now)); vitest.spyOn(OpenAI, 'getSummary').mockResolvedValue(mockedOpenAISummary); -const getResults = async (media: MediaType, mockedElements: string[], module: any, methodName: string) => { +const getResults = async ( + media: MediaType, + mockedElements: string[], + module: any, + methodName: string, + configuration: any, +) => { vitest.spyOn(module, methodName).mockResolvedValue(mockedElements); - const result = await getCompanyMediaSentiment(company, media, timerange, strategyType, scoreThreshold); + const result = await getCompanyMediaSentiment(company, + media, + timerange, + configuration, + strategyType, + scoreThreshold, + ); const expectedAnalyzedElements = mockedElements.map((text, index) => { const { score, category } = result!.analyzedElements[index]; return { text, score, category }; @@ -45,11 +77,33 @@ describe('getCompanyMediaSentiment', () => { it('get with empty results from provider', async () => { vitest.spyOn(FacebookClient, 'getPosts').mockResolvedValueOnce([]); - const result = await getCompanyMediaSentiment(company, 'facebook', timerange, strategyType, scoreThreshold); + const result = await getCompanyMediaSentiment( + company, + 'facebook', + timerange, + { facebook: { accessToken: 'aToken' }, openai: { apiKey: 'aToken' } }, + strategyType, + scoreThreshold, + ); expect(result).toBeNull(); }); + it.each([ + ['facebook', 'Facebook', mockedPostTexts, FacebookClient, 'getPosts'], + ['instagram', 'Instagram', mockedInstaTexts, InstagramClient, 'getInsta'], + ['news', 'NewsAPI', mockedNewsTexts, NewsClient, 'getNews'], + ['tiktok', 'TikTok', mockedVideosCaptions, TiktokClient, 'getCaptions'], + ['twitter', 'Twitter', mockedDataTexts, TwitterClient, 'getTweets'], + ['youtube', 'YouTube', mockedVideoCommentsTexts, YoutubeClient, 'getComments'], + ])( + 'get error with "%s" as provider and missing configuration', + async (media, error, mockedElements, module, method) => { + await expect(getResults(media as MediaType, mockedElements, module, method, errorConfiguration)) + .rejects.toEqual(new Error(`Invalid ${error} configuration`)); + }, + ); + it.each([ ['facebook', mockedPostTexts, FacebookClient, 'getPosts'], ['instagram', mockedInstaTexts, InstagramClient, 'getInsta'], @@ -58,7 +112,13 @@ describe('getCompanyMediaSentiment', () => { ['twitter', mockedDataTexts, TwitterClient, 'getTweets'], ['youtube', mockedVideoCommentsTexts, YoutubeClient, 'getComments'], ])('get with "%s" as provider', async (media, mockedElements, module, method) => { - const { result, expectedAnalyzedElements } = await getResults(media as MediaType, mockedElements, module, method); + const { result, expectedAnalyzedElements } = await getResults( + media as MediaType, + mockedElements, + module, + method, + successConfiguration, + ); const { positive, negative, neutral } = result!; expect(result).toEqual(expect.objectContaining({ @@ -70,5 +130,6 @@ describe('getCompanyMediaSentiment', () => { expect(positive).toBeTypeOf('number'); expect(negative).toBeTypeOf('number'); expect(neutral).toBeTypeOf('number'); - }); + }, + ); }); diff --git a/src/helpers/getCompanyMediaSentiment.ts b/src/helpers/getCompanyMediaSentiment.ts index 52b7d7d..c583564 100644 --- a/src/helpers/getCompanyMediaSentiment.ts +++ b/src/helpers/getCompanyMediaSentiment.ts @@ -1,4 +1,4 @@ -import { DateRange, MediaType, SentimentAnalysisResult } from '../types'; +import { DateRange, MediaType, SentimentAnalysisResult, SentimentConfigurationType } from '../types'; import { sentimentMediaFactory } from '../providers/factory'; import { OpenAI } from '../utils/openai'; import { ScoreStrategyOptions, StrategyType } from '../strategies/types'; @@ -7,11 +7,20 @@ export const getCompanyMediaSentiment = async ( company: string, media: MediaType, timerange: DateRange, + configuration: SentimentConfigurationType, strategyType: StrategyType, scoreThreshold: number, strategyOptions?: ScoreStrategyOptions, ): Promise => { - const result = await sentimentMediaFactory(media)(company, timerange, strategyType, scoreThreshold, strategyOptions); + const { openai } = configuration; + const result = await sentimentMediaFactory(media)( + company, + timerange, + strategyType, + scoreThreshold, + configuration, + strategyOptions, + ); if (!result) { return null; } @@ -19,7 +28,7 @@ export const getCompanyMediaSentiment = async ( const { sentimentStats, scores: analyzedElements } = result; // Generate summary text using OpenAI API - const summary = await OpenAI.getSummary(company, media, analyzedElements.map((element) => element.text)); + const summary = await OpenAI.getSummary(openai, company, media, analyzedElements.map((element) => element.text)); return { ...sentimentStats, diff --git a/src/methods/sentiment.ts b/src/methods/sentiment.ts index c730e29..f7dec49 100644 --- a/src/methods/sentiment.ts +++ b/src/methods/sentiment.ts @@ -1,4 +1,4 @@ -import { SentimentAnalysisResult } from '../types'; +import { SentimentAnalysisResult, SentimentConfigurationType } from '../types'; import { getTimerange } from '../helpers/getTimerange'; import { validator } from '../validators/sentiment'; import { getCompanyMediaSentiment } from '../helpers/getCompanyMediaSentiment'; @@ -16,6 +16,7 @@ type SentimentConfig = { export const sentiment = async ( company: string, media: string[], + configuration: SentimentConfigurationType, options?: SentimentConfig, ): Promise => { const validatedMedia = validator(media); @@ -29,6 +30,7 @@ export const sentiment = async ( company, medium, timerange, + configuration, strategy ?? 'afinn', scaledScoreThreshold, strategyOptions, diff --git a/src/providers/facebook.ts b/src/providers/facebook.ts index 095f861..4fa3a73 100644 --- a/src/providers/facebook.ts +++ b/src/providers/facebook.ts @@ -1,5 +1,5 @@ import { FacebookClient } from '../clients/facebook'; -import { AnalysisResultType, DateRange, ProviderFunctionType } from '../types'; +import { AnalysisResultType, DateRange, ProviderFunctionType, SentimentConfigurationType } from '../types'; import { getAnalysisResults } from '../strategies/helpers/getAnalysisResults'; import { ScoreStrategyOptions, StrategyType } from '../strategies/types'; @@ -8,9 +8,13 @@ export const analyze: ProviderFunctionType = async ( timerange: DateRange, strategyType: StrategyType, scoreThreshold: number, + configuration: SentimentConfigurationType, strategyOptions?: ScoreStrategyOptions, ): Promise => { - const posts = await FacebookClient.getPosts(company, timerange); + if (!configuration.facebook) { + throw new Error('Invalid Facebook configuration'); + } + const posts = await FacebookClient.getPosts(company, timerange, configuration.facebook); - return getAnalysisResults(company, 'facebook', posts, strategyType, scoreThreshold, strategyOptions); + return getAnalysisResults(company, posts, strategyType, scoreThreshold, strategyOptions); }; diff --git a/src/providers/factory.ts b/src/providers/factory.ts index f0ba8d4..8ab436c 100644 --- a/src/providers/factory.ts +++ b/src/providers/factory.ts @@ -18,10 +18,10 @@ export const sentimentMediaFactory = (media: MediaType): ProviderFunctionType => const provider = cond([ [isFacebook, constant(facebookAnalyze)], [isInstagram, constant(instagramAnalyze)], - [isTwitter, constant(twitterAnalyze)], - [isYoutube, constant(youtubeAnalyze)], [isNews, constant(newsAnalyze)], [isTiktok, constant(tiktokAnalyze)], + [isTwitter, constant(twitterAnalyze)], + [isYoutube, constant(youtubeAnalyze)], [stubTrue, () => { throw new Error('Invalid media provider'); }], diff --git a/src/providers/instagram.ts b/src/providers/instagram.ts index 224077f..86fad47 100644 --- a/src/providers/instagram.ts +++ b/src/providers/instagram.ts @@ -1,5 +1,5 @@ import { InstagramClient } from '../clients/instagram'; -import { AnalysisResultType, DateRange, ProviderFunctionType } from '../types'; +import { AnalysisResultType, DateRange, ProviderFunctionType, SentimentConfigurationType } from '../types'; import { getAnalysisResults } from '../strategies/helpers/getAnalysisResults'; import { ScoreStrategyOptions, StrategyType } from '../strategies/types'; @@ -8,9 +8,13 @@ export const analyze: ProviderFunctionType = async ( timerange: DateRange, strategyType: StrategyType, scoreThreshold: number, + configuration: SentimentConfigurationType, strategyOptions?: ScoreStrategyOptions, ): Promise => { - const insta = await InstagramClient.getInsta(company, timerange); + if (!configuration.instagram) { + throw new Error('Invalid Instagram configuration'); + } + const insta = await InstagramClient.getInsta(company, timerange, configuration.instagram); - return getAnalysisResults(company, 'instagram', insta, strategyType, scoreThreshold, strategyOptions); + return getAnalysisResults(company, insta, strategyType, scoreThreshold, strategyOptions); }; diff --git a/src/providers/news.ts b/src/providers/news.ts index b592efa..e0f3e42 100644 --- a/src/providers/news.ts +++ b/src/providers/news.ts @@ -1,4 +1,4 @@ -import { AnalysisResultType, DateRange, ProviderFunctionType } from '../types'; +import { AnalysisResultType, DateRange, ProviderFunctionType, SentimentConfigurationType } from '../types'; import { NewsClient } from '../clients/news'; import { getAnalysisResults } from '../strategies/helpers/getAnalysisResults'; import { ScoreStrategyOptions, StrategyType } from '../strategies/types'; @@ -8,9 +8,13 @@ export const analyze: ProviderFunctionType = async ( timerange: DateRange, strategyType: StrategyType, scoreThreshold: number, + configuration: SentimentConfigurationType, strategyOptions?: ScoreStrategyOptions, ): Promise => { - const articles = await NewsClient.getNews(company, timerange); + if (!configuration.news) { + throw new Error('Invalid NewsAPI configuration'); + } + const articles = await NewsClient.getNews(company, timerange, configuration.news); - return getAnalysisResults(company, 'news', articles, strategyType, scoreThreshold, strategyOptions); + return getAnalysisResults(company, articles, strategyType, scoreThreshold, strategyOptions); }; diff --git a/src/providers/tiktok.ts b/src/providers/tiktok.ts index 9309ef0..29bd456 100644 --- a/src/providers/tiktok.ts +++ b/src/providers/tiktok.ts @@ -1,7 +1,6 @@ -import { AnalysisResultType, DateRange, ProviderFunctionType } from '../types'; +import { AnalysisResultType, DateRange, ProviderFunctionType, SentimentConfigurationType } from '../types'; import { TiktokClient } from '../clients/tiktok'; import { getAnalysisResults } from '../strategies/helpers/getAnalysisResults'; -import { CONFIG } from '../constants'; import { ScoreStrategyOptions, StrategyType } from '../strategies/types'; export const analyze: ProviderFunctionType = async ( @@ -9,9 +8,13 @@ export const analyze: ProviderFunctionType = async ( timerange: DateRange, strategyType: StrategyType, scoreThreshold: number, + configuration: SentimentConfigurationType, strategyOptions?: ScoreStrategyOptions, ): Promise => { - const captions = await TiktokClient.getCaptions(company, timerange, CONFIG.TIKTOK.COUNT); + if (!configuration.tiktok) { + throw new Error('Invalid TikTok configuration'); + } + const captions = await TiktokClient.getCaptions(company, timerange, configuration.tiktok); - return getAnalysisResults(company, 'tiktok', captions, strategyType, scoreThreshold, strategyOptions); + return getAnalysisResults(company, captions, strategyType, scoreThreshold, strategyOptions); }; diff --git a/src/providers/twitter.ts b/src/providers/twitter.ts index 285a30a..6f01ff8 100644 --- a/src/providers/twitter.ts +++ b/src/providers/twitter.ts @@ -1,7 +1,6 @@ -import { AnalysisResultType, DateRange, ProviderFunctionType } from '../types'; +import { AnalysisResultType, DateRange, ProviderFunctionType, SentimentConfigurationType } from '../types'; import { TwitterClient } from '../clients/twitter'; import { getAnalysisResults } from '../strategies/helpers/getAnalysisResults'; -import { CONFIG } from '../constants'; import { ScoreStrategyOptions, StrategyType } from '../strategies/types'; export const analyze: ProviderFunctionType = async ( @@ -9,9 +8,13 @@ export const analyze: ProviderFunctionType = async ( timerange: DateRange, strategyType: StrategyType, scoreThreshold: number, + configuration: SentimentConfigurationType, strategyOptions?: ScoreStrategyOptions, ): Promise => { - const tweets = await TwitterClient.getTweets(company, timerange, CONFIG.TWITTER.COUNT); + if (!configuration.twitter) { + throw new Error('Invalid Twitter configuration'); + } + const tweets = await TwitterClient.getTweets(company, timerange, configuration.twitter); - return getAnalysisResults(company, 'twitter', tweets, strategyType, scoreThreshold, strategyOptions); + return getAnalysisResults(company, tweets, strategyType, scoreThreshold, strategyOptions); }; diff --git a/src/providers/youtube.ts b/src/providers/youtube.ts index 6277b17..b2b2df2 100644 --- a/src/providers/youtube.ts +++ b/src/providers/youtube.ts @@ -1,7 +1,6 @@ import { YoutubeClient } from '../clients/youtube'; -import { AnalysisResultType, DateRange, ProviderFunctionType } from '../types'; +import { AnalysisResultType, DateRange, ProviderFunctionType, SentimentConfigurationType } from '../types'; import { getAnalysisResults } from '../strategies/helpers/getAnalysisResults'; -import { CONFIG } from '../constants'; import { ScoreStrategyOptions, StrategyType } from '../strategies/types'; export const analyze: ProviderFunctionType = async ( @@ -9,14 +8,13 @@ export const analyze: ProviderFunctionType = async ( timerange: DateRange, strategyType: StrategyType, scoreThreshold: number, + configuration: SentimentConfigurationType, strategyOptions?: ScoreStrategyOptions, ): Promise => { - const comments = await YoutubeClient.getComments( - company, - timerange, - CONFIG.YOUTUBE.COUNT.VIDEO, - CONFIG.YOUTUBE.COUNT.COMMENTS, - ); + if (!configuration.youtube) { + throw new Error('Invalid YouTube configuration'); + } + const comments = await YoutubeClient.getComments(company, timerange, configuration.youtube); - return getAnalysisResults(company, 'youtube', comments, strategyType, scoreThreshold, strategyOptions); + return getAnalysisResults(company, comments, strategyType, scoreThreshold, strategyOptions); }; diff --git a/src/strategies/helpers/__tests__/getAnalysisResults.test.ts b/src/strategies/helpers/__tests__/getAnalysisResults.test.ts index 49aff0d..0e52677 100644 --- a/src/strategies/helpers/__tests__/getAnalysisResults.test.ts +++ b/src/strategies/helpers/__tests__/getAnalysisResults.test.ts @@ -1,13 +1,11 @@ import { expect, describe, it, vitest, beforeEach, SpyInstance } from 'vitest'; import * as strategyProvider from '../../provider'; import { getAnalysisResults } from '../getAnalysisResults'; -import { MediaType } from '../../../types'; import { StrategyType } from '../../types'; let mockedStrategyProvider: SpyInstance; const company = 'Test Company'; -const media: MediaType = 'twitter'; const items = ['item 1', 'item 2', 'item 3']; const strategyType: StrategyType = 'afinn'; const scoreThreshold = 0; @@ -33,27 +31,27 @@ describe('getAnalysisResults', () => { { text: 'item 3', score: 0, category: 'neutral' }, ], }; - const result = await getAnalysisResults(company, media, items, strategyType, scoreThreshold); + const result = await getAnalysisResults(company, items, strategyType, scoreThreshold); expect(result).toEqual(expectedResult); }); it('should call strategyProvider with the correct strategy type', async () => { - await getAnalysisResults(company, media, items, strategyType, scoreThreshold); + await getAnalysisResults(company, items, strategyType, scoreThreshold); expect(mockedStrategyProvider).toHaveBeenCalledWith(strategyType); }); it('should call evaluateScores with the correct parameters', async () => { - await getAnalysisResults(company, media, items, strategyType, scoreThreshold); + await getAnalysisResults(company, items, strategyType, scoreThreshold); expect(evaluateScoresMock).toHaveBeenCalledWith(items, scoreThreshold, undefined); }); it('should throw an error if company argument is missing', async () => { - await expect(getAnalysisResults(undefined as unknown as string, media, items, strategyType, scoreThreshold)) + await expect(getAnalysisResults(undefined as unknown as string, items, strategyType, scoreThreshold)) .rejects.toThrowError('Missing argument: company'); }); it.each([undefined, []])('should return null if items argument is missing or empty', async (item) => { - expect(await getAnalysisResults(company, media, item as unknown as string[], strategyType, scoreThreshold)) + expect(await getAnalysisResults(company, item as unknown as string[], strategyType, scoreThreshold)) .toBeNull(); }); }); diff --git a/src/strategies/helpers/getAnalysisResults.ts b/src/strategies/helpers/getAnalysisResults.ts index bc1db1f..c754d1f 100644 --- a/src/strategies/helpers/getAnalysisResults.ts +++ b/src/strategies/helpers/getAnalysisResults.ts @@ -1,10 +1,9 @@ -import { AnalysisResultType, MediaType } from '../../types'; +import { AnalysisResultType } from '../../types'; import { strategyProvider } from '../provider'; import { SentimentValues, StrategyType, Score, ScoreStrategyOptions } from '../types'; export const getAnalysisResults = async ( company: string, - media: MediaType, items: string[], strategyType: StrategyType, scoreThreshold: number, diff --git a/src/types.ts b/src/types.ts index c9cb7c3..03c5aaa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,26 @@ import { Score, ScoreStrategyOptions, SentimentValues, StrategyType } from './strategies/types'; +import { + FacebookClientType, + InstagramClientType, + NewsClientType, OpenAiClientType, + TiktokClientType, + TwitterClientType, YoutubeClientType, +} from './clients/types'; + +export type WithRequiredProperty = Type & { + [Property in Key]-?: Type[Property]; +}; + +export type WithNotNullProperty = Type & { + [Property in Key]: NonNullable; +}; + +export type WithRequiredAndNotNullProperty = WithNotNullProperty< + WithRequiredProperty, + Key +>; + +export type WithAtLeastProperty = Partial & Pick; export type DateRange = { since: string; @@ -40,16 +62,27 @@ export type ProviderFunctionType = ( timerange: DateRange, strategyType: StrategyType, scoreThreshold: number, + configuration: SentimentConfigurationType, strategyOptions?: ScoreStrategyOptions, ) => Promise; export type DescriptiveSource = { text: string; rating: number; -} +}; export type MatchedAttributes = { overallMatch: number; positiveMatch: number; negativeMatch: number; -} +}; + +export type SentimentConfigurationType = { + facebook?: FacebookClientType; + instagram?: InstagramClientType; + news?: NewsClientType; + tiktok?: TiktokClientType; + twitter?: TwitterClientType; + youtube?: YoutubeClientType; + openai: OpenAiClientType; +}; diff --git a/src/utils/openai.ts b/src/utils/openai.ts index 33dcd8a..7bb9756 100644 --- a/src/utils/openai.ts +++ b/src/utils/openai.ts @@ -1,16 +1,15 @@ import { OpenAIApi, Configuration } from 'openai'; import { MEDIA, MediaType } from '../types'; -import { OPENAI } from '../constants'; +import { OpenAiClientType } from '../clients/types'; export namespace OpenAI { export const getSummary = async ( + { apiKey }: OpenAiClientType, company: string, media: MediaType, elements: string[], ): Promise => { - const configuration = new Configuration({ - apiKey: OPENAI.API_KEY, - }); + const configuration = new Configuration({ apiKey }); const client = new OpenAIApi(configuration); if (!elements.length) {