first commit
This commit is contained in:
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
// prompts/quote.templates.ts
|
||||
import {QuoteType} from '../enum/quote-type.enum';
|
||||
|
||||
// ============================================================
|
||||
// SYSTEM PROMPTS — native per language (chống đổi ngôn ngữ)
|
||||
// ============================================================
|
||||
export const QUOTE_SYSTEM_PROMPTS: Record<Language, string> = {
|
||||
en: [
|
||||
'You are an expert X (Twitter) quote-tweet writer.',
|
||||
'Quote tweets are BROADCASTS to your followers, not replies to the OP.',
|
||||
'They must deliver standalone value — add a new angle, insight, or context.',
|
||||
'Write ONLY the quote text. No preamble, no quotes wrapping, No links attached, no "Here is...".',
|
||||
'Never start with "Great post!" or praise the OP sycophantically.',
|
||||
'Sound like a sharp, confident human — NOT an AI assistant.',
|
||||
].join(' '),
|
||||
vi: [
|
||||
'Bạn là chuyên gia viết quote-tweet trên X.',
|
||||
'Quote tweet là BROADCAST tới followers của bạn, không phải reply cho tác giả gốc.',
|
||||
'Quote phải có giá trị độc lập — thêm góc nhìn, insight, hoặc context mới.',
|
||||
'CHỈ viết nội dung quote bằng Tiếng Việt. Không giải thích, không đính kèm link ,không dấu ngoặc kép bao ngoài.',
|
||||
'KHÔNG bắt đầu bằng "Bài hay!" hay nịnh tác giả gốc.',
|
||||
'Giọng như người thật sắc sảo, tự tin — KHÔNG giống AI assistant.',
|
||||
].join(' '),
|
||||
cn: [
|
||||
'你是一名X(Twitter)引用转推写作专家。',
|
||||
'引用转推是向你的粉丝进行的广播,而不是对原帖作者的回复。',
|
||||
'内容必须具备独立价值——提供新的角度、洞察或背景信息。',
|
||||
'只写引用内容本身。不要前言、不要加引号、不要附带链接、不要写“Here is...”之类的句子。',
|
||||
'不要以“Great post!”开头,也不要对原作者进行阿谀式夸赞。',
|
||||
'语气要像一个犀利、自信的人类,而不是AI助手。',
|
||||
].join(' '),
|
||||
ja: [
|
||||
'あなたはX(Twitter)の引用リツイート専門ライターです。',
|
||||
'引用ツイートは元投稿への返信ではなく、自分のフォロワーへの発信です。',
|
||||
'独立した価値を提供すること — 新しい視点、洞察、文脈を加える。',
|
||||
'引用の本文のみを日本語で出力。前置き、リンクは添付されていません, 引用符での囲み、「以下は…」などは不要。',
|
||||
'「素晴らしい投稿ですね!」のような元投稿への追従は禁止。',
|
||||
'鋭く自信のある人間として書く — AIアシスタント風にしない。',
|
||||
].join(' '),
|
||||
ko: [
|
||||
'X(트위터) 인용 트윗 전문 작성자입니다.',
|
||||
'인용 트윗은 원 작성자에 대한 답글이 아닌, 당신의 팔로워들에게 보내는 브로드캐스트입니다.',
|
||||
'독립적 가치를 전달해야 합니다 — 새로운 관점, 통찰, 맥락을 추가하세요.',
|
||||
'인용 본문만 한국어로 작성. 서두, 링크가 첨부되어 있지 않습니다, 인용부호 감싸기, "다음은..." 등 금지.',
|
||||
'"좋은 글이네요!" 같은 원 작성자 아부 금지.',
|
||||
'날카롭고 자신감 있는 사람처럼 — AI 어시스턴트처럼 쓰지 말 것.',
|
||||
].join(' '),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// QUOTE TYPE INSTRUCTIONS (multilingual)
|
||||
// ============================================================
|
||||
interface QuoteTypeSpec {
|
||||
name: Record<Language, string>;
|
||||
instruction: Record<Language, string>;
|
||||
openerHints: Record<Language, string[]>; // gợi ý cách mở đầu (không bắt buộc)
|
||||
avoid: Record<Language, string>;
|
||||
}
|
||||
|
||||
export const QUOTE_TYPE_SPECS: Record<QuoteType, QuoteTypeSpec> = {
|
||||
[QuoteType.AGREE_AMPLIFY]: {
|
||||
name: {
|
||||
en: 'Agree + Amplify',
|
||||
cn: 'Agree + Amplify',
|
||||
vi: 'Đồng ý + Mở rộng',
|
||||
ja: '同意+拡張',
|
||||
ko: '동의 + 확장',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Agree with the core point, then ADD a unique supporting insight or example the OP did not mention. Do not just repeat them.',
|
||||
cn: 'Agree with the core point, then ADD a unique supporting insight or example the OP did not mention. Do not just repeat them.',
|
||||
vi: 'Đồng ý với ý chính, sau đó THÊM insight/ví dụ riêng mà OP chưa nói. Không lặp lại họ.',
|
||||
ja: '要点に同意した上で、元投稿にない独自の裏付け洞察や事例を追加する。繰り返しにならないこと。',
|
||||
ko: '핵심에 동의한 후, 원 게시물에 없는 고유한 뒷받침 통찰이나 예시를 추가. 반복 금지.',
|
||||
},
|
||||
openerHints: {
|
||||
en: ['This. And...', 'Exactly. What most miss is...', '100%. The under-discussed part:'],
|
||||
cn: ['This. And...', 'Exactly. What most miss is...', '100%. The under-discussed part:'],
|
||||
vi: ['Chuẩn. Và...', 'Đúng vậy. Điều ít người nói đến là...', 'Chính xác. Góc bị bỏ qua:'],
|
||||
ja: [
|
||||
'それな',
|
||||
'これマジでわかる',
|
||||
'ガチでこれ',
|
||||
'ほんとそう、補足すると',
|
||||
'同意。あんま語られないけど',
|
||||
],
|
||||
ko: ['바로 이것. 그리고...', '정확히. 놓치기 쉬운 건...', '동의. 잘 안 다뤄지는 부분:'],
|
||||
},
|
||||
avoid: {
|
||||
en: 'Do not simply restate the original.',
|
||||
cn: 'Do not simply restate the original.',
|
||||
vi: 'Không đơn thuần nhắc lại bài gốc.',
|
||||
ja: '元投稿の言い換えだけにしない。',
|
||||
ko: '원문을 그대로 반복하지 말 것.',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.DISAGREE]: {
|
||||
name: {
|
||||
en: 'Disagree / Challenge',
|
||||
cn: 'Disagree / Challenge',
|
||||
vi: 'Phản biện',
|
||||
ja: '反論',
|
||||
ko: '반론',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Politely but firmly disagree. Present a specific counter-argument with reasoning or data. Not personal — attack the idea, not the author.',
|
||||
cn: 'Politely but firmly disagree. Present a specific counter-argument with reasoning or data. Not personal — attack the idea, not the author.',
|
||||
vi: 'Phản biện lịch sự nhưng dứt khoát. Đưa luận điểm ngược cụ thể có lý lẽ/dữ liệu. Không cá nhân — chỉ phản bác ý, không công kích tác giả.',
|
||||
ja: '丁寧ながら明確に反論する。具体的な根拠・データを伴う対論を示す。個人攻撃せず、主張のみに反論。',
|
||||
ko: '정중하지만 단호하게 반론. 구체적 근거/데이터로 반박. 인신공격 금지, 주장에만 반박.',
|
||||
},
|
||||
openerHints: {
|
||||
en: ['Respectfully, I see it differently.', 'Counter-take:', 'The data suggests otherwise:'],
|
||||
cn: ['Respectfully, I see it differently.', 'Counter-take:', 'The data suggests otherwise:'],
|
||||
vi: ['Tôi có góc nhìn khác.', 'Ngược lại:', 'Dữ liệu cho thấy điều khác:'],
|
||||
ja: [
|
||||
'いや、これはちゃう',
|
||||
'別の見方もあって',
|
||||
'ちょっと違うと思う',
|
||||
'データ的には逆',
|
||||
'反対意見いいですか',
|
||||
],
|
||||
ko: ['다른 관점도 있습니다.', '반대 의견:', '데이터는 다르게 말합니다:'],
|
||||
},
|
||||
avoid: {
|
||||
en: 'No sarcasm, no personal attacks, no "actually" condescension.',
|
||||
cn: 'No sarcasm, no personal attacks, no "actually" condescension.',
|
||||
vi: 'Không mỉa mai, không công kích cá nhân, không giọng "thực ra thì" trịch thượng.',
|
||||
ja: '皮肉、個人攻撃、上から目線の「実は」表現を避ける。',
|
||||
ko: '빈정거림, 인신공격, 거만한 "사실은" 표현 금지.',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.ADD_CONTEXT]: {
|
||||
name: {
|
||||
en: 'Add Context',
|
||||
cn: 'Add Context',
|
||||
vi: 'Bổ sung context',
|
||||
ja: '文脈を追加',
|
||||
ko: '맥락 추가',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Provide missing context, background, or nuance that changes how the original should be interpreted. Be factual.',
|
||||
cn: 'Provide missing context, background, or nuance that changes how the original should be interpreted. Be factual.',
|
||||
vi: 'Cung cấp context, background, hoặc nuance còn thiếu làm thay đổi cách hiểu bài gốc. Giữ đúng sự thật.',
|
||||
ja: '元投稿の解釈を変える、欠けている文脈・背景・ニュアンスを提供。事実ベースで。',
|
||||
ko: '원 게시물 해석을 바꾸는 누락된 맥락, 배경, 뉘앙스를 제공. 사실 기반으로.',
|
||||
},
|
||||
openerHints: {
|
||||
en: ['Important context:', 'Worth noting:', 'Missing from this:'],
|
||||
cn: ['Important context:', 'Worth noting:', 'Missing from this:'],
|
||||
vi: ['Context quan trọng:', 'Đáng chú ý:', 'Điểm còn thiếu:'],
|
||||
ja: ['重要な文脈:', '注目すべき点:', 'この投稿に欠けている:'],
|
||||
ko: ['중요한 맥락:', '주목할 점:', '빠진 부분:'],
|
||||
},
|
||||
avoid: {
|
||||
en: 'Do not fabricate facts.',
|
||||
cn: 'Do not fabricate facts.',
|
||||
vi: 'Không bịa đặt sự thật.',
|
||||
ja: '事実を捏造しないこと。',
|
||||
ko: '사실을 날조하지 말 것.',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.REFRAME]: {
|
||||
name: {
|
||||
en: 'Reframe',
|
||||
cn: 'Reframe',
|
||||
vi: 'Nhìn góc khác',
|
||||
ja: '再構成',
|
||||
ko: '재구성',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Shift the mental frame — show the same situation from a completely different angle or level of abstraction.',
|
||||
cn: 'Shift the mental frame — show the same situation from a completely different angle or level of abstraction.',
|
||||
vi: 'Chuyển khung nhìn — cho thấy cùng sự việc từ góc độ hoặc cấp độ hoàn toàn khác.',
|
||||
ja: 'フレームを転換 — 同じ状況を全く異なる角度・抽象度で見せる。',
|
||||
ko: '프레임 전환 — 같은 상황을 완전히 다른 각도/추상화 수준에서 보기.',
|
||||
},
|
||||
openerHints: {
|
||||
en: ['Another way to see this:', 'Zoom out:', 'Reframe:'],
|
||||
cn: ['Another way to see this:', 'Zoom out:', 'Reframe:'],
|
||||
vi: ['Một cách nhìn khác:', 'Zoom out:', 'Đổi khung:'],
|
||||
ja: ['別の見方:', '俯瞰すると:', 'フレーム転換:'],
|
||||
ko: ['다른 시각:', '시야를 넓히면:', '프레임 전환:'],
|
||||
},
|
||||
avoid: {
|
||||
en: 'Do not just restate in different words.',
|
||||
cn: 'Do not just restate in different words.',
|
||||
vi: 'Không chỉ đổi từ ngữ giữ nguyên ý.',
|
||||
ja: '言葉を変えただけの言い換えにしない。',
|
||||
ko: '단어만 바꾼 재진술 금지.',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.BUILD_ON]: {
|
||||
name: {
|
||||
en: 'Build On',
|
||||
cn: 'Build On',
|
||||
vi: 'Mở rộng',
|
||||
ja: '発展',
|
||||
ko: '확장',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Take the original idea further. Apply it to a new domain, extend the logic, or show a non-obvious implication.',
|
||||
cn: 'Take the original idea further. Apply it to a new domain, extend the logic, or show a non-obvious implication.',
|
||||
vi: 'Đẩy ý tưởng gốc đi xa hơn. Áp dụng sang lĩnh vực mới, mở rộng logic, hoặc cho thấy hệ quả không hiển nhiên.',
|
||||
ja: '元のアイデアをさらに展開。新領域への応用、論理の拡張、非自明な含意を示す。',
|
||||
ko: '원 아이디어를 더 발전시키기. 새 영역 적용, 논리 확장, 비자명한 함의 제시.',
|
||||
},
|
||||
openerHints: {
|
||||
en: ['Taking this further:', 'Extending this logic:', 'The next step:'],
|
||||
cn: ['Taking this further:', 'Extending this logic:', 'The next step:'],
|
||||
vi: ['Đi xa hơn:', 'Mở rộng logic này:', 'Bước tiếp theo:'],
|
||||
ja: ['さらに展開すると:', 'この論理を広げると:', '次のステップ:'],
|
||||
ko: ['더 나아가면:', '이 논리를 확장하면:', '다음 단계:'],
|
||||
},
|
||||
avoid: {
|
||||
en: 'Stay grounded — no wild speculation.',
|
||||
cn: 'Stay grounded — no wild speculation.',
|
||||
vi: 'Bám thực tế — không suy diễn hoang đường.',
|
||||
ja: '現実的に — 過度な憶測はしない。',
|
||||
ko: '현실적으로 — 무리한 추측 금지.',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.HIGHLIGHT]: {
|
||||
name: {
|
||||
en: 'Highlight Key , You are NOT adding new content. You are a spotlight — just make the existing best point impossible to miss.',
|
||||
cn: 'Highlight Key Point, You are NOT adding new content. You are a spotlight — just make the existing best point impossible to miss.',
|
||||
vi: 'Nhấn mạnh, Bạn KHÔNG thêm nội dung mới. Bạn chỉ là người làm nổi bật điểm mạnh hiện có – hãy làm cho điểm mạnh đó trở nên không thể bỏ qua.',
|
||||
ja: '重要ポイント , あなたは新しいコンテンツを追加するわけではありません。あなたはスポットライトを当てる役割を担っています。既存の最も優れた点を、見逃せないようにするだけです。',
|
||||
ko: '핵심 강조 , 당신은 새로운 콘텐츠를 추가하는 것이 아닙니다. 당신은 기존의 가장 뛰어난 부분을 부각시키는 역할을 하는 것입니다. 즉, 기존의 가장 뛰어난 부분을 놓치지 않도록 하는 것입니다.',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Call out THE most important or under-appreciated insight in the original. Make it impossible to miss.',
|
||||
cn: 'Call out THE most important or under-appreciated insight in the original. Make it impossible to miss.',
|
||||
vi: 'Chỉ ra insight QUAN TRỌNG nhất hoặc bị đánh giá thấp trong bài gốc. Làm cho không thể bỏ qua.',
|
||||
ja: '元投稿で最も重要、または過小評価されている洞察を強調。見逃せないように。',
|
||||
ko: '원 게시물에서 가장 중요하거나 저평가된 통찰을 강조. 놓칠 수 없게.',
|
||||
},
|
||||
openerHints: {
|
||||
en: ['THIS is the key:', 'The real insight here:', 'Don\'t miss this:'],
|
||||
cn: ['THIS is the key:', 'The real insight here:', 'Don\'t miss this:'],
|
||||
vi: ['ĐÂY là điểm mấu chốt:', 'Insight thật sự:', 'Đừng bỏ lỡ:'],
|
||||
ja: ['核心はここ:', '本当の洞察:', '見逃し厳禁:'],
|
||||
ko: ['핵심은 이것:', '진짜 통찰:', '절대 놓치지 말 것:'],
|
||||
},
|
||||
avoid: {
|
||||
en: 'Do not exaggerate or sensationalize.',
|
||||
cn: 'Do not exaggerate or sensationalize.',
|
||||
vi: 'Không phóng đại, không giật gân.',
|
||||
ja: '誇張・煽り禁止。',
|
||||
ko: '과장이나 자극적 표현 금지.',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.ROAST]: {
|
||||
name: {
|
||||
en: 'Roast / Dunk',
|
||||
cn: 'Roast / Dunk',
|
||||
vi: 'Roast',
|
||||
ja: 'ロースト',
|
||||
ko: '풍자',
|
||||
},
|
||||
instruction: {
|
||||
en: `Witty, sharp criticism with humor. Attack the idea, never the person. Must be actually funny, not mean. Avoid punching at demographics, political groups, or anything that could be read as targeted harassment even in jest. If the original post is already self-aware or humble, skip the roast — it won't land.`,
|
||||
cn: `Witty, sharp criticism with humor. Attack the idea, never the person. Must be actually funny, not mean. Avoid punching at demographics, political groups, or anything that could be read as targeted harassment even in jest. If the original post is already self-aware or humble, skip the roast — it won't land.`,
|
||||
vi: 'Lời phê bình sắc sảo, dí dỏm và hài hước. Hãy tấn công vào ý tưởng, chứ không phải cá nhân. Phải thực sự hài hước, không được ác ý. Tránh công kích các nhóm nhân khẩu học, nhóm chính trị, hoặc bất cứ điều gì có thể bị hiểu là quấy rối có chủ đích, ngay cả khi chỉ là nói đùa. Nếu bài đăng gốc đã tự nhận thức hoặc khiêm tốn, hãy bỏ qua phần chỉ trích – nó sẽ không hiệu quả',
|
||||
ja: 'ユーモアを交えた、機知に富んだ鋭い批判。アイデアを攻撃し、決して人を攻撃しないこと。本当に面白くなければならず、意地悪であってはならない。特定の人口統計グループ、政治団体、あるいは冗談であっても標的型嫌がらせと受け取られかねないものを攻撃することは避けること。元の投稿が既に自己認識が高かったり謙虚だったりする場合は、皮肉を言うのはやめよう。効果がないだろう。',
|
||||
ko: '재치 있고 날카로운 비판에 유머를 더하세요. 아이디어를 공격하되, 사람을 공격해서는 안 됩니다. 악의가 아닌 진정한 웃음을 유발해야 합니다. 특정 인구 집단, 정치 집단 또는 표적 공격으로 해석될 수 있는 내용은 농담이라 할지라도 피하세요. 원 게시글 작성자가 이미 자기 인식적이거나 겸손한 태도를 보인다면, 신랄한 비판은 삼가세요. 효과적이지 않을 겁니다.',
|
||||
},
|
||||
openerHints: {
|
||||
en: ['Imagine thinking...', 'Bold of you to...', 'The confidence of posting this...'],
|
||||
cn: ['Imagine thinking...', 'Bold of you to...', 'The confidence of posting this...'],
|
||||
vi: ['Tưởng tượng mà nghĩ rằng...', 'Bạo dạn thật...', 'Đủ tự tin để post cái này...'],
|
||||
ja: [
|
||||
'よくこれ投稿できたな',
|
||||
'こいつ本当に',
|
||||
'お兄さん、',
|
||||
'伝説の投稿',
|
||||
'保存させていただきました',
|
||||
'今週一の',
|
||||
],
|
||||
ko: ['이런 생각을 하다니...', '대담하네요...', '이걸 올릴 자신감...'],
|
||||
},
|
||||
avoid: {
|
||||
en: 'No slurs, no personal attacks, no bullying punching down.',
|
||||
cn: 'No slurs, no personal attacks, no bullying punching down.',
|
||||
vi: 'Không miệt thị, không công kích cá nhân, không bắt nạt người yếu thế.',
|
||||
ja: '差別語、個人攻撃、弱者叩き禁止。',
|
||||
ko: '비방, 인신공격, 약자 공격 금지.',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.HOT_TAKE]: {
|
||||
name: {
|
||||
en: 'Hot Take',
|
||||
cn: 'Hot Take',
|
||||
vi: 'Hot take',
|
||||
ja: 'ホットテイク',
|
||||
ko: '핫 테이크',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Bold, confident opinion triggered by the original. Must be defensible, not just contrarian for clout.The hot take must visibly connect back to the original post — readers should see why this was triggered by it.',
|
||||
cn: 'Bold, confident opinion triggered by the original. Must be defensible, not just contrarian for clout. The hot take must visibly connect back to the original post — readers should see why this was triggered by it.',
|
||||
vi: 'Opinion mạnh, tự tin, được kích bởi bài gốc. Phải bảo vệ được, không chỉ ngược chiều để câu view. Quan điểm gây tranh cãi phải có mối liên hệ rõ ràng với bài đăng gốc — người đọc cần thấy lý do tại sao nó lại được đưa ra sau bài đăng gốc.',
|
||||
ja: '元投稿をきっかけとした大胆で自信ある意見。反対のための反対ではなく、擁護可能な内容。その過激な意見は、元の投稿と明確に関連していなければならない。読者は、なぜそれがきっかけでこの意見が出たのかを理解できる必要がある。',
|
||||
ko: '원 게시물이 촉발한 대담하고 자신 있는 의견. 반대를 위한 반대가 아닌, 방어 가능한 내용.비판적인 의견은 원래 게시글과 명확하게 연결되어야 하며, 독자들은 왜 그 게시글이 비판의 계기가 되었는지 이해할 수 있어야 합니다.',
|
||||
},
|
||||
openerHints: {
|
||||
en: ['Unpopular opinion:', 'Hot take:', 'Controversial but true:'],
|
||||
cn: ['Unpopular opinion:', 'Hot take:', 'Controversial but true:'],
|
||||
vi: ['Opinion không phổ biến:', 'Hot take:', 'Gây tranh cãi nhưng đúng:'],
|
||||
ja: [
|
||||
'不人気な意見だけど',
|
||||
'結論:',
|
||||
'ホットテイク:',
|
||||
'誰も言わないけど',
|
||||
'物議を醸すかもだが',
|
||||
],
|
||||
ko: ['비주류 의견:', '핫 테이크:', '논란의 여지가 있지만 사실:'],
|
||||
},
|
||||
avoid: {
|
||||
en: 'No empty contrarianism.',
|
||||
cn: 'No empty contrarianism.',
|
||||
vi: 'Không ngược chiều rỗng tuếch.',
|
||||
ja: '中身のない逆張り禁止。',
|
||||
ko: '알맹이 없는 반대 금지.',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.QUESTION]: {
|
||||
name: {
|
||||
en: 'Provocative Question',
|
||||
cn: 'Provocative Question',
|
||||
vi: 'Câu hỏi khơi gợi',
|
||||
ja: '問いかけ',
|
||||
ko: '질문 던지기',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Ask ONE sharp question that makes people think deeper about the original. Not rhetorical flipping — genuinely thought-provoking.',
|
||||
cn: 'Ask ONE sharp question that makes people think deeper about the original. Not rhetorical flipping — genuinely thought-provoking.',
|
||||
vi: 'Đặt MỘT câu hỏi sắc khiến người đọc suy nghĩ sâu hơn về bài gốc. Không tu từ rỗng — thực sự kích thích suy nghĩ.',
|
||||
ja: '元投稿を深く考えさせる鋭い問いを1つ。修辞的な反転ではなく、本当に考えさせる内容。',
|
||||
ko: '원 게시물을 더 깊이 생각하게 만드는 날카로운 질문 1개. 수사적 반문이 아닌, 진짜 생각하게 만드는 것.',
|
||||
},
|
||||
openerHints: {
|
||||
en: ['But what about...?', 'Genuine question:', 'Have we considered...?'],
|
||||
cn: ['But what about...?', 'Genuine question:', 'Have we considered...?'],
|
||||
vi: ['Nhưng còn...?', 'Câu hỏi thật sự:', 'Đã ai tính đến...?'],
|
||||
ja: ['では…はどうか?', '素朴な疑問:', '…を考えたことは?'],
|
||||
ko: ['하지만...는 어떤가요?', '진지한 질문:', '...을 고려해봤나요?'],
|
||||
},
|
||||
avoid: {
|
||||
en: 'No gotcha questions.',
|
||||
cn: 'No gotcha questions.',
|
||||
vi: 'Không câu hỏi "gotcha" bẫy.',
|
||||
ja: '揚げ足取りの質問禁止。',
|
||||
ko: '트집 잡기식 질문 금지.',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.SUMMARIZE]: {
|
||||
name: {
|
||||
en: 'TL;DR Summary',
|
||||
cn: 'TL;DR Summary',
|
||||
vi: 'Tóm tắt TL;DR',
|
||||
ja: 'TL;DR要約',
|
||||
ko: 'TL;DR 요약',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Distill the original into its sharpest, most shareable essence. One-line TL;DR style.',
|
||||
cn: 'Distill the original into its sharpest, most shareable essence. One-line TL;DR style.',
|
||||
vi: 'Cô đọng bài gốc thành tinh túy sắc nhất, dễ share nhất. Kiểu TL;DR một dòng.',
|
||||
ja: '元投稿を最も鋭く、シェアしやすい本質に凝縮。1行TL;DR形式。',
|
||||
ko: '원 게시물을 가장 날카롭고 공유하기 쉬운 본질로 압축. 한 줄 TL;DR 형식.',
|
||||
},
|
||||
openerHints: {
|
||||
en: ['TL;DR:', 'In one line:', 'The whole thing in a sentence:'],
|
||||
cn: ['TL;DR:', 'In one line:', 'The whole thing in a sentence:'],
|
||||
vi: ['TL;DR:', 'Một dòng:', 'Cả bài trong 1 câu:'],
|
||||
ja: [
|
||||
'TL;DR:',
|
||||
'要するに',
|
||||
'一言でいうと',
|
||||
'3秒で分かる版:',
|
||||
'まとめ:',
|
||||
],
|
||||
ko: ['TL;DR:', '한 줄로:', '요약하면:'],
|
||||
},
|
||||
avoid: {
|
||||
en: 'Do not add new content — this is compression.',
|
||||
cn: 'Do not add new content — this is compression.',
|
||||
vi: 'Không thêm nội dung mới — đây là nén.',
|
||||
ja: '新規情報は加えない — これは圧縮。',
|
||||
ko: '새 내용 추가 금지 — 압축 작업.',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.PERSONAL_STORY]: {
|
||||
name: {
|
||||
en: 'Personal Story',
|
||||
vi: 'Câu chuyện cá nhân',
|
||||
ja: '個人的な体験',
|
||||
ko: '개인 경험 공유',
|
||||
cn: '个人故事',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Share a real, specific personal experience that connects to the original post. Lead with the moment, not the lesson. Include one concrete detail (time, place, number, name) to make it feel real. The story should naturally validate, contrast, or deepen the original — not just say "same". End with a brief reflection or takeaway, but keep it earned, not preachy.',
|
||||
vi: 'Chia sẻ một trải nghiệm cá nhân cụ thể, thực tế liên quan đến bài gốc. Mở đầu bằng khoảnh khắc xảy ra, không phải bài học. Thêm một chi tiết cụ thể (thời gian, địa điểm, con số, tên) để câu chuyện có độ thật. Câu chuyện nên tự nhiên xác nhận, tương phản, hoặc làm sâu hơn bài gốc — không chỉ nói "tôi cũng vậy". Kết bằng một reflection ngắn nhưng phải tự nhiên, không giáo điều.',
|
||||
ja: '元の投稿に関連する、具体的なリアルな個人体験を共有する。教訓からではなく、その瞬間から書き始める。リアル感を出すために具体的な detail(時間・場所・数字・名前)を1つ入れる。元投稿を自然に裏付け、対比、または深掘りする内容にする — 単なる「わかる」にしない。短い気づきで締めるが、説教にならないこと。',
|
||||
ko: '원 게시물과 연결되는 구체적이고 실제적인 개인 경험을 공유한다. 교훈이 아닌 그 순간부터 시작할 것. 실감나게 만들 구체적인 디테일(시간, 장소, 숫자, 이름) 하나를 포함한다. 단순히 "나도 그래"가 아니라 원 게시물을 자연스럽게 뒷받침하거나 대비하거나 심화시키는 내용이어야 한다. 짧은 성찰로 마무리하되, 설교조가 되지 않을 것.',
|
||||
cn: '分享一个与原帖相关的真实、具体的个人经历。从那个时刻切入,而非从教训开始。加入一个具体细节(时间、地点、数字、名字)让故事显得真实。内容应自然地印证、对比或深化原帖——而不只是说"我也是"。以简短的感悟收尾,但要自然流露,不要说教。',
|
||||
},
|
||||
openerHints: {
|
||||
en: [
|
||||
'This takes me back to...',
|
||||
'Three years ago I learned this the hard way —',
|
||||
'Happened to me. [Year/Place]:',
|
||||
'I used to think differently — until...',
|
||||
'Real story:',
|
||||
],
|
||||
vi: [
|
||||
'Cái này nhắc tôi nhớ lại...',
|
||||
'Ba năm trước tôi đã học điều này theo cách khó khăn nhất —',
|
||||
'Tôi đã từng gặp đúng chuyện này. [Năm/Nơi]:',
|
||||
'Tôi từng nghĩ khác — cho đến khi...',
|
||||
'Chuyện thật:',
|
||||
],
|
||||
ja: [
|
||||
'これを見て昔を思い出した…',
|
||||
'3年前、痛い経験から学んだことがある —',
|
||||
'自分にも起きた。[年/場所]:',
|
||||
'以前は違う考えだった — あの日まで…',
|
||||
'実話:',
|
||||
],
|
||||
ko: [
|
||||
'이걸 보니 예전 생각이 나네요...',
|
||||
'3년 전, 저는 이걸 아주 힘든 방식으로 배웠습니다 —',
|
||||
'저한테도 있었던 일이에요. [연도/장소]:',
|
||||
'예전엔 다르게 생각했어요 — 그날까지는...',
|
||||
'실제 있었던 일:',
|
||||
],
|
||||
cn: [
|
||||
'这让我想起了...',
|
||||
'三年前,我用最艰难的方式学到了这件事 —',
|
||||
'这事发生在我身上。[年份/地点]:',
|
||||
'我曾经想法不同 —— 直到那一天...',
|
||||
'真实故事:',
|
||||
],
|
||||
},
|
||||
avoid: {
|
||||
en: 'Do not write vague, generic "relatable" content like "I once felt this way too." No fake humility. No manufactured vulnerability. The story must have a specific detail — if it has none, it reads as fabricated. Do not moralize or over-explain the lesson at the end.',
|
||||
vi: 'Không viết kiểu mơ hồ, chung chung như "Tôi cũng từng cảm thấy vậy." Không khiêm tốn giả tạo. Không tạo ra sự dễ tổn thương giả. Câu chuyện phải có ít nhất một chi tiết cụ thể — nếu không có, nó sẽ lộ là bịa. Không đạo đức hoá hoặc giải thích quá dài bài học ở cuối.',
|
||||
ja: '「私もそう感じたことがある」のような曖昧で当たり障りのない内容を書かない。作られた謙虚さや演出された脆さは不要。具体的な detail が1つもなければ作り話に見える。最後に教訓を説教したり過剰に説明したりしない。',
|
||||
ko: '"저도 그런 느낌을 받은 적 있어요"같은 모호하고 일반적인 공감성 내용을 쓰지 말 것. 가짜 겸손이나 연출된 취약함 금지. 구체적인 디테일이 하나도 없으면 지어낸 것처럼 보임. 마지막에 교훈을 설교하거나 과도하게 설명하지 말 것.',
|
||||
cn: '不要写模糊、套路化的"感同身受"内容,如"我也曾有过这种感觉"。不要假谦虚,不要刻意制造脆弱感。故事必须有具体细节——否则会显得是编造的。结尾不要说教或过度解释教训。',
|
||||
},
|
||||
},
|
||||
|
||||
[QuoteType.CONNECT_DOTS]: {
|
||||
name: {
|
||||
en: 'Connect the Dots',
|
||||
vi: 'Kết nối sự kiện',
|
||||
ja: 'パターンを繋ぐ',
|
||||
ko: '점 잇기',
|
||||
cn: '连点成线',
|
||||
},
|
||||
instruction: {
|
||||
en: 'Identify a separate event, trend, data point, or pattern — not mentioned in the original — that when placed next to it reveals a bigger picture. The connection must be logical and traceable, not vague. Structure: [Original signal] + [External signal you bring] = [Insight neither alone would reveal]. The reader should finish thinking: "I would not have seen that without this quote."',
|
||||
vi: 'Xác định một sự kiện, xu hướng, dữ liệu hoặc pattern riêng biệt — không được nhắc đến trong bài gốc — mà khi đặt cạnh bài gốc sẽ lộ ra một bức tranh lớn hơn. Kết nối phải có logic và có thể truy vết, không mơ hồ. Cấu trúc: [Tín hiệu từ bài gốc] + [Tín hiệu bên ngoài bạn mang vào] = [Insight mà cả hai riêng lẻ đều không cho thấy]. Người đọc xong phải nghĩ: "Nếu không có quote này tôi đã không nhận ra điều đó."',
|
||||
ja: '元の投稿で触れられていない別の出来事・トレンド・データ・パターンを見つけ、元投稿と並べることでより大きな全体像を明らかにする。繋がりは論理的で追跡可能でなければならない — 曖昧な連想は不可。構造:[元投稿のシグナル] + [あなたが持ち込む外部シグナル] = [どちらか単体では見えないインサイト]。読者が読み終えて「このquoteがなければ気づかなかった」と思わせること。',
|
||||
ko: '원 게시물에서 언급되지 않은 별개의 사건, 트렌드, 데이터, 패턴을 찾아 원 게시물 옆에 놓았을 때 더 큰 그림이 드러나도록 한다. 연결은 논리적이고 추적 가능해야 하며 모호한 연상은 금지. 구조: [원 게시물 신호] + [내가 가져오는 외부 신호] = [둘 중 어느 하나만으로는 보이지 않는 통찰]. 독자가 읽고 나서 "이 quote 없었으면 몰랐을 것"이라고 느끼게 할 것.',
|
||||
cn: '找到一个原帖未提及的独立事件、趋势、数据点或模式——将其与原帖并列时能揭示更大的图景。连接必须合乎逻辑且可追溯,不能模糊。结构:[原帖信号] + [你带入的外部信号] = [两者单独都无法揭示的洞察]。读者读完应该想:「没有这条quote我不会发现这一点。」',
|
||||
},
|
||||
openerHints: {
|
||||
en: [
|
||||
'This + [X] = a pattern worth watching:',
|
||||
'Third time seeing this signal this month.',
|
||||
'Connect this to what happened with [X] and it makes sense:',
|
||||
'Alone this looks like noise. With [X] it looks like signal:',
|
||||
'The dots are connecting:',
|
||||
],
|
||||
vi: [
|
||||
'Cái này + [X] = một pattern đáng chú ý:',
|
||||
'Tháng này tôi thấy tín hiệu này lần thứ ba rồi.',
|
||||
'Kết nối điều này với chuyện xảy ra với [X] thì mọi thứ có lý:',
|
||||
'Riêng lẻ thì trông như nhiễu. Cộng với [X] thì đây là tín hiệu thật:',
|
||||
'Các mảnh ghép đang khớp lại:',
|
||||
],
|
||||
ja: [
|
||||
'これ+[X]=注目すべきパターン:',
|
||||
'今月これで3回目のシグナルだ。',
|
||||
'[X]で起きたことと繋げると腑に落ちる:',
|
||||
'単体ではノイズに見える。[X]と合わせるとシグナルになる:',
|
||||
'点と点が繋がってきた:',
|
||||
],
|
||||
ko: [
|
||||
'이것 + [X] = 주목할 만한 패턴:',
|
||||
'이번 달 세 번째로 보는 신호다.',
|
||||
'[X]에서 일어난 일과 연결하면 이해가 된다:',
|
||||
'단독으로는 노이즈처럼 보인다. [X]와 합치면 진짜 신호:',
|
||||
'점들이 이어지고 있다:',
|
||||
],
|
||||
cn: [
|
||||
'这个 + [X] = 一个值得关注的模式:',
|
||||
'这个月第三次看到这个信号了。',
|
||||
'把这个和[X]发生的事联系起来,一切就说得通了:',
|
||||
'单独看像是噪音。加上[X]就是真实信号:',
|
||||
'点与点正在连成线:',
|
||||
],
|
||||
},
|
||||
avoid: {
|
||||
en: 'Do not fabricate connections — if you cannot name the external signal specifically, do not make one up. Avoid superficial pattern-matching like "this is just like [famous event]" without explaining the actual mechanism. Do not connect things that are merely similar in surface topic — the connection must reveal causation, correlation, or a systemic pattern. No conspiracy-adjacent reasoning.',
|
||||
vi: 'Không bịa đặt kết nối — nếu không thể đặt tên cụ thể cho tín hiệu bên ngoài thì không được bịa. Tránh pattern matching hời hợt kiểu "cái này giống hệt [sự kiện nổi tiếng]" mà không giải thích cơ chế thực sự. Không kết nối những thứ chỉ giống nhau về chủ đề bề mặt — kết nối phải lộ ra quan hệ nhân quả, tương quan, hoặc một pattern có hệ thống. Không suy luận kiểu thuyết âm mưu.',
|
||||
ja: '繋がりを捏造しない — 外部シグナルを具体的に名指しできないなら作り上げない。「これは[有名な出来事]と同じだ」という表面的なパターンマッチングを、実際のメカニズム説明なしに行わない。表面的なトピックが似ているだけのものを繋げない — 繋がりは因果・相関・システム的パターンを明らかにするものでなければならない。陰謀論的な推論は禁止。',
|
||||
ko: '연결을 날조하지 말 것 — 외부 신호를 구체적으로 명명할 수 없다면 만들어내지 말 것. "이건 [유명한 사건]과 똑같다"는 식의 실제 메커니즘 설명 없는 피상적 패턴 매칭 금지. 표면적 주제만 비슷한 것들을 연결하지 말 것 — 연결은 인과관계, 상관관계, 또는 시스템적 패턴을 드러내야 함. 음모론적 추론 금지.',
|
||||
cn: '不要捏造联系——如果无法具体说出外部信号,就不要编造。避免没有解释实际机制的表面模式匹配,如"这和[著名事件]一模一样"。不要连接仅在表面话题上相似的事物——连接必须揭示因果关系、相关性或系统性模式。禁止阴谋论式推理。',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// AUTO-DETECT quote type nếu user không truyền
|
||||
// ============================================================
|
||||
export function suggestQuoteType(originalPost: string, yourAngle?: string): QuoteType {
|
||||
console.log('==> suggestQuoteType');
|
||||
const text = (yourAngle ?? originalPost).toLowerCase();
|
||||
|
||||
if (/disagree|wrong|incorrect|false|sai|không đúng|違う|틀렸/.test(text)) return QuoteType.DISAGREE;
|
||||
if (/tl;?dr|summary|tóm tắt|要約|요약/.test(text)) return QuoteType.SUMMARIZE;
|
||||
if (/question|như thế nào|\?|\?/.test(text)) return QuoteType.QUESTION;
|
||||
if (/funny|lol|haha|imagine|😂|🤣/.test(text)) return QuoteType.ROAST;
|
||||
if (/context|background|actually|thực ra|実は|사실/.test(text)) return QuoteType.ADD_CONTEXT;
|
||||
if (/unpopular|controversial|hot take|gây tranh cãi/.test(text)) return QuoteType.HOT_TAKE;
|
||||
|
||||
// Default: amplify (an toàn nhất)
|
||||
return QuoteType.AGREE_AMPLIFY;
|
||||
}
|
||||
|
||||
export const QUOTE_TYPE_TELEGRAM_BUTTON_SPECS = {
|
||||
[QuoteType.AGREE_AMPLIFY]: {
|
||||
key: 'agree_amplify',
|
||||
text: 'Đồng ý + thêm insight',
|
||||
}, // Đồng ý + thêm insight
|
||||
[QuoteType.DISAGREE]: {
|
||||
key: 'disagree',
|
||||
text: 'Disagree-Phản biện có lý'
|
||||
}, // Phản biện có lý
|
||||
[QuoteType.ADD_CONTEXT]: {
|
||||
key: 'add_context',
|
||||
text: 'Bổ sung context'
|
||||
}, // Bổ sung context
|
||||
[QuoteType.REFRAME]: {
|
||||
key: 'reframe',
|
||||
text: 'Nhìn góc khác'
|
||||
}, // Nhìn góc khác
|
||||
[QuoteType.BUILD_ON]: {
|
||||
key: 'build_on',
|
||||
text: 'Mở rộng ý'
|
||||
}, // Mở rộng ý
|
||||
[QuoteType.HIGHLIGHT]: {
|
||||
key: 'highlight',
|
||||
text: 'Nhấn mạnh key point'
|
||||
}, // Nhấn mạnh key point
|
||||
[QuoteType.ROAST]: {
|
||||
key: 'roast',
|
||||
text: 'Chỉ trích sắc'
|
||||
}, // Chỉ trích sắc
|
||||
[QuoteType.HOT_TAKE]: {
|
||||
key: 'host_take',
|
||||
text: 'Opinion mạnh'
|
||||
}, // Opinion mạnh
|
||||
[QuoteType.QUESTION]: {
|
||||
key: 'question', text: 'Đặt câu hỏi'
|
||||
}, // Đặt câu hỏi
|
||||
[QuoteType.SUMMARIZE]: {
|
||||
key: 'summarize',
|
||||
text: 'tóm tắt'
|
||||
},
|
||||
[QuoteType.CONNECT_DOTS]: {
|
||||
key: 'connect_dot',
|
||||
text: 'connect the dot'
|
||||
},
|
||||
[QuoteType.PERSONAL_STORY]: {
|
||||
key: 'connect_dot',
|
||||
text: 'personal story'
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MAIN PROMPT BUILDER
|
||||
// ============================================================
|
||||
import {LengthRange} from '../config/platform-limits';
|
||||
import {LANGUAGE_LOCK} from './templates';
|
||||
import {ContentTone} from '../enum/tone.enum';
|
||||
import {TONE_HINTS} from './templates';
|
||||
import {Language} from "../../../common/interfaces/language.prompt.interface";
|
||||
|
||||
export interface QuotePromptParams {
|
||||
originalPost: string;
|
||||
originalAuthor?: string;
|
||||
quoteType: QuoteType;
|
||||
language: Language;
|
||||
tone?: ContentTone;
|
||||
persona?: string;
|
||||
yourAngle?: string;
|
||||
lengthRange: LengthRange;
|
||||
}
|
||||
|
||||
export function buildQuotePrompt(params: QuotePromptParams): {
|
||||
system: string;
|
||||
user: string;
|
||||
} {
|
||||
const {
|
||||
originalPost,
|
||||
originalAuthor,
|
||||
quoteType,
|
||||
language,
|
||||
tone,
|
||||
persona,
|
||||
yourAngle,
|
||||
lengthRange,
|
||||
} = params;
|
||||
|
||||
const spec = QUOTE_TYPE_SPECS[quoteType];
|
||||
const system = QUOTE_SYSTEM_PROMPTS[language];
|
||||
|
||||
const authorLine = originalAuthor ? `Original by @${originalAuthor}` : 'Original tweet';
|
||||
const openerExamples = spec.openerHints[language].slice(0, 3).join(' | ');
|
||||
|
||||
const user = [
|
||||
`=== ${authorLine} ===`,
|
||||
`"""${originalPost}"""`,
|
||||
``,
|
||||
`=== Your quote-tweet task ===`,
|
||||
`[Target Language: ${spec.name[language]}]
|
||||
IMPORTANT: Your previous answer violated language rules.
|
||||
Rewrite strictly in ${spec.name[language]} only.`,
|
||||
`Quote type: ${spec.name[language]}`,
|
||||
`Instruction: ${spec.instruction[language]}`,
|
||||
`Avoid: ${spec.avoid[language]}`,
|
||||
``,
|
||||
`Length: ${lengthRange.min}-${lengthRange.max} chars (aim ~${lengthRange.sweet})`,
|
||||
tone ? `Tone: ${TONE_HINTS[tone]}` : '',
|
||||
persona ? `Voice/persona: ${persona}` : '',
|
||||
yourAngle ? `Your specific angle: ${yourAngle}` : '',
|
||||
``,
|
||||
`Opener style examples (pick ONE or create your own similar):`,
|
||||
` ${openerExamples}`,
|
||||
``,
|
||||
`Rules:`,
|
||||
`- Quote must stand alone — readers may NOT read the original`,
|
||||
`- Deliver value in first 280 chars even if long-form`,
|
||||
`- NO "Great post!" / "Love this!" / sycophancy`,
|
||||
`- NO AI phrases ("I think it's important to note that...")`,
|
||||
`- 0-2 hashtags MAX, only if natural`,
|
||||
`- 0-2 emojis MAX, only if they add meaning`,
|
||||
`- ${LANGUAGE_LOCK[language]}`,
|
||||
``,
|
||||
`Output: the quote-tweet text ONLY.`,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return {system, user};
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
// prompts/templates.ts
|
||||
|
||||
import {ContentStyle} from "../enum/style.enum";
|
||||
import {ContentTone} from "../enum/tone.enum";
|
||||
import {Platform} from "../enum/platform.enum";
|
||||
import {WriterPromptParams} from "../interfaces/writer-prompt-params.interface";
|
||||
import {Language} from "../../../common/interfaces/language.prompt.interface";
|
||||
import {PostLength} from "../enum/post-length.enum";
|
||||
// export type Language = 'en' | 'vi' | 'ja' | 'ko' | 'jp' | 'kr' | 'vn';
|
||||
|
||||
// ============================================================
|
||||
// LANGUAGE LABELS (dùng cho lock ngôn ngữ)
|
||||
// ============================================================
|
||||
export const LANGUAGE_NAMES: Record<Language, string> = {
|
||||
en: 'English',
|
||||
vi: 'Vietnamese (Tiếng Việt)',
|
||||
ja: 'Japanese (日本語)',
|
||||
ko: 'Korean (한국어)',
|
||||
cn: 'Chinese (中国人)',
|
||||
};
|
||||
|
||||
// Native instructions để lock ngôn ngữ chắc chắn
|
||||
export const LANGUAGE_LOCK: Record<Language, string> = {
|
||||
en: 'Output language: English ONLY. Do not use other languages.',
|
||||
cn: '输出语言:仅限中文。不会使用其他语言。',
|
||||
vi: 'Ngôn ngữ output: CHỈ Tiếng Việt. Không dùng ngôn ngữ khác.',
|
||||
ja: '出力言語: 日本語のみ。他の言語は使用しないこと。',
|
||||
ko: '출력 언어: 한국어만 사용. 다른 언어 사용 금지.',
|
||||
};
|
||||
|
||||
export const STYLE_HINTS_TELEGRAM_BUTTON = {
|
||||
[ContentStyle.GENERAL]: {
|
||||
text: 'GENERAL'
|
||||
},
|
||||
[ContentStyle.CRYPTO]: {
|
||||
text: 'Crypto/Web3'
|
||||
},
|
||||
[ContentStyle.BREAKING_NEWS]: {
|
||||
text: 'Breaking news'
|
||||
},
|
||||
[ContentStyle.TECH]: {
|
||||
text: 'Tech-savvy'
|
||||
},
|
||||
[ContentStyle.FINANCE]: {
|
||||
text: 'Financial'
|
||||
},
|
||||
[ContentStyle.LIFESTYLE]: {
|
||||
text: 'Life style'
|
||||
},
|
||||
[ContentStyle.MEME]: {
|
||||
text: 'Meme'
|
||||
},
|
||||
[ContentStyle.EDUCATIONAL]: {
|
||||
text: 'Education'
|
||||
},
|
||||
}
|
||||
|
||||
export const STYLE_HINTS: Record<ContentStyle, string> = {
|
||||
[ContentStyle.CRYPTO]: 'Crypto/Web3 tone. Use terms: bullish, alpha, gm, LFG. Add $TICKER, emojis 🚀📈. Degen but credible.',
|
||||
[ContentStyle.BREAKING_NEWS]: 'Breaking news format. Start with 🚨 BREAKING. Concise facts. Who/What/When. No fluff.',
|
||||
[ContentStyle.TECH]: 'Tech-savvy. Clear, confident. Mention specifics (tools, versions). Minimal hype.',
|
||||
[ContentStyle.FINANCE]: 'Financial, data-driven. Numbers, %, market terms. Neutral professional.',
|
||||
[ContentStyle.LIFESTYLE]: 'Warm, relatable, human. Light emojis. Story-driven.',
|
||||
[ContentStyle.MEME]: 'Funny, meme-ish, punchy. One-liner energy. Reference internet culture. Can use "[X] but actually [Y]" format',
|
||||
[ContentStyle.EDUCATIONAL]: 'Teach clearly. Structure: hook → insight → takeaway. Use analogies. Avoid jargon unless explained.',
|
||||
[ContentStyle.GENERAL]: 'Clear, engaging, neutral.',
|
||||
[ContentStyle.OPINION]: 'First-person opinion. Bold take. "I think / Hot take:". Invites debate.',
|
||||
[ContentStyle.STORYTELLING]: 'Narrative arc. Hook with tension → build → resolution. Personal or case-study.',
|
||||
[ContentStyle.THREAD]: 'Thread format. Start with hook tweet. Each point numbered. End with CTA or summary.',
|
||||
};
|
||||
|
||||
export const TONE_HINTS_TELEGRAM_BUTTON = {
|
||||
[ContentTone.PROFESSIONAL]: {text: 'chuyên nghiệp, rõ ràng, đáng tin cậy'},
|
||||
[ContentTone.CASUAL]: {text: 'Giản dị,thân thiện'},
|
||||
[ContentTone.HYPE]: {text: 'Hype-Hào hứng,tràn đầy năng lượng'},
|
||||
[ContentTone.URGENT]: {text: 'urgent'},
|
||||
[ContentTone.HUMOROUS]: {text: 'Dí dỏm, hài hước'},
|
||||
[ContentTone.INFORMATIVE]: {text: 'Thông tin, chính xác'},
|
||||
[ContentStyle.OPINION]: {text: 'Quan điểm táo bạo. \n"Tôi nghĩ / Quan điểm gây tranh cãi:". Mời gọi tranh luận.'},
|
||||
[ContentStyle.STORYTELLING]: {text: 'STORYTELLING-Cấu trúc tự sự. Mở đầu bằng sự căng thẳng → phát triển → kết thúc.\n Có thể là câu chuyện cá nhân hoặc nghiên cứu trường hợp.'},
|
||||
[ContentStyle.THREAD]: {text: 'THREAD-Bắt đầu bằng một tweet thu hút sự chú ý. \nMỗi điểm được đánh số. \nKết thúc bằng lời kêu gọi hành động (CTA) hoặc tóm tắt.'},
|
||||
}
|
||||
export const TONE_HINTS: Record<ContentTone, string> = {
|
||||
[ContentTone.PROFESSIONAL]: 'professional, clear, credible',
|
||||
[ContentTone.CASUAL]: 'casual, friendly',
|
||||
[ContentTone.HYPE]: 'hyped, energetic',
|
||||
[ContentTone.URGENT]: 'urgent, attention-grabbing',
|
||||
[ContentTone.HUMOROUS]: 'witty, humorous',
|
||||
[ContentTone.INFORMATIVE]: 'informative, factual',
|
||||
[ContentTone.EMPATHETIC]: 'empathetic, emotionally aware, validating',
|
||||
[ContentTone.PROVOCATIVE]: 'thought-provoking, slightly controversial, challenges assumptions',
|
||||
[ContentTone.AUTHORITATIVE]: 'confident, commanding, expert-voice',
|
||||
};
|
||||
|
||||
// export const PLATFORM_RULES: Record<Platform, string> = {
|
||||
// [Platform.X]: 'Max 420 chars. 2-5 hashtags. Hook in first line. No markdown.',
|
||||
// [Platform.FACEBOOK]: '400-800 chars. Can use line breaks. 2-5 hashtags. Engaging hook + CTA.',
|
||||
// };
|
||||
export const PLATFORM_RULES: Record<Platform, {
|
||||
short: string;
|
||||
detailed: (minChars: number, maxChars: number) => string;
|
||||
}> = {
|
||||
[Platform.X]: {
|
||||
short: 'Max 420 chars. 2-5 hashtags. Hook first line. No markdown.',
|
||||
detailed: (min, max) =>
|
||||
`Length: ${min}-${max} characters (aim for ~${Math.floor((min + max) / 2)}). ` +
|
||||
`Use ALL available space to deliver maximum value. ` +
|
||||
`2-5 hashtags. Strong hook in first line. No markdown.`,
|
||||
},
|
||||
[Platform.FACEBOOK]: {
|
||||
short: '400-1200 chars. Hook + body + CTA.',
|
||||
detailed: (min, max) =>
|
||||
`Length: ${min}-${max} characters (aim for ~${Math.floor((min + max) / 2)}). ` +
|
||||
`Write a FULL post: strong hook → 2-4 paragraphs of value → clear CTA. ` +
|
||||
`Use line breaks for readability. 2-5 relevant hashtags at end.`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Hướng dẫn structure theo length — QUAN TRỌNG để GPT không viết lan man.
|
||||
*/
|
||||
export const LENGTH_STRUCTURE_HINTS: Record<PostLength, {
|
||||
en: string;
|
||||
vi: string;
|
||||
ja: string;
|
||||
ko: string;
|
||||
}> = {
|
||||
[PostLength.SHORT]: {
|
||||
en: 'Single punchy statement. Hook + 1 key fact. No paragraphs. 1-2 hashtag',
|
||||
vi: 'Một câu punchy duy nhất. Hook + 1 điểm chính. Không xuống dòng. 1-2 hashtag',
|
||||
ja: '短く強烈な一文。フック + 要点1つ。段落分けなし。, 1-2ハッシュタグ',
|
||||
ko: '짧고 강렬한 한 문장. 훅 + 핵심 1개. 단락 없음., 1-2 해시태그',
|
||||
},
|
||||
[PostLength.MEDIUM]: {
|
||||
en: 'Structure: Hook line → 2-3 key points → brief takeaway. Use line breaks. 2-4 hashtag',
|
||||
vi: 'Cấu trúc: Câu hook → 2-3 điểm chính → kết luận ngắn. Dùng xuống dòng. 2-4 hashtag',
|
||||
ja: '構成: フック文 → 要点2-3個 → 短い結論。改行を使う。1-4ハッシュタグ',
|
||||
ko: '구조: 훅 문장 → 핵심 2-3개 → 짧은 결론. 줄바꿈 사용. 1-4 해시태그',
|
||||
},
|
||||
[PostLength.LONG]: {
|
||||
en: [
|
||||
'Structure: Strong hook (1 line) → Context (2-3 lines) → 3-4 key points with details → Analysis/implication → CTA or takeaway.',
|
||||
'Use line breaks between sections. Numbered or bulleted lists OK.',
|
||||
'This is a Premium long-form post — use the space to deliver REAL value, not fluff.',
|
||||
'2-4 hashtag'
|
||||
].join(' '),
|
||||
vi: [
|
||||
'Cấu trúc: Hook mạnh (1 dòng) → Bối cảnh (2-3 dòng) → 3-4 điểm chính có chi tiết → Phân tích/ý nghĩa → CTA hoặc kết luận.',
|
||||
'Dùng xuống dòng giữa các phần. List đánh số hoặc bullet OK.',
|
||||
'Đây là long-form post Premium — dùng không gian để truyền tải GIÁ TRỊ THẬT, không nhồi chữ.',
|
||||
'2-4 hashtag'
|
||||
].join(' '),
|
||||
ja: [
|
||||
'構成: 強いフック (1行) → 背景 (2-3行) → 詳細な要点3-4個 → 分析/示唆 → CTAまたは結論。',
|
||||
'セクション間は改行。番号付き・箇条書きOK。',
|
||||
'これはPremiumのロングフォーム投稿です。スペースを使って本当の価値を届けること。冗長にしない。',
|
||||
'1-4ハッシュタグ'
|
||||
].join(' '),
|
||||
ko: [
|
||||
'구조: 강한 훅 (1줄) → 배경 (2-3줄) → 상세한 핵심 3-4개 → 분석/시사점 → CTA 또는 결론.',
|
||||
'섹션 간 줄바꿈. 번호/글머리 기호 OK.',
|
||||
'Premium 롱폼 게시물 — 공간을 활용해 진짜 가치 전달. 채우기식 금지.',
|
||||
'1-4 해시태그'
|
||||
].join(' '),
|
||||
},
|
||||
[PostLength.EXTENDED]: {
|
||||
en: 'Mini-article format: headline-style hook → intro paragraph → 4-6 sections with subheadings → conclusion → CTA. Treat as authority-building content. 2-5 hashtag',
|
||||
vi: 'Format mini-article: hook kiểu tiêu đề → đoạn intro → 4-6 phần có subheading → kết luận → CTA. Coi như nội dung xây dựng authority. 2-5 hashtag',
|
||||
ja: 'ミニ記事形式: 見出し風フック → 導入段落 → 小見出し付き4-6セクション → 結論 → CTA。オーソリティ構築コンテンツとして扱う。, 2-5ハッシュタグ',
|
||||
ko: '미니 아티클 형식: 헤드라인형 훅 → 서론 → 소제목 있는 4-6 섹션 → 결론 → CTA. 권위 구축 콘텐츠로 취급. 2-5 해시태그',
|
||||
},
|
||||
[PostLength.ARTICLE]: {
|
||||
en: 'Full article: title → lede → 6-10 sections → detailed examples → strong conclusion. Write like a journalist or analyst.',
|
||||
vi: 'Bài viết đầy đủ: tiêu đề → lede → 6-10 phần → ví dụ chi tiết → kết luận mạnh. Viết như nhà báo/analyst.',
|
||||
ja: '完全な記事: タイトル → リード → 6-10セクション → 詳細な例 → 強い結論。ジャーナリストやアナリストのように書く。, 2-5ハッシュタグ',
|
||||
ko: '완전한 기사: 제목 → 리드 → 6-10 섹션 → 상세한 예시 → 강한 결론. 기자/애널리스트처럼 작성. 2-5 해시태그',
|
||||
},
|
||||
};
|
||||
|
||||
/** System prompt cho writer - cực ngắn để tiết kiệm token */
|
||||
export function buildWriterSystemPrompt(): string {
|
||||
return 'You are a social media copywriter. Write ONLY the post content, no explanations, no quotes, no preamble.';
|
||||
}
|
||||
|
||||
/** User prompt compact */
|
||||
export function buildGenericWriterPrompt(params: WriterPromptParams): {
|
||||
system: string;
|
||||
user: string;
|
||||
} {
|
||||
// const budget = calculateLengthBudget(ctx.platform, ctx.language);
|
||||
//
|
||||
// const targetLanguage = LANGUAGE_NAMES[ctx.language] || LANGUAGE_NAMES['en'];
|
||||
// const parts = [
|
||||
// `[Target Language: ${targetLanguage}]
|
||||
// IMPORTANT: Your previous answer violated language rules.
|
||||
// Rewrite strictly in ${targetLanguage} only.`,
|
||||
// `Platform: ${ctx.platform.toUpperCase()}`,
|
||||
// `${PLATFORM_RULES[ctx.platform].detailed(budget.minChars, budget.maxChars)}`,
|
||||
// `Style: ${STYLE_HINTS[ctx.style]}`,
|
||||
// `Tone: ${TONE_HINTS[ctx.tone]}`,
|
||||
// `Language: ${LANGUAGE_NAMES[ctx.language] || LANGUAGE_NAMES['en']}`,
|
||||
// `Topic: ${ctx.topic}`,
|
||||
// `⚠️ Target length: ${budget.minChars}-${budget.maxChars} characters. Write a substantive post.`,
|
||||
// ];
|
||||
//
|
||||
// if (ctx.extraInstructions) parts.push(`Extra: ${ctx.extraInstructions}`);
|
||||
// parts.push('Output: the post only.');
|
||||
// if ([ContentStyle.CRYPTO, ContentStyle.FINANCE].includes(ctx.style)) {
|
||||
// parts.push(`Append disclaimer: “\n ⚠️ This content is for informational purposes only, not financial advice. DYOR.”`)
|
||||
// }
|
||||
// return parts.join('\n');
|
||||
|
||||
|
||||
const structure = LENGTH_STRUCTURE_HINTS[params.postLength][params.language];
|
||||
|
||||
const systemByLang: Record<Language, string> = {
|
||||
en: 'You are a social media copywriter. Write ONLY the post in English. No preamble. must have hashtag',
|
||||
cn: '你是社交媒体文案撰写员。请仅用中文撰写帖子正文,无需前言,但必须包含话题标签。',
|
||||
vi: 'Bạn là copywriter MXH. CHỈ viết bài post bằng Tiếng Việt. Không giải thích. phải có hashtag',
|
||||
ja: 'ソーシャルメディアのコピーライター。投稿文のみを日本語で出力。, ハッシュタグは必須です。',
|
||||
ko: '소셜미디어 카피라이터. 게시물만 한국어로 작성. 해시태그를 사용해야 합니다.',
|
||||
};
|
||||
const targetLanguage = LANGUAGE_NAMES[params.language] || LANGUAGE_NAMES['en'];
|
||||
|
||||
const parts = [
|
||||
`[Target Language: ${targetLanguage}]
|
||||
IMPORTANT: Your previous answer violated language rules.
|
||||
Rewrite strictly in ${targetLanguage} only.`,
|
||||
`Platform: ${params.platform.toUpperCase()}`,
|
||||
`Target length: ${params.lengthRange.min}-${params.lengthRange.max} chars (aim ~${params.lengthRange.sweet})`,
|
||||
`Structure: ${structure}`,
|
||||
`Style: ${STYLE_HINTS[params.style]}`,
|
||||
`Tone: ${TONE_HINTS[params.tone]}`,
|
||||
`${LANGUAGE_LOCK[params.language]}`,
|
||||
`Topic: ${params.topic}`,
|
||||
params.extraInstructions ? `Extra: ${params.extraInstructions}` : '',
|
||||
`Output: the post only.`,
|
||||
];
|
||||
if ([ContentStyle.CRYPTO, ContentStyle.FINANCE].includes(params.style)) {
|
||||
parts.push(`Append disclaimer: “\n⚠️ This content is for informational purposes only, not financial advice. DYOR.”`)
|
||||
}
|
||||
const user = parts.filter(Boolean).join('\n');
|
||||
|
||||
return {system: systemByLang[params.language], user};
|
||||
|
||||
}
|
||||
|
||||
/** Reviewer prompt - ngắn gọn, chỉ trả về JSON */
|
||||
export function buildReviewerPrompt(draft: string, platform: Platform, style: ContentStyle, language: string): string {
|
||||
const targetLanguage = LANGUAGE_NAMES[language] || LANGUAGE_NAMES['en'];
|
||||
|
||||
return [
|
||||
`[Output MUST be in ${targetLanguage}. Do NOT translate.`,
|
||||
`Review this ${platform.toUpperCase()} post (style: ${style}).`,
|
||||
//`Rules: ${PLATFORM_RULES[platform]}`,
|
||||
`Fix: grammar, hook strength, length, clarity. Keep voice.`,
|
||||
`Return ONLY JSON: {"improved":"<final post>","notes":"<short notes>"}`,
|
||||
`---\n${draft}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const BREAKING_NEWS_TEMPLATES: Record<Language, {
|
||||
system: string;
|
||||
formatHint: string;
|
||||
prefix: string;
|
||||
}> = {
|
||||
en: {
|
||||
system: 'You are a breaking news writer for X. Write ONLY the post. No preamble, no explanations, no quotes. must have hashtag',
|
||||
formatHint: '🚨 BREAKING: [headline]\\n\\n[1-2 key facts]\\n\\n[source/impact] \\n\\n[hashtag]',
|
||||
prefix: '🚨 BREAKING',
|
||||
},
|
||||
cn: {
|
||||
system: 'You are a breaking news writer for X. Write ONLY the post. No preamble, no explanations, no quotes. must have hashtag',
|
||||
formatHint: '🚨 BREAKING: [headline]\\n\\n[1-2 key facts]\\n\\n[source/impact] \\n\\n[hashtag]',
|
||||
prefix: '🚨 BREAKING',
|
||||
},
|
||||
vi: {
|
||||
system: 'Bạn là biên tập tin nóng cho X. CHỈ viết bài post bằng Tiếng Việt. Không giải thích, không trích dẫn.',
|
||||
formatHint: '🚨 NÓNG: [tiêu đề]\\n\\n[1-2 thông tin chính]\\n\\n[nguồn/tác động] \\n\\n[hashtag]',
|
||||
prefix: '🚨 NÓNG',
|
||||
},
|
||||
ja: {
|
||||
system: 'あなたはX用の速報ライターです。投稿文のみを日本語で出力してください。説明・引用符・前置きは一切不要です。',
|
||||
formatHint: '🚨【速報】[見出し]\\n\\n[主要な事実1-2点]\\n\\n[情報源/影響] \\n\\n[해시태그.]',
|
||||
prefix: '🚨【速報】',
|
||||
},
|
||||
ko: {
|
||||
system: 'X 속보 작성자입니다. 게시물만 한국어로 작성하세요. 설명, 인용부호, 서두 모두 금지.',
|
||||
formatHint: '🚨 [속보] [헤드라인]\\n\\n[핵심 사실 1-2개]\\n\\n[출처/영향] \\n\\n[해시태그.]',
|
||||
prefix: '🚨 [속보]',
|
||||
},
|
||||
};
|
||||
|
||||
export function buildBreakingNewsPrompt(params: WriterPromptParams
|
||||
): { system: string; user: string } {
|
||||
const tpl = BREAKING_NEWS_TEMPLATES[params.language];
|
||||
// const budget = calculateLengthBudget(params.platform, params.language);
|
||||
|
||||
// const user = [
|
||||
// `Raw news content:\n${params.rawContent}`,
|
||||
// ``,
|
||||
// `Rewrite for X (280 char max):`,
|
||||
// `- Format hint: ${tpl.formatHint}`,
|
||||
// `- Start with 🚨 BREAKING / 速報 / 속보 / NÓNG`,
|
||||
// `- Keep facts accurate, NO fabrication`,
|
||||
// `- Urgent tone but not clickbait`,
|
||||
// `- 1-2 relevant hashtags`,
|
||||
// params.extraInstructions ? `- Extra: ${params.extraInstructions}` : '',
|
||||
// ].filter(Boolean).join('\n');
|
||||
// const user = [
|
||||
// `Raw news content:\n${params.topic}`,
|
||||
// ``,
|
||||
// `Rewrite for ${params.platform.toUpperCase()}:`,
|
||||
// `- ${PLATFORM_RULES[params.platform].detailed(budget.minChars, budget.maxChars)}`,
|
||||
// `- Format: ${tpl.formatHint}`,
|
||||
// `- Start with: ${tpl.prefix}`,
|
||||
// `- Tone: ${TONE_HINTS[params.tone]}`,
|
||||
// `- ⚠️ Write a COMPLETE post, not a one-liner. Aim for ${budget.minChars}-${budget.maxChars} characters.`,
|
||||
// `- Keep facts accurate, NO fabrication`,
|
||||
// `- ${LANGUAGE_LOCK[params.language]}`,
|
||||
// params.extraInstructions ? `- Extra: ${params.extraInstructions}` : '',
|
||||
// ``,
|
||||
// `Output: the post only.`,
|
||||
// ].filter(Boolean).join('\n');
|
||||
|
||||
const structure = LENGTH_STRUCTURE_HINTS[params.postLength][params.language];
|
||||
|
||||
const user = [
|
||||
`Raw news content:\n${params.topic}`,
|
||||
``,
|
||||
`Rewrite for ${params.platform.toUpperCase()}:`,
|
||||
`- Target length: ${params.lengthRange.min}-${params.lengthRange.max} chars (aim ~${params.lengthRange.sweet})`,
|
||||
`- Structure: ${structure}`,
|
||||
`- Format: ${tpl.formatHint}`,
|
||||
`- Start with: ${tpl.prefix}`,
|
||||
`- Tone: ${TONE_HINTS[params.tone]}`,
|
||||
`- Keep facts accurate, NO fabrication`,
|
||||
`- ${LANGUAGE_LOCK[params.language]}`,
|
||||
params.extraInstructions ? `- Extra: ${params.extraInstructions}` : '',
|
||||
``,
|
||||
`Output: the post only.`,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
|
||||
return {system: tpl.system, user};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// providers/ai-provider.factory.ts
|
||||
import {Injectable} from '@nestjs/common';
|
||||
import {OpenAIProvider} from './openai.provider';
|
||||
import {DeepSeekProvider} from './deepseek.provider';
|
||||
import {IAIProvider} from '../interfaces/ai-provider.interface';
|
||||
import {GrokProvider} from "./grok.provider";
|
||||
|
||||
export type ProviderName = 'openai' | 'deepseek' | 'grok';
|
||||
|
||||
@Injectable()
|
||||
export class AIProviderFactory {
|
||||
constructor(
|
||||
private readonly openai: OpenAIProvider,
|
||||
private readonly deepseek: DeepSeekProvider,
|
||||
private readonly grok: GrokProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
get(name: ProviderName): IAIProvider {
|
||||
switch (name) {
|
||||
case 'openai':
|
||||
return this.openai;
|
||||
case 'deepseek':
|
||||
return this.deepseek;
|
||||
case 'grok':
|
||||
return this.grok;
|
||||
default:
|
||||
throw new Error(`Unknown AI provider: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
getGrok(): GrokProvider {
|
||||
return this.grok;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// providers/deepseek.provider.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import OpenAI from 'openai';
|
||||
import { IAIProvider, AIMessage, AICompletionOptions, AICompletionResult } from '../interfaces/ai-provider.interface';
|
||||
|
||||
/**
|
||||
* DeepSeek dùng OpenAI-compatible API, giá cực rẻ (~$0.14/1M input).
|
||||
* Lý tưởng cho reviewer.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DeepSeekProvider implements IAIProvider {
|
||||
readonly name = 'deepseek';
|
||||
private client: OpenAI;
|
||||
private defaultModel: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.client = new OpenAI({
|
||||
apiKey: this.config.get('DEEPSEEK_API_KEY'),
|
||||
baseURL: 'https://api.deepseek.com/v1',
|
||||
});
|
||||
this.defaultModel = this.config.get('DEEPSEEK_MODEL', 'deepseek-chat');
|
||||
}
|
||||
|
||||
async complete(messages: AIMessage[], options: AICompletionOptions = {}): Promise<AICompletionResult> {
|
||||
console.log(`DeepSeekProvider_deepseek`);
|
||||
const model = options.model ?? this.defaultModel;
|
||||
const res = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages,
|
||||
temperature: options.temperature ?? 0.6,
|
||||
max_tokens: options.maxTokens ?? 400,
|
||||
});
|
||||
return {
|
||||
content: res.choices[0]?.message?.content?.trim() ?? '',
|
||||
tokensUsed: res.usage?.total_tokens ?? 0,
|
||||
model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// providers/grok.provider.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import OpenAI from 'openai';
|
||||
import { IAIProvider, AIMessage, AICompletionOptions, AICompletionResult } from '../interfaces/ai-provider.interface';
|
||||
import {ChatCompletionTool} from "openai/resources/chat/completions/completions";
|
||||
|
||||
/**
|
||||
* Grok từ xAI - OpenAI-compatible API.
|
||||
* Lợi thế: real-time X data, X-native voice.
|
||||
* Dùng cho: English breaking news + witty replies.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GrokProvider implements IAIProvider {
|
||||
readonly name = 'grok';
|
||||
private readonly logger = new Logger(GrokProvider.name);
|
||||
private client: OpenAI;
|
||||
private defaultModel: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
// this.client = new OpenAI({
|
||||
// apiKey: this.config.get('XAI_API_KEY'),
|
||||
// baseURL: 'https://api.x.ai/v1',
|
||||
// });
|
||||
this.client = new OpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: this.config.get('OPEN_ROUTER_API_KEY')
|
||||
});
|
||||
// grok-3-mini: rẻ & nhanh; grok-3: mạnh hơn
|
||||
// this.defaultModel = this.config.get('XAI_MODEL', 'x-ai/grok-3-mini');
|
||||
this.defaultModel = this.config.get('XAI_MODEL', 'x-ai/grok-4.1-fast');
|
||||
}
|
||||
|
||||
async getModelName() {
|
||||
return this.defaultModel;
|
||||
}
|
||||
|
||||
async complete(
|
||||
messages: AIMessage[],
|
||||
options: AICompletionOptions = {}
|
||||
|
||||
): Promise<AICompletionResult> {
|
||||
const model = options.model ?? this.defaultModel;
|
||||
const res = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages,
|
||||
temperature: options.temperature ?? 0.8,
|
||||
max_tokens: options.maxTokens ?? 400,
|
||||
});
|
||||
|
||||
return {
|
||||
content: res.choices[0]?.message?.content?.trim() ?? '',
|
||||
tokensUsed: res.usage?.total_tokens ?? 0,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
async complete2(
|
||||
messages: AIMessage[],
|
||||
options: AICompletionOptions = {},
|
||||
tools: Array<ChatCompletionTool> = [],
|
||||
|
||||
): Promise<AICompletionResult> {
|
||||
console.log('complete2')
|
||||
const model = options.model ?? this.defaultModel;
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-ignore
|
||||
const res = await this.client.chat.completions.create({
|
||||
model,
|
||||
// Tham số đặc biệt của OpenRouter dành cho Grok
|
||||
//@ts-ignore
|
||||
// plugins: [{ id: "web" }],
|
||||
// "plugins": [{ "id": "x_search" }], // Kích hoạt công cụ tìm kiếm X
|
||||
|
||||
extra_body: {
|
||||
"plugins": [{ "id": "x_search" }],
|
||||
"x_search_filter": {
|
||||
// "from_date": todayStr, // Định dạng: YYYY-MM-DD
|
||||
"result_type": "recent", // Lấy bài đăng mới nhất (thay vì bài đăng phổ biến)
|
||||
"count": 5 // Số lượng bài đăng tối đa muốn quét
|
||||
}
|
||||
},
|
||||
messages,
|
||||
// tools: [
|
||||
// {
|
||||
// //@ts-ignore
|
||||
// type: "openrouter:x_search", // Hoặc "openrouter:web_search" tùy theo cấu hình plugin OpenRouter
|
||||
// }
|
||||
// ],
|
||||
// // Tùy chọn: Ép mô hình dùng tìm kiếm
|
||||
// tool_choice: "auto",
|
||||
temperature: options.temperature ?? 0.8,
|
||||
max_tokens: options.maxTokens ?? 2000,
|
||||
|
||||
max_completion_tokens: 2000,
|
||||
// tools: tools,
|
||||
// tool_choice: "auto",
|
||||
//@ts-ignore
|
||||
// extra_body: {
|
||||
// search: {
|
||||
// mode: "on", // hoặc "on"
|
||||
// return_sources: true
|
||||
// }
|
||||
// }
|
||||
}).catch(err=> {
|
||||
console.log(err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
return {
|
||||
content: res.choices[0]?.message?.content?.trim() ?? '',
|
||||
tokensUsed: res.usage?.total_tokens ?? 0,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok-only: fetch X context về 1 topic (nếu API hỗ trợ live search).
|
||||
* Trả về trends/context để inject vào prompt của writer khác.
|
||||
*/
|
||||
async enrichXContext(topic: string): Promise<string> {
|
||||
const messages: AIMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You have real-time X data. Return concise context only.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Topic: "${topic}"
|
||||
Return in 3 bullet points:
|
||||
- Current trending angle/hashtags on X
|
||||
- Notable KOL reactions (if any)
|
||||
- Sentiment (bullish/bearish/mixed)
|
||||
Max 80 words total.`,
|
||||
},
|
||||
];
|
||||
|
||||
const res = await this.complete(
|
||||
messages,
|
||||
{ temperature: 0.3, maxTokens: 150 },
|
||||
).catch(err =>{
|
||||
console.log(err);
|
||||
console.log(err.message);
|
||||
throw err;
|
||||
});
|
||||
console.log({res});
|
||||
return res.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok-only: fetch X context về 1 topic (nếu API hỗ trợ live search).
|
||||
* Trả về trends/context để inject vào prompt của writer khác.
|
||||
*/
|
||||
async searchXContext(topic: string): Promise<string> {
|
||||
const userPrompt = `
|
||||
|
||||
Bạn là một hệ thống phân tích tin tức tự động.
|
||||
Khi người dùng yêu cầu tìm: ${topic}, bạn phải sử dụng công cụ tìm kiếm web để lấy thông tin mới nhất.
|
||||
DO NOT use outdated knowledge.
|
||||
DO NOT return any news older than 1 day.
|
||||
|
||||
If no recent news is found, return an empty list.
|
||||
Yêu cầu nghiêm ngặt về đầu ra:
|
||||
- Chỉ trả về duy nhất một đối tượng JSON hợp lệ.
|
||||
- Không được có bất kỳ ký tự, dòng text, giải thích hay markdown nào trước hoặc sau JSON.
|
||||
- Không được dùng \`\`\`json ... \`\`\`.
|
||||
- JSON phải đúng cú pháp, dùng dấu nháy kép (").
|
||||
|
||||
Cấu trúc JSON bắt buộc:
|
||||
{
|
||||
"news": [
|
||||
{
|
||||
"title": "tiêu đề tin",
|
||||
"summary": "tóm tắt dưới 30 chữ",
|
||||
"day": "ngày tin tức xuất hiện"
|
||||
}
|
||||
]
|
||||
}
|
||||
Chỉ trả về JSON. Không thêm bất kỳ điều gì khác.
|
||||
`;
|
||||
const now = new Date();
|
||||
const isoDate = now.toISOString().split('T')[0]; // 2026-04-24
|
||||
const prompt = `
|
||||
Search for REAL current news in ${topic} from the past 24 hours.
|
||||
|
||||
DO NOT use outdated knowledge.
|
||||
DO NOT return any news older than 1 day.
|
||||
|
||||
If no recent news is found, return an empty list.
|
||||
|
||||
Return EXACTLY 3 items in JSON.`;
|
||||
const messages: AIMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You have real-time X data. You must use search. If you cannot find recent information, return empty results.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: userPrompt,
|
||||
// content: `Topic: "${topic}"
|
||||
// Return in 3 bullet points:
|
||||
// - Current trending angle/hashtags on X
|
||||
// - Notable KOL reactions (if any)
|
||||
// - Sentiment (bullish/bearish/mixed)
|
||||
// Max 80 words total.`,
|
||||
},
|
||||
];
|
||||
console.log(messages);
|
||||
const res = await this.complete(
|
||||
messages,
|
||||
{ temperature: 0.3, maxTokens: 150 },
|
||||
).catch(err =>{
|
||||
console.log(err);
|
||||
console.log(err.message);
|
||||
throw err;
|
||||
});
|
||||
console.log(res);
|
||||
return res.content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// providers/openai.provider.ts
|
||||
import {Injectable, Logger} from '@nestjs/common';
|
||||
import {ConfigService} from '@nestjs/config';
|
||||
import OpenAI from 'openai';
|
||||
import {IAIProvider, AIMessage, AICompletionOptions, AICompletionResult} from '../interfaces/ai-provider.interface';
|
||||
import {OpenRouter} from "@openrouter/sdk";
|
||||
|
||||
@Injectable()
|
||||
export class OpenAIProvider implements IAIProvider {
|
||||
readonly name = 'openai';
|
||||
private readonly logger = new Logger(OpenAIProvider.name);
|
||||
private client: OpenAI;
|
||||
private defaultModel: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
|
||||
// const openRouter = new OpenRouter({
|
||||
// apiKey: '<OPEN_ROUTER_API_KEY>',
|
||||
// // defaultHeaders: {
|
||||
// // 'HTTP-Referer': '<YOUR_SITE_URL>', // Optional. Site URL for rankings on openrouter.ai.
|
||||
// // 'X-OpenRouter-Title': '<YOUR_SITE_NAME>', // Optional. Site title for rankings on openrouter.ai.
|
||||
// // },
|
||||
// });
|
||||
// const completion = await openRouter.chat.send({
|
||||
// model: 'openai/gpt-4o-mini',
|
||||
// messages: [
|
||||
// {
|
||||
// role: 'user',
|
||||
// content: 'What is the meaning of life?',
|
||||
// },
|
||||
// ],
|
||||
// stream: false,
|
||||
// });
|
||||
|
||||
// this.client = new OpenAI({ apiKey: this.config.get('CHATGPT_API_KEY') });
|
||||
this.client = new OpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: this.config.get('OPEN_ROUTER_API_KEY')
|
||||
});
|
||||
this.defaultModel = this.config.get('OPENAI_MODEL', 'openai/gpt-4o-mini'); // rẻ
|
||||
}
|
||||
|
||||
async complete(messages: AIMessage[], options: AICompletionOptions = {}): Promise<AICompletionResult> {
|
||||
console.log(`OpenAIProvider_complete`);
|
||||
|
||||
const model = options.model ?? this.defaultModel;
|
||||
try {
|
||||
const res = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages,
|
||||
temperature: options.temperature ?? 0.7,
|
||||
max_tokens: options.maxTokens ?? 400, // giới hạn để tiết kiệm
|
||||
});
|
||||
return {
|
||||
content: res.choices[0]?.message?.content?.trim() ?? '',
|
||||
tokensUsed: res.usage?.total_tokens ?? 0,
|
||||
model,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// services/comment-writer.service.ts
|
||||
import {Injectable, Logger} from '@nestjs/common';
|
||||
import {AIProviderFactory} from '../providers/ai-provider.factory';
|
||||
import {GenerateCommentDto} from '../dto/generate-comment.dto';
|
||||
import {buildCommentPrompt} from '../prompts/comment.templates';
|
||||
import {ProviderRouterService} from "./provider-router.service";
|
||||
|
||||
@Injectable()
|
||||
export class CommentWriterService {
|
||||
private readonly logger = new Logger(CommentWriterService.name);
|
||||
|
||||
constructor(
|
||||
private factory: AIProviderFactory,
|
||||
private router: ProviderRouterService,
|
||||
) {
|
||||
}
|
||||
|
||||
async generateComment(dto: GenerateCommentDto) {
|
||||
const decision = this.router.route({
|
||||
language: dto.language,
|
||||
contentType: 'comment',
|
||||
tone: dto.tone,
|
||||
});
|
||||
|
||||
|
||||
// GPT-4o-mini là best choice cho comment đa ngôn ngữ
|
||||
// const provider = this.factory.get('openai');
|
||||
|
||||
const provider = this.factory.get(decision.writer);
|
||||
this.logger.log(`==> Comment routing: ${decision.reason} ==>`);
|
||||
// console.log({dto})
|
||||
const {system, user} = buildCommentPrompt({
|
||||
originalPost: dto.originalPost,
|
||||
angle: dto.angle,
|
||||
language: dto.language,
|
||||
persona: dto.persona,
|
||||
tone: dto.tone,
|
||||
});
|
||||
|
||||
// console.log({system, user})
|
||||
|
||||
const res = await provider.complete(
|
||||
[
|
||||
{role: 'system', content: system},
|
||||
{role: 'user', content: user},
|
||||
],
|
||||
{
|
||||
temperature: 0.9, // cao hơn để tự nhiên
|
||||
maxTokens: 150, // comment ngắn
|
||||
},
|
||||
);
|
||||
|
||||
// console.log({res});
|
||||
|
||||
// Clean output: bỏ quotes nếu AI lỡ wrap
|
||||
const cleaned = res.content.replace(/^["""']|["""']$/g, '').trim();
|
||||
|
||||
return {
|
||||
comment: cleaned,
|
||||
tokensUsed: res.tokensUsed,
|
||||
model: res.model,
|
||||
language: dto.language,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate nhiều variant để bạn chọn
|
||||
*/
|
||||
// async generateVariants(dto: GenerateCommentDto, count = 3) {
|
||||
// const tasks = Array.from({length: count}, () => this.generateComment(dto));
|
||||
// return Promise.all(tasks);
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// services/length-strategy.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ContentStyle } from '../enum/style.enum';
|
||||
import { ContentTone } from '../enum/tone.enum';
|
||||
import { AccountTier } from '../enum/account-tier.enum';
|
||||
import { PostLength } from '../enum/post-length.enum';
|
||||
import { Platform } from '../enum/platform.enum';
|
||||
import {LENGTH_RANGES, LengthRange, PLATFORM_LIMITS} from "../config/platform-limits";
|
||||
|
||||
export interface LengthDecision {
|
||||
postLength: PostLength;
|
||||
range: LengthRange;
|
||||
hardLimit: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LengthStrategyService {
|
||||
/**
|
||||
* Quyết định độ dài tối ưu dựa trên:
|
||||
* 1. User request (nếu có)
|
||||
* 2. Content style + tone
|
||||
* 3. Account tier
|
||||
* 4. Platform
|
||||
*/
|
||||
decide(params: {
|
||||
platform: Platform;
|
||||
tier: AccountTier;
|
||||
style: ContentStyle;
|
||||
tone: ContentTone;
|
||||
requestedLength?: PostLength; // user override
|
||||
}): LengthDecision {
|
||||
const { platform, tier, style, tone, requestedLength } = params;
|
||||
const hardLimit = PLATFORM_LIMITS[platform][tier];
|
||||
|
||||
// 1. User explicit request -> tôn trọng
|
||||
if (requestedLength) {
|
||||
return {
|
||||
postLength: requestedLength,
|
||||
range: this.capRange(LENGTH_RANGES[requestedLength], hardLimit),
|
||||
hardLimit,
|
||||
reason: `User requested: ${requestedLength}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Free account -> luôn SHORT
|
||||
if (tier === AccountTier.FREE && platform === Platform.X) {
|
||||
return {
|
||||
postLength: PostLength.SHORT,
|
||||
range: LENGTH_RANGES[PostLength.SHORT],
|
||||
hardLimit: 280,
|
||||
reason: 'Free account on X: max 280 chars',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Smart default theo style (Premium/Plus)
|
||||
const styleDefault = this.styleBasedLength(style, tone);
|
||||
|
||||
return {
|
||||
postLength: styleDefault,
|
||||
range: this.capRange(LENGTH_RANGES[styleDefault], hardLimit),
|
||||
hardLimit,
|
||||
reason: `${tier} + ${style}/${tone}: ${styleDefault}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Core logic: style + tone -> length.
|
||||
* Dựa trên best practices của X Premium 2025-2026.
|
||||
*/
|
||||
private styleBasedLength(style: ContentStyle, tone: ContentTone): PostLength {
|
||||
// tone urgent -> short
|
||||
if(tone === ContentTone.URGENT) {
|
||||
return PostLength.SHORT;
|
||||
}
|
||||
// Breaking news KHẨN CẤP -> vẫn ngắn dù có Premium
|
||||
// (vì cần viral tốc độ, retweet nhanh)
|
||||
// @ts-ignore
|
||||
// if (style === ContentStyle.BREAKING_NEWS && tone === ContentTone.URGENT) {
|
||||
// return PostLength.SHORT;
|
||||
// }
|
||||
|
||||
// Breaking news non-urgent -> MEDIUM (có context)
|
||||
if (style === ContentStyle.BREAKING_NEWS) {
|
||||
return PostLength.MEDIUM;
|
||||
}
|
||||
|
||||
// Meme / Humorous -> luôn SHORT
|
||||
if (style === ContentStyle.MEME || tone === ContentTone.HUMOROUS) {
|
||||
return PostLength.SHORT;
|
||||
}
|
||||
|
||||
// Educational / Tech / Finance -> LONG (Premium sweet spot)
|
||||
if (
|
||||
style === ContentStyle.EDUCATIONAL ||
|
||||
style === ContentStyle.TECH ||
|
||||
style === ContentStyle.FINANCE
|
||||
) {
|
||||
return PostLength.LONG;
|
||||
}
|
||||
|
||||
// Crypto analysis -> MEDIUM to LONG
|
||||
if (style === ContentStyle.CRYPTO) {
|
||||
return tone === ContentTone.HYPE ? PostLength.SHORT : PostLength.MEDIUM;
|
||||
}
|
||||
|
||||
// Lifestyle -> MEDIUM
|
||||
if (style === ContentStyle.LIFESTYLE) {
|
||||
return PostLength.MEDIUM;
|
||||
}
|
||||
|
||||
// Default
|
||||
return PostLength.MEDIUM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap range trong hard limit (đảm bảo không vượt platform limit).
|
||||
*/
|
||||
private capRange(range: LengthRange, hardLimit: number): LengthRange {
|
||||
return {
|
||||
min: Math.min(range.min, hardLimit),
|
||||
max: Math.min(range.max, hardLimit),
|
||||
sweet: Math.min(range.sweet, hardLimit),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// services/prompt-builder.service.ts
|
||||
import {Injectable} from '@nestjs/common';
|
||||
import {ContentContext} from '../interfaces/content-context.interface';
|
||||
import {AIMessage} from '../interfaces/ai-provider.interface';
|
||||
import {
|
||||
buildWriterSystemPrompt,
|
||||
buildGenericWriterPrompt,
|
||||
buildReviewerPrompt,
|
||||
buildBreakingNewsPrompt
|
||||
} from '../prompts/templates';
|
||||
import {WriterPromptParams} from "../interfaces/writer-prompt-params.interface";
|
||||
|
||||
@Injectable()
|
||||
export class PromptBuilderService {
|
||||
buildWriterMessages(ctx: WriterPromptParams): AIMessage[] {
|
||||
console.debug('buildWriterMessages_ctx', ctx);
|
||||
|
||||
const prompts =ctx.style === 'breaking_news' ? buildBreakingNewsPrompt(ctx) : buildGenericWriterPrompt(ctx);
|
||||
|
||||
return [
|
||||
{role: 'system', content: prompts.system},
|
||||
{role: 'user', content: prompts.user},
|
||||
];
|
||||
}
|
||||
|
||||
buildReviewerMessages(draft: string, ctx: ContentContext): AIMessage[] {
|
||||
return [
|
||||
{role: 'system', content: 'You are a strict social media editor. Return ONLY valid JSON.'},
|
||||
{role: 'user', content: buildReviewerPrompt(draft, ctx.platform, ctx.style, ctx.language)},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// services/provider-router.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ContentStyle } from '../enum/style.enum';
|
||||
import { ProviderName } from '../providers/ai-provider.factory';
|
||||
import {Language} from "../../../common/interfaces/language.prompt.interface";
|
||||
|
||||
interface ProviderPair {
|
||||
writer: ProviderName;
|
||||
reviewer: ProviderName;
|
||||
}
|
||||
export type ContentType = 'post' | 'comment';
|
||||
interface RoutingDecision {
|
||||
writer: ProviderName;
|
||||
reviewer: ProviderName;
|
||||
useXEnrichment: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProviderRouterService {
|
||||
/**
|
||||
* Chọn cặp provider tối ưu theo style.
|
||||
* Logic: style cần creativity -> GPT viết
|
||||
* style cần accuracy -> DeepSeek viết
|
||||
*/
|
||||
// routev0(style: ContentStyle): ProviderPair {
|
||||
// const map: Record<ContentStyle, ProviderPair> = {
|
||||
// // Cần hook mạnh, emotion -> GPT viết
|
||||
// [ContentStyle.BREAKING_NEWS]: { writer: 'openai', reviewer: 'deepseek' },
|
||||
// [ContentStyle.MEME]: { writer: 'openai', reviewer: 'deepseek' },
|
||||
// [ContentStyle.LIFESTYLE]: { writer: 'openai', reviewer: 'deepseek' },
|
||||
// [ContentStyle.CRYPTO]: { writer: 'openai', reviewer: 'deepseek' },
|
||||
//
|
||||
// // Cần accuracy, logic -> DeepSeek viết
|
||||
// [ContentStyle.TECH]: { writer: 'deepseek', reviewer: 'openai' },
|
||||
// [ContentStyle.FINANCE]: { writer: 'deepseek', reviewer: 'openai' },
|
||||
// [ContentStyle.EDUCATIONAL]: { writer: 'deepseek', reviewer: 'openai' },
|
||||
//
|
||||
// // Neutral -> cheapest
|
||||
// [ContentStyle.GENERAL]: { writer: 'deepseek', reviewer: 'deepseek' },
|
||||
// };
|
||||
// return map[style];
|
||||
// }
|
||||
|
||||
/**
|
||||
* Core routing logic: dựa vào language + contentType + style.
|
||||
*/
|
||||
route(params: {
|
||||
language: Language;
|
||||
contentType: ContentType;
|
||||
style?: ContentStyle;
|
||||
tone?: string;
|
||||
}): RoutingDecision {
|
||||
const { language, contentType, style, tone } = params;
|
||||
|
||||
// === ENGLISH ===
|
||||
if (language === 'en') {
|
||||
// Breaking news EN -> Grok (real-time + X-native)
|
||||
if (style === ContentStyle.BREAKING_NEWS) {
|
||||
return {
|
||||
writer: 'grok',
|
||||
reviewer: 'deepseek',
|
||||
useXEnrichment: true,
|
||||
reason: 'EN breaking news: Grok has real-time X context',
|
||||
};
|
||||
}
|
||||
|
||||
// Comment EN casual/witty -> Grok
|
||||
if (contentType === 'comment' && tone !== 'professional') {
|
||||
return {
|
||||
writer: 'grok',
|
||||
reviewer: 'deepseek',
|
||||
useXEnrichment: false,
|
||||
reason: 'EN casual comment: Grok sounds most human on X',
|
||||
};
|
||||
}
|
||||
|
||||
// Comment EN professional/analyst -> GPT
|
||||
if (contentType === 'comment' && tone === 'professional') {
|
||||
return {
|
||||
writer: 'openai',
|
||||
reviewer: 'deepseek',
|
||||
useXEnrichment: false,
|
||||
reason: 'EN professional comment: GPT more consistent',
|
||||
};
|
||||
}
|
||||
|
||||
// Default EN
|
||||
return {
|
||||
writer: 'openai',
|
||||
reviewer: 'deepseek',
|
||||
useXEnrichment: false,
|
||||
reason: 'EN default: GPT reliable',
|
||||
};
|
||||
}
|
||||
|
||||
// === JP / KR / VI ===
|
||||
// GPT thắng áp đảo với non-English
|
||||
// @ts-ignore
|
||||
return {
|
||||
writer: 'openai',
|
||||
reviewer: 'deepseek',
|
||||
useXEnrichment: [ContentStyle.BREAKING_NEWS].includes(style!), // breaking news cần phải dùng X tìm cho hay
|
||||
reason: `${language.toUpperCase()}: GPT superior for non-English`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// services/quote-writer.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AIProviderFactory } from '../providers/ai-provider.factory';
|
||||
import { ProviderRouterService } from './provider-router.service';
|
||||
import { LengthStrategyService } from './length-strategy.service';
|
||||
import { ReviewerService } from './reviewer.service';
|
||||
import { GenerateQuoteDto } from '../dto/generate-quote.dto';
|
||||
import { buildQuotePrompt, suggestQuoteType } from '../prompts/quote.templates';
|
||||
import { AccountTier } from '../enum/account-tier.enum';
|
||||
import { Platform } from '../enum/platform.enum';
|
||||
import { ContentStyle } from '../enum/style.enum';
|
||||
import { ContentTone } from '../enum/tone.enum';
|
||||
import {calculateTokenBudget} from "../../../common/utils/token-calculator";
|
||||
import {QuoteType} from "../enum/quote-type.enum";
|
||||
|
||||
@Injectable()
|
||||
export class QuoteWriterService {
|
||||
private readonly logger = new Logger(QuoteWriterService.name);
|
||||
|
||||
constructor(
|
||||
private factory: AIProviderFactory,
|
||||
private router: ProviderRouterService,
|
||||
private lengthStrategy: LengthStrategyService,
|
||||
private reviewer: ReviewerService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async generateQuote(dto: GenerateQuoteDto) {
|
||||
this.logger.debug(`==> QuoteWriterService_generateQuote`);
|
||||
// 1. Auto-detect quote type nếu không có
|
||||
const quoteType = dto.quoteType ?? suggestQuoteType(dto.originalPost, dto.yourAngle);
|
||||
this.logger.log(`Quote type: ${quoteType}`);
|
||||
|
||||
// 2. Tier & length
|
||||
const tier = dto.accountTier ?? this.config.get<AccountTier>('X_ACCOUNT_TIER', AccountTier.PREMIUM);
|
||||
|
||||
const lengthDecision = this.lengthStrategy.decide({
|
||||
platform: Platform.X,
|
||||
tier,
|
||||
style: ContentStyle.GENERAL, // quote không map style thông thường
|
||||
tone: dto.tone ?? ContentTone.CASUAL,
|
||||
requestedLength: dto.postLength,
|
||||
});
|
||||
|
||||
const budget = calculateTokenBudget(lengthDecision.range, dto.language);
|
||||
|
||||
// 3. Router — quote EN ưu tiên Grok (X-native voice), non-EN -> GPT
|
||||
const providerDecision = this.router.route({
|
||||
language: dto.language,
|
||||
contentType: 'comment', // quote giống comment hơn post về routing
|
||||
tone: dto.tone,
|
||||
});
|
||||
console.log({lengthDecision,tier, budget,providerDecision});
|
||||
// 4. Build prompt
|
||||
const { system, user } = buildQuotePrompt({
|
||||
originalPost: dto.originalPost,
|
||||
originalAuthor: dto.originalAuthor,
|
||||
quoteType,
|
||||
language: dto.language,
|
||||
tone: dto.tone,
|
||||
persona: dto.persona,
|
||||
yourAngle: dto.yourAngle,
|
||||
lengthRange: lengthDecision.range,
|
||||
});
|
||||
|
||||
console.log({providerDecision, system, user})
|
||||
|
||||
// 5. Generate
|
||||
const provider = this.factory.get(providerDecision.writer);
|
||||
const res = await provider.complete(
|
||||
[
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
{
|
||||
temperature: 0.85, // cao để quote có personality
|
||||
maxTokens: budget.maxTokens,
|
||||
},
|
||||
);
|
||||
|
||||
let quote = this.cleanOutput(res.content);
|
||||
let reviewNotes: string | undefined;
|
||||
let totalTokens = res.tokensUsed;
|
||||
let modelUsed = res.model;
|
||||
|
||||
// 6. Optional review
|
||||
if (dto.enableReview) {
|
||||
const ctx = {
|
||||
topic: dto.originalPost,
|
||||
platform: Platform.X,
|
||||
style: ContentStyle.GENERAL,
|
||||
tone: dto.tone ?? ContentTone.CASUAL,
|
||||
language: dto.language,
|
||||
} as any;
|
||||
|
||||
try {
|
||||
const reviewed = await this.reviewer.review(
|
||||
quote,
|
||||
ctx,
|
||||
providerDecision.reviewer,
|
||||
dto.originalPost,
|
||||
Math.ceil(budget.maxTokens * 1.3),
|
||||
);
|
||||
if (reviewed.languageValid) {
|
||||
quote = this.cleanOutput(reviewed.improved);
|
||||
reviewNotes = reviewed.notes;
|
||||
}
|
||||
totalTokens += reviewed.tokensUsed;
|
||||
modelUsed = `${res.model} + ${reviewed.model}`;
|
||||
} catch (err) {
|
||||
this.logger.warn('Quote review failed, using draft', err);
|
||||
}
|
||||
}
|
||||
|
||||
// // 7. Hard cap
|
||||
// if (quote.length > lengthDecision.hardLimit) {
|
||||
// quote = quote.substring(0, lengthDecision.hardLimit);
|
||||
// }
|
||||
|
||||
return {
|
||||
quote,
|
||||
quoteType,
|
||||
charCount: quote.length,
|
||||
language: dto.language,
|
||||
reviewNotes,
|
||||
tokensUsed: totalTokens,
|
||||
model: modelUsed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate nhiều variants để bạn chọn bài hay nhất.
|
||||
*/
|
||||
async generateVariants(dto: GenerateQuoteDto, count = 3) {
|
||||
const tasks = Array.from({ length: count }, () => this.generateQuote(dto));
|
||||
return Promise.all(tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 1 quote cho MỖI quote type — để bạn thấy tất cả góc nhìn.
|
||||
*/
|
||||
async generateAllAngles(dto: GenerateQuoteDto) {
|
||||
const types = Object.values(QuoteType) as QuoteType[];
|
||||
const tasks = types.map((t) =>
|
||||
this.generateQuote({ ...dto, quoteType: t }).catch((e) => ({
|
||||
quoteType: t,
|
||||
error: e.message,
|
||||
})),
|
||||
);
|
||||
return Promise.all(tasks);
|
||||
}
|
||||
|
||||
private cleanOutput(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/^["""'「『]+|["""'」』]+$/g, '') // bỏ quote wrapping
|
||||
.replace(/^(Here is|Here's|Quote:|以下|다음은)[^\n]*\n+/i, '') // bỏ AI preamble
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// services/reviewer.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AIProviderFactory, ProviderName } from '../providers/ai-provider.factory';
|
||||
import { PromptBuilderService } from './prompt-builder.service';
|
||||
import { ContentContext } from '../interfaces/content-context.interface';
|
||||
import {AIMessage} from "../interfaces/ai-provider.interface";
|
||||
|
||||
export interface ReviewResult {
|
||||
improved: string;
|
||||
notes: string;
|
||||
tokensUsed: number;
|
||||
model: string;
|
||||
languageValid: boolean;
|
||||
prompt?: AIMessage[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReviewerService {
|
||||
private readonly logger = new Logger(ReviewerService.name);
|
||||
|
||||
constructor(
|
||||
private factory: AIProviderFactory,
|
||||
private promptBuilder: PromptBuilderService,
|
||||
) {}
|
||||
|
||||
async review(
|
||||
draft: string,
|
||||
ctx: ContentContext,
|
||||
providerName: ProviderName = 'deepseek',
|
||||
originalTopic:string,
|
||||
maxToken:number// rẻ nhất cho review
|
||||
): Promise<ReviewResult> {
|
||||
console.log('==> ReviewerService_review: ');
|
||||
const provider = this.factory.get(providerName);
|
||||
const messages = this.promptBuilder.buildReviewerMessages(draft, ctx);
|
||||
console.log('==> ReviewerService_review_promp:==> ');
|
||||
|
||||
const res = await provider.complete(messages, { temperature: 0.3, maxTokens: maxToken });
|
||||
|
||||
// Parse JSON an toàn
|
||||
const parsed = this.safeParseJson(res.content);
|
||||
return {
|
||||
improved: parsed?.improved ?? draft,
|
||||
notes: parsed?.notes ?? '',
|
||||
tokensUsed: res.tokensUsed,
|
||||
model: res.model,
|
||||
prompt: messages,
|
||||
languageValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
private safeParseJson(text: string): { improved?: string; notes?: string } | null {
|
||||
try {
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (!match) return null;
|
||||
return JSON.parse(match[0]);
|
||||
} catch (e) {
|
||||
this.logger.warn('Reviewer returned non-JSON output');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// services/style-detector.service.ts
|
||||
import {Injectable, Logger} from '@nestjs/common';
|
||||
import {ContentStyle} from '../enum/style.enum';
|
||||
import {ContentTone} from '../enum/tone.enum';
|
||||
import {Language} from "../../../common/interfaces/language.prompt.interface";
|
||||
import {getLanguageByJSTTime} from "../../../shared/helper";
|
||||
|
||||
/**
|
||||
* Heuristic keyword-based detector - 0 token cost.
|
||||
* Chỉ fallback gọi AI nếu không match gì (optional).
|
||||
*/
|
||||
@Injectable()
|
||||
export class StyleDetectorService {
|
||||
private readonly logger = new Logger(StyleDetectorService.name);
|
||||
private readonly styleKeywords: Record<ContentStyle, RegExp> = {
|
||||
[ContentStyle.CRYPTO]: /\b(btc|eth|sol|crypto|token|defi|nft|airdrop|bullish|bearish|pump|dump|web3|dex|memecoin|on-chain|layer2|testnet|mainnet|whitelist|degen|\$[a-z]{2,10})\b/i,
|
||||
[ContentStyle.BREAKING_NEWS]: /\b(breaking|Tin nhanh|họp báo|cảnh báo|🔴|just in|urgent|announced|report(ed)?|confirms?|leaked)\b/i,
|
||||
[ContentStyle.TECH]: /\b(ai model|ai tool|ai agent|docker|kubernetes|microservice|claude|cursor|open ai|api|sdk|framework|nestjs|react|python|github|opensource|llm|gpt|model)\b/i,
|
||||
[ContentStyle.FINANCE]: /\b(stock|dự báo|market|fed|inflation|nasdaq|sp500|s&p500|earnings|ipo|yield|portfolio|hedge|cpi|gdp|quarterly|interest rate|lãi suất|chứng khoán|cổ phiếu|quỹ|bond)\b/i,
|
||||
[ContentStyle.LIFESTYLE]: /\b(morning|coffee|travel|family|food|recipe|wellness|selfcare|self-care|mindset|routine,grateful|vibe|sunday|weekend|balance)\b/i,
|
||||
[ContentStyle.MEME]: /\b(lol|lmao|meme|funny|hits different|living rent free|understood the assignment|no cap|fr fr|😂|🤣)\b/i,
|
||||
[ContentStyle.EDUCATIONAL]: /\b(how to|tutorial|guide|learn|explain|tips?)\b/i,
|
||||
[ContentStyle.OPINION]: /\b(opinion|hot take|unpopular|i think|my take|controversial|change my mind|fight me|disagree)\b/i,
|
||||
[ContentStyle.STORYTELLING]: /\b(thread|story time|true story|happened to me|years ago|flashback|let me tell you|here's how)\b/i,
|
||||
[ContentStyle.THREAD]: /\b(thread| 🧵|a thread|part 1)\b/i,
|
||||
[ContentStyle.GENERAL]: /.^/, // never match
|
||||
};
|
||||
|
||||
private readonly toneKeywords: Record<ContentTone, RegExp> = {
|
||||
[ContentTone.URGENT]: /\b(urgent|now|🔴|immediately|just dropped|breaking|họp báo|cảnh báo|khẩn|quan trọng| thông báo gấp|alert)\b/i,
|
||||
[ContentTone.HYPE]: /\b(lfg|huge|massive|insane|alpha|don't miss|🚀|🔥)\b/i,
|
||||
[ContentTone.HUMOROUS]: /\b(lol|funny|joke|meme|plot twist|not me|understood the assignment|lmao|bruh|no way|bestie)\b/i,
|
||||
[ContentTone.PROFESSIONAL]: /\b(report|analysis|official|statement)\b/i,
|
||||
[ContentTone.INFORMATIVE]: /\b(study|data|research|found|shows)\b/i,
|
||||
[ContentTone.EMPATHETIC]: /\b(feel|feeling|understand|hard|tough|going through|been there|sending love|mental health|burnout|struggle)\b/i,
|
||||
[ContentTone.PROVOCATIVE]: /\b(change my mind|unpopular opinion|fight me|controversial|hot take|nobody talks about|am i wrong, be honest)\b/i,
|
||||
[ContentTone.AUTHORITATIVE]: /\b(the truth is|let me be clear|fact:|experience shows|from my experience|data shows|evidence|proven|decades)\b/i,
|
||||
[ContentTone.CASUAL]: /.^/,
|
||||
};
|
||||
|
||||
detectStyle(text: string): ContentStyle {
|
||||
this.logger.debug('===> styleDetectorService_detectStyle', text);
|
||||
const scores: Partial<Record<ContentStyle, number>> = {};
|
||||
for (const [style, regex] of Object.entries(this.styleKeywords)) {
|
||||
const matches = text.match(new RegExp(regex, 'gi'));
|
||||
if (matches) scores[style as ContentStyle] = matches.length;
|
||||
}
|
||||
const best = Object.entries(scores).sort((a, b) => b[1]! - a[1]!)[0];
|
||||
return (best?.[0] as ContentStyle) ?? ContentStyle.GENERAL;
|
||||
}
|
||||
|
||||
detectTone(text: string): ContentTone {
|
||||
this.logger.debug('===> styleDetectorService_detectTone', text);
|
||||
|
||||
for (const [tone, regex] of Object.entries(this.toneKeywords)) {
|
||||
if (regex.test(text)) return tone as ContentTone;
|
||||
}
|
||||
return ContentTone.CASUAL;
|
||||
}
|
||||
|
||||
detectLanguageFromTelegramAutoContent(text: string): Language {
|
||||
if (/nhật[ _]bản/i.test(text)) return "ja";
|
||||
if (/#hàn_quốc/i.test(text)) return "ko";
|
||||
|
||||
// return getLanguageByJSTTime();
|
||||
//
|
||||
return "en"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// src/modules/manager/manager.module.ts
|
||||
import {Module} from '@nestjs/common';
|
||||
import {BullModule} from '@nestjs/bullmq';
|
||||
import {ManagerService} from './manager.service';
|
||||
import {ManagerProcessor} from './manager.processor';
|
||||
import {AIService} from "../../shared/ai.service";
|
||||
import {BullBoardModule} from "@bull-board/nestjs";
|
||||
import {BullMQAdapter} from "@bull-board/api/bullMQAdapter";
|
||||
import {GrokProvider} from "../content-writer/providers/grok.provider";
|
||||
import {ContentWriterModule} from "../content-writer/content-writer.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ContentWriterModule,
|
||||
BullModule.registerQueue(
|
||||
{name: 'content_writer_queue'}, // Hàng đợi cho AI-B
|
||||
// Hàng đợi cho AI-B
|
||||
{name: 'download_video_queue'},
|
||||
{name: 'content_writer_completed_queue'},// Hàng đợi cho AI-C
|
||||
{name: 'manager_task_queue'},// Hàng đợi cho AI-C
|
||||
{name: 'comment_writer_queue'},// Hàng đợi cho AI-C
|
||||
{name: 'comment_writer_completed_queue'},// Hàng đợi cho AI-C
|
||||
),
|
||||
BullBoardModule.forFeature(
|
||||
{
|
||||
name: 'manager_task_queue',
|
||||
adapter: BullMQAdapter
|
||||
},
|
||||
{
|
||||
name: 'content_writer_queue',
|
||||
adapter: BullMQAdapter
|
||||
},
|
||||
{
|
||||
name: 'comment_writer_queue',
|
||||
adapter: BullMQAdapter
|
||||
},
|
||||
{
|
||||
name: 'download_video_queue',
|
||||
adapter: BullMQAdapter
|
||||
},
|
||||
{
|
||||
name: 'content_writer_completed_queue',
|
||||
adapter: BullMQAdapter
|
||||
},
|
||||
{
|
||||
name: 'comment_writer_completed_queue',
|
||||
adapter: BullMQAdapter
|
||||
},
|
||||
),
|
||||
],
|
||||
providers: [
|
||||
ManagerService,
|
||||
ManagerProcessor,
|
||||
AIService,
|
||||
GrokProvider,
|
||||
// ContentWriterService,
|
||||
// StyleDetectorService
|
||||
],
|
||||
exports: [ManagerService],
|
||||
})
|
||||
export class ManagerModule {
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// src/modules/manager/manager.processor.ts
|
||||
import {OnWorkerEvent, Processor, WorkerHost} from '@nestjs/bullmq';
|
||||
import {Job} from 'bullmq';
|
||||
import {AIService} from '../../shared/ai.service';
|
||||
import {InjectBot} from "nestjs-telegraf";
|
||||
import {Context, Telegraf} from "telegraf";
|
||||
import {_JsonToStr} from "../../shared/helper";
|
||||
import {ContentWriterService} from "../content-writer/content-writer.service";
|
||||
import {GenerateContentDto} from "../content-writer/dto/generate-content.dto";
|
||||
|
||||
@Processor('manager_task_queue')
|
||||
export class ManagerProcessor extends WorkerHost {
|
||||
private adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
|
||||
|
||||
constructor(
|
||||
private aiService: AIService,
|
||||
private contentWriterService: ContentWriterService,
|
||||
@InjectBot() private readonly bot: Telegraf<Context>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<any, any, string>): Promise<any> {
|
||||
console.log(`ManagerProcessor ==> process => ${job.name}`);
|
||||
console.log('job_data', job.data);
|
||||
|
||||
let {rawData, style, telegramChatId} = job.data;
|
||||
|
||||
switch (job.name) {
|
||||
case 'analyze_news':
|
||||
// Gọi AI phân tích
|
||||
let analysis = await this.aiService.analyzeTrend(rawData, style);
|
||||
// console.log(analysis.result);
|
||||
|
||||
// @ts-ignore
|
||||
await this.bot.telegram.sendMessage(this.adminChatId, _JsonToStr(analysis.result));
|
||||
|
||||
if (analysis.is_relevant && analysis.hot_score > 7) {
|
||||
// Đẩy tiếp sang Queue của AI-B (Facebook) và AI-C (TikTok)
|
||||
return analysis;
|
||||
}
|
||||
return {status: 'skipped'};
|
||||
case 'asking': {
|
||||
// Gọi AI phân tích
|
||||
const returnQuestion = await this.aiService.askQuestion(rawData);
|
||||
// console.log(analysis.result);
|
||||
console.log('telegramChatId:', telegramChatId);
|
||||
await this.bot.telegram.sendMessage(telegramChatId > 0 ? telegramChatId : this.adminChatId,
|
||||
returnQuestion.content,
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
}
|
||||
);
|
||||
|
||||
return {status: 'completed'};
|
||||
}
|
||||
case 'enrich_x_context': {
|
||||
const dto: GenerateContentDto = {
|
||||
topic: rawData,
|
||||
enableReview: true,
|
||||
language: 'vi',
|
||||
useXEnrichment: true,
|
||||
}
|
||||
const xContent = await this.contentWriterService.generate(dto)
|
||||
console.log('enrich_x_context:', xContent);
|
||||
await this.bot.telegram.sendMessage(telegramChatId > 0 ? telegramChatId : this.adminChatId,
|
||||
xContent.final,
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
}
|
||||
);
|
||||
|
||||
return {status: 'completed'};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
async onCompleted(job: Job<any>) {
|
||||
console.log(`ManagerProcessor ==> onCompleted => ${job.name}`);
|
||||
//console.log({job});
|
||||
|
||||
return {status: 'completed'};
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// src/modules/manager/manager.service.ts
|
||||
import {Injectable} from '@nestjs/common';
|
||||
import {InjectQueue} from '@nestjs/bullmq';
|
||||
import {Queue} from 'bullmq';
|
||||
import {QuoteType} from "../content-writer/enum/quote-type.enum";
|
||||
|
||||
@Injectable()
|
||||
export class ManagerService {
|
||||
constructor(
|
||||
@InjectQueue('content_writer_queue') private fbQueue: Queue,
|
||||
@InjectQueue('comment_writer_queue') private commentQueue: Queue,
|
||||
@InjectQueue('download_video_queue') private downloadVideoQueue: Queue,
|
||||
@InjectQueue('content_writer_completed_queue') private fbContentCompletedQueue: Queue,
|
||||
@InjectQueue('manager_task_queue') private managerTaskQueue: Queue,
|
||||
) {
|
||||
}
|
||||
|
||||
async handleAnalyzeNewsTrend(question: string) {
|
||||
await this.managerTaskQueue.add('analyze_news', {
|
||||
rawData: question,
|
||||
style: 'analytic'
|
||||
}, {attempts: 1, backoff: 5000});
|
||||
// // 2. Đẩy việc cho AI-C tìm video TikTok
|
||||
// await this.ttQueue.add('find_video', {
|
||||
// keyword: trendData.keyword,
|
||||
// }, {attempts: 2});
|
||||
}
|
||||
|
||||
async handleDownloadVideo(
|
||||
url: string,
|
||||
telegramChatId: number = 0
|
||||
) {
|
||||
await this.downloadVideoQueue.add('download', {
|
||||
url: url,
|
||||
type: 'video/mp4',
|
||||
telegramChatId
|
||||
}, {attempts: 1, backoff: 5000})
|
||||
}
|
||||
|
||||
async handleDownloadVideoMulti(
|
||||
urls: Array<string>,
|
||||
telegramChatId: number = 0
|
||||
) {
|
||||
await this.downloadVideoQueue.add('download_multi', {
|
||||
urls: urls,
|
||||
telegramChatId
|
||||
}, {attempts: 1, backoff: 5000})
|
||||
}
|
||||
|
||||
async handleDownloadTiktok(url: string, telegramChatId: number = 0) {
|
||||
await this.downloadVideoQueue.add('download_tiktok', {
|
||||
url: url,
|
||||
type: 'video/mp4',
|
||||
telegramChatId
|
||||
}, {attempts: 1, backoff: 5000})
|
||||
}
|
||||
|
||||
async handleDownloadFacebookReels(url: string, telegramChatId: number = 0) {
|
||||
await this.downloadVideoQueue.add('facebook_reels', {
|
||||
url: url,
|
||||
type: 'video/mp4',
|
||||
telegramChatId,
|
||||
sourceType: 'fb'
|
||||
}, {attempts: 1, backoff: 5000})
|
||||
}
|
||||
|
||||
async handleDownloadMp4Url(
|
||||
url: string,
|
||||
sourceType = 'x',
|
||||
telegramChatId: number = 0
|
||||
) {
|
||||
await this.downloadVideoQueue.add('mp4url', {
|
||||
url: url,
|
||||
type: 'video/mp4',
|
||||
telegramChatId,
|
||||
sourceType
|
||||
}, {attempts: 1, backoff: 5000})
|
||||
}
|
||||
|
||||
async handleAskQues(question: string, telegramChatId: number = 0) {
|
||||
await this.managerTaskQueue.add('asking', {
|
||||
rawData: question,
|
||||
telegramChatId
|
||||
}, {attempts: 1, backoff: 5000});
|
||||
// // 2. Đẩy việc cho AI-C tìm video TikTok
|
||||
// await this.ttQueue.add('find_video', {
|
||||
// keyword: trendData.keyword,
|
||||
// }, {attempts: 2});
|
||||
}
|
||||
|
||||
async handleAskXContext(question: string, telegramChatId: number = 0) {
|
||||
await this.managerTaskQueue.add('enrich_x_context', {
|
||||
rawData: question,
|
||||
telegramChatId
|
||||
}, {attempts: 1, backoff: 5000});
|
||||
// // 2. Đẩy việc cho AI-C tìm video TikTok
|
||||
// await this.ttQueue.add('find_video', {
|
||||
// keyword: trendData.keyword,
|
||||
// }, {attempts: 2});
|
||||
}
|
||||
|
||||
async manualTrigger(keyword: string, style = 'general', preferLanguage = 'en') {
|
||||
// 1. Đẩy việc cho AI-B viết bài FB
|
||||
const manualtriggerresult = await this.fbQueue.add('generate_post_ver1', {
|
||||
title: keyword,
|
||||
style,
|
||||
language: preferLanguage,
|
||||
}, {
|
||||
attempts: 1,
|
||||
backoff: 5000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
|
||||
//console.log(manualtriggerresult);
|
||||
|
||||
return {
|
||||
needsConfirm: 1,
|
||||
summary: '',
|
||||
id: '',
|
||||
}
|
||||
}
|
||||
|
||||
async manualTriggerWriteWithReview(
|
||||
dto:
|
||||
{
|
||||
keyword: string,
|
||||
style: 'general',
|
||||
language: 'en',
|
||||
tone: undefined,
|
||||
postLength: undefined,
|
||||
telegramChatId: undefined,
|
||||
},
|
||||
) {
|
||||
// 1. Đẩy việc cho AI-B viết bài FB
|
||||
const manualtriggerresult = await this.fbQueue.add('generate_post_ver2', {
|
||||
title: dto.keyword,
|
||||
style: dto.style,
|
||||
language: dto.language,
|
||||
tone: dto.tone,
|
||||
postLength: dto.postLength,
|
||||
enableReview: true,
|
||||
telegramChatId: dto.telegramChatId,
|
||||
}, {
|
||||
attempts: 1,
|
||||
backoff: 5000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
|
||||
//console.log(manualtriggerresult);
|
||||
|
||||
return {
|
||||
needsConfirm: 1,
|
||||
summary: '',
|
||||
id: '',
|
||||
}
|
||||
}
|
||||
|
||||
async manualTriggerCommentLinkTwitter(TwitterUrl, preferLanguage = 'en', telegramChatId = '') {
|
||||
await this.commentQueue.add('generate_comment_twitter', {
|
||||
url: TwitterUrl,
|
||||
language: preferLanguage,
|
||||
telegramChatId,
|
||||
}, {
|
||||
attempts: 1,
|
||||
backoff: 5000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
|
||||
//console.log(manualtriggerresult);
|
||||
|
||||
return {
|
||||
needsConfirm: 1,
|
||||
summary: '',
|
||||
id: '',
|
||||
}
|
||||
}
|
||||
|
||||
async manualTriggerCommentAsText({
|
||||
comtext,
|
||||
tone,
|
||||
agle,
|
||||
language = 'en',
|
||||
tweetId = undefined,
|
||||
tweetUrl = undefined,
|
||||
chatId = undefined,
|
||||
}) {
|
||||
await this.commentQueue.add('generate_comment_as_text_twitter', {
|
||||
comtext,
|
||||
language,
|
||||
tone,
|
||||
agle,
|
||||
tweetId,
|
||||
tweetUrl,
|
||||
telegramChatId: chatId
|
||||
}, {
|
||||
attempts: 1,
|
||||
backoff: 5000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
|
||||
//console.log(manualtriggerresult);
|
||||
|
||||
return {
|
||||
needsConfirm: 1,
|
||||
summary: '',
|
||||
id: '',
|
||||
}
|
||||
}
|
||||
|
||||
async manualTriggerQuoteLinkTwitter(TwitterUrl, quoteType?: QuoteType, preferLanguage = 'en', telegramChatId = '') {
|
||||
await this.commentQueue.add('generate_quote_twitter', {
|
||||
url: TwitterUrl,
|
||||
quoteType,
|
||||
language: preferLanguage,
|
||||
telegramChatId,
|
||||
}, {
|
||||
attempts: 1,
|
||||
backoff: 5000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
needsConfirm: 1,
|
||||
summary: '',
|
||||
id: '',
|
||||
}
|
||||
}
|
||||
|
||||
async manualTriggerQuoteAsTextInput(userTopic, quoteType?: QuoteType, preferLanguage = 'en', telegramChatId = '') {
|
||||
await this.commentQueue.add('generate_quote_twitter_as_text_input', {
|
||||
quoteText: userTopic,
|
||||
quoteType,
|
||||
language: preferLanguage,
|
||||
telegramChatId
|
||||
}, {
|
||||
attempts: 1,
|
||||
backoff: 5000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
needsConfirm: 1,
|
||||
summary: '',
|
||||
id: '',
|
||||
}
|
||||
}
|
||||
|
||||
async sendEventFbContentCompletedQueue(pgPostId: number, content?: string): Promise<void> {
|
||||
// 1. Đẩy việc vao queue completed
|
||||
// await this.fbContentCompletedQueue.add('generate_post_completed', {
|
||||
// pgPostId: pgPostId,
|
||||
// content,
|
||||
// }, {attempts: 1, backoff: 5000});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { SchedulerService } from './scheduler.service';
|
||||
import { TrendsModule } from '../trends/trends.module';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), TrendsModule],
|
||||
providers: [SchedulerService],
|
||||
})
|
||||
export class SchedulerModule {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { TrendsService } from '../trends/trends.service';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulerService {
|
||||
private readonly logger = new Logger(SchedulerService.name);
|
||||
private isCollecting = false;
|
||||
|
||||
constructor(private readonly trendsService: TrendsService) {}
|
||||
|
||||
/**
|
||||
* Thu thập mỗi 2 giờ
|
||||
*/
|
||||
@Cron('0 */2 * * *')
|
||||
async scheduledCollection() {
|
||||
//clear before
|
||||
this.logger.log('🧹 Running daily cleanup...');
|
||||
const deleted = await this.trendsService.cleanupOldTrends(2);
|
||||
this.logger.log(`🧹 Cleanup done: removed ${deleted} old trends`);
|
||||
|
||||
if (this.isCollecting) {
|
||||
this.logger.warn('Previous collection still running — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCollecting = true;
|
||||
this.logger.log('⏰ Scheduled collection starting...');
|
||||
|
||||
try {
|
||||
const result = await this.trendsService.collectAndStore();
|
||||
this.logger.log(
|
||||
`⏰ Scheduled collection done: ${result.stats.afterDedup} items in ${result.stats.durationMs}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`⏰ Scheduled collection failed: ${error.message}`);
|
||||
} finally {
|
||||
this.isCollecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup mỗi ngày lúc 3h sáng
|
||||
*/
|
||||
@Cron('0 3 * * *')
|
||||
async scheduledCleanup() {
|
||||
this.logger.log('🧹 Running daily cleanup...');
|
||||
const deleted = await this.trendsService.cleanupOldTrends(3);
|
||||
this.logger.log(`🧹 Cleanup done: removed ${deleted} old trends`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thu thập lần đầu khi app start (sau 10s delay)
|
||||
*/
|
||||
async onApplicationBootstrap() {
|
||||
setTimeout(async () => {
|
||||
this.logger.log('🚀 Initial collection on startup...');
|
||||
try {
|
||||
await this.scheduledCollection();
|
||||
} catch (error) {
|
||||
this.logger.error(`Initial collection failed: ${error.message}`);
|
||||
}
|
||||
}, 10_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// dto/create-tweet.dto.ts
|
||||
import { IsString, IsOptional, IsArray, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateTweetDto {
|
||||
@IsString()
|
||||
// @MaxLength(280)
|
||||
text: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
mediaIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
replyToTweetId?: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {IsOptional, IsString} from "class-validator";
|
||||
|
||||
export class XCookieAccountDto {
|
||||
@IsString()
|
||||
authToken: string; // auth_token cookie
|
||||
|
||||
@IsString()
|
||||
ct0: string; // ct0 cookie (CSRF token)
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
proxy?: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// src/modules/social/facebook-api.service.ts
|
||||
import {Injectable, HttpException, HttpStatus} from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class FacebookApi {
|
||||
private readonly fbBaseUrl = 'https://graph.facebook.com/v19.0';
|
||||
private readonly pageAccessToken = process.env.FB_PAGE_ACCESS_TOKEN;
|
||||
private readonly pageId = process.env.FB_PAGE_ID;
|
||||
|
||||
async postToPage(content: string, imageUrl?: string) {
|
||||
// console.log('postToPage==>', content, imageUrl);
|
||||
try {
|
||||
let url = `${this.fbBaseUrl}/${this.pageId}/feed`;
|
||||
let params: any = {
|
||||
message: content + `\n Disclaimer: For reference only. AI content may have errors. Not liable for inaccuracies or damages. Verify from official sources.`,
|
||||
access_token: this.pageAccessToken,
|
||||
};
|
||||
|
||||
// Nếu có ảnh, chúng ta dùng endpoint /photos
|
||||
if (imageUrl) {
|
||||
url = `${this.fbBaseUrl}/${this.pageId}/photos`;
|
||||
params.url = imageUrl;
|
||||
}
|
||||
|
||||
const response = await axios.post(url, params);
|
||||
//response.data= { id: '1010286162176053_122107818902775551' }
|
||||
return response.data; // Trả về ID bài viết nếu thành công
|
||||
} catch (error) {
|
||||
console.log('Lỗi khi đăng bài lên FB');
|
||||
console.log(error.message);
|
||||
throw new HttpException(
|
||||
error.response?.data || 'Lỗi khi đăng bài lên FB',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// interfaces/x-cookie.interface.ts
|
||||
export interface XCookieAccount {
|
||||
authToken: string; // auth_token cookie
|
||||
ct0: string; // ct0 cookie (CSRF token)
|
||||
proxy?: string; // optional proxy per account
|
||||
}
|
||||
|
||||
export interface TweetResult {
|
||||
success: boolean;
|
||||
tweetId?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {HttpException, Injectable} from "@nestjs/common";
|
||||
import {PgPostService} from "../../shared/pg.post.service";
|
||||
import {FacebookApi} from "./facebook.api";
|
||||
import {TwitterClient} from "./twitter.client";
|
||||
import {normalizeTagsSingleCashtag} from "../../shared/helper";
|
||||
import {XBrowserService} from "./x-browser.service";
|
||||
import {XStrategy} from "./x-router.service";
|
||||
import {buildXCookies} from "./utils/x-headers.util";
|
||||
import {isEmpty} from "lodash";
|
||||
import {XCacheService} from "../x-cache/x-cache.service";
|
||||
|
||||
@Injectable()
|
||||
export class PublishPageService {
|
||||
constructor(
|
||||
private readonly pgPostService: PgPostService,
|
||||
private readonly facebookApiService: FacebookApi,
|
||||
private readonly twitterClient: TwitterClient,
|
||||
private readonly xBrowserService: XBrowserService,
|
||||
private readonly cacheService: XCacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
async relyX(content: string, tweetId: string) {
|
||||
//return this.twitterClient.postReply(content, tweetId);
|
||||
// return this.twitterClient.qoute(content, tweetId);
|
||||
const tweetUrl = await this.cacheService.getCacheTweetUrlById(tweetId);
|
||||
if(isEmpty(tweetUrl)) {
|
||||
throw new HttpException(`Không tìm thấy tweet url từ Id : ${tweetId}`, 500);
|
||||
}
|
||||
return this.xBrowserService.postReply(
|
||||
tweetUrl,
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
async quoteX(content: string, tweetId: string) {
|
||||
// return this.twitterClient.qoute(content, tweetId);
|
||||
const tweetUrl = await this.cacheService.getCacheTweetUrlById(tweetId);
|
||||
if(isEmpty(tweetUrl)) {
|
||||
throw new HttpException(`Không tìm thấy tweet url từ Id : ${tweetId}`, 500);
|
||||
}
|
||||
return this.xBrowserService.quoteTweet(
|
||||
{
|
||||
accountId: 'realflashkaze',
|
||||
cookies: buildXCookies()
|
||||
},
|
||||
tweetUrl,
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
async publishToFacebook(pgPostId: number): Promise<any> {
|
||||
const post = await this.pgPostService.post({id: 1 * pgPostId})
|
||||
if (!post) {
|
||||
console.log('publishToFacebook:no post was found:', pgPostId);
|
||||
return 'no post';
|
||||
}
|
||||
|
||||
if (!post.content) {
|
||||
console.log('publishToFacebook:Post with no content:', pgPostId);
|
||||
return 'no post';
|
||||
}
|
||||
// if (post.style) {
|
||||
// console.log('Post with no content:', pgPostId);
|
||||
// return 'no post';
|
||||
// }
|
||||
if (post.status != 'pending') {
|
||||
console.log('publishToFacebook:This post already change status', pgPostId);
|
||||
return 'This post already change status: ' + pgPostId;
|
||||
}
|
||||
if (post.isFbPostState > 0) {
|
||||
console.log('publishToFacebook:Already posted', pgPostId);
|
||||
return 'Already posted on FB: ' + pgPostId;
|
||||
}
|
||||
console.log('publishToFacebook=>posting')
|
||||
|
||||
const resultPost = await this.facebookApiService.postToPage(post.content);
|
||||
|
||||
await this.pgPostService.updatePost(pgPostId, {
|
||||
isFbPostState: 1
|
||||
})
|
||||
console.log({resultPost});
|
||||
console.log('publishToFacebook=>done');
|
||||
return resultPost
|
||||
}
|
||||
|
||||
async publishTwitter(pgPostIdNum: number, XPostProvider = XStrategy.API_ONLY): Promise<any> {
|
||||
const pgPostId = 1 * pgPostIdNum;
|
||||
const post = await this.pgPostService.post({id: 1 * pgPostId})
|
||||
if (!post) {
|
||||
console.log('no post was found:', pgPostId);
|
||||
return 'no post';
|
||||
}
|
||||
|
||||
if (!post.content) {
|
||||
console.log('Post with no content:', pgPostId);
|
||||
return 'no post';
|
||||
}
|
||||
if (post.status != 'pending') {
|
||||
console.log('This post already change status', pgPostId);
|
||||
return 'This post already change status: ' + pgPostId;
|
||||
}
|
||||
if (post.isTwitterPostState > 0) {
|
||||
console.log('Already posted', pgPostId);
|
||||
return 'Already posted on X: ' + pgPostId;
|
||||
}
|
||||
console.log('publishTwitter=>posting');
|
||||
const _normalizeTagsSingleCashtagContent = normalizeTagsSingleCashtag(post.content);
|
||||
|
||||
if (XPostProvider === XStrategy.BROWSER_ONLY) {
|
||||
const resp = await this.xBrowserService.postTweet(
|
||||
{
|
||||
accountId: 'realflashkaze',
|
||||
cookies: buildXCookies()
|
||||
},
|
||||
_normalizeTagsSingleCashtagContent
|
||||
)
|
||||
if (resp.success) {
|
||||
await this.pgPostService.updatePost(pgPostId, {
|
||||
isTwitterPostState: 1
|
||||
})
|
||||
} else {
|
||||
console.log('publishTwitter=>posting_error', resp.error);
|
||||
throw new HttpException(resp.error || 'exception', 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (XPostProvider === XStrategy.API_ONLY) {
|
||||
const {data: createdTweet} = await this.twitterClient
|
||||
.postSimpleTwitte(_normalizeTagsSingleCashtagContent)
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
const errStatus = err.data.status;
|
||||
// if (errStatus == '401') {
|
||||
// this.twitterClient.refreshAccessToken();
|
||||
// }
|
||||
const errText = err.data.title;
|
||||
const errDetail = err.data.detail;
|
||||
console.log(`publishTwitter=>posting_err_ ${errText} - ${errStatus} -${errDetail}`)
|
||||
throw new HttpException(errText, errStatus);
|
||||
});
|
||||
await this.pgPostService.updatePost(pgPostId, {
|
||||
isTwitterPostState: 1
|
||||
})
|
||||
|
||||
console.log('Tweet', createdTweet.id);
|
||||
return {
|
||||
...createdTweet,
|
||||
url: `https://x.com/${process.env.TWITTER_USERNAME}/status/${createdTweet.id}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rejectedPost(pgPostIdNum: number): Promise<any> {
|
||||
const pgPostId = 1 * pgPostIdNum;
|
||||
|
||||
await this.pgPostService.updatePost(pgPostId, {
|
||||
status: 'rejected',
|
||||
})
|
||||
|
||||
return pgPostId;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// src/modules/manager/manager.module.ts
|
||||
import {Global, Module} from '@nestjs/common';
|
||||
import {PgPostService} from "../../shared/pg.post.service";
|
||||
import {PublishPageService} from "./publish.page.service";
|
||||
import {FacebookApi} from "./facebook.api";
|
||||
import {TwitterClient} from "./twitter.client";
|
||||
import {XBrowserService} from "./x-browser.service";
|
||||
import {XCookieController} from "./x-cookie.controller";
|
||||
import {XRouterService} from "./x-router.service";
|
||||
import {XCookieService} from "./x-cookie.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
FacebookApi,
|
||||
PgPostService,
|
||||
PublishPageService,
|
||||
TwitterClient,
|
||||
XBrowserService,
|
||||
XRouterService,
|
||||
XCookieService,
|
||||
|
||||
],
|
||||
controllers: [XCookieController],
|
||||
|
||||
exports: [PublishPageService, TwitterClient, XBrowserService],
|
||||
})
|
||||
export class SocialModule {
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {HttpException, Inject, Injectable} from "@nestjs/common";
|
||||
import {TUploadableMedia, TwitterApi, UploadMediaV1Params} from 'twitter-api-v2';
|
||||
import {TwitterApiAutoTokenRefresher} from '@twitter-api-v2/plugin-token-refresher'
|
||||
|
||||
import {EUploadMimeType, MediaStatusV1Result} from "twitter-api-v2/dist/esm/types";
|
||||
import {MediaV2MediaCategory} from "twitter-api-v2/dist/esm/types/v2/media.v2.types";
|
||||
import {XCacheService} from "../x-cache/x-cache.service";
|
||||
|
||||
//oauth2
|
||||
// clientId: Rl9BU2xXUHVqWl9rcVdWSWFFYWg6MTpjaQ
|
||||
// clientSecet: h0WgiG24dAdLC80NWf3u-_X0Wzilm8ejLwO-iZ8nZ7UOG-5G5m
|
||||
// ====
|
||||
|
||||
//oath 1.0 Access Token and Secret?
|
||||
//consumer key tfQh9yhtgx30zvfmTUwQ3o3rh
|
||||
//comsumer sceret ysQtt8PI3lmEdtWx9oRtC0OpDC7fSwYYSwkE5J4v2NIdEcFUHU
|
||||
//Access Token 2043937828644536320-JWM8czpnoadaqQYj8Xp62ZCrZuVlBt
|
||||
//Access Token Secret 9EjIg8E5S44Fw1U84UqGBNPx6T1cHBEh00iC8YffDurMO
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class TwitterClient {
|
||||
private clientId = process.env.TWITTER_CLIENT_ID + '';
|
||||
private clientSecret = process.env.TWITTER_CLIENT_SECRET;
|
||||
// private consumerAppKey = 'tfQh9yhtgx30zvfmTUwQ3o3rh';
|
||||
// private consumerAppSecret = 'ysQtt8PI3lmEdtWx9oRtC0OpDC7fSwYYSwkE5J4v2NIdEcFUHU';
|
||||
// private oath10AccessToken = '2043937828644536320-JWM8czpnoadaqQYj8Xp62ZCrZuVlBt';
|
||||
// private oath10AccessTokenSecret = '9EjIg8E5S44Fw1U84UqGBNPx6T1cHBEh00iC8YffDurMO';
|
||||
|
||||
private readonly userClient: any;
|
||||
|
||||
constructor(
|
||||
@Inject() private cacheService: XCacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
// async getTwitterClient() {
|
||||
// return new TwitterApi({
|
||||
// appKey: this.consumerAppKey,
|
||||
// appSecret: this.consumerAppSecret,
|
||||
// // Following access tokens are not required if you are
|
||||
// // at part 1 of user-auth process (ask for a request token)
|
||||
// // or if you want a app-only client (see below)
|
||||
// accessToken: this.oath10AccessToken,
|
||||
// accessSecret: this.oath10AccessTokenSecret,
|
||||
// })
|
||||
// }
|
||||
|
||||
|
||||
async getTwitterClientV2() {
|
||||
return new TwitterApi(
|
||||
{
|
||||
clientId: this.clientId,
|
||||
clientSecret: this.clientSecret,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async getTwitterClientV2ViaAccessToken() {
|
||||
const accessToken = await this.getCacheAccessToken() as string;
|
||||
const refreshToken = await this.getCacheRefreshToken() as string;
|
||||
// console.log({refreshToken});
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-ignore
|
||||
return new TwitterApi(accessToken,
|
||||
{
|
||||
plugins: [
|
||||
new TwitterApiAutoTokenRefresher({
|
||||
refreshCredentials: {
|
||||
clientId: '' + this.clientId,
|
||||
clientSecret: this.clientSecret,
|
||||
},
|
||||
refreshToken,
|
||||
// Hàm này được gọi tự động khi token được refresh thành công
|
||||
onTokenUpdate: async (newTokens) => {
|
||||
console.log('===> Token đã được làm mới:', newTokens);
|
||||
await this.setCacheRefreshToken('' + newTokens.refreshToken);
|
||||
await this.setCacheAccessToken(newTokens.accessToken);
|
||||
},
|
||||
// Hàm xử lý khi refresh thất bại (ví dụ: refresh token cũng hết hạn)
|
||||
onTokenRefreshError: async (error) => {
|
||||
console.error('Không thể refresh token:', error);
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async uploadImageV1(media: Buffer, options: {
|
||||
media_type: `${EUploadMimeType}` | EUploadMimeType;
|
||||
media_category?: MediaV2MediaCategory;
|
||||
additional_owners?: string[];
|
||||
}, chunkSize?: number): Promise<string> {
|
||||
const client = await this.getTwitterClientV2ViaAccessToken();
|
||||
// return client.v2.uploadMedia(media, options, chunkSize);
|
||||
return client.v2.uploadMedia(media, options, chunkSize);
|
||||
}
|
||||
|
||||
async postSimpleTwitte(content) {
|
||||
const client = await this.getTwitterClientV2ViaAccessToken();
|
||||
return client.v2.tweet(content)
|
||||
}
|
||||
|
||||
async postReply(content: string, tweetId: string) {
|
||||
const client = await this.getTwitterClientV2ViaAccessToken();
|
||||
return client.v2.reply(content, tweetId)
|
||||
}
|
||||
|
||||
async qoute(content: string, tweetId: string) {
|
||||
const client = await this.getTwitterClientV2ViaAccessToken();
|
||||
|
||||
// @ts-ignore
|
||||
if (content.indexOf(`https://x.com/${process.env.TWITTER_USERNAME}/status`) === -1) {
|
||||
//hacking, use tweet instead of quote, because limit x
|
||||
return client.v2.tweet(content)
|
||||
}
|
||||
return client.v2.quote(content, tweetId)
|
||||
}
|
||||
|
||||
async setCacheAccessToken(accessToken: string) {
|
||||
return this.cacheService.setCachedKey('tw_accesstoken', '' + accessToken, 24 * 3600);
|
||||
}
|
||||
|
||||
async delCacheAccessToken() {
|
||||
return this.cacheService.delCachedKey('tw_accesstoken');
|
||||
}
|
||||
|
||||
async getCacheAccessToken() {
|
||||
return this.cacheService.getCachedData('tw_accesstoken')
|
||||
}
|
||||
|
||||
async getCacheRefreshToken() {
|
||||
return this.cacheService.getCachedData('tw_refreshtoken')
|
||||
}
|
||||
|
||||
async setCacheRefreshToken(refreshToken: string) {
|
||||
//30day
|
||||
return this.cacheService.setCachedKey('tw_refreshtoken', refreshToken, 30 * 24 * 3600);
|
||||
}
|
||||
|
||||
async refreshAccessToken() {
|
||||
const client = new TwitterApi({clientId: this.clientId, clientSecret: this.clientSecret});
|
||||
const refreshToken = await this.getCacheRefreshToken();
|
||||
|
||||
const {
|
||||
client: refreshedClient,
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken
|
||||
} = await client.refreshOAuth2Token('' + refreshToken);
|
||||
|
||||
await this.cacheService.setCachedKey('tw_accesstoken_time_add', Date.now(), 3 * 24 * 3600);
|
||||
await this.setCacheAccessToken(accessToken);
|
||||
await this.setCacheRefreshToken('' + refreshToken);
|
||||
// Example request
|
||||
await refreshedClient.v2.me();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// utils/x-headers.util.ts
|
||||
export const X_BEARER_TOKEN =
|
||||
'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
|
||||
export const X_COOKIE_NAME = {
|
||||
'kdt': '2JWxVxk3NZUEjkorxsQE6ilftoYnFQDI8u5IgsBz',
|
||||
'auth_token': 'f5574950a0d98cf49ca2e574cc6a4139cc8a2d81',
|
||||
'cto': '75db96b40f4814925670722e3335840467b87c7eb403aab14fbe719297bf465347810f92dc2116c3d30f15153a332976c50d856983dfe19046d4f10413b3d1cd8bac6f7a99e5b03c6949bafdad0f01c0'
|
||||
}
|
||||
|
||||
export function buildXHeaders(authToken: string, ct0: string) {
|
||||
return {
|
||||
authority: 'x.com',
|
||||
accept: '*/*',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
authorization: `Bearer ${X_BEARER_TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
cookie: `auth_token=${authToken}; ct0=${ct0}; kdt=${X_COOKIE_NAME.kdt}`,
|
||||
origin: 'https://x.com',
|
||||
referer: 'https://x.com/home',
|
||||
host: 'api.x.com',
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15',
|
||||
'x-csrf-token': ct0,
|
||||
'x-twitter-active-user': 'yes',
|
||||
'x-twitter-auth-type': 'OAuth2Session',
|
||||
'x-twitter-client-language': 'en',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildXCookies(accountId = 'realflashkaze') {
|
||||
return [
|
||||
{
|
||||
name: "ct0",
|
||||
value: X_COOKIE_NAME.cto
|
||||
},
|
||||
{
|
||||
name: "auth_token",
|
||||
value: X_COOKIE_NAME.auth_token,
|
||||
},
|
||||
{name: "dnt", value: "1"},
|
||||
{name: "lang", value: "en"},
|
||||
{name: "twid", value: "u%3D2043937828644536320"},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
// src/modules/x-browser/x-browser.service.ts
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy, HttpException,
|
||||
} from '@nestjs/common';
|
||||
import {chromium, Browser, BrowserContext, Page} from 'playwright';
|
||||
import {pick, rand} from "../../shared/helper";
|
||||
import {buildXCookies} from "./utils/x-headers.util";
|
||||
|
||||
export interface BrowserAccount {
|
||||
accountId: string;
|
||||
cookies: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
}>;
|
||||
proxy?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface BrowserTweetResult {
|
||||
success: boolean;
|
||||
tweetId?: string;
|
||||
error?: string;
|
||||
needsRelogin?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class XBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(XBrowserService.name);
|
||||
private browser: Browser | null = null;
|
||||
private contextPool = new Map<
|
||||
string,
|
||||
{ ctx: BrowserContext; lastUsed: number }
|
||||
>();
|
||||
private readonly MAX_CONTEXTS = 5;
|
||||
private readonly CONTEXT_TTL_MS = 15 * 60 * 1000; // 15 phút
|
||||
|
||||
async onModuleInit() {
|
||||
// Lazy launch – chỉ mở khi cần
|
||||
setInterval(() => this.cleanupStaleContexts(), 60_000);
|
||||
}
|
||||
|
||||
private async ensureBrowser(): Promise<Browser> {
|
||||
if (this.browser && this.browser.isConnected()) return this.browser;
|
||||
this.logger.log('Launching Chromium...');
|
||||
this.browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
],
|
||||
});
|
||||
return this.browser;
|
||||
}
|
||||
|
||||
private async getOrCreateContext(
|
||||
account: BrowserAccount,
|
||||
useCache = true
|
||||
): Promise<BrowserContext> {
|
||||
console.log('getOrCreateContext:1')
|
||||
// console.log({account});
|
||||
const cached = this.contextPool.get(account.accountId);
|
||||
if (useCache && cached) {
|
||||
console.log('getOrCreateContext:cached');
|
||||
cached.lastUsed = Date.now();
|
||||
return cached.ctx;
|
||||
}
|
||||
console.log('getOrCreateContext:2')
|
||||
|
||||
// LRU eviction
|
||||
if (this.contextPool.size >= this.MAX_CONTEXTS) {
|
||||
const oldest = [...this.contextPool.entries()].sort(
|
||||
(a, b) => a[1].lastUsed - b[1].lastUsed,
|
||||
)[0];
|
||||
await oldest[1].ctx.close().catch(() => null);
|
||||
this.contextPool.delete(oldest[0]);
|
||||
}
|
||||
console.log('getOrCreateContext:3')
|
||||
|
||||
const browser = await this.ensureBrowser();
|
||||
console.log('getOrCreateContext:4')
|
||||
|
||||
const ctx = await browser.newContext({
|
||||
userAgent:
|
||||
account.userAgent ??
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
||||
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
viewport: {width: 1366, height: 768},
|
||||
locale: 'en-US',
|
||||
proxy: account.proxy ? {server: account.proxy} : undefined,
|
||||
});
|
||||
console.log('getOrCreateContext:5')
|
||||
|
||||
// Anti-detection: ẩn webdriver flag
|
||||
await ctx.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
||||
});
|
||||
console.log('getOrCreateContext:6')
|
||||
|
||||
await ctx.addCookies(
|
||||
account.cookies.map((c) => ({
|
||||
...c,
|
||||
domain: c.domain || '.x.com',
|
||||
path: c.path || '/',
|
||||
})),
|
||||
);
|
||||
|
||||
this.contextPool.set(account.accountId, {ctx, lastUsed: Date.now()});
|
||||
console.log('getOrCreateContext:7')
|
||||
|
||||
// console.log({
|
||||
// ctx
|
||||
// })
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async postTweet(
|
||||
account: BrowserAccount,
|
||||
text: string,
|
||||
): Promise<BrowserTweetResult> {
|
||||
let page: Page | null = null;
|
||||
try {
|
||||
const ctx = await this.getOrCreateContext(account);
|
||||
const cookies = account.cookies.map((c) => ({
|
||||
...c,
|
||||
domain: c.domain || '.x.com',
|
||||
path: c.path || '/',
|
||||
}));
|
||||
await ctx.addCookies(cookies);
|
||||
page = await ctx.newPage();
|
||||
|
||||
// Intercept để lấy tweet id từ response
|
||||
let capturedTweetId: string | undefined;
|
||||
page.on('response', async (resp) => {
|
||||
if (resp.url().includes('/CreateTweet')) {
|
||||
try {
|
||||
const json = await resp.json();
|
||||
capturedTweetId =
|
||||
json?.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
// await page.keyboard.press('Mở trang ...');
|
||||
await page.goto('https://x.com/home', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||
await page.mouse.wheel(0, rand(300, 500));
|
||||
await page.waitForTimeout(rand(1000, 4000));
|
||||
|
||||
// Detect login/challenge screen
|
||||
if (page.url().includes('/login') || page.url().includes('/flow')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Redirected to login',
|
||||
needsRelogin: true,
|
||||
};
|
||||
}
|
||||
await page.mouse.wheel(200, rand(300, 800));
|
||||
await page.waitForTimeout(rand(2000, 5000));
|
||||
await page.evaluate(() => window.scrollTo({top: 0, behavior: 'smooth'}));
|
||||
await page.waitForTimeout(rand(1000, 2000));
|
||||
// page.mouse.move()
|
||||
// Mở composer
|
||||
// const composer = page.locator('a[href="/compose/post"]').first();
|
||||
// await page.waitForTimeout(2000 + (Math.random()+Math.random()) * 3000);
|
||||
// await composer.click();
|
||||
|
||||
const textarea = page.locator('div[data-testid="tweetTextarea_0"]');
|
||||
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||
await textarea.fill(text);
|
||||
console.log(' Nhập tweet xong ...');
|
||||
await page.waitForTimeout(2000 + (Math.random() + Math.random()) * 3000);
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Chờ nút enable
|
||||
// const postBtn = page.locator('button[data-testid="tweetButtonInline"]');
|
||||
// await postBtn.waitFor({state: 'visible', timeout: 5_000});
|
||||
// await postBtn.click();
|
||||
// await page.locator('button[data-testid="tweetButtonInline"]').click({ force: true });
|
||||
const btn = page.locator('button[data-testid="tweetButtonInline"]');
|
||||
const btnBox = await btn.boundingBox();
|
||||
console.log(btnBox);
|
||||
console.log('Nhấn Control+Enter ...');
|
||||
|
||||
// @ts-ignore
|
||||
// await page.mouse.click(btnBox?.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
|
||||
await page.keyboard.press('Control+Enter');
|
||||
console.log('Nhấn Control+Enter done ...');
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Chờ request CreateTweet hoàn tất
|
||||
// await page.waitForResponse(
|
||||
// (r) => r.url().includes('/CreateTweet') && r.status() === 200,
|
||||
// {timeout: 15_000},
|
||||
// );
|
||||
|
||||
return {success: true, tweetId: capturedTweetId};
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Browser post failed: ${err.message}`);
|
||||
// console.error(err);
|
||||
return {success: false, error: err.message};
|
||||
} finally {
|
||||
await page?.close().catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
async quoteTweet(
|
||||
account: BrowserAccount,
|
||||
tweetUrl: string,
|
||||
quoteText: string,
|
||||
) {
|
||||
|
||||
let ctx = await this.getOrCreateContext(account);
|
||||
if (ctx.isClosed()) {
|
||||
console.log('browser is closeed, reopen');
|
||||
ctx = await this.getOrCreateContext(account, false);
|
||||
}
|
||||
const cookies = account.cookies.map((c) => ({
|
||||
...c,
|
||||
domain: c.domain || '.x.com',
|
||||
path: c.path || '/',
|
||||
}));
|
||||
console.log('cookies:', cookies);
|
||||
await ctx.addCookies(cookies);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
// ===== SAFE GOTO =====
|
||||
try {
|
||||
await page.goto(tweetUrl, {waitUntil: 'domcontentloaded', timeout: 30000});
|
||||
} catch (e) {
|
||||
console.log('❌ Load fail');
|
||||
throw e;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(rand(2000, 4000));
|
||||
|
||||
// ===== CHECK LOGIN =====
|
||||
if (await page.locator('input[name="text"]').count()) {
|
||||
console.log('❌ Cookie die → bị redirect login');
|
||||
|
||||
throw new HttpException('❌ Không thấy nút retweet (tweet private?)', 500);
|
||||
|
||||
}
|
||||
|
||||
// ===== SCROLL (giống người thật) =====
|
||||
await page.mouse.wheel(0, rand(300, 800));
|
||||
await page.waitForTimeout(rand(1000, 5000));
|
||||
await page.mouse.wheel(0, rand(300, 800));
|
||||
await page.waitForTimeout(rand(4000, 8000));
|
||||
await page.evaluate(() => window.scrollTo({top: 0, behavior: 'smooth'}));
|
||||
await page.waitForTimeout(rand(1000, 2000));
|
||||
|
||||
|
||||
// ===== CLICK RETWEET =====
|
||||
let retweetBtn = page.locator('[data-testid="retweet"]');
|
||||
|
||||
if (!(await retweetBtn.count())) {
|
||||
console.log('❌ Không thấy nút retweet (tweet private?)');
|
||||
throw new HttpException('❌ Không thấy nút retweet (tweet private?)', 500);
|
||||
}
|
||||
|
||||
await retweetBtn.first().click();
|
||||
|
||||
await page.waitForTimeout(rand(1000, 2000));
|
||||
try {
|
||||
await page.locator('a[href="/compose/post"]').click({timeout: 2000});
|
||||
} catch {
|
||||
console.log('fallback → click by text');
|
||||
await page.locator('a[role="menuitem"]')
|
||||
.filter({hasText: 'Quote'})
|
||||
.click();
|
||||
}
|
||||
// // ===== CLICK QUOTE =====
|
||||
// let quoteBtn = page.locator('[data-testid="retweetWithComment"]');
|
||||
//
|
||||
// if (!(await quoteBtn.count())) {
|
||||
// console.log('❌ Không thấy nút quote');
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// await quoteBtn.first().click();
|
||||
|
||||
await page.waitForTimeout(rand(2000, 3000));
|
||||
|
||||
// ===== TYPE LIKE HUMAN =====
|
||||
// const content = pick(quoteText);
|
||||
const content = quoteText;
|
||||
|
||||
const box = page.locator('div[role="textbox"]').first();
|
||||
// chọn đúng textbox đang visible
|
||||
// const box = page.locator('div[role="textbox"]:visible').first();
|
||||
|
||||
if (!(await box.count())) {
|
||||
console.log('❌ Không thấy textbox');
|
||||
throw new HttpException('❌ Không thấy textbox', 500);
|
||||
}
|
||||
|
||||
// đợi nó xuất hiện thật sự
|
||||
await box.waitFor({state: 'visible', timeout: 7000});
|
||||
|
||||
// scroll nhẹ vào view (tránh bị offscreen)
|
||||
// await box.scrollIntoViewIfNeeded();
|
||||
|
||||
// focus trước khi gõ
|
||||
console.log('focus trước khi gõ')
|
||||
await box.click({delay: rand(50, 150)});
|
||||
|
||||
|
||||
for (let char of content) {
|
||||
await box.type(char, {delay: rand(50, 120)});
|
||||
}
|
||||
console.log('gõ quote xong ...')
|
||||
await page.waitForTimeout(rand(1000, 2000));
|
||||
|
||||
// ===== POST =====
|
||||
let postBtn = page.locator('[data-testid="tweetButton"]');
|
||||
console.log('count ...')
|
||||
|
||||
if ((await postBtn.count())) {
|
||||
console.log('click nút quote ...')
|
||||
await postBtn.click({timeout: 7000}).catch(async (e) => {
|
||||
console.log('❌ Nut click khong duoc, thử dùng bàn phím Control+Enter');
|
||||
await page.keyboard.press('Control+Enter');
|
||||
});
|
||||
await page.waitForTimeout(rand(4000, 6000));
|
||||
|
||||
console.log('✅ Quoted thành công');
|
||||
} else {
|
||||
console.log('❌ Không thấy nút post, gọi Ctr + Enter');
|
||||
await page.keyboard.press('Control+Enter');
|
||||
await page.waitForTimeout(rand(4000, 6000));
|
||||
console.log('✅ Quoted thành công');
|
||||
}
|
||||
return {success: true, error: ''};
|
||||
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Browser post quote failed: ${err.message}`);
|
||||
// console.error(err);
|
||||
return {success: false, error: `Browser post quote failed: ${err.message}`};
|
||||
} finally {
|
||||
await page?.close().catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
async postReply(tweetUrl, content) {
|
||||
if (!content) {
|
||||
throw new HttpException('Nội dung trả lời không có', 500);
|
||||
}
|
||||
|
||||
const accountInfo = {
|
||||
accountId: 'realflashkaze',
|
||||
cookies: buildXCookies()
|
||||
};
|
||||
|
||||
let ctx = await this.getOrCreateContext(accountInfo);
|
||||
if (ctx.isClosed()) {
|
||||
console.log('browser is closeed, reopen');
|
||||
ctx = await this.getOrCreateContext(accountInfo, false);
|
||||
}
|
||||
const cookies = accountInfo.cookies.map((c) => ({
|
||||
...c,
|
||||
domain: '.x.com',
|
||||
path: '/',
|
||||
}));
|
||||
await ctx.addCookies(cookies);
|
||||
const page = await ctx.newPage();
|
||||
|
||||
try {
|
||||
// limit X
|
||||
// content = content.slice(0, 280);
|
||||
|
||||
// vào tweet
|
||||
// ===== SAFE GOTO =====
|
||||
try {
|
||||
await page.goto(tweetUrl, {waitUntil: 'domcontentloaded', timeout: 30000});
|
||||
} catch (e) {
|
||||
console.log('❌ Load fail');
|
||||
throw e;
|
||||
}
|
||||
|
||||
// đợi UI ổn
|
||||
console.log(`đợi UI ổn...`)
|
||||
await page.waitForSelector('article', {timeout: 7000});
|
||||
|
||||
// scroll nhẹ
|
||||
console.log(`scroll nhẹ ...`)
|
||||
await page.mouse.wheel(0, 300);
|
||||
await page.waitForTimeout(1000 + Math.random() * 2000);
|
||||
|
||||
// lấy textbox visible
|
||||
const box = page.locator('div[role="textbox"]:visible').first();
|
||||
|
||||
await box.waitFor({state: 'visible', timeout: 7000});
|
||||
|
||||
// focus
|
||||
console.log(`box focus ...`)
|
||||
await box.click();
|
||||
|
||||
// nhập content (fallback nếu type fail)
|
||||
try {
|
||||
await box.fill(''); // clear
|
||||
await box.type(content, {delay: 30 + Math.random() * 150});
|
||||
} catch {
|
||||
await box.fill(content);
|
||||
}
|
||||
console.log(`nhập nội dung xong ...`)
|
||||
await page.waitForTimeout(800 + Math.random() * 1200);
|
||||
|
||||
// nút reply
|
||||
const btn = page.locator('[data-testid="tweetButtonInline"]:visible');
|
||||
|
||||
if (!(await btn.count())) {
|
||||
console.log('❌ Không thấy nút reply');
|
||||
return false;
|
||||
}
|
||||
|
||||
await btn.click();
|
||||
console.log(`nhấn nút gửi ...`)
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('✅ Reply OK');
|
||||
return {success: true, error: ''};
|
||||
} catch (err) {
|
||||
this.logger.error(`Browser reply failed: ${err.message}`);
|
||||
// console.error(err);
|
||||
return {success: false, error: `Browser reply failed: ${err.message}`};
|
||||
} finally {
|
||||
await page?.close().catch(() => null);
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private async cleanupStaleContexts() {
|
||||
const now = Date.now();
|
||||
for (const [id, entry] of this.contextPool.entries()) {
|
||||
if (now - entry.lastUsed > this.CONTEXT_TTL_MS) {
|
||||
await entry.ctx.close().catch(() => null);
|
||||
this.contextPool.delete(id);
|
||||
this.logger.log(`Closed stale context ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
for (const {ctx} of this.contextPool.values()) {
|
||||
await ctx.close().catch(() => null);
|
||||
}
|
||||
this.contextPool.clear();
|
||||
await this.browser?.close().catch(() => null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// x-poster.controller.ts
|
||||
import {Body, Controller, Get, Post} from '@nestjs/common';
|
||||
import {CreateTweetDto} from './dto/create-tweet.dto';
|
||||
import {XCookieAccountDto} from "./dto/x-cookie-account.dto";
|
||||
import {XRouterService, XStrategy} from "./x-router.service";
|
||||
import {XCookieService} from "./x-cookie.service";
|
||||
|
||||
@Controller('x-cookie')
|
||||
export class XCookieController {
|
||||
constructor(
|
||||
private readonly service: XRouterService,
|
||||
private readonly xCookieService: XCookieService
|
||||
) {
|
||||
}
|
||||
|
||||
@Post('tweet')
|
||||
async tweet(
|
||||
@Body() tweet: CreateTweetDto,
|
||||
) {
|
||||
const authToken = 'f5574950a0d98cf49ca2e574cc6a4139cc8a2d81';
|
||||
const ct0 = '75db96b40f4814925670722e3335840467b87c7eb403aab14fbe719297bf465347810f92dc2116c3d30f15153a332976c50d856983dfe19046d4f10413b3d1cd8bac6f7a99e5b03c6949bafdad0f01c0';
|
||||
return this.service.postTweet(
|
||||
{
|
||||
account: {
|
||||
id: 'realflashkaze',
|
||||
browser: {
|
||||
accountId: 'realflashkaze',
|
||||
cookies: [
|
||||
{
|
||||
name: "ct0",
|
||||
value: '75db96b40f4814925670722e3335840467b87c7eb403aab14fbe719297bf465347810f92dc2116c3d30f15153a332976c50d856983dfe19046d4f10413b3d1cd8bac6f7a99e5b03c6949bafdad0f01c0'
|
||||
},
|
||||
{
|
||||
name: "auth_token",
|
||||
value: 'f5574950a0d98cf49ca2e574cc6a4139cc8a2d81'
|
||||
},
|
||||
{name: "dnt", value: "1"},
|
||||
{name: "lang", value: "en"},
|
||||
{name: "twid", value: "u%3D2043937828644536320"},
|
||||
]
|
||||
}
|
||||
},
|
||||
text: tweet.text,
|
||||
strategy: XStrategy.BROWSER_ONLY
|
||||
});
|
||||
}
|
||||
|
||||
@Get('verify')
|
||||
verify() {
|
||||
return this.xCookieService.verifyCookie();
|
||||
// return this.xCookieService.verifyCookie('UserByScreenName');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// x-cookie.service.ts
|
||||
import {Injectable, Logger, BadRequestException} from '@nestjs/common';
|
||||
import axios, {AxiosInstance} from 'axios';
|
||||
import {HttpsProxyAgent} from 'https-proxy-agent';
|
||||
import {CreateTweetDto} from './dto/create-tweet.dto';
|
||||
import {XCookieAccount, TweetResult} from './interfaces/x-cookie.interface';
|
||||
import {buildXHeaders, X_COOKIE_NAME} from './utils/x-headers.util';
|
||||
import {ConfigService} from "@nestjs/config";
|
||||
import {parse} from "cookie";
|
||||
|
||||
interface GraphQLVariables {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class XCookieService {
|
||||
private readonly logger = new Logger(XCookieService.name);
|
||||
private readonly client: AxiosInstance;
|
||||
private userId: string;
|
||||
private queryID: string;
|
||||
private screenName: string | null = null;
|
||||
private TWEET_URL = '';
|
||||
// Endpoint GraphQL tạo tweet (queryId có thể đổi theo thời gian)
|
||||
private readonly CREATE_TWEET_URL =
|
||||
'https://x.com/i/api/graphql/bDE2rBtZb3uyrczSZ_pI9g/CreateTweet';
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
// ── Lấy cookie từ env ──
|
||||
const authToken = X_COOKIE_NAME.auth_token;
|
||||
const ct0 = X_COOKIE_NAME.cto;
|
||||
const kdt = X_COOKIE_NAME.kdt;
|
||||
// const authToken = this.config.get<string>('TWITTER_AUTH_TOKEN');
|
||||
// const ct0 = this.config.get<string>('TWITTER_CT0')!;
|
||||
this.queryID = this.config.get<string>('TWITTER_QUERY_ID') || '';
|
||||
|
||||
const createQid = this.config.get<string>('TWITTER_CREATE_TWEET_QUERY_ID') || 'Qkq4oPdZYuNB_Qw3TDuFqQ';
|
||||
this.TWEET_URL = `https://x.com/i/api/graphql/${createQid}/CreateTweet`;
|
||||
|
||||
|
||||
// Bearer token này gần như cố định cho Web App, có thể để default
|
||||
const bearer = 'AAAAAAAAAAAAAAAAAAAAAF7OAAAAAAAPS6nVJjCEf6gW6rLVnQujDGAh8%3DkQS5VtDfPBTSO89WMK4HvSpSUYshWVio9dNNWQEvwfkGmL7nPF';
|
||||
|
||||
// Ghép chuỗi cookie để axios gửi kèm request
|
||||
const cookies = [
|
||||
`auth_token=${authToken}`,
|
||||
`ct0=${ct0}`,
|
||||
`kdt=${kdt}`,
|
||||
].join('; ');
|
||||
|
||||
const cookie = `__cuid=4035196cccd84e129f04429c872518a7; twid=u%3D2043937828644536320; guest_id_ads=v1%3A177685259136534867; guest_id_marketing=v1%3A177685259136534867; __cf_bm=hrKUIMlfUs3DfWLr3eOohfxqagtyPQVnHiLOu.Ogtu0-1777886768.8740897-1.0.1.1-ubUA2SEhNiWFs.uruUZ98qVRHCn_EoEmeKqsXTa1RjeOvd9CTXKkF3Su3mTf4fbr4.GdQPb5QClU0JE2raMBRBL_I6NmM.sqm0hKe5tOuF3AxxVXQb4J5.Dcw9uKpio9; external_referer=padhuUp37zjgzgv1mFWxJ12Ozwit7owX|0|8e8t2xd8A2w%3D; personalization_id="v1_plsX3tTzuhmy/NMuFYA8zg=="; ads_prefs="HBISAAA="; auth_token=f5574950a0d98cf49ca2e574cc6a4139cc8a2d81; ct0=75db96b40f4814925670722e3335840467b87c7eb403aab14fbe719297bf465347810f92dc2116c3d30f15153a332976c50d856983dfe19046d4f10413b3d1cd8bac6f7a99e5b03c6949bafdad0f01c0; dnt=1; guest_id=v1%3A177685259136534867; lang=en; _twpid=tw.1776151761301.87290159275149142; kdt=2JWxVxk3NZUEjkorxsQE6ilftoYnFQDI8u5IgsBz; __eoi=ID=1d23cba9bef5792f:T=1776090662:RT=1776090662:S=AA-AfjYo3lmC9kyBbnzpvdA3zf9a; __gads=ID=72ac119a9545f1cf:T=1776090662:RT=1776090662:S=ALNI_MYl23FAc-out8xXJykggkJnCBrxFQ; __gpi=UID=00001252726e7c96:T=1776090662:RT=1776090662:S=ALNI_MZUcHGy1ZnZJP5-uU2_I8WMY75QUA; cf_clearance=9R0VPss1VjPR85IFC8i9ydboDzfnQFh0pdZMmkgzpZk-1774104987-1.2.1.1-.Io13bhStMnBLS17Vl2TQfMafDfwpglq50K7Jc22v9k0eihCURaZu8dTy1imZyfVCUt227dQvQWVHJY1mSL8wUqmWCX9PIXSPrgcn_4A1qroXXYqpwQ1U0.UhtzIPplj3sCYqsJKnJ_BR981amqOpcpNV9IFIBERk70vIFPU0o5TIOFpkhCddj3Xhb3yxmZ82w3o95m2XVKw0wm_yXEEXypOJTl7Ubn5zYw3520gX_Q; d_prefs=MjoxLGNvbnNlbnRfdmVyc2lvbjoyLHRleHRfdmVyc2lvbjoxMDAw; ph_phc_TXdpocbGVeZVm5VJmAsHTMrCofBQu3e0kN8HGMNGTVW_posthog=%7B%22distinct_id%22%3A%2201991503-adac-7662-9300-2c5ed339def2%22%2C%22%24sesid%22%3A%5B1757078963247%2C%2201991a11-3e32-731b-b131-dd286bdf9c08%22%2C1757078961713%5D%7D`;
|
||||
|
||||
// ── Tạo axios instance với header giả lập browser ──
|
||||
// this.client = axios.create({
|
||||
// timeout: 30000,
|
||||
// headers: {
|
||||
// 'authorization': `Bearer ${bearer}`,
|
||||
// 'content-type': 'application/json',
|
||||
// 'cookie': cookies, // <-- Cookie đăng nhập
|
||||
// 'x-csrf-token': ct0, // <-- BẮT BUỘC, không có là lỗi 403
|
||||
// 'x-twitter-auth-type': 'OAuth2Session',
|
||||
// 'x-twitter-client-language': 'en',
|
||||
// 'x-twitter-active-user': 'yes',
|
||||
// 'referer': 'https://x.com/home',
|
||||
// 'user-agent':
|
||||
// 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
// },
|
||||
// });
|
||||
// const cookies = parse(cookie);
|
||||
// console.log(cookies);
|
||||
// this.userId = cookies.twid?.split('u=')[1] || '';
|
||||
// if (!this.userId) throw new Error('Missing twid cookie (user ID)');
|
||||
console.log(`userId=${this.userId}`);
|
||||
this.client = axios.create({
|
||||
baseURL: 'https://x.com/i/api',
|
||||
headers: {
|
||||
'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
|
||||
'x-csrf-token': ct0,
|
||||
'x-twitter-auth-type': 'OAuth2Session',
|
||||
'x-twitter-active-user': 'yes',
|
||||
'x-twitter-client-language': 'en',
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'cookie': cookies, // 'auth_token=xxx; ct0=yyy; ...' full string từ devtools
|
||||
'referer': 'https://x.com/compose/tweet',
|
||||
'origin': 'https://x.com',
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Interceptor: log lỗi chi tiết nếu bị 403/404/400
|
||||
this.client.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
const status = err.response?.status;
|
||||
const errors = err.response?.data?.errors;
|
||||
this.logger.log(`Call Url: ${err.config.url}`)
|
||||
this.logger.error(`Twitter HTTP ${status}:`, errors || err.message);
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private buildClient(account: XCookieAccount): AxiosInstance {
|
||||
const config: any = {
|
||||
headers: buildXHeaders(account.authToken, account.ct0),
|
||||
timeout: 20000,
|
||||
};
|
||||
|
||||
if (account.proxy) {
|
||||
const agent = new HttpsProxyAgent(account.proxy);
|
||||
config.httpsAgent = agent;
|
||||
config.proxy = false;
|
||||
}
|
||||
|
||||
const client = axios.create(config);
|
||||
// Interceptor: log lỗi chi tiết nếu bị 403/404/400
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
const status = err.response?.status;
|
||||
const errors = err.response?.data?.errors;
|
||||
this.logger.log(`Call Url: ${err.config.url}`)
|
||||
this.logger.error(`Twitter HTTP ${status}:`, errors || err.message);
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* ĐĂNG TWEET đơn
|
||||
*/
|
||||
async createTweet(text: string): Promise<{ id: string; url: string }> {
|
||||
const payload = {
|
||||
variables: {
|
||||
tweet_text: text,
|
||||
dark_request: false,
|
||||
media: {media_entities: [], possibly_sensitive: false},
|
||||
semantic_annotation_id: [],
|
||||
},
|
||||
features: {
|
||||
tweets_nudges_moments: true,
|
||||
tweet_with_visibility_results_prefetch_gql_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
interactive_text_enabled: true,
|
||||
responsive_web_text_conversations_enabled: false,
|
||||
longform_notetweets_richtext_consumption_enabled: false,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await this.client.post(this.TWEET_URL, payload);
|
||||
|
||||
const result = res.data?.data?.create_tweet?.tweet_results?.result;
|
||||
if (!result) {
|
||||
throw new Error(`CreateTweet failed: ${JSON.stringify(res.data)}`);
|
||||
}
|
||||
|
||||
const tid = result.rest_id;
|
||||
const screenName = result.core?.user_results?.result?.legacy?.screen_name || 'i';
|
||||
return {id: tid, url: `https://x.com/${screenName}/status/${tid}`};
|
||||
}
|
||||
|
||||
// async createTweet(
|
||||
// account: XCookieAccount,
|
||||
// dto: CreateTweetDto,
|
||||
// ): Promise<TweetResult> {
|
||||
// const client = this.buildClient(account);
|
||||
//
|
||||
// const variables: any = {
|
||||
// tweet_text: dto.text,
|
||||
// dark_request: false,
|
||||
// media: {
|
||||
// media_entities: (dto.mediaIds ?? []).map((id) => ({
|
||||
// media_id: id,
|
||||
// tagged_users: [],
|
||||
// })),
|
||||
// possibly_sensitive: false,
|
||||
// },
|
||||
// semantic_annotation_ids: [],
|
||||
// disallowed_reply_hashtags: [],
|
||||
// };
|
||||
//
|
||||
// if (dto.replyToTweetId) {
|
||||
// variables.reply = {
|
||||
// in_reply_to_tweet_id: dto.replyToTweetId,
|
||||
// exclude_reply_user_ids: [],
|
||||
// };
|
||||
// }
|
||||
//
|
||||
// const features = {
|
||||
// communities_web_enable_tweet_community_results_fetch: true,
|
||||
// c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
// responsive_web_edit_tweet_api_enabled: true,
|
||||
// graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
// view_counts_everywhere_api_enabled: true,
|
||||
// longform_notetweets_consumption_enabled: true,
|
||||
// responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
// tweet_awards_web_tipping_enabled: false,
|
||||
// creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
// longform_notetweets_rich_text_read_enabled: true,
|
||||
// longform_notetweets_inline_media_enabled: true,
|
||||
// rweb_video_timestamps_enabled: true,
|
||||
// responsive_web_graphql_exclude_directive_enabled: true,
|
||||
// verified_phone_label_enabled: false,
|
||||
// freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
// standardized_nudges_misinfo: true,
|
||||
// tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
// responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
// responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
// responsive_web_enhance_cards_enabled: false,
|
||||
// };
|
||||
//
|
||||
// try {
|
||||
// const {data} = await client.post(this.CREATE_TWEET_URL, {
|
||||
// variables,
|
||||
// features,
|
||||
// queryId: 'bDE2rBtZb3uyrczSZ_pI9g',
|
||||
// });
|
||||
//
|
||||
// const tweetId =
|
||||
// data?.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||
//
|
||||
// if (!tweetId) {
|
||||
// this.logger.warn('Tweet created but no id found', data);
|
||||
// return {success: false, error: 'No tweet id returned'};
|
||||
// }
|
||||
//
|
||||
// return {success: true, tweetId};
|
||||
// } catch (err: any) {
|
||||
// const msg =
|
||||
// err.response?.data?.errors?.[0]?.message ||
|
||||
// err.message ||
|
||||
// 'Unknown error';
|
||||
// this.logger.error(`Create tweet failed: ${msg}`);
|
||||
//
|
||||
// if (err.response?.status === 401 || err.response?.status === 403) {
|
||||
// throw new BadRequestException(
|
||||
// 'Cookie expired or invalid. Need re-login.',
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// return {success: false, error: msg};
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
/**
|
||||
* VERIFY COOKIE (FIX lỗi 34): Dùng GraphQL UserByRestId (hash ổn định hơn v1.1)
|
||||
* @param userHandle Optional: Nếu biết @username
|
||||
*/
|
||||
async verifyCookie(userHandle?: string): Promise<{ screen_name: string; name: string }> {
|
||||
try {
|
||||
let variables: GraphQLVariables;
|
||||
let queryId: string;
|
||||
|
||||
// if (userHandle) {
|
||||
// UserByScreenName (hash hiện tại 2026: extract nếu lỗi)
|
||||
queryId = 'IGgvgiOx4QZndDHuD3x9TQ'; // PLACEHOLDER → Extract (xem dưới)
|
||||
variables = {
|
||||
"screen_name": userHandle,
|
||||
"withSafetyModeUserFields": true,
|
||||
"withSuperFollowsUserFields": true
|
||||
};
|
||||
// }
|
||||
// else {
|
||||
// // UserByRestId (dùng userId từ twid)
|
||||
// queryId = 'IGgvgiOx4QZndDHuD3x9TQ'; // Placeholder → Extract
|
||||
// variables = { "userId": this.userId, "withSafetyModeUserFields": true, "withSuperFollowsUserFields": true };
|
||||
// }
|
||||
|
||||
const account = {
|
||||
authToken: X_COOKIE_NAME.auth_token,
|
||||
ct0: X_COOKIE_NAME.cto,
|
||||
}
|
||||
const client = this.buildClient(account);
|
||||
const res = await client.get(`/graphql/${this.queryID}/UserByScreenName?variables=%7B%22screen_name%22%3A%22realflashkaze%22%2C%22withSafetyModeUserFields%22%3Atrue%7D&features=%7B%7D`, { // Hoặc UserByScreenName
|
||||
// features: {
|
||||
// "rweb_tipjar_consumption_enabled":true,
|
||||
// "responsive_web_graphql_exclude_directive_enabled":true,
|
||||
// "verified_phone_label_enabled":false,
|
||||
// "creator_subscriptions_tweet_preview_api_enabled":true,
|
||||
// "responsive_web_graphql_timeline_navigation_enabled":true,
|
||||
// "responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,
|
||||
// "communities_web_preview_enabled":true
|
||||
// },
|
||||
// Add more features nếu cần (copy từ devtools)
|
||||
});
|
||||
return res.data.data.user;
|
||||
const user = res.data.data.user; // Hoặc res.data.data.result
|
||||
this.screenName = user.rest_id ? user.legacy.screen_name : user.screen_name;
|
||||
this.logger.log(`Verified: @${this.screenName}`);
|
||||
return {screen_name: this.screenName!, name: user.legacy.name};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Verify failed: ${error.response?.data?.errors?.[0]?.message || error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gọi Twitter API v1.1 để kiểm tra cookie còn sống.
|
||||
* Nếu cookie chết sẽ throw lỗi 401/403.
|
||||
*/
|
||||
async getCurrentUser(): Promise<{ id: string; screen_name: string; name: string }> {
|
||||
const url = 'https://api.twitter.com/1.1/account/verify_credentials.json?skip_status=true';
|
||||
|
||||
const res = await this.client.get(url);
|
||||
const data = res.data;
|
||||
|
||||
if (!data?.screen_name) {
|
||||
throw new Error('verify_credentials không trả về screen_name');
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id_str,
|
||||
screen_name: data.screen_name,
|
||||
name: data.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra nhanh: true = sống, false = chết (không throw)
|
||||
*/
|
||||
async isCookieAlive(): Promise<boolean> {
|
||||
try {
|
||||
await this.getCurrentUser();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// src/modules/x-router/x-router.service.ts
|
||||
import {Injectable, Logger} from '@nestjs/common';
|
||||
import {XCookieAccount} from "./interfaces/x-cookie.interface";
|
||||
import {BrowserAccount, XBrowserService} from "./x-browser.service";
|
||||
import {TwitterClient} from "./twitter.client";
|
||||
import {XCookieService} from "./x-cookie.service";
|
||||
|
||||
export enum XStrategy {
|
||||
COOKIE_FIRST = 'cookie_first', // rẻ nhất → fallback browser → api
|
||||
API_FIRST = 'api_first', // ổn định nhất → fallback cookie → browser
|
||||
BROWSER_FIRST = 'browser_first', // khi cần chống bot nặng
|
||||
COOKIE_ONLY = 'cookie_only',
|
||||
API_ONLY = 'api_only',
|
||||
BROWSER_ONLY = 'browser_only',
|
||||
AUTO = 'auto', // dựa vào health account
|
||||
BROWSER_API = 'browser_api',
|
||||
BROWSER_COOKIE='browser_cookie'// khi cần chống bot nặng
|
||||
}
|
||||
|
||||
export interface UnifiedAccount {
|
||||
id: string;
|
||||
api?: { accessToken: string; accessSecret: string; appKey: string; appSecret: string };
|
||||
cookie?: XCookieAccount;
|
||||
browser?: BrowserAccount;
|
||||
}
|
||||
|
||||
export interface RouterResult {
|
||||
success: boolean;
|
||||
tweetId?: string;
|
||||
via: 'api' | 'cookie' | 'browser';
|
||||
attempts: Array<{ method: string; error?: string }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class XRouterService {
|
||||
private readonly logger = new Logger(XRouterService.name);
|
||||
|
||||
constructor(
|
||||
private readonly apiSvc: TwitterClient,
|
||||
private readonly cookieSvc: XCookieService,
|
||||
private readonly browserSvc: XBrowserService,
|
||||
) {
|
||||
}
|
||||
|
||||
async verifyCookie(account: XCookieAccount): Promise<any> {
|
||||
return this.cookieSvc.verifyCookie('UserByScreenName')
|
||||
}
|
||||
|
||||
async postTweet(params: {
|
||||
account: UnifiedAccount;
|
||||
text: string;
|
||||
strategy?: XStrategy;
|
||||
}): Promise<RouterResult> {
|
||||
const strategy = params.strategy ?? XStrategy.COOKIE_FIRST;
|
||||
const chain = this.buildChain(strategy, params.account);
|
||||
const attempts: RouterResult['attempts'] = [];
|
||||
|
||||
for (const method of chain) {
|
||||
this.logger.log(`[${params.account.id}] Trying via ${method}`);
|
||||
const result = await this.execute(method, params.account, params.text);
|
||||
attempts.push({method, error: result.error});
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
tweetId: result.tweetId,
|
||||
via: method,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
// Nếu lỗi không fallback được (vd: text quá dài) → dừng
|
||||
if (this.isFatalError(result.error)) {
|
||||
return {
|
||||
success: false,
|
||||
via: method,
|
||||
attempts,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
via: chain[chain.length - 1],
|
||||
attempts,
|
||||
error: 'All methods failed',
|
||||
};
|
||||
}
|
||||
|
||||
/** Xây chain dựa trên strategy + account capabilities */
|
||||
private buildChain(
|
||||
strategy: XStrategy,
|
||||
account: UnifiedAccount,
|
||||
): Array<'api' | 'cookie' | 'browser'> {
|
||||
const has = {
|
||||
api: !!account.api,
|
||||
cookie: !!account.cookie,
|
||||
browser: !!account.browser,
|
||||
};
|
||||
|
||||
const chains: Record<XStrategy, Array<'api' | 'cookie' | 'browser'>> = {
|
||||
[XStrategy.BROWSER_API]: ['browser', 'api'],
|
||||
[XStrategy.COOKIE_FIRST]: ['cookie', 'browser', 'api'],
|
||||
[XStrategy.API_FIRST]: ['api', 'cookie', 'browser'],
|
||||
[XStrategy.BROWSER_FIRST]: ['browser', 'cookie', 'api'],
|
||||
[XStrategy.COOKIE_ONLY]: ['cookie'],
|
||||
[XStrategy.API_ONLY]: ['api'],
|
||||
[XStrategy.BROWSER_ONLY]: ['browser'],
|
||||
[XStrategy.BROWSER_COOKIE]: ['browser', 'cookie'],
|
||||
[XStrategy.AUTO]: ['cookie', 'browser', 'api'], // có thể dựa health store
|
||||
};
|
||||
|
||||
return chains[strategy].filter((m) => has[m]);
|
||||
}
|
||||
|
||||
private async execute(
|
||||
method: 'api' | 'cookie' | 'browser',
|
||||
account: UnifiedAccount,
|
||||
text: string,
|
||||
): Promise<{ success: boolean; tweetId?: string; error?: string }> {
|
||||
try {
|
||||
if (method === 'api' && account.api) {
|
||||
const {data: r} = await this.apiSvc.postSimpleTwitte(text);
|
||||
return {
|
||||
tweetId: r.id,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
if (method === 'cookie' && account.cookie) {
|
||||
// return await this.cookieSvc.createTweet(account.cookie, {text});
|
||||
}
|
||||
if (method === 'browser' && account.browser) {
|
||||
return await this.browserSvc.postTweet(account.browser, text);
|
||||
}
|
||||
return {success: false, error: `Method ${method} not configured`};
|
||||
} catch (e: any) {
|
||||
return {success: false, error: e.message};
|
||||
}
|
||||
}
|
||||
|
||||
private isFatalError(error?: string): boolean {
|
||||
if (!error) return false;
|
||||
const fatalPatterns = [
|
||||
/duplicate/i,
|
||||
/too long/i,
|
||||
/forbidden content/i,
|
||||
/suspended/i,
|
||||
];
|
||||
return fatalPatterns.some((p) => p.test(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// sqs.module.ts
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { SqsService } from './sqs.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SqsService],
|
||||
exports: [SqsService],
|
||||
})
|
||||
export class SqsModule {}
|
||||
@@ -0,0 +1,49 @@
|
||||
// post.service.ts
|
||||
import {Injectable} from '@nestjs/common';
|
||||
import {SQS_QUEUES_NAME, SqsService} from './sqs.service';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class SqsPostService {
|
||||
constructor(private readonly sqs: SqsService) {
|
||||
}
|
||||
|
||||
async sendMessage(username: string, data) {
|
||||
console.log(`SqsPostService -> sendMessage ${username} `);
|
||||
switch (username) {
|
||||
case 'realflashkaze': {
|
||||
return this.postFlashKaze(data)
|
||||
break;
|
||||
}
|
||||
case 'echcomvuive': {
|
||||
return this.postEchCom(data)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async postFlashKaze(data: any): Promise<any> {
|
||||
console.log(`SqsPostService_postFlashKaze`)
|
||||
return this.sqs.enqueue(
|
||||
SQS_QUEUES_NAME.REALFLASHKAZE!,
|
||||
data,
|
||||
{
|
||||
jobId: `acc1-${Date.now()}`,
|
||||
delaySeconds: Math.floor(Math.random() * 30),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async postEchCom(data: any) {
|
||||
console.log(`SqsPostService_postEchCom`)
|
||||
|
||||
await this.sqs.enqueue(
|
||||
SQS_QUEUES_NAME.ECHCOMVUIVE!,
|
||||
data,
|
||||
{
|
||||
jobId: `acc2-${Date.now()}`,
|
||||
delaySeconds: Math.floor(Math.random() * 30),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// sqs.service.ts
|
||||
import {
|
||||
SQSClient,
|
||||
SendMessageCommand,
|
||||
ReceiveMessageCommand,
|
||||
DeleteMessageCommand,
|
||||
CreateQueueCommand,
|
||||
GetQueueUrlCommand,
|
||||
} from '@aws-sdk/client-sqs';
|
||||
import {Injectable, Logger, OnModuleInit} from '@nestjs/common';
|
||||
|
||||
export const SQS_QUEUES_NAME = {
|
||||
REALFLASHKAZE: 'q_realflashkaze',
|
||||
ECHCOMVUIVE: 'q_echcomvuive'
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SqsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SqsService.name);
|
||||
private client: SQSClient;
|
||||
|
||||
private baseUrl = process.env.SQS_ENDPOINT + '000000000000';
|
||||
|
||||
// // 👉 define tất cả queue ở đây
|
||||
// private queues = [
|
||||
// 'post-acc1',
|
||||
// 'post-acc2',
|
||||
// ];
|
||||
|
||||
constructor() {
|
||||
this.client = new SQSClient({
|
||||
region: 'elasticmq',
|
||||
endpoint: process.env.SQS_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: 'x',
|
||||
secretAccessKey: 'x',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log('Initializing queues...');
|
||||
for (const name of Object.values(SQS_QUEUES_NAME)) {
|
||||
await this.ensureQueue(name);
|
||||
}
|
||||
this.logger.log('All queues ready ✅');
|
||||
}
|
||||
|
||||
getQueueUrl(name: string) {
|
||||
return `${this.baseUrl}/${name}`;
|
||||
}
|
||||
|
||||
private async ensureQueue(name: string) {
|
||||
try {
|
||||
await this.client.send(new CreateQueueCommand({
|
||||
QueueName: name,
|
||||
}));
|
||||
|
||||
this.logger.log(`Queue ensured: ${name}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to ensure queue ${name}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// private async ensureQueue(name: string) {
|
||||
// try {
|
||||
// await this.client.send(new GetQueueUrlCommand({
|
||||
// QueueName: name,
|
||||
// }));
|
||||
//
|
||||
// this.logger.log(`Queue exists: ${name}`);
|
||||
// } catch (err) {
|
||||
// this.logger.warn(`Queue missing → creating: ${name}`);
|
||||
//
|
||||
// await this.client.send(new CreateQueueCommand({
|
||||
// QueueName: name,
|
||||
// Attributes: {
|
||||
// VisibilityTimeout: '60',
|
||||
// MessageRetentionPeriod: '86400', // 1 ngày
|
||||
// },
|
||||
// }));
|
||||
//
|
||||
// this.logger.log(`Queue created: ${name}`);
|
||||
// }
|
||||
// }
|
||||
|
||||
// =====================
|
||||
// Core APIs
|
||||
// =====================
|
||||
|
||||
async enqueue(name: string, data: any, opts?: {
|
||||
delaySeconds?: number;
|
||||
jobId?: string;
|
||||
}) {
|
||||
const queueUrl = this.getQueueUrl(name);
|
||||
|
||||
// console.log(`QueueUrl: ${queueUrl}`);
|
||||
|
||||
const body = {
|
||||
...data,
|
||||
_jobId: opts?.jobId,
|
||||
_ts: Date.now(),
|
||||
};
|
||||
try {
|
||||
const data = await this.client.send(new SendMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MessageBody: JSON.stringify(body),
|
||||
DelaySeconds: opts?.delaySeconds || 0,
|
||||
}))
|
||||
console.log("Gửi thành công! MessageId:", data.MessageId);
|
||||
} catch (err) {
|
||||
console.error("Lỗi khi gửi tin nhắn:", err.message);
|
||||
throw err;
|
||||
}
|
||||
// return this.client.send(new SendMessageCommand({
|
||||
// QueueUrl: queueUrl,
|
||||
// MessageBody: JSON.stringify(body),
|
||||
// DelaySeconds: opts?.delaySeconds || 0,
|
||||
// })).then(()=> {
|
||||
// this.logger.log(`Queue enqueued: ${name}`);
|
||||
// });
|
||||
}
|
||||
|
||||
async receive(name: string) {
|
||||
const queueUrl = this.getQueueUrl(name);
|
||||
|
||||
const res = await this.client.send(new ReceiveMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MaxNumberOfMessages: 1,
|
||||
WaitTimeSeconds: 10,
|
||||
VisibilityTimeout: 60,
|
||||
}));
|
||||
|
||||
if (!res.Messages?.length) return null;
|
||||
|
||||
const msg = res.Messages[0];
|
||||
|
||||
return {
|
||||
raw: msg,
|
||||
body: JSON.parse(msg.Body!),
|
||||
};
|
||||
}
|
||||
|
||||
async ack(name: string, receiptHandle: string) {
|
||||
const queueUrl = this.getQueueUrl(name);
|
||||
|
||||
await this.client.send(new DeleteMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
ReceiptHandle: receiptHandle,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const InjectGrammyBot = () => {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {DynamicModule, Global, Module, OnModuleInit} from "@nestjs/common";
|
||||
import {Bot} from "grammy";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
{
|
||||
provide: 'TELEGRAM_GRAMMY_BOT',
|
||||
useFactory: () => {
|
||||
// const bot = new Bot("" + process.env.TELEGRAM_BOT_V2_TOKEN);
|
||||
// console.log("✅ ✅ TeleGrammY Bot initialized");
|
||||
|
||||
// perform any setup like bot.api.setMyCommands(...)
|
||||
// Start the bot.
|
||||
// bot.start().then(() => {
|
||||
// console.log("✅ ✅ TeleGrammY Bot Started");
|
||||
// });
|
||||
//
|
||||
//
|
||||
// return bot;
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
export class TeleGrammYModule implements OnModuleInit {
|
||||
|
||||
onModuleInit(): any {
|
||||
|
||||
console.log("✅ ✅ TeleGrammY Module initialized");
|
||||
// Start the bot.
|
||||
// this.bot.start();
|
||||
}
|
||||
|
||||
|
||||
// static forRootAsync(options: any): DynamicModule {
|
||||
// return {
|
||||
// module: TeleGrammYModule,
|
||||
// imports: options.imports || [],
|
||||
// providers: [
|
||||
// {
|
||||
// provide: 'MY_OPTIONS',
|
||||
// useFactory: options.useFactory,
|
||||
// inject: options.inject || [],
|
||||
// },
|
||||
// MyService,
|
||||
// ],
|
||||
// exports: [MyService],
|
||||
// };
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {Inject, Injectable} from "@nestjs/common";
|
||||
import {Bot} from "grammy";
|
||||
|
||||
@Injectable()
|
||||
export class TeleGrammYService {
|
||||
constructor(@Inject('TELEGRAM_GRAMMY_BOT') bot: Bot) {
|
||||
}
|
||||
|
||||
// @Command()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// auth.guard.ts
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { TelegrafExecutionContext } from 'nestjs-telegraf';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
// Danh sách ID được phép (có thể để trong file .env)
|
||||
private readonly allowedIds =(process.env.TELEGRAM_ALLOW_CHAT_IDS || '').split(',');
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
console.log('AuthGuard');
|
||||
const ctx = TelegrafExecutionContext.create(context);
|
||||
const { chat } = ctx.getContext();
|
||||
|
||||
// Kiểm tra nếu chat_id nằm trong danh sách cho phép
|
||||
const isAllowed = this.allowedIds.includes(chat.id);
|
||||
|
||||
if (!isAllowed) {
|
||||
console.warn(`Truy cập trái phép từ ID: ${chat.id}`);
|
||||
}
|
||||
|
||||
return isAllowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const WIZARD_WRITER_SCENE_ID = 'WIZARD_WRITER_SCENE_ID';
|
||||
export const WIZARD_COMMENT_SCENE_ID = 'WIZARD_COMMENT_SCENE_ID';
|
||||
export const WIZARD_COMMENT2_SCENE_ID = 'WIZARD_COMMENT2_SCENE_ID';
|
||||
export const WIZARD_QUOTE_SCENE_MAIN = 'WIZARD_QUOTE_SCENE_MAIN';
|
||||
@@ -0,0 +1,110 @@
|
||||
// src/modules/telegram/telegram.processor.ts
|
||||
import {OnWorkerEvent, Processor, WorkerHost} from '@nestjs/bullmq';
|
||||
import {Job} from 'bullmq';
|
||||
import {InjectBot} from 'nestjs-telegraf';
|
||||
import {Context, Telegraf} from 'telegraf';
|
||||
import {Injectable} from "@nestjs/common";
|
||||
import {PublishPageService} from "../social-api/publish.page.service";
|
||||
import {isEmpty, toNumber} from "lodash";
|
||||
|
||||
@Injectable()
|
||||
@Processor('content_writer_completed_queue') // Lắng nghe hàng đợi của AI-B
|
||||
export class TelegramContentWriterCompletedProcessor extends WorkerHost {
|
||||
constructor(
|
||||
@InjectBot() private readonly bot: Telegraf<Context>,
|
||||
private readonly publishPageService: PublishPageService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
// Đây là logic xử lý task (nếu cần xử lý thêm ở tầng telegram)
|
||||
async process(job: Job<any>): Promise<any> {
|
||||
// Task viết bài đã được AI-B xử lý ở FacebookProcessor
|
||||
// Ở đây chúng ta có thể thực hiện các logic hậu kỳ nếu muốn
|
||||
console.log('TelegramProcessor_facebook_content_writer_completed_process ==> begin');
|
||||
// console.log({job});
|
||||
|
||||
const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
|
||||
|
||||
console.log('TelegramProcessor_facebook_content_writer_completed_process=>posting')
|
||||
//console.log(fbContent);
|
||||
if (job.name === 'generate_post_completed') {
|
||||
const {id, needConfirm, content, autoPublish, telegramChatId, xSubmitProvider} = job.data;
|
||||
console.log({id, needConfirm, autoPublish, xSubmitProvider})
|
||||
const X_USERS = process.env.TWITTER_USERNAMES!.split(',');
|
||||
console.log({X_USERS});
|
||||
//console.log({job})
|
||||
// if (autoPublish) {
|
||||
// // await this.publishPageService.publishTwitter(id);
|
||||
// // await this.publishPageService.publishToFacebook(id);
|
||||
// const postId = toNumber(id);
|
||||
// await this.bot.telegram.sendMessage(adminChatId, '🤖Đã viết xong tin auto:');
|
||||
//
|
||||
// try {
|
||||
// await Promise.allSettled([
|
||||
// this.publishPageService.publishToFacebook(postId).then(() => {
|
||||
// this.bot.telegram.sendMessage(adminChatId, `Đăng bài lên FB số ${postId} thành công`);
|
||||
// }).catch(err => {
|
||||
// this.bot.telegram.sendMessage(adminChatId, `PublishToFacebook error ${err.message}`);
|
||||
//
|
||||
// }),
|
||||
// this.publishPageService.publishTwitter(postId, xSubmitProvider).then((resp) => {
|
||||
// this.bot.telegram.sendMessage(adminChatId, `Đăng bài lên X số ${postId} thành công via ${xSubmitProvider}, ${resp?.url}`);
|
||||
// }).catch(err => {
|
||||
// this.bot.telegram.sendMessage(adminChatId, `PublishToX error ${err.message}`);
|
||||
// }),
|
||||
// ]
|
||||
// )
|
||||
// } catch (e) {
|
||||
// await this.bot.telegram.sendMessage(adminChatId, `❌ Lỗi đăng bài: ${e.message}`);
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
await this.bot.telegram.sendMessage(telegramChatId || adminChatId, '🤖Đã viết xong:');
|
||||
await this.bot.telegram.sendMessage(telegramChatId || adminChatId,
|
||||
`${content}\n\n`,
|
||||
{
|
||||
// parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
...X_USERS.map((xuser) => {
|
||||
return [{
|
||||
text: `↗️X ${xuser}`,
|
||||
callback_data: `publish-post_twitter_${id}_${xuser}`
|
||||
}];
|
||||
}),
|
||||
[
|
||||
// {text: "↗️X", callback_data: `publish-post_twitter_${id}`},
|
||||
{text: "↗️FB", callback_data: `publish-post_facebook_${id}`},
|
||||
],
|
||||
[
|
||||
// {text: "↗️All", callback_data: `publish-post_all_${id}`},
|
||||
{text: "🗑️ Hủy bài", callback_data: `delete-post_${id}`}]
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
return {};
|
||||
|
||||
//return fbContent;
|
||||
// const postFbId= await this.publishPageService.publishToFacebook(id)
|
||||
// const twId= await this.publishPageService.publishTwitter(id)
|
||||
// return job;
|
||||
return {
|
||||
//id: id,
|
||||
// postFbId,
|
||||
// twId,
|
||||
// content: postContent,
|
||||
//image: imageSuggestion,
|
||||
status: 'posted'
|
||||
};
|
||||
}
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
async onFailed(job: Job<any>, error: Error) {
|
||||
const adminChatId = process.env.TELEGRAM_ADMIN_ID || 0;
|
||||
const telegramChatId = job.data.telegramChatId
|
||||
await this.bot.telegram.sendMessage(telegramChatId || adminChatId, `❌ **Lỗi AI-B:** Job #${job.id} thất bại. \nLý do: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {Module, OnModuleInit} from '@nestjs/common';
|
||||
import {TelegramContentWriterCompletedProcessor} from "./telegram.content.writer.completed.processor";
|
||||
import {TelegramUpdates} from "./telegram.updates";
|
||||
import {ManagerModule} from "../manager/manager.module";
|
||||
import {PgPostService} from "../../shared/pg.post.service";
|
||||
import {PublishPageService} from "../social-api/publish.page.service";
|
||||
import {FacebookApi} from "../social-api/facebook.api";
|
||||
import {SocialModule} from "../social-api/social.module";
|
||||
import {InjectBot} from "nestjs-telegraf";
|
||||
import {Context, Telegraf} from "telegraf";
|
||||
import {XReaderService} from "../x-reader/x-reader.service";
|
||||
import {TrendsService} from "../trends/trends.service";
|
||||
import {CollectorModule} from "../collector/collector.module";
|
||||
import {WriterWizard} from "./wizard/writer.wizard";
|
||||
import {CommentWizard} from "./wizard/comment.wizard";
|
||||
import {QuoteWizard} from "./wizard/quote.wizard";
|
||||
import {XImageUploadService} from "../x-uploader/x-image.upload.service";
|
||||
import {Comment2Wizard} from "./wizard/comment2.wizard";
|
||||
import {SqsPostService} from "../sqs-module/sqs.post.service";
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ManagerModule,
|
||||
SocialModule,
|
||||
CollectorModule,
|
||||
],
|
||||
providers: [
|
||||
TelegramUpdates,
|
||||
TelegramContentWriterCompletedProcessor,
|
||||
FacebookApi,
|
||||
PgPostService,
|
||||
PublishPageService,
|
||||
XReaderService,
|
||||
TrendsService,
|
||||
WriterWizard,
|
||||
CommentWizard,
|
||||
QuoteWizard,
|
||||
Comment2Wizard,
|
||||
XImageUploadService,
|
||||
SqsPostService
|
||||
],
|
||||
})
|
||||
export class TelegramModule implements OnModuleInit{
|
||||
constructor(@InjectBot() private readonly bot: Telegraf<Context>) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.bot.telegram.setMyCommands( [
|
||||
{ command: 'start', description: 'Khởi động bot' },
|
||||
{ command: 'help', description: 'Trợ giúp' },
|
||||
{ command: 'trendstat', description: 'trend statistic' },
|
||||
{ command: 'analyzenews', description: 'Tìm trend' },
|
||||
{ command: 'ask', description: 'Hoi AI' },
|
||||
{ command: 'write', description: 'Version mới, dùng 2 AI, 1 viết và 1 reviewer' },
|
||||
{ command: 'write_', description: 'Version mới, dùng 2 AI, 1 viết và 1 reviewer' },
|
||||
{ command: 'writecheap', description: 'Version cũ, dùng deepseek' },
|
||||
{ command: 'xreader', description: 'Lấy nội dung băng api' },
|
||||
{ command: 'xreader_browser', description: 'Lấy nội dung bằng trình duyệt' },
|
||||
{ command: 'collectrss', description: 'Lấy nội dung' },
|
||||
{ command: 'comment', description: 'Viet reply X' },
|
||||
{ command: 'comen', description: '/com[en,ja,vi,ko] comment as text' },
|
||||
]);
|
||||
console.log('✅ Đã cập nhật Menu commands thành công');
|
||||
} catch (error) {
|
||||
console.error('❌ Lỗi khi cập nhật Menu:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
import {Action, Command, Ctx, On, Start, Update} from 'nestjs-telegraf';
|
||||
import {Context, Scenes} from 'telegraf';
|
||||
import {HttpException, Injectable} from "@nestjs/common";
|
||||
import {ManagerService} from "../manager/manager.service";
|
||||
import {PublishPageService} from "../social-api/publish.page.service";
|
||||
import {_toNum} from "../../shared/helper";
|
||||
import {XReaderService} from "../x-reader/x-reader.service";
|
||||
import {TrendsService} from "../trends/trends.service";
|
||||
import {get, isEmpty} from 'lodash'
|
||||
import {
|
||||
WIZARD_COMMENT2_SCENE_ID,
|
||||
WIZARD_COMMENT_SCENE_ID,
|
||||
WIZARD_QUOTE_SCENE_MAIN,
|
||||
WIZARD_WRITER_SCENE_ID
|
||||
} from "./telegram.constants";
|
||||
import {XImageUploadService} from "../x-uploader/x-image.upload.service";
|
||||
import {TextUtil} from "../../common/utils/text.util";
|
||||
import {XStrategy} from "../social-api/x-router.service";
|
||||
import {XCacheService} from "../x-cache/x-cache.service";
|
||||
import {SqsPostService} from "../sqs-module/sqs.post.service";
|
||||
|
||||
@Injectable()
|
||||
@Update()
|
||||
export class TelegramUpdates {
|
||||
|
||||
constructor(
|
||||
private readonly managerService: ManagerService,
|
||||
private readonly publishPageService: PublishPageService,
|
||||
private readonly xReaderService: XReaderService,
|
||||
private readonly trendsService: TrendsService,
|
||||
private readonly xUpload: XImageUploadService,
|
||||
private readonly cacheService: XCacheService,
|
||||
private readonly sqsPostService: SqsPostService
|
||||
) {
|
||||
}
|
||||
|
||||
@Start()
|
||||
async onStart(ctx: Context) {
|
||||
|
||||
await ctx.reply('Hệ thống Automation đã sẵn sàng! Gửi /help để xem lệnh.');
|
||||
}
|
||||
|
||||
@Command('help')
|
||||
async onHelpCommand(ctx: Context) {
|
||||
await ctx.reply('Sau đây là các tính năng... ' +
|
||||
'\n' +
|
||||
'/writecheap_[en|ja|ko|vi] your_topic... (viết bài bằng AI deepseek)\n' +
|
||||
'/write_[vi|en|ja|ko] your_topic... (viết bài bằng AI chatgpt + deepseek)\n' +
|
||||
'/manual_publish_post postNum\n' +
|
||||
'/analyzenews ... (tim trend,...) \n' +
|
||||
'/trendstat \n' +
|
||||
'/ask ... (hỏi với ai deepseek,...) \n' +
|
||||
'/trend_stat ... (...) \n' +
|
||||
'/xreader ... (...) \n' +
|
||||
'/xreaderbrowser ... (...) \n' +
|
||||
'/collectrss ... (...) \n' +
|
||||
'/comment link X... (...) \n' +
|
||||
'/quote link X... (...) \n' +
|
||||
'');
|
||||
}
|
||||
|
||||
@Command(['download', 'dl'])
|
||||
async onDownloadVideo(ctx) {
|
||||
// @ts-ignore
|
||||
const {command, payload} = ctx;
|
||||
|
||||
if (!payload) {
|
||||
await ctx.reply(`vui lòng đưa topic, ví dụ: /${command} link tiktok/fb`);
|
||||
return;
|
||||
}
|
||||
// Regex tổng hợp các loại link
|
||||
const tiktokRegex = /https?:\/\/(?:www\.|vm\.|vt\.|m\.)?tiktok\.com\/[^\s]+/gi;
|
||||
const youtubeRegex = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)[^\s]+/gi;
|
||||
const facebookRegex = /https?:\/\/(?:www\.|fb\.)?(?:facebook\.com\/reels\/|watch\/)[^\s]+/gi;
|
||||
const mp4Regex = /https?:\/\/[^\s^"]+\.mp4(?:\?[^\s^"]*)?/gi;
|
||||
|
||||
// Cách 1: Lấy tất cả vào một mảng
|
||||
const allRegex = new RegExp(`${tiktokRegex.source}|${youtubeRegex.source}|${facebookRegex.source}|${mp4Regex.source}`, 'gi');
|
||||
const allLinks = payload.match(allRegex) || [];
|
||||
console.log({allLinks});
|
||||
|
||||
console.log("Tất cả link tìm thấy:", allLinks);
|
||||
|
||||
if (allLinks.length === 0) {
|
||||
await ctx.reply(`Khong tim thay link download`);
|
||||
return;
|
||||
}
|
||||
if (allLinks.length === 1) {
|
||||
await this.managerService.handleDownloadVideo(allLinks[0], ctx.chat.id);
|
||||
} else {
|
||||
await this.managerService.handleDownloadVideoMulti(allLinks, ctx.chat.id);
|
||||
}
|
||||
await ctx.reply(`vui lòng chờ download ....`);
|
||||
}
|
||||
|
||||
@Command('downloadmp4url')
|
||||
async onDownloadMp4Url(ctx) {
|
||||
// @ts-ignore
|
||||
const {command, payload} = ctx;
|
||||
|
||||
if (!payload) {
|
||||
await ctx.reply(`vui lòng đưa topic, ví dụ: /${command} link mp4`);
|
||||
return;
|
||||
}
|
||||
await ctx.reply(`vui lòng chờ download....`);
|
||||
await this.managerService.handleDownloadMp4Url(payload, 'x', ctx.chat.id);
|
||||
}
|
||||
|
||||
@Command('comment')
|
||||
async onWizardCommand(@Ctx() ctx: Scenes.SceneContext): Promise<void> {
|
||||
await ctx.scene.enter(WIZARD_COMMENT_SCENE_ID);
|
||||
}
|
||||
|
||||
@Command([
|
||||
'comvi',
|
||||
'comen',
|
||||
'comja',
|
||||
'comko',
|
||||
'comcn',
|
||||
])
|
||||
async onCommentAsTextCommand(@Ctx() ctx: Scenes.SceneContext): Promise<void> {
|
||||
|
||||
let language = 'en';
|
||||
// @ts-ignore
|
||||
let {command, payload} = ctx;
|
||||
if (['comvi', 'comvn', 'com_vi'].includes(command)) {
|
||||
language = 'vi';
|
||||
}
|
||||
if (['comko', 'comkr', 'com_ko'].includes(command)) {
|
||||
language = 'ko';
|
||||
}
|
||||
if (['comja', 'comjp', 'com_ja'].includes(command)) {
|
||||
language = 'ja';
|
||||
}
|
||||
if (['comcn'].includes(command)) {
|
||||
language = 'cn';
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
await ctx.reply(`vui lòng đưa topic, ví dụ: /${command} bitcoin approach $100000`);
|
||||
return;
|
||||
}
|
||||
const chatId = ctx.chat?.id;
|
||||
payload = payload.trim(); // Gets "my_payload"
|
||||
console.log({payload});
|
||||
let tweetId = undefined;
|
||||
let tweetUrl = undefined;
|
||||
let _linkX = payload.replace('twitter.com', 'x.com').split('?')[0];
|
||||
const match = _linkX.match(/status\/(\d+)/);
|
||||
if (match) {
|
||||
//nêu match => get content x
|
||||
const xpost = await this.xReaderService.readXPost(_linkX);
|
||||
console.log('==> content text:' + xpost.text);
|
||||
payload = xpost.text;
|
||||
tweetId = xpost.tweetId;
|
||||
tweetUrl = _linkX;
|
||||
} else {
|
||||
|
||||
}
|
||||
await ctx.scene.enter(WIZARD_COMMENT2_SCENE_ID, {
|
||||
language,
|
||||
userTopic: payload,
|
||||
tweetId,
|
||||
tweetUrl,
|
||||
chatId
|
||||
});
|
||||
}
|
||||
|
||||
@Command([
|
||||
'quote_vi',
|
||||
'quotevi',
|
||||
'quotevn',
|
||||
'quote_en',
|
||||
'quoteen',
|
||||
'quote_ja',
|
||||
'quoteja',
|
||||
'quote_jp',
|
||||
'quotejp',
|
||||
'quote_ko',
|
||||
'quoteko',
|
||||
'quotecn',
|
||||
])
|
||||
async onWizardQuote(@Ctx() ctx: Scenes.SceneContext): Promise<void> {
|
||||
// @ts-ignore
|
||||
let {command, payload} = ctx;
|
||||
if (['quote_jp', 'quotejp', 'quoteja'].includes(command)) {
|
||||
command = 'quote_ja';
|
||||
}
|
||||
if (['quote_vn', 'quotevi', 'quotevn'].includes(command)) {
|
||||
command = 'quote_vi';
|
||||
}
|
||||
if (['quoteen'].includes(command)) {
|
||||
command = 'quote_en';
|
||||
}
|
||||
if (['quoteko'].includes(command)) {
|
||||
command = 'quote_ko';
|
||||
}
|
||||
if (['quotecn'].includes(command)) {
|
||||
command = 'quote_cn';
|
||||
}
|
||||
if (!payload) {
|
||||
await ctx.reply(`vui lòng đưa url, ví dụ: /${command} link_x_post ?`);
|
||||
return;
|
||||
}
|
||||
const preferLanguage = command.split('_')[1];
|
||||
await ctx.scene.enter(WIZARD_QUOTE_SCENE_MAIN, {
|
||||
language: preferLanguage,
|
||||
linkUrl: payload,
|
||||
quoteText: '',
|
||||
quoteAs: 'quotelink',
|
||||
});
|
||||
}
|
||||
|
||||
@Command([
|
||||
'quoteentext',
|
||||
'quotevitext',
|
||||
'quotejatext',
|
||||
'quotekotext',
|
||||
'quotecntext',
|
||||
])
|
||||
async onQuoteAsTextCommand(@Ctx() ctx: Scenes.SceneContext): Promise<void> {
|
||||
// @ts-ignore
|
||||
let {command, payload} = ctx;
|
||||
let language = 'en';
|
||||
if (['quotevitext'].includes(command)) {
|
||||
language = 'vi';
|
||||
}
|
||||
|
||||
if (['quotejatext'].includes(command)) {
|
||||
language = 'ja';
|
||||
}
|
||||
if (['quotecntext'].includes(command)) {
|
||||
language = 'cn';
|
||||
}
|
||||
|
||||
if (['quotekotext'].includes(command)) {
|
||||
language = 'ko';
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
await ctx.reply(`ví dụ: /${command} nội dung quote ?`);
|
||||
return;
|
||||
}
|
||||
await ctx.scene.enter(WIZARD_QUOTE_SCENE_MAIN, {
|
||||
language: language,
|
||||
quoteText: payload,
|
||||
quoteAs: 'quotetext',
|
||||
});
|
||||
}
|
||||
|
||||
@Command('collectrss')
|
||||
async onCollectRssCommand(ctx: Context) {
|
||||
const result = await this.trendsService.collectAndStore('rss');
|
||||
|
||||
ctx.reply('Collection completed');
|
||||
await ctx.reply(JSON.stringify(result.stats));
|
||||
}
|
||||
|
||||
@Command(['writecheap_vi', 'writecheap_en', 'writecheap_ja', 'writecheap_ko'])
|
||||
async onWriteCheapCommand(@Ctx() ctx: Context) {
|
||||
// @ts-ignore
|
||||
const {command, payload} = ctx;
|
||||
const preferLanguage = command.split('_')[1];
|
||||
|
||||
if (!payload) {
|
||||
await ctx.reply(`vui lòng đưa topic, ví dụ: /${command} bitcoin approach $100000`);
|
||||
return;
|
||||
}
|
||||
await this.managerService.manualTrigger(payload, '', preferLanguage);
|
||||
|
||||
await ctx.reply(`Đang tìm kiếm tin tức về: ${payload} với ngôn ngữ ${preferLanguage}...`);
|
||||
}
|
||||
|
||||
@Command([
|
||||
'write_vn',
|
||||
'write_vi',
|
||||
'writevi',
|
||||
'write_en',
|
||||
'writeen',
|
||||
'write_jp',
|
||||
'write_ja',
|
||||
'writeja',
|
||||
'write_kr',
|
||||
'write_ko',
|
||||
'writeko',
|
||||
'writecn',
|
||||
'write_cn',
|
||||
])
|
||||
async onWriteSmartWithAIReviewerCommand(@Ctx() ctx: Scenes.SceneContext) {
|
||||
// @ts-ignore
|
||||
let {command, payload} = ctx;
|
||||
if (['write_jp', 'writejp', 'writeja'].includes(command)) {
|
||||
command = 'write_ja';
|
||||
}
|
||||
if (['write_kr', 'writekr', 'writeko'].includes(command)) {
|
||||
command = 'write_ko';
|
||||
}
|
||||
if (['write_vn', 'writevn', 'writevi'].includes(command)) {
|
||||
command = 'write_vi';
|
||||
}
|
||||
if (['writeen'].includes(command)) {
|
||||
command = 'write_en';
|
||||
}
|
||||
if (['writecn'].includes(command)) {
|
||||
command = 'write_cn';
|
||||
}
|
||||
const preferLanguage = command.split('_')[1];
|
||||
|
||||
if (!payload) {
|
||||
await ctx.reply(`vui lòng đưa topic, ví dụ: /${command} bitcoin approach $100000`);
|
||||
return;
|
||||
}
|
||||
const chatId = ctx.chat?.id;
|
||||
if (payload.length < 200) {
|
||||
//check has link X
|
||||
const detectSendLinkX = TextUtil.detectLinkX(payload);
|
||||
if (detectSendLinkX.hasLinkX) {
|
||||
const xpost = await this.xReaderService.readXPost(detectSendLinkX.url);
|
||||
payload = xpost.text;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.scene.enter(WIZARD_WRITER_SCENE_ID, {
|
||||
language: preferLanguage,
|
||||
userTopic: payload,
|
||||
chatId,
|
||||
});
|
||||
// await this.managerService.manualTriggerWrite2(payload, '', preferLanguage);
|
||||
|
||||
// await ctx.reply(`Đang tìm kiếm tin tức về: ${payload} với ngôn ngữ ${preferLanguage}...`);
|
||||
}
|
||||
|
||||
@Command('manual_publish_post')
|
||||
async manual_publish_post(@Ctx() ctx: Context) {
|
||||
// @ts-ignore
|
||||
const {command, payload} = ctx;
|
||||
if (!payload) {
|
||||
await ctx.reply('thiếu thông tin post num');
|
||||
return;
|
||||
}
|
||||
const postNo = 1 * payload;
|
||||
await ctx.reply(`Đang đẩy bài ${payload} lên Facebook, Twitter`);
|
||||
|
||||
await ctx.reply(
|
||||
`🤖 **Vui lòng chọn kênh**`,
|
||||
{
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{text: "🐦 Post X", callback_data: `publish-post_twitter_${postNo}`},
|
||||
{text: "🚀 Post FB", callback_data: `publish-post_facebook_${postNo}`},
|
||||
],
|
||||
[
|
||||
{text: "🐦 Post X via browser", callback_data: `publish-post_twitterbrowser_${postNo}`},
|
||||
{text: "🚀 Post all", callback_data: `publish-post_all_${postNo}`},
|
||||
],
|
||||
[{text: "🗑️ Hủy bài", callback_data: `delete-post_${postNo}`}]
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// await this.publishPageService.publishToFacebook(postNo).then(() => {
|
||||
// ctx.reply(`Đăng bài lên FB số ${postNo} thành công`);
|
||||
// }).catch(err => {
|
||||
// ctx.reply(`PublishToFacebook error ${err.message}`);
|
||||
// });
|
||||
// await this.publishPageService.publishTwitter(postNo).then(() => {
|
||||
// ctx.reply(`Đăng bài lên X số ${postNo} thành công`);
|
||||
// }).catch(err => {
|
||||
// ctx.reply(`PublishTwitter error ${err.message}`);
|
||||
// });
|
||||
|
||||
}
|
||||
|
||||
@Command('analyzenews')
|
||||
async onAnalyzeNews(ctx: Context) {
|
||||
// @ts-ignore
|
||||
const {command, payload} = ctx;
|
||||
|
||||
if (!payload) {
|
||||
await ctx.reply(`vui lòng đưa topic, ví dụ: /${command} Hot news today in japan ?`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.managerService.handleAnalyzeNewsTrend(payload);
|
||||
await ctx.reply(`vui lòng đưa chờ phân tích ....`);
|
||||
}
|
||||
|
||||
@Command('ask')
|
||||
async onAsk(ctx: Context) {
|
||||
// @ts-ignore
|
||||
const {command, payload} = ctx;
|
||||
|
||||
if (!payload) {
|
||||
await ctx.reply(`vui lòng đưa topic, ví dụ: /${command} Hot news today in japan ?`);
|
||||
return;
|
||||
}
|
||||
console.log(ctx.chat);
|
||||
await this.managerService.handleAskQues(payload, ctx.chat?.id);
|
||||
await ctx.reply(`vui lòng đưa chờ phân tích ....`);
|
||||
}
|
||||
|
||||
@Command('askx')
|
||||
async onAskXContext(ctx: Context) {
|
||||
// @ts-ignore
|
||||
const {command, payload} = ctx;
|
||||
|
||||
if (!payload) {
|
||||
await ctx.reply(`vui lòng đưa topic, ví dụ: /${command} Hot news today in japan ?`);
|
||||
return;
|
||||
}
|
||||
console.log(ctx.chat);
|
||||
await this.managerService.handleAskXContext(payload, ctx.chat?.id);
|
||||
await ctx.reply(`vui lòng đưa chờ phân tích ....`);
|
||||
}
|
||||
|
||||
@Command(['xreader', 'xreader_browser'])
|
||||
async onXReader(ctx: Context) {
|
||||
// @ts-ignore
|
||||
let {command, payload} = ctx;
|
||||
if (!payload) {
|
||||
await ctx.reply(`vui lòng đưa url, ví dụ: /${command} link_x_post ?`);
|
||||
return;
|
||||
}
|
||||
await ctx.reply(`vui lòng chờ phân tích ....`);
|
||||
|
||||
const content = await this.xReaderService.readXPost(
|
||||
payload,
|
||||
command === 'xreader_browser' ? 'browser' : 'any'
|
||||
).catch((err) => {
|
||||
ctx.reply(err.message);
|
||||
return err;
|
||||
});
|
||||
let textMessage = ``;
|
||||
try {
|
||||
textMessage += `🤖 crawler by: ${content.via} \n\n`;
|
||||
if (content) {
|
||||
textMessage += `====== CONTENT ===== \n\n`;
|
||||
textMessage += `text: ${content.text} \n----------------------- \n`;
|
||||
if (!isEmpty(content.images)) {
|
||||
textMessage += `images: ${content.images.join(' , ')} \n----------------------- \n`;
|
||||
}
|
||||
if (!isEmpty(content.videos)) {
|
||||
textMessage += `videos: ${content.videos.join(' , ')} \n`;
|
||||
}
|
||||
|
||||
if (!isEmpty(content.quoted)) {
|
||||
textMessage += `====== QUOTE ===== \n`;
|
||||
textMessage += `quoted text: ${get(content, 'quoted.text')} \n----------------------- \n`;
|
||||
if (!isEmpty(content?.quoted?.images)) {
|
||||
textMessage += `quoted images: ${get(content, 'quoted.images', []).join(' , ')} \n----------------------- \n`;
|
||||
}
|
||||
if (!isEmpty(content?.quoted?.videos)) {
|
||||
textMessage += `quoted videos: ${get(content, 'quoted.videos', []).join(' , ')} \n----------------------- \n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
await ctx.reply(textMessage, {parse_mode: 'HTML'});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
ctx.reply(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@Action(/publish-post_(.+)/)
|
||||
async onPublishFB(ctx: Context) {
|
||||
const telegramChatId = ctx.chat?.id;
|
||||
const callbackData = (ctx.callbackQuery as any).data;
|
||||
const social = callbackData.split('_')[1];
|
||||
const postId = _toNum(callbackData.split('_')[2]);
|
||||
const xUsername = callbackData.split('_')[3];
|
||||
|
||||
await ctx.answerCbQuery(`Đang đẩy bài lên Fb,X ${xUsername}...`);
|
||||
|
||||
const promiseSettedFunc = [];
|
||||
let allowTw = 0;
|
||||
let allowFb = 0;
|
||||
let twStrageryPost = XStrategy.API_ONLY;
|
||||
let publishTo: any = [];
|
||||
switch (social) {
|
||||
case 'all':
|
||||
allowTw = 1;
|
||||
allowFb = 1;
|
||||
publishTo = ['x', 'fb'];
|
||||
break;
|
||||
case 'twitter':
|
||||
allowTw = 1;
|
||||
publishTo = ['x'];
|
||||
twStrageryPost=XStrategy.BROWSER_FIRST
|
||||
break;
|
||||
case 'twitterbrowser':
|
||||
allowTw = 1;
|
||||
publishTo = ['x'];
|
||||
twStrageryPost = XStrategy.BROWSER_ONLY;
|
||||
break;
|
||||
case 'facebook':
|
||||
allowFb = 1;
|
||||
publishTo = ['fb'];
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const messageText = ctx.callbackQuery.message.text;
|
||||
|
||||
await this.sqsPostService.sendMessage(xUsername,
|
||||
{
|
||||
name: 'generate_post_completed',
|
||||
type: 'X_POSTER_TWEET',
|
||||
needConfirm: 1,
|
||||
content: messageText,
|
||||
autoPublish: 1,
|
||||
telegramChatId,
|
||||
xUsername,
|
||||
publishTo,
|
||||
xSubmitProvider: XStrategy.BROWSER_FIRST //cứ 3post api, có 1 post browser
|
||||
}
|
||||
)
|
||||
// const r = await this.publishPageService.relyX(messageText, tweetId)
|
||||
//@ts-ignore
|
||||
await ctx.editMessageText(`✅ ** Đã gửi tweet sang queue ${xUsername} ID bài viết: ${postId}`);
|
||||
|
||||
|
||||
// await Promise.allSettled([
|
||||
// allowFb && this.publishPageService.publishToFacebook(postId).then(() => {
|
||||
// ctx.reply(`Đăng bài lên FB số ${postId} thành công`);
|
||||
// }).catch(err => {
|
||||
// ctx.reply(`PublishToFacebook error ${err.message}`);
|
||||
// }),
|
||||
// allowTw && this.publishPageService.publishTwitter(postId, twStrageryPost).then((resp) => {
|
||||
// ctx.reply(`Đăng bài lên X số ${postId} thành công, ${resp?.url}`);
|
||||
// }).catch(err => {
|
||||
// ctx.reply(`PublishTwitter error ${err.message}`);
|
||||
// }),
|
||||
// ]
|
||||
// )
|
||||
|
||||
// await ctx.editMessageText(`✅ **ĐÃ ĐĂNG THÀNH CÔNG!**\nID bài viết: ${postId}`);
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ Lỗi gửi bài: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Action(/delete-post_(.+)/)
|
||||
async onDelete(ctx: Context) {
|
||||
const callbackData = (ctx.callbackQuery as any).data;
|
||||
const postId = _toNum(callbackData.split('_')[1]);
|
||||
// console.log(ctx.match);
|
||||
console.log('delete_post_id=', postId);
|
||||
|
||||
await this.publishPageService.rejectedPost(postId);
|
||||
await ctx.answerCbQuery('Đã hủy bài viết.');
|
||||
await ctx.editMessageText('🗑️ Bài viết đã được loại bỏ khỏi hàng đợi.');
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
@Command(['trendstat', 'trend_stat'])
|
||||
async onTrendStatCommand(ctx: Context) {
|
||||
await ctx.reply(`Đang query..`);
|
||||
|
||||
const {topTrends, byCategory} = await this.trendsService.getStats();
|
||||
|
||||
let message = ``;
|
||||
topTrends.forEach(trend => {
|
||||
message += `\n${trend.title} - ${trend.category} - ${trend.score}\n`;
|
||||
});
|
||||
await ctx.reply(message, {
|
||||
parse_mode: 'HTML'
|
||||
});
|
||||
|
||||
message = '';
|
||||
console.log({byCategory});
|
||||
byCategory.forEach(trend => {
|
||||
message += `\n${trend.category} - ${trend._count._all} - ${trend._avg.score} \n`;
|
||||
});
|
||||
await ctx.reply(message, {
|
||||
parse_mode: 'HTML'
|
||||
});
|
||||
ctx.reply('Done')
|
||||
}
|
||||
|
||||
@Action(/publish-reply-twitter1_(.+)/)
|
||||
async onActionSubmitReplyX1(ctx: Context) {
|
||||
const callbackData = (ctx.callbackQuery as any).data;
|
||||
// @ts-ignore
|
||||
const messageText = ctx.callbackQuery.message.text;
|
||||
|
||||
const callbackDataArr = callbackData.split('_');
|
||||
const tweetId = callbackDataArr[1];
|
||||
const xUsername = callbackDataArr[2];
|
||||
|
||||
await ctx.answerCbQuery('Đang đẩy reply lên Twitter...');
|
||||
|
||||
console.log(messageText);
|
||||
console.log(`publish-reply-twitter1_ ${tweetId} ${xUsername}`);
|
||||
const telegramChatId = ctx.chat?.id;
|
||||
try {
|
||||
const tweetUrl = await this.cacheService.getCacheTweetUrlById(tweetId);
|
||||
if (isEmpty(tweetUrl)) {
|
||||
throw new HttpException(`Không tìm thấy tweet url từ Id : ${tweetId}`, 500);
|
||||
}
|
||||
await this.sqsPostService.sendMessage(xUsername,
|
||||
{
|
||||
tweetId: tweetId,
|
||||
tweetUrl,
|
||||
// name: 'generate_comment_twitter_completed',
|
||||
type: 'X_POSTER_REPLY',
|
||||
needConfirm: 1,
|
||||
content: messageText,
|
||||
autoPublish: 1,
|
||||
telegramChatId,
|
||||
xUsername,
|
||||
xSubmitProvider: XStrategy.BROWSER_ONLY //cứ 3post api, có 1 post browser
|
||||
}
|
||||
)
|
||||
// const r = await this.publishPageService.relyX(messageText, tweetId)
|
||||
//@ts-ignore
|
||||
if (r.success) {
|
||||
await ctx.editMessageText(`✅ ** Reply success !**\n ID bài viết: ${tweetId}`);
|
||||
await ctx.reply(`✅ ** Reply success !`);
|
||||
} else {
|
||||
//@ts-ignore
|
||||
await ctx.reply(`❌ Lỗi reply: ${r.error} `);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
const detail = get(err, 'data.detail', '')
|
||||
await ctx.reply(`❌ Lỗi reply: ${err.message} `);
|
||||
await ctx.reply(`=> ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Action(/publish-reply-twitter_(.+)/)
|
||||
async onActionSubmitReplyX(ctx: Context) {
|
||||
const callbackData = (ctx.callbackQuery as any).data;
|
||||
// @ts-ignore
|
||||
const messageText = ctx.callbackQuery.message.text;
|
||||
|
||||
const callbackDataArr = callbackData.split('_');
|
||||
const tweetId = callbackDataArr[1];
|
||||
const xUsername = callbackDataArr[2];
|
||||
|
||||
await ctx.answerCbQuery('Đang đẩy reply lên Twitter...');
|
||||
console.log(`Đang đẩy reply lên Twitter 0...`);
|
||||
|
||||
console.log(messageText);
|
||||
const telegramChatId = ctx.chat?.id;
|
||||
try {
|
||||
const tweetUrl = await this.cacheService.getCacheTweetUrlById(tweetId);
|
||||
if (isEmpty(tweetUrl)) {
|
||||
console.error(`Không tìm thấy tweet url từ Id : ${tweetId}`);
|
||||
throw new HttpException(`Không tìm thấy tweet url từ Id : ${tweetId}`, 500);
|
||||
}
|
||||
await this.sqsPostService.sendMessage(xUsername,
|
||||
{
|
||||
tweetId: tweetId,
|
||||
tweetUrl,
|
||||
// name: 'generate_comment_twitter_completed',
|
||||
type: 'X_POSTER_REPLY',
|
||||
needConfirm: 1,
|
||||
content: messageText,
|
||||
autoPublish: 1,
|
||||
telegramChatId,
|
||||
xUsername,
|
||||
xSubmitProvider: XStrategy.BROWSER_ONLY //cứ 3post api, có 1 post browser
|
||||
}
|
||||
)
|
||||
// const r = await this.publishPageService.relyX(messageText, tweetId)
|
||||
//@ts-ignore
|
||||
await ctx.editMessageText(`✅ ** Đã gửi tin sang queue ${xUsername} ID bài viết: ${tweetId}`);
|
||||
// await ctx.reply(`✅ Đã gửi tin sang queue ${xUsername}!, ID bài viết: ${tweetId}`);
|
||||
// else {
|
||||
// //@ts-ignore
|
||||
// await ctx.reply(`❌ Lỗi reply: ${r.error} `);
|
||||
// }
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
const detail = get(err, 'data.detail', '')
|
||||
await ctx.reply(`❌ Lỗi reply: ${err.message} `);
|
||||
await ctx.reply(`=> ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Action(/publish-quote-twitter_(.+)/)
|
||||
async onActionSubmitQuoteX(ctx: Context) {
|
||||
const callbackData = (ctx.callbackQuery as any).data;
|
||||
// @ts-ignore
|
||||
const messageText = ctx.callbackQuery.message.text;
|
||||
|
||||
// const tweetId = callbackData.split('_')[1];
|
||||
const callbackDataArr = callbackData.split('_');
|
||||
const tweetId = callbackDataArr[1];
|
||||
const xUsername = callbackDataArr[2];
|
||||
|
||||
await ctx.answerCbQuery('Đang đẩy quote lên Twitter...');
|
||||
console.log(`Đang đẩy quote lên Twitter...`);
|
||||
|
||||
console.log(messageText);
|
||||
console.log(tweetId);
|
||||
console.log({xUsername, tweetId});
|
||||
|
||||
const telegramChatId = ctx.chat?.id;
|
||||
|
||||
try {
|
||||
const tweetUrl = await this.cacheService.getCacheTweetUrlById(tweetId);
|
||||
if (isEmpty(tweetUrl)) {
|
||||
console.error(`Không tìm thấy tweet url từ Id : ${tweetId}`);
|
||||
|
||||
throw new HttpException(`Không tìm thấy tweet url từ Id : ${tweetId}`, 500);
|
||||
}
|
||||
|
||||
await this.sqsPostService.sendMessage(xUsername,
|
||||
{
|
||||
tweetId: tweetId,
|
||||
tweetUrl,
|
||||
// name: 'generate_comment_twitter_completed',
|
||||
type: 'X_POSTER_QUOTE',
|
||||
needConfirm: 1,
|
||||
content: messageText,
|
||||
autoPublish: 1,
|
||||
telegramChatId,
|
||||
xUsername,
|
||||
xSubmitProvider: XStrategy.BROWSER_ONLY //cứ 3post api, có 1 post browser
|
||||
}
|
||||
)
|
||||
await ctx.reply(`✅ Gửi tin sang queue.`);
|
||||
//await this.publishPageService.quoteX(messageText, tweetId)
|
||||
//const r = await this.publishPageService.quoteX(messageText, tweetId)
|
||||
// @ts-ignore
|
||||
// if (r.success) {
|
||||
// await ctx.editMessageText(`✅ ** Quote success !**`);
|
||||
// await ctx.reply(`✅ ** Quote success !**`);
|
||||
// } else {
|
||||
// await ctx.reply(`❌ Lỗi quote`);
|
||||
// // @ts-ignore
|
||||
// await ctx.reply(r.error);
|
||||
// }
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
const detail = get(err, 'data.detail', '')
|
||||
await ctx.reply(`❌ Lỗi quote: ${err.message} `);
|
||||
await ctx.reply(`=> ${detail}`);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Action([
|
||||
/delete-reply_(.+)/,
|
||||
/delete-quote_(.+)/
|
||||
])
|
||||
async onDeleteReplyX(ctx: Context) {
|
||||
const callbackData = (ctx.callbackQuery as any).data;
|
||||
const postId = _toNum(callbackData.split('_')[1]);
|
||||
// console.log(ctx.match);
|
||||
console.log('delete_post_id=', postId);
|
||||
|
||||
// await this.publishPageService.rejectedPost(postId);
|
||||
await ctx.answerCbQuery('Đã hủy bài viết.');
|
||||
await ctx.editMessageText('🗑️ Bài viết đã được loại bỏ khỏi hàng đợi.');
|
||||
}
|
||||
|
||||
@On('photo')
|
||||
async handlePhoto(@Ctx() ctx: Context) {
|
||||
try {
|
||||
const photos = (ctx.message as any).photo;
|
||||
const largest = photos[photos.length - 1];
|
||||
const fileId = largest.file_id;
|
||||
|
||||
// Lấy link để tải ảnh về (nếu cần)
|
||||
const fileUrl = await ctx.telegram.getFileLink(fileId);
|
||||
|
||||
await ctx.reply(`Đã nhận được ảnh! Bạn có thể xem tại: ${fileUrl}`);
|
||||
|
||||
// // 1. get file path
|
||||
// const res = await axios.get(
|
||||
// `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/getFile`,
|
||||
// {
|
||||
// params: {file_id: fileId},
|
||||
// }
|
||||
// );
|
||||
//
|
||||
// const filePath = res.data.result.file_path;
|
||||
//
|
||||
// // 2. build download url
|
||||
// const fileUrl = `https://api.telegram.org/file/bot${process.env.TELEGRAM_BOT_TOKEN}/${filePath}`;
|
||||
//
|
||||
// // 3. download buffer
|
||||
// const img = await axios.get(fileUrl, {
|
||||
// responseType: 'arraybuffer',
|
||||
// });
|
||||
//
|
||||
// const buffer = Buffer.from(img.data);
|
||||
|
||||
// 4. upload to X
|
||||
const mediaId = await this.xUpload.uploadImageFromUrl(fileUrl.toString());
|
||||
|
||||
|
||||
await ctx.reply(`✅ Uploaded mediaId: ${mediaId}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
await ctx.reply('❌ Upload failed');
|
||||
}
|
||||
}
|
||||
|
||||
@Command('chatid')
|
||||
async onGetChatId(ctx: Context) {
|
||||
// @ts-ignore
|
||||
ctx.reply(ctx.chat.id)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user