From b0b91ac1d91e4527935762b12f85f096aa094495 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Wed, 5 Mar 2025 02:52:44 +0300 Subject: [PATCH 01/25] feat: rewrite project --- bun.lockb | Bin 63484 -> 98241 bytes drizzle.config.ts | 11 + drizzle/0000_dry_pete_wisdom.sql | 42 ++ drizzle/0001_spicy_ben_parker.sql | 2 + drizzle/meta/0000_snapshot.json | 279 +++++++++++ drizzle/meta/0001_snapshot.json | 281 +++++++++++ drizzle/meta/_journal.json | 20 + env.config.ts | 3 + package.json | 24 +- prisma/schema.prisma | 466 ------------------ src/index.ts | 8 +- src/services/cron.ts | 165 +++++++ src/services/cronService.ts | 230 --------- .../database/database-client.interface.ts | 24 + .../database/drizzle/drizzle-client.ts | 94 ++++ .../database/drizzle/drizzle.config.ts | 3 + .../database/drizzle/models/response.ts | 9 + .../database/drizzle/schema/artists.ts | 81 +++ .../{webhookService.ts => discord-webhook.ts} | 4 +- src/services/supabaseService.ts | 274 ---------- .../twitter/models/parsed-profile.ts} | 4 +- .../open-api/twitter-client.ts} | 73 +-- .../twitter/the-convocation/twitter-client.ts | 50 ++ .../twitter/twitter-client.interface.ts | 10 + tsconfig.json | 37 +- 25 files changed, 1133 insertions(+), 1061 deletions(-) create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_dry_pete_wisdom.sql create mode 100644 drizzle/0001_spicy_ben_parker.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 env.config.ts delete mode 100644 prisma/schema.prisma create mode 100644 src/services/cron.ts delete mode 100644 src/services/cronService.ts create mode 100644 src/services/database/database-client.interface.ts create mode 100644 src/services/database/drizzle/drizzle-client.ts create mode 100644 src/services/database/drizzle/drizzle.config.ts create mode 100644 src/services/database/drizzle/models/response.ts create mode 100644 src/services/database/drizzle/schema/artists.ts rename src/services/{webhookService.ts => discord-webhook.ts} (83%) delete mode 100644 src/services/supabaseService.ts rename src/{models/ParsedProfile.ts => services/twitter/models/parsed-profile.ts} (86%) rename src/services/{twitterService.ts => twitter/open-api/twitter-client.ts} (55%) create mode 100644 src/services/twitter/the-convocation/twitter-client.ts create mode 100644 src/services/twitter/twitter-client.interface.ts diff --git a/bun.lockb b/bun.lockb index bf3d34fb34966b69efa77aa92e4e7a7e09ca2d83..55d44dc8d844b4430c61448a4dc51d2e1c3ebea1 100644 GIT binary patch literal 98241 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 diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..a99c506 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'drizzle-kit'; +import { drizzleConfig } from './src/services/database/drizzle/drizzle.config'; + +export default defineConfig({ + out: './drizzle', + schema: './src/services/database/drizzle/schema', + dialect: 'postgresql', + dbCredentials: { + url: drizzleConfig.DATABASE_CONNECTION_URL, + }, +}); diff --git a/drizzle/0000_dry_pete_wisdom.sql b/drizzle/0000_dry_pete_wisdom.sql new file mode 100644 index 0000000..acadace --- /dev/null +++ b/drizzle/0000_dry_pete_wisdom.sql @@ -0,0 +1,42 @@ +CREATE TABLE "artists" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "twitter_user_id" text NOT NULL, + "username" varchar(255) NOT NULL, + "name" varchar(255), + "tweets_count" integer NOT NULL, + "followers_count" integer NOT NULL, + "weekly_tweets_trend" real DEFAULT 0 NOT NULL, + "weekly_followers_trend" real DEFAULT 0 NOT NULL, + "images" jsonb NOT NULL, + "tags" text[] NOT NULL, + "url" text NOT NULL, + "country" text, + "website" text, + "bio" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "artists_twitter_user_id_unique" UNIQUE("twitter_user_id") +); +--> statement-breakpoint +CREATE TABLE "artistsSuggestions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "username" varchar(255) NOT NULL, + "avatar_url" text NOT NULL, + "country" text, + "tags" text[] NOT NULL, + "status" varchar(255) DEFAULT 'requested' NOT NULL, + "requested_from" text DEFAULT 'suggestions' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "artistsTrends" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "artist_id" text NOT NULL, + "tweets_count" integer NOT NULL, + "followers_count" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "artistsTrends" ADD CONSTRAINT "artistsTrends_artist_id_artists_twitter_user_id_fk" FOREIGN KEY ("artist_id") REFERENCES "public"."artists"("twitter_user_id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0001_spicy_ben_parker.sql b/drizzle/0001_spicy_ben_parker.sql new file mode 100644 index 0000000..f427a7c --- /dev/null +++ b/drizzle/0001_spicy_ben_parker.sql @@ -0,0 +1,2 @@ +ALTER TABLE "artists" ALTER COLUMN "tags" SET DEFAULT '{"pixelart"}';--> statement-breakpoint +ALTER TABLE "artistsSuggestions" ALTER COLUMN "tags" SET DEFAULT '{"pixelart"}'; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..3dadbd0 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,279 @@ +{ + "id": "ad5fac44-07bc-4611-b5bb-1f9cd6f3a954", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.artists": { + "name": "artists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "twitter_user_id": { + "name": "twitter_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "tweets_count": { + "name": "tweets_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_tweets_trend": { + "name": "weekly_tweets_trend", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "weekly_followers_trend": { + "name": "weekly_followers_trend", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "artists_twitter_user_id_unique": { + "name": "artists_twitter_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "twitter_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artistsSuggestions": { + "name": "artistsSuggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "requested_from": { + "name": "requested_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'suggestions'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artistsTrends": { + "name": "artistsTrends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "artist_id": { + "name": "artist_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tweets_count": { + "name": "tweets_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "artistsTrends_artist_id_artists_twitter_user_id_fk": { + "name": "artistsTrends_artist_id_artists_twitter_user_id_fk", + "tableFrom": "artistsTrends", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "twitter_user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..adc219b --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,281 @@ +{ + "id": "4a9a0f0e-acfd-4263-b43a-fb6c433bbedc", + "prevId": "ad5fac44-07bc-4611-b5bb-1f9cd6f3a954", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.artists": { + "name": "artists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "twitter_user_id": { + "name": "twitter_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "tweets_count": { + "name": "tweets_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_tweets_trend": { + "name": "weekly_tweets_trend", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "weekly_followers_trend": { + "name": "weekly_followers_trend", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"pixelart\"}'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "artists_twitter_user_id_unique": { + "name": "artists_twitter_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "twitter_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artistsSuggestions": { + "name": "artistsSuggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"pixelart\"}'" + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "requested_from": { + "name": "requested_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'suggestions'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artistsTrends": { + "name": "artistsTrends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "artist_id": { + "name": "artist_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tweets_count": { + "name": "tweets_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "artistsTrends_artist_id_artists_twitter_user_id_fk": { + "name": "artistsTrends_artist_id_artists_twitter_user_id_fk", + "tableFrom": "artistsTrends", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "twitter_user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..b014873 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1741109777578, + "tag": "0000_dry_pete_wisdom", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1741110175355, + "tag": "0001_spicy_ben_parker", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/env.config.ts b/env.config.ts new file mode 100644 index 0000000..6c6a186 --- /dev/null +++ b/env.config.ts @@ -0,0 +1,3 @@ +export const envConfig = { + RUN_ON_START: (process.env.RUN_ON_INIT as unknown as boolean) || false, +}; diff --git a/package.json b/package.json index 3ab644c..608447c 100644 --- a/package.json +++ b/package.json @@ -5,28 +5,28 @@ "main": "src/index.ts", "scripts": { "dev": "bun run --watch src/index.ts", - "test": "echo \"Error: no test specified\" && exit 1", - "dev:prisma-create": "bunx prisma init", - "dev:prisma": "bunx prisma db pull && bunx prisma generate" + "start": "bun run src/index.ts", + "build:drizzle-schema": "bunx drizzle-kit generate && bunx drizzle-kit push" }, "author": "anivire", "license": "ISC", "dependencies": { - "@prisma/client": "^5.14.0", - "@the-convocation/twitter-scraper": "^0.12.0", - "@types/node": "^20.13.0", - "@types/node-cron": "^3.0.11", + "@the-convocation/twitter-scraper": "^0.15.1", "axios": "^1.7.2", "discord-ts-webhook": "^1.2.1", "discord-webhook-node": "^1.1.8", - "install": "^0.13.0", + "drizzle-orm": "^0.40.0", "node-cron": "^3.0.3", - "prisma": "^5.14.0", - "ts-node": "^10.9.2", - "twitter-openapi-typescript": "^0.0.34", + "pg": "^8.13.3", + "prettier": "^3.5.2", + "twitter-openapi-typescript": "0.0.45", "typescript": "^5.4.5" }, "devDependencies": { - "eslint": "^9.4.0" + "@types/node-cron": "^3.0.11", + "@types/pg": "^8.11.11", + "bun-types": "^1.2.4", + "drizzle-kit": "^0.30.5", + "eslint": "^9.21.0" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma deleted file mode 100644 index 89143ac..0000000 --- a/prisma/schema.prisma +++ /dev/null @@ -1,466 +0,0 @@ -generator client { - provider = "prisma-client-js" - previewFeatures = ["multiSchema"] -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - schemas = ["auth", "dashboard", "public"] -} - -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model Artist { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - username String - userId String @unique(map: "artists_userid_key") - tweetsCount Int - followersCount Int - images Json @db.Json - name String? - website String? - country String? - tags String[] - bio String? - url String - joinedAt DateTime @db.Timestamptz(6) - createdAt DateTime @default(now()) @db.Timestamptz(6) - lastUpdatedAt DateTime @default(now()) @db.Timestamptz(6) - weeklyFollowersGrowingTrend Float @default(0) @db.Real - weeklyPostsGrowingTrend Float @default(0) @db.Real - artistsTrending ArtistTrending[] - - @@map("artists") - @@schema("public") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model ArtistSuggestion { - requestId String @id(map: "artistsQueue_pkey") @default(dbgenerated("gen_random_uuid()")) @db.Uuid - username String - country String? - tags String[] - requestStatus String @default("requested") - createdAt DateTime @default(now()) @db.Timestamptz(6) - avatarUrl String? - - @@map("artistsSuggestions") - @@schema("public") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model ArtistTrending { - userId String - followersCount Int - tweetsCount Int - recordedAt DateTime? @default(now()) @db.Timestamptz(6) - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - artists Artist @relation(fields: [userId], references: [userId], onDelete: Cascade, onUpdate: NoAction) - - @@map("artistsTrending") - @@schema("public") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model audit_log_entries { - instance_id String? @db.Uuid - id String @id @db.Uuid - payload Json? @db.Json - created_at DateTime? @db.Timestamptz(6) - ip_address String @default("") @db.VarChar(64) - - @@index([instance_id], map: "audit_logs_instance_id_idx") - @@schema("auth") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model flow_state { - id String @id @db.Uuid - user_id String? @db.Uuid - auth_code String - code_challenge_method code_challenge_method - code_challenge String - provider_type String - provider_access_token String? - provider_refresh_token String? - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - authentication_method String - auth_code_issued_at DateTime? @db.Timestamptz(6) - saml_relay_states saml_relay_states[] - - @@index([created_at(sort: Desc)]) - @@index([auth_code], map: "idx_auth_code") - @@index([user_id, authentication_method], map: "idx_user_id_auth_method") - @@schema("auth") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model identities { - provider_id String - user_id String @db.Uuid - identity_data Json - provider String - last_sign_in_at DateTime? @db.Timestamptz(6) - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - email String? @default(dbgenerated("lower((identity_data ->> 'email'::text))")) - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - - @@unique([provider_id, provider], map: "identities_provider_id_provider_unique") - @@index([email]) - @@index([user_id]) - @@schema("auth") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model instances { - id String @id @db.Uuid - uuid String? @db.Uuid - raw_base_config String? - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - - @@schema("auth") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model mfa_amr_claims { - session_id String @db.Uuid - created_at DateTime @db.Timestamptz(6) - updated_at DateTime @db.Timestamptz(6) - authentication_method String - id String @id(map: "amr_id_pk") @db.Uuid - sessions sessions @relation(fields: [session_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - - @@unique([session_id, authentication_method], map: "mfa_amr_claims_session_id_authentication_method_pkey") - @@schema("auth") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model mfa_challenges { - id String @id @db.Uuid - factor_id String @db.Uuid - created_at DateTime @db.Timestamptz(6) - verified_at DateTime? @db.Timestamptz(6) - ip_address String @db.Inet - otp_code String? - mfa_factors mfa_factors @relation(fields: [factor_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "mfa_challenges_auth_factor_id_fkey") - - @@index([created_at(sort: Desc)], map: "mfa_challenge_created_at_idx") - @@schema("auth") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model mfa_factors { - id String @id @db.Uuid - user_id String @db.Uuid - friendly_name String? - factor_type factor_type - status factor_status - created_at DateTime @db.Timestamptz(6) - updated_at DateTime @db.Timestamptz(6) - secret String? - phone String? @unique - last_challenged_at DateTime? @unique @db.Timestamptz(6) - mfa_challenges mfa_challenges[] - users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - - @@unique([user_id, phone], map: "unique_verified_phone_factor") - @@index([user_id, created_at], map: "factor_id_created_at_idx") - @@index([user_id]) - @@schema("auth") -} - -/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. -model one_time_tokens { - id String @id @db.Uuid - user_id String @db.Uuid - token_type one_time_token_type - token_hash String - relates_to String - created_at DateTime @default(now()) @db.Timestamp(6) - updated_at DateTime @default(now()) @db.Timestamp(6) - users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - - @@unique([user_id, token_type]) - @@index([relates_to], map: "one_time_tokens_relates_to_hash_idx", type: Hash) - @@index([token_hash], map: "one_time_tokens_token_hash_hash_idx", type: Hash) - @@schema("auth") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model refresh_tokens { - instance_id String? @db.Uuid - id BigInt @id @default(autoincrement()) - token String? @unique(map: "refresh_tokens_token_unique") @db.VarChar(255) - user_id String? @db.VarChar(255) - revoked Boolean? - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - parent String? @db.VarChar(255) - session_id String? @db.Uuid - sessions sessions? @relation(fields: [session_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - - @@index([instance_id]) - @@index([instance_id, user_id]) - @@index([parent]) - @@index([session_id, revoked]) - @@index([updated_at(sort: Desc)]) - @@schema("auth") -} - -/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model saml_providers { - id String @id @db.Uuid - sso_provider_id String @db.Uuid - entity_id String @unique - metadata_xml String - metadata_url String? - attribute_mapping Json? - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - name_id_format String? - sso_providers sso_providers @relation(fields: [sso_provider_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - - @@index([sso_provider_id]) - @@schema("auth") -} - -/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model saml_relay_states { - id String @id @db.Uuid - sso_provider_id String @db.Uuid - request_id String - for_email String? - redirect_to String? - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - flow_state_id String? @db.Uuid - flow_state flow_state? @relation(fields: [flow_state_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - sso_providers sso_providers @relation(fields: [sso_provider_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - - @@index([created_at(sort: Desc)]) - @@index([for_email]) - @@index([sso_provider_id]) - @@schema("auth") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model schema_migrations { - version String @id @db.VarChar(255) - - @@schema("auth") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model sessions { - id String @id @db.Uuid - user_id String @db.Uuid - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - factor_id String? @db.Uuid - aal aal_level? - not_after DateTime? @db.Timestamptz(6) - refreshed_at DateTime? @db.Timestamp(6) - user_agent String? - ip String? @db.Inet - tag String? - mfa_amr_claims mfa_amr_claims[] - refresh_tokens refresh_tokens[] - users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - - @@index([not_after(sort: Desc)]) - @@index([user_id]) - @@index([user_id, created_at], map: "user_id_created_at_idx") - @@schema("auth") -} - -/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. -model sso_domains { - id String @id @db.Uuid - sso_provider_id String @db.Uuid - domain String - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - sso_providers sso_providers @relation(fields: [sso_provider_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - - @@index([sso_provider_id]) - @@schema("auth") -} - -/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. -model sso_providers { - id String @id @db.Uuid - resource_id String? - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - saml_providers saml_providers[] - saml_relay_states saml_relay_states[] - sso_domains sso_domains[] - - @@schema("auth") -} - -/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. -model users { - instance_id String? @db.Uuid - id String @id @db.Uuid - aud String? @db.VarChar(255) - role String? @db.VarChar(255) - email String? @db.VarChar(255) - encrypted_password String? @db.VarChar(255) - email_confirmed_at DateTime? @db.Timestamptz(6) - invited_at DateTime? @db.Timestamptz(6) - confirmation_token String? @db.VarChar(255) - confirmation_sent_at DateTime? @db.Timestamptz(6) - recovery_token String? @db.VarChar(255) - recovery_sent_at DateTime? @db.Timestamptz(6) - email_change_token_new String? @db.VarChar(255) - email_change String? @db.VarChar(255) - email_change_sent_at DateTime? @db.Timestamptz(6) - last_sign_in_at DateTime? @db.Timestamptz(6) - raw_app_meta_data Json? - raw_user_meta_data Json? - is_super_admin Boolean? - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - phone String? @unique - phone_confirmed_at DateTime? @db.Timestamptz(6) - phone_change String? @default("") - phone_change_token String? @default("") @db.VarChar(255) - phone_change_sent_at DateTime? @db.Timestamptz(6) - confirmed_at DateTime? @default(dbgenerated("LEAST(email_confirmed_at, phone_confirmed_at)")) @db.Timestamptz(6) - email_change_token_current String? @default("") @db.VarChar(255) - email_change_confirm_status Int? @default(0) @db.SmallInt - banned_until DateTime? @db.Timestamptz(6) - reauthentication_token String? @default("") @db.VarChar(255) - reauthentication_sent_at DateTime? @db.Timestamptz(6) - is_sso_user Boolean @default(false) - deleted_at DateTime? @db.Timestamptz(6) - is_anonymous Boolean @default(false) - identities identities[] - mfa_factors mfa_factors[] - one_time_tokens one_time_tokens[] - sessions sessions[] - dashboardUsers dashboardUsers? - - @@index([instance_id]) - @@index([is_anonymous]) - @@schema("auth") -} - -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model analyticsArtists { - id String @id(map: "analyticsartists_pkey") @default(dbgenerated("gen_random_uuid()")) @db.Uuid - totalArtistsCount Int? - recordedAt DateTime @default(now()) @db.Timestamptz(6) - - @@schema("public") -} - -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model analyticsSuggestions { - id String @id(map: "analyticssuggestions_pkey") @default(dbgenerated("gen_random_uuid()")) @db.Uuid - totalSuggestionsCount Int @default(0) - recordedAt DateTime @default(now()) @db.Timestamptz(6) - - @@schema("public") -} - -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model dashboardUsers { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - discordId Int @unique - username String? - roles Json? @db.Json - createdAt DateTime @default(now()) @db.Timestamptz(6) - users users @relation(fields: [id], references: [id], onDelete: Cascade) - - @@schema("public") -} - -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model users_legacy { - id String @id(map: "Users_pkey") - githubId String @unique(map: "users_githubId_key") - username String - avatarUrl String? - - @@schema("dashboard") -} - -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model AuthSessions { - id String @id(map: "sessions_pkey") - userId String - expiresAt DateTime @db.Timestamptz(6) - authUsers AuthUsers @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "sessions_user_id_fkey") - - @@map("AuthSessions") - @@schema("dashboard") -} - -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model AuthUsers { - id String @id(map: "users_pkey") - githubId String - username String - avatarUrl String? - AuthSessions AuthSessions[] - - @@map("AuthUsers") - @@schema("dashboard") -} - -enum aal_level { - aal1 - aal2 - aal3 - - @@schema("auth") -} - -enum code_challenge_method { - s256 - plain - - @@schema("auth") -} - -enum factor_status { - unverified - verified - - @@schema("auth") -} - -enum factor_type { - totp - webauthn - phone - - @@schema("auth") -} - -enum one_time_token_type { - confirmation_token - reauthentication_token - recovery_token - email_change_token_new - email_change_token_current - phone_change_token - - @@schema("auth") -} diff --git a/src/index.ts b/src/index.ts index a0bfc97..77a5244 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ import { cronFetchArtistSuggestion, cronUpdateStats, -} from './services/cronService'; + UpdateArtists, +} from './services/cron'; import { logger } from './utils'; logger('Service started.'); // Start cron services -cronFetchArtistSuggestion(); -cronUpdateStats(); +// cronFetchArtistSuggestion(); +// cronUpdateStats(); +UpdateArtists(); diff --git a/src/services/cron.ts b/src/services/cron.ts new file mode 100644 index 0000000..1f5a018 --- /dev/null +++ b/src/services/cron.ts @@ -0,0 +1,165 @@ +import cron from 'node-cron'; +import { logger } from '../utils'; +import { sendDiscordMessage } from './discord-webhook'; +import { envConfig } from '../../env.config'; +import DrizzleClient from './database/drizzle/drizzle-client'; +import TwitterClient from './twitter/open-api/twitter-client'; +import { ParsedProfile } from './twitter/models/parsed-profile'; +import { Artist } from './database/drizzle/schema/artists'; + +const EVERY_HOURS = 24; +const FETCH_TIMEOUT = 3000; + +export function cronUpdateStats() { + cron.schedule(`0 0 */${EVERY_HOURS} * * *`, async () => UpdateArtists(), { + name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', + runOnInit: envConfig.RUN_ON_START, + }); +} + +export function cronFetchArtistSuggestion() { + cron.schedule(`0 0 */${EVERY_HOURS} * * *`, async () => {}, { + name: 'Fetching suggested artists profiles.', + runOnInit: envConfig.RUN_ON_START, + }); +} + +export async function UpdateArtists() { + logger('Starting fetching artist profiles from dotcreatros-sun...'); + + try { + const drizzleClient = new DrizzleClient(); + const twitterClient = new TwitterClient(); + + const artistProfiles = await drizzleClient.getArtistsProfiles(); + + if (artistProfiles.length === 0) { + logger('Recieved 0 artists profiles dotcreators-sun'); + return; + } + + logger(`Recieved ${artistProfiles.length} artist profiles`); + logger(`Starting recieving artist profiles from twitter...`); + + const reqList = artistProfiles.map( + (artist, index) => + new Promise(async resolve => { + setTimeout(async () => { + const data = await twitterClient.getTwitterUserByUserId( + artist.twitterUserId + ); + logger( + `[${index + 1} / ${artistProfiles.length}] Fetched profile for user ${artist.username}` + ); + resolve(data); + }, index * FETCH_TIMEOUT); + }) + ); + + const artistsInformation: ParsedProfile[] = (await Promise.all( + reqList + )) as ParsedProfile[]; + + if (artistsInformation.length === 0) { + logger('Recieved 0 artists profiles from twitter'); + return; + } + + logger('Fetched all profiles from twitter'); + logger('Starting updating artists information...'); + + const updatedArtistsProfiles: Artist[] = artistProfiles.map( + (artist, index) => ({ + ...artist, + name: artistsInformation[index]?.displayName || artist.name, + username: artistsInformation[index]?.username || artist.username, + tweetsCount: + artistsInformation[index]?.tweetsCount || artist.tweetsCount, + followersCount: + artistsInformation[index]?.followersCount || artist.followersCount, + images: { + avatar: artistsInformation[index]?.avatarUrl || artist.images.avatar, + banner: artistsInformation[index]?.bannerUrl || artist.images.banner, + }, + bio: artistsInformation[index]?.biography || artist.bio, + website: artistsInformation[index]?.website || artist.website, + updatedAt: new Date(), + }) + ); + + const updatedArtistsProfilesResponse = + await drizzleClient.updateArtistInformationBulk(updatedArtistsProfiles); + + const updatedArtistsTrendsResponse = + await drizzleClient.updateTrendsInformationBulk( + updatedArtistsProfiles.map(artist => ({ + id: artist.id, + twitterUserId: artist.twitterUserId, + followersCount: artist.followersCount, + tweetsCount: artist.tweetsCount, + createdAt: new Date(), + })) + ); + + logger( + `Successfully updated artists ${updatedArtistsProfilesResponse.items.length} profiles and trends ${updatedArtistsTrendsResponse.items.length}` + ); + sendDiscordMessage( + 'Updating artists information', + `Successfully updated ${updatedArtistsProfilesResponse.items.length} artist profiles and ${updatedArtistsTrendsResponse.items.length} trends`, + 'info' + ); + + if ( + updatedArtistsProfilesResponse.errors && + updatedArtistsProfilesResponse.errors.length > 0 + ) { + logger(`Errors ${updatedArtistsProfilesResponse.errors.length}:`); + logger( + updatedArtistsProfilesResponse.errors + .map(error => error.description) + .join(', ') + ); + + if (updatedArtistsProfilesResponse.errors.length > 0) { + updatedArtistsProfilesResponse.errors.forEach(element => { + sendDiscordMessage( + 'Error while updating artist profile', + `${'```'}${element.reason}:\n${element.description}${'```'}`, + 'error' + ); + }); + } + } + + if ( + updatedArtistsTrendsResponse.errors && + updatedArtistsTrendsResponse.errors.length > 0 + ) { + logger(`Errors ${updatedArtistsTrendsResponse.errors.length}:`); + logger( + updatedArtistsTrendsResponse.errors + .map(error => error.description) + .join(', ') + ); + + updatedArtistsTrendsResponse.errors.forEach(element => { + sendDiscordMessage( + 'Error while updating artist trends', + `${'```'}${element.reason}:\n${element.description}${'```'}`, + 'error' + ); + }); + } else { + logger('Successfully updated artists information'); + } + } catch (e: any) { + logger('Error while trying to update artists information:'); + logger(e.message); + sendDiscordMessage( + 'Error while trying to update artists information', + `${'```'}${e.reason}:\n${e.message}${'```'}`, + 'error' + ); + } +} diff --git a/src/services/cronService.ts b/src/services/cronService.ts deleted file mode 100644 index df58e1f..0000000 --- a/src/services/cronService.ts +++ /dev/null @@ -1,230 +0,0 @@ -import cron from 'node-cron'; -import { logger } from '../utils'; -import { SupabaseService } from './supabaseService'; -import { TwitterService } from './twitterService'; -import { sendDiscordMessage } from './webhookService'; -import { ParsedProfile } from '../models/ParsedProfile'; - -const IS_RUN_ON_INIT = process.env.RUN_ON_INIT; - -const EVERY_HOURS = 24; -const RUN_ON_INIT = IS_RUN_ON_INIT === 'true' ? true : false; - -const supabase = new SupabaseService(); -const twitter = new TwitterService(); - -export function cronUpdateStats() { - cron.schedule( - `0 0 */${EVERY_HOURS} * * *`, - async () => { - logger(`Start fetching artists profiles...`); - let page = 1; - let artistsNewData: ParsedProfile[] = []; - let morePages = true; - - while (morePages) { - const artistsProfiles = await supabase.getArtistsProfilesPaginated( - page - ); - - if (artistsProfiles && artistsProfiles.data.length > 0) { - const fetchPromises = artistsProfiles.data.map( - async (artist, index) => { - logger( - `[${index + 1}/${ - artistsProfiles.data.length - }] Start fetching twitter data for ${artist.username}...` - ); - - try { - const artistData = await twitter.getTwitterProfileById( - artist.userId - ); - if (artistData) { - artistsNewData.push(artistData); - logger( - `[${index + 1}/${ - artistsProfiles.data.length - }] Data fetched for ${ - artist.username - }, added to profile update queue.` - ); - } - } catch (e) { - logger(`Error fetching data for ${artist.username}: ${e}`); - if (e instanceof Error) { - sendDiscordMessage( - e.name, - `${e.message}\n\n\`username: ${artist.username}\``, - 'error' - ); - } else { - sendDiscordMessage('UnknownError', `${e}`, 'error'); - } - } - } - ); - - await Promise.all(fetchPromises); - page++; - } else { - morePages = false; - } - } - - if (artistsNewData.length > 0) { - logger(`Updating ${artistsNewData.length} profiles...`); - try { - await supabase.updateArtistProfiles(artistsNewData); - await supabase.createArtistTrends(artistsNewData); - await supabase.updateArtistsTrendPercent(); - await supabase.updateAnalyticsArtists(artistsNewData.length); - - logger(`Successfully updated ${artistsNewData.length} profiles.`); - sendDiscordMessage( - 'Update user profiles and trends', - `Successfully updated ${artistsNewData.length} profiles`, - 'info' - ); - artistsNewData = []; - } catch (e) { - logger(`Error while updating profiles: ${e}`); - if (e instanceof Error) { - sendDiscordMessage( - 'Error while updating profiles', - `${e.message}`, - 'error' - ); - } else { - sendDiscordMessage('UnknownError', `${e}`, 'error'); - } - } - } else { - logger('No new artist data to update.'); - } - }, - { - name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', - runOnInit: RUN_ON_INIT, - } - ); -} - -export function cronFetchArtistSuggestion() { - cron.schedule( - `0 0 */${EVERY_HOURS} * * *`, - async () => { - logger(`Start fetching suggested artists profiles...`); - - const artistsSuggestions = await supabase.getArtistsSuggestions(); - - if (artistsSuggestions) { - if (artistsSuggestions.length === 0) { - logger(`Received 0 artist suggestions, skip fetching.`); - sendDiscordMessage( - 'Artists suggestions', - 'Received 0 artist suggestions, skip fetching', - 'info' - ); - return; - } - - logger( - `Received ${artistsSuggestions.length} artist suggestion(s), start fetching twitter data.` - ); - - if (artistsSuggestions && artistsSuggestions.length > 0) { - const fetchAndCreatePromises = artistsSuggestions.map( - async (artist, index) => { - logger( - `[${index + 1}/${ - artistsSuggestions.length - }] Start fetching twitter data for ${artist.username}...` - ); - - try { - const artistData = await twitter.getTwitterProfileByUsername( - artist.username - ); - if (artistData) { - logger( - `[${index + 1}/${ - artistsSuggestions.length - }] Data fetched for ${artist.username}, creating profile...` - ); - - const isProfileCreated = await supabase.createArtistInstance( - { - tags: artist.tags, - country: artist.country, - bio: artistData.biography || null, - followersCount: artistData.followersCount || 0, - tweetsCount: artistData.tweetsCount || 0, - images: { - avatar: artistData.avatarUrl || null, - banner: artistData.bannerUrl || null, - }, - name: artistData.displayName || null, - url: artistData.url, - website: artistData.website || null, - joinedAt: new Date(artistData.createdAt) || null, - username: artistData.username!, - userId: artistData.userId!, - lastUpdatedAt: new Date(), - createdAt: new Date(), - weeklyFollowersGrowingTrend: 0, - weeklyPostsGrowingTrend: 0, - }, - artist.requestId - ); - - if (isProfileCreated) { - logger( - `[${index + 1}/${artistsSuggestions.length}] ${ - artist.username - } profile is successfully created!` - ); - } else { - logger( - `Unable to create profile for ${artist.username}, skipping...` - ); - } - } else { - logger( - `Unable to fetch data for ${artist.username}, skipping...` - ); - } - } catch (e) { - if (e instanceof Error) { - sendDiscordMessage( - e.name, - `${e.message}\n\n\`username: ${artist.username}\``, - 'error' - ); - } else { - sendDiscordMessage('UnknownError', `${e}`, 'error'); - } - } - } - ); - - // Wait for all promises to complete - await Promise.all(fetchAndCreatePromises); - } - - await supabase.updateAnalyticsSuggestions(artistsSuggestions.length); - sendDiscordMessage( - 'Artists suggestions', - `Created ${artistsSuggestions.length} new artist suggestions`, - 'info' - ); - } else { - logger(`Unable to fetch profiles, skipping...`); - } - }, - { - name: 'Fetching suggested artists profiles.', - runOnInit: RUN_ON_INIT, - } - ); -} diff --git a/src/services/database/database-client.interface.ts b/src/services/database/database-client.interface.ts new file mode 100644 index 0000000..e365d0b --- /dev/null +++ b/src/services/database/database-client.interface.ts @@ -0,0 +1,24 @@ +import { Response } from './drizzle/models/response'; +import { Artist, ArtistTrend } from './drizzle/schema/artists'; + +export interface IDatabaseClient + extends IArtistsDatabaseClient, + ITrendsDatabaseClient {} + +export interface IArtistsDatabaseClient { + getArtistsProfiles(): Promise< + { + username: string; + twitterUserId: string; + }[] + >; + updateArtistInformationBulk( + artistsData: Artist[] + ): Promise>; +} + +export interface ITrendsDatabaseClient { + updateTrendsInformationBulk( + trendData: ArtistTrend[] + ): Promise>; +} diff --git a/src/services/database/drizzle/drizzle-client.ts b/src/services/database/drizzle/drizzle-client.ts new file mode 100644 index 0000000..52706df --- /dev/null +++ b/src/services/database/drizzle/drizzle-client.ts @@ -0,0 +1,94 @@ +import { + Artist, + artists, + artistsSuggestions, + artistsTrends, + ArtistTrend, +} from './schema/artists'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { drizzleConfig } from './drizzle.config'; +import { IDatabaseClient } from '../database-client.interface'; +import { eq } from 'drizzle-orm'; +import { ErrorResponse, Response } from './models/response'; + +export default class DrizzleClient implements IDatabaseClient { + private client; + + constructor() { + this.client = drizzle({ + connection: { + connectionString: drizzleConfig.DATABASE_CONNECTION_URL, + }, + schema: { artists, artistsSuggestions, artistsTrends }, + }); + } + + async getArtistsProfiles(): Promise { + return await this.client.query.artists.findMany(); + } + + async updateArtistInformationBulk( + artistsData: Artist[] + ): Promise> { + const promises = artistsData.map(profile => { + return this.client + .update(artists) + .set({ ...profile }) + .where(eq(artists.id, profile.id)) + .returning() + .execute(); + }); + + const results = await Promise.allSettled(promises); + + const errorResults: ErrorResponse[] = []; + const processedResults = results + .map((result, index) => { + if (result.status === 'fulfilled') { + return result.value[0]; + } else { + errorResults.push({ + reason: result.reason, + description: artistsData[index]!.id, + }); + return null; + } + }) + .filter(Boolean) as Artist[]; + + return { items: processedResults, errors: errorResults }; + } + + async updateTrendsInformationBulk( + trendData: ArtistTrend[] + ): Promise> { + if (!this.client) throw Error('Client is not initialized'); + + const promises = trendData.map(trend => { + return this.client + .insert(artistsTrends) + .values({ ...trend }) + .returning() + .execute(); + }); + + const results = await Promise.allSettled(promises); + + let errorResults: ErrorResponse[] = []; + const processedResults = results + .map((result, index) => { + if (result.status === 'fulfilled') { + return result.value[0]; + } else { + errorResults.push({ + reason: result.reason, + description: trendData[index]!.id, + }); + return null; + } + }) + .filter(result => result !== null) as Artist[]; + + return { items: processedResults, errors: errorResults }; + } +} diff --git a/src/services/database/drizzle/drizzle.config.ts b/src/services/database/drizzle/drizzle.config.ts new file mode 100644 index 0000000..1fb4085 --- /dev/null +++ b/src/services/database/drizzle/drizzle.config.ts @@ -0,0 +1,3 @@ +export const drizzleConfig = { + DATABASE_CONNECTION_URL: process.env.DATABASE_CONNECTION_URL || '', +}; diff --git a/src/services/database/drizzle/models/response.ts b/src/services/database/drizzle/models/response.ts new file mode 100644 index 0000000..c3cd0a8 --- /dev/null +++ b/src/services/database/drizzle/models/response.ts @@ -0,0 +1,9 @@ +export type Response = { + items: T; + errors?: ErrorResponse[]; +}; + +export type ErrorResponse = { + reason: string; + description: string; +}; diff --git a/src/services/database/drizzle/schema/artists.ts b/src/services/database/drizzle/schema/artists.ts new file mode 100644 index 0000000..d924bfb --- /dev/null +++ b/src/services/database/drizzle/schema/artists.ts @@ -0,0 +1,81 @@ +import { InferModelFromColumns, InferSelectModel } from 'drizzle-orm'; +import { + integer, + pgTable, + real, + text, + timestamp, + uuid, + varchar, + customType, +} from 'drizzle-orm/pg-core'; + +type Images = { avatar: string; banner?: string }; + +const typedJsonb = (name: string) => + customType<{ data: TData; driverData: string }>({ + dataType() { + return 'jsonb'; + }, + toDriver(value: TData): string { + return JSON.stringify(value); + }, + })(name); + +export const artists = pgTable('artists', { + id: uuid('id').primaryKey().defaultRandom(), + twitterUserId: text('twitter_user_id').notNull().unique(), + username: varchar('username', { length: 255 }).notNull(), + name: varchar('name', { length: 255 }), + tweetsCount: integer('tweets_count').notNull(), + followersCount: integer('followers_count').notNull(), + weeklyTweetsTrend: real('weekly_tweets_trend').notNull().default(0), + weeklyFollowersTrend: real('weekly_followers_trend').notNull().default(0), + images: typedJsonb('images').notNull(), + tags: text('tags').array().notNull().default(['pixelart']), + url: text('url').notNull(), + country: text('country'), + website: text('website'), + bio: text('bio'), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + joinedAt: timestamp('joined_at', { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const artistsSuggestions = pgTable('artistsSuggestions', { + id: uuid('id').primaryKey().defaultRandom(), + username: varchar('username', { length: 255 }).notNull(), + avatarUrl: text('avatar_url').notNull(), + country: text('country'), + tags: text('tags').array().notNull().default(['pixelart']), + status: varchar('status', { length: 255 }).notNull().default('requested'), + requestedFrom: text('requested_from').notNull().default('suggestions'), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const artistsTrends = pgTable('artistsTrends', { + id: uuid('id').primaryKey().defaultRandom(), + twitterUserId: text('artist_id') + .references(() => artists.twitterUserId) + .notNull(), + tweetsCount: integer('tweets_count').notNull(), + followersCount: integer('followers_count').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export type Artist = InferSelectModel; +export type ArtistSuggestion = InferSelectModel; +export type ArtistTrend = InferSelectModel; diff --git a/src/services/webhookService.ts b/src/services/discord-webhook.ts similarity index 83% rename from src/services/webhookService.ts rename to src/services/discord-webhook.ts index 08aa9ea..f9c87d5 100644 --- a/src/services/webhookService.ts +++ b/src/services/discord-webhook.ts @@ -13,13 +13,13 @@ export function sendDiscordMessage( if (severity === 'error') { embed = new MessageBuilder() .setColor('#FA4545') - .setTitle(`galatea-${title.toLocaleLowerCase()}`) + .setTitle(`galatea - ${title.toLocaleLowerCase()}`) .setDescription(message) .setTimestamp(); } else if (severity === 'info') { embed = new MessageBuilder() .setColor('#FF902B') - .setTitle(`galatea-${title.toLocaleLowerCase()}`) + .setTitle(`galatea - ${title.toLocaleLowerCase()}`) .setDescription(message) .setTimestamp(); } diff --git a/src/services/supabaseService.ts b/src/services/supabaseService.ts deleted file mode 100644 index 6cff2c4..0000000 --- a/src/services/supabaseService.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { Artist, ArtistSuggestion, Prisma, PrismaClient } from '@prisma/client'; -import { logger } from '../utils'; -import { ParsedProfile } from '../models/ParsedProfile'; - -export class SupabaseService { - private readonly prisma = new PrismaClient(); - - async getArtistsSuggestions(): Promise< - Omit[] | undefined - > { - try { - const artistsSuggestions = await this.prisma.artistSuggestion.findMany({ - where: { - requestStatus: 'approved', - }, - select: { - requestId: true, - createdAt: true, - username: true, - country: true, - tags: true, - }, - }); - - if (artistsSuggestions) { - return artistsSuggestions; - } - return undefined; - } catch (e) { - console.log(`Error while trying to fetch artists suggestions list: ${e}`); - return undefined; - } - } - - async getArtistsProfilesPaginated( - page: number - ): Promise<{ data: Artist[]; hasNext: boolean } | undefined> { - try { - const limit = 50; - const artistsProfiles = await this.prisma.artist.findMany({ - take: limit, - skip: (page - 1) * limit, - }); - - if (artistsProfiles) { - return { - data: artistsProfiles, - hasNext: artistsProfiles.length === limit, - }; - } - return undefined; - } catch (e) { - console.log(`Error while trying to fetch artists profiles list: ${e}`); - return undefined; - } - } - - async createArtistInstance( - artistData: Omit, - requestId: string - ): Promise { - try { - // Transform `images` in `InputJsonValue` - const artistCreateInput: Prisma.ArtistCreateInput = { - ...artistData, - images: artistData.images as Prisma.InputJsonValue, - }; - - await this.prisma.$transaction(async prisma => { - await prisma.artist.create({ - data: artistCreateInput, - }); - - await prisma.artistSuggestion.update({ - where: { - requestId: requestId, - }, - data: { - requestStatus: 'created', - }, - }); - }); - - return true; - } catch (e) { - console.log(`Error while trying to create artist profile: ${e}`); - return false; - } - } - - async updateArtistsTrendPercent() { - try { - let page = 1; - let artistData: Artist[] = []; - let morePages = true; - - logger(`Start artist profiles for update trending percent...`); - - while (morePages) { - const recievedProfiles = await this.getArtistsProfilesPaginated(page); - - if ( - recievedProfiles && - recievedProfiles.data && - recievedProfiles.data.length > 0 - ) { - logger( - `Recieved ${recievedProfiles.data.length} artist profiles, merging...` - ); - artistData = artistData.concat(recievedProfiles.data); - - page++; - } else { - morePages = false; - } - } - - logger( - `Recieved total ${artistData.length} artist profiles, updating trend percent...` - ); - - let updateProfilePromises = artistData.map(async artist => { - logger(`Updating trend for ${artist.userId}@${artist.username}`); - - const weeklyTrends = await this.prisma.artistTrending.findMany({ - where: { - userId: artist.userId, - }, - orderBy: { - recordedAt: 'desc', - }, - take: 7, - }); - - let growthTrend: { - followers: number; - posts: number; - } = { followers: 0, posts: 0 }; - - // Update weekly trend percent - if (weeklyTrends.length >= 7) { - const initialTrendData = weeklyTrends[6]; - const latestTrendData = weeklyTrends[0]; - - if ( - artist.followersCount != null && - artist.tweetsCount != null && - initialTrendData && - latestTrendData - ) { - const initialTrend = { - followers: initialTrendData.followersCount, - tweets: initialTrendData.tweetsCount, - }; - - const latestTrend = { - followers: latestTrendData.followersCount, - tweets: latestTrendData.tweetsCount, - }; - - const followersDifference = - latestTrend.followers - initialTrend.followers; - growthTrend.followers = - (followersDifference / initialTrend.followers) * 100; - - const tweetsDifference = latestTrend.tweets - initialTrend.tweets; - growthTrend.posts = (tweetsDifference / initialTrend.tweets) * 100; - } - } - - return this.prisma.artist.update({ - where: { userId: artist.userId }, - data: { - lastUpdatedAt: new Date().toISOString(), - weeklyFollowersGrowingTrend: Number.parseFloat( - growthTrend.followers.toFixed(3) - ), - weeklyPostsGrowingTrend: Number.parseFloat( - growthTrend.posts.toFixed(3) - ), - }, - }); - }); - - await Promise.all(updateProfilePromises); - console.log('All artist trends percent data updated successfully'); - } catch (e) { - console.log(`Error while trying to update artist trends: ${e}`); - } - } - - async createArtistTrends(artistData: ParsedProfile[]) { - try { - let createArtistTrend = artistData.map(artist => { - return this.prisma.artistTrending.create({ - data: { - followersCount: artist.followersCount!, - tweetsCount: artist.tweetsCount!, - recordedAt: new Date().toISOString(), - userId: artist.userId!, - }, - }); - }); - - await Promise.all(createArtistTrend); - console.log('Succesfully created new artist trends entries'); - } catch (e) { - console.log( - `Error while trying to create new artist trends entries: ${e}` - ); - } - } - - async updateArtistProfiles(artistData: ParsedProfile[]) { - try { - let artistsProfileUpdate = artistData.map(async artist => { - return this.prisma.artist.update({ - where: { userId: artist.userId }, - data: { - followersCount: artist.followersCount, - tweetsCount: artist.tweetsCount, - images: { - avatar: artist.avatarUrl, - banner: artist.bannerUrl, - }, - bio: artist.biography, - website: artist.website, - name: artist.displayName, - lastUpdatedAt: new Date().toISOString(), - url: artist.url, - }, - }); - }); - - await Promise.all(artistsProfileUpdate); - console.log('All artist profiles updated successfully'); - } catch (e) { - console.log(`Error while trying to update artist profiles: ${e}`); - } - } - - async updateAnalyticsArtists(totalArtists: number) { - try { - const totalArtistsRequest = await this.prisma.analyticsArtists.create({ - data: { - totalArtistsCount: totalArtists, - }, - }); - - if (totalArtistsRequest) { - console.log('Artists analytics records successfully created'); - } - } catch (e) { - console.log(`Error while trying to create analytics records: ${e}`); - } - } - - async updateAnalyticsSuggestions(totalSuggestions: number) { - try { - const totalSuggestionsRequest = - await this.prisma.analyticsSuggestions.create({ - data: { - totalSuggestionsCount: totalSuggestions ?? 0, - }, - }); - - if (totalSuggestionsRequest) { - console.log('Suggestions analytics records successfully created'); - } - } catch (e) { - console.log(`Error while trying to create analytics records: ${e}`); - } - } -} diff --git a/src/models/ParsedProfile.ts b/src/services/twitter/models/parsed-profile.ts similarity index 86% rename from src/models/ParsedProfile.ts rename to src/services/twitter/models/parsed-profile.ts index 5abee97..962f23f 100644 --- a/src/models/ParsedProfile.ts +++ b/src/services/twitter/models/parsed-profile.ts @@ -1,4 +1,4 @@ -export interface ParsedProfile { +export type ParsedProfile = { userId: string; username: string; tweetsCount: number; @@ -10,4 +10,4 @@ export interface ParsedProfile { bannerUrl?: string; website?: string; biography?: string; -} +}; diff --git a/src/services/twitterService.ts b/src/services/twitter/open-api/twitter-client.ts similarity index 55% rename from src/services/twitterService.ts rename to src/services/twitter/open-api/twitter-client.ts index a193b3e..761a8db 100644 --- a/src/services/twitterService.ts +++ b/src/services/twitter/open-api/twitter-client.ts @@ -1,62 +1,19 @@ -import { Profile, Scraper } from '@the-convocation/twitter-scraper'; -import { formatBio, getOriginalUrl } from '../utils'; -import { sendDiscordMessage } from './webhookService'; import { TwitterOpenApi } from 'twitter-openapi-typescript'; -import { ParsedProfile } from '../models/ParsedProfile'; +import { ITwitterClient } from '../twitter-client.interface'; +import { ParsedProfile } from '../models/parsed-profile'; +import { formatBio } from '../../../utils'; -export class TwitterService { - private readonly scraper = new Scraper(); +export default class TwitterClient implements ITwitterClient { private readonly api = new TwitterOpenApi(); - async getTwitterProfileLegacy( - username: string - ): Promise { - try { - let profile = await this.scraper.getProfile(username); - - if (profile.biography) { - const regex = - /https?:\/\/(?:www\.|(?!www))[^\s.]+(?:\.[^\s.]+)+(?:\w\/?)*/gi; - - const matches = profile.biography.match(regex); - - if (matches) { - const promises = matches.map(async link => { - const originalUrl = await getOriginalUrl(link); - return originalUrl || ''; - }); - - const newBioArray = await Promise.all(promises); - - let index = 0; - const processedBio = profile.biography.replace( - regex, - () => newBioArray[index++] || '' - ); - - profile.biography = processedBio; - } - } - - return profile; - } catch (e) { - console.log(e); - - if (e instanceof Error) { - sendDiscordMessage( - e.name, - `${e.message}\n\n\`username: ${username}\``, - 'error' - ); - } else { - sendDiscordMessage('UnknownError', `${e}`, 'error'); - } - return undefined; - } + private async getClient() { + return await this.api.getGuestClient(); } - async getTwitterProfileByUsername(username: string) { - const twitterClient = await this.api.getGuestClient(); + async getTwitterUserByUsername( + username: string + ): Promise { + const twitterClient = await this.getClient(); const r = await twitterClient .getUserApi() .getUserByScreenName({ screenName: username }); @@ -82,11 +39,15 @@ export class TwitterService { }; return profile; + } else { + return { error: 'Unable to find requested user' }; } } - async getTwitterProfileById(userId: string) { - const twitterClient = await this.api.getGuestClient(); + async getTwitterUserByUserId( + userId: string + ): Promise { + const twitterClient = await this.getClient(); const r = await twitterClient .getUserApi() .getUserByRestId({ userId: userId }); @@ -112,6 +73,8 @@ export class TwitterService { }; return profile; + } else { + return { error: 'Unable to find requested user' }; } } } diff --git a/src/services/twitter/the-convocation/twitter-client.ts b/src/services/twitter/the-convocation/twitter-client.ts new file mode 100644 index 0000000..a12586e --- /dev/null +++ b/src/services/twitter/the-convocation/twitter-client.ts @@ -0,0 +1,50 @@ +import { ITwitterClient } from '../twitter-client.interface'; +import { ParsedProfile } from '../models/parsed-profile'; +import { formatBio } from '../../../utils'; +import { Scraper } from '@the-convocation/twitter-scraper'; + +const REQUEST_TIMEOUT = 5000; + +export default class TwitterClient implements ITwitterClient { + private readonly api = new Scraper({ + transform: { + request(input: RequestInfo | URL, init: RequestInit = {}) { + init.signal = AbortSignal.timeout(REQUEST_TIMEOUT); + + return [input, init]; + }, + }, + }); + + async getTwitterUserByUsername( + username: string + ): Promise { + const r = await this.api.getProfile(username); + + if (r) { + const profile: ParsedProfile = { + userId: r.userId!, + username: r.username!, + followersCount: r.followersCount!, + tweetsCount: r.tweetsCount!, + url: `https://x.com/${r.username}`, + avatarUrl: r.avatar!.replace('_normal', ''), + bannerUrl: r.banner, + displayName: r.name, + biography: await formatBio(r.biography!), + website: r.website, + createdAt: new Date(r.joined!).toISOString(), + }; + + return profile; + } else { + return { error: 'Unable to find requested user' }; + } + } + + async getTwitterUserByUserId( + userId: string + ): Promise { + throw Error('Method not implemented'); + } +} diff --git a/src/services/twitter/twitter-client.interface.ts b/src/services/twitter/twitter-client.interface.ts new file mode 100644 index 0000000..dff50e3 --- /dev/null +++ b/src/services/twitter/twitter-client.interface.ts @@ -0,0 +1,10 @@ +import { ParsedProfile } from './models/parsed-profile'; + +export interface ITwitterClient { + getTwitterUserByUsername( + username: string + ): Promise; + getTwitterUserByUserId( + userId: string + ): Promise; +} diff --git a/tsconfig.json b/tsconfig.json index d0c3668..50c9f5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,18 +1,21 @@ { - "compilerOptions": { - "esModuleInterop": true, - "skipLibCheck": true, - "target": "es2022", - "allowJs": true, - "resolveJsonModule": true, - "moduleDetection": "force", - "isolatedModules": true, - "strict": true, - "noUncheckedIndexedAccess": true, - "moduleResolution": "NodeNext", - "module": "NodeNext", - "outDir": "dist", - "sourceMap": true, - "lib": ["es2022"] - } - } \ No newline at end of file + "compilerOptions": { + "target": "ES2022", + "module": "ES6", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "noImplicitAny": true, + "isolatedModules": true, + "types": ["node"], + "strictNullChecks": true, + "lib": ["ES2022", "DOM"], + "baseUrl": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} From d004886ecdeb38fa63c9d48c93ff9046755e14cd Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Wed, 5 Mar 2025 02:57:33 +0300 Subject: [PATCH 02/25] fix: methods --- src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 77a5244..cfb4394 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,5 +9,4 @@ logger('Service started.'); // Start cron services // cronFetchArtistSuggestion(); -// cronUpdateStats(); -UpdateArtists(); +cronUpdateStats(); From d3cb3b1f6dfe05f916f9b37a32f530d0b7c072d6 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Wed, 5 Mar 2025 20:28:27 +0300 Subject: [PATCH 03/25] fix: log and method naming --- src/index.ts | 10 ++-------- src/services/cron.ts | 46 +++++++++++++++++++++++++++++++++----------- src/utils.ts | 2 +- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/index.ts b/src/index.ts index cfb4394..bfc4c5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,6 @@ -import { - cronFetchArtistSuggestion, - cronUpdateStats, - UpdateArtists, -} from './services/cron'; +import { startCronUpdateStats } from './services/cron'; import { logger } from './utils'; logger('Service started.'); -// Start cron services -// cronFetchArtistSuggestion(); -cronUpdateStats(); +startCronUpdateStats(); diff --git a/src/services/cron.ts b/src/services/cron.ts index 1f5a018..083c4fe 100644 --- a/src/services/cron.ts +++ b/src/services/cron.ts @@ -10,21 +10,38 @@ import { Artist } from './database/drizzle/schema/artists'; const EVERY_HOURS = 24; const FETCH_TIMEOUT = 3000; -export function cronUpdateStats() { - cron.schedule(`0 0 */${EVERY_HOURS} * * *`, async () => UpdateArtists(), { - name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', - runOnInit: envConfig.RUN_ON_START, - }); +function startCronUpdateStats() { + cron.schedule( + `0 0 */${EVERY_HOURS} * * *`, + async () => updateArtistsInformation(), + { + name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', + runOnInit: envConfig.RUN_ON_START, + } + ); } -export function cronFetchArtistSuggestion() { - cron.schedule(`0 0 */${EVERY_HOURS} * * *`, async () => {}, { - name: 'Fetching suggested artists profiles.', - runOnInit: envConfig.RUN_ON_START, - }); +function startCronFetchArtistSuggestion() { + cron.schedule( + `0 0 */${EVERY_HOURS} * * *`, + async () => { + throw new Error('Not implemented'); + }, + { + name: 'Fetching suggested artists profiles.', + runOnInit: envConfig.RUN_ON_START, + } + ); } -export async function UpdateArtists() { +/** + * Updates artists information by fetching profiles from dotcreators-sun and Twitter, + * then updating the information in the database. + * + * @async + * @function updateArtistsInformation + */ +async function updateArtistsInformation(): Promise { logger('Starting fetching artist profiles from dotcreatros-sun...'); try { @@ -40,6 +57,11 @@ export async function UpdateArtists() { logger(`Recieved ${artistProfiles.length} artist profiles`); logger(`Starting recieving artist profiles from twitter...`); + sendDiscordMessage( + 'Updating artists information', + `Recieved ${artistProfiles.length} artist profiles\nStarting update data...`, + 'info' + ); const reqList = artistProfiles.map( (artist, index) => @@ -163,3 +185,5 @@ export async function UpdateArtists() { ); } } + +export { startCronUpdateStats, startCronFetchArtistSuggestion }; diff --git a/src/utils.ts b/src/utils.ts index ea3b60c..49083d9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ export function logger(text: string) { console.log( - `[${new Date().toLocaleDateString()} ${new Date().toLocaleTimeString()}] ${text}` + `[${new Date().toLocaleDateString('en-EN')} ${new Date().toLocaleTimeString()}] ${text}` ); } From 7bf278a4bf4860d91e6e299eae3a09190ec50a63 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Thu, 6 Mar 2025 15:27:37 +0300 Subject: [PATCH 04/25] fix: correct id applying --- src/services/cron.ts | 2 -- src/services/database/drizzle/drizzle-client.ts | 11 ++++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/services/cron.ts b/src/services/cron.ts index 083c4fe..aa170af 100644 --- a/src/services/cron.ts +++ b/src/services/cron.ts @@ -115,11 +115,9 @@ async function updateArtistsInformation(): Promise { const updatedArtistsTrendsResponse = await drizzleClient.updateTrendsInformationBulk( updatedArtistsProfiles.map(artist => ({ - id: artist.id, twitterUserId: artist.twitterUserId, followersCount: artist.followersCount, tweetsCount: artist.tweetsCount, - createdAt: new Date(), })) ); diff --git a/src/services/database/drizzle/drizzle-client.ts b/src/services/database/drizzle/drizzle-client.ts index 52706df..0a71c63 100644 --- a/src/services/database/drizzle/drizzle-client.ts +++ b/src/services/database/drizzle/drizzle-client.ts @@ -60,14 +60,19 @@ export default class DrizzleClient implements IDatabaseClient { } async updateTrendsInformationBulk( - trendData: ArtistTrend[] + trendData: Omit[] ): Promise> { if (!this.client) throw Error('Client is not initialized'); const promises = trendData.map(trend => { return this.client .insert(artistsTrends) - .values({ ...trend }) + .values({ + followersCount: trend.followersCount, + tweetsCount: trend.tweetsCount, + twitterUserId: trend.twitterUserId, + createdAt: new Date(), + }) .returning() .execute(); }); @@ -82,7 +87,7 @@ export default class DrizzleClient implements IDatabaseClient { } else { errorResults.push({ reason: result.reason, - description: trendData[index]!.id, + description: trendData[index]!.twitterUserId, }); return null; } From 3e6b0c0bbeece04fcd8a530000b280bfbf271fe7 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Thu, 6 Mar 2025 15:30:39 +0300 Subject: [PATCH 05/25] fix: start init --- src/services/cron.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/cron.ts b/src/services/cron.ts index aa170af..7bfaab5 100644 --- a/src/services/cron.ts +++ b/src/services/cron.ts @@ -16,7 +16,7 @@ function startCronUpdateStats() { async () => updateArtistsInformation(), { name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', - runOnInit: envConfig.RUN_ON_START, + runOnInit: envConfig.RUN_ON_START as boolean, } ); } @@ -29,7 +29,7 @@ function startCronFetchArtistSuggestion() { }, { name: 'Fetching suggested artists profiles.', - runOnInit: envConfig.RUN_ON_START, + runOnInit: envConfig.RUN_ON_START as boolean, } ); } From 5a45f54a108eb22c8d4017a4795359dd91f575ea Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Thu, 6 Mar 2025 15:33:45 +0300 Subject: [PATCH 06/25] fix: start init --- src/services/cron.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/cron.ts b/src/services/cron.ts index 7bfaab5..4960b88 100644 --- a/src/services/cron.ts +++ b/src/services/cron.ts @@ -9,6 +9,7 @@ import { Artist } from './database/drizzle/schema/artists'; const EVERY_HOURS = 24; const FETCH_TIMEOUT = 3000; +const s: boolean = envConfig.RUN_ON_START ? true : false; function startCronUpdateStats() { cron.schedule( @@ -16,7 +17,7 @@ function startCronUpdateStats() { async () => updateArtistsInformation(), { name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', - runOnInit: envConfig.RUN_ON_START as boolean, + runOnInit: s, } ); } @@ -29,7 +30,7 @@ function startCronFetchArtistSuggestion() { }, { name: 'Fetching suggested artists profiles.', - runOnInit: envConfig.RUN_ON_START as boolean, + runOnInit: s, } ); } From 7e5e07867f7337b2557b5afa862718f3a2b744d3 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Thu, 6 Mar 2025 16:24:46 +0300 Subject: [PATCH 07/25] fix: update discord logs --- src/services/cron.ts | 4 ++-- src/services/discord-webhook.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/cron.ts b/src/services/cron.ts index 4960b88..6c2a347 100644 --- a/src/services/cron.ts +++ b/src/services/cron.ts @@ -60,7 +60,7 @@ async function updateArtistsInformation(): Promise { logger(`Starting recieving artist profiles from twitter...`); sendDiscordMessage( 'Updating artists information', - `Recieved ${artistProfiles.length} artist profiles\nStarting update data...`, + `Recieved ${artistProfiles.length} artist profiles, updating`, 'info' ); @@ -127,7 +127,7 @@ async function updateArtistsInformation(): Promise { ); sendDiscordMessage( 'Updating artists information', - `Successfully updated ${updatedArtistsProfilesResponse.items.length} artist profiles and ${updatedArtistsTrendsResponse.items.length} trends`, + `Successfully updated:\nTotal updated artists: ${'```' + updatedArtistsProfilesResponse.items.length + '```'}`, 'info' ); diff --git a/src/services/discord-webhook.ts b/src/services/discord-webhook.ts index f9c87d5..66b2df2 100644 --- a/src/services/discord-webhook.ts +++ b/src/services/discord-webhook.ts @@ -18,7 +18,7 @@ export function sendDiscordMessage( .setTimestamp(); } else if (severity === 'info') { embed = new MessageBuilder() - .setColor('#FF902B') + .setColor('#7ffa45') .setTitle(`galatea - ${title.toLocaleLowerCase()}`) .setDescription(message) .setTimestamp(); From 90a09356064c0eaf65cb4e4f8ee27ea84c23f191 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Thu, 6 Mar 2025 16:27:38 +0300 Subject: [PATCH 08/25] fix: config --- src/services/cron.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/services/cron.ts b/src/services/cron.ts index 6c2a347..6e47de7 100644 --- a/src/services/cron.ts +++ b/src/services/cron.ts @@ -9,7 +9,6 @@ import { Artist } from './database/drizzle/schema/artists'; const EVERY_HOURS = 24; const FETCH_TIMEOUT = 3000; -const s: boolean = envConfig.RUN_ON_START ? true : false; function startCronUpdateStats() { cron.schedule( @@ -17,7 +16,7 @@ function startCronUpdateStats() { async () => updateArtistsInformation(), { name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', - runOnInit: s, + runOnInit: envConfig.RUN_ON_START, } ); } @@ -30,7 +29,7 @@ function startCronFetchArtistSuggestion() { }, { name: 'Fetching suggested artists profiles.', - runOnInit: s, + runOnInit: envConfig.RUN_ON_START, } ); } From 4f522ab87902dd97ce79a8b4b21a35bc098699ea Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Sun, 9 Mar 2025 23:07:23 +0300 Subject: [PATCH 09/25] fix: discord text log formatting --- src/services/cron.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/cron.ts b/src/services/cron.ts index 6e47de7..b38d855 100644 --- a/src/services/cron.ts +++ b/src/services/cron.ts @@ -59,7 +59,7 @@ async function updateArtistsInformation(): Promise { logger(`Starting recieving artist profiles from twitter...`); sendDiscordMessage( 'Updating artists information', - `Recieved ${artistProfiles.length} artist profiles, updating`, + `Recieved ${'`'}${artistProfiles.length}${'`'} artist profiles, updating`, 'info' ); @@ -83,7 +83,7 @@ async function updateArtistsInformation(): Promise { )) as ParsedProfile[]; if (artistsInformation.length === 0) { - logger('Recieved 0 artists profiles from twitter'); + logger('Recieved `0` artists profiles from twitter'); return; } @@ -126,7 +126,7 @@ async function updateArtistsInformation(): Promise { ); sendDiscordMessage( 'Updating artists information', - `Successfully updated:\nTotal updated artists: ${'```' + updatedArtistsProfilesResponse.items.length + '```'}`, + `Successfully updated:\nTotal updated artists: ${'`' + updatedArtistsProfilesResponse.items.length + '`'}`, 'info' ); From 70467c93152f6403254ab00a4cb186ed027bc3fa Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Tue, 11 Mar 2025 21:58:57 +0300 Subject: [PATCH 10/25] feat: added trend percent update --- src/index.ts | 1 - src/services/cron.ts | 24 +++++- .../database/drizzle/drizzle-client.ts | 74 ++++++++++++++++++- 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index bfc4c5e..b0e7383 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,5 +2,4 @@ import { startCronUpdateStats } from './services/cron'; import { logger } from './utils'; logger('Service started.'); - startCronUpdateStats(); diff --git a/src/services/cron.ts b/src/services/cron.ts index b38d855..9853e1f 100644 --- a/src/services/cron.ts +++ b/src/services/cron.ts @@ -109,9 +109,6 @@ async function updateArtistsInformation(): Promise { }) ); - const updatedArtistsProfilesResponse = - await drizzleClient.updateArtistInformationBulk(updatedArtistsProfiles); - const updatedArtistsTrendsResponse = await drizzleClient.updateTrendsInformationBulk( updatedArtistsProfiles.map(artist => ({ @@ -121,6 +118,12 @@ async function updateArtistsInformation(): Promise { })) ); + const updatedArtistsProfilesResponse = + await drizzleClient.updateArtistInformationBulk(updatedArtistsProfiles); + + const updatePercent = + await drizzleClient.updateArtistsFollowersTweetsPercent(); + logger( `Successfully updated artists ${updatedArtistsProfilesResponse.items.length} profiles and trends ${updatedArtistsTrendsResponse.items.length}` ); @@ -130,6 +133,21 @@ async function updateArtistsInformation(): Promise { 'info' ); + if (updatePercent.errors && updatePercent.errors.length > 0) { + logger(`Errors ${updatePercent.errors.length}:`); + logger(updatePercent.errors.map(error => error.description).join(', ')); + + if (updatePercent.errors.length > 0) { + updatePercent.errors.forEach(element => { + sendDiscordMessage( + 'Error while updating artist percent change', + `${'```'}${element.reason}:\n${element.description}${'```'}`, + 'error' + ); + }); + } + } + if ( updatedArtistsProfilesResponse.errors && updatedArtistsProfilesResponse.errors.length > 0 diff --git a/src/services/database/drizzle/drizzle-client.ts b/src/services/database/drizzle/drizzle-client.ts index 0a71c63..0f078e7 100644 --- a/src/services/database/drizzle/drizzle-client.ts +++ b/src/services/database/drizzle/drizzle-client.ts @@ -8,7 +8,7 @@ import { import { drizzle } from 'drizzle-orm/node-postgres'; import { drizzleConfig } from './drizzle.config'; import { IDatabaseClient } from '../database-client.interface'; -import { eq } from 'drizzle-orm'; +import { eq, gte } from 'drizzle-orm'; import { ErrorResponse, Response } from './models/response'; export default class DrizzleClient implements IDatabaseClient { @@ -96,4 +96,76 @@ export default class DrizzleClient implements IDatabaseClient { return { items: processedResults, errors: errorResults }; } + + async updateArtistsFollowersTweetsPercent(): Promise> { + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const trends = await this.client + .select() + .from(artistsTrends) + .where(gte(artistsTrends.createdAt, sevenDaysAgo)) + .execute(); + + const trendsByArtist = trends.reduce( + (acc, trend) => { + if (!acc[trend.twitterUserId]) { + acc[trend.twitterUserId] = []; + } + acc[trend.twitterUserId].push(trend); + return acc; + }, + {} as Record + ); + + const updatePromises = Object.keys(trendsByArtist).map( + async twitterUserId => { + const artistTrends = trendsByArtist[twitterUserId]; + const sortedTrends = artistTrends.sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime() + ); + + const oldestTrend = sortedTrends[0]; + const latestTrend = sortedTrends[sortedTrends.length - 1]; + + const followersChangePercent = + ((latestTrend.followersCount - oldestTrend.followersCount) / + oldestTrend.followersCount) * + 100; + const tweetsChangePercent = + ((latestTrend.tweetsCount - oldestTrend.tweetsCount) / + oldestTrend.tweetsCount) * + 100; + + return this.client + .update(artists) + .set({ + weeklyFollowersTrend: followersChangePercent, + weeklyTweetsTrend: tweetsChangePercent, + }) + .where(eq(artists.twitterUserId, twitterUserId)) + .returning() + .execute(); + } + ); + + const updateResults = await Promise.allSettled(updatePromises); + + const errorResults: ErrorResponse[] = []; + const processedResults = updateResults + .map((result, index) => { + if (result.status === 'fulfilled') { + return result.value[0]; + } else { + errorResults.push({ + reason: result.reason, + description: Object.keys(trendsByArtist)[index], + }); + return null; + } + }) + .filter(Boolean) as Artist[]; + + return { items: processedResults, errors: errorResults }; + } } From 6ee802da6c08ee389839d079ce62024046741a97 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Tue, 18 Mar 2025 20:02:42 +0300 Subject: [PATCH 11/25] feat: added ranking and isEnabled support --- .prettierrc | 2 +- src/services/cron.ts | 6 +- .../database/drizzle/drizzle-client.ts | 71 ++++++++----------- .../database/drizzle/schema/artists.ts | 41 +++-------- 4 files changed, 43 insertions(+), 77 deletions(-) diff --git a/.prettierrc b/.prettierrc index 01ea2b5..35c73d8 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,7 +2,7 @@ "semi": true, "arrowParens": "avoid", "singleQuote": true, - "printWidth": 80, + "printWidth": 120, "tabWidth": 2, "useTabs": false, "trailingComma": "es5", diff --git a/src/services/cron.ts b/src/services/cron.ts index 9853e1f..464564d 100644 --- a/src/services/cron.ts +++ b/src/services/cron.ts @@ -1,9 +1,9 @@ import cron from 'node-cron'; +import DrizzleClient from './database/drizzle/drizzle-client'; +import TwitterClient from './twitter/open-api/twitter-client'; import { logger } from '../utils'; import { sendDiscordMessage } from './discord-webhook'; import { envConfig } from '../../env.config'; -import DrizzleClient from './database/drizzle/drizzle-client'; -import TwitterClient from './twitter/open-api/twitter-client'; import { ParsedProfile } from './twitter/models/parsed-profile'; import { Artist } from './database/drizzle/schema/artists'; @@ -16,7 +16,7 @@ function startCronUpdateStats() { async () => updateArtistsInformation(), { name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', - runOnInit: envConfig.RUN_ON_START, + runOnInit: true, } ); } diff --git a/src/services/database/drizzle/drizzle-client.ts b/src/services/database/drizzle/drizzle-client.ts index 0f078e7..c451e05 100644 --- a/src/services/database/drizzle/drizzle-client.ts +++ b/src/services/database/drizzle/drizzle-client.ts @@ -1,14 +1,8 @@ -import { - Artist, - artists, - artistsSuggestions, - artistsTrends, - ArtistTrend, -} from './schema/artists'; +import { Artist, artists, artistsSuggestions, artistsTrends, ArtistTrend } from './schema/artists'; import { drizzle } from 'drizzle-orm/node-postgres'; import { drizzleConfig } from './drizzle.config'; import { IDatabaseClient } from '../database-client.interface'; -import { eq, gte } from 'drizzle-orm'; +import { and, eq, gte, ne } from 'drizzle-orm'; import { ErrorResponse, Response } from './models/response'; export default class DrizzleClient implements IDatabaseClient { @@ -24,12 +18,12 @@ export default class DrizzleClient implements IDatabaseClient { } async getArtistsProfiles(): Promise { - return await this.client.query.artists.findMany(); + return await this.client.query.artists.findMany({ + where: ne(artists.isEnabled, false), + }); } - async updateArtistInformationBulk( - artistsData: Artist[] - ): Promise> { + async updateArtistInformationBulk(artistsData: Artist[]): Promise> { const promises = artistsData.map(profile => { return this.client .update(artists) @@ -104,7 +98,7 @@ export default class DrizzleClient implements IDatabaseClient { const trends = await this.client .select() .from(artistsTrends) - .where(gte(artistsTrends.createdAt, sevenDaysAgo)) + .where(and(gte(artistsTrends.createdAt, sevenDaysAgo), ne(artists.isEnabled, false))) .execute(); const trendsByArtist = trends.reduce( @@ -118,36 +112,27 @@ export default class DrizzleClient implements IDatabaseClient { {} as Record ); - const updatePromises = Object.keys(trendsByArtist).map( - async twitterUserId => { - const artistTrends = trendsByArtist[twitterUserId]; - const sortedTrends = artistTrends.sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime() - ); - - const oldestTrend = sortedTrends[0]; - const latestTrend = sortedTrends[sortedTrends.length - 1]; - - const followersChangePercent = - ((latestTrend.followersCount - oldestTrend.followersCount) / - oldestTrend.followersCount) * - 100; - const tweetsChangePercent = - ((latestTrend.tweetsCount - oldestTrend.tweetsCount) / - oldestTrend.tweetsCount) * - 100; - - return this.client - .update(artists) - .set({ - weeklyFollowersTrend: followersChangePercent, - weeklyTweetsTrend: tweetsChangePercent, - }) - .where(eq(artists.twitterUserId, twitterUserId)) - .returning() - .execute(); - } - ); + const updatePromises = Object.keys(trendsByArtist).map(async twitterUserId => { + const artistTrends = trendsByArtist[twitterUserId]; + const sortedTrends = artistTrends.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + const oldestTrend = sortedTrends[0]; + const latestTrend = sortedTrends[sortedTrends.length - 1]; + + const followersChangePercent = + ((latestTrend.followersCount - oldestTrend.followersCount) / oldestTrend.followersCount) * 100; + const tweetsChangePercent = ((latestTrend.tweetsCount - oldestTrend.tweetsCount) / oldestTrend.tweetsCount) * 100; + + return this.client + .update(artists) + .set({ + weeklyFollowersTrend: followersChangePercent, + weeklyTweetsTrend: tweetsChangePercent, + }) + .where(eq(artists.twitterUserId, twitterUserId)) + .returning() + .execute(); + }); const updateResults = await Promise.allSettled(updatePromises); diff --git a/src/services/database/drizzle/schema/artists.ts b/src/services/database/drizzle/schema/artists.ts index d924bfb..27d0226 100644 --- a/src/services/database/drizzle/schema/artists.ts +++ b/src/services/database/drizzle/schema/artists.ts @@ -1,14 +1,5 @@ -import { InferModelFromColumns, InferSelectModel } from 'drizzle-orm'; -import { - integer, - pgTable, - real, - text, - timestamp, - uuid, - varchar, - customType, -} from 'drizzle-orm/pg-core'; +import { InferSelectModel } from 'drizzle-orm'; +import { integer, pgTable, real, text, timestamp, uuid, varchar, customType, boolean } from 'drizzle-orm/pg-core'; type Images = { avatar: string; banner?: string }; @@ -32,20 +23,16 @@ export const artists = pgTable('artists', { weeklyTweetsTrend: real('weekly_tweets_trend').notNull().default(0), weeklyFollowersTrend: real('weekly_followers_trend').notNull().default(0), images: typedJsonb('images').notNull(), - tags: text('tags').array().notNull().default(['pixelart']), + tags: text('tags').array().notNull(), url: text('url').notNull(), country: text('country'), website: text('website'), bio: text('bio'), - createdAt: timestamp('created_at', { withTimezone: true }) - .notNull() - .defaultNow(), - joinedAt: timestamp('joined_at', { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }) - .notNull() - .defaultNow(), + isEnabled: boolean('is_enabled').notNull().default(true), + ranking: integer('ranking').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); export const artistsSuggestions = pgTable('artistsSuggestions', { @@ -56,12 +43,8 @@ export const artistsSuggestions = pgTable('artistsSuggestions', { tags: text('tags').array().notNull().default(['pixelart']), status: varchar('status', { length: 255 }).notNull().default('requested'), requestedFrom: text('requested_from').notNull().default('suggestions'), - createdAt: timestamp('created_at', { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); export const artistsTrends = pgTable('artistsTrends', { @@ -71,9 +54,7 @@ export const artistsTrends = pgTable('artistsTrends', { .notNull(), tweetsCount: integer('tweets_count').notNull(), followersCount: integer('followers_count').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); export type Artist = InferSelectModel; From 08da77ada6a649e09e034cd94db066004a1b38b8 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Tue, 18 Mar 2025 20:09:00 +0300 Subject: [PATCH 12/25] fix: disabled occasionally enabled auto-start for cron --- src/services/cron.ts | 99 +++++++++++++++----------------------------- 1 file changed, 34 insertions(+), 65 deletions(-) diff --git a/src/services/cron.ts b/src/services/cron.ts index 464564d..a0c556f 100644 --- a/src/services/cron.ts +++ b/src/services/cron.ts @@ -11,14 +11,10 @@ const EVERY_HOURS = 24; const FETCH_TIMEOUT = 3000; function startCronUpdateStats() { - cron.schedule( - `0 0 */${EVERY_HOURS} * * *`, - async () => updateArtistsInformation(), - { - name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', - runOnInit: true, - } - ); + cron.schedule(`0 0 */${EVERY_HOURS} * * *`, async () => updateArtistsInformation(), { + name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', + runOnInit: envConfig.RUN_ON_START, + }); } function startCronFetchArtistSuggestion() { @@ -67,20 +63,14 @@ async function updateArtistsInformation(): Promise { (artist, index) => new Promise(async resolve => { setTimeout(async () => { - const data = await twitterClient.getTwitterUserByUserId( - artist.twitterUserId - ); - logger( - `[${index + 1} / ${artistProfiles.length}] Fetched profile for user ${artist.username}` - ); + const data = await twitterClient.getTwitterUserByUserId(artist.twitterUserId); + logger(`[${index + 1} / ${artistProfiles.length}] Fetched profile for user ${artist.username}`); resolve(data); }, index * FETCH_TIMEOUT); }) ); - const artistsInformation: ParsedProfile[] = (await Promise.all( - reqList - )) as ParsedProfile[]; + const artistsInformation: ParsedProfile[] = (await Promise.all(reqList)) as ParsedProfile[]; if (artistsInformation.length === 0) { logger('Recieved `0` artists profiles from twitter'); @@ -90,39 +80,32 @@ async function updateArtistsInformation(): Promise { logger('Fetched all profiles from twitter'); logger('Starting updating artists information...'); - const updatedArtistsProfiles: Artist[] = artistProfiles.map( - (artist, index) => ({ - ...artist, - name: artistsInformation[index]?.displayName || artist.name, - username: artistsInformation[index]?.username || artist.username, - tweetsCount: - artistsInformation[index]?.tweetsCount || artist.tweetsCount, - followersCount: - artistsInformation[index]?.followersCount || artist.followersCount, - images: { - avatar: artistsInformation[index]?.avatarUrl || artist.images.avatar, - banner: artistsInformation[index]?.bannerUrl || artist.images.banner, - }, - bio: artistsInformation[index]?.biography || artist.bio, - website: artistsInformation[index]?.website || artist.website, - updatedAt: new Date(), - }) + const updatedArtistsProfiles: Artist[] = artistProfiles.map((artist, index) => ({ + ...artist, + name: artistsInformation[index]?.displayName || artist.name, + username: artistsInformation[index]?.username || artist.username, + tweetsCount: artistsInformation[index]?.tweetsCount || artist.tweetsCount, + followersCount: artistsInformation[index]?.followersCount || artist.followersCount, + images: { + avatar: artistsInformation[index]?.avatarUrl || artist.images.avatar, + banner: artistsInformation[index]?.bannerUrl || artist.images.banner, + }, + bio: artistsInformation[index]?.biography || artist.bio, + website: artistsInformation[index]?.website || artist.website, + updatedAt: new Date(), + })); + + const updatedArtistsTrendsResponse = await drizzleClient.updateTrendsInformationBulk( + updatedArtistsProfiles.map(artist => ({ + twitterUserId: artist.twitterUserId, + followersCount: artist.followersCount, + tweetsCount: artist.tweetsCount, + })) ); - const updatedArtistsTrendsResponse = - await drizzleClient.updateTrendsInformationBulk( - updatedArtistsProfiles.map(artist => ({ - twitterUserId: artist.twitterUserId, - followersCount: artist.followersCount, - tweetsCount: artist.tweetsCount, - })) - ); - - const updatedArtistsProfilesResponse = - await drizzleClient.updateArtistInformationBulk(updatedArtistsProfiles); + const updatedArtistsProfilesResponse = await drizzleClient.updateArtistInformationBulk(updatedArtistsProfiles); - const updatePercent = - await drizzleClient.updateArtistsFollowersTweetsPercent(); + const updatePercent = await drizzleClient.updateArtistsFollowersTweetsPercent(); logger( `Successfully updated artists ${updatedArtistsProfilesResponse.items.length} profiles and trends ${updatedArtistsTrendsResponse.items.length}` @@ -148,16 +131,9 @@ async function updateArtistsInformation(): Promise { } } - if ( - updatedArtistsProfilesResponse.errors && - updatedArtistsProfilesResponse.errors.length > 0 - ) { + if (updatedArtistsProfilesResponse.errors && updatedArtistsProfilesResponse.errors.length > 0) { logger(`Errors ${updatedArtistsProfilesResponse.errors.length}:`); - logger( - updatedArtistsProfilesResponse.errors - .map(error => error.description) - .join(', ') - ); + logger(updatedArtistsProfilesResponse.errors.map(error => error.description).join(', ')); if (updatedArtistsProfilesResponse.errors.length > 0) { updatedArtistsProfilesResponse.errors.forEach(element => { @@ -170,16 +146,9 @@ async function updateArtistsInformation(): Promise { } } - if ( - updatedArtistsTrendsResponse.errors && - updatedArtistsTrendsResponse.errors.length > 0 - ) { + if (updatedArtistsTrendsResponse.errors && updatedArtistsTrendsResponse.errors.length > 0) { logger(`Errors ${updatedArtistsTrendsResponse.errors.length}:`); - logger( - updatedArtistsTrendsResponse.errors - .map(error => error.description) - .join(', ') - ); + logger(updatedArtistsTrendsResponse.errors.map(error => error.description).join(', ')); updatedArtistsTrendsResponse.errors.forEach(element => { sendDiscordMessage( From 808e6a1f803fb14f503d45a6bdb060cd877388ff Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Wed, 19 Mar 2025 13:19:45 +0300 Subject: [PATCH 13/25] fix: removed crashing condition --- src/services/database/drizzle/drizzle-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/database/drizzle/drizzle-client.ts b/src/services/database/drizzle/drizzle-client.ts index c451e05..023d00f 100644 --- a/src/services/database/drizzle/drizzle-client.ts +++ b/src/services/database/drizzle/drizzle-client.ts @@ -98,7 +98,7 @@ export default class DrizzleClient implements IDatabaseClient { const trends = await this.client .select() .from(artistsTrends) - .where(and(gte(artistsTrends.createdAt, sevenDaysAgo), ne(artists.isEnabled, false))) + .where(gte(artistsTrends.createdAt, sevenDaysAgo)) .execute(); const trendsByArtist = trends.reduce( From 2a51b0ae495bb9f215049189c8b158d21f7eeb6f Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Thu, 20 Mar 2025 10:09:42 +0300 Subject: [PATCH 14/25] fix: correct env boolean convert --- env.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/env.config.ts b/env.config.ts index 6c6a186..250686c 100644 --- a/env.config.ts +++ b/env.config.ts @@ -1,3 +1,3 @@ export const envConfig = { - RUN_ON_START: (process.env.RUN_ON_INIT as unknown as boolean) || false, + RUN_ON_START: Boolean(process.env.RUN_ON_INIT) || false, }; From c370cdaa03148efcf2e0a9d7b7983b93670e9fa1 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Thu, 20 Mar 2025 19:09:12 +0300 Subject: [PATCH 15/25] fix: correct env vars extracting --- env.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/env.config.ts b/env.config.ts index 250686c..c289836 100644 --- a/env.config.ts +++ b/env.config.ts @@ -1,3 +1,3 @@ export const envConfig = { - RUN_ON_START: Boolean(process.env.RUN_ON_INIT) || false, + RUN_ON_START: process.env['RUN_ON_INIT'] === 'true', }; From 4617454c6ff33192e0e64e264439e4e858ff276d Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Fri, 21 Mar 2025 22:42:22 +0300 Subject: [PATCH 16/25] feat: ranking --- src/services/cron.ts | 174 -------------- src/services/cron/data-gathering.ts | 214 ++++++++++++++++++ src/services/cron/index.ts | 29 +++ .../database/drizzle/schema/artists.ts | 2 + 4 files changed, 245 insertions(+), 174 deletions(-) delete mode 100644 src/services/cron.ts create mode 100644 src/services/cron/data-gathering.ts create mode 100644 src/services/cron/index.ts diff --git a/src/services/cron.ts b/src/services/cron.ts deleted file mode 100644 index a0c556f..0000000 --- a/src/services/cron.ts +++ /dev/null @@ -1,174 +0,0 @@ -import cron from 'node-cron'; -import DrizzleClient from './database/drizzle/drizzle-client'; -import TwitterClient from './twitter/open-api/twitter-client'; -import { logger } from '../utils'; -import { sendDiscordMessage } from './discord-webhook'; -import { envConfig } from '../../env.config'; -import { ParsedProfile } from './twitter/models/parsed-profile'; -import { Artist } from './database/drizzle/schema/artists'; - -const EVERY_HOURS = 24; -const FETCH_TIMEOUT = 3000; - -function startCronUpdateStats() { - cron.schedule(`0 0 */${EVERY_HOURS} * * *`, async () => updateArtistsInformation(), { - name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', - runOnInit: envConfig.RUN_ON_START, - }); -} - -function startCronFetchArtistSuggestion() { - cron.schedule( - `0 0 */${EVERY_HOURS} * * *`, - async () => { - throw new Error('Not implemented'); - }, - { - name: 'Fetching suggested artists profiles.', - runOnInit: envConfig.RUN_ON_START, - } - ); -} - -/** - * Updates artists information by fetching profiles from dotcreators-sun and Twitter, - * then updating the information in the database. - * - * @async - * @function updateArtistsInformation - */ -async function updateArtistsInformation(): Promise { - logger('Starting fetching artist profiles from dotcreatros-sun...'); - - try { - const drizzleClient = new DrizzleClient(); - const twitterClient = new TwitterClient(); - - const artistProfiles = await drizzleClient.getArtistsProfiles(); - - if (artistProfiles.length === 0) { - logger('Recieved 0 artists profiles dotcreators-sun'); - return; - } - - logger(`Recieved ${artistProfiles.length} artist profiles`); - logger(`Starting recieving artist profiles from twitter...`); - sendDiscordMessage( - 'Updating artists information', - `Recieved ${'`'}${artistProfiles.length}${'`'} artist profiles, updating`, - 'info' - ); - - const reqList = artistProfiles.map( - (artist, index) => - new Promise(async resolve => { - setTimeout(async () => { - const data = await twitterClient.getTwitterUserByUserId(artist.twitterUserId); - logger(`[${index + 1} / ${artistProfiles.length}] Fetched profile for user ${artist.username}`); - resolve(data); - }, index * FETCH_TIMEOUT); - }) - ); - - const artistsInformation: ParsedProfile[] = (await Promise.all(reqList)) as ParsedProfile[]; - - if (artistsInformation.length === 0) { - logger('Recieved `0` artists profiles from twitter'); - return; - } - - logger('Fetched all profiles from twitter'); - logger('Starting updating artists information...'); - - const updatedArtistsProfiles: Artist[] = artistProfiles.map((artist, index) => ({ - ...artist, - name: artistsInformation[index]?.displayName || artist.name, - username: artistsInformation[index]?.username || artist.username, - tweetsCount: artistsInformation[index]?.tweetsCount || artist.tweetsCount, - followersCount: artistsInformation[index]?.followersCount || artist.followersCount, - images: { - avatar: artistsInformation[index]?.avatarUrl || artist.images.avatar, - banner: artistsInformation[index]?.bannerUrl || artist.images.banner, - }, - bio: artistsInformation[index]?.biography || artist.bio, - website: artistsInformation[index]?.website || artist.website, - updatedAt: new Date(), - })); - - const updatedArtistsTrendsResponse = await drizzleClient.updateTrendsInformationBulk( - updatedArtistsProfiles.map(artist => ({ - twitterUserId: artist.twitterUserId, - followersCount: artist.followersCount, - tweetsCount: artist.tweetsCount, - })) - ); - - const updatedArtistsProfilesResponse = await drizzleClient.updateArtistInformationBulk(updatedArtistsProfiles); - - const updatePercent = await drizzleClient.updateArtistsFollowersTweetsPercent(); - - logger( - `Successfully updated artists ${updatedArtistsProfilesResponse.items.length} profiles and trends ${updatedArtistsTrendsResponse.items.length}` - ); - sendDiscordMessage( - 'Updating artists information', - `Successfully updated:\nTotal updated artists: ${'`' + updatedArtistsProfilesResponse.items.length + '`'}`, - 'info' - ); - - if (updatePercent.errors && updatePercent.errors.length > 0) { - logger(`Errors ${updatePercent.errors.length}:`); - logger(updatePercent.errors.map(error => error.description).join(', ')); - - if (updatePercent.errors.length > 0) { - updatePercent.errors.forEach(element => { - sendDiscordMessage( - 'Error while updating artist percent change', - `${'```'}${element.reason}:\n${element.description}${'```'}`, - 'error' - ); - }); - } - } - - if (updatedArtistsProfilesResponse.errors && updatedArtistsProfilesResponse.errors.length > 0) { - logger(`Errors ${updatedArtistsProfilesResponse.errors.length}:`); - logger(updatedArtistsProfilesResponse.errors.map(error => error.description).join(', ')); - - if (updatedArtistsProfilesResponse.errors.length > 0) { - updatedArtistsProfilesResponse.errors.forEach(element => { - sendDiscordMessage( - 'Error while updating artist profile', - `${'```'}${element.reason}:\n${element.description}${'```'}`, - 'error' - ); - }); - } - } - - if (updatedArtistsTrendsResponse.errors && updatedArtistsTrendsResponse.errors.length > 0) { - logger(`Errors ${updatedArtistsTrendsResponse.errors.length}:`); - logger(updatedArtistsTrendsResponse.errors.map(error => error.description).join(', ')); - - updatedArtistsTrendsResponse.errors.forEach(element => { - sendDiscordMessage( - 'Error while updating artist trends', - `${'```'}${element.reason}:\n${element.description}${'```'}`, - 'error' - ); - }); - } else { - logger('Successfully updated artists information'); - } - } catch (e: any) { - logger('Error while trying to update artists information:'); - logger(e.message); - sendDiscordMessage( - 'Error while trying to update artists information', - `${'```'}${e.reason}:\n${e.message}${'```'}`, - 'error' - ); - } -} - -export { startCronUpdateStats, startCronFetchArtistSuggestion }; diff --git a/src/services/cron/data-gathering.ts b/src/services/cron/data-gathering.ts new file mode 100644 index 0000000..0676584 --- /dev/null +++ b/src/services/cron/data-gathering.ts @@ -0,0 +1,214 @@ +import DrizzleClient from 'services/database/drizzle/drizzle-client'; +import { ErrorResponse } from 'services/database/drizzle/models/response'; +import { Artist } from 'services/database/drizzle/schema/artists'; +import { sendDiscordMessage } from 'services/discord-webhook'; +import { ParsedProfile } from 'services/twitter/models/parsed-profile'; +import TwitterClient from 'services/twitter/open-api/twitter-client'; +import { logger } from 'utils'; + +class DataGathering { + private drizzleClient = new DrizzleClient(); + private twitterClient = new TwitterClient(); + private readonly FETCH_TIMEOUT = 3000; + + private calculateArtistsRanking(artists: Artist[]): Artist[] { + artists.sort((a, b) => b.weeklyFollowersTrend - a.weeklyFollowersTrend); + + artists.forEach((artist, index) => { + const newRanking = index + 1; + const rankingChange = artist.previousRanking - newRanking; + + artist.previousRanking = artist.ranking; + artist.ranking = newRanking; + artist.rankingChange = rankingChange; + }); + + return artists; + } + + private handleErrors(errors: ErrorResponse[], operation: string) { + if (errors && errors.length > 0) { + logger(`Errors in ${operation} (${errors.length}):`); + logger(errors.map(error => error.description).join(', ')); + + errors.forEach(element => { + sendDiscordMessage( + `Error while updating ${'```'}${operation}${'```'}`, + `${'```'}${element.reason}:\n${element.description}${'```'}`, + 'error' + ); + }); + } + } + + async updateArtistsInformation() { + try { + const artists = await this.drizzleClient.getArtistsProfiles(); + + if (artists.length === 0) { + logger('Recieved 0 artists profiles dotcreators-sun'); + return; + } + + logger(`Recieved ${artists.length} artist profiles`); + logger(`Starting recieving artist profiles from twitter...`); + sendDiscordMessage( + 'Updating artists information', + `Recieved ${'`'}${artists.length}${'`'} artist profiles, updating`, + 'info' + ); + + const requestsList = artists.map( + (artist, index) => + new Promise(async resolve => { + setTimeout(async () => { + const data = await this.twitterClient.getTwitterUserByUserId(artist.twitterUserId); + logger(`[${index + 1} / ${artists.length}] Fetched profile for user ${artist.username}`); + resolve(data); + }, index * this.FETCH_TIMEOUT); + }) + ); + const parsedArtists: ParsedProfile[] = (await Promise.all(requestsList)) as ParsedProfile[]; + + if (parsedArtists.length === 0) { + logger('Recieved `0` artists profiles from twitter'); + return; + } + + logger('Fetched all profiles from twitter'); + logger('Starting updating artists information...'); + + const updatedArtists: Artist[] = artists.map((artist, index) => ({ + ...artist, + name: parsedArtists[index]?.displayName || artist.name, + username: parsedArtists[index]?.username || artist.username, + images: { + avatar: parsedArtists[index]?.avatarUrl || artist.images.avatar, + banner: parsedArtists[index]?.bannerUrl || artist.images.banner, + }, + bio: parsedArtists[index]?.biography || artist.bio, + website: parsedArtists[index]?.website || artist.website, + updatedAt: new Date(), + })); + const updatedArtistsWithRanking = this.calculateArtistsRanking(updatedArtists); + + const updatedArtistsProfilesResponse = + await this.drizzleClient.updateArtistInformationBulk(updatedArtistsWithRanking); + + logger(`Successfully updated artists ${updatedArtistsProfilesResponse.items.length} profiles`); + sendDiscordMessage( + 'Updating artists information', + `Total updated artists: ${'`' + updatedArtistsProfilesResponse.items.length + '`'}`, + 'info' + ); + + if (updatedArtistsProfilesResponse.errors) { + this.handleErrors(updatedArtistsProfilesResponse.errors, 'updating artists profiles'); + } + } catch (e: any) { + logger('Error while trying to update artists information:'); + logger(e.message); + sendDiscordMessage( + 'Error while trying to update artists information', + `${'```'}${e.reason}:\n${e.message}${'```'}`, + 'error' + ); + } + } + + async updateArtistsInformationWithTrends() { + try { + const artists = await this.drizzleClient.getArtistsProfiles(); + + if (artists.length === 0) { + logger('Recieved 0 artists profiles dotcreators-sun'); + return; + } + + logger(`Recieved ${artists.length} artist profiles`); + logger(`Starting recieving artist profiles from twitter...`); + sendDiscordMessage( + 'Updating artists information', + `Recieved ${'`'}${artists.length}${'`'} artist profiles, updating`, + 'info' + ); + + const requestsList = artists.map( + (artist, index) => + new Promise(async resolve => { + setTimeout(async () => { + const data = await this.twitterClient.getTwitterUserByUserId(artist.twitterUserId); + logger(`[${index + 1} / ${artists.length}] Fetched profile for user ${artist.username}`); + resolve(data); + }, index * this.FETCH_TIMEOUT); + }) + ); + const parsedArtists: ParsedProfile[] = (await Promise.all(requestsList)) as ParsedProfile[]; + + if (parsedArtists.length === 0) { + logger('Recieved `0` artists profiles from twitter'); + return; + } + + logger('Fetched all profiles from twitter'); + logger('Starting updating artists information...'); + + const updatedArtists: Artist[] = artists.map((artist, index) => ({ + ...artist, + name: parsedArtists[index]?.displayName || artist.name, + username: parsedArtists[index]?.username || artist.username, + tweetsCount: parsedArtists[index]?.tweetsCount || artist.tweetsCount, + followersCount: parsedArtists[index]?.followersCount || artist.followersCount, + images: { + avatar: parsedArtists[index]?.avatarUrl || artist.images.avatar, + banner: parsedArtists[index]?.bannerUrl || artist.images.banner, + }, + bio: parsedArtists[index]?.biography || artist.bio, + website: parsedArtists[index]?.website || artist.website, + updatedAt: new Date(), + })); + const updatedArtistsWithRanking = this.calculateArtistsRanking(updatedArtists); + + // Final responses + const updatedArtistsTrendsResponse = await this.drizzleClient.updateTrendsInformationBulk( + updatedArtistsWithRanking.map(artist => ({ + twitterUserId: artist.twitterUserId, + followersCount: artist.followersCount, + tweetsCount: artist.tweetsCount, + })) + ); + const updatedArtistsProfilesResponse = + await this.drizzleClient.updateArtistInformationBulk(updatedArtistsWithRanking); + const updatedArtistsPercentResponse = await this.drizzleClient.updateArtistsFollowersTweetsPercent(); + + logger( + `Successfully updated artists ${updatedArtistsProfilesResponse.items.length} profiles and trends ${updatedArtistsTrendsResponse.items.length}` + ); + sendDiscordMessage( + 'Updating artists information', + `Total updated artists with trends: ${'`' + updatedArtistsProfilesResponse.items.length + '`'}`, + 'info' + ); + + if (updatedArtistsProfilesResponse.errors) { + this.handleErrors(updatedArtistsProfilesResponse.errors, 'updating artists profiles'); + } + if (updatedArtistsTrendsResponse.errors) { + this.handleErrors(updatedArtistsTrendsResponse.errors, 'updating artists trends'); + } + if (updatedArtistsPercentResponse.errors) { + this.handleErrors(updatedArtistsPercentResponse.errors, 'updating artists growing percent'); + } + } catch (e: any) { + logger('Error while trying to update artists information with trends:'); + logger(e.message); + sendDiscordMessage( + 'Error while trying to update artists information', + `${'```'}${e.reason}:\n${e.message}${'```'}`, + 'error' + ); + } + } +} + +export { DataGathering }; diff --git a/src/services/cron/index.ts b/src/services/cron/index.ts new file mode 100644 index 0000000..88a9771 --- /dev/null +++ b/src/services/cron/index.ts @@ -0,0 +1,29 @@ +import cron from 'node-cron'; +import { envConfig } from '../../../env.config'; +import { DataGathering } from './data-gathering'; + +const EVERY_HOURS = 24; + +const dataGathering = new DataGathering(); + +function startCronUpdateStats() { + cron.schedule(`0 0 */${EVERY_HOURS} * * *`, async () => dataGathering.updateArtistsInformationWithTrends(), { + name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', + runOnInit: envConfig.RUN_ON_START, + }); +} + +function startCronFetchArtistSuggestion() { + cron.schedule( + `0 0 */${EVERY_HOURS} * * *`, + async () => { + throw new Error('Not implemented'); + }, + { + name: 'Fetching suggested artists profiles.', + runOnInit: envConfig.RUN_ON_START, + } + ); +} + +export { startCronUpdateStats, startCronFetchArtistSuggestion }; diff --git a/src/services/database/drizzle/schema/artists.ts b/src/services/database/drizzle/schema/artists.ts index 27d0226..39b946f 100644 --- a/src/services/database/drizzle/schema/artists.ts +++ b/src/services/database/drizzle/schema/artists.ts @@ -30,6 +30,8 @@ export const artists = pgTable('artists', { bio: text('bio'), isEnabled: boolean('is_enabled').notNull().default(true), ranking: integer('ranking').notNull().default(0), + previousRanking: integer('previous_ranking').notNull().default(0), + rankingChange: integer('ranking_change').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), From 14555d1cb4a923d0408dec3221fdde0f90b1cbb0 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Sat, 22 Mar 2025 17:48:20 +0300 Subject: [PATCH 17/25] fix: prob fixed incorrect calculation for artists growing trend --- .../database/drizzle/drizzle-client.ts | 24 ++++++--- src/tests/index.ts | 49 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 src/tests/index.ts diff --git a/src/services/database/drizzle/drizzle-client.ts b/src/services/database/drizzle/drizzle-client.ts index 023d00f..c9e946d 100644 --- a/src/services/database/drizzle/drizzle-client.ts +++ b/src/services/database/drizzle/drizzle-client.ts @@ -2,7 +2,7 @@ import { Artist, artists, artistsSuggestions, artistsTrends, ArtistTrend } from import { drizzle } from 'drizzle-orm/node-postgres'; import { drizzleConfig } from './drizzle.config'; import { IDatabaseClient } from '../database-client.interface'; -import { and, eq, gte, ne } from 'drizzle-orm'; +import { and, eq, gt, gte, ne } from 'drizzle-orm'; import { ErrorResponse, Response } from './models/response'; export default class DrizzleClient implements IDatabaseClient { @@ -17,10 +17,19 @@ export default class DrizzleClient implements IDatabaseClient { }); } - async getArtistsProfiles(): Promise { - return await this.client.query.artists.findMany({ - where: ne(artists.isEnabled, false), - }); + async getArtistsProfiles(twitterUserId?: string): Promise { + if (twitterUserId) { + const r = await this.client.query.artists.findFirst({ + where: and(ne(artists.isEnabled, false), eq(artists.twitterUserId, twitterUserId)), + }); + + if (!r) throw Error('Failed to fetch artist from database'); + return [r]; + } else { + return await this.client.query.artists.findMany({ + where: ne(artists.isEnabled, false), + }); + } } async updateArtistInformationBulk(artistsData: Artist[]): Promise> { @@ -93,12 +102,13 @@ export default class DrizzleClient implements IDatabaseClient { async updateArtistsFollowersTweetsPercent(): Promise> { const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); + sevenDaysAgo.setHours(0, 0, 0, 0); const trends = await this.client .select() .from(artistsTrends) - .where(gte(artistsTrends.createdAt, sevenDaysAgo)) + .where(and(gte(artistsTrends.createdAt, sevenDaysAgo))) .execute(); const trendsByArtist = trends.reduce( diff --git a/src/tests/index.ts b/src/tests/index.ts new file mode 100644 index 0000000..55b2b2c --- /dev/null +++ b/src/tests/index.ts @@ -0,0 +1,49 @@ +import DrizzleClient from 'services/database/drizzle/drizzle-client'; +import { Artist } from 'services/database/drizzle/schema/artists'; +import { ParsedProfile } from 'services/twitter/models/parsed-profile'; +import TwitterClient from 'services/twitter/open-api/twitter-client'; + +const drizzleClient = new DrizzleClient(); +const twitterClient = new TwitterClient(); + +async function testTrendingCalculation() { + try { + const a = await drizzleClient.getArtistsProfiles('1287977244023914496'); + const artist: Artist = a[0]; + + const parsedArtist = (await twitterClient.getTwitterUserByUserId(artist.twitterUserId)) as ParsedProfile; + + if (!parsedArtist) throw Error('Unable to fetch artist profile'); + + const updatedArtist: Artist = { + ...artist, + name: parsedArtist.displayName || artist.name, + username: parsedArtist.username || artist.username, + images: { + avatar: parsedArtist.avatarUrl || artist.images.avatar, + banner: parsedArtist.bannerUrl || artist.images.banner, + }, + bio: parsedArtist.biography || artist.bio, + website: parsedArtist.website || artist.website, + updatedAt: new Date(), + }; + + const updatedArtistsInfo = await drizzleClient.updateArtistInformationBulk([updatedArtist]); + const updatedArtistsTrendsResponse = await drizzleClient.updateTrendsInformationBulk([ + { + twitterUserId: updatedArtist.twitterUserId, + followersCount: updatedArtist.followersCount, + tweetsCount: updatedArtist.tweetsCount, + }, + ]); + const updatedArtistsPercentResponse = await drizzleClient.updateArtistsFollowersTweetsPercent(); + + console.log('updatedArtistsInfo', updatedArtistsInfo); + console.log('updatedArtistsTrendsResponse', updatedArtistsTrendsResponse); + console.log('updatedArtistsPercentResponse', updatedArtistsPercentResponse); + } catch (e) { + console.log(e); + } +} + +export { testTrendingCalculation }; From 82cedbc37163b9b21f4097dd7a15a65746ccfd81 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Sat, 22 Mar 2025 22:43:53 +0300 Subject: [PATCH 18/25] fix: change ranking dependencies --- src/services/cron/data-gathering.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/cron/data-gathering.ts b/src/services/cron/data-gathering.ts index 0676584..d63dd31 100644 --- a/src/services/cron/data-gathering.ts +++ b/src/services/cron/data-gathering.ts @@ -12,7 +12,7 @@ class DataGathering { private readonly FETCH_TIMEOUT = 3000; private calculateArtistsRanking(artists: Artist[]): Artist[] { - artists.sort((a, b) => b.weeklyFollowersTrend - a.weeklyFollowersTrend); + artists.sort((a, b) => b.followersCount - a.followersCount); artists.forEach((artist, index) => { const newRanking = index + 1; From 4740f256e4fa237b0dc21a1ea08997f971f5a696 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Tue, 29 Apr 2025 23:24:43 +0300 Subject: [PATCH 19/25] chore: update twitter-openapi-typescript to 0.0.53 --- package.json | 2 +- src/tests/index.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 608447c..9d045a1 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "node-cron": "^3.0.3", "pg": "^8.13.3", "prettier": "^3.5.2", - "twitter-openapi-typescript": "0.0.45", + "twitter-openapi-typescript": "0.0.53", "typescript": "^5.4.5" }, "devDependencies": { diff --git a/src/tests/index.ts b/src/tests/index.ts index 55b2b2c..83d784e 100644 --- a/src/tests/index.ts +++ b/src/tests/index.ts @@ -46,4 +46,15 @@ async function testTrendingCalculation() { } } -export { testTrendingCalculation }; +async function testGetArtistProfile(twitterUserId: string) { + try { + console.log('try to fetch'); + const a = await drizzleClient.getArtistsProfiles(twitterUserId); + const f = await twitterClient.getTwitterUserByUsername(a[0].username); + console.log(f); + } catch (e) { + console.error(e); + } +} + +export { testTrendingCalculation, testGetArtistProfile }; From 365936ef3ddaf9dabf615ec991d09d2f70b061c9 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Fri, 9 May 2025 03:22:29 +0300 Subject: [PATCH 20/25] chore: bump scraper api version --- bun.lockb | Bin 98241 -> 102707 bytes package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lockb b/bun.lockb index 55d44dc8d844b4430c61448a4dc51d2e1c3ebea1..b77f4fd85379e05bdc87bd4b5aafce37104a9720 100644 GIT binary patch delta 20119 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 LPxlsVr1^gV_5(Qc delta 17495 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 diff --git a/package.json b/package.json index 9d045a1..ebf5a32 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "node-cron": "^3.0.3", "pg": "^8.13.3", "prettier": "^3.5.2", - "twitter-openapi-typescript": "0.0.53", + "twitter-openapi-typescript": "^0.0.54", "typescript": "^5.4.5" }, "devDependencies": { From b6951e969fc39a1e4d162259d1157009e4198222 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Mon, 12 May 2025 23:20:17 +0300 Subject: [PATCH 21/25] feat: onError refetch --- src/index.ts | 2 + src/services/cron/data-gathering.ts | 180 +++++++++++++++++++++++++++- src/services/cron/index.ts | 2 +- src/services/discord-webhook.ts | 33 +++-- 4 files changed, 196 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index b0e7383..41c6a5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ +import { testGetArtistProfile } from 'tests'; import { startCronUpdateStats } from './services/cron'; import { logger } from './utils'; logger('Service started.'); startCronUpdateStats(); +// testGetArtistProfile('1349150508125192192'); diff --git a/src/services/cron/data-gathering.ts b/src/services/cron/data-gathering.ts index d63dd31..dc21a5a 100644 --- a/src/services/cron/data-gathering.ts +++ b/src/services/cron/data-gathering.ts @@ -55,7 +55,7 @@ class DataGathering { sendDiscordMessage( 'Updating artists information', `Recieved ${'`'}${artists.length}${'`'} artist profiles, updating`, - 'info' + 'warning' ); const requestsList = artists.map( @@ -130,7 +130,7 @@ class DataGathering { sendDiscordMessage( 'Updating artists information', `Recieved ${'`'}${artists.length}${'`'} artist profiles, updating`, - 'info' + 'warning' ); const requestsList = artists.map( @@ -209,6 +209,182 @@ class DataGathering { ); } } + + async updateArtistsData() { + const artists = await this.drizzleClient.getArtistsProfiles(); + + if (artists.length === 0) { + logger('Recieved 0 artists profiles'); + sendDiscordMessage('Updating artists data', `Recieved \`0\` artist profiles`, 'error'); + return; + } + + logger(`Recieved ${artists.length} artist profiles`); + logger(`Starting data gatheting from twitter...`); + sendDiscordMessage( + 'Updating artists data', + `Recieved \`${artists.length}\` artist profiles, starting data gatheting...`, + 'warning' + ); + + const parsedArtists: ParsedProfile[] = []; + const failedRequests: { + artist: Artist; + attempt: number; + }[] = []; + + const fetchArtistProfile = async ( + artist: Artist, + index: number, + attempt: number = 1 + ): Promise<{ + data: ParsedProfile | null; + artist?: Artist; + attempt?: number; + error?: unknown; + }> => { + try { + const d: + | ParsedProfile + | { + error: string; + } = await this.twitterClient.getTwitterUserByUserId(artist.twitterUserId); + + if ('error' in d) { + logger( + `[${index + 1} / ${artists.length}] Error fetching profile for user ${artist.username}, attempt ${attempt}` + ); + return { data: null, error: d.error, artist, attempt }; + } + + logger(`[${index + 1} / ${artists.length}] Fetched profile for user ${artist.username}`); + return { data: d }; + } catch (error) { + logger( + `[${index + 1} / ${artists.length}] Error fetching profile for user ${artist.username}, attempt ${attempt}` + ); + return { data: null, error, artist, attempt }; + } + }; + + const requestsList = artists.map( + (artist, index) => + new Promise(async resolve => { + setTimeout(async () => { + const result = await fetchArtistProfile(artist, index); + resolve(result); + }, index * this.FETCH_TIMEOUT); + }) + ); + + const results = await Promise.all(requestsList); + results.forEach((result: any) => { + if (result.error) { + failedRequests.push({ + artist: result.artist, + attempt: result.attempt, + }); + } else { + parsedArtists.push(result.data); + } + }); + + let remainingFailed = [...failedRequests]; + const MAX_ATTEMPTS = 5; + let currentAttempt = 2; + + while (remainingFailed.length > 0 && currentAttempt <= MAX_ATTEMPTS) { + logger(`Retrying ${remainingFailed.length} failed profiles, attempt ${currentAttempt}`); + const retryPromises = remainingFailed.map( + (failed, index) => + new Promise(async resolve => { + const backoffDelay = 5000 * Math.pow(2, currentAttempt - 1); + setTimeout(async () => { + const result = await fetchArtistProfile(failed.artist, artists.indexOf(failed.artist), currentAttempt); + resolve(result); + }, index * backoffDelay); + }) + ); + + const retryResults = await Promise.all(retryPromises); + const newFailed: typeof failedRequests = []; + remainingFailed = []; + + retryResults.forEach((result: any) => { + if (result.error && result.attempt < MAX_ATTEMPTS) { + newFailed.push({ artist: result.artist, attempt: result.attempt + 1 }); + } else if (!result.error) { + parsedArtists.push(result.data); + } + }); + + remainingFailed = newFailed; + currentAttempt++; + } + + if (remainingFailed.length > 0) { + const failedIds = remainingFailed.map(f => f.artist.twitterUserId).join(', '); + logger(`Failed to fetch ${remainingFailed.length} profiles after ${MAX_ATTEMPTS} attempts: ${failedIds}`); + sendDiscordMessage( + 'Updating artists data', + `Failed to fetch \`${remainingFailed.length}\` profiles after ${MAX_ATTEMPTS} attempts: ${failedIds}`, + 'error' + ); + } + + logger('Fetched all profiles from twitter'); + logger('Starting updating artists information in dotcreators-sun...'); + + parsedArtists.sort((a, b) => a.username.localeCompare(b.username)); + artists.sort((a, b) => a.username.localeCompare(b.username)); + + try { + const updatedArtists: Artist[] = artists.map(artist => { + const parsedArtist = parsedArtists.find(p => p.userId === artist.twitterUserId); + if (parsedArtist) { + return { + ...artist, + name: parsedArtist.displayName || artist.name, + username: parsedArtist.username || artist.username, + tweetsCount: parsedArtist.tweetsCount || artist.tweetsCount, + followersCount: parsedArtist.followersCount || artist.followersCount, + images: { + avatar: parsedArtist.avatarUrl || artist.images.avatar, + banner: parsedArtist.bannerUrl || artist.images.banner, + }, + bio: parsedArtist.biography || artist.bio, + website: parsedArtist.website || artist.website, + updatedAt: new Date(), + }; + } + return artist; + }); + + const updatedArtistsWithRanking = this.calculateArtistsRanking(updatedArtists); + const updatedArtistsProfilesResponse = + await this.drizzleClient.updateArtistInformationBulk(updatedArtistsWithRanking); + const updatedArtistsTrendsResponse = await this.drizzleClient.updateTrendsInformationBulk( + updatedArtistsWithRanking.map(artist => ({ + twitterUserId: artist.twitterUserId, + followersCount: artist.followersCount, + tweetsCount: artist.tweetsCount, + })) + ); + await this.drizzleClient.updateArtistsFollowersTweetsPercent(); + + logger( + `Successfully updated artists ${updatedArtistsProfilesResponse.items.length} profiles and trends ${updatedArtistsTrendsResponse.items.length}` + ); + sendDiscordMessage( + 'Updating artists data', + `Updated \`${updatedArtistsProfilesResponse.items.length}\` artists with \`${updatedArtistsTrendsResponse.items.length}\` trends`, + 'info' + ); + } catch (error) { + console.error('Error updating artists:', error); + sendDiscordMessage('Updating artists data', `Error: \n\n${error}`, 'error'); + } + } } export { DataGathering }; diff --git a/src/services/cron/index.ts b/src/services/cron/index.ts index 88a9771..f8135c0 100644 --- a/src/services/cron/index.ts +++ b/src/services/cron/index.ts @@ -7,7 +7,7 @@ const EVERY_HOURS = 24; const dataGathering = new DataGathering(); function startCronUpdateStats() { - cron.schedule(`0 0 */${EVERY_HOURS} * * *`, async () => dataGathering.updateArtistsInformationWithTrends(), { + cron.schedule(`0 0 */${EVERY_HOURS} * * *`, async () => dataGathering.updateArtistsData(), { name: 'Update followers and tweets count for artists (pfp/banner/bio and etc).', runOnInit: envConfig.RUN_ON_START, }); diff --git a/src/services/discord-webhook.ts b/src/services/discord-webhook.ts index 66b2df2..330fbcf 100644 --- a/src/services/discord-webhook.ts +++ b/src/services/discord-webhook.ts @@ -3,26 +3,23 @@ const { Webhook, MessageBuilder } = require('discord-webhook-node'); const hook = new Webhook(process.env.WEBHOOK_URL); hook.setUsername('dotcreator'); -export function sendDiscordMessage( - title: string, - message: string, - severity: 'error' | 'info' -) { +type Severity = 'error' | 'info' | 'warning'; + +export function sendDiscordMessage(title: string, message: string, severity: Severity) { let embed: any = {}; - if (severity === 'error') { - embed = new MessageBuilder() - .setColor('#FA4545') - .setTitle(`galatea - ${title.toLocaleLowerCase()}`) - .setDescription(message) - .setTimestamp(); - } else if (severity === 'info') { - embed = new MessageBuilder() - .setColor('#7ffa45') - .setTitle(`galatea - ${title.toLocaleLowerCase()}`) - .setDescription(message) - .setTimestamp(); - } + const getMessageColor = (s: Severity): string => { + if (s === 'error') return '#FA4545'; + else if (s === 'info') return '#7ffa45'; + else if (s === 'warning') return '#fad245'; + else return '#2e2e2e'; + }; + + embed = new MessageBuilder() + .setColor(getMessageColor(severity)) + .setTitle(`galatea - ${title.toLocaleLowerCase()}`) + .setDescription(message) + .setTimestamp(); hook.send(embed); } From dbfa18068dc9dea1de01217f38dc18ff027f1a4c Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Wed, 26 Nov 2025 03:38:22 +0300 Subject: [PATCH 22/25] refactor: reworked artist gathering --- bun.lockb | Bin 102707 -> 103417 bytes package.json | 2 + src/index.ts | 2 +- src/services/cron/data-gathering.ts | 488 +++++------------- src/services/discord-webhook.ts | 2 +- .../twitter/open-api/twitter-client.ts | 36 +- .../twitter/the-convocation/twitter-client.ts | 10 +- src/utils/logger.ts | 65 +++ src/{ => utils}/utils.ts | 4 +- 9 files changed, 225 insertions(+), 384 deletions(-) create mode 100644 src/utils/logger.ts rename src/{ => utils}/utils.ts (90%) diff --git a/bun.lockb b/bun.lockb index b77f4fd85379e05bdc87bd4b5aafce37104a9720..ce33e5d6eba2a7f3203fbae61c9e69b577156aec 100644 GIT binary patch delta 17436 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 diff --git a/package.json b/package.json index ebf5a32..f6ca490 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "dependencies": { "@the-convocation/twitter-scraper": "^0.15.1", "axios": "^1.7.2", + "chalk": "^5.6.2", + "colorette": "^2.0.20", "discord-ts-webhook": "^1.2.1", "discord-webhook-node": "^1.1.8", "drizzle-orm": "^0.40.0", diff --git a/src/index.ts b/src/index.ts index 41c6a5e..3169d3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { testGetArtistProfile } from 'tests'; import { startCronUpdateStats } from './services/cron'; -import { logger } from './utils'; +import { logger } from './utils/utils'; logger('Service started.'); startCronUpdateStats(); diff --git a/src/services/cron/data-gathering.ts b/src/services/cron/data-gathering.ts index dc21a5a..bfd7b10 100644 --- a/src/services/cron/data-gathering.ts +++ b/src/services/cron/data-gathering.ts @@ -4,387 +4,185 @@ import { Artist } from 'services/database/drizzle/schema/artists'; import { sendDiscordMessage } from 'services/discord-webhook'; import { ParsedProfile } from 'services/twitter/models/parsed-profile'; import TwitterClient from 'services/twitter/open-api/twitter-client'; -import { logger } from 'utils'; +import { logger } from 'utils/logger'; -class DataGathering { - private drizzleClient = new DrizzleClient(); - private twitterClient = new TwitterClient(); - private readonly FETCH_TIMEOUT = 3000; - - private calculateArtistsRanking(artists: Artist[]): Artist[] { - artists.sort((a, b) => b.followersCount - a.followersCount); - - artists.forEach((artist, index) => { - const newRanking = index + 1; - const rankingChange = artist.previousRanking - newRanking; +type FetchResult = + | { success: true; data: ParsedProfile } + | { success: false; artist: Artist; error: string; attempt: number }; - artist.previousRanking = artist.ranking; - artist.ranking = newRanking; - artist.rankingChange = rankingChange; - }); +class RateLimiter { + private timestamps: number[] = []; + constructor( + private limit: number, + private intervalMs: number + ) {} - return artists; - } + async schedule(fn: () => Promise): Promise { + const now = Date.now(); + this.timestamps = this.timestamps.filter(t => now - t < this.intervalMs); - private handleErrors(errors: ErrorResponse[], operation: string) { - if (errors && errors.length > 0) { - logger(`Errors in ${operation} (${errors.length}):`); - logger(errors.map(error => error.description).join(', ')); + if (this.timestamps.length >= this.limit) { + const waitTime = this.timestamps[0] + this.intervalMs - now + 100; + logger.warn(`Rate limit reached (${this.limit} requests/${this.intervalMs / 1000}s). Waiting ${waitTime}ms...`); + await new Promise(res => setTimeout(res, waitTime)); - errors.forEach(element => { - sendDiscordMessage( - `Error while updating ${'```'}${operation}${'```'}`, - `${'```'}${element.reason}:\n${element.description}${'```'}`, - 'error' - ); - }); + return this.schedule(fn); } - } - - async updateArtistsInformation() { - try { - const artists = await this.drizzleClient.getArtistsProfiles(); - - if (artists.length === 0) { - logger('Recieved 0 artists profiles dotcreators-sun'); - return; - } - - logger(`Recieved ${artists.length} artist profiles`); - logger(`Starting recieving artist profiles from twitter...`); - sendDiscordMessage( - 'Updating artists information', - `Recieved ${'`'}${artists.length}${'`'} artist profiles, updating`, - 'warning' - ); - - const requestsList = artists.map( - (artist, index) => - new Promise(async resolve => { - setTimeout(async () => { - const data = await this.twitterClient.getTwitterUserByUserId(artist.twitterUserId); - logger(`[${index + 1} / ${artists.length}] Fetched profile for user ${artist.username}`); - resolve(data); - }, index * this.FETCH_TIMEOUT); - }) - ); - const parsedArtists: ParsedProfile[] = (await Promise.all(requestsList)) as ParsedProfile[]; - if (parsedArtists.length === 0) { - logger('Recieved `0` artists profiles from twitter'); - return; - } + this.timestamps.push(now); + return fn(); + } +} - logger('Fetched all profiles from twitter'); - logger('Starting updating artists information...'); +export class DataGathering { + private drizzleClient = new DrizzleClient(); + private twitterClient = new TwitterClient(); - const updatedArtists: Artist[] = artists.map((artist, index) => ({ - ...artist, - name: parsedArtists[index]?.displayName || artist.name, - username: parsedArtists[index]?.username || artist.username, - images: { - avatar: parsedArtists[index]?.avatarUrl || artist.images.avatar, - banner: parsedArtists[index]?.bannerUrl || artist.images.banner, - }, - bio: parsedArtists[index]?.biography || artist.bio, - website: parsedArtists[index]?.website || artist.website, - updatedAt: new Date(), - })); - const updatedArtistsWithRanking = this.calculateArtistsRanking(updatedArtists); + private readonly FETCH_CONCURRENCY = 5; + private readonly MAX_RETRIES = 5; + private readonly BASE_DELAY = 10000; + private readonly RATE_LIMITER = new RateLimiter(30, 60_000); - const updatedArtistsProfilesResponse = - await this.drizzleClient.updateArtistInformationBulk(updatedArtistsWithRanking); + private async sleep(ms: number) { + return new Promise(res => setTimeout(res, ms)); + } - logger(`Successfully updated artists ${updatedArtistsProfilesResponse.items.length} profiles`); - sendDiscordMessage( - 'Updating artists information', - `Total updated artists: ${'`' + updatedArtistsProfilesResponse.items.length + '`'}`, - 'info' - ); + private handleErrors(errors: ErrorResponse[], operation: string) { + if (!errors?.length) return; + logger.warn(`${errors.length} errors during ${operation}`); + errors.forEach(error => { + const msg = `${error.reason}: ${error.description}`; + logger.error(msg, operation); + sendDiscordMessage(`Error while updating \`${operation}\``, `\`\`\`${msg}\`\`\``, 'error'); + }); + } - if (updatedArtistsProfilesResponse.errors) { - this.handleErrors(updatedArtistsProfilesResponse.errors, 'updating artists profiles'); - } - } catch (e: any) { - logger('Error while trying to update artists information:'); - logger(e.message); - sendDiscordMessage( - 'Error while trying to update artists information', - `${'```'}${e.reason}:\n${e.message}${'```'}`, - 'error' - ); - } + private calculateArtistsRanking(artists: Artist[]): Artist[] { + return artists + .sort((a, b) => b.followersCount - a.followersCount) + .map((artist, i) => { + const newRank = i + 1; + const rankingChange = artist.previousRanking && artist.ranking ? artist.ranking - newRank : 0; + return { + ...artist, + previousRanking: artist.ranking, + ranking: newRank, + rankingChange, + }; + }); } - async updateArtistsInformationWithTrends() { + private async fetchWithRetry(artist: Artist, attempt = 1): Promise { try { - const artists = await this.drizzleClient.getArtistsProfiles(); - - if (artists.length === 0) { - logger('Recieved 0 artists profiles dotcreators-sun'); - return; - } - - logger(`Recieved ${artists.length} artist profiles`); - logger(`Starting recieving artist profiles from twitter...`); - sendDiscordMessage( - 'Updating artists information', - `Recieved ${'`'}${artists.length}${'`'} artist profiles, updating`, - 'warning' - ); - - const requestsList = artists.map( - (artist, index) => - new Promise(async resolve => { - setTimeout(async () => { - const data = await this.twitterClient.getTwitterUserByUserId(artist.twitterUserId); - logger(`[${index + 1} / ${artists.length}] Fetched profile for user ${artist.username}`); - resolve(data); - }, index * this.FETCH_TIMEOUT); - }) + const data = await this.RATE_LIMITER.schedule(() => + this.twitterClient.getTwitterUserByUserId(artist.twitterUserId) ); - const parsedArtists: ParsedProfile[] = (await Promise.all(requestsList)) as ParsedProfile[]; - if (parsedArtists.length === 0) { - logger('Recieved `0` artists profiles from twitter'); - return; + if ('error' in data) throw new Error(data.error); + logger.debug(`Fetched profile for ${artist.username}`); + return { success: true, data }; + } catch (err: any) { + const message = err.message || String(err); + logger.warn(`Fetch failed for ${artist.username} (attempt ${attempt}): ${message}`); + + if (attempt < this.MAX_RETRIES) { + const backoff = this.BASE_DELAY * Math.pow(2, attempt - 1); + logger.info(`Retrying after ${backoff}ms...`); + await this.sleep(backoff); + return this.fetchWithRetry(artist, attempt + 1); } - logger('Fetched all profiles from twitter'); - logger('Starting updating artists information...'); - - const updatedArtists: Artist[] = artists.map((artist, index) => ({ - ...artist, - name: parsedArtists[index]?.displayName || artist.name, - username: parsedArtists[index]?.username || artist.username, - tweetsCount: parsedArtists[index]?.tweetsCount || artist.tweetsCount, - followersCount: parsedArtists[index]?.followersCount || artist.followersCount, - images: { - avatar: parsedArtists[index]?.avatarUrl || artist.images.avatar, - banner: parsedArtists[index]?.bannerUrl || artist.images.banner, - }, - bio: parsedArtists[index]?.biography || artist.bio, - website: parsedArtists[index]?.website || artist.website, - updatedAt: new Date(), - })); - const updatedArtistsWithRanking = this.calculateArtistsRanking(updatedArtists); - - // Final responses - const updatedArtistsTrendsResponse = await this.drizzleClient.updateTrendsInformationBulk( - updatedArtistsWithRanking.map(artist => ({ - twitterUserId: artist.twitterUserId, - followersCount: artist.followersCount, - tweetsCount: artist.tweetsCount, - })) - ); - const updatedArtistsProfilesResponse = - await this.drizzleClient.updateArtistInformationBulk(updatedArtistsWithRanking); - const updatedArtistsPercentResponse = await this.drizzleClient.updateArtistsFollowersTweetsPercent(); - - logger( - `Successfully updated artists ${updatedArtistsProfilesResponse.items.length} profiles and trends ${updatedArtistsTrendsResponse.items.length}` - ); - sendDiscordMessage( - 'Updating artists information', - `Total updated artists with trends: ${'`' + updatedArtistsProfilesResponse.items.length + '`'}`, - 'info' - ); - - if (updatedArtistsProfilesResponse.errors) { - this.handleErrors(updatedArtistsProfilesResponse.errors, 'updating artists profiles'); - } - if (updatedArtistsTrendsResponse.errors) { - this.handleErrors(updatedArtistsTrendsResponse.errors, 'updating artists trends'); - } - if (updatedArtistsPercentResponse.errors) { - this.handleErrors(updatedArtistsPercentResponse.errors, 'updating artists growing percent'); - } - } catch (e: any) { - logger('Error while trying to update artists information with trends:'); - logger(e.message); - sendDiscordMessage( - 'Error while trying to update artists information', - `${'```'}${e.reason}:\n${e.message}${'```'}`, - 'error' - ); + logger.error(`All attempts failed for ${artist.username}: ${message}`); + return { success: false, artist, error: message, attempt }; } } - async updateArtistsData() { - const artists = await this.drizzleClient.getArtistsProfiles(); - - if (artists.length === 0) { - logger('Recieved 0 artists profiles'); - sendDiscordMessage('Updating artists data', `Recieved \`0\` artist profiles`, 'error'); - return; - } - - logger(`Recieved ${artists.length} artist profiles`); - logger(`Starting data gatheting from twitter...`); - sendDiscordMessage( - 'Updating artists data', - `Recieved \`${artists.length}\` artist profiles, starting data gatheting...`, - 'warning' - ); - - const parsedArtists: ParsedProfile[] = []; - const failedRequests: { - artist: Artist; - attempt: number; - }[] = []; - - const fetchArtistProfile = async ( - artist: Artist, - index: number, - attempt: number = 1 - ): Promise<{ - data: ParsedProfile | null; - artist?: Artist; - attempt?: number; - error?: unknown; - }> => { - try { - const d: - | ParsedProfile - | { - error: string; - } = await this.twitterClient.getTwitterUserByUserId(artist.twitterUserId); - - if ('error' in d) { - logger( - `[${index + 1} / ${artists.length}] Error fetching profile for user ${artist.username}, attempt ${attempt}` - ); - return { data: null, error: d.error, artist, attempt }; - } - - logger(`[${index + 1} / ${artists.length}] Fetched profile for user ${artist.username}`); - return { data: d }; - } catch (error) { - logger( - `[${index + 1} / ${artists.length}] Error fetching profile for user ${artist.username}, attempt ${attempt}` - ); - return { data: null, error, artist, attempt }; - } - }; - - const requestsList = artists.map( - (artist, index) => - new Promise(async resolve => { - setTimeout(async () => { - const result = await fetchArtistProfile(artist, index); - resolve(result); - }, index * this.FETCH_TIMEOUT); - }) - ); - - const results = await Promise.all(requestsList); - results.forEach((result: any) => { - if (result.error) { - failedRequests.push({ - artist: result.artist, - attempt: result.attempt, - }); - } else { - parsedArtists.push(result.data); + private async fetchAllArtists(artists: Artist[]): Promise { + const parsed: ParsedProfile[] = []; + const total = artists.length; + const q = [...artists]; + let processed = 0; + + const workers = Array.from({ length: this.FETCH_CONCURRENCY }, async (_, i) => { + const worker = `W${i + 1}`; + while (q.length > 0) { + const artist = q.shift()!; + const current = ++processed; + logger.info(`[${worker}] [${current}/${total}] Fetching ${artist.username}`); + const res = await this.fetchWithRetry(artist); + if (res.success) parsed.push(res.data); } }); - let remainingFailed = [...failedRequests]; - const MAX_ATTEMPTS = 5; - let currentAttempt = 2; - - while (remainingFailed.length > 0 && currentAttempt <= MAX_ATTEMPTS) { - logger(`Retrying ${remainingFailed.length} failed profiles, attempt ${currentAttempt}`); - const retryPromises = remainingFailed.map( - (failed, index) => - new Promise(async resolve => { - const backoffDelay = 5000 * Math.pow(2, currentAttempt - 1); - setTimeout(async () => { - const result = await fetchArtistProfile(failed.artist, artists.indexOf(failed.artist), currentAttempt); - resolve(result); - }, index * backoffDelay); - }) - ); + await Promise.all(workers); + return parsed; + } - const retryResults = await Promise.all(retryPromises); - const newFailed: typeof failedRequests = []; - remainingFailed = []; + async updateArtistsData() { + try { + const artists = await this.drizzleClient.getArtistsProfiles(); + if (!artists.length) { + logger.warn('No artists found'); + sendDiscordMessage('Updating artists data', 'Received `0` artist profiles', 'error'); + return; + } - retryResults.forEach((result: any) => { - if (result.error && result.attempt < MAX_ATTEMPTS) { - newFailed.push({ artist: result.artist, attempt: result.attempt + 1 }); - } else if (!result.error) { - parsedArtists.push(result.data); - } + logger.info(`Received ${artists.length} artist profiles`); + sendDiscordMessage('Updating artists data', `Fetching \`${artists.length}\` artists`, 'warning'); + + const parsedArtists = await this.fetchAllArtists(artists); + logger.info(`Fetched ${parsedArtists.length} profiles`); + + if (!parsedArtists.length) return; + + const updated = artists.map(artist => { + const parsed = parsedArtists.find(p => p.userId === artist.twitterUserId); + if (!parsed) return artist; + + return { + ...artist, + name: parsed.displayName || artist.name, + username: parsed.username || artist.username, + tweetsCount: parsed.tweetsCount ?? artist.tweetsCount, + followersCount: parsed.followersCount ?? artist.followersCount, + images: { + avatar: parsed.avatarUrl || artist.images.avatar, + banner: parsed.bannerUrl || artist.images.banner, + }, + bio: parsed.biography || artist.bio, + website: parsed.website || artist.website, + updatedAt: new Date(), + }; }); - remainingFailed = newFailed; - currentAttempt++; - } - - if (remainingFailed.length > 0) { - const failedIds = remainingFailed.map(f => f.artist.twitterUserId).join(', '); - logger(`Failed to fetch ${remainingFailed.length} profiles after ${MAX_ATTEMPTS} attempts: ${failedIds}`); - sendDiscordMessage( - 'Updating artists data', - `Failed to fetch \`${remainingFailed.length}\` profiles after ${MAX_ATTEMPTS} attempts: ${failedIds}`, - 'error' - ); - } - - logger('Fetched all profiles from twitter'); - logger('Starting updating artists information in dotcreators-sun...'); - - parsedArtists.sort((a, b) => a.username.localeCompare(b.username)); - artists.sort((a, b) => a.username.localeCompare(b.username)); + const ranked = this.calculateArtistsRanking(updated); - try { - const updatedArtists: Artist[] = artists.map(artist => { - const parsedArtist = parsedArtists.find(p => p.userId === artist.twitterUserId); - if (parsedArtist) { - return { - ...artist, - name: parsedArtist.displayName || artist.name, - username: parsedArtist.username || artist.username, - tweetsCount: parsedArtist.tweetsCount || artist.tweetsCount, - followersCount: parsedArtist.followersCount || artist.followersCount, - images: { - avatar: parsedArtist.avatarUrl || artist.images.avatar, - banner: parsedArtist.bannerUrl || artist.images.banner, - }, - bio: parsedArtist.biography || artist.bio, - website: parsedArtist.website || artist.website, - updatedAt: new Date(), - }; - } - return artist; - }); + const [profileRes, trendsRes, percentRes] = await Promise.all([ + this.drizzleClient.updateArtistInformationBulk(ranked), + this.drizzleClient.updateTrendsInformationBulk( + ranked.map(a => ({ + twitterUserId: a.twitterUserId, + followersCount: a.followersCount, + tweetsCount: a.tweetsCount, + })) + ), + this.drizzleClient.updateArtistsFollowersTweetsPercent(), + ]); - const updatedArtistsWithRanking = this.calculateArtistsRanking(updatedArtists); - const updatedArtistsProfilesResponse = - await this.drizzleClient.updateArtistInformationBulk(updatedArtistsWithRanking); - const updatedArtistsTrendsResponse = await this.drizzleClient.updateTrendsInformationBulk( - updatedArtistsWithRanking.map(artist => ({ - twitterUserId: artist.twitterUserId, - followersCount: artist.followersCount, - tweetsCount: artist.tweetsCount, - })) - ); - await this.drizzleClient.updateArtistsFollowersTweetsPercent(); + logger.info(`Updated profiles: ${profileRes.items.length}, trends: ${trendsRes.items.length}`); - logger( - `Successfully updated artists ${updatedArtistsProfilesResponse.items.length} profiles and trends ${updatedArtistsTrendsResponse.items.length}` - ); sendDiscordMessage( 'Updating artists data', - `Updated \`${updatedArtistsProfilesResponse.items.length}\` artists with \`${updatedArtistsTrendsResponse.items.length}\` trends`, + `Updated \`${profileRes.items.length}\` artists + \`${trendsRes.items.length}\` trends`, 'info' ); - } catch (error) { - console.error('Error updating artists:', error); - sendDiscordMessage('Updating artists data', `Error: \n\n${error}`, 'error'); + + this.handleErrors(profileRes.errors ?? [], 'update artists profiles'); + this.handleErrors(trendsRes.errors ?? [], 'update artists trends'); + this.handleErrors(percentRes.errors ?? [], 'update follower/tweet percent'); + } catch (err: any) { + logger.error(`Fatal error in updateArtistsData: ${err.message}`); + sendDiscordMessage('Fatal error in updating artists data', `\`\`\`${err.message}\`\`\``, 'error'); } } } - -export { DataGathering }; diff --git a/src/services/discord-webhook.ts b/src/services/discord-webhook.ts index 330fbcf..a7bf987 100644 --- a/src/services/discord-webhook.ts +++ b/src/services/discord-webhook.ts @@ -17,7 +17,7 @@ export function sendDiscordMessage(title: string, message: string, severity: Sev embed = new MessageBuilder() .setColor(getMessageColor(severity)) - .setTitle(`galatea - ${title.toLocaleLowerCase()}`) + .setTitle(`galatea: ${title.toLocaleLowerCase()}`) .setDescription(message) .setTimestamp(); diff --git a/src/services/twitter/open-api/twitter-client.ts b/src/services/twitter/open-api/twitter-client.ts index 761a8db..008fa7f 100644 --- a/src/services/twitter/open-api/twitter-client.ts +++ b/src/services/twitter/open-api/twitter-client.ts @@ -1,7 +1,7 @@ import { TwitterOpenApi } from 'twitter-openapi-typescript'; import { ITwitterClient } from '../twitter-client.interface'; import { ParsedProfile } from '../models/parsed-profile'; -import { formatBio } from '../../../utils'; +import { formatBio } from '../../../utils/utils'; export default class TwitterClient implements ITwitterClient { private readonly api = new TwitterOpenApi(); @@ -10,13 +10,9 @@ export default class TwitterClient implements ITwitterClient { return await this.api.getGuestClient(); } - async getTwitterUserByUsername( - username: string - ): Promise { + async getTwitterUserByUsername(username: string): Promise { const twitterClient = await this.getClient(); - const r = await twitterClient - .getUserApi() - .getUserByScreenName({ screenName: username }); + const r = await twitterClient.getUserApi().getUserByScreenName({ screenName: username }); if (r && r.data && r.data.user) { const profile: ParsedProfile = { @@ -25,16 +21,11 @@ export default class TwitterClient implements ITwitterClient { followersCount: r.data.user.legacy.normalFollowersCount, tweetsCount: r.data.user.legacy.statusesCount, url: `https://x.com/${r.data.user.legacy.screenName}`, - avatarUrl: r.data.user.legacy.profileImageUrlHttps.replace( - '_normal', - '' - ), + avatarUrl: r.data.user.legacy.profileImageUrlHttps.replace('_normal', ''), bannerUrl: r.data.user.legacy.profileBannerUrl, displayName: r.data.user.legacy.name, biography: await formatBio(r.data.user.legacy.description), - website: r.data.user.legacy.entities.url - ? r.data.user.legacy.entities.url.urls[0].expanded_url - : null, + website: r.data.user.legacy.entities.url ? r.data.user.legacy.entities.url.urls[0].expanded_url : null, createdAt: new Date(r.data.user.legacy.createdAt).toISOString(), }; @@ -44,13 +35,9 @@ export default class TwitterClient implements ITwitterClient { } } - async getTwitterUserByUserId( - userId: string - ): Promise { + async getTwitterUserByUserId(userId: string): Promise { const twitterClient = await this.getClient(); - const r = await twitterClient - .getUserApi() - .getUserByRestId({ userId: userId }); + const r = await twitterClient.getUserApi().getUserByRestId({ userId: userId }); if (r && r.data && r.data.user) { const profile: ParsedProfile = { @@ -59,16 +46,11 @@ export default class TwitterClient implements ITwitterClient { followersCount: r.data.user.legacy.normalFollowersCount, tweetsCount: r.data.user.legacy.statusesCount, url: `https://x.com/${r.data.user.legacy.screenName}`, - avatarUrl: r.data.user.legacy.profileImageUrlHttps.replace( - '_normal', - '' - ), + avatarUrl: r.data.user.legacy.profileImageUrlHttps.replace('_normal', ''), bannerUrl: r.data.user.legacy.profileBannerUrl, displayName: r.data.user.legacy.name, biography: await formatBio(r.data.user.legacy.description), - website: r.data.user.legacy.entities.url - ? r.data.user.legacy.entities.url.urls[0].expanded_url - : null, + website: r.data.user.legacy.entities.url ? r.data.user.legacy.entities.url.urls[0].expanded_url : null, createdAt: new Date(r.data.user.legacy.createdAt).toISOString(), }; diff --git a/src/services/twitter/the-convocation/twitter-client.ts b/src/services/twitter/the-convocation/twitter-client.ts index a12586e..455e2fe 100644 --- a/src/services/twitter/the-convocation/twitter-client.ts +++ b/src/services/twitter/the-convocation/twitter-client.ts @@ -1,6 +1,6 @@ import { ITwitterClient } from '../twitter-client.interface'; import { ParsedProfile } from '../models/parsed-profile'; -import { formatBio } from '../../../utils'; +import { formatBio } from '../../../utils/utils'; import { Scraper } from '@the-convocation/twitter-scraper'; const REQUEST_TIMEOUT = 5000; @@ -16,9 +16,7 @@ export default class TwitterClient implements ITwitterClient { }, }); - async getTwitterUserByUsername( - username: string - ): Promise { + async getTwitterUserByUsername(username: string): Promise { const r = await this.api.getProfile(username); if (r) { @@ -42,9 +40,7 @@ export default class TwitterClient implements ITwitterClient { } } - async getTwitterUserByUserId( - userId: string - ): Promise { + async getTwitterUserByUserId(userId: string): Promise { throw Error('Method not implemented'); } } diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..ca3ed79 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,65 @@ +import { blue, yellow, red, green, gray, cyan, isColorSupported } from 'colorette'; + +export enum LogLevel { + INFO = 'INFO', + WARN = 'WARN', + ERROR = 'ERROR', + DEBUG = 'DEBUG', +} + +function getTimestamp(): string { + const d = new Date(); + return d.toISOString().split('T')[1].split('.')[0]; +} + +export function logger(message: string, level: LogLevel = LogLevel.INFO, prefix?: string) { + const ts = gray(`[${getTimestamp()}]`); + const pfx = prefix ? blue(`[${prefix}]`) : ''; + + let levelLabel: string; + switch (level) { + case LogLevel.INFO: + levelLabel = green(`[INFO]`); + break; + case LogLevel.WARN: + levelLabel = yellow(`[WARN]`); + break; + case LogLevel.ERROR: + levelLabel = red(`[ERROR]`); + break; + case LogLevel.DEBUG: + levelLabel = cyan(`[DEBUG]`); + break; + default: + levelLabel = gray(`[LOG]`); + } + + let line = `${ts} ${levelLabel} ${pfx}${message}`; + if (isColorSupported) { + switch (level) { + case LogLevel.WARN: + line = yellow(line); + break; + case LogLevel.ERROR: + line = red(line); + break; + case LogLevel.DEBUG: + line = cyan(line); + break; + default: + break; + } + } + + if (!isColorSupported) { + console.log(line.replace(/\x1B\[[0-9;]*[mK]/g, '').trim()); + return; + } + + console.log(line); +} + +logger.info = (msg: string, prefix?: string) => logger(msg, LogLevel.INFO, prefix); +logger.warn = (msg: string, prefix?: string) => logger(msg, LogLevel.WARN, prefix); +logger.error = (msg: string, prefix?: string) => logger(msg, LogLevel.ERROR, prefix); +logger.debug = (msg: string, prefix?: string) => logger(msg, LogLevel.DEBUG, prefix); diff --git a/src/utils.ts b/src/utils/utils.ts similarity index 90% rename from src/utils.ts rename to src/utils/utils.ts index 49083d9..22e8958 100644 --- a/src/utils.ts +++ b/src/utils/utils.ts @@ -1,7 +1,5 @@ export function logger(text: string) { - console.log( - `[${new Date().toLocaleDateString('en-EN')} ${new Date().toLocaleTimeString()}] ${text}` - ); + console.log(`[${new Date().toLocaleDateString('en-EN')} ${new Date().toLocaleTimeString()}] ${text}`); } export async function getOriginalUrl(shortenedUrl: string): Promise { From 588abd5dc198b7cd5d63ece53757c59c4a269cba Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Wed, 26 Nov 2025 04:10:52 +0300 Subject: [PATCH 23/25] fix: id gathering not working, swap to username --- package.json | 1 - src/services/cron/data-gathering.ts | 81 +++++++++++++---------------- 2 files changed, 35 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index f6ca490..8cffb0e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "dependencies": { "@the-convocation/twitter-scraper": "^0.15.1", "axios": "^1.7.2", - "chalk": "^5.6.2", "colorette": "^2.0.20", "discord-ts-webhook": "^1.2.1", "discord-webhook-node": "^1.1.8", diff --git a/src/services/cron/data-gathering.ts b/src/services/cron/data-gathering.ts index bfd7b10..268ec95 100644 --- a/src/services/cron/data-gathering.ts +++ b/src/services/cron/data-gathering.ts @@ -10,38 +10,12 @@ type FetchResult = | { success: true; data: ParsedProfile } | { success: false; artist: Artist; error: string; attempt: number }; -class RateLimiter { - private timestamps: number[] = []; - constructor( - private limit: number, - private intervalMs: number - ) {} - - async schedule(fn: () => Promise): Promise { - const now = Date.now(); - this.timestamps = this.timestamps.filter(t => now - t < this.intervalMs); - - if (this.timestamps.length >= this.limit) { - const waitTime = this.timestamps[0] + this.intervalMs - now + 100; - logger.warn(`Rate limit reached (${this.limit} requests/${this.intervalMs / 1000}s). Waiting ${waitTime}ms...`); - await new Promise(res => setTimeout(res, waitTime)); - - return this.schedule(fn); - } - - this.timestamps.push(now); - return fn(); - } -} - export class DataGathering { private drizzleClient = new DrizzleClient(); private twitterClient = new TwitterClient(); - private readonly FETCH_CONCURRENCY = 5; - private readonly MAX_RETRIES = 5; - private readonly BASE_DELAY = 10000; - private readonly RATE_LIMITER = new RateLimiter(30, 60_000); + private readonly MAX_RETRIES = 3; + private readonly BASE_DELAY = 5000; private async sleep(ms: number) { return new Promise(res => setTimeout(res, ms)); @@ -74,11 +48,23 @@ export class DataGathering { private async fetchWithRetry(artist: Artist, attempt = 1): Promise { try { - const data = await this.RATE_LIMITER.schedule(() => - this.twitterClient.getTwitterUserByUserId(artist.twitterUserId) - ); + const usernameResult = await this.twitterClient.getTwitterUserByUsername(artist.username); + if ('error' in usernameResult) { + throw new Error(typeof usernameResult.error === 'string' ? usernameResult.error : 'Unknown error from API'); + } + const data = usernameResult; + + if (data.userId !== artist.twitterUserId) { + logger.warn( + `UserId mismatch for ${artist.username}: expected ${artist.twitterUserId}, got ${data.userId}. User might have been suspended/deleted and username taken by someone else.` + ); + sendDiscordMessage( + 'UserId mismatch detected!', + `Username **${artist.username}** now belongs to different user.\nExpected: \`${artist.twitterUserId}\`\nGot: \`${data.userId}\` (${data.displayName})`, + 'warning' + ); + } - if ('error' in data) throw new Error(data.error); logger.debug(`Fetched profile for ${artist.username}`); return { success: true, data }; } catch (err: any) { @@ -86,7 +72,7 @@ export class DataGathering { logger.warn(`Fetch failed for ${artist.username} (attempt ${attempt}): ${message}`); if (attempt < this.MAX_RETRIES) { - const backoff = this.BASE_DELAY * Math.pow(2, attempt - 1); + const backoff = this.BASE_DELAY * attempt; logger.info(`Retrying after ${backoff}ms...`); await this.sleep(backoff); return this.fetchWithRetry(artist, attempt + 1); @@ -100,21 +86,24 @@ export class DataGathering { private async fetchAllArtists(artists: Artist[]): Promise { const parsed: ParsedProfile[] = []; const total = artists.length; - const q = [...artists]; - let processed = 0; - - const workers = Array.from({ length: this.FETCH_CONCURRENCY }, async (_, i) => { - const worker = `W${i + 1}`; - while (q.length > 0) { - const artist = q.shift()!; - const current = ++processed; - logger.info(`[${worker}] [${current}/${total}] Fetching ${artist.username}`); - const res = await this.fetchWithRetry(artist); - if (res.success) parsed.push(res.data); + + for (let i = 0; i < artists.length; i++) { + const artist = artists[i]; + const current = i + 1; + + logger.info(`[${current}/${total}] Fetching ${artist.username}`); + + if (i > 0) { + await this.sleep(1500); } - }); - await Promise.all(workers); + const result = await this.fetchWithRetry(artist); + + if (result.success) { + parsed.push(result.data); + } + } + return parsed; } From dbb810e999f5576d009998fa662a3eab93c04b89 Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Wed, 26 Nov 2025 04:47:58 +0300 Subject: [PATCH 24/25] fix: ~ --- src/services/cron/data-gathering.ts | 68 ++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/src/services/cron/data-gathering.ts b/src/services/cron/data-gathering.ts index 268ec95..e54f78d 100644 --- a/src/services/cron/data-gathering.ts +++ b/src/services/cron/data-gathering.ts @@ -14,8 +14,9 @@ export class DataGathering { private drizzleClient = new DrizzleClient(); private twitterClient = new TwitterClient(); - private readonly MAX_RETRIES = 3; - private readonly BASE_DELAY = 5000; + private readonly MAX_RETRIES = 10; + private readonly BASE_DELAY = 15000; + private readonly REQUEST_INTERVAL = 3000; private async sleep(ms: number) { return new Promise(res => setTimeout(res, ms)); @@ -69,22 +70,33 @@ export class DataGathering { return { success: true, data }; } catch (err: any) { const message = err.message || String(err); - logger.warn(`Fetch failed for ${artist.username} (attempt ${attempt}): ${message}`); + logger.warn(`Fetch failed for ${artist.username} (attempt ${attempt}/${this.MAX_RETRIES}): ${message}`); if (attempt < this.MAX_RETRIES) { - const backoff = this.BASE_DELAY * attempt; - logger.info(`Retrying after ${backoff}ms...`); - await this.sleep(backoff); + const backoff = this.BASE_DELAY * Math.pow(1.5, attempt - 1); + const maxBackoff = 300000; + const finalBackoff = Math.min(backoff, maxBackoff); + + logger.info(`Retrying after ${Math.round(finalBackoff / 1000)}s...`); + await this.sleep(finalBackoff); return this.fetchWithRetry(artist, attempt + 1); } - logger.error(`All attempts failed for ${artist.username}: ${message}`); + logger.error(`All ${this.MAX_RETRIES} attempts failed for ${artist.username}: ${message}`); + + sendDiscordMessage( + 'Failed to fetch artist after all retries', + `**${artist.username}** (${artist.name})\nError: \`${message}\`\nAttempts: ${this.MAX_RETRIES}`, + 'error' + ); + return { success: false, artist, error: message, attempt }; } } private async fetchAllArtists(artists: Artist[]): Promise { const parsed: ParsedProfile[] = []; + const failed: Artist[] = []; const total = artists.length; for (let i = 0; i < artists.length; i++) { @@ -94,16 +106,44 @@ export class DataGathering { logger.info(`[${current}/${total}] Fetching ${artist.username}`); if (i > 0) { - await this.sleep(1500); + await this.sleep(this.REQUEST_INTERVAL); } const result = await this.fetchWithRetry(artist); if (result.success) { parsed.push(result.data); + logger.info(`Successfully fetched ${artist.username} (${parsed.length}/${total})`); + } else { + failed.push(result.artist); + logger.error(`Failed to fetch ${artist.username} after all attempts`); + } + + if (current % 50 === 0) { + const successRate = Math.round((parsed.length / current) * 100); + sendDiscordMessage( + 'Progress update', + `Processed ${current}/${total} artists\nSuccess rate: ${successRate}% (${parsed.length} successful, ${failed.length} failed)`, + 'info' + ); } } + const successRate = Math.round((parsed.length / total) * 100); + logger.info(`Fetch completed: ${parsed.length}/${total} successful (${successRate}%)`); + + if (failed.length > 0) { + logger.warn(`Failed to fetch ${failed.length} artists: ${failed.map(a => a.username).join(', ')}`); + sendDiscordMessage( + 'Fetch summary', + `**Final results:**\nSuccessful: ${parsed.length}\nFailed: ${failed.length}\nSuccess rate: ${successRate}%\n\nFailed users: ${failed + .slice(0, 10) + .map(a => a.username) + .join(', ')}${failed.length > 10 ? '...' : ''}`, + failed.length > total * 0.1 ? 'error' : 'warning' + ); + } + return parsed; } @@ -117,12 +157,16 @@ export class DataGathering { } logger.info(`Received ${artists.length} artist profiles`); - sendDiscordMessage('Updating artists data', `Fetching \`${artists.length}\` artists`, 'warning'); + sendDiscordMessage('Updating artists data', `Starting to fetch \`${artists.length}\` artists`, 'warning'); const parsedArtists = await this.fetchAllArtists(artists); logger.info(`Fetched ${parsedArtists.length} profiles`); - if (!parsedArtists.length) return; + if (!parsedArtists.length) { + logger.error('No artists were successfully fetched!'); + sendDiscordMessage('Critical error', 'Failed to fetch ANY artist profiles!', 'error'); + return; + } const updated = artists.map(artist => { const parsed = parsedArtists.find(p => p.userId === artist.twitterUserId); @@ -161,8 +205,8 @@ export class DataGathering { logger.info(`Updated profiles: ${profileRes.items.length}, trends: ${trendsRes.items.length}`); sendDiscordMessage( - 'Updating artists data', - `Updated \`${profileRes.items.length}\` artists + \`${trendsRes.items.length}\` trends`, + 'Updating artists data completed', + `**Successfully updated:**\nProfiles: \`${profileRes.items.length}\`\nTrends: \`${trendsRes.items.length}\`\nFetch rate: \`${Math.round((parsedArtists.length / artists.length) * 100)}%\``, 'info' ); From 65d4bf34af04a071829e9f8eb1f68db13ccc1c9c Mon Sep 17 00:00:00 2001 From: Nikita Grichan Date: Wed, 26 Nov 2025 15:06:23 +0300 Subject: [PATCH 25/25] fix: ~ --- src/services/cron/data-gathering.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/cron/data-gathering.ts b/src/services/cron/data-gathering.ts index e54f78d..7f64a79 100644 --- a/src/services/cron/data-gathering.ts +++ b/src/services/cron/data-gathering.ts @@ -190,7 +190,7 @@ export class DataGathering { const ranked = this.calculateArtistsRanking(updated); - const [profileRes, trendsRes, percentRes] = await Promise.all([ + const [profileRes, trendsRes] = await Promise.all([ this.drizzleClient.updateArtistInformationBulk(ranked), this.drizzleClient.updateTrendsInformationBulk( ranked.map(a => ({ @@ -199,11 +199,12 @@ export class DataGathering { tweetsCount: a.tweetsCount, })) ), - this.drizzleClient.updateArtistsFollowersTweetsPercent(), ]); logger.info(`Updated profiles: ${profileRes.items.length}, trends: ${trendsRes.items.length}`); + const percentRes = await this.drizzleClient.updateArtistsFollowersTweetsPercent(); + sendDiscordMessage( 'Updating artists data completed', `**Successfully updated:**\nProfiles: \`${profileRes.items.length}\`\nTrends: \`${trendsRes.items.length}\`\nFetch rate: \`${Math.round((parsedArtists.length / artists.length) * 100)}%\``,