first commit
This commit is contained in:
+57
@@ -0,0 +1,57 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
.env
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||||
|
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||||
|
|
||||||
|
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||||
|
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||||
|
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||||
|
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||||
|
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||||
|
</p>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](https://opencollective.com/nest#sponsor)-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compile and run the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
$ pnpm run start
|
||||||
|
|
||||||
|
# watch mode
|
||||||
|
$ pnpm run start:dev
|
||||||
|
|
||||||
|
# production mode
|
||||||
|
$ pnpm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit tests
|
||||||
|
$ pnpm run test
|
||||||
|
|
||||||
|
# e2e tests
|
||||||
|
$ pnpm run test:e2e
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
$ pnpm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||||
|
|
||||||
|
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pnpm install -g @nestjs/mau
|
||||||
|
$ mau deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Check out a few resources that may come in handy when working with NestJS:
|
||||||
|
|
||||||
|
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||||
|
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||||
|
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||||
|
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||||
|
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||||
|
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||||
|
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||||
|
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|
||||||
|
## Stay in touch
|
||||||
|
|
||||||
|
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||||
|
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||||
|
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"name": "x_news",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch --no-clear",
|
||||||
|
"start:debug": "nest start --debug --watch --no-clear",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-sqs": "^3.1042.0",
|
||||||
|
"@bull-board/api": "^6.21.0",
|
||||||
|
"@bull-board/express": "^6.21.0",
|
||||||
|
"@bull-board/nestjs": "^6.21.0",
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
|
"@keyv/redis": "^5.1.6",
|
||||||
|
"@nestjs/axios": "^4.0.1",
|
||||||
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
|
"@nestjs/cache-manager": "^3.1.0",
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/schedule": "^6.1.3",
|
||||||
|
"@nestjs/swagger": "^11.3.0",
|
||||||
|
"@openrouter/sdk": "^0.12.15",
|
||||||
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
|
"@prisma/client": "^7.7.0",
|
||||||
|
"@shaivpidadi/trends-js": "^1.0.3",
|
||||||
|
"@telegraf/session": "2.0.0-beta.7",
|
||||||
|
"@twitter-api-v2/plugin-token-refresher": "^1.0.0",
|
||||||
|
"@xdevplatform/xdk": "^0.5.0",
|
||||||
|
"axios": "^1.15.0",
|
||||||
|
"bullMQAdapter": "link:@bull-board/api/bullMQAdapter",
|
||||||
|
"bullmq": "^5.73.4",
|
||||||
|
"cache-manager": "^7.2.8",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"cookie": "^1.1.1",
|
||||||
|
"dotenv": "^17.4.1",
|
||||||
|
"google-trends-api": "^4.9.2",
|
||||||
|
"grammy": "^1.42.0",
|
||||||
|
"https-proxy-agent": "^9.0.0",
|
||||||
|
"install": "^0.13.0",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
|
"lodash": "^4.18.1",
|
||||||
|
"nestjs-telegraf": "^2.9.1",
|
||||||
|
"openai": "^6.34.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"playwright": "^1.59.1",
|
||||||
|
"playwright-extra": "^4.3.6",
|
||||||
|
"prisma": "^7.7.0",
|
||||||
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
|
"redis": "^5.12.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rss-parser": "^3.13.0",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"telegraf": "^4.16.3",
|
||||||
|
"twitter-api-v2": "^1.29.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/devtools-integration": "^0.2.1",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/lodash": "^4.17.24",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/supertest": "^7.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^17.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+9642
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import {defineConfig, env} from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Post" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"prompt" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"style" TEXT NOT NULL DEFAULT 'general',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Config" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Config_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Config_key_key" ON "Config"("key");
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Post" ADD COLUMN "isFbPostState" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "isTiktokPostState" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "isTwitterPostState" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Trend" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"url" TEXT,
|
||||||
|
"score" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"category" TEXT,
|
||||||
|
"tags" JSONB,
|
||||||
|
"engagement" JSONB,
|
||||||
|
"raw" JSONB,
|
||||||
|
"fingerprint" VARCHAR,
|
||||||
|
"sourceTimestamp" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Trend_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Trend_score_idx" ON "Trend"("score");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Trend_category_idx" ON "Trend"("category");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Trend_sourceTimestamp_idx" ON "Trend"("sourceTimestamp");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Trend" ALTER COLUMN "updatedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Trend" ADD COLUMN "geo" VARCHAR;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Trend_geo_idx" ON "Trend"("geo");
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Post" ADD COLUMN "draft" TEXT,
|
||||||
|
ADD COLUMN "model" TEXT,
|
||||||
|
ADD COLUMN "reviewNotes" TEXT,
|
||||||
|
ADD COLUMN "tokensUsed" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "tone" TEXT DEFAULT '';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {Global, Module} from '@nestjs/common';
|
||||||
|
import {PrismaService} from "./prisma.service";
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [],
|
||||||
|
providers: [
|
||||||
|
PrismaService,
|
||||||
|
],
|
||||||
|
exports: [PrismaService],
|
||||||
|
|
||||||
|
})
|
||||||
|
export class PrismaModule {
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// src/prisma/prisma.service.ts
|
||||||
|
import {Injectable, OnModuleInit, OnModuleDestroy} from '@nestjs/common';
|
||||||
|
import {PrismaClient} from '../src/generated/prisma/client';
|
||||||
|
import {PrismaPg} from "@prisma/adapter-pg";
|
||||||
|
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
constructor() {
|
||||||
|
const adapter = new PrismaPg({
|
||||||
|
connectionString: process.env.DATABASE_URL!,
|
||||||
|
});
|
||||||
|
super({adapter});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
try {
|
||||||
|
await this.$connect();
|
||||||
|
console.log('✅ Đã kết nối Database thành công');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Lỗi kết nối Database:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getManyAndCount<T>(
|
||||||
|
model: any,
|
||||||
|
args: any
|
||||||
|
) {
|
||||||
|
const [data, total] = await this.$transaction([
|
||||||
|
model.findMany(args),
|
||||||
|
model.count({ where: args.where }),
|
||||||
|
]);
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect(); // Ngắt kết nối khi tắt app
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// prisma/schema.prisma
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
moduleFormat = "cjs"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Post {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
prompt String
|
||||||
|
content String @db.Text
|
||||||
|
imageUrl String?
|
||||||
|
style String @default("general")
|
||||||
|
tone String? @default("")
|
||||||
|
isFbPostState Int @default(0)
|
||||||
|
isTwitterPostState Int @default(0)
|
||||||
|
isTiktokPostState Int @default(0)
|
||||||
|
draft String? @db.Text
|
||||||
|
tokensUsed Int @default(0)
|
||||||
|
model String?
|
||||||
|
reviewNotes String?
|
||||||
|
status String @default("pending") // Các trạng thái: pending, approved, published, rejected
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Config {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
}
|
||||||
|
|
||||||
|
model Trend {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
description String? @db.Text
|
||||||
|
url String?
|
||||||
|
score Int @default(0)
|
||||||
|
source String @db.Text
|
||||||
|
category String?
|
||||||
|
geo String? @db.VarChar()
|
||||||
|
tags Json?
|
||||||
|
engagement Json?
|
||||||
|
raw Json?
|
||||||
|
fingerprint String? @db.VarChar()
|
||||||
|
sourceTimestamp DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime? @default(now())
|
||||||
|
|
||||||
|
@@index([score]) // Tạo index cho cột name
|
||||||
|
@@index([category]) // Tạo index cho cột name
|
||||||
|
@@index([geo]) // Tạo index cho cột name
|
||||||
|
@@index([sourceTimestamp]) // Tạo index cho cột name
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {
|
||||||
|
it('should return "Hello World!"', () => {
|
||||||
|
expect(appController.getHello()).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import {Controller, Get, HttpException, Param, Post, Query, Req} from '@nestjs/common';
|
||||||
|
import {AppService} from './app.service';
|
||||||
|
import {AIService} from "./shared/ai.service";
|
||||||
|
import {PgPostService} from "./shared/pg.post.service";
|
||||||
|
import {PublishPageService} from "./modules/social-api/publish.page.service";
|
||||||
|
import {TwitterClient} from "./modules/social-api/twitter.client";
|
||||||
|
import {normalizeTagsSingleCashtag} from "./shared/helper";
|
||||||
|
import {XReaderService} from "./modules/x-reader/x-reader.service";
|
||||||
|
import {XCacheService} from "./modules/x-cache/x-cache.service";
|
||||||
|
import {SqsPostService} from "./modules/sqs-module/sqs.post.service";
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
constructor(
|
||||||
|
private readonly appService: AppService,
|
||||||
|
private readonly aiService: AIService,
|
||||||
|
private readonly pgPostService: PgPostService,
|
||||||
|
private readonly publishPageService: PublishPageService,
|
||||||
|
private readonly twitterClient: TwitterClient,
|
||||||
|
private readonly cacheService: XCacheService,
|
||||||
|
private readonly xReaderService: XReaderService,
|
||||||
|
private readonly sqsPostService: SqsPostService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getHello(): string {
|
||||||
|
return this.appService.getHello();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('test-gemini-ai')
|
||||||
|
async testAI() {
|
||||||
|
console.log('Test gọi gemini AI trực tiếp...');
|
||||||
|
// return await this.aiService.listAvailableModels();
|
||||||
|
const title = 'bitcoin bullish';
|
||||||
|
const result = await this.aiService.generateContentViaGemini(title, 'crypto', 'en');
|
||||||
|
return result;
|
||||||
|
console.log({result});
|
||||||
|
const newPost = await this.pgPostService.createPost({
|
||||||
|
title: title,
|
||||||
|
content: result.content,
|
||||||
|
style: 'crypto',
|
||||||
|
status: 'pending',
|
||||||
|
prompt: result.prompt,
|
||||||
|
});
|
||||||
|
return newPost;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('test-chatgpt-ai')
|
||||||
|
async testChatGPTAi(@Query('input') input: string) {
|
||||||
|
console.log(`Test gọi chatgpt AI trực tiếp with input ${input}...`);
|
||||||
|
// return await this.aiService.listAvailableModels();
|
||||||
|
if (!input) {
|
||||||
|
return 'No input';
|
||||||
|
}
|
||||||
|
const openai = await this.aiService.getChatgptModel();
|
||||||
|
const resp = await openai.responses.create({
|
||||||
|
model: "gpt-5-nano",
|
||||||
|
input,
|
||||||
|
store: true,
|
||||||
|
});
|
||||||
|
return resp.output_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('test-deepseek-ai')
|
||||||
|
async testDeepSeekAI() {
|
||||||
|
console.log('Test gọi deepseed AI trực tiếp...');
|
||||||
|
// return await this.aiService.listAvailableModels();
|
||||||
|
const title = 'bitcoin bullish'
|
||||||
|
const result = await this.aiService.generateContentViaDeepseek(title, 'crypto',);
|
||||||
|
console.log({result});
|
||||||
|
return result;
|
||||||
|
|
||||||
|
const newPost = await this.pgPostService.createPost({
|
||||||
|
title: title,
|
||||||
|
content: result.final,
|
||||||
|
style: 'crypto',
|
||||||
|
status: 'pending',
|
||||||
|
prompt: result.prompt,
|
||||||
|
});
|
||||||
|
return newPost;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('test-list-ai')
|
||||||
|
async listModelGeminiAI() {
|
||||||
|
console.log('Test gọi AI trực tiếp...');
|
||||||
|
// return await this.aiService.listAvailableModels();
|
||||||
|
return await this.aiService.listAvailableModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('test-save-post')
|
||||||
|
async testSavePost() {
|
||||||
|
return this.pgPostService.createPost({
|
||||||
|
title: 'zz',
|
||||||
|
prompt: 'xx',
|
||||||
|
content: 'xx',
|
||||||
|
imageUrl: 'xx.png',
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('test-publish-post/:id')
|
||||||
|
async testPostFb(@Param('id') id: number) {
|
||||||
|
console.log({id});
|
||||||
|
console.log('testPostFb==>')
|
||||||
|
return this.publishPageService.publishToFacebook(1 * id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('test-reply-post/:id')
|
||||||
|
async testReplyX(@Param('id') id: string, @Query('comment') input: string) {
|
||||||
|
console.log({id});
|
||||||
|
console.log('testReplyX==>')
|
||||||
|
// return this.publishPageService.relyX(input, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('post/:id')
|
||||||
|
async showPostFb(@Param('id') id: number) {
|
||||||
|
console.log({id});
|
||||||
|
let post = await this.pgPostService.post({id: id * 1})
|
||||||
|
if (!post) {
|
||||||
|
return 'no post';
|
||||||
|
}
|
||||||
|
const _normalizeTagsSingleCashtagContent = normalizeTagsSingleCashtag(post.content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: post.content,
|
||||||
|
_content: _normalizeTagsSingleCashtagContent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tw_callback')
|
||||||
|
async twitterAuthCallback(
|
||||||
|
@Query('code') code: string,
|
||||||
|
@Query('state') state: string,
|
||||||
|
@Req() request: Request) {
|
||||||
|
|
||||||
|
console.log('twitterAuthCallback==>')
|
||||||
|
const cacheAuthData = await this.cacheService.getCachedData('tw_authorize');
|
||||||
|
console.log({cacheAuthData});
|
||||||
|
//@ts-ignore
|
||||||
|
const {codeVerifier, state: sessionState} = cacheAuthData || {}
|
||||||
|
|
||||||
|
if (!codeVerifier || !state || !sessionState || !code) {
|
||||||
|
throw new HttpException('You denied the app or your session expired!', 400)
|
||||||
|
}
|
||||||
|
if (state !== sessionState) {
|
||||||
|
throw new HttpException('Stored tokens didnt match!!', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await this.twitterClient.getTwitterClientV2();
|
||||||
|
|
||||||
|
const {client: loggedClient, accessToken, refreshToken} = await client.loginWithOAuth2({
|
||||||
|
code,
|
||||||
|
codeVerifier: codeVerifier,
|
||||||
|
redirectUri: 'http://localhost:3000/tw_callback'
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.cacheService.setCachedKey('tw_accesstoken_time_add', Date.now(), 3 * 24 * 3600);
|
||||||
|
await this.cacheService.setCachedKey('tw_accesstoken', accessToken, 3 * 24 * 3600);
|
||||||
|
await this.twitterClient.setCacheRefreshToken('' + refreshToken);
|
||||||
|
console.log({loggedClient, accessToken, refreshToken});
|
||||||
|
// @ts-ignore
|
||||||
|
const {data: userObject} = loggedClient.v2.me();
|
||||||
|
|
||||||
|
return userObject;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tw_authorize')
|
||||||
|
async twitterAuthorize() {
|
||||||
|
const client = await this.twitterClient.getTwitterClientV2();
|
||||||
|
const {url, codeVerifier, state} = client.generateOAuth2AuthLink('http://localhost:3000/tw_callback',
|
||||||
|
{
|
||||||
|
scope:
|
||||||
|
[
|
||||||
|
'tweet.read',
|
||||||
|
'tweet.write',
|
||||||
|
'users.read',
|
||||||
|
'offline.access',
|
||||||
|
'media.write',
|
||||||
|
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// Redirect your client to {url}
|
||||||
|
console.log('Please go to', url);
|
||||||
|
console.log({codeVerifier, state});
|
||||||
|
await this.cacheService.setCachedKey('tw_authorize', {codeVerifier, state});
|
||||||
|
|
||||||
|
return this.cacheService.getCachedData('tw_authorize');
|
||||||
|
|
||||||
|
// Redirect your client to authLink.url
|
||||||
|
// console.log('Please go to', authLink.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/tw/post/:id')
|
||||||
|
async testPostTwitter(@Param('id') id: number) {
|
||||||
|
return this.publishPageService.publishTwitter(id * 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/tw/me')
|
||||||
|
async testTwitterMe() {
|
||||||
|
const client = await this.twitterClient.getTwitterClientV2ViaAccessToken();
|
||||||
|
const me = client.v2.me();
|
||||||
|
return me;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/tw/read')
|
||||||
|
async getTwitterMe(@Query('url') url: string, @Query('via') via: string = 'api') {
|
||||||
|
if (via === 'browser') {
|
||||||
|
return this.xReaderService.readXPostViaBrowserV2(url)
|
||||||
|
}
|
||||||
|
return this.xReaderService.readXPostViaApi(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/sqs/send')
|
||||||
|
async postSqsQueueTest() {
|
||||||
|
// return this.sqsPostService.postFlashKaze({
|
||||||
|
// content: 'aaa',
|
||||||
|
// id: 1
|
||||||
|
// })
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {Module} from '@nestjs/common';
|
||||||
|
import {ConfigModule} from '@nestjs/config';
|
||||||
|
|
||||||
|
import {BullModule} from '@nestjs/bullmq';
|
||||||
|
import {AppController} from './app.controller';
|
||||||
|
import {AppService} from './app.service';
|
||||||
|
import {ManagerModule} from "./modules/manager/manager.module";
|
||||||
|
import {TelegramModule} from "./modules/telegram/telegram.module";
|
||||||
|
import {SocialModule} from "./modules/social-api/social.module";
|
||||||
|
import {AIService} from "./shared/ai.service";
|
||||||
|
import {TelegrafModule} from "nestjs-telegraf";
|
||||||
|
import {BullBoardModule} from "@bull-board/nestjs";
|
||||||
|
import {ExpressAdapter} from "@bull-board/express";
|
||||||
|
import {PrismaService} from "../prisma/prisma.service";
|
||||||
|
import {PgPostService} from "./shared/pg.post.service";
|
||||||
|
import {PrismaModule} from "../prisma/prisma.module";
|
||||||
|
import {CacheModule} from "@nestjs/cache-manager";
|
||||||
|
import KeyvRedis from "@keyv/redis";
|
||||||
|
import {XReaderModule} from './modules/x-reader/x-reader.module';
|
||||||
|
import {CollectorModule} from "./modules/collector/collector.module";
|
||||||
|
import {TrendsModule} from "./modules/trends/trends.module";
|
||||||
|
import configuration from "./common/config/configuration";
|
||||||
|
import {SchedulerModule} from "./modules/scheduler/scheduler.module";
|
||||||
|
import {ContentWriterModule} from "./modules/content-writer/content-writer.module";
|
||||||
|
import {TeleGrammYModule} from "./modules/tele-grammY/tele-grammY.module";
|
||||||
|
import {session} from "telegraf";
|
||||||
|
import {Redis} from '@telegraf/session/redis';
|
||||||
|
import {XUploaderModule} from "./modules/x-uploader/x-uploader.module";
|
||||||
|
import {TiktokDownloadModule} from "./modules/tiktok-download/tiktok.download.module";
|
||||||
|
import {XCacheModule} from "./modules/x-cache/x-cache.module";
|
||||||
|
import {XCacheService} from "./modules/x-cache/x-cache.service";
|
||||||
|
import {SqsModule} from "./modules/sqs-module/sqs.module";
|
||||||
|
import {SqsPostService} from "./modules/sqs-module/sqs.post.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// DevtoolsModule.register({
|
||||||
|
// http: process.env.NODE_ENV !== 'production',
|
||||||
|
// }),
|
||||||
|
ConfigModule.forRoot(
|
||||||
|
{
|
||||||
|
isGlobal: true,
|
||||||
|
load: [configuration],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CacheModule.registerAsync({
|
||||||
|
isGlobal: true,
|
||||||
|
useFactory: () => ({
|
||||||
|
stores: [
|
||||||
|
new KeyvRedis(`redis://127.0.0.1:6379/1`)
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
BullModule.forRoot({
|
||||||
|
connection: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 6379,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
//register the bull-board module forRoot in your app.module
|
||||||
|
BullBoardModule.forRoot({
|
||||||
|
route: "/queues",
|
||||||
|
adapter: ExpressAdapter
|
||||||
|
}),
|
||||||
|
TelegrafModule.forRoot({
|
||||||
|
token: process.env.TELEGRAM_BOT_TOKEN!,
|
||||||
|
middlewares: [
|
||||||
|
session({
|
||||||
|
store: Redis({
|
||||||
|
url: 'redis://127.0.0.1:6379/3', // Dùng DB 2 để tách biệt với CacheModule
|
||||||
|
})
|
||||||
|
}), // BẮT BUỘC: Phải có store này thì Wizard mới nhảy bước được
|
||||||
|
(ctx, next) => {
|
||||||
|
const allowedIds = (process.env.TELEGRAM_ALLOW_CHAT_IDS || '').split(',');
|
||||||
|
if (allowedIds.includes(''+ctx.chat?.id)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return ctx.reply('Xin lỗi, bạn không có quyền sử dụng bot này.');
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
// TelegrafModule.forRootAsync({
|
||||||
|
// useFactory: () => {
|
||||||
|
// // NestJS CacheManager stores (Keyv) usually expose the client
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// token: process.env.TELEGRAM_BOT_TOKEN!,
|
||||||
|
// middlewares: [
|
||||||
|
// session({
|
||||||
|
// store: new KeyvRedis({
|
||||||
|
// url: 'redis://127.0.0.1:6379/3', // Dùng DB 2 để tách biệt với CacheModule
|
||||||
|
// })
|
||||||
|
// }),
|
||||||
|
// ],
|
||||||
|
// };
|
||||||
|
// },
|
||||||
|
// // token: process.env.TELEGRAM_BOT_TOKEN!,
|
||||||
|
// // middlewares: [session()],
|
||||||
|
// }),
|
||||||
|
XCacheModule,
|
||||||
|
PrismaModule,
|
||||||
|
ManagerModule,
|
||||||
|
SocialModule,
|
||||||
|
ContentWriterModule,
|
||||||
|
CollectorModule,
|
||||||
|
TrendsModule,
|
||||||
|
TelegramModule,
|
||||||
|
TeleGrammYModule,
|
||||||
|
XReaderModule,
|
||||||
|
SchedulerModule,
|
||||||
|
XUploaderModule,
|
||||||
|
TiktokDownloadModule,
|
||||||
|
SqsModule,
|
||||||
|
],
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService, AIService, PrismaService, PgPostService, SqsPostService],
|
||||||
|
exports: [],
|
||||||
|
})
|
||||||
|
export class AppModule {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {_toNum} from "../../shared/helper";
|
||||||
|
|
||||||
|
export default () => ({
|
||||||
|
port: _toNum(process.env.PORT) || 3000,
|
||||||
|
newsapi: {
|
||||||
|
key: process.env.NEWSAPIORG_API_KEY || '',
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
perplexityKey: process.env.PERPLEXITY_API_KEY || '',
|
||||||
|
claudeKey: process.env.CLAUDE_API_KEY || '',
|
||||||
|
},
|
||||||
|
collector: {
|
||||||
|
redditUserAgent: process.env.COLLECTOR_REDDIT_USER_AGENT || 'TrendHunter/1.0',
|
||||||
|
cronIntervalHours: _toNum(process.env.COLLECTOR_CRON_INTERVAL_HOURS) || 2,
|
||||||
|
maxItemsPerSource: _toNum(process.env.COLLECTOR_MAX_ITEMS_PER_SOURCE) || 25,
|
||||||
|
googleTrendsGeo: process.env.GOOGLE_TRENDS_GEO || 'VN',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type Language = 'en' | 'vi' | 'ja' | 'ko' | 'cn';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export interface TrendItem {
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
score: number; // normalized 0–100
|
||||||
|
timestamp: Date;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
engagement?: {
|
||||||
|
upvotes?: number;
|
||||||
|
comments?: number;
|
||||||
|
shares?: number;
|
||||||
|
views?: number;
|
||||||
|
};
|
||||||
|
raw?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectorResult {
|
||||||
|
source: string;
|
||||||
|
items: TrendItem[];
|
||||||
|
collectedAt: Date;
|
||||||
|
durationMs: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions cho text processing — dedup, normalize, similarity
|
||||||
|
* Hoàn toàn FREE, không cần AI
|
||||||
|
*/
|
||||||
|
export class TextUtil {
|
||||||
|
/**
|
||||||
|
* Normalize title để so sánh similarity
|
||||||
|
*/
|
||||||
|
static normalizeTitle(title: string): string {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-zA-Z0-9\u00C0-\u024F\u1E00-\u1EFF\s]/g, '') // giữ unicode (tiếng Việt)
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jaccard similarity giữa 2 strings (dựa trên words)
|
||||||
|
* Return 0–1, >= 0.5 coi như trùng
|
||||||
|
*/
|
||||||
|
static jaccardSimilarity(a: string, b: string): number {
|
||||||
|
const setA = new Set(TextUtil.normalizeTitle(a).split(' '));
|
||||||
|
const setB = new Set(TextUtil.normalizeTitle(b).split(' '));
|
||||||
|
|
||||||
|
const intersection = new Set([...setA].filter((x) => setB.has(x)));
|
||||||
|
const union = new Set([...setA, ...setB]);
|
||||||
|
|
||||||
|
if (union.size === 0) return 0;
|
||||||
|
return intersection.size / union.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract keywords đơn giản (bỏ stopwords)
|
||||||
|
*/
|
||||||
|
static extractKeywords(text: string, maxKeywords = 5): string[] {
|
||||||
|
const stopwords = new Set([
|
||||||
|
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
|
||||||
|
'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will',
|
||||||
|
'would', 'could', 'should', 'may', 'might', 'can', 'shall',
|
||||||
|
'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from',
|
||||||
|
'as', 'into', 'through', 'during', 'before', 'after', 'above',
|
||||||
|
'below', 'between', 'out', 'off', 'over', 'under', 'again',
|
||||||
|
'further', 'then', 'once', 'here', 'there', 'when', 'where',
|
||||||
|
'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more',
|
||||||
|
'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only',
|
||||||
|
'own', 'same', 'so', 'than', 'too', 'very', 'just', 'because',
|
||||||
|
'but', 'and', 'or', 'if', 'while', 'about', 'up', 'it', 'its',
|
||||||
|
'this', 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'our',
|
||||||
|
'you', 'your', 'he', 'him', 'his', 'she', 'her', 'they', 'them',
|
||||||
|
'their', 'what', 'which', 'who', 'whom',
|
||||||
|
// Vietnamese stopwords
|
||||||
|
'và', 'của', 'là', 'có', 'được', 'cho', 'trong', 'với', 'không',
|
||||||
|
'này', 'đã', 'từ', 'một', 'những', 'các', 'để', 'theo', 'về',
|
||||||
|
'người', 'năm', 'đến', 'khi', 'còn', 'ra', 'cũng', 'như', 'hay',
|
||||||
|
'tại', 'vào', 'lại', 'sẽ', 'bị', 'đó', 'nếu', 'sau', 'trên',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const words = TextUtil.normalizeTitle(text).split(' ');
|
||||||
|
return words
|
||||||
|
.filter((w) => w.length > 2 && !stopwords.has(w))
|
||||||
|
.slice(0, maxKeywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tạo fingerprint cho dedup nhanh
|
||||||
|
*/
|
||||||
|
static fingerprint(title: string): string {
|
||||||
|
const keywords = TextUtil.extractKeywords(title, 4);
|
||||||
|
return keywords.sort().join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text an toàn (không cắt giữa từ)
|
||||||
|
*/
|
||||||
|
static truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
const truncated = text.substring(0, maxLength);
|
||||||
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
|
return (lastSpace > 0 ? truncated.substring(0, lastSpace) : truncated) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
static detectLinkX(text: string) {
|
||||||
|
const regex = /(https?:\/\/)?(www\.)?(x\.com|twitter\.com)\/[^\s]+/gi;
|
||||||
|
const links = text.match(regex);
|
||||||
|
console.log({links});
|
||||||
|
return {
|
||||||
|
hasLinkX: links && links.length > 0,
|
||||||
|
url: links ? links[0] : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeAllUrl(text: string) {
|
||||||
|
return text.replace(/(?:https?|ftp):\/\/[\n\S]+/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// utils/token-calculator.ts
|
||||||
|
|
||||||
|
import {Platform} from "../../modules/content-writer/enum/platform.enum";
|
||||||
|
import {Language} from "../interfaces/language.prompt.interface";
|
||||||
|
import {LengthRange} from "../../modules/content-writer/config/platform-limits";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens trung bình per character theo ngôn ngữ.
|
||||||
|
* Dựa trên tokenizer GPT/Claude thực tế.
|
||||||
|
*/
|
||||||
|
const TOKENS_PER_CHAR: Record<Language, number> = {
|
||||||
|
en: 0.25, // 1 token ≈ 4 chars
|
||||||
|
vi: 0.5, // 1 token ≈ 2 chars (dấu tiếng Việt tốn token)
|
||||||
|
ja: 1.0, // 1 token ≈ 1 char (hiragana/kanji)
|
||||||
|
ko: 1.0, // 1 token ≈ 1 char (hangul)
|
||||||
|
cn: 1.0, // 1 token ≈ 1 char (chinese)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target character length theo platform + buffer để AI có không gian "thở".
|
||||||
|
*/
|
||||||
|
const PLATFORM_TARGET_CHARS: Record<Platform, { min: number; max: number; buffer: number }> = {
|
||||||
|
[Platform.X]: { min: 180, max: 280, buffer: 1.3 },
|
||||||
|
[Platform.FACEBOOK]: { min: 400, max: 1200, buffer: 1.5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LengthBudget {
|
||||||
|
minChars: number;
|
||||||
|
maxChars: number;
|
||||||
|
maxTokens: number;
|
||||||
|
}
|
||||||
|
export interface TokenBudget {
|
||||||
|
minChars: number;
|
||||||
|
maxChars: number;
|
||||||
|
sweetChars: number;
|
||||||
|
maxTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateLengthBudget(
|
||||||
|
platform: Platform,
|
||||||
|
language: Language,
|
||||||
|
): LengthBudget {
|
||||||
|
const target = PLATFORM_TARGET_CHARS[platform];
|
||||||
|
const tokensPerChar = TOKENS_PER_CHAR[language];
|
||||||
|
|
||||||
|
const raw = Math.ceil(target.max * tokensPerChar * target.buffer);
|
||||||
|
|
||||||
|
// Safe minimums để đảm bảo không bị cắt
|
||||||
|
const SAFE_MIN: Record<Platform, number> = {
|
||||||
|
[Platform.X]: 300, // X post không bao giờ < 300 tokens
|
||||||
|
[Platform.FACEBOOK]: 800, // FB không bao giờ < 800 tokens
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
minChars: target.min,
|
||||||
|
maxChars: target.max,
|
||||||
|
maxTokens: Math.max(raw, SAFE_MIN[platform]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function calculateTokenBudget(
|
||||||
|
range: LengthRange,
|
||||||
|
language: Language,
|
||||||
|
): TokenBudget {
|
||||||
|
const tokensPerChar = TOKENS_PER_CHAR[language];
|
||||||
|
|
||||||
|
// Buffer 1.4x cho emoji, punctuation, line breaks
|
||||||
|
const maxTokens = Math.ceil(range.max * tokensPerChar * 1.4);
|
||||||
|
|
||||||
|
// Safe minimum theo range
|
||||||
|
const SAFE_MIN = Math.ceil(range.min * tokensPerChar * 1.5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
minChars: range.min,
|
||||||
|
maxChars: range.max,
|
||||||
|
sweetChars: range.sweet,
|
||||||
|
maxTokens: Math.max(maxTokens, SAFE_MIN, 200), // floor 200 tokens
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* This file should be your main import to use Prisma-related types and utilities in a browser.
|
||||||
|
* Use it to get access to models, enums, and input types.
|
||||||
|
*
|
||||||
|
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
|
||||||
|
* See `client.ts` for the standard, server-side entry point.
|
||||||
|
*
|
||||||
|
* 🟢 You can import this file directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Prisma from './internal/prismaNamespaceBrowser.js'
|
||||||
|
export { Prisma }
|
||||||
|
export * as $Enums from './enums.js'
|
||||||
|
export * from './enums.js';
|
||||||
|
/**
|
||||||
|
* Model User
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type User = Prisma.UserModel
|
||||||
|
/**
|
||||||
|
* Model Post
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Post = Prisma.PostModel
|
||||||
|
/**
|
||||||
|
* Model Config
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Config = Prisma.ConfigModel
|
||||||
|
/**
|
||||||
|
* Model Trend
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Trend = Prisma.TrendModel
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
|
||||||
|
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
|
||||||
|
*
|
||||||
|
* 🟢 You can import this file directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as process from 'node:process'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
|
||||||
|
import * as runtime from "@prisma/client/runtime/client"
|
||||||
|
import * as $Enums from "./enums.js"
|
||||||
|
import * as $Class from "./internal/class.js"
|
||||||
|
import * as Prisma from "./internal/prismaNamespace.js"
|
||||||
|
|
||||||
|
export * as $Enums from './enums.js'
|
||||||
|
export * from "./enums.js"
|
||||||
|
/**
|
||||||
|
* ## Prisma Client
|
||||||
|
*
|
||||||
|
* Type-safe database client for TypeScript
|
||||||
|
* @example
|
||||||
|
* ```
|
||||||
|
* const prisma = new PrismaClient({
|
||||||
|
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||||
|
* })
|
||||||
|
* // Fetch zero or more Users
|
||||||
|
* const users = await prisma.user.findMany()
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Read more in our [docs](https://pris.ly/d/client).
|
||||||
|
*/
|
||||||
|
export const PrismaClient = $Class.getPrismaClientClass()
|
||||||
|
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
|
||||||
|
export { Prisma }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model User
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type User = Prisma.UserModel
|
||||||
|
/**
|
||||||
|
* Model Post
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Post = Prisma.PostModel
|
||||||
|
/**
|
||||||
|
* Model Config
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Config = Prisma.ConfigModel
|
||||||
|
/**
|
||||||
|
* Model Trend
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Trend = Prisma.TrendModel
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
|
||||||
|
*
|
||||||
|
* 🟢 You can import this file directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as runtime from "@prisma/client/runtime/client"
|
||||||
|
import * as $Enums from "./enums.js"
|
||||||
|
import type * as Prisma from "./internal/prismaNamespace.js"
|
||||||
|
|
||||||
|
|
||||||
|
export type IntFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StringFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
mode?: Prisma.QueryMode
|
||||||
|
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StringNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
mode?: Prisma.QueryMode
|
||||||
|
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortOrderInput = {
|
||||||
|
sort: Prisma.SortOrder
|
||||||
|
nulls?: Prisma.NullsOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||||
|
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StringWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
mode?: Prisma.QueryMode
|
||||||
|
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
mode?: Prisma.QueryMode
|
||||||
|
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateTimeFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonNullableFilter<$PrismaModel = never> =
|
||||||
|
| Prisma.PatchUndefined<
|
||||||
|
Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>,
|
||||||
|
Required<JsonNullableFilterBase<$PrismaModel>>
|
||||||
|
>
|
||||||
|
| Prisma.OptionalFlat<Omit<Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>
|
||||||
|
|
||||||
|
export type JsonNullableFilterBase<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
path?: string[]
|
||||||
|
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||||
|
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
|
||||||
|
| Prisma.PatchUndefined<
|
||||||
|
Prisma.Either<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
||||||
|
Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>
|
||||||
|
>
|
||||||
|
| Prisma.OptionalFlat<Omit<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
||||||
|
|
||||||
|
export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
path?: string[]
|
||||||
|
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||||
|
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedJsonNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedIntFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedStringFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||||
|
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||||
|
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedFloatFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||||
|
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedDateTimeFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||||
|
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedJsonNullableFilter<$PrismaModel = never> =
|
||||||
|
| Prisma.PatchUndefined<
|
||||||
|
Prisma.Either<Required<NestedJsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>,
|
||||||
|
Required<NestedJsonNullableFilterBase<$PrismaModel>>
|
||||||
|
>
|
||||||
|
| Prisma.OptionalFlat<Omit<Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>
|
||||||
|
|
||||||
|
export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
|
||||||
|
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
path?: string[]
|
||||||
|
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||||
|
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
|
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||||
|
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* This file exports all enum related types from the schema.
|
||||||
|
*
|
||||||
|
* 🟢 You can import this file directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// This file is empty because there are no enums in the schema.
|
||||||
|
export {}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* WARNING: This is an internal file that is subject to change!
|
||||||
|
*
|
||||||
|
* 🛑 Under no circumstances should you import this file directly! 🛑
|
||||||
|
*
|
||||||
|
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
|
||||||
|
* While this enables partial backward compatibility, it is not part of the stable public API.
|
||||||
|
*
|
||||||
|
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
|
||||||
|
* model files in the `model` directory!
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as runtime from "@prisma/client/runtime/index-browser"
|
||||||
|
|
||||||
|
export type * from '../models.js'
|
||||||
|
export type * from './prismaNamespace.js'
|
||||||
|
|
||||||
|
export const Decimal = runtime.Decimal
|
||||||
|
|
||||||
|
|
||||||
|
export const NullTypes = {
|
||||||
|
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
|
||||||
|
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
|
||||||
|
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
|
||||||
|
*
|
||||||
|
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||||
|
*/
|
||||||
|
export const DbNull = runtime.DbNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
|
||||||
|
*
|
||||||
|
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||||
|
*/
|
||||||
|
export const JsonNull = runtime.JsonNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
|
||||||
|
*
|
||||||
|
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||||
|
*/
|
||||||
|
export const AnyNull = runtime.AnyNull
|
||||||
|
|
||||||
|
|
||||||
|
export const ModelName = {
|
||||||
|
User: 'User',
|
||||||
|
Post: 'Post',
|
||||||
|
Config: 'Config',
|
||||||
|
Trend: 'Trend'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Enums
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TransactionIsolationLevel = runtime.makeStrictEnum({
|
||||||
|
ReadUncommitted: 'ReadUncommitted',
|
||||||
|
ReadCommitted: 'ReadCommitted',
|
||||||
|
RepeatableRead: 'RepeatableRead',
|
||||||
|
Serializable: 'Serializable'
|
||||||
|
} as const)
|
||||||
|
|
||||||
|
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
|
||||||
|
|
||||||
|
|
||||||
|
export const UserScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
email: 'email',
|
||||||
|
name: 'name'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const PostScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
title: 'title',
|
||||||
|
prompt: 'prompt',
|
||||||
|
content: 'content',
|
||||||
|
imageUrl: 'imageUrl',
|
||||||
|
style: 'style',
|
||||||
|
tone: 'tone',
|
||||||
|
isFbPostState: 'isFbPostState',
|
||||||
|
isTwitterPostState: 'isTwitterPostState',
|
||||||
|
isTiktokPostState: 'isTiktokPostState',
|
||||||
|
draft: 'draft',
|
||||||
|
tokensUsed: 'tokensUsed',
|
||||||
|
model: 'model',
|
||||||
|
reviewNotes: 'reviewNotes',
|
||||||
|
status: 'status',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PostScalarFieldEnum = (typeof PostScalarFieldEnum)[keyof typeof PostScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const ConfigScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
key: 'key',
|
||||||
|
value: 'value'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ConfigScalarFieldEnum = (typeof ConfigScalarFieldEnum)[keyof typeof ConfigScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const TrendScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
title: 'title',
|
||||||
|
description: 'description',
|
||||||
|
url: 'url',
|
||||||
|
score: 'score',
|
||||||
|
source: 'source',
|
||||||
|
category: 'category',
|
||||||
|
geo: 'geo',
|
||||||
|
tags: 'tags',
|
||||||
|
engagement: 'engagement',
|
||||||
|
raw: 'raw',
|
||||||
|
fingerprint: 'fingerprint',
|
||||||
|
sourceTimestamp: 'sourceTimestamp',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TrendScalarFieldEnum = (typeof TrendScalarFieldEnum)[keyof typeof TrendScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const SortOrder = {
|
||||||
|
asc: 'asc',
|
||||||
|
desc: 'desc'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||||
|
|
||||||
|
|
||||||
|
export const NullableJsonNullValueInput = {
|
||||||
|
DbNull: DbNull,
|
||||||
|
JsonNull: JsonNull
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
||||||
|
|
||||||
|
|
||||||
|
export const QueryMode = {
|
||||||
|
default: 'default',
|
||||||
|
insensitive: 'insensitive'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||||
|
|
||||||
|
|
||||||
|
export const NullsOrder = {
|
||||||
|
first: 'first',
|
||||||
|
last: 'last'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||||
|
|
||||||
|
|
||||||
|
export const JsonNullValueFilter = {
|
||||||
|
DbNull: DbNull,
|
||||||
|
JsonNull: JsonNull,
|
||||||
|
AnyNull: AnyNull
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* This is a barrel export file for all models and their related types.
|
||||||
|
*
|
||||||
|
* 🟢 You can import this file directly.
|
||||||
|
*/
|
||||||
|
export type * from './models/User.js'
|
||||||
|
export type * from './models/Post.js'
|
||||||
|
export type * from './models/Config.js'
|
||||||
|
export type * from './models/Trend.js'
|
||||||
|
export type * from './commonInputTypes.js'
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
export default import('./query_compiler_fast_bg.wasm?module')
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
||||||
|
/* eslint-disable */
|
||||||
|
// biome-ignore-all lint: generated file
|
||||||
|
export default import('./query_compiler_fast_bg.wasm')
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import {ValidationPipe} from "@nestjs/common";
|
||||||
|
import {DocumentBuilder, SwaggerModule} from "@nestjs/swagger";
|
||||||
|
|
||||||
|
// const { createBullBoard } = require('@bull-board/api');
|
||||||
|
// const { BullAdapter } = require('@bull-board/api/bullAdapter');
|
||||||
|
// const { BullMQAdapter } = require('@bull-board/api/bullMQAdapter');
|
||||||
|
// const { ExpressAdapter } = require('@bull-board/express');
|
||||||
|
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
snapshot: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global validation pipe
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.enableCors();
|
||||||
|
|
||||||
|
// Swagger
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('🔥 Trend Hunter API')
|
||||||
|
.setDescription(
|
||||||
|
'API tìm kiếm và tổng hợp trends từ nhiều nguồn social media',
|
||||||
|
)
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addTag('Trends')
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('docs', app, document);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
await app.listen(port);
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
🔥 X-News is running!
|
||||||
|
📡 API: http://localhost:${port}
|
||||||
|
📖 Swagger: http://localhost:${port}/docs
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ClaudeService } from './claude.service';
|
||||||
|
import { PerplexityService } from './perplexity.service';
|
||||||
|
import { AiRouterService } from './ai-router.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
|
HttpModule.register({ timeout: 30000 }),
|
||||||
|
],
|
||||||
|
providers: [ClaudeService, PerplexityService, AiRouterService],
|
||||||
|
exports: [AiRouterService, ClaudeService, PerplexityService],
|
||||||
|
})
|
||||||
|
export class AiAnalysisModule {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ClaudeService } from './claude.service';
|
||||||
|
import { PerplexityService } from './perplexity.service';
|
||||||
|
import {TrendItem} from "../../common/interfaces/trend-item.interface";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiRouterService {
|
||||||
|
private readonly logger = new Logger(AiRouterService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly claude: ClaudeService,
|
||||||
|
private readonly perplexity: PerplexityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder — bật lên khi cần AI analysis
|
||||||
|
* Hiện tại hệ thống chạy hoàn toàn bằng free collectors
|
||||||
|
*/
|
||||||
|
async enrichTrends(items: TrendItem[]): Promise<TrendItem[]> {
|
||||||
|
const claudeAvailable = await this.claude.isAvailable();
|
||||||
|
const perplexityAvailable = await this.perplexity.isAvailable();
|
||||||
|
|
||||||
|
if (!claudeAvailable && !perplexityAvailable) {
|
||||||
|
this.logger.log('No AI services configured — returning raw trends');
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu Claude available → batch analyze top items
|
||||||
|
if (claudeAvailable) {
|
||||||
|
const topItems = items.slice(0, 20);
|
||||||
|
const analysis = await this.claude.analyzeBatch(topItems);
|
||||||
|
|
||||||
|
if (analysis?.trends) {
|
||||||
|
// Merge AI analysis back vào items
|
||||||
|
for (const aiTrend of analysis.trends) {
|
||||||
|
const match = items.find(
|
||||||
|
(item) =>
|
||||||
|
item.title.toLowerCase().includes(aiTrend.title.toLowerCase()) ||
|
||||||
|
aiTrend.title.toLowerCase().includes(item.title.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
match.category = aiTrend.category;
|
||||||
|
match.description = match.description || aiTrend.summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import {TrendItem} from "../../common/interfaces/trend-item.interface";
|
||||||
|
|
||||||
|
export interface ClaudeAnalysisResult {
|
||||||
|
summary: string;
|
||||||
|
category: string;
|
||||||
|
sentiment: 'positive' | 'negative' | 'neutral';
|
||||||
|
importance: number; // 1-10
|
||||||
|
relatedTopics: string[];
|
||||||
|
keyEntities: string[];
|
||||||
|
whyTrending: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaudeBatchAnalysisResult {
|
||||||
|
trends: {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
category: string;
|
||||||
|
sentiment: 'positive' | 'negative' | 'neutral';
|
||||||
|
score: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClaudeService {
|
||||||
|
private readonly logger = new Logger(ClaudeService.name);
|
||||||
|
private readonly apiUrl = 'https://api.anthropic.com/v1/messages';
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly isConfigured: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.apiKey = this.configService.get<string>('ai.claudeKey', '');
|
||||||
|
this.isConfigured = !!this.apiKey;
|
||||||
|
|
||||||
|
if (!this.isConfigured) {
|
||||||
|
this.logger.warn(
|
||||||
|
'Claude API key not configured — Claude analysis will be skipped',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phân tích 1 trend item chi tiết
|
||||||
|
* Model: claude-3-5-haiku (rẻ nhất: $0.25/$1.25 per 1M tokens)
|
||||||
|
*/
|
||||||
|
async analyzeTrend(item: TrendItem): Promise<ClaudeAnalysisResult | null> {
|
||||||
|
if (!this.isConfigured) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.callClaude(
|
||||||
|
'claude-3-5-haiku-20241022',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Analyze this trending topic and respond in JSON only:
|
||||||
|
|
||||||
|
Title: "${item.title}"
|
||||||
|
Description: "${item.description}"
|
||||||
|
Source: ${item.source}
|
||||||
|
Current engagement: ${JSON.stringify(item.engagement || {})}
|
||||||
|
|
||||||
|
Return this exact JSON format:
|
||||||
|
{
|
||||||
|
"summary": "2-3 câu tóm tắt bằng tiếng Việt",
|
||||||
|
"category": "tech|business|politics|entertainment|science|sports|health|other",
|
||||||
|
"sentiment": "positive|negative|neutral",
|
||||||
|
"importance": <1-10>,
|
||||||
|
"relatedTopics": ["topic1", "topic2", "topic3"],
|
||||||
|
"keyEntities": ["entity1", "entity2"],
|
||||||
|
"whyTrending": "giải thích ngắn gọn tại sao topic này đang hot"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
300, // max_tokens — giữ nhỏ để tiết kiệm
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.parseJsonResponse<ClaudeAnalysisResult>(response);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Claude analyzeTrend error: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phân tích BATCH nhiều trends 1 lần — TIẾT KIỆM tokens
|
||||||
|
* Thay vì gọi 15 lần (15 × overhead), gom lại 1 lần
|
||||||
|
*/
|
||||||
|
async analyzeBatch(
|
||||||
|
items: TrendItem[],
|
||||||
|
): Promise<ClaudeBatchAnalysisResult | null> {
|
||||||
|
if (!this.isConfigured) return null;
|
||||||
|
|
||||||
|
const trendList = items
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(
|
||||||
|
(item, i) =>
|
||||||
|
`${i + 1}. [${item.source}] "${item.title}" — Score: ${item.score}`,
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.callClaude(
|
||||||
|
'claude-3-5-haiku-20241022',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Phân tích danh sách trends sau. Trả về JSON:
|
||||||
|
|
||||||
|
${trendList}
|
||||||
|
|
||||||
|
Return this exact JSON:
|
||||||
|
{
|
||||||
|
"trends": [
|
||||||
|
{
|
||||||
|
"title": "tên trend",
|
||||||
|
"summary": "tóm tắt ngắn tiếng Việt (1 câu)",
|
||||||
|
"category": "tech|business|politics|entertainment|science|sports|health|other",
|
||||||
|
"sentiment": "positive|negative|neutral",
|
||||||
|
"score": <0-100, đánh giá mức độ trending>
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
1500,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.parseJsonResponse<ClaudeBatchAnalysisResult>(response);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Claude analyzeBatch error: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tóm tắt + categorize 1 nhóm trends (giống nhau) thành 1 unified trend
|
||||||
|
*/
|
||||||
|
async summarizeGroup(
|
||||||
|
items: TrendItem[],
|
||||||
|
): Promise<{ title: string; summary: string; category: string } | null> {
|
||||||
|
if (!this.isConfigured) return null;
|
||||||
|
|
||||||
|
const titles = items.map((i) => `- ${i.title} (${i.source})`).join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.callClaude(
|
||||||
|
'claude-3-5-haiku-20241022',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Các bài viết sau cùng 1 chủ đề. Tóm tắt thành 1 trend. JSON only:
|
||||||
|
|
||||||
|
${titles}
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "tiêu đề tổng hợp ngắn gọn",
|
||||||
|
"summary": "tóm tắt 2-3 câu tiếng Việt",
|
||||||
|
"category": "tech|business|politics|entertainment|science|sports|health|other"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
250,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.parseJsonResponse(response);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Claude summarizeGroup error: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core API call tới Claude
|
||||||
|
*/
|
||||||
|
private async callClaude(
|
||||||
|
model: string,
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
maxTokens: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const { data } = await firstValueFrom(
|
||||||
|
this.httpService.post(
|
||||||
|
this.apiUrl,
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature: 0.1, // Low temperature cho output consistent
|
||||||
|
messages,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'x-api-key': this.apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = data.content?.[0]?.text || '';
|
||||||
|
|
||||||
|
// Log token usage cho cost tracking
|
||||||
|
if (data.usage) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Claude tokens — input: ${data.usage.input_tokens}, output: ${data.usage.output_tokens}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseJsonResponse<T>(content: string): T | null {
|
||||||
|
try {
|
||||||
|
// Extract JSON từ response (có thể wrapped trong ```json```)
|
||||||
|
const jsonMatch = content.match(/```json\s*([\s\S]*?)```/) ||
|
||||||
|
content.match(/(\{[\s\S]*\})/);
|
||||||
|
|
||||||
|
if (jsonMatch) {
|
||||||
|
return JSON.parse(jsonMatch[1] || jsonMatch[0]);
|
||||||
|
}
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to parse Claude JSON: ${error.message}`);
|
||||||
|
this.logger.debug(`Raw response: ${content.substring(0, 200)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check — kiểm tra API key có valid không
|
||||||
|
*/
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
if (!this.isConfigured) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.callClaude(
|
||||||
|
'claude-3-5-haiku-20241022',
|
||||||
|
[{ role: 'user', content: 'ping' }],
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
export interface PerplexitySearchResult {
|
||||||
|
content: string;
|
||||||
|
citations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PerplexityService {
|
||||||
|
private readonly logger = new Logger(PerplexityService.name);
|
||||||
|
private readonly apiUrl = 'https://api.perplexity.ai/chat/completions';
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly isConfigured: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.apiKey = this.configService.get<string>('ai.perplexityKey', '');
|
||||||
|
this.isConfigured = !!this.apiKey;
|
||||||
|
|
||||||
|
if (!this.isConfigured) {
|
||||||
|
this.logger.warn('Perplexity API key not configured — search enrichment will be skipped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search real-time bằng Perplexity Sonar
|
||||||
|
*/
|
||||||
|
async search(query: string): Promise<PerplexitySearchResult | null> {
|
||||||
|
if (!this.isConfigured) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await firstValueFrom(
|
||||||
|
this.httpService.post(
|
||||||
|
this.apiUrl,
|
||||||
|
{
|
||||||
|
model: 'sonar',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: 'You are a helpful assistant that provides concise, factual answers about current events and trends.',
|
||||||
|
},
|
||||||
|
{ role: 'user', content: query },
|
||||||
|
],
|
||||||
|
max_tokens: 500,
|
||||||
|
temperature: 0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: data.choices?.[0]?.message?.content || '',
|
||||||
|
citations: data.citations || [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Perplexity search error: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
if (!this.isConfigured) return false;
|
||||||
|
try {
|
||||||
|
const result = await this.search('ping');
|
||||||
|
return !!result;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {GoogleTrendsService} from "./google-trends.service";
|
||||||
|
import {RedditService} from "./reddit.service";
|
||||||
|
import {HackerNewsService} from "./hackernews.service";
|
||||||
|
import {RssService} from "./rss.service";
|
||||||
|
import {NewsApiService} from "./newsapi.service";
|
||||||
|
import {CollectorResult, TrendItem} from "../../common/interfaces/trend-item.interface";
|
||||||
|
import {TextUtil} from "../../common/utils/text.util";
|
||||||
|
|
||||||
|
export interface OrchestratorResult {
|
||||||
|
items: TrendItem[];
|
||||||
|
stats: {
|
||||||
|
totalRaw: number;
|
||||||
|
afterDedup: number;
|
||||||
|
bySource: Record<string, number>;
|
||||||
|
errors: string[];
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CollectorOrchestratorService {
|
||||||
|
private readonly logger = new Logger(CollectorOrchestratorService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly googleTrends: GoogleTrendsService,
|
||||||
|
private readonly reddit: RedditService,
|
||||||
|
private readonly hackerNews: HackerNewsService,
|
||||||
|
private readonly rss: RssService,
|
||||||
|
private readonly newsApi: NewsApiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thu thập từ TẤT CẢ nguồn, dedup, sort by score
|
||||||
|
*/
|
||||||
|
async collectAll(): Promise<OrchestratorResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
this.logger.log('🚀 Starting full collection cycle...');
|
||||||
|
|
||||||
|
// Chạy tất cả collectors song song
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
// this.googleTrends.collect(),
|
||||||
|
this.reddit.collect(['general', 'tech', 'news', 'japan', 'korean']),
|
||||||
|
this.hackerNews.collect(),
|
||||||
|
this.rss.collect(),
|
||||||
|
this.newsApi.collect(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const collectorResults: CollectorResult[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const sourceNames = [
|
||||||
|
'google-trends',
|
||||||
|
'reddit',
|
||||||
|
'hackernews',
|
||||||
|
'rss',
|
||||||
|
'newsapi',
|
||||||
|
];
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
collectorResults.push(result.value);
|
||||||
|
if (result.value.error) {
|
||||||
|
errors.push(`[${sourceNames[index]}] ${result.value.error}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push(
|
||||||
|
`[${sourceNames[index]}] FAILED: ${result.reason?.message}`,
|
||||||
|
);
|
||||||
|
this.logger.error(
|
||||||
|
`Collector ${sourceNames[index]} crashed: ${result.reason?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge tất cả items
|
||||||
|
const allItems = collectorResults.flatMap((r) => r.items);
|
||||||
|
const totalRaw = allItems.length;
|
||||||
|
|
||||||
|
// Count by source
|
||||||
|
const bySource: Record<string, number> = {};
|
||||||
|
for (const item of allItems) {
|
||||||
|
const src = item.source.split(':')[0];
|
||||||
|
bySource[src] = (bySource[src] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup
|
||||||
|
const deduped = this.deduplicateItems(allItems);
|
||||||
|
|
||||||
|
// Cross-reference bonus: nếu cùng topic xuất hiện ở nhiều source → boost score
|
||||||
|
const boosted = this.applyCrossSourceBonus(deduped);
|
||||||
|
|
||||||
|
// Sort by score desc
|
||||||
|
boosted.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`✅ Collection complete: ${totalRaw} raw → ${boosted.length} deduped in ${durationMs}ms`,
|
||||||
|
);
|
||||||
|
this.logger.log(`📊 By source: ${JSON.stringify(bySource)}`);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
this.logger.warn(`⚠️ Errors: ${errors.length}`);
|
||||||
|
errors.forEach((e) => this.logger.warn(` ${e}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: boosted,
|
||||||
|
stats: {
|
||||||
|
totalRaw,
|
||||||
|
afterDedup: boosted.length,
|
||||||
|
bySource,
|
||||||
|
errors,
|
||||||
|
durationMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thu thập từ 1 source cụ thể
|
||||||
|
*/
|
||||||
|
async collectFromSource(source: string): Promise<CollectorResult> {
|
||||||
|
switch (source) {
|
||||||
|
case 'google-trends':
|
||||||
|
return this.googleTrends.collect();
|
||||||
|
case 'reddit':
|
||||||
|
return this.reddit.collect();
|
||||||
|
case 'hackernews':
|
||||||
|
return this.hackerNews.collect();
|
||||||
|
case 'rss':
|
||||||
|
return this.rss.collect();
|
||||||
|
case 'newsapi':
|
||||||
|
return this.newsApi.collect();
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown source: ${source}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedup dựa trên title similarity (Jaccard >= 0.5)
|
||||||
|
*/
|
||||||
|
private deduplicateItems(items: TrendItem[]): TrendItem[] {
|
||||||
|
const result: TrendItem[] = [];
|
||||||
|
const fingerprints = new Map<string, number>(); // fingerprint → index in result
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.title || item.title.trim().length === 0) continue;
|
||||||
|
|
||||||
|
const fp = TextUtil.fingerprint(item.title);
|
||||||
|
|
||||||
|
// Check exact fingerprint match
|
||||||
|
if (fingerprints.has(fp)) {
|
||||||
|
const existingIdx = fingerprints.get(fp)!;
|
||||||
|
// Giữ item có score cao hơn
|
||||||
|
if (item.score > result[existingIdx].score) {
|
||||||
|
result[existingIdx] = item;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check similarity với existing items (chỉ check 50 gần nhất để performance)
|
||||||
|
let isDuplicate = false;
|
||||||
|
const startCheck = Math.max(0, result.length - 50);
|
||||||
|
|
||||||
|
for (let i = startCheck; i < result.length; i++) {
|
||||||
|
const similarity = TextUtil.jaccardSimilarity(
|
||||||
|
item.title,
|
||||||
|
result[i].title,
|
||||||
|
);
|
||||||
|
if (similarity >= 0.5) {
|
||||||
|
// Merge: giữ score cao hơn, gộp source
|
||||||
|
if (item.score > result[i].score) {
|
||||||
|
result[i] = {
|
||||||
|
...item,
|
||||||
|
tags: [
|
||||||
|
...new Set([
|
||||||
|
...(result[i].tags || []),
|
||||||
|
...(item.tags || []),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
isDuplicate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDuplicate) {
|
||||||
|
fingerprints.set(fp, result.length);
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nếu cùng topic xuất hiện trên nhiều source → boost score
|
||||||
|
* Vì: multi-source = THỰC SỰ TRENDING
|
||||||
|
*/
|
||||||
|
private applyCrossSourceBonus(items: TrendItem[]): TrendItem[] {
|
||||||
|
// Group by similar titles
|
||||||
|
const groups: { canonical: TrendItem; sources: Set<string> }[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
let foundGroup = false;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const sim = TextUtil.jaccardSimilarity(
|
||||||
|
item.title,
|
||||||
|
group.canonical.title,
|
||||||
|
);
|
||||||
|
if (sim >= 0.35) {
|
||||||
|
// Looser threshold cho cross-source matching
|
||||||
|
const src = item.source.split(':')[0];
|
||||||
|
group.sources.add(src);
|
||||||
|
foundGroup = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundGroup) {
|
||||||
|
const src = item.source.split(':')[0];
|
||||||
|
groups.push({ canonical: item, sources: new Set([src]) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo source count map
|
||||||
|
const titleBonus = new Map<string, number>();
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.sources.size > 1) {
|
||||||
|
const bonus = (group.sources.size - 1) * 12; // +12 per extra source
|
||||||
|
titleBonus.set(TextUtil.fingerprint(group.canonical.title), bonus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply bonus
|
||||||
|
return items.map((item) => {
|
||||||
|
const fp = TextUtil.fingerprint(item.title);
|
||||||
|
const bonus = titleBonus.get(fp) || 0;
|
||||||
|
if (bonus > 0) {
|
||||||
|
return { ...item, score: Math.min(item.score + bonus, 100) };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {Module} from "@nestjs/common";
|
||||||
|
import {ConfigModule} from "@nestjs/config";
|
||||||
|
import {HttpModule} from "@nestjs/axios";
|
||||||
|
import {GoogleTrendsService} from "./google-trends.service";
|
||||||
|
import {RedditService} from "./reddit.service";
|
||||||
|
import {HackerNewsService} from "./hackernews.service";
|
||||||
|
import {RssService} from "./rss.service";
|
||||||
|
import {NewsApiService} from "./newsapi.service";
|
||||||
|
import {CollectorOrchestratorService} from "./collector-orchestrator.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
|
HttpModule.register({
|
||||||
|
timeout: 15000,
|
||||||
|
maxRedirects: 3,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
GoogleTrendsService,
|
||||||
|
RedditService,
|
||||||
|
HackerNewsService,
|
||||||
|
RssService,
|
||||||
|
NewsApiService,
|
||||||
|
CollectorOrchestratorService,
|
||||||
|
],
|
||||||
|
exports: [CollectorOrchestratorService, GoogleTrendsService],
|
||||||
|
})
|
||||||
|
export class CollectorModule {}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {Injectable, Logger} from '@nestjs/common';
|
||||||
|
import {ConfigService} from '@nestjs/config';
|
||||||
|
import googleTrends from '@shaivpidadi/trends-js';
|
||||||
|
import {TrendItem, CollectorResult} from '../../common/interfaces/trend-item.interface';
|
||||||
|
import {_JsonParseSafe} from "../../shared/helper";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleTrendsService {
|
||||||
|
private readonly logger = new Logger(GoogleTrendsService.name);
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect(googleTrendsGeo: string = 'JP'): Promise<CollectorResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const items: TrendItem[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 1. Daily trends
|
||||||
|
try {
|
||||||
|
const daily = await this.getDailyTrends(googleTrendsGeo);
|
||||||
|
items.push(...daily);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`dailyTrends: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Real-time trends (nếu available)
|
||||||
|
try {
|
||||||
|
// const realtime = await this.getRealtimeTrends();
|
||||||
|
// items.push(...realtime);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`realtimeTrends: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'google-trends',
|
||||||
|
items,
|
||||||
|
collectedAt: new Date(),
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
error: errors.length > 0 ? errors.join('; ') : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily trending searches — 100% FREE
|
||||||
|
*/
|
||||||
|
private async getDailyTrends(googleTrendsGeo: string = 'JP'): Promise<TrendItem[]> {
|
||||||
|
console.debug('google-trends:getDailyTrends :'+googleTrendsGeo);
|
||||||
|
const {data: results} = await googleTrends.dailyTrends({
|
||||||
|
trendDate: new Date(),
|
||||||
|
geo: googleTrendsGeo,
|
||||||
|
});
|
||||||
|
|
||||||
|
// const parsed = _JsonParseSafe(results);
|
||||||
|
|
||||||
|
const days = results.allTrendingStories || [];
|
||||||
|
// const days = results.s || [];
|
||||||
|
console.log(days);
|
||||||
|
const allTrends = days.splice(0,15).map((trend) => {
|
||||||
|
const article = trend.articles?.[0];
|
||||||
|
const traffic = trend.formattedTraffic || '0';
|
||||||
|
const trafficNum = this.parseTraffic(traffic);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'google-trends:daily',
|
||||||
|
title: trend.title || '',
|
||||||
|
description: article?.snippet || trend.title || '',
|
||||||
|
url: article?.url || `https://trends.google.com/trends/explore?q=${encodeURIComponent(trend.title || '')}`,
|
||||||
|
score: 10,//this.calculateScore(trafficNum, index),
|
||||||
|
timestamp: new Date(),
|
||||||
|
category: this.detectCategory(trend),
|
||||||
|
tags: (trend.relatedQueries || [])
|
||||||
|
.map((q: any) => q.query)
|
||||||
|
.slice(0, 5),
|
||||||
|
engagement: {
|
||||||
|
views: trafficNum,
|
||||||
|
},
|
||||||
|
raw: {
|
||||||
|
formattedTraffic: traffic,
|
||||||
|
image: trend.image,
|
||||||
|
articles: (trend.articles || []).map((a: any) => ({
|
||||||
|
title: a.title,
|
||||||
|
url: a.url,
|
||||||
|
source: a.source,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Google Trends daily: ${allTrends.length} items`);
|
||||||
|
return allTrends;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realtime trending topics
|
||||||
|
*/
|
||||||
|
private async getRealtimeTrends(googleTrendsGeo: string = 'JP'): Promise<TrendItem[]> {
|
||||||
|
try {
|
||||||
|
console.debug('google-trends:getRealtimeTrends');
|
||||||
|
const results = await googleTrends.realTimeTrends({
|
||||||
|
geo: googleTrendsGeo,
|
||||||
|
category: 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(results);
|
||||||
|
const stories = parsed.storySummaries?.trendingStories || [];
|
||||||
|
|
||||||
|
const items: TrendItem[] = stories.map((story: any, index: number) => ({
|
||||||
|
source: 'google-trends:realtime',
|
||||||
|
title: story.title || story.entityNames?.join(', ') || '',
|
||||||
|
description: story.articles?.[0]?.articleTitle || '',
|
||||||
|
url: story.articles?.[0]?.url || '',
|
||||||
|
score: Math.max(95 - index * 4, 10),
|
||||||
|
timestamp: new Date(),
|
||||||
|
tags: story.entityNames || [],
|
||||||
|
engagement: {
|
||||||
|
shares: story.articles?.length || 0,
|
||||||
|
},
|
||||||
|
raw: {
|
||||||
|
entityNames: story.entityNames,
|
||||||
|
articleCount: story.articles?.length,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.logger.log(`Google Trends realtime: ${items.length} items`);
|
||||||
|
return items;
|
||||||
|
} catch (error) {
|
||||||
|
// realTimeTrends không phải lúc nào cũng available cho mọi geo
|
||||||
|
this.logger.warn(`Realtime trends not available: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tra cứu interest cho 1 keyword cụ thể — dùng cho scoring
|
||||||
|
*/
|
||||||
|
async getInterestScore(keyword: string, googleTrendsGeo: string = 'JP'): Promise<number> {
|
||||||
|
try {
|
||||||
|
const result = await googleTrends.interestByRegion({
|
||||||
|
keyword,
|
||||||
|
startTime: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||||
|
geo: googleTrendsGeo,
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
const timeline = parsed.default?.timelineData || [];
|
||||||
|
if (timeline.length === 0) return 0;
|
||||||
|
|
||||||
|
return timeline[timeline.length - 1].value?.[0] || 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTraffic(formatted: string): number {
|
||||||
|
// "200K+" → 200000, "1M+" → 1000000
|
||||||
|
const clean = formatted.replace(/[+,]/g, '').trim();
|
||||||
|
const match = clean.match(/^(\d+(?:\.\d+)?)\s*(K|M|B)?$/i);
|
||||||
|
if (!match) return 0;
|
||||||
|
|
||||||
|
const num = parseFloat(match[1]);
|
||||||
|
const unit = (match[2] || '').toUpperCase();
|
||||||
|
const multipliers: Record<string, number> = {K: 1000, M: 1000000, B: 1000000000};
|
||||||
|
return Math.round(num * (multipliers[unit] || 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateScore(traffic: number, index: number): number {
|
||||||
|
// Kết hợp traffic volume + ranking position
|
||||||
|
const trafficScore = traffic > 0 ? Math.min(Math.log10(traffic) * 15, 60) : 30;
|
||||||
|
const positionScore = Math.max(40 - index * 3, 0);
|
||||||
|
return Math.min(Math.round(trafficScore + positionScore), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectCategory(trend: any): string {
|
||||||
|
// Basic category detection dựa trên articles
|
||||||
|
const sources = (trend.articles || [])
|
||||||
|
.map((a: any) => (a.source || '').toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
if (/sport|bóng|goal|fifa|nfl|nba/i.test(sources)) return 'sports';
|
||||||
|
if (/tech|crypto|ai|apple|google|meta/i.test(sources)) return 'tech';
|
||||||
|
if (/politic|chính trị|quốc hội/i.test(sources)) return 'politics';
|
||||||
|
return 'general';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { TrendItem, CollectorResult } from '../../common/interfaces/trend-item.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HackerNewsService {
|
||||||
|
private readonly logger = new Logger(HackerNewsService.name);
|
||||||
|
private readonly baseUrl = 'https://hacker-news.firebaseio.com/v0';
|
||||||
|
private readonly maxItems: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.maxItems = this.configService.get<number>(
|
||||||
|
'collector.maxItemsPerSource',
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect(): Promise<CollectorResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const items: TrendItem[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Thu thập từ cả 3 endpoint
|
||||||
|
const endpoints = [
|
||||||
|
{ name: 'top', fn: () => this.getStories('topstories') },
|
||||||
|
{ name: 'best', fn: () => this.getStories('beststories') },
|
||||||
|
{ name: 'new-hot', fn: () => this.getNewHotStories() },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const ep of endpoints) {
|
||||||
|
try {
|
||||||
|
const stories = await ep.fn();
|
||||||
|
items.push(...stories);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`${ep.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup bởi URL (vì top/best có thể overlap)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped = items.filter((item) => {
|
||||||
|
if (seen.has(item.url)) return false;
|
||||||
|
seen.add(item.url);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`HackerNews collected: ${deduped.length} items`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'hackernews',
|
||||||
|
items: deduped,
|
||||||
|
collectedAt: new Date(),
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
error: errors.length > 0 ? errors.join('; ') : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy stories từ endpoint (topstories / beststories / newstories)
|
||||||
|
* 100% FREE, UNLIMITED, không cần API key
|
||||||
|
*/
|
||||||
|
private async getStories(
|
||||||
|
endpoint: string,
|
||||||
|
limit?: number,
|
||||||
|
): Promise<TrendItem[]> {
|
||||||
|
const effectiveLimit = limit || this.maxItems;
|
||||||
|
|
||||||
|
// 1. Lấy danh sách IDs
|
||||||
|
const { data: storyIds } = await firstValueFrom(
|
||||||
|
this.httpService.get<number[]>(`${this.baseUrl}/${endpoint}.json`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const topIds = storyIds.slice(0, effectiveLimit);
|
||||||
|
|
||||||
|
// 2. Lấy chi tiết song song (batch 10 để tránh quá nhiều concurrent)
|
||||||
|
const stories: TrendItem[] = [];
|
||||||
|
const batchSize = 10;
|
||||||
|
|
||||||
|
for (let i = 0; i < topIds.length; i += batchSize) {
|
||||||
|
const batch = topIds.slice(i, i + batchSize);
|
||||||
|
const batchResults = await Promise.allSettled(
|
||||||
|
batch.map((id) => this.getStoryDetail(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const result of batchResults) {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
stories.push({
|
||||||
|
...result.value,
|
||||||
|
// Score dựa trên position trong list
|
||||||
|
score: Math.max(
|
||||||
|
result.value.score,
|
||||||
|
100 - Math.round((stories.length / effectiveLimit) * 80),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stories;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lọc new stories có engagement cao (rising trends)
|
||||||
|
*/
|
||||||
|
private async getNewHotStories(): Promise<TrendItem[]> {
|
||||||
|
const { data: newIds } = await firstValueFrom(
|
||||||
|
this.httpService.get<number[]>(`${this.baseUrl}/newstories.json`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lấy 50 bài mới nhất, filter lấy những bài nào có điểm cao
|
||||||
|
const topNewIds = newIds.slice(0, 50);
|
||||||
|
const batchSize = 10;
|
||||||
|
const hotNew: TrendItem[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < topNewIds.length; i += batchSize) {
|
||||||
|
const batch = topNewIds.slice(i, i + batchSize);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map((id) => this.getStoryDetail(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
const item = result.value;
|
||||||
|
// Chỉ lấy những bài mới nhưng đã có engagement
|
||||||
|
const points = item.engagement?.upvotes || 0;
|
||||||
|
const comments = item.engagement?.comments || 0;
|
||||||
|
if (points >= 5 || comments >= 3) {
|
||||||
|
hotNew.push({
|
||||||
|
...item,
|
||||||
|
source: 'hackernews:rising',
|
||||||
|
// Rising trend bonus
|
||||||
|
score: Math.min(item.score + 15, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hotNew;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStoryDetail(id: number): Promise<TrendItem | null> {
|
||||||
|
const { data } = await firstValueFrom(
|
||||||
|
this.httpService.get(`${this.baseUrl}/item/${id}.json`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data || data.deleted || data.dead) return null;
|
||||||
|
|
||||||
|
const points = data.score || 0;
|
||||||
|
const comments = data.descendants || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'hackernews',
|
||||||
|
title: data.title || '',
|
||||||
|
description: data.text
|
||||||
|
? data.text.replace(/<[^>]*>/g, '').substring(0, 500)
|
||||||
|
: '',
|
||||||
|
url: data.url || `https://news.ycombinator.com/item?id=${id}`,
|
||||||
|
score: this.calculateScore(points, comments),
|
||||||
|
timestamp: new Date((data.time || 0) * 1000),
|
||||||
|
category: 'tech',
|
||||||
|
tags: this.extractDomain(data.url),
|
||||||
|
engagement: {
|
||||||
|
upvotes: points,
|
||||||
|
comments,
|
||||||
|
},
|
||||||
|
raw: {
|
||||||
|
hnId: id,
|
||||||
|
by: data.by,
|
||||||
|
type: data.type,
|
||||||
|
domain: data.url ? new URL(data.url).hostname : 'news.ycombinator.com',
|
||||||
|
hnUrl: `https://news.ycombinator.com/item?id=${id}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateScore(points: number, comments: number): number {
|
||||||
|
const pointScore = Math.log10(Math.max(points, 1)) * 20;
|
||||||
|
const commentScore = Math.log10(Math.max(comments, 1)) * 10;
|
||||||
|
return Math.min(Math.round(pointScore + commentScore), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractDomain(url?: string): string[] {
|
||||||
|
if (!url) return [];
|
||||||
|
try {
|
||||||
|
const hostname = new URL(url).hostname.replace('www.', '');
|
||||||
|
return [hostname];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { TrendItem, CollectorResult } from '../../common/interfaces/trend-item.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NewsApiService {
|
||||||
|
private readonly logger = new Logger(NewsApiService.name);
|
||||||
|
private readonly baseUrl = 'https://newsapi.org/v2';
|
||||||
|
private readonly apiKey: string;
|
||||||
|
|
||||||
|
// Track daily usage (free = 100 req/day)
|
||||||
|
private dailyRequestCount = 0;
|
||||||
|
private lastResetDate = new Date().toDateString();
|
||||||
|
private readonly DAILY_LIMIT = 90; // Buffer 10 cho safety
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.apiKey = this.configService.get<string>('newsapi.key', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect(): Promise<CollectorResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
if (!this.apiKey) {
|
||||||
|
return {
|
||||||
|
source: 'newsapi',
|
||||||
|
items: [],
|
||||||
|
collectedAt: new Date(),
|
||||||
|
durationMs: 0,
|
||||||
|
error: 'NEWSAPI_KEY not configured — skipping',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.canMakeRequest()) {
|
||||||
|
return {
|
||||||
|
source: 'newsapi',
|
||||||
|
items: [],
|
||||||
|
collectedAt: new Date(),
|
||||||
|
durationMs: 0,
|
||||||
|
error: `Daily limit reached (${this.dailyRequestCount}/${this.DAILY_LIMIT})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: TrendItem[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 1. Top headlines (1 request)
|
||||||
|
try {
|
||||||
|
const headlines = await this.getTopHeadlines();
|
||||||
|
items.push(...headlines);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`headlines: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Top headlines cho VN (1 request)
|
||||||
|
try {
|
||||||
|
const vnHeadlines = await this.getTopHeadlines('vn');
|
||||||
|
items.push(...vnHeadlines);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`headlines-vn: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`NewsAPI collected: ${items.length} items (${this.dailyRequestCount}/${this.DAILY_LIMIT} daily requests used)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'newsapi',
|
||||||
|
items,
|
||||||
|
collectedAt: new Date(),
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
error: errors.length > 0 ? errors.join('; ') : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top Headlines — 1 request mỗi lần gọi
|
||||||
|
* Free tier: 100 requests/day
|
||||||
|
*/
|
||||||
|
private async getTopHeadlines(country?: string): Promise<TrendItem[]> {
|
||||||
|
this.incrementRequestCount();
|
||||||
|
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
pageSize: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (country) {
|
||||||
|
params.country = country;
|
||||||
|
} else {
|
||||||
|
// Nếu không chỉ định country, lấy theo category
|
||||||
|
params.language = 'en';
|
||||||
|
params.category = 'technology';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await firstValueFrom(
|
||||||
|
this.httpService.get(`${this.baseUrl}/top-headlines`, { params }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.status !== 'ok') {
|
||||||
|
throw new Error(`NewsAPI error: ${data.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data.articles || []).map((article: any, index: number) => ({
|
||||||
|
source: `newsapi${country ? `:${country}` : ''}`,
|
||||||
|
title: article.title || '',
|
||||||
|
description: article.description || '',
|
||||||
|
url: article.url || '',
|
||||||
|
score: this.calculateScore(index, article),
|
||||||
|
timestamp: article.publishedAt
|
||||||
|
? new Date(article.publishedAt)
|
||||||
|
: new Date(),
|
||||||
|
category: country === 'vn' ? 'vietnam' : 'tech',
|
||||||
|
tags: [article.source?.name].filter(Boolean),
|
||||||
|
engagement: {},
|
||||||
|
raw: {
|
||||||
|
sourceName: article.source?.name,
|
||||||
|
sourceId: article.source?.id,
|
||||||
|
author: article.author,
|
||||||
|
urlToImage: article.urlToImage,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateScore(index: number, article: any): number {
|
||||||
|
const positionScore = Math.max(70 - index * 3, 10);
|
||||||
|
// Boost nếu từ nguồn uy tín
|
||||||
|
const topSources = [
|
||||||
|
'bbc-news', 'cnn', 'reuters', 'the-verge',
|
||||||
|
'techcrunch', 'bloomberg', 'associated-press',
|
||||||
|
];
|
||||||
|
const sourceBonus = topSources.includes(article.source?.id) ? 15 : 0;
|
||||||
|
|
||||||
|
return Math.min(positionScore + sourceBonus, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private canMakeRequest(): boolean {
|
||||||
|
this.resetDailyCountIfNeeded();
|
||||||
|
return this.dailyRequestCount < this.DAILY_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementRequestCount(): void {
|
||||||
|
this.resetDailyCountIfNeeded();
|
||||||
|
this.dailyRequestCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetDailyCountIfNeeded(): void {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
if (today !== this.lastResetDate) {
|
||||||
|
this.dailyRequestCount = 0;
|
||||||
|
this.lastResetDate = today;
|
||||||
|
this.logger.log('NewsAPI daily request counter reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemainingRequests(): number {
|
||||||
|
this.resetDailyCountIfNeeded();
|
||||||
|
return Math.max(this.DAILY_LIMIT - this.dailyRequestCount, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { TrendItem, CollectorResult } from '../../common/interfaces/trend-item.interface';
|
||||||
|
|
||||||
|
// Các subreddit phổ biến theo category
|
||||||
|
const SUBREDDIT_MAP: Record<string, string[]> = {
|
||||||
|
general: ['popular', 'all'],
|
||||||
|
tech: ['technology', 'programming', 'webdev', 'artificial', 'MachineLearning'],
|
||||||
|
news: ['worldnews', 'news', 'UpliftingNews'],
|
||||||
|
science: ['science', 'space', 'Futurology'],
|
||||||
|
business: ['business', 'Economics', 'stocks', 'CryptoCurrency'],
|
||||||
|
vietnam: ['VietNam', 'vietnam'],
|
||||||
|
japan: ['Japan', 'japanese'],
|
||||||
|
entertainment: ['movies', 'gaming', 'Music'],
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedditService {
|
||||||
|
private readonly logger = new Logger(RedditService.name);
|
||||||
|
private readonly baseUrl = 'https://www.reddit.com';
|
||||||
|
private readonly userAgent: string;
|
||||||
|
private readonly maxItems: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.userAgent = this.configService.get<string>(
|
||||||
|
'collector.redditUserAgent',
|
||||||
|
'TrendHunter/1.0',
|
||||||
|
);
|
||||||
|
this.maxItems = this.configService.get<number>(
|
||||||
|
'collector.maxItemsPerSource',
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect(
|
||||||
|
categories: string[] = ['general', 'tech', 'news'],
|
||||||
|
): Promise<CollectorResult> {
|
||||||
|
this.logger.debug('RedditService collect ...');
|
||||||
|
const start = Date.now();
|
||||||
|
const allItems: TrendItem[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Thu thập từ mỗi category
|
||||||
|
const subreddits = categories.flatMap((cat) => SUBREDDIT_MAP[cat] || []);
|
||||||
|
const uniqueSubreddits = [...new Set(subreddits)];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
uniqueSubreddits.map((sub) => this.getHotPosts(sub)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const result = results[i];
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
allItems.push(...result.value);
|
||||||
|
} else {
|
||||||
|
errors.push(`r/${uniqueSubreddits[i]}: ${result.reason?.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting: Reddit cho phép ~60 req/phút cho unauthenticated
|
||||||
|
// Thêm delay nhỏ giữa mỗi request
|
||||||
|
if (i < results.length - 1) {
|
||||||
|
await this.sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Reddit collected: ${allItems.length} items from ${uniqueSubreddits.length} subreddits`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'reddit',
|
||||||
|
items: allItems,
|
||||||
|
collectedAt: new Date(),
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
error: errors.length > 0 ? errors.join('; ') : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy hot posts từ 1 subreddit
|
||||||
|
* Dùng .json endpoint — KHÔNG CẦN API KEY
|
||||||
|
*/
|
||||||
|
private async getHotPosts(
|
||||||
|
subreddit: string,
|
||||||
|
limit: number = 15,
|
||||||
|
): Promise<TrendItem[]> {
|
||||||
|
const { data } = await firstValueFrom(
|
||||||
|
this.httpService.get(`${this.baseUrl}/r/${subreddit}/hot.json`, {
|
||||||
|
params: {
|
||||||
|
limit: Math.min(limit, this.maxItems),
|
||||||
|
raw_json: 1,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'User-Agent': this.userAgent,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const posts = data?.data?.children || [];
|
||||||
|
|
||||||
|
return posts
|
||||||
|
.filter((post: any) => {
|
||||||
|
// Bỏ stickied posts (thường là rules/announcements)
|
||||||
|
return !post.data.stickied && post.data.title;
|
||||||
|
})
|
||||||
|
.map((post: any) => {
|
||||||
|
const d = post.data;
|
||||||
|
return {
|
||||||
|
source: `reddit:r/${subreddit}`,
|
||||||
|
title: d.title,
|
||||||
|
description: this.buildDescription(d),
|
||||||
|
url: d.url_overridden_by_dest || `https://reddit.com${d.permalink}`,
|
||||||
|
score: this.normalizeScore(d.score, d.num_comments, d.upvote_ratio),
|
||||||
|
timestamp: new Date(d.created_utc * 1000),
|
||||||
|
category: this.mapSubredditToCategory(subreddit),
|
||||||
|
tags: [
|
||||||
|
subreddit,
|
||||||
|
d.link_flair_text,
|
||||||
|
].filter(Boolean),
|
||||||
|
engagement: {
|
||||||
|
upvotes: d.score,
|
||||||
|
comments: d.num_comments,
|
||||||
|
shares: d.num_crossposts || 0,
|
||||||
|
},
|
||||||
|
raw: {
|
||||||
|
subreddit: d.subreddit,
|
||||||
|
permalink: d.permalink,
|
||||||
|
upvoteRatio: d.upvote_ratio,
|
||||||
|
isOriginalContent: d.is_original_content,
|
||||||
|
flair: d.link_flair_text,
|
||||||
|
awards: d.total_awards_received,
|
||||||
|
thumbnail: d.thumbnail,
|
||||||
|
domain: d.domain,
|
||||||
|
},
|
||||||
|
} as TrendItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDescription(postData: any): string {
|
||||||
|
if (postData.selftext) {
|
||||||
|
return postData.selftext.substring(0, 500);
|
||||||
|
}
|
||||||
|
if (postData.media?.reddit_video) {
|
||||||
|
return `[Video] ${postData.title}`;
|
||||||
|
}
|
||||||
|
if (postData.post_hint === 'image') {
|
||||||
|
return `[Image] ${postData.title}`;
|
||||||
|
}
|
||||||
|
return postData.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeScore(
|
||||||
|
upvotes: number,
|
||||||
|
comments: number,
|
||||||
|
upvoteRatio: number,
|
||||||
|
): number {
|
||||||
|
// Formula: engagement + controversy bonus
|
||||||
|
const engagement = Math.log10(Math.max(upvotes, 1)) * 12;
|
||||||
|
const commentBonus = Math.log10(Math.max(comments, 1)) * 8;
|
||||||
|
// Controversial posts (ratio ~0.5) get a boost
|
||||||
|
const controversyBonus = upvoteRatio < 0.7 ? 10 : 0;
|
||||||
|
|
||||||
|
return Math.min(
|
||||||
|
Math.round(engagement + commentBonus + controversyBonus),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapSubredditToCategory(subreddit: string): string {
|
||||||
|
for (const [category, subs] of Object.entries(SUBREDDIT_MAP)) {
|
||||||
|
if (subs.map((s) => s.toLowerCase()).includes(subreddit.toLowerCase())) {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'general';
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import {Injectable, Logger} from '@nestjs/common';
|
||||||
|
import Parser from 'rss-parser';
|
||||||
|
import {TrendItem, CollectorResult} from '../../common/interfaces/trend-item.interface';
|
||||||
|
|
||||||
|
interface FeedConfig {
|
||||||
|
url: string;
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
language: string;
|
||||||
|
priority: number; // 1=high, 5=low
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// 🆓 TẤT CẢ ĐỀU FREE — chỉ cần HTTP GET
|
||||||
|
// =========================================
|
||||||
|
const RSS_FEEDS: FeedConfig[] = [
|
||||||
|
//japan
|
||||||
|
{
|
||||||
|
url: 'https://newsonjapan.com/rss/top.xml',
|
||||||
|
category: 'japan',
|
||||||
|
language: 'jp',
|
||||||
|
priority: 1,
|
||||||
|
name: 'newsonjapan'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://www.nytimes.com/svc/collections/v1/publish/http://www.nytimes.com/topic/destination/japan/rss.xml',
|
||||||
|
category: 'japan',
|
||||||
|
language: 'jp',
|
||||||
|
priority: 1,
|
||||||
|
name: 'nytimes_japan'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://www3.nhk.or.jp/rss/news/cat0.xml',
|
||||||
|
category: 'japan',
|
||||||
|
language: 'jp',
|
||||||
|
priority: 1,
|
||||||
|
name: 'nhk',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://news.yahoo.co.jp/rss/topics/top-picks.xml',
|
||||||
|
category: 'japan',
|
||||||
|
language: 'jp',
|
||||||
|
priority: 1,
|
||||||
|
name: 'yahoo',
|
||||||
|
},
|
||||||
|
// // ── Vietnam ──
|
||||||
|
// {
|
||||||
|
// url: 'https://vnexpress.net/rss/tin-moi-nhat.rss',
|
||||||
|
// category: 'vietnam',
|
||||||
|
// name: 'VnExpress',
|
||||||
|
// language: 'vi',
|
||||||
|
// priority: 1,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// url: 'https://tuoitre.vn/rss/tin-moi-nhat.rss',
|
||||||
|
// category: 'vietnam',
|
||||||
|
// name: 'Tuổi Trẻ',
|
||||||
|
// language: 'vi',
|
||||||
|
// priority: 1,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// url: 'https://thanhnien.vn/rss/home.rss',
|
||||||
|
// category: 'vietnam',
|
||||||
|
// name: 'Thanh Niên',
|
||||||
|
// language: 'vi',
|
||||||
|
// priority: 2,
|
||||||
|
// },
|
||||||
|
// ── Tech ──
|
||||||
|
{
|
||||||
|
url: 'https://techcrunch.com/feed/',
|
||||||
|
category: 'tech',
|
||||||
|
name: 'TechCrunch',
|
||||||
|
language: 'en',
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://www.theverge.com/rss/index.xml',
|
||||||
|
category: 'tech',
|
||||||
|
name: 'The Verge',
|
||||||
|
language: 'en',
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://feeds.arstechnica.com/arstechnica/index',
|
||||||
|
category: 'tech',
|
||||||
|
name: 'Ars Technica',
|
||||||
|
language: 'en',
|
||||||
|
priority: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://www.wired.com/feed/rss',
|
||||||
|
category: 'tech',
|
||||||
|
name: 'Wired',
|
||||||
|
language: 'en',
|
||||||
|
priority: 2,
|
||||||
|
},
|
||||||
|
// ── World News ──
|
||||||
|
{
|
||||||
|
url: 'https://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
category: 'world',
|
||||||
|
name: 'BBC News',
|
||||||
|
language: 'en',
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml',
|
||||||
|
category: 'world',
|
||||||
|
name: 'NY Times',
|
||||||
|
language: 'en',
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://www.reuters.com/rssFeed/topNews',
|
||||||
|
category: 'world',
|
||||||
|
name: 'Reuters',
|
||||||
|
language: 'en',
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
// ── Science ──
|
||||||
|
{
|
||||||
|
url: 'https://www.sciencedaily.com/rss/all.xml',
|
||||||
|
category: 'science',
|
||||||
|
name: 'ScienceDaily',
|
||||||
|
language: 'en',
|
||||||
|
priority: 2,
|
||||||
|
},
|
||||||
|
// ── Business ──
|
||||||
|
{
|
||||||
|
url: 'https://feeds.bloomberg.com/markets/news.rss',
|
||||||
|
category: 'business',
|
||||||
|
name: 'Bloomberg',
|
||||||
|
language: 'en',
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
// ── AI / Dev ──
|
||||||
|
{
|
||||||
|
url: 'https://blog.google/innovation-and-ai/technology/ai/rss/',
|
||||||
|
category: 'tech',
|
||||||
|
name: 'Google AI Blog',
|
||||||
|
language: 'en',
|
||||||
|
priority: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://openai.com/news/rss.xml',
|
||||||
|
category: 'tech',
|
||||||
|
name: 'OpenAI Blog',
|
||||||
|
language: 'en',
|
||||||
|
priority: 2,
|
||||||
|
},
|
||||||
|
//google trend rss https://trends.google.com/trending/rss?geo=JP
|
||||||
|
// {
|
||||||
|
// url: 'https://trends.google.com/trending/rss?geo=JP',
|
||||||
|
// category: 'news',
|
||||||
|
// name: 'google_trends',
|
||||||
|
// language: 'japan',
|
||||||
|
// priority: 1,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// url: 'https://trends.google.com/trending/rss?geo=US',
|
||||||
|
// category: 'us',
|
||||||
|
// name: 'google_trends',
|
||||||
|
// language: 'en',
|
||||||
|
// priority: 1,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// url: 'https://trends.google.com/trending/rss?geo=KR',
|
||||||
|
// category: 'korean',
|
||||||
|
// name: 'google_trends',
|
||||||
|
// language: 'en',
|
||||||
|
// priority: 1,
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
// const RSS_FEEDS_2: FeedConfig[] = [
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// url: 'https://trends.google.com/trending/rss?geo=US',
|
||||||
|
// category: 'us',
|
||||||
|
// name: 'google_trends',
|
||||||
|
// language: 'en',
|
||||||
|
// priority: 1,
|
||||||
|
// },
|
||||||
|
// ];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RssService {
|
||||||
|
private readonly logger = new Logger(RssService.name);
|
||||||
|
private readonly parser: Parser;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.parser = new Parser({
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'TrendHunter RSS Reader/1.0',
|
||||||
|
Accept: 'application/rss+xml, application/xml, text/xml',
|
||||||
|
},
|
||||||
|
customFields: {
|
||||||
|
item: [
|
||||||
|
['media:content', 'mediaContent'],
|
||||||
|
['media:thumbnail', 'mediaThumbnail'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async collect(categories?: string[]): Promise<CollectorResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// Filter feeds theo category nếu có
|
||||||
|
const feeds = categories
|
||||||
|
? RSS_FEEDS.filter((f) => categories.includes(f.category))
|
||||||
|
: RSS_FEEDS;
|
||||||
|
|
||||||
|
// Parse tất cả feeds song song
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
feeds.map((feed) => this.parseFeed(feed)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const items: TrendItem[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
items.push(...result.value);
|
||||||
|
} else {
|
||||||
|
errors.push(`${feeds[index].name}: ${result.reason?.message}`);
|
||||||
|
this.logger.warn(
|
||||||
|
`RSS failed for ${feeds[index].name}: ${result.reason?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
||||||
|
this.logger.log(
|
||||||
|
`RSS collected: ${items.length} items from ${successCount}/${feeds.length} feeds`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'rss',
|
||||||
|
items,
|
||||||
|
collectedAt: new Date(),
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
error: errors.length > 0 ? errors.join('; ') : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parseFeed(feedConfig: FeedConfig): Promise<TrendItem[]> {
|
||||||
|
const feed = await this.parser.parseURL(feedConfig.url);
|
||||||
|
const feedItems = feed.items || [];
|
||||||
|
// console.log('feedItems', feedItems);
|
||||||
|
//check name='google_trends'
|
||||||
|
|
||||||
|
return feedItems.slice(0, 15).map((item, index) => {
|
||||||
|
const pubDate = item.pubDate ? new Date(item.pubDate) : new Date();
|
||||||
|
const recencyHours =
|
||||||
|
(Date.now() - pubDate.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: `rss:${feedConfig.name}`,
|
||||||
|
title: (item.title || '').trim(),
|
||||||
|
description: this.cleanHtml(
|
||||||
|
item.contentSnippet || item.content || '',
|
||||||
|
).substring(0, 500),
|
||||||
|
url: item.link || '',
|
||||||
|
score: this.calculateScore(index, feedConfig.priority, recencyHours),
|
||||||
|
timestamp: pubDate,
|
||||||
|
category: feedConfig.category,
|
||||||
|
tags: [
|
||||||
|
feedConfig.name,
|
||||||
|
feedConfig.language,
|
||||||
|
...(item.categories || []).slice(0, 3),
|
||||||
|
].filter(Boolean),
|
||||||
|
engagement: {},
|
||||||
|
raw: {
|
||||||
|
feedName: feedConfig.name,
|
||||||
|
feedUrl: feedConfig.url,
|
||||||
|
creator: item.creator || item['dc:creator'],
|
||||||
|
guid: item.guid,
|
||||||
|
categories: item.categories,
|
||||||
|
language: feedConfig.language,
|
||||||
|
},
|
||||||
|
} as TrendItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateScore(
|
||||||
|
position: number,
|
||||||
|
priority: number,
|
||||||
|
recencyHours: number,
|
||||||
|
): number {
|
||||||
|
// RSS không có engagement data → dùng position + recency + source priority
|
||||||
|
const positionScore = Math.max(50 - position * 4, 5);
|
||||||
|
const priorityBonus = (6 - priority) * 5; // priority 1 → +25, priority 5 → +5
|
||||||
|
const recencyBonus = recencyHours < 2 ? 20 : recencyHours < 6 ? 10 : 0;
|
||||||
|
|
||||||
|
return Math.min(positionScore + priorityBonus + recencyBonus, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import {OnWorkerEvent, Processor, WorkerHost} from "@nestjs/bullmq";
|
||||||
|
import {Job} from "bullmq";
|
||||||
|
import {PostCreateInput} from "../../generated/prisma/models/Post";
|
||||||
|
import {CommentWriterService} from "./services/comment-writer.service";
|
||||||
|
import {GenerateCommentDto} from "./dto/generate-comment.dto";
|
||||||
|
import {XReaderService} from "../x-reader/x-reader.service";
|
||||||
|
import {InjectBot} from "nestjs-telegraf";
|
||||||
|
import {Context, Telegraf} from "telegraf";
|
||||||
|
import {Logger} from "@nestjs/common";
|
||||||
|
import {QuoteWriterService} from "./services/quote-writer.service";
|
||||||
|
import {GenerateQuoteDto} from "./dto/generate-quote.dto";
|
||||||
|
import {TextUtil} from "../../common/utils/text.util";
|
||||||
|
import {isEmpty} from "lodash";
|
||||||
|
|
||||||
|
|
||||||
|
@Processor('comment_writer_queue')
|
||||||
|
export class CommentWriterProcessor extends WorkerHost {
|
||||||
|
private readonly logger = new Logger(CommentWriterProcessor.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly quoteWriterService: QuoteWriterService,
|
||||||
|
private readonly commentWriterService: CommentWriterService,
|
||||||
|
private readonly xreader: XReaderService,
|
||||||
|
@InjectBot() private readonly bot: Telegraf<Context>,
|
||||||
|
//@InjectQueue('comment_writer_completed_queue') private readonly aiCommentWriteCompletedQueue: Queue,
|
||||||
|
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(job: Job, token: string | undefined): Promise<any> {
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
style,
|
||||||
|
url,
|
||||||
|
quoteType,
|
||||||
|
quoteText,
|
||||||
|
language,
|
||||||
|
tone,
|
||||||
|
agle,
|
||||||
|
comtext,
|
||||||
|
telegramChatId,
|
||||||
|
} = job.data;
|
||||||
|
const topic = summary || title;
|
||||||
|
let pgPostCreateDto!: PostCreateInput;
|
||||||
|
|
||||||
|
console.log(`CommentWriterProcessor_processing_${job.name}`);
|
||||||
|
switch (job.name) {
|
||||||
|
case 'generate_comment_twitter': {
|
||||||
|
|
||||||
|
const xpost = await this.xreader.readXPost(url);
|
||||||
|
|
||||||
|
const dto: GenerateCommentDto = {
|
||||||
|
originalPost: xpost.text,
|
||||||
|
language
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiWriterResult={
|
||||||
|
// comment: cleaned,
|
||||||
|
// tokensUsed: res.tokensUsed,
|
||||||
|
// model: res.model,
|
||||||
|
// language: dto.language,
|
||||||
|
// }
|
||||||
|
// const aiWriterResult = {
|
||||||
|
// comment: "Greet",
|
||||||
|
// url
|
||||||
|
// }
|
||||||
|
const aiWriterResult = await this.commentWriterService.generateComment(dto);
|
||||||
|
|
||||||
|
this.logger.log({aiWriterResult});
|
||||||
|
|
||||||
|
// await this.aiCommentWriteCompletedQueue.add('generate_comment_completed', {
|
||||||
|
// //id: post.id,
|
||||||
|
// name: 'generate_comment_completed',
|
||||||
|
// needConfirm: 1,
|
||||||
|
// content: aiWriterResult.comment,
|
||||||
|
// url: url,
|
||||||
|
// }, {attempts: 1, backoff: 5000, removeOnComplete: true,});
|
||||||
|
|
||||||
|
await this.bot.telegram.sendMessage(telegramChatId, `
|
||||||
|
Đã viết reply xong ...\nmodel: ${aiWriterResult.model} - tokenUsed: ${aiWriterResult.tokensUsed}`);
|
||||||
|
// const _url = url.indexOf('?s=20') > -1 ? url : `${url}?s=20`;
|
||||||
|
// console.log({_url});
|
||||||
|
|
||||||
|
//tìm xem bài này có phải của tôi không
|
||||||
|
const X_USERS = process.env.TWITTER_USERNAMES!.split(',');
|
||||||
|
console.log({X_USERS});
|
||||||
|
const isMyPost = url.indexOf(process.env.TWITTER_USERNAMES) > -1
|
||||||
|
|
||||||
|
//await this.bot.telegram.sendMessage(telegramChatId || adminChatId, isMyPost ? 'Đây là bài của bạn, có thế gửi' : 'Có thế không gửi được')
|
||||||
|
await this.bot.telegram.sendMessage(telegramChatId,
|
||||||
|
`${aiWriterResult.comment}`,
|
||||||
|
{
|
||||||
|
parse_mode: 'Markdown',
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
...X_USERS.map((xuser) => {
|
||||||
|
return [{
|
||||||
|
text: `↗️X ${xuser}`,
|
||||||
|
callback_data: `publish-reply-twitter1_${xpost.tweetId}_${xuser}`
|
||||||
|
}];
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{text: "🗑️ Hủy bài", callback_data: `delete-reply_${xpost.tweetId}`}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// pgPostCreateDto = {
|
||||||
|
// title: aiWriterResult.topic,
|
||||||
|
// content: aiWriterResult.final,
|
||||||
|
// style: aiWriterResult.detectedStyle,
|
||||||
|
// status: 'pending',
|
||||||
|
// prompt: aiWriterResult.prompt,
|
||||||
|
// draft: aiWriterResult.draft,
|
||||||
|
// tokensUsed: aiWriterResult.tokensUsed,
|
||||||
|
// tone: aiWriterResult.detectedTone,
|
||||||
|
// model: aiWriterResult.model,
|
||||||
|
// }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'generate_comment_as_text_twitter': {
|
||||||
|
const {
|
||||||
|
tweetId
|
||||||
|
} = job.data;
|
||||||
|
const dto: GenerateCommentDto = {
|
||||||
|
originalPost: comtext,
|
||||||
|
language,
|
||||||
|
angle: agle,
|
||||||
|
tone: tone
|
||||||
|
|
||||||
|
}
|
||||||
|
const X_USERS = process.env.TWITTER_USERNAMES!.split(',');
|
||||||
|
console.log({X_USERS});
|
||||||
|
|
||||||
|
const aiWriterResult = await this.commentWriterService.generateComment(dto);
|
||||||
|
|
||||||
|
this.logger.log({aiWriterResult});
|
||||||
|
|
||||||
|
|
||||||
|
const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
|
||||||
|
await this.bot.telegram.sendMessage(telegramChatId || adminChatId, `
|
||||||
|
Đã viết reply xong ...\ntweetId=${tweetId}\nmodel: ${aiWriterResult.model} }
|
||||||
|
`);
|
||||||
|
|
||||||
|
await this.bot.telegram.sendMessage(telegramChatId || adminChatId,
|
||||||
|
`${aiWriterResult.comment}`,
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
...(!isEmpty(tweetId) ? X_USERS.map((xuser) => {
|
||||||
|
return [{
|
||||||
|
text: `↗️X ${xuser}`,
|
||||||
|
callback_data: `publish-reply-twitter_${tweetId}_${xuser}`
|
||||||
|
}];
|
||||||
|
}) : [
|
||||||
|
// {
|
||||||
|
// text: `Reply vào bài X`,
|
||||||
|
// callback_data: `publish-reply-twitter_${tweetId}_${xuser}`
|
||||||
|
// }
|
||||||
|
])
|
||||||
|
,
|
||||||
|
[
|
||||||
|
{text: "🗑️ Hủy bài", callback_data: `delete-reply_${tweetId}`}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// pgPostCreateDto = {
|
||||||
|
// title: aiWriterResult.topic,
|
||||||
|
// content: aiWriterResult.final,
|
||||||
|
// style: aiWriterResult.detectedStyle,
|
||||||
|
// status: 'pending',
|
||||||
|
// prompt: aiWriterResult.prompt,
|
||||||
|
// draft: aiWriterResult.draft,
|
||||||
|
// tokensUsed: aiWriterResult.tokensUsed,
|
||||||
|
// tone: aiWriterResult.detectedTone,
|
||||||
|
// model: aiWriterResult.model,
|
||||||
|
// }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'generate_quote_twitter': {
|
||||||
|
this.logger.debug('===>generate_quote_twitter:', url);
|
||||||
|
const xpost = await this.xreader.readXPost(url);
|
||||||
|
|
||||||
|
const originalAuthor = `${xpost.author} ${xpost.handle}`;
|
||||||
|
const dto: GenerateQuoteDto = {
|
||||||
|
originalPost: xpost.text,
|
||||||
|
originalAuthor,
|
||||||
|
language,
|
||||||
|
quoteType,
|
||||||
|
tweetId: xpost.tweetId,
|
||||||
|
}
|
||||||
|
await this.onHandleAiGenerateQuote(
|
||||||
|
dto,
|
||||||
|
false,
|
||||||
|
telegramChatId,
|
||||||
|
);
|
||||||
|
// const aiWriterResult = await this.quoteWriterService.generateQuote(dto);
|
||||||
|
//
|
||||||
|
// this.logger.log({aiWriterResult});
|
||||||
|
// const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
|
||||||
|
// await this.bot.telegram.sendMessage(adminChatId, `
|
||||||
|
// Đã viết quote xong ...\nmodel: ${aiWriterResult.model} - type: ${aiWriterResult.quoteType}
|
||||||
|
// `);
|
||||||
|
// const _url = url.indexOf('?s=') > -1 ? url : `${url}?s=20`;
|
||||||
|
|
||||||
|
//xoá url trong bài vì tốn 0,2$ cho bài có url
|
||||||
|
// let isSendSucceeded = true;
|
||||||
|
// const quoteCleanUrl = TextUtil.removeAllUrl(dto.originalPost)
|
||||||
|
// await this.bot.telegram.sendMessage(
|
||||||
|
// adminChatId,
|
||||||
|
// `${aiWriterResult.quote}\n\nQuote:"${quoteCleanUrl}\n\n${originalAuthor}"`,
|
||||||
|
// {
|
||||||
|
// // parse_mode: 'Markdown',
|
||||||
|
// reply_markup: {
|
||||||
|
// inline_keyboard: [
|
||||||
|
// [
|
||||||
|
// {text: "↗️X", callback_data: `publish-quote-twitter_${xpost.tweetId}`},
|
||||||
|
// {text: "🗑️ Hủy bài", callback_data: `delete-quote_${xpost.tweetId}`}
|
||||||
|
// ],
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// .catch(error => {
|
||||||
|
// console.log('==> send message to telegram error:' + error.message);
|
||||||
|
// console.error(error);
|
||||||
|
// isSendSucceeded = false;
|
||||||
|
// });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'generate_quote_twitter_as_text_input': {
|
||||||
|
const dto: GenerateQuoteDto = {
|
||||||
|
originalPost: quoteText,
|
||||||
|
originalAuthor: '',
|
||||||
|
language,
|
||||||
|
quoteType
|
||||||
|
}
|
||||||
|
await this.onHandleAiGenerateQuote(
|
||||||
|
dto,
|
||||||
|
false,
|
||||||
|
telegramChatId,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
//id: post.id,
|
||||||
|
// content: postContent,
|
||||||
|
//image: imageSuggestion,
|
||||||
|
status: 'ready_to_post'
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWorkerEvent('completed')
|
||||||
|
async onCompleted(job: Job<any>) {
|
||||||
|
console.log('CommentWriterProcessor_completed');
|
||||||
|
const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
|
||||||
|
const postId = job.returnvalue.id;
|
||||||
|
if (postId === 0) {
|
||||||
|
const topic = job.returnvalue.topic;
|
||||||
|
|
||||||
|
await this.bot.telegram.sendMessage(adminChatId, `Lỗi viết bài: ${topic}`);
|
||||||
|
} else {
|
||||||
|
//return job.returnvalue.topic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onHandleAiGenerateQuote(
|
||||||
|
dto: GenerateQuoteDto,
|
||||||
|
isAttachQuote = true,
|
||||||
|
telegramChatId: number = 0
|
||||||
|
) {
|
||||||
|
const aiWriterResult = await this.quoteWriterService.generateQuote(dto);
|
||||||
|
|
||||||
|
this.logger.log({aiWriterResult});
|
||||||
|
const sendId = telegramChatId || process.env.TELEGRAM_ADMIN_ID || 0;
|
||||||
|
await this.bot.telegram.sendMessage(sendId, `
|
||||||
|
Đã viết quote xong ...\nmodel: ${aiWriterResult.model} - type: ${aiWriterResult.quoteType}
|
||||||
|
`);
|
||||||
|
|
||||||
|
//
|
||||||
|
let finalQuote = aiWriterResult.quote;
|
||||||
|
if (isAttachQuote) {
|
||||||
|
finalQuote += `\n\nQuote:"${dto.originalPost}\n\n${dto.originalAuthor}`
|
||||||
|
}
|
||||||
|
//xoá url trong bài vì tốn 0,2$ cho bài có url
|
||||||
|
const finalQuoteCleanUrl = TextUtil.removeAllUrl(finalQuote)
|
||||||
|
|
||||||
|
const X_USERS = process.env.TWITTER_USERNAMES!.split(',');
|
||||||
|
|
||||||
|
await this.bot.telegram.sendMessage(
|
||||||
|
sendId,
|
||||||
|
finalQuoteCleanUrl,
|
||||||
|
{
|
||||||
|
// parse_mode: 'Markdown',
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
...X_USERS.map((xuser) => {
|
||||||
|
return [{
|
||||||
|
text: `↗️X ${xuser}`,
|
||||||
|
callback_data: `publish-quote-twitter_${dto.tweetId}_${xuser}`
|
||||||
|
}];
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{text: "🗑️ Hủy bài", callback_data: `delete-quote_${dto.tweetId}`}
|
||||||
|
],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.log('==> send message to telegram error:' + error.message);
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// config/platform-limits.ts
|
||||||
|
import { Platform } from '../enum/platform.enum';
|
||||||
|
import { AccountTier } from '../enum/account-tier.enum';
|
||||||
|
import { PostLength } from '../enum/post-length.enum';
|
||||||
|
|
||||||
|
export interface LengthRange {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
sweet: number; // target tối ưu cho engagement
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard limits theo platform + tier (characters)
|
||||||
|
*/
|
||||||
|
export const PLATFORM_LIMITS: Record<Platform, Record<AccountTier, number>> = {
|
||||||
|
[Platform.X]: {
|
||||||
|
[AccountTier.FREE]: 280,
|
||||||
|
[AccountTier.PREMIUM]: 25000,
|
||||||
|
[AccountTier.PREMIUM_PLUS]: 25000,
|
||||||
|
[AccountTier.VERIFIED_ORG]: 25000,
|
||||||
|
},
|
||||||
|
[Platform.FACEBOOK]: {
|
||||||
|
[AccountTier.FREE]: 63206,
|
||||||
|
[AccountTier.PREMIUM]: 63206,
|
||||||
|
[AccountTier.PREMIUM_PLUS]: 63206,
|
||||||
|
[AccountTier.VERIFIED_ORG]: 63206,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweet spot ranges theo PostLength.
|
||||||
|
* Dựa trên data engagement thực tế của X 2025-2026.
|
||||||
|
*/
|
||||||
|
export const LENGTH_RANGES: Record<PostLength, LengthRange> = {
|
||||||
|
[PostLength.SHORT]: { min: 180, max: 280, sweet: 210 },
|
||||||
|
[PostLength.MEDIUM]: { min: 200, max: 500, sweet: 320 },
|
||||||
|
[PostLength.LONG]: { min: 400, max: 1200, sweet: 600 },
|
||||||
|
[PostLength.EXTENDED]: { min: 1500, max: 3000, sweet: 2200 },
|
||||||
|
[PostLength.ARTICLE]: { min: 3000, max: 8000, sweet: 5000 },
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// content-writer.controller.ts
|
||||||
|
import {Body, Controller, Get, NotFoundException, Post, Query} from '@nestjs/common';
|
||||||
|
import {ContentWriterService} from './content-writer.service';
|
||||||
|
import {CommentWriterService} from './services/comment-writer.service';
|
||||||
|
import {GenerateContentDto} from './dto/generate-content.dto';
|
||||||
|
import {GenerateCommentDto} from './dto/generate-comment.dto';
|
||||||
|
import {IAIGrokProvider} from "./interfaces/ai-provider.interface";
|
||||||
|
|
||||||
|
@Controller('content-writer')
|
||||||
|
export class ContentWriterController {
|
||||||
|
constructor(
|
||||||
|
private readonly writerService: ContentWriterService,
|
||||||
|
private readonly commentService: CommentWriterService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('grok/test')
|
||||||
|
async grokWrite(@Query('q') q: string) {
|
||||||
|
const provider = await this.writerService.getGrokAI() as IAIGrokProvider;
|
||||||
|
if (!q) {
|
||||||
|
throw new NotFoundException('Not Found querystring.');
|
||||||
|
}
|
||||||
|
return provider.enrichXContext(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('generate')
|
||||||
|
generate(@Body() dto: GenerateContentDto) {
|
||||||
|
return this.writerService.generate(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('comment')
|
||||||
|
generateComment(@Body() dto: GenerateCommentDto) {
|
||||||
|
return this.commentService.generateComment(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('comment/variants')
|
||||||
|
generateCommentVariants(@Body() dto: GenerateCommentDto) {
|
||||||
|
// return this.commentService.generateVariants(dto, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {Global, Module} from '@nestjs/common';
|
||||||
|
import {AIService} from "../../shared/ai.service";
|
||||||
|
import {ContentWriterProcessor} from "./content.writer.processor";
|
||||||
|
import {PgPostService} from "../../shared/pg.post.service";
|
||||||
|
import {BullModule} from "@nestjs/bullmq";
|
||||||
|
import {PublishPageService} from "../social-api/publish.page.service";
|
||||||
|
import {SocialModule} from "../social-api/social.module";
|
||||||
|
import {ContentWriterController} from "./content-writer.controller";
|
||||||
|
import {AIProviderFactory} from "./providers/ai-provider.factory";
|
||||||
|
import {ContentWriterService} from "./content-writer.service";
|
||||||
|
import {StyleDetectorService} from "./services/style-detector.service";
|
||||||
|
import {PromptBuilderService} from "./services/prompt-builder.service";
|
||||||
|
import {ReviewerService} from "./services/reviewer.service";
|
||||||
|
import {OpenAIProvider} from "./providers/openai.provider";
|
||||||
|
import {DeepSeekProvider} from "./providers/deepseek.provider";
|
||||||
|
import {CommentWriterService} from "./services/comment-writer.service";
|
||||||
|
import {GrokProvider} from "./providers/grok.provider";
|
||||||
|
import {ProviderRouterService} from "./services/provider-router.service";
|
||||||
|
import {CommentWriterProcessor} from "./comment-writer.processor";
|
||||||
|
import {XReaderService} from "../x-reader/x-reader.service";
|
||||||
|
import {LengthStrategyService} from "./services/length-strategy.service";
|
||||||
|
import {QuoteWriterService} from "./services/quote-writer.service";
|
||||||
|
import {SqsPostService} from "../sqs-module/sqs.post.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
BullModule.registerQueue(
|
||||||
|
{name: 'content_writer_completed_queue'},// Hàng đợi cho AI-C
|
||||||
|
),
|
||||||
|
SocialModule
|
||||||
|
],
|
||||||
|
controllers: [ContentWriterController],
|
||||||
|
providers: [
|
||||||
|
AIService,
|
||||||
|
PgPostService,
|
||||||
|
ContentWriterProcessor,
|
||||||
|
ContentWriterService,
|
||||||
|
CommentWriterService,
|
||||||
|
StyleDetectorService,
|
||||||
|
PromptBuilderService,
|
||||||
|
ReviewerService,
|
||||||
|
OpenAIProvider,
|
||||||
|
DeepSeekProvider,
|
||||||
|
GrokProvider,
|
||||||
|
AIProviderFactory,
|
||||||
|
ProviderRouterService,
|
||||||
|
CommentWriterProcessor,
|
||||||
|
XReaderService,
|
||||||
|
LengthStrategyService,
|
||||||
|
QuoteWriterService,
|
||||||
|
SqsPostService
|
||||||
|
],
|
||||||
|
exports: [GrokProvider, ContentWriterService],
|
||||||
|
|
||||||
|
})
|
||||||
|
export class ContentWriterModule {
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// content-writer.service.ts
|
||||||
|
import {BadRequestException, Injectable, Logger} from '@nestjs/common';
|
||||||
|
import {GenerateContentDto} from './dto/generate-content.dto';
|
||||||
|
import {ContentResponseDto} from './dto/content-response.dto';
|
||||||
|
import {StyleDetectorService} from './services/style-detector.service';
|
||||||
|
import {PromptBuilderService} from './services/prompt-builder.service';
|
||||||
|
import {ReviewerService} from './services/reviewer.service';
|
||||||
|
import {AIProviderFactory, ProviderName} from './providers/ai-provider.factory';
|
||||||
|
import {Platform} from "./enum/platform.enum";
|
||||||
|
import {ProviderRouterService} from "./services/provider-router.service";
|
||||||
|
import {Language} from "../../common/interfaces/language.prompt.interface";
|
||||||
|
import {WriterPromptParams} from "./interfaces/writer-prompt-params.interface";
|
||||||
|
import {AccountTier} from "./enum/account-tier.enum";
|
||||||
|
import {ConfigService} from "@nestjs/config";
|
||||||
|
import {LengthStrategyService} from "./services/length-strategy.service";
|
||||||
|
import {calculateTokenBudget} from "../../common/utils/token-calculator";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContentWriterService {
|
||||||
|
private readonly logger = new Logger(ContentWriterService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private detector: StyleDetectorService,
|
||||||
|
private promptBuilder: PromptBuilderService,
|
||||||
|
private reviewer: ReviewerService,
|
||||||
|
private factory: AIProviderFactory,
|
||||||
|
private router: ProviderRouterService,
|
||||||
|
private lengthStrategy: LengthStrategyService, // 👈 mới
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGrokAI() {
|
||||||
|
return this.factory.get('grok');
|
||||||
|
}
|
||||||
|
|
||||||
|
async generate(
|
||||||
|
dto: GenerateContentDto,
|
||||||
|
isForceManualProvider: boolean = false,
|
||||||
|
writerProvider: ProviderName = 'openai',
|
||||||
|
reviewerProvider: ProviderName = 'deepseek',
|
||||||
|
): Promise<ContentResponseDto> {
|
||||||
|
// 1. Detect style/tone nếu user không truyền (0 token)
|
||||||
|
const style = dto.style ?? this.detector.detectStyle(dto.topic);
|
||||||
|
const tone = dto.tone ?? this.detector.detectTone(dto.topic);
|
||||||
|
const platform = dto.platform ? dto.platform : Platform.X;
|
||||||
|
const language = (dto.language ?? 'en') as Language;
|
||||||
|
|
||||||
|
// Default tier từ env nếu user không pass
|
||||||
|
const tier = dto.accountTier ?? this.configService.get<AccountTier>(
|
||||||
|
'X_ACCOUNT_TIER',
|
||||||
|
AccountTier.PREMIUM,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 📏 Decide length strategy
|
||||||
|
const lengthDecision = this.lengthStrategy.decide({
|
||||||
|
platform: dto.platform || Platform.X,
|
||||||
|
tier,
|
||||||
|
style,
|
||||||
|
tone,
|
||||||
|
requestedLength: dto.postLength,
|
||||||
|
});
|
||||||
|
this.logger.debug(`>>> style:${style} - tone:${tone} - pf:${platform} -lang:${language} - tier:${tier}`);
|
||||||
|
this.logger.log(`Length: ${lengthDecision.reason}`);
|
||||||
|
|
||||||
|
// 💰 Token budget
|
||||||
|
const budget = calculateTokenBudget(lengthDecision.range, language);
|
||||||
|
this.logger.log(`Budget: ${budget.minChars}-${budget.maxChars} chars, ${budget.maxTokens} tokens`);
|
||||||
|
|
||||||
|
// 🧭 Smart routing
|
||||||
|
const decision = this.router.route({
|
||||||
|
language,
|
||||||
|
contentType: 'post',
|
||||||
|
style,
|
||||||
|
tone,
|
||||||
|
});
|
||||||
|
this.logger.log(`ContentWriterService => Routing: ${decision.reason} - writer:${decision.writer} - reviewer:${decision.reviewer}`);
|
||||||
|
|
||||||
|
const ctx: WriterPromptParams = {
|
||||||
|
topic: dto.topic,
|
||||||
|
platform,
|
||||||
|
style,
|
||||||
|
tone,
|
||||||
|
language,
|
||||||
|
extraInstructions: dto.extraInstructions,
|
||||||
|
postLength: lengthDecision.postLength, // 👈 pass xuống
|
||||||
|
lengthRange: lengthDecision.range,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🌐 Optional: Grok enriches X context (chỉ dùng cho EN breaking)
|
||||||
|
let enrichedTopic = dto.topic;
|
||||||
|
let enrichmentTokens = 0;
|
||||||
|
if (dto.useXEnrichment || decision.useXEnrichment) {
|
||||||
|
this.logger.log(`==> Prepare X-AI enrich topic: ${enrichedTopic}`);
|
||||||
|
try {
|
||||||
|
const grok = this.factory.getGrok();
|
||||||
|
const xContext = await grok.enrichXContext(dto.topic);
|
||||||
|
enrichedTopic = `${dto.topic}\n\n[X Context]:\n${xContext}`;
|
||||||
|
this.logger.log(`===> X enrichment: ${xContext}`);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn('===> X enrichment failed, proceeding without', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✍️ Writer
|
||||||
|
console.log('==> ContentWriterService_write: ');
|
||||||
|
|
||||||
|
const provider = this.factory.get(isForceManualProvider ? writerProvider : decision.writer);
|
||||||
|
const messages = this.promptBuilder.buildWriterMessages({
|
||||||
|
...ctx,
|
||||||
|
topic: enrichedTopic
|
||||||
|
});
|
||||||
|
// console.debug('prompt message:==>', messages);
|
||||||
|
const draft = await provider.complete(messages, {
|
||||||
|
temperature: 0.75,
|
||||||
|
maxTokens: budget.maxTokens,
|
||||||
|
});
|
||||||
|
this.logger.debug(`===> ${draft.model} đã viết xong!`);
|
||||||
|
|
||||||
|
let final = draft.content;
|
||||||
|
let totalTokens = draft.tokensUsed + enrichmentTokens;
|
||||||
|
let reviewNotes: string | undefined;
|
||||||
|
let modelUsed = draft.model;
|
||||||
|
|
||||||
|
// 🔍 Reviewer
|
||||||
|
if (dto.enableReview) {
|
||||||
|
this.logger.debug(`===> chuẩn bị review`);
|
||||||
|
try {
|
||||||
|
const reviewed = await this.reviewer.review(
|
||||||
|
draft.content,
|
||||||
|
ctx,
|
||||||
|
isForceManualProvider ? reviewerProvider : decision.reviewer,
|
||||||
|
dto.topic,
|
||||||
|
Math.ceil(budget.maxTokens * 1.3),
|
||||||
|
);
|
||||||
|
final = reviewed.improved;
|
||||||
|
reviewNotes = reviewed.notes;
|
||||||
|
totalTokens += reviewed.tokensUsed;
|
||||||
|
modelUsed = `${draft.model} + ${reviewed.model}`;
|
||||||
|
this.logger.debug(`===> ${reviewed.model} đã review xong!`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Review failed, fallback to draft', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛡️ Hard cap check
|
||||||
|
if (final.length > lengthDecision.hardLimit) {
|
||||||
|
this.logger.warn(`==> Output exceeds hard limit (${final.length} > ${lengthDecision.hardLimit})`);
|
||||||
|
// final = final.substring(0, lengthDecision.hardLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if ([ContentStyle.FINANCE, ContentStyle.CRYPTO].includes(ctx.style)) {
|
||||||
|
// final += `\n ⚠️ This content is for informational purposes only, not financial advice. DYOR. \n`
|
||||||
|
// }
|
||||||
|
return {
|
||||||
|
topic: dto.topic,
|
||||||
|
final,
|
||||||
|
draft: dto.enableReview ? draft.content : undefined,
|
||||||
|
reviewNotes,
|
||||||
|
detectedStyle: style,
|
||||||
|
detectedTone: tone,
|
||||||
|
tokensUsed: totalTokens,
|
||||||
|
model: modelUsed,
|
||||||
|
prompt: JSON.stringify(messages),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// src/modules/writer/facebook.processor.ts
|
||||||
|
import {InjectQueue, OnWorkerEvent, Processor, WorkerHost} from '@nestjs/bullmq';
|
||||||
|
import {Job, Queue} from 'bullmq';
|
||||||
|
import {AIService} from '../../shared/ai.service';
|
||||||
|
import {PgPostService} from "../../shared/pg.post.service";
|
||||||
|
import {isEmpty} from "lodash";
|
||||||
|
import {ContentWriterService} from "./content-writer.service";
|
||||||
|
import {GenerateContentDto} from "./dto/generate-content.dto";
|
||||||
|
import {PostCreateInput} from "../../generated/prisma/models/Post";
|
||||||
|
import {InjectBot} from "nestjs-telegraf";
|
||||||
|
import {Context, Telegraf} from "telegraf";
|
||||||
|
import {StyleDetectorService} from "./services/style-detector.service";
|
||||||
|
import {ContentStyle} from "./enum/style.enum";
|
||||||
|
import {ContentTone} from "./enum/tone.enum";
|
||||||
|
import {XStrategy} from "../social-api/x-router.service";
|
||||||
|
import {SqsPostService} from "../sqs-module/sqs.post.service";
|
||||||
|
|
||||||
|
@Processor('content_writer_queue')
|
||||||
|
export class ContentWriterProcessor extends WorkerHost {
|
||||||
|
constructor(
|
||||||
|
private aiService: AIService,
|
||||||
|
private readonly writerService: ContentWriterService,
|
||||||
|
private readonly styleDetectorService: StyleDetectorService,
|
||||||
|
private readonly pgPostService: PgPostService,
|
||||||
|
@InjectBot() private readonly bot: Telegraf<Context>,
|
||||||
|
// private readonly managerService: ManagerService,
|
||||||
|
@InjectQueue('content_writer_completed_queue') private readonly fbContentCompletedQueue: Queue,
|
||||||
|
private readonly sqsPostService: SqsPostService,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(job: Job<any>): Promise<any> {
|
||||||
|
const {title, summary, style, language, tone, enableReview, postLength, autoPublish, telegramChatId} = job.data;
|
||||||
|
const topic = summary || title;
|
||||||
|
let pgPostCreateDto!: PostCreateInput;
|
||||||
|
|
||||||
|
console.log(`ContentWriterProcessor_processing_${job.name}`);
|
||||||
|
let isAutoPublish = false;
|
||||||
|
switch (job.name) {
|
||||||
|
case 'generate_post_ver2': {
|
||||||
|
const dto: GenerateContentDto = {
|
||||||
|
topic,
|
||||||
|
enableReview,
|
||||||
|
language,
|
||||||
|
tone,
|
||||||
|
postLength
|
||||||
|
}
|
||||||
|
const aiWriterResult = await this.writerService.generate(dto, false, 'openai', 'deepseek');
|
||||||
|
// console.log({aiWriterResult});
|
||||||
|
pgPostCreateDto = {
|
||||||
|
title: aiWriterResult.topic,
|
||||||
|
content: aiWriterResult.final,
|
||||||
|
style: aiWriterResult.detectedStyle,
|
||||||
|
status: 'pending',
|
||||||
|
prompt: aiWriterResult.prompt,
|
||||||
|
draft: aiWriterResult.draft,
|
||||||
|
tokensUsed: aiWriterResult.tokensUsed,
|
||||||
|
tone: aiWriterResult.detectedTone,
|
||||||
|
model: aiWriterResult.model,
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'generate_post_ver1': {
|
||||||
|
const aiWriterResult = await this.aiService.generateContentViaDeepseek(
|
||||||
|
summary || title,
|
||||||
|
style,
|
||||||
|
language
|
||||||
|
);
|
||||||
|
|
||||||
|
pgPostCreateDto = {
|
||||||
|
title: aiWriterResult.topic,
|
||||||
|
content: aiWriterResult.final,
|
||||||
|
style: aiWriterResult.detectedStyle,
|
||||||
|
status: 'pending',
|
||||||
|
prompt: aiWriterResult.prompt,
|
||||||
|
tone: aiWriterResult.detectedTone,
|
||||||
|
model: aiWriterResult.model,
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'generate_post_telegram': {
|
||||||
|
isAutoPublish = true;
|
||||||
|
const topicLen = topic.length;
|
||||||
|
console.log({topicLen});
|
||||||
|
const dto: GenerateContentDto = {
|
||||||
|
topic,
|
||||||
|
enableReview,
|
||||||
|
language: this.styleDetectorService.detectLanguageFromTelegramAutoContent(topic),
|
||||||
|
tone: topicLen < 150 ? ContentTone.URGENT : tone,
|
||||||
|
postLength,
|
||||||
|
style: topicLen < 150 ? ContentStyle.BREAKING_NEWS : style
|
||||||
|
}
|
||||||
|
const aiWriterResult = await this.writerService.generate(dto, false);
|
||||||
|
// console.log({aiWriterResult});
|
||||||
|
pgPostCreateDto = {
|
||||||
|
title: aiWriterResult.topic,
|
||||||
|
content: aiWriterResult.final,
|
||||||
|
style: aiWriterResult.detectedStyle,
|
||||||
|
status: 'pending',
|
||||||
|
prompt: aiWriterResult.prompt,
|
||||||
|
draft: aiWriterResult.draft,
|
||||||
|
tokensUsed: aiWriterResult.tokensUsed,
|
||||||
|
tone: aiWriterResult.detectedTone,
|
||||||
|
model: aiWriterResult.model,
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'generate_post_telegram_batch': {
|
||||||
|
isAutoPublish = true;
|
||||||
|
// return ;
|
||||||
|
const {messages} = job.data;
|
||||||
|
let compose_topic = `Viết 1 thread Twitter/X ngắn gọn tổng hợp ${messages.length} tin sau:\n`
|
||||||
|
compose_topic += messages.map(m => '- ' + m.text).join('\n');
|
||||||
|
const topicLen = compose_topic.length;
|
||||||
|
console.log({compose_topic});
|
||||||
|
const dto: GenerateContentDto = {
|
||||||
|
topic: compose_topic,
|
||||||
|
enableReview: false,
|
||||||
|
language: this.styleDetectorService.detectLanguageFromTelegramAutoContent(compose_topic),
|
||||||
|
tone: ContentTone.CASUAL,
|
||||||
|
postLength,
|
||||||
|
style: ContentStyle.BREAKING_NEWS
|
||||||
|
}
|
||||||
|
const aiWriterResult = await this.writerService.generate(dto, false);
|
||||||
|
// console.log({aiWriterResult});
|
||||||
|
pgPostCreateDto = {
|
||||||
|
title: aiWriterResult.topic,
|
||||||
|
content: aiWriterResult.final,
|
||||||
|
style: aiWriterResult.detectedStyle,
|
||||||
|
status: 'pending',
|
||||||
|
prompt: aiWriterResult.prompt,
|
||||||
|
draft: aiWriterResult.draft,
|
||||||
|
tokensUsed: aiWriterResult.tokensUsed,
|
||||||
|
tone: aiWriterResult.detectedTone,
|
||||||
|
model: aiWriterResult.model,
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log({pgPostCreateDto});
|
||||||
|
|
||||||
|
if (isEmpty(pgPostCreateDto)) {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
topic,
|
||||||
|
'status': 'error',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// 2. Giả lập việc tạo ảnh hoặc tìm ảnh minh họa (có thể tích hợp API sau)
|
||||||
|
// const imageSuggestion = `https://image-service.com/search?q=${keywords[0]}`;
|
||||||
|
//
|
||||||
|
|
||||||
|
// let finalContent = aiWriterResult.content;
|
||||||
|
const post = await this.pgPostService.createPost(pgPostCreateDto);
|
||||||
|
if (!isAutoPublish) {
|
||||||
|
await this.fbContentCompletedQueue.add('generate_post_completed', {
|
||||||
|
id: post.id,
|
||||||
|
name: 'generate_post_completed',
|
||||||
|
needConfirm: 1,
|
||||||
|
content: pgPostCreateDto.content,
|
||||||
|
autoPublish: false,
|
||||||
|
telegramChatId,
|
||||||
|
xSubmitProvider: post.id % 2 === 0 ? XStrategy.BROWSER_ONLY : XStrategy.API_ONLY, //cứ 3post api, có 1 post browser
|
||||||
|
|
||||||
|
}, {attempts: 1, backoff: 5000, removeOnComplete: true,});
|
||||||
|
} else {
|
||||||
|
await this.sqsPostService.postFlashKaze({
|
||||||
|
id: post.id,
|
||||||
|
name: 'generate_post_completed',
|
||||||
|
type: 'X_POSTER_TWEET',
|
||||||
|
needConfirm: 1,
|
||||||
|
content: pgPostCreateDto.content,
|
||||||
|
autoPublish,
|
||||||
|
telegramChatId,
|
||||||
|
publishTo: ['x', 'fb'],
|
||||||
|
xSubmitProvider: post.id % 3 === 0 ? XStrategy.API_FIRST : XStrategy.BROWSER_ONLY, //cứ 3post api, có 1 post browser
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: post.id,
|
||||||
|
// content: postContent,
|
||||||
|
//image: imageSuggestion,
|
||||||
|
status: 'ready_to_post',
|
||||||
|
telegramChatId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWorkerEvent('completed')
|
||||||
|
async onCompleted(job: Job<any>) {
|
||||||
|
console.log('ContentWriterProcessor_completed');
|
||||||
|
const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
|
||||||
|
const postId = job.returnvalue.id;
|
||||||
|
const telegramChatId = job.returnvalue.telegramChatId;
|
||||||
|
if (postId === 0) {
|
||||||
|
const topic = job.returnvalue.topic;
|
||||||
|
|
||||||
|
await this.bot.telegram.sendMessage(telegramChatId || adminChatId, `Lỗi viết bài: ${topic}`);
|
||||||
|
} else {
|
||||||
|
//return job.returnvalue.topic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// dto/content-response.dto.ts
|
||||||
|
export class ContentResponseDto {
|
||||||
|
topic: string;
|
||||||
|
final: string;
|
||||||
|
draft?: string;
|
||||||
|
reviewNotes?: string;
|
||||||
|
detectedStyle: string;
|
||||||
|
detectedTone: string;
|
||||||
|
tokensUsed: number;
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// dto/generate-comment.dto.ts
|
||||||
|
import { IsEnum, IsString, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
import { ContentTone } from '../enum/tone.enum';
|
||||||
|
import * as languagePromptInterface from "../../../common/interfaces/language.prompt.interface";
|
||||||
|
|
||||||
|
export class GenerateCommentDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3000)
|
||||||
|
originalPost: string; // nội dung bài X gốc
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
angle?: string; // góc nhìn muốn comment: "agree", "challenge", "add-info", "funny"
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
language: languagePromptInterface.Language;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContentTone)
|
||||||
|
tone?: ContentTone;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
persona?: string; // "crypto trader", "news analyst"...
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// dto/generate-content.dto.ts
|
||||||
|
import { IsEnum, IsOptional, IsString, IsBoolean, MaxLength } from 'class-validator';
|
||||||
|
import {ContentStyle} from "../enum/style.enum";
|
||||||
|
import {Platform} from "../enum/platform.enum";
|
||||||
|
import {ContentTone} from "../enum/tone.enum";
|
||||||
|
import {AccountTier} from "../enum/account-tier.enum";
|
||||||
|
import {PostLength} from "../enum/post-length.enum";
|
||||||
|
|
||||||
|
export class GenerateContentDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5000)
|
||||||
|
topic: string; // chủ đề / input thô từ user
|
||||||
|
|
||||||
|
@IsEnum(Platform)
|
||||||
|
platform?: Platform;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AccountTier)
|
||||||
|
accountTier?: AccountTier; // 👈 mới
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(PostLength)
|
||||||
|
postLength?: PostLength; // 👈 user override length
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContentStyle)
|
||||||
|
style?: ContentStyle; // nếu không truyền -> auto-detect
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContentTone)
|
||||||
|
tone?: ContentTone;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
language?: string; // 'vi' | 'en' ... default 'en'
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enableReview?: boolean; // bật AI reviewer
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
useXEnrichment?: boolean; // bật X Enrichment reviewer
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
extraInstructions?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// dto/generate-quote.dto.ts
|
||||||
|
import { IsEnum, IsString, IsOptional, MaxLength, IsBoolean } from 'class-validator';
|
||||||
|
import { QuoteType } from '../enum/quote-type.enum';
|
||||||
|
import { ContentTone } from '../enum/tone.enum';
|
||||||
|
import { PostLength } from '../enum/post-length.enum';
|
||||||
|
import { AccountTier } from '../enum/account-tier.enum';
|
||||||
|
|
||||||
|
export class GenerateQuoteDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5000)
|
||||||
|
originalPost: string; // Tweet gốc bạn muốn quote
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
originalAuthor?: string; // username của OP (optional, giúp AI biết tone)
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(QuoteType)
|
||||||
|
quoteType?: QuoteType; // Nếu không truyền -> AI tự chọn best fit
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
language: 'en' | 'vi' | 'ja' | 'ko';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContentTone)
|
||||||
|
tone?: ContentTone;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(PostLength)
|
||||||
|
postLength?: PostLength; // short/medium/long (Premium)
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AccountTier)
|
||||||
|
accountTier?: AccountTier;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
persona?: string; // "crypto analyst", "tech journalist"...
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
yourAngle?: string; // Góc nhìn riêng của bạn muốn express
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enableReview?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
tweetId?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum AccountTier {
|
||||||
|
FREE = 'free',
|
||||||
|
PREMIUM = 'premium', // $8/month
|
||||||
|
PREMIUM_PLUS = 'premium_plus', // $16/month
|
||||||
|
VERIFIED_ORG = 'verified_org',
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export enum AngleEnum {
|
||||||
|
AGREE = 'agree',
|
||||||
|
CHALLENGE = 'challenge',
|
||||||
|
ADD_INFO = 'add_info',
|
||||||
|
FUNNY = 'funny',
|
||||||
|
QUESTION = 'question',
|
||||||
|
RELATE = 'relate',
|
||||||
|
DEVIL_ADVOCATE = 'devil_advocate',
|
||||||
|
EXPAND = 'expand',
|
||||||
|
VALIDATE = 'validate',
|
||||||
|
CTA = 'cta'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ANGLE_HINTS: Record<AngleEnum, string> = {
|
||||||
|
[AngleEnum.AGREE]: 'I agree and would like to add a small point to support my argument.',
|
||||||
|
[AngleEnum.CHALLENGE]: 'Disagree or add further nuance',
|
||||||
|
[AngleEnum.ADD_INFO]: 'additional useful related information',
|
||||||
|
[AngleEnum.FUNNY]: 'Witty, mildly humorous, and not offensive.',
|
||||||
|
[AngleEnum.QUESTION]: 'Ask a smart follow-up question',
|
||||||
|
[AngleEnum.RELATE]: 'Share a personal experience or feeling that mirrors the original post',
|
||||||
|
[AngleEnum.DEVIL_ADVOCATE]: `Play devil's advocate. Present the opposite view fairly without being hostile`,
|
||||||
|
[AngleEnum.EXPAND]: 'Take one point from the post and zoom in deeper with more nuance',
|
||||||
|
[AngleEnum.VALIDATE]: `Affirm the post's point with evidence or strong agreement, boost credibility`,
|
||||||
|
[AngleEnum.CTA]: 'End with a soft call-to-action: ask others to share their view'
|
||||||
|
}
|
||||||
|
export const ANGLE_HINTS_TELEGRAM_BUTTON: Record<AngleEnum, Object> = {
|
||||||
|
[AngleEnum.AGREE]: {text: 'Đồng ý'},
|
||||||
|
[AngleEnum.CHALLENGE]: {text: 'Không đồng ý'},
|
||||||
|
[AngleEnum.ADD_INFO]: {text: 'thêm thông tin liên quan hữu ích'},
|
||||||
|
[AngleEnum.FUNNY]: {text: 'Hóm hỉnh, hài hước nhẹ nhàng, không gây khó chịu'},
|
||||||
|
[AngleEnum.QUESTION]: {text: 'Đặt một câu hỏi tiếp theo thông minh'},
|
||||||
|
[AngleEnum.RELATE]: {text: 'Chia sẻ một trải nghiệm \n hoặc cảm xúc cá nhân tương tự như bài đăng gốc.'},
|
||||||
|
[AngleEnum.DEVIL_ADVOCATE]: {
|
||||||
|
text: `Hãy đóng vai trò người phản biện. \n Trình bày quan điểm trái chiều một cách công bằng mà không tỏ ra thù địch.`
|
||||||
|
},
|
||||||
|
[AngleEnum.EXPAND]: {text: 'expand-Chọn một điểm từ bài viết và phân tích sâu hơn với nhiều sắc thái khác nhau.'},
|
||||||
|
[AngleEnum.VALIDATE]: {
|
||||||
|
text: `validate-Khẳng định luận điểm của bài đăng bằng bằng chứng hoặc sự đồng tình mạnh mẽ, tăng cường độ tin cậy.`
|
||||||
|
},
|
||||||
|
[AngleEnum.CTA]: {text: 'cta-Kết thúc bằng lời kêu gọi hành động nhẹ nhàng'}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
// enums/platform.enum.ts
|
||||||
|
export enum Platform {
|
||||||
|
X = 'x',
|
||||||
|
FACEBOOK = 'facebook',
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export enum PostLength {
|
||||||
|
SHORT = 'short', // 200-280 chars - viral/breaking
|
||||||
|
MEDIUM = 'medium', // 280-500 chars - hot take
|
||||||
|
LONG = 'long', // 400-1200 chars - analysis (Premium sweet spot)
|
||||||
|
EXTENDED = 'extended', // 1500-3000 chars - deep dive
|
||||||
|
ARTICLE = 'article', // 3000-10000 chars - full essay
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// enums/quote-type.enum.ts
|
||||||
|
export enum QuoteType {
|
||||||
|
AGREE_AMPLIFY = 'agree_amplify', // Đồng ý + thêm insight
|
||||||
|
DISAGREE = 'disagree', // Phản biện có lý
|
||||||
|
ADD_CONTEXT = 'add_context', // Bổ sung context
|
||||||
|
REFRAME = 'reframe', // Nhìn góc khác
|
||||||
|
BUILD_ON = 'build_on', // Mở rộng ý
|
||||||
|
HIGHLIGHT = 'highlight', // Nhấn mạnh key point
|
||||||
|
ROAST = 'roast', // Chỉ trích sắc
|
||||||
|
HOT_TAKE = 'hot_take', // Opinion mạnh
|
||||||
|
QUESTION = 'question', // Đặt câu hỏi
|
||||||
|
SUMMARIZE = 'summarize', // TL;DR
|
||||||
|
PERSONAL_STORY = 'personal_story', // TL;DR
|
||||||
|
CONNECT_DOTS = 'connect_dot', // TL;DR
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// enums/style.enum.ts
|
||||||
|
export enum ContentStyle {
|
||||||
|
CRYPTO = 'crypto',
|
||||||
|
BREAKING_NEWS = 'breaking_news',
|
||||||
|
TECH = 'tech',
|
||||||
|
FINANCE = 'finance',
|
||||||
|
LIFESTYLE = 'lifestyle',
|
||||||
|
MEME = 'meme',
|
||||||
|
EDUCATIONAL = 'educational',
|
||||||
|
GENERAL = 'general',
|
||||||
|
OPINION = 'opinion',
|
||||||
|
STORYTELLING = 'storytelling',
|
||||||
|
THREAD = 'thread',
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export enum ContentTone {
|
||||||
|
PROFESSIONAL = 'professional',
|
||||||
|
CASUAL = 'casual',
|
||||||
|
HYPE = 'hype',
|
||||||
|
URGENT = 'urgent',
|
||||||
|
HUMOROUS = 'humorous',
|
||||||
|
INFORMATIVE = 'informative',
|
||||||
|
EMPATHETIC = 'empathetic',
|
||||||
|
PROVOCATIVE = 'provocative',
|
||||||
|
AUTHORITATIVE = 'authoritative',
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// interfaces/ai-provider.interface.ts
|
||||||
|
import {Context} from "telegraf";
|
||||||
|
|
||||||
|
export interface AIMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AICompletionOptions {
|
||||||
|
model?: string;
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AICompletionResult {
|
||||||
|
content: string;
|
||||||
|
tokensUsed: number;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAIProvider {
|
||||||
|
readonly name: string;
|
||||||
|
complete(messages: AIMessage[], options?: AICompletionOptions): Promise<AICompletionResult>;
|
||||||
|
}
|
||||||
|
export interface IAIGrokProvider extends IAIProvider {
|
||||||
|
enrichXContext(topic: string): Promise<string>
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// interfaces/content-context.interface.ts
|
||||||
|
|
||||||
|
import {Platform} from "../enum/platform.enum";
|
||||||
|
import {ContentStyle} from "../enum/style.enum";
|
||||||
|
import {ContentTone} from "../enum/tone.enum";
|
||||||
|
import {Language} from "../../../common/interfaces/language.prompt.interface";
|
||||||
|
|
||||||
|
export interface ContentContext {
|
||||||
|
topic: string;
|
||||||
|
platform: Platform;
|
||||||
|
style: ContentStyle;
|
||||||
|
tone: ContentTone;
|
||||||
|
language: Language;
|
||||||
|
extraInstructions?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {Platform} from "../enum/platform.enum";
|
||||||
|
import {ContentStyle} from "../enum/style.enum";
|
||||||
|
import {ContentTone} from "../enum/tone.enum";
|
||||||
|
import {Language} from "../../../common/interfaces/language.prompt.interface";
|
||||||
|
import {LengthRange} from "../config/platform-limits";
|
||||||
|
import {PostLength} from "../enum/post-length.enum";
|
||||||
|
|
||||||
|
|
||||||
|
export interface WriterPromptParams {
|
||||||
|
topic: string;
|
||||||
|
platform: Platform;
|
||||||
|
style: ContentStyle;
|
||||||
|
tone: ContentTone;
|
||||||
|
language: Language;
|
||||||
|
postLength: PostLength; // 👈 mới
|
||||||
|
lengthRange: LengthRange;
|
||||||
|
extraInstructions?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// prompts/breaking-news.templates.ts
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// BREAKING NEWS — native templates per language
|
||||||
|
// ============================================================
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// prompts/comment.templates.ts
|
||||||
|
|
||||||
|
import {Language} from "../../../common/interfaces/language.prompt.interface";
|
||||||
|
import {calculateLengthBudget} from "../../../common/utils/token-calculator";
|
||||||
|
import {Platform} from "../enum/platform.enum";
|
||||||
|
import {ANGLE_HINTS} from "../enum/angle.enum";
|
||||||
|
|
||||||
|
export const COMMENT_SYSTEM_PROMPTS = {
|
||||||
|
en: 'You write casual, human replies on X. Sound like a real person, NOT AI. No hashtags unless natural.',
|
||||||
|
vi: 'Bạn viết reply tự nhiên trên X như người thật. Không giống AI. Không hashtag nếu không cần.',
|
||||||
|
vn: 'Bạn viết reply tự nhiên trên X như người thật. Không giống AI. Không hashtag nếu không cần.',
|
||||||
|
cn: '像真实用户一样在X上自然地回复,不要显得像AI生成的。除非必要,否则不要使用话题标签(#)。',
|
||||||
|
ja: 'Xで人間らしい返信を書きます。AI臭くない。自然な口語。ハッシュタグは不要な限り使わない。',
|
||||||
|
jp: 'Xで人間らしい返信を書きます。AI臭くない。自然な口語。ハッシュタグは不要な限り使わない。',
|
||||||
|
ko: 'X에서 사람처럼 자연스럽게 답글을 씁니다. AI 같지 않게. 해시태그는 필요한 경우만.',
|
||||||
|
kr: 'X에서 사람처럼 자연스럽게 답글을 씁니다. AI 같지 않게. 해시태그는 필요한 경우만.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// export const COMMENT_AGLE_TELEGRAM_BUTTONS = {
|
||||||
|
// agree: {text: 'Đồng ý và bổ sung thêm một điểm hỗ trợ nhỏ.'},
|
||||||
|
// challenge: {text: 'Lịch sự bày tỏ sự KHÔNG ĐỒNG Ý hoặc bổ sung thêm sắc thái.'},
|
||||||
|
// 'add-info': {text: 'Thêm thông tin liên quan hữu ích'},
|
||||||
|
// funny: {text: 'Hài hước dí dỏm, nhẹ nhàng, không gây khó chịu.'},
|
||||||
|
// question: {text: 'Hãy đặt một câu hỏi tiếp theo thông minh.'},
|
||||||
|
// }
|
||||||
|
|
||||||
|
export function buildCommentPrompt(params: {
|
||||||
|
originalPost: string;
|
||||||
|
angle?: string;
|
||||||
|
language: Language;
|
||||||
|
persona?: string;
|
||||||
|
tone?: string;
|
||||||
|
}): { system: string; user: string } {
|
||||||
|
// const angleHints: Record<string, string> = {
|
||||||
|
// agree: 'agree:Đồng ý và bổ sung thêm một luận điểm nhỏ để hỗ trợ',
|
||||||
|
// challenge: 'challenge:Không đồng ý hoặc bổ sung thêm sắc thái',
|
||||||
|
// 'add-info': 'add-info:Thêm thông tin liên quan hữu ích',
|
||||||
|
// funny: 'funny:Hóm hỉnh, hài hước nhẹ nhàng, không gây khó chịu',
|
||||||
|
// question: 'question:Đặt một câu hỏi tiếp theo thông minh',
|
||||||
|
// };
|
||||||
|
const budget = calculateLengthBudget(Platform.X, params.language);
|
||||||
|
|
||||||
|
const user = [
|
||||||
|
`Original X post:\n"""${params.originalPost}"""`,
|
||||||
|
``,
|
||||||
|
`Write a reply target length: ${budget.minChars}-${budget.maxChars} characters:`,
|
||||||
|
`[Target Language: ${params.language}]
|
||||||
|
Rewrite strictly in ${params.language} only.`,
|
||||||
|
params.angle ? `- Angle: ${ANGLE_HINTS[params.angle] ?? params.angle}` : '- Angle: natural reaction',
|
||||||
|
params.persona ? `- Speak as: ${params.persona}` : '',
|
||||||
|
params.tone ? `- Tone: ${params.tone}` : '- Tone: casual, conversational',
|
||||||
|
`- Sound HUMAN, not AI. No "Great post!" openings.`,
|
||||||
|
`- No emoji spam. 0-1 emoji max.`,
|
||||||
|
`- Output ONLY the reply text.`,
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
return {system: COMMENT_SYSTEM_PROMPTS[params.language], user};
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// // prompts/edgy-tones.ts — UPDATE phần JP
|
||||||
|
//
|
||||||
|
// import {ContentTone} from "../enum/tone.enum";
|
||||||
|
//
|
||||||
|
// export const EDGY_TONE_SPECS: Record<ContentTone, ToneSpec> = {
|
||||||
|
// [ContentTone.SPICY]: {
|
||||||
|
// intensity: 2,
|
||||||
|
// description: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// // 👇 REFINED JP
|
||||||
|
// ja: [
|
||||||
|
// 'ストレートで歯に衣着せない。鋭いが冷静。',
|
||||||
|
// '軽い悪態OK:「は?」「いや無理」「マジで?」「草」',
|
||||||
|
// 'JPのX的に:堅い敬語を使わず、話し言葉ベース。改行を効かせる。',
|
||||||
|
// '「w」「草」を末尾に使ってOK(やりすぎ注意、1-2回まで)。',
|
||||||
|
// '感情的にキレるのではなく、淡々と切るイメージ。',
|
||||||
|
// ].join(' '),
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// examples: {
|
||||||
|
// en: ['...'],
|
||||||
|
// vi: ['...'],
|
||||||
|
// // 👇 REFINED JP — real JP X patterns
|
||||||
|
// ja: [
|
||||||
|
// 'いやこれ違うやろ\n\nデータちゃんと見た?普通に逆の話してるんやが',
|
||||||
|
// 'は?このチャートで強気とか草\n\nさすがに無理があるって',
|
||||||
|
// 'まあ気持ちは分かるけど、これは普通にナンピン地獄コースやで',
|
||||||
|
// ],
|
||||||
|
// ko: ['...'],
|
||||||
|
// },
|
||||||
|
// vocabulary: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// // 👇 REFINED JP
|
||||||
|
// ja: 'は?/いや/マジで/普通に/ガチで/草/w/無理/えぐい/やばい',
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// avoid: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: [
|
||||||
|
// '丁寧な敬語禁止(「〜と思います」「〜でしょうか」NG)',
|
||||||
|
// '個人攻撃禁止(一般人ターゲットNG)',
|
||||||
|
// '差別語・脅迫NG',
|
||||||
|
// '「!」連発禁止',
|
||||||
|
// ].join(' / '),
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
//
|
||||||
|
// [ContentTone.AGGRESSIVE]: {
|
||||||
|
// intensity: 3,
|
||||||
|
// description: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// // 👇 REFINED JP
|
||||||
|
// ja: [
|
||||||
|
// '粗野で生々しい、主張をガチで叩く。バカな意見を嘲笑する。',
|
||||||
|
// '強めの悪態OK:「クソ」「ふざけんな」「アホか」「は?マジで言ってる?」',
|
||||||
|
// '関西弁ミックスもOK(「なんやねん」「あほちゃう」「言うてるやろ」)— ナチュラルなら。',
|
||||||
|
// '感情的になりすぎず、論破口調をベースに。',
|
||||||
|
// '攻撃対象:主張・意見・市場・公人の公的行為。私人NG。',
|
||||||
|
// ].join(' '),
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// examples: {
|
||||||
|
// en: ['...'],
|
||||||
|
// vi: ['...'],
|
||||||
|
// ja: [
|
||||||
|
// 'は?マジで言ってる?\n\nそのロジックでよく投稿ボタン押せたな、感心するわ',
|
||||||
|
// 'いや待って、これ今週見た中で一番アホな分析やんけ\n\n根拠ゼロで断言するの草',
|
||||||
|
// 'ふざけんなよ、3日前に同じこと言ったやろ。\n誰も聞かんかったから今こうなってるんやで',
|
||||||
|
// ],
|
||||||
|
// ko: ['...'],
|
||||||
|
// },
|
||||||
|
// vocabulary: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: 'クソ/ふざけんな/アホか/は?/マジで/いい加減にしろ/寝言/なんやねん/養分/ピエロ',
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// avoid: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: '差別語NG/脅迫NG/私人攻撃NG/本物の侮辱罪リスク回避(公的主張のみ叩く)',
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
//
|
||||||
|
// [ContentTone.PROFANE]: {
|
||||||
|
// intensity: 4,
|
||||||
|
// description: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: [
|
||||||
|
// '荒っぽい、フィルター無し、悪態多め。熱くなったトレーダー・配信者風。',
|
||||||
|
// '激しい悪態OK:「クソが」「マジでクソ」「ふざけんなクソ」「は?クソが」',
|
||||||
|
// '感情がガチで出てる感じ。ただし支離滅裂にはしない。',
|
||||||
|
// 'JPのX的に:「www」連発、「草不可避」「クソ草」OK。',
|
||||||
|
// '対象:市場・主張・公人。私人NG。',
|
||||||
|
// ].join(' '),
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// examples: {
|
||||||
|
// en: ['...'],
|
||||||
|
// vi: ['...'],
|
||||||
|
// ja: [
|
||||||
|
// 'クソが、この相場マジで何なんだよw\n\n3日前にロング切られて、今度はショート狩られる。地獄かよ',
|
||||||
|
// 'いやマジでふざけんな、このプロジェクト\n\nリリース3回延期して、結局これ?クソ案件確定やん',
|
||||||
|
// 'は?クソが、また下げかよwww\n\nもう養分卒業したいんだが、神は俺を見捨てた',
|
||||||
|
// ],
|
||||||
|
// ko: ['...'],
|
||||||
|
// },
|
||||||
|
// vocabulary: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: 'クソ/クソが/クソ案件/ふざけんな/地獄/養分/死んだ/逝った/www/草不可避',
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// avoid: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: '差別語絶対NG/脅迫NG/私人特定NG/侮辱罪に該当する表現避ける',
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
//
|
||||||
|
// [ContentTone.INFLAMMATORY]: {
|
||||||
|
// intensity: 4,
|
||||||
|
// description: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: [
|
||||||
|
// '強い反応を引き出す設計。物議を醸す断言。',
|
||||||
|
// '両極化する言葉。当てこすり。炎上を生むが擁護可能。',
|
||||||
|
// 'JPのX的に:断言系(「結論:〜」「答え:〜」)、逆張り、リスト形式が伸びる。',
|
||||||
|
// '「〜してる奴は〜」「〜と言ってる時点で〜」のような決めつけ構文OK。',
|
||||||
|
// '挑発的≠根拠なし。根拠は持つこと。',
|
||||||
|
// ].join(' '),
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// examples: {
|
||||||
|
// en: ['...'],
|
||||||
|
// vi: ['...'],
|
||||||
|
// ja: [
|
||||||
|
// '結論:2026年にまだ[X]ホールドしてる奴は、出口流動性です。\n\n認めたくないだろうけど、それが現実',
|
||||||
|
// 'これが物議を醸してる時点で、この界隈のレベルが分かる\n\n基本的なことを言ってるだけなのに',
|
||||||
|
// '「まだ早い」って言ってる人へ:\n\nもう遅いです。認めて次のサイクル準備した方が建設的',
|
||||||
|
// ],
|
||||||
|
// ko: ['...'],
|
||||||
|
// },
|
||||||
|
// vocabulary: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: '結論/答え/現実/養分/出口流動性/中級者の壁/NPC/弱者男性/情弱/w',
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// avoid: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: 'ヘイトスピーチNG/脅迫NG/差別語NG/挑発的でも法的に擁護可能な範囲で',
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
//
|
||||||
|
// [ContentTone.SAVAGE]: {
|
||||||
|
// intensity: 5,
|
||||||
|
// description: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: [
|
||||||
|
// '残虐ロースト・モード。外科的かつ機知に富んだ粉砕。',
|
||||||
|
// '悪態OK、最大限の毒舌。面白い+残酷+賢いの三位一体。',
|
||||||
|
// 'JPのX的に:「こいつ本当に〜」「よく〜できたな」「保存した」「伝説」系構文。',
|
||||||
|
// '直接の罵倒より、淡々とした観察口調の方が刺さる(JP特有)。',
|
||||||
|
// '「お兄さん、」「お疲れさまでした」などの慇懃無礼OK。',
|
||||||
|
// '対象:主張・公人の公的行為。私人・弱者NG(パンチアップのみ)。',
|
||||||
|
// ].join(' '),
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// examples: {
|
||||||
|
// en: ['...'],
|
||||||
|
// vi: ['...'],
|
||||||
|
// ja: [
|
||||||
|
// 'こいつ本当にこれを投稿して送信ボタン押したのか\n\n複数回。胸を張って。すごい才能だ',
|
||||||
|
// '次の「妄想の極致」スレッド用に保存させていただきました\n\n貴重なサンプルありがとうございます',
|
||||||
|
// 'お兄さん、自信と正確性の比率がバグってますよ\n\n一回深呼吸してチャート見直すといいかも(優しさ)',
|
||||||
|
// ],
|
||||||
|
// ko: ['...'],
|
||||||
|
// },
|
||||||
|
// vocabulary: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: 'お兄さん/こいつ/伝説/保存しました/お疲れさまでした/貴重なサンプル/才能/天才/中級者の壁',
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// avoid: {
|
||||||
|
// en: '...',
|
||||||
|
// vi: '...',
|
||||||
|
// ja: 'パンチアップのみ(公人・主張)/私人・弱者NG/差別語NG/脅迫NG/侮辱罪リスク回避',
|
||||||
|
// ko: '...',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// };
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// prompts/jp-cultural-context.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JP X (Twitter) culture context để inject vào prompts.
|
||||||
|
* Tách riêng vì sẽ refine nhiều theo thời gian.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const JP_X_CULTURE = {
|
||||||
|
/**
|
||||||
|
* Đặc trưng JP X writing style.
|
||||||
|
*/
|
||||||
|
styleNotes: [
|
||||||
|
'日本のX文化: 短文・改行多め・絵文字控えめ(過剰だと逆効果)',
|
||||||
|
'「〜だわ」「〜やん」「〜やろ」など話し言葉OK、堅すぎる文体は避ける',
|
||||||
|
'「w」「草」「www」を文末に使うのは自然(やりすぎ注意)',
|
||||||
|
'「マジで」「ガチで」「普通に」は強調表現として頻出',
|
||||||
|
'改行を効果的に使う — 長文1段落より、短く区切る方が読まれる',
|
||||||
|
'英語表現の直訳は避ける(「This is huge」→「これはデカい」より「やばい」「えぐい」)',
|
||||||
|
].join('\n'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phrases AI thường viết → người Nhật KHÔNG bao giờ viết.
|
||||||
|
* Cực kỳ quan trọng — đây là dead giveaway của AI output.
|
||||||
|
*/
|
||||||
|
aiPhrasesAvoid: [
|
||||||
|
'❌ 「〜だと思います」連発(フォーマルすぎ)',
|
||||||
|
'❌ 「以下のような〜」「上記の〜」(書き言葉すぎ)',
|
||||||
|
'❌ 「いかがでしょうか」(営業文っぽい)',
|
||||||
|
'❌ 「重要なポイントは〜」(教科書的)',
|
||||||
|
'❌ 「素晴らしい投稿ですね」(おべっか・AI臭)',
|
||||||
|
'❌ 「私の意見では」「個人的には〜」を文頭に毎回(くどい)',
|
||||||
|
'❌ 結論で「まとめると〜」(説明文っぽい)',
|
||||||
|
'❌ 「皆さんはどう思いますか?」(典型的なAI締め)',
|
||||||
|
'❌ 過剰な「!」連発',
|
||||||
|
'❌ 「〜することができます」(「〜できる」で十分)',
|
||||||
|
'❌ 礼儀正しすぎる敬語(Xでは浮く)',
|
||||||
|
].join('\n'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Natural JP X starters (theo tone).
|
||||||
|
*/
|
||||||
|
naturalStarters: {
|
||||||
|
casual: ['いやこれ', 'てかさ', 'これマジ', 'え、', 'ちょ、', 'なんか'],
|
||||||
|
spicy: ['いや無理', 'は?', 'おい', 'ちょっと待って', 'これ草'],
|
||||||
|
aggressive: ['は?マジで言ってる?', 'おい、', 'いやいや', 'なんやねん', '寝言は寝て言え'],
|
||||||
|
profane: ['くそが', 'ふざけんな', 'マジで草', 'いや無理だわ', 'なんやこれ'],
|
||||||
|
savage: ['よくこれ投稿できたな', 'こいつ本当に', '伝説の', '保存した', 'お兄さん、'],
|
||||||
|
professional: ['結論から言うと', '今回の件、', '事実として、', 'データを見る限り'],
|
||||||
|
informative: ['補足すると', '前提として', 'ポイントは', '実は'],
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Natural JP X endings.
|
||||||
|
*/
|
||||||
|
naturalEndings: {
|
||||||
|
casual: ['知らんけど', 'まあそんな感じ', 'って思う', 'ってわけ'],
|
||||||
|
spicy: ['', '草', 'マジで', 'ほんま'],
|
||||||
|
aggressive: ['ふざけんな', 'いい加減にしろ', 'マジでないわ', '冷静になれ'],
|
||||||
|
savage: ['草', 'お疲れさまでした', '永久保存版', '伝説残した'],
|
||||||
|
professional: ['以上', 'ご参考まで', ''],
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net slang JP X dùng.
|
||||||
|
*/
|
||||||
|
netSlang: {
|
||||||
|
agree: ['それな', 'わかる', 'ほんそれ', 'ガチで', '同意'],
|
||||||
|
disagree: ['は?', 'いや違うやろ', 'ないない', 'それはちゃう'],
|
||||||
|
laugh: ['草', 'w', 'www', '草生える', '笑う'],
|
||||||
|
surprise: ['えぐい', 'やばい', 'マジか', '嘘やろ', 'ガチ?'],
|
||||||
|
intensifier: ['マジで', 'ガチで', '普通に', 'えぐいぐらい', '異次元'],
|
||||||
|
crypto: ['爆益', '退場', '握力', '養分', 'ガチホ', '損切り', 'ATH', 'ATL', 'ガチ勢'],
|
||||||
|
finance: ['含み益', '含み損', '気絶', 'ナンピン', '逃げろ'],
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JP X engagement patterns — cái gì viral.
|
||||||
|
*/
|
||||||
|
engagementPatterns: [
|
||||||
|
'共感ポイント: 「あるある」「わかる」を引き出す',
|
||||||
|
'逆張り: 多数派と逆の意見(根拠あり)',
|
||||||
|
'断言: 「結論:〜」「答え:〜」明確に',
|
||||||
|
'具体性: 数字・固有名詞・具体例があると伸びる',
|
||||||
|
'リスト形式: 「3つの理由」「やってはいけない5選」',
|
||||||
|
'体験談フック: 「実際に〜してみた」',
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject vào prompt JP để cải thiện quality.
|
||||||
|
*/
|
||||||
|
export function getJpContextBlock(opts: {
|
||||||
|
includeStyleNotes?: boolean;
|
||||||
|
includeAvoid?: boolean;
|
||||||
|
starterCategory?: keyof typeof JP_X_CULTURE.naturalStarters;
|
||||||
|
}): string {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
|
||||||
|
if (opts.includeStyleNotes !== false) {
|
||||||
|
blocks.push(`📝 日本のX文化:\n${JP_X_CULTURE.styleNotes}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.includeAvoid !== false) {
|
||||||
|
blocks.push(`🚫 AI臭が出る表現(絶対避ける):\n${JP_X_CULTURE.aiPhrasesAvoid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.starterCategory) {
|
||||||
|
const starters = JP_X_CULTURE.naturalStarters[opts.starterCategory];
|
||||||
|
if (starters) {
|
||||||
|
blocks.push(`💬 自然な書き出し例: ${starters.join(' / ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks.join('\n\n');
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user