feat: added generate posts feature

This commit is contained in:
Nevo David 2024-05-17 15:45:41 +07:00
parent ef20ca4f57
commit 75c5e10415
10 changed files with 670 additions and 220 deletions

View File

@ -23,6 +23,7 @@ import {
import { ApiTags } from '@nestjs/swagger';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto';
import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto';
@ApiTags('Posts')
@Controller('/posts')
@ -89,6 +90,15 @@ export class PostsController {
return this._postsService.createPost(org.id, body);
}
@Post('/generator/draft')
@CheckPolicies([AuthorizationActions.Create, Sections.POSTS_PER_MONTH])
generatePostsDraft(
@GetOrgFromRequest() org: Organization,
@Body() body: CreateGeneratedPostsDto
) {
return this._postsService.generatePostsDraft(org.id, body);
}
@Post('/generator')
@CheckPolicies([AuthorizationActions.Create, Sections.POSTS_PER_MONTH])
generatePosts(

View File

@ -283,4 +283,8 @@ html {
.editor * {
color: white;
}
:empty + .existing-empty {
display: none;
}

View File

@ -22,30 +22,57 @@ import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator
import { Button } from '@gitroom/react/form/button';
import { PostSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import clsx from 'clsx';
const FirstStep: FC<{ nextStep: () => void }> = (props) => {
const { nextStep } = props;
const ThirdStep: FC<{ week: number; year: number }> = (props) => {
const { week, year } = props;
const gotToPosts = useCallback(() => {
window.location.href = `/launches?week=${week}&year=${year}`;
}, [week, year]);
return (
<div>
<div className="text-[20px] mb-[20px] flex flex-col items-center justify-center text-center mt-[20px] gap-[20px]">
<img src="/success.svg" alt="success" />
Your posts have been scheduled as drafts.
<br />
<Button onClick={gotToPosts}>Click here to see them</Button>
</div>
</div>
);
};
const SecondStep: FC<{
posts: Array<Array<{ post: string }>>;
url: string;
postId?: string;
nextStep: (params: { week: number; year: number }) => void;
}> = (props) => {
const { posts, nextStep, url, postId } = props;
const fetch = useFetch();
const resolver = useMemo(() => {
return classValidatorResolver(GeneratorDto);
}, []);
const [selected, setSelected] = useState<Array<string>>([]);
const [loading, setLoading] = useState(false);
const form = useForm({
mode: 'all',
resolver,
values: {
date: dayjs().week() + '_' + dayjs().year(),
url: '',
post: undefined as undefined | string,
},
});
const [url, post] = form.watch(['url', 'post']);
const addPost = useCallback(
(index: string) => () => {
if (selected.includes(index)) {
setSelected(selected.filter((i) => i !== index));
return;
}
setSelected([...selected, index]);
},
[selected]
);
const list = useMemo(() => {
const currentDate = dayjs();
const generateWeeks = [...new Array(52)].map((_, i) => {
return [...new Array(52)].map((_, i) => {
const week = currentDate.add(i, 'week');
return {
value: week.week() + '_' + week.year(),
@ -56,30 +83,38 @@ const FirstStep: FC<{ nextStep: () => void }> = (props) => {
.format('YYYY-MM-DD')})`,
};
});
return generateWeeks;
}, []);
const makeSelect = useCallback((post?: string) => {
form.setValue('post', post?.split?.(':')[1]?.split(')')?.[0]);
}, []);
const onSubmit: SubmitHandler<{
date: string;
url: string;
post: string | undefined;
}> = useCallback(async (value) => {
fetch('/posts/generator', {
method: 'POST',
body: JSON.stringify(value),
});
// nextStep();
}, []);
const createPosts: SubmitHandler<{
date: any;
}> = useCallback(
async (values) => {
setLoading(true);
await fetch('/posts/generator/draft', {
method: 'POST',
body: JSON.stringify({
posts: posts
.filter((_, index) => selected.includes(String(index)))
.map((po) => ({ list: po })),
url,
postId: postId ? `(post:${postId})` : undefined,
year: values.date.year,
week: values.date.week,
}),
});
setLoading(false);
nextStep({
week: values.date.week,
year: values.date.year,
});
},
[selected, postId, url]
);
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<form onSubmit={form.handleSubmit(createPosts)}>
<FormProvider {...form}>
<div className="flex flex-col">
<div className={loading ? 'opacity-75' : ''}>
<Select
label="Select a week"
name="date"
@ -96,25 +131,150 @@ const FirstStep: FC<{ nextStep: () => void }> = (props) => {
</option>
))}
</Select>
<div className="text-[20px] mb-[20px]">
Click on the posts you would like to schedule.
<br />
They will be saved as drafts and you can edit them later.
</div>
<div className="grid grid-cols-3 gap-[25px] select-none cursor-pointer">
{posts.map((post, index) => (
<div
onClick={addPost(String(index))}
className={clsx(
'flex flex-col h-[200px] border rounded-[4px] group hover:border-white relative',
selected.includes(String(index))
? 'border-white'
: 'border-fifth'
)}
key={post[0].post}
>
{post.length > 1 && (
<div className="bg-[#612AD5] absolute -left-[15px] -top-[15px] z-[100] p-[3px] rounded-[10px]">
a thread
</div>
)}
<div
className={clsx(
'flex-1 relative h-full w-full group-hover:bg-black',
selected.includes(String(index)) && 'bg-black'
)}
>
<div className="absolute left-0 top-0 w-full h-full p-[16px]">
<div className="w-full h-full overflow-hidden text-ellipsis group-hover:bg-black resize-none outline-none">
{post[0].post.split('\n\n')[0]}
</div>
</div>
</div>
</div>
))}
</div>
<div className="mt-[20px] flex justify-end">
<Button type="submit" disabled={!selected.length} loading={loading}>
Create posts
</Button>
</div>
</div>
</FormProvider>
</form>
);
};
const FirstStep: FC<{
nextStep: (
posts: Array<Array<{ post: string }>>,
url: string,
postId?: string
) => void;
}> = (props) => {
const { nextStep } = props;
const fetch = useFetch();
const [loading, setLoading] = useState(false);
const resolver = useMemo(() => {
return classValidatorResolver(GeneratorDto);
}, []);
const form = useForm({
mode: 'all',
resolver,
values: {
url: '',
post: undefined as undefined | string,
},
});
const [url, post] = form.watch(['url', 'post']);
const makeSelect = useCallback(
(post?: string) => {
form.setValue('post', post?.split?.(':')[1]?.split(')')?.[0]);
if (!post && !url) {
form.setError('url', {
message: 'You need to select a post or a URL',
});
return;
}
if (post && url) {
form.setError('url', {
message: 'You can only have a URL or a post',
});
return;
}
form.setError('url', {
message: '',
});
},
[post, url]
);
const onSubmit: SubmitHandler<{
url: string;
post: string | undefined;
}> = useCallback(async (value) => {
setLoading(true);
const data = await (
await fetch('/posts/generator', {
method: 'POST',
body: JSON.stringify(value),
})
).json();
nextStep(data.list, value.url, value.post);
setLoading(false);
}, []);
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className={loading ? 'pointer-events-none select-none opacity-75' : ''}
>
<FormProvider {...form}>
<div className="flex flex-col">
<div className="p-[20px] border border-fifth rounded-[4px]">
<div className="flex">
<div className="flex-1">
<Input label="URL" {...form.register('url')} />
</div>
</div>
<div className="pb-[10px]">Or select from exising posts</div>
<div className="p-[16px] bg-input border-fifth border rounded-[4px] min-h-[500px]">
<PostSelector
noModal={true}
onClose={() => {}}
onSelect={makeSelect}
date={dayjs().add(1, 'year')}
/>
<div className="flex flex-col-reverse">
<div className="p-[16px] bg-input border-fifth border rounded-[4px] min-h-[500px] empty:hidden">
<PostSelector
noModal={true}
onClose={() => null}
onSelect={makeSelect}
date={dayjs().add(1, 'year')}
only="article"
/>
</div>
<div className="pb-[10px] existing-empty">
Or select from exising posts
</div>
</div>
</div>
</div>
<div className="mt-[20px] flex justify-end">
<Button type="submit" disabled={!!(url && post)}>
<Button type="submit" disabled={!!(url && post)} loading={loading}>
{url && post ? "You can't have both URL and a POST" : 'Next'}
</Button>
</div>
@ -124,9 +284,47 @@ const FirstStep: FC<{ nextStep: () => void }> = (props) => {
};
export const GeneratorPopup = () => {
const [step, setStep] = useState(1);
const modals = useModals();
const [posts, setPosts] = useState<
| {
posts: Array<Array<{ post: string }>>;
url: string;
postId?: string;
}
| undefined
>(undefined);
const [yearAndWeek, setYearAndWeek] = useState<{
year: number;
week: number;
} | null>(null);
const closeAll = useCallback(() => {
modals.closeAll();
}, []);
return (
<div className="bg-sixth p-[32px] w-full max-w-[920px] mx-auto flex flex-col gap-[24px] rounded-[4px] border border-[#172034] relative">
<button
onClick={closeAll}
className="outline-none absolute right-[20px] top-[15px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
<h1 className="text-[24px]">Generate Posts</h1>
<div className="flex">
<Step title="Generate posts" step={1} currentStep={step} lastStep={3} />
@ -135,7 +333,30 @@ export const GeneratorPopup = () => {
<StepSpace />
<Step title="Done" step={3} currentStep={step} lastStep={3} />
</div>
{step === 1 && <FirstStep nextStep={() => setStep(2)} />}
{step === 1 && (
<FirstStep
nextStep={(posts, url: string, postId?: string) => {
setPosts({
posts,
url,
postId,
});
setStep(2);
}}
/>
)}
{step === 2 && (
<SecondStep
{...posts!}
nextStep={(e) => {
setYearAndWeek(e);
setStep(3);
}}
/>
)}
{step === 3 && (
<ThirdStep week={yearAndWeek?.week!} year={yearAndWeek?.year!} />
)}
</div>
);
};
@ -161,7 +382,6 @@ export const GeneratorComponent = () => {
modal.openModal({
title: '',
withCloseButton: false,
closeOnEscape: false,
classNames: {
modal: 'bg-transparent text-white',
},
@ -172,7 +392,7 @@ export const GeneratorComponent = () => {
return (
<button
className="text-white p-[8px] rounded-md bg-red-700 flex justify-center items-center gap-[5px]"
className="text-white p-[8px] rounded-md bg-red-700 flex justify-center items-center gap-[5px] outline-none"
onClick={generate}
>
<svg

View File

@ -1,7 +1,7 @@
'use client';
import { EventEmitter } from 'events';
import React, { FC, useCallback, useEffect, useState } from 'react';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import {
executeCommand,
@ -85,10 +85,11 @@ export const useShowPostSelector = (day: dayjs.Dayjs) => {
export const PostSelector: FC<{
onClose: () => void;
onSelect: (tag: string | undefined) => void;
only?: 'article' | 'social';
noModal?: boolean;
date: dayjs.Dayjs;
}> = (props) => {
const { onClose, onSelect, date, noModal } = props;
const { onClose, onSelect, only, date, noModal } = props;
const fetch = useFetch();
const fetchOldPosts = useCallback(() => {
return fetch(
@ -118,92 +119,105 @@ export const PostSelector: FC<{
[current]
);
const { isLoading, data } = useSWR('old-posts', fetchOldPosts);
const { data: loadData } = useSWR('old-posts', fetchOldPosts);
const data = useMemo(() => {
if (!only) {
return loadData;
}
return loadData?.filter((p: any) => p.integration.type === only);
}, [loadData, only]);
return (
<div
className={
!noModal
? 'text-white fixed left-0 top-0 bg-black/80 z-[300] w-full h-full p-[60px] animate-fade'
: ''
}
>
<div
className={
!noModal
? 'flex flex-col w-full max-w-[1200px] mx-auto h-full bg-[#0B101B] border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative'
: ''
}
>
{!noModal && (
<div className="flex">
<div className="flex-1">
<TopTitle
title={
'Select Post Before ' + date.format('DD/MM/YYYY HH:mm:ss')
}
/>
</div>
<button
onClick={onCloseWithEmptyString}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-black hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
<>
{!noModal ||
(data?.length > 0 && (
<div
className={
!noModal
? 'text-white fixed left-0 top-0 bg-black/80 z-[300] w-full h-full p-[60px] animate-fade'
: ''
}
>
<div
className={
!noModal
? 'flex flex-col w-full max-w-[1200px] mx-auto h-full bg-[#0B101B] border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative'
: ''
}
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
)}
<div className="mt-[10px]">
{!!data && data.length > 0 && (
<div className="flex flex-row flex-wrap gap-[10px]">
{data.map((p: any) => (
<div
onClick={select(p.id)}
className={clsx(
'cursor-pointer overflow-hidden flex gap-[20px] flex-col w-[200px] h-[200p] p-3 border border-tableBorder rounded-[8px] hover:bg-primary',
current === p.id ? 'bg-primary' : 'bg-secondary'
)}
key={p.id}
>
<div className="flex gap-[10px] items-center">
<div className="relative">
<img
src={p.integration.picture}
className="w-[32px] h-[32px] rounded-full"
/>
<img
className="w-[20px] h-[20px] rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
src={
`/icons/platforms/` +
p?.integration?.providerIdentifier +
'.png'
}
/>
</div>
<div>{p.integration.name}</div>
{!noModal && (
<div className="flex">
<div className="flex-1">
<TopTitle
title={
'Select Post Before ' +
date.format('DD/MM/YYYY HH:mm:ss')
}
/>
</div>
<div className="flex-1">{removeMd(p.content)}</div>
<div>Status: {p.state}</div>
<button
onClick={onCloseWithEmptyString}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-black hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
))}
)}
{!!data && data.length > 0 && (
<div className="mt-[10px]">
<div className="flex flex-row flex-wrap gap-[10px]">
{data.map((p: any) => (
<div
onClick={select(p.id)}
className={clsx(
'cursor-pointer overflow-hidden flex gap-[20px] flex-col w-[200px] h-[200px] text-ellipsis p-3 border border-tableBorder rounded-[8px] hover:bg-primary',
current === p.id ? 'bg-primary' : 'bg-secondary'
)}
key={p.id}
>
<div className="flex gap-[10px] items-center">
<div className="relative">
<img
src={p.integration.picture}
className="w-[32px] h-[32px] rounded-full"
/>
<img
className="w-[20px] h-[20px] rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
src={
`/icons/platforms/` +
p?.integration?.providerIdentifier +
'.png'
}
/>
</div>
<div>{p.integration.name}</div>
</div>
<div className="flex-1">{removeMd(p.content)}</div>
<div>Status: {p.state}</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
))}
</>
);
};

View File

@ -38,6 +38,7 @@ export class PostsRepository {
name: true,
providerIdentifier: true,
picture: true,
type: true,
},
},
},
@ -62,9 +63,10 @@ export class PostsRepository {
getPosts(orgId: string, query: GetPostsDto) {
const date = dayjs().year(query.year).isoWeek(query.week);
const startDate = date.startOf('isoWeek').toDate();
const endDate = date.endOf('isoWeek').toDate();
const startDate = date.startOf('week').toDate();
const endDate = date.endOf('week').toDate();
console.log(startDate, endDate);
return this._post.model.post.findMany({
where: {
OR: [

View File

@ -7,12 +7,15 @@ import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integ
import { Integration, Post, Media, From } from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { capitalize } from 'lodash';
import { capitalize, chunk, shuffle } from 'lodash';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto';
import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
type PostWithConditionals = Post & {
integration?: Integration;
@ -29,7 +32,8 @@ export class PostsService {
private _messagesService: MessagesService,
private _stripeService: StripeService,
private _extractContentService: ExtractContentService,
private _openAiService: OpenaiService
private _openAiService: OpenaiService,
private _integrationService: IntegrationService
) {}
async getPostsRecursively(
@ -262,7 +266,11 @@ export class PostsService {
throw new Error('You can not add a post to this publication');
}
const getOrgByOrder = await this._messagesService.getOrgByOrder(order);
const submit = await this._postRepository.submit(id, order, getOrgByOrder?.messageGroup?.buyerOrganizationId!);
const submit = await this._postRepository.submit(
id,
order,
getOrgByOrder?.messageGroup?.buyerOrganizationId!
);
const messageModel = await this._messagesService.createNewMessage(
submit?.submittedForOrder?.messageGroupId || '',
From.SELLER,
@ -438,13 +446,100 @@ export class PostsService {
}
}
async generatePosts(orgId: string, body: GeneratorDto) {
const content = await this._extractContentService.extractContent(body.url);
if (content) {
const value = await this._openAiService.extractWebsiteText(content);
return {list: value};
async loadPostContent(postId: string) {
const post = await this._postRepository.getPostById(postId);
if (!post) {
return '';
}
return [];
return post.content;
}
async generatePosts(orgId: string, body: GeneratorDto) {
const content = body.url
? await this._extractContentService.extractContent(body.url)
: await this.loadPostContent(body.post);
const value = body.url
? await this._openAiService.extractWebsiteText(content!)
: await this._openAiService.generatePosts(content!);
return { list: value };
}
async generatePostsDraft(orgId: string, body: CreateGeneratedPostsDto) {
const getAllIntegrations = (
await this._integrationService.getIntegrationsList(orgId)
).filter((f) => !f.disabled && f.providerIdentifier !== 'reddit');
// const posts = chunk(body.posts, getAllIntegrations.length);
const allDates = dayjs()
.isoWeek(body.week)
.year(body.year)
.startOf('isoWeek');
const dates = [...new Array(7)].map((_, i) => {
return allDates.add(i, 'day').format('YYYY-MM-DD');
});
const findTime = (): string => {
const totalMinutes = Math.floor(Math.random() * 144) * 10;
// Convert total minutes to hours and minutes
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
// Format hours and minutes to always be two digits
const formattedHours = hours.toString().padStart(2, '0');
const formattedMinutes = minutes.toString().padStart(2, '0');
const randomDate =
shuffle(dates)[0] + 'T' + `${formattedHours}:${formattedMinutes}:00`;
if (dayjs(randomDate).isBefore(dayjs())) {
return findTime();
}
return randomDate;
};
for (const integration of getAllIntegrations) {
for (const toPost of body.posts) {
const group = makeId(10);
const randomDate = findTime();
await this.createPost(orgId, {
type: 'draft',
date: randomDate,
order: '',
posts: [
{
group,
integration: {
id: integration.id,
},
settings: {
subtitle: '',
title: '',
tags: [],
subreddit: [],
},
value: [
...toPost.list.map((l) => ({
id: '',
content: l.post,
image: [],
})),
{
id: '',
content: `Check out the full story here:\n${
body.postId || body.url
}`,
image: [],
},
],
},
],
});
}
}
}
}

View File

@ -0,0 +1,52 @@
import {
ArrayMinSize,
IsArray,
IsDefined,
IsNumber,
IsString,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
class InnerPost {
@IsString()
@IsDefined()
post: string;
}
class PostGroup {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => InnerPost)
@IsDefined()
list: InnerPost[];
}
export class CreateGeneratedPostsDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => PostGroup)
@IsDefined()
posts: PostGroup[];
@IsNumber()
@IsDefined()
week: number;
@IsNumber()
@IsDefined()
year: number;
@IsString()
@IsDefined()
@ValidateIf((o) => !o.url)
url: string;
@IsString()
@IsDefined()
@ValidateIf((o) => !o.url)
postId: string;
}

View File

@ -7,18 +7,7 @@ import {
ValidateNested,
} from 'class-validator';
class Date {
@IsInt()
week: number;
@IsInt()
year: number;
}
export class GeneratorDto {
@IsDefined()
@ValidateNested()
date: Date;
@IsString()
@ValidateIf((o) => !o.post)
@IsUrl(

View File

@ -1,36 +1,90 @@
import { Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
function findDepth(element: Element) {
let depth = 0;
let elementer = element;
while (elementer.parentNode) {
depth++;
// @ts-ignore
elementer = elementer.parentNode;
}
return depth;
}
@Injectable()
export class ExtractContentService {
async extractContent(url: string) {
const load = await (await fetch(url)).text();
const dom = new JSDOM(load);
const allElements = Array.from(
dom.window.document.querySelectorAll('*')
).filter((f) => f.tagName !== 'SCRIPT');
const findIndex = allElements.findIndex((element) => {
return (
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].indexOf(
element.tagName.toLowerCase()
) > -1
);
});
if (!findIndex) {
return false;
}
return allElements
.slice(findIndex)
.map((element) => element.textContent)
// only element that has a title
const allTitles = Array.from(dom.window.document.querySelectorAll('*'))
.filter((f) => {
const trim = f?.trim();
return (trim?.length || 0) > 0 && trim !== '\n';
return (
f.querySelector('h1') ||
f.querySelector('h2') ||
f.querySelector('h3') ||
f.querySelector('h4') ||
f.querySelector('h5') ||
f.querySelector('h6')
);
})
.map((f) => f?.trim())
.join('')
.replace(/\n/g, ' ')
.replace(/ {2,}/g, ' ');
.reverse();
const findTheOneWithMostTitles = allTitles.reduce(
(all, current) => {
const depth = findDepth(current);
const calculate = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].reduce(
(total, tag) => {
if (current.querySelector(tag)) {
return total + 1;
}
return total;
},
0
);
if (calculate > all.total) {
return { total: calculate, depth, element: current };
}
if (depth > all.depth) {
return { total: calculate, depth, element: current };
}
return all;
},
{ total: 0, depth: 0, element: null as Element | null }
);
return findTheOneWithMostTitles?.element?.textContent?.replace(/\n/g, ' ').replace(/ {2,}/g, ' ');
//
// const allElements = Array.from(
// dom.window.document.querySelectorAll('*')
// ).filter((f) => f.tagName !== 'SCRIPT');
// const findIndex = allElements.findIndex((element) => {
// return (
// ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].indexOf(
// element.tagName.toLowerCase()
// ) > -1
// );
// });
//
// if (!findIndex) {
// return false;
// }
//
// return allElements
// .slice(findIndex)
// .map((element) => element.textContent)
// .filter((f) => {
// const trim = f?.trim();
// return (trim?.length || 0) > 0 && trim !== '\n';
// })
// .map((f) => f?.trim())
// .join('')
// .replace(/\n/g, ' ')
// .replace(/ {2,}/g, ' ');
}
}

View File

@ -8,6 +8,65 @@ const openai = new OpenAI({
@Injectable()
export class OpenaiService {
async generatePosts(content: string) {
const posts = (
await Promise.all([
openai.chat.completions.create({
messages: [
{
role: 'assistant',
content:
'Generate a Twitter post from the content without emojis in the following JSON format: { "post": string } put it in an array with one element',
},
{
role: 'user',
content: content!,
},
],
n: 5,
temperature: 1,
model: 'gpt-4o',
}),
openai.chat.completions.create({
messages: [
{
role: 'assistant',
content:
'Generate a thread for social media in the following JSON format: Array<{ "post": string }> without emojis',
},
{
role: 'user',
content: content!,
},
],
n: 5,
temperature: 1,
model: 'gpt-4o',
}),
])
).flatMap((p) => p.choices);
return shuffle(
posts.map((choice) => {
const { content } = choice.message;
const start = content?.indexOf('[')!;
const end = content?.lastIndexOf(']')!;
try {
return JSON.parse(
'[' +
content
?.slice(start + 1, end)
.replace(/\n/g, ' ')
.replace(/ {2,}/g, ' ') +
']'
);
} catch (e) {
console.log(content);
return [];
}
})
);
}
async extractWebsiteText(content: string) {
const websiteContent = await openai.chat.completions.create({
messages: [
@ -26,55 +85,6 @@ export class OpenaiService {
const { content: articleContent } = websiteContent.choices[0].message;
const posts = (
await Promise.all([
openai.chat.completions.create({
messages: [
{
role: 'assistant',
content:
'Generate a Twitter post from the content without emojis in the following JSON format: { "post": string } put it in an array with one element',
},
{
role: 'user',
content: articleContent!,
},
],
n: 5,
temperature: 0.7,
model: 'gpt-4o',
}),
openai.chat.completions.create({
messages: [
{
role: 'assistant',
content:
'Generate a thread for social media in the following JSON format: Array<{ "post": string }> without emojis',
},
{
role: 'user',
content: articleContent!,
},
],
n: 5,
temperature: 0.7,
model: 'gpt-4o',
}),
])
).flatMap((p) => p.choices);
return shuffle(
posts.map((choice) => {
const { content } = choice.message;
const start = content?.indexOf('[')!;
const end = content?.lastIndexOf(']')!;
try {
return JSON.parse('[' + content?.slice(start + 1, end) + ']');
} catch (e) {
console.log(content);
return [];
}
})
);
return this.generatePosts(articleContent!);
}
}