7 Commits
Author SHA1 Message Date
Nathan Rashleigh 4097b05cf9 combination WIP, add client 2024-05-04 17:20:35 +10:00
ntr 3c5e7d9aee alternate between new and old 2024-05-03 00:24:01 +10:00
ntr ecd8f78fe4 add content-type to uploads 2024-04-23 00:28:25 +10:00
ntr 74c2a4597c deps 2024-04-14 15:30:12 +10:00
ntr d3cc44a959 posted at for sorting 2024-04-14 13:35:25 +10:00
ntr 544b7085ed log meme 2024-04-13 23:41:25 +10:00
ntr ed0a42eeb3 vision fix 2024-04-13 21:19:37 +10:00
47 changed files with 22375 additions and 5189 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
data/
node_modules/
.env
vision-credentials.json
+1 -2
View File
@@ -1,8 +1,7 @@
.env .env
.venv/ .venv/
gimmeme-d052de2b9472.json vision-credentials.json
gimmeme-db7225b3cf31.json
__pycache__/ __pycache__/
node_modules node_modules
+21
View File
@@ -0,0 +1,21 @@
FROM node:20 as builder
WORKDIR /app
COPY . ./
RUN npm i
RUN npm run build
# execution
FROM node:20
WORKDIR /app
COPY --from=builder /app/build /app
# packages are not bundled in the build because sharp uses native modules
COPY package.json /app/package.json
RUN npm i --include prod
# prisma setup
COPY ./prisma /app/prisma
RUN npx prisma generate
CMD node index.js
View File
File renamed without changes.
+336
View File
@@ -0,0 +1,336 @@
// index.ts
import "dotenv/config";
import pino from "pino";
import * as Minio from "minio";
import Vision from "@google-cloud/vision";
import { PrismaClient } from "@prisma/client";
import { MeiliSearch } from "meilisearch";
// src/scraper.ts
import { setTimeout } from "timers/promises";
import axios2 from "axios";
import _2 from "lodash";
// src/attachments.ts
import sharp from "sharp";
import axios from "axios";
import mime from "mime-types";
import _ from "lodash";
var STORAGE_BUCKET = process.env.STORAGE_BUCKET;
var IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"image/gif"
];
function isImage(a) {
return IMAGE_TYPES.includes(a.contentType);
}
function objectName(a) {
if (isImage(a))
return `${a.memeId}.webp`;
return `${a.memeId}.${mime.extension(a.contentType)}`;
}
async function attachmentService(log, minio, prisma, memes) {
async function ensureStorageBucketExists() {
const exists = await minio.bucketExists(STORAGE_BUCKET);
if (exists)
return true;
await minio.makeBucket(STORAGE_BUCKET);
}
async function storeAttachment(a) {
const download = await axios.get(a.url, { responseType: "stream" });
const stream = () => {
if (isImage(a)) {
const converter = sharp().webp();
download.data.pipe(converter);
return converter;
}
return download.data;
};
return minio.putObject(
STORAGE_BUCKET,
objectName(a),
stream(),
null,
// hardcode this for now, we upload everything as webp
{ "Content-Type": "image/webp" }
);
}
async function deleteAttachment(a) {
return minio.removeObject(STORAGE_BUCKET, objectName(a));
}
async function process2(a, history) {
try {
const results = await prisma.$transaction(async (prisma2) => {
const meme = memes.fromAttachment(a);
const dbMeme = await prisma2.meme.upsert({ create: meme, update: meme, where: { id: meme.id } });
const dbA = await prisma2.attachment.upsert({ create: a, update: a, where: { id: a.id } });
log.debug({ msg: "pending addition to db", dbMeme });
const upload = await storeAttachment(a);
log.debug({ msg: "successfully uploaded", upload });
await memes.persist(dbMeme);
log.info(dbMeme, "new meme");
return { meme: dbMeme, upload, attachment: dbA };
}, { timeout: 6e4 });
} catch (e) {
if (e.meta?.modelName !== "Meme" || e.meta?.target?.[0] !== "attachmentId") {
log.error(e, "failed to process attachment");
log.error(_.omit(a, ["vision"]), "failed to process attachment");
}
await deleteAttachment(a);
}
const newHistory = await prisma.channelHistory.update({ data: history, where: { id: history.id } });
log.debug({ newHistory }, "updated channel history");
return true;
}
await ensureStorageBucketExists();
return {
process: process2
};
}
// src/scraper.ts
function convertSnowflakeToDate(snowflake) {
const DISCORD_EPOCH = 14200704e5;
const milliseconds = global.BigInt(snowflake) >> 22n;
return new Date(Number(milliseconds) + DISCORD_EPOCH);
}
async function scraper(discordAuth, vision, minio, prisma, baseLog, attachments) {
const headers = {
"Authorization": discordAuth
};
async function fetchChannelMessages(channelId) {
const url = `https://discord.com/api/v9/channels/${channelId}/messages&limit=100`;
const res = await axios2.get(url, { headers });
return res.data;
}
async function fetchChannelMessagesAfter(channelId, lastMessageId) {
const url = `https://discord.com/api/v9/channels/${channelId}/messages?after=${lastMessageId}&limit=100`;
const res = await axios2.get(url, { headers });
return res.data;
}
async function fetchChannelMessagesBefore(channelId, earliestMessageId) {
const url = `https://discord.com/api/v9/channels/${channelId}/messages?before=${earliestMessageId}&limit=100`;
const res = await axios2.get(url, { headers });
return res.data;
}
async function fetchGuildDetails(guildId) {
const url = `https://discord.com/api/v9/guilds/${guildId}`;
const res = await axios2.get(url, { headers });
return _2.omit(res.data, ["emojis", "stickers", "roles", "features"]);
}
async function fetchChannel(channelId) {
const url = `https://discord.com/api/v9/channels/${channelId}`;
const res = await axios2.get(url, { headers });
return res.data;
}
async function detectText(attachment) {
if (!IMAGE_TYPES.includes(attachment.contentType))
return {};
const annotateRequest = {
image: {
source: {
imageUri: attachment.url
}
},
features: [{ type: "TEXT_DETECTION" }]
};
const res = await vision.annotateImage(annotateRequest);
return res[0] || {};
}
async function fetchMissingVision() {
const attachments2 = await prisma.attachment.findMany({ where: { vision: {}, contentType: { in: IMAGE_TYPES } } });
baseLog.info({ attachments: attachments2.length }, "missing vision attachments");
}
async function processChannel(channelId) {
const channel = await fetchChannel(channelId);
const guild = await fetchGuildDetails(channel.guild_id);
const log = baseLog.child({ guild: guild.name, channel: channel.name, channelId });
async function filterAttachments(messages) {
const acceptedContentTypes = [
...IMAGE_TYPES
// ...VIDEO_TYPES,
];
const validAttachments = messages.filter((m) => m.attachments).flatMap((m) => m.attachments.map((a) => [a, m])).filter(([a, m]) => acceptedContentTypes.includes(a.content_type));
log.debug({ validAttachments: validAttachments.length }, "attachments found in messages");
return Promise.all(validAttachments.map(async ([a, m]) => {
const attachment = {
id: BigInt(a.id),
msgId: BigInt(m.id),
channelId: BigInt(m.channel_id),
url: a.url,
contentType: a.content_type,
// create this ahead of time to associate with our copy of the media
memeId: crypto.randomUUID(),
vision: {},
// quick hack so that detectText can receive a full attachment
guild,
channel,
message: m,
postedAt: convertSnowflakeToDate(a.id),
// ts asks for these to be included but they don't need to be set
createdAt: void 0,
updatedAt: void 0
};
return { ...attachment, vision: await detectText(attachment) };
}));
}
async function* fetchNew(channelId2, log2, latestMessageId) {
const messages = (await fetchChannelMessagesAfter(channelId2, latestMessageId)).reverse();
if (messages.length === 0) {
log2.info("reached latest message");
}
log2.debug({ messages: messages.length, latestMessageId }, "fetched new messages");
yield* await filterAttachments(messages);
}
async function* fetchOld(channelId2, log2, earliestMessageId) {
const messages = await fetchChannelMessagesBefore(channelId2, earliestMessageId);
if (messages.length === 0) {
log2.info("reached beginning of history");
}
log2.debug({ messages: messages.length, earliestMessageId }, "fetched older messages");
yield* await filterAttachments(messages);
}
const ensureHistory = async () => {
const stored = await prisma.channelHistory.findFirst({ where: { id: channelId } });
if (stored)
return stored;
const lastMessageId = channel.last_message_id;
return await prisma.channelHistory.create({
data: { latestMessageId: lastMessageId, earliestMessageId: lastMessageId, id: channelId }
});
};
const history = await ensureHistory();
async function processLoop() {
const history2 = await ensureHistory();
const newAttachments = fetchNew(channelId, log, history2.latestMessageId);
const oldAttachments = fetchOld(channelId, log, history2.earliestMessageId);
let oldCount = 0;
let newCount = 0;
for await (const attachment of newAttachments) {
log.debug({ msg: "scraped new attachment", attachment: _2.omit(attachment, ["guild", "message", "channel", "vision"]) });
const newHistory = { id: history2.id, latestMessageId: attachment.msgId };
await attachments.process(attachment, newHistory);
oldCount++;
}
for await (const attachment of oldAttachments) {
log.debug({ msg: "scraped old attachment", attachment: _2.omit(attachment, ["guild", "message", "channel", "vision"]) });
const newHistory = { id: history2.id, earliestMessageId: attachment.msgId };
await attachments.process(attachment, newHistory);
newCount++;
}
log.info({ oldCount, newCount }, "processed attachments");
const shouldWait = newCount === 0 && oldCount === 0;
const PROCESS_DELAY_MS = 1e3 * 60;
if (shouldWait) {
log.info({ PROCESS_DELAY_MS }, "waiting for new messages");
await setTimeout(PROCESS_DELAY_MS);
}
return processLoop();
}
await processLoop();
}
return {
processChannel
};
}
// src/memes.ts
import _3 from "lodash";
var STORAGE_BUCKET2 = process.env.STORAGE_BUCKET;
function memeUrl(a) {
const STORAGE_URL = process.env.STORAGE_URL;
const u = new URL(`${STORAGE_BUCKET2}/${objectName(a)}`, `https://${STORAGE_URL}`);
return u.href;
}
async function memeService(baseLog, prisma, meili) {
const log = baseLog.child({ service: "meme" });
function fromAttachment(a) {
const text = a.vision.fullTextAnnotation?.text || "";
const tags = [
a.guild.name,
a.channel.name,
a.message.author.username
];
const m = {
id: a.memeId,
url: memeUrl(a),
text,
tags,
attachmentId: a.id,
postedAt: a.postedAt,
createdAt: void 0,
updatedAt: void 0
};
return m;
}
async function ensureMeiliIndex() {
const task = await meili.createIndex("memes", { primaryKey: "id" });
const result = await meili.waitForTask(task.taskUid);
log.debug(result);
const index = await meili.index("memes");
await index.updateSortableAttributes(["postedAt"]);
return index;
}
async function resetMeili() {
const delTask = await meili.deleteIndex("memes");
const delResult = await meili.waitForTask(delTask.taskUid);
const index = await ensureMeiliIndex();
const memes = (await prisma.meme.findMany()).map((m) => ({ ...m, postedAt: m.postedAt.valueOf() }));
for await (const batch of _3.chunk(memes, 1e3)) {
const task = await index.updateDocuments(batch);
const res = await meili.waitForTask(task.taskUid);
log.info({ res }, "added meme batch");
}
}
async function persist(m) {
const index = meili.index("memes");
const doc = { ...m, postedAt: m.postedAt.valueOf() };
return index.updateDocuments([doc]);
}
await ensureMeiliIndex();
return {
ensureMeiliIndex,
resetMeili,
fromAttachment,
persist
};
}
// index.ts
BigInt.prototype.toJSON = function bigIntStringify() {
return this.toString();
};
async function main() {
const VISION_CREDENTIALS = (await import(process.env.GOOGLE_APPLICATION_CREDENTIALS, { with: { type: "json" } })).default;
const log = pino({ base: {} });
const vision = new Vision.ImageAnnotatorClient({ credentials: VISION_CREDENTIALS });
const prisma = new PrismaClient();
const minio = new Minio.Client({
endPoint: process.env.STORAGE_URL,
accessKey: process.env.STORAGE_ACCESS_KEY,
secretKey: process.env.STORAGE_SECRET_KEY
});
const meili = new MeiliSearch({
host: process.env.MEILI_URL,
apiKey: process.env.MEILI_MASTER_KEY
});
const memes = await memeService(log, prisma, meili);
const attachments = await attachmentService(log, minio, prisma, memes);
const scraper2 = await scraper(process.env.DISCORD_AUTH, vision, minio, prisma, log, attachments);
const CHANNEL_IDS = [
367936426962190336n,
// TPM shitposts
367936467592413194n,
// tpm screenshots
1028511952277864501n,
// tpm sludge
725835129326665768n,
// machinegirl memes
1095060717117374687n
// aella memes
];
await Promise.all(CHANNEL_IDS.map((id) => scraper2.processChannel(id)));
}
main().then(() => true).catch((e) => console.error(e));
+68
View File
@@ -0,0 +1,68 @@
import "dotenv/config"
import fs from 'fs/promises'
import { exec as execCb } from 'child_process'
import { promisify } from 'util'
import pino from 'pino'
import * as Minio from 'minio'
import Vision from '@google-cloud/vision'
import { PrismaClient } from '@prisma/client'
import { MeiliSearch } from 'meilisearch'
import discordScraper from './src/scraper'
import attachmentService from "./src/attachments"
import memeService from "./src/memes"
// @ts-ignore - ts don't know wtf it talking about
BigInt.prototype.toJSON = function bigIntStringify() {
return this.toString();
};
const exec = promisify(execCb)
async function main() {
const VISION_CREDENTIALS = (await import(process.env.GOOGLE_APPLICATION_CREDENTIALS, { with: { type: "json" } })).default
const log = pino({ base: {} });
const vision = new Vision.ImageAnnotatorClient({ credentials: VISION_CREDENTIALS })
const prisma = new PrismaClient()
const minio = new Minio.Client({
endPoint: process.env.STORAGE_URL,
accessKey: process.env.STORAGE_ACCESS_KEY,
secretKey: process.env.STORAGE_SECRET_KEY,
})
const meili = new MeiliSearch({
host: process.env.MEILI_URL,
apiKey: process.env.MEILI_MASTER_KEY,
})
const memes = await memeService(log, prisma, meili)
const attachments = await attachmentService(log, minio, prisma, memes)
const scraper = await discordScraper(process.env.DISCORD_AUTH, vision, minio, prisma, log, attachments)
const CHANNEL_IDS = [
367936426962190336n, // TPM shitposts
367936467592413194n, // tpm screenshots
1028511952277864501n, // tpm sludge
725835129326665768n, // machinegirl memes
1095060717117374687n, // aella memes
]
async function migrateDb() {
const output = await exec('prisma migrate deploy')
log.info(output)
}
await migrateDb()
await Promise.all(CHANNEL_IDS.map(id => scraper.processChannel(id)))
}
main()
.then(() => true)
.catch(e => console.error(e))
+2360
View File
File diff suppressed because it is too large. Load diff
+5 -3
View File
@@ -6,7 +6,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "esrun index.ts | pino-pretty", "start": "esrun index.ts | pino-pretty",
"start:bundle": "node index.js",
"dev": "esrun --watch index.ts | pino-pretty", "dev": "esrun --watch index.ts | pino-pretty",
"build": "esbuild index.ts --bundle --format=esm --outdir=build --platform=node --target=node22.1 --packages=external",
"migrate:make": "prisma migrate dev --name", "migrate:make": "prisma migrate dev --name",
"test": "esrun src/scraper.test.ts | pino-pretty" "test": "esrun src/scraper.test.ts | pino-pretty"
}, },
@@ -15,7 +17,6 @@
"dependencies": { "dependencies": {
"@google-cloud/vision": "^4.2.0", "@google-cloud/vision": "^4.2.0",
"@prisma/client": "^5.12.1", "@prisma/client": "^5.12.1",
"@theinternetfolks/snowflake": "^1.3.0",
"axios": "^1.6.8", "axios": "^1.6.8",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -24,11 +25,12 @@
"minio": "^7.1.3", "minio": "^7.1.3",
"pino": "^8.20.0", "pino": "^8.20.0",
"pino-pretty": "^11.0.0", "pino-pretty": "^11.0.0",
"sharp": "^0.33.3", "sharp": "^0.33.3"
"typescript": "^5.3.3"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash": "^4.17.0", "@types/lodash": "^4.17.0",
"typescript": "^5.3.3",
"esbuild": "^0.20.2",
"esrun": "^3.2.26", "esrun": "^3.2.26",
"prisma": "^5.12.1" "prisma": "^5.12.1"
} }
@@ -0,0 +1,25 @@
/*
Warnings:
- Added the required column `postedAt` to the `attachments` table without a default value. This is not possible if the table is not empty.
- Added the required column `timestamp` to the `memes` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "attachments"
ADD COLUMN "postedAt"
TIMESTAMP(3);
UPDATE "attachments"
set "postedAt" = TIMESTAMP 'epoch' + ((id >> 22) + 1420070400000) * INTERVAL '1 millisecond';
-- AlterTable
ALTER TABLE "memes"
ADD COLUMN "postedAt"
TIMESTAMP(3);
UPDATE "memes"
set "postedAt" = TIMESTAMP 'epoch' + (("attachmentId" >> 22) + 1420070400000) * INTERVAL '1 millisecond';
ALTER TABLE "attachments" ALTER COLUMN "postedAt" SET NOT NULL;
ALTER TABLE "memes" ALTER COLUMN "postedAt" SET NOT NULL;
@@ -1,7 +1,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
// include for node docker image // include for node docker image
binaryTargets = ["native", "debian-openssl-1.1.x"] binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"]
} }
datasource db { datasource db {
@@ -27,6 +27,7 @@ model Attachment {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) updatedAt DateTime @default(now())
postedAt DateTime
@@index([channelId]) @@index([channelId])
@@map("attachments") @@map("attachments")
@@ -38,6 +39,12 @@ model Meme {
text String text String
tags Json tags Json
// need an timestamp for giving to meili to sort by
// i think a usize would actually work but can't
// convince the meili-js lib to serialize it properly to json
// this one actually has to be .valueOf()'d before we send it
// cause js numbers are bigger than postgres int4
postedAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) updatedAt DateTime @default(now())
@@ -2,6 +2,8 @@ import sharp from 'sharp'
import axios from 'axios' import axios from 'axios'
import mime from 'mime-types' import mime from 'mime-types'
import _ from 'lodash';
import type { Logger } from 'pino' import type { Logger } from 'pino'
import type Minio from 'minio' import type Minio from 'minio'
@@ -53,16 +55,21 @@ export default async function attachmentService(log: Logger, minio: Minio.Client
return download.data return download.data
} }
return minio.putObject(STORAGE_BUCKET, objectName(a), stream()) return minio.putObject(STORAGE_BUCKET,
objectName(a),
stream(),
null,
// hardcode this for now, we upload everything as webp
{ 'Content-Type': 'image/webp' })
} }
async function deleteAttachment(a: Attachment) { async function deleteAttachment(a: Attachment) {
return minio.removeObject(STORAGE_BUCKET, objectName(a)) return minio.removeObject(STORAGE_BUCKET, objectName(a))
} }
async function process(a: Attachment) { async function process(a: Attachment, history: Partial<ChannelHistory>) {
try { try {
return prisma.$transaction(async prisma => { const results = await prisma.$transaction(async prisma => {
const meme = memes.fromAttachment(a) const meme = memes.fromAttachment(a)
const dbMeme = await prisma.meme.upsert({ create: meme, update: meme, where: { id: meme.id } }) const dbMeme = await prisma.meme.upsert({ create: meme, update: meme, where: { id: meme.id } })
const dbA = await prisma.attachment.upsert({ create: a, update: a, where: { id: a.id } }) const dbA = await prisma.attachment.upsert({ create: a, update: a, where: { id: a.id } })
@@ -71,24 +78,30 @@ export default async function attachmentService(log: Logger, minio: Minio.Client
const upload = await storeAttachment(a) const upload = await storeAttachment(a)
log.debug({ msg: 'successfully uploaded', upload }) log.debug({ msg: 'successfully uploaded', upload })
await memes.persist(dbMeme)
log.info(dbMeme, 'new meme')
return { meme: dbMeme, upload, attachment: dbA } return { meme: dbMeme, upload, attachment: dbA }
}, { timeout: 60000 }) }, { timeout: 60000 })
} catch (e) { } catch (e) {
// this happens pretty regularly it seems // this happens pretty regularly it seems
// when ppl reply to an attachment it is included in their message // when ppl reply to an attachment it is included in their message
if (e.meta.modelName !== 'Meme' && e.meta.target[0] !== 'attachmentId') { if (e.meta?.modelName !== 'Meme' || e.meta?.target?.[0] !== 'attachmentId') {
log.error(e, 'failed to process attachment') log.error(e, 'failed to process attachment')
log.error(a, 'failed to process attachment') log.error(_.omit(a, ['vision']), 'failed to process attachment')
} }
// deletes the upload if we got that far // deletes the upload if we got that far
// (probably not) // (probably not)
await deleteAttachment(a) await deleteAttachment(a)
// throw again cause we gather with allSettled
throw e
} }
// we assume here that a failure in processing was caused by a duplicate
// so we update pg to say we've processed it
const newHistory = await prisma.channelHistory.update({ data: history, where: { id: history.id }})
log.debug({ newHistory }, 'updated channel history')
return true
} }
await ensureStorageBucketExists() await ensureStorageBucketExists()
+27 -15
View File
@@ -2,6 +2,8 @@ import type { PrismaClient, Attachment, Meme } from '@prisma/client';
import type { Meilisearch } from 'meilisearch'; import type { Meilisearch } from 'meilisearch';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
import _ from 'lodash'
import * as attachments from './attachments' import * as attachments from './attachments'
const STORAGE_BUCKET = process.env.STORAGE_BUCKET const STORAGE_BUCKET = process.env.STORAGE_BUCKET
@@ -29,6 +31,7 @@ export default async function memeService(baseLog: Logger, prisma: PrismaClient,
text, text,
tags: tags, tags: tags,
attachmentId: a.id, attachmentId: a.id,
postedAt: a.postedAt,
createdAt: undefined, createdAt: undefined,
updatedAt: undefined updatedAt: undefined
} }
@@ -37,36 +40,45 @@ export default async function memeService(baseLog: Logger, prisma: PrismaClient,
} }
async function ensureMeiliIndex() { async function ensureMeiliIndex() {
const delTask = await meili.deleteIndex('memes')
const delResult = await meili.waitForTask(delTask.taskUid)
const task = await meili.createIndex('memes', { primaryKey: 'id' }) const task = await meili.createIndex('memes', { primaryKey: 'id' })
const result = await meili.waitForTask(task.taskUid) const result = await meili.waitForTask(task.taskUid)
log.debug(result); log.debug(result);
return meili.index('memes') const index = await meili.index('memes')
await index.updateSortableAttributes(['postedAt'])
return index;
} }
async function updateMeili() { // BLOWS IT UP
await ensureMeiliIndex(); async function resetMeili() {
const index = meili.index('memes') const delTask = await meili.deleteIndex('memes')
const memes = await prisma.meme.findMany(); const delResult = await meili.waitForTask(delTask.taskUid)
const task = await index.updateDocuments(memes) const index = await ensureMeiliIndex()
const res = await meili.waitForTask(task.taskUid) const memes = (await prisma.meme.findMany())
log.debug(res) .map(m => ({ ...m, postedAt: m.postedAt.valueOf() }));
for await (const batch of _.chunk(memes, 1000)) {
const task = await index.updateDocuments(batch)
const res = await meili.waitForTask(task.taskUid)
log.info({ res }, 'added meme batch')
}
} }
async function addToIndex(memes: Meme[]) { async function persist(m: Meme) {
const index = meili.index('memes') const index = meili.index('memes')
return index.updateDocuments(memes) const doc = ({ ...m, postedAt: m.postedAt.valueOf() })
return index.updateDocuments([doc])
} }
await ensureMeiliIndex(); await ensureMeiliIndex();
await updateMeili();
// BAM
// await resetMeili();
return { return {
ensureMeiliIndex, ensureMeiliIndex,
updateMeili, resetMeili,
fromAttachment, fromAttachment,
addToIndex, persist,
} }
} }
+63 -59
View File
@@ -1,3 +1,4 @@
import { setTimeout } from 'timers/promises'
import axios from 'axios' import axios from 'axios'
import _ from 'lodash'; import _ from 'lodash';
@@ -11,19 +12,23 @@ import sharp from 'sharp';
import type { AttachmentService } from './attachments' import type { AttachmentService } from './attachments'
import { IMAGE_TYPES, VIDEO_TYPES } from './attachments' import { IMAGE_TYPES, VIDEO_TYPES } from './attachments'
import { MemeService } from './memes';
// TODO // TODO
// also fetch embeds and their descriptions // also fetch embeds and their descriptions
export default function scraper( function convertSnowflakeToDate(snowflake) {
const DISCORD_EPOCH = 1420070400000
const milliseconds = global.BigInt(snowflake) >> 22n
return new Date(Number(milliseconds) + DISCORD_EPOCH)
}
export default async function scraper(
discordAuth, discordAuth,
vision: ImageAnnotatorClient, vision: ImageAnnotatorClient,
minio: Minio.Client, minio: Minio.Client,
prisma: PrismaClient, prisma: PrismaClient,
baseLog: Logger, baseLog: Logger,
attachments: AttachmentService, attachments: AttachmentService
memes: MemeService,
) { ) {
const headers = { const headers = {
'Authorization': discordAuth, 'Authorization': discordAuth,
@@ -51,9 +56,7 @@ export default function scraper(
async function fetchGuildDetails(guildId) { async function fetchGuildDetails(guildId) {
const url = `https://discord.com/api/v9/guilds/${guildId}` const url = `https://discord.com/api/v9/guilds/${guildId}`
const res = await axios.get(url, { headers }) const res = await axios.get(url, { headers })
const keys_to_remove = ['emojis', 'stickers', 'roles', 'features'] return _.omit(res.data, ['emojis', 'stickers', 'roles', 'features'])
keys_to_remove.forEach(k => delete res.data[k])
return res.data
} }
async function fetchChannel(channelId) { async function fetchChannel(channelId) {
@@ -79,6 +82,12 @@ export default function scraper(
return res[0] || {} return res[0] || {}
} }
async function fetchMissingVision() {
const attachments = await prisma.attachment
.findMany({ where: { vision: {}, contentType: { in: IMAGE_TYPES } }})
baseLog.info({attachments: attachments.length }, 'missing vision attachments')
}
async function processChannel(channelId) { async function processChannel(channelId) {
const channel = await fetchChannel(channelId) const channel = await fetchChannel(channelId)
const guild = await fetchGuildDetails(channel.guild_id) const guild = await fetchGuildDetails(channel.guild_id)
@@ -87,19 +96,18 @@ export default function scraper(
async function filterAttachments(messages): Promise<Attachment[]> { async function filterAttachments(messages): Promise<Attachment[]> {
const acceptedContentTypes = [ const acceptedContentTypes = [
...IMAGE_TYPES, ...IMAGE_TYPES,
...VIDEO_TYPES, // ...VIDEO_TYPES,
] ]
const validAttachments = messages const validAttachments = messages
.filter(m => m.attachments) .filter(m => m.attachments)
// bundle message and attachment together // bundle message and attachment together
.flatMap(m => m.attachments.map(a => [a, m])) .flatMap(m => m.attachments.map(a => [a, m]))
.filter(([a, m]) => acceptedContentTypes.includes(a.content_type)) .filter(([a, m]) => acceptedContentTypes.includes(a.content_type));
const newAttachments = validAttachments.filter(([a, m]) => ![].includes(a.id)) log.debug({ validAttachments: validAttachments.length }, 'attachments found in messages')
log.info({ newAttachments: newAttachments.length, validAttachments: validAttachments.length })
return Promise.all(newAttachments return Promise.all(validAttachments
.map(async ([a, m]) => { .map(async ([a, m]) => {
const attachment: Attachment = { const attachment: Attachment = {
id: BigInt(a.id), id: BigInt(a.id),
@@ -111,47 +119,41 @@ export default function scraper(
// create this ahead of time to associate with our copy of the media // create this ahead of time to associate with our copy of the media
memeId: crypto.randomUUID(), memeId: crypto.randomUUID(),
// @ts-ignore it's fine vision: {}, // quick hack so that detectText can receive a full attachment
vision: {},
guild, guild,
channel, channel,
message: m, message: m,
postedAt: convertSnowflakeToDate(a.id),
// ts asks for these to be included but they don't need to be set
createdAt: undefined, createdAt: undefined,
updatedAt: undefined updatedAt: undefined
} }
const visionData = await detectText(attachment) // actually assign vision here
return { ...attachment, vision: visionData } return { ...attachment, vision: await detectText(attachment) }
})) }))
} }
async function* fetchNew(channelId, log, latestMessageId) { async function* fetchNew(channelId, log, latestMessageId): AsyncIterable<Attachment> {
// reverse the messages because we want to yield them in chronological order // reverse the messages because we want to yield them in chronological order
// the api call returns them most recent first // the api call returns them most recent first
const messages = (await fetchChannelMessagesAfter(channelId, latestMessageId)).reverse() const messages = (await fetchChannelMessagesAfter(channelId, latestMessageId)).reverse()
if (messages.length === 0) { if (messages.length === 0) {
log.info('no new messages') log.info('reached latest message')
return
} }
log.info({ messages: messages.length, latestMessageId }, 'fetched new messages') log.debug({ messages: messages.length, latestMessageId }, 'fetched new messages')
const cursor = _.last(messages).id yield* await filterAttachments(messages)
yield [await filterAttachments(messages), cursor]
yield* fetchNew(channelId, log, cursor)
} }
async function* fetchOld(channelId, log, earliestMessageId) { async function* fetchOld(channelId, log, earliestMessageId): AsyncIterable<Attachment> {
const messages = await fetchChannelMessagesBefore(channelId, earliestMessageId) const messages = await fetchChannelMessagesBefore(channelId, earliestMessageId)
if (messages.length === 0) { if (messages.length === 0) {
log.info('no new messages') log.info('reached beginning of history')
return
} }
log.info({ messages: messages.length, earliestMessageId }, 'fetched old messages') log.debug({ messages: messages.length, earliestMessageId }, 'fetched older messages')
const cursor = _.last(messages).id yield* await filterAttachments(messages)
yield [await filterAttachments(messages), cursor]
yield* fetchOld(channelId, log, cursor)
} }
const ensureHistory = async () => { const ensureHistory = async () => {
@@ -164,43 +166,45 @@ export default function scraper(
}) })
} }
const startingHistory = await ensureHistory() const history = await ensureHistory()
const updateHistory = async update => prisma.channelHistory async function processLoop() {
.update({ data: update, where: { id: startingHistory.id } }) const history = await ensureHistory()
const newAttachments = fetchNew(channelId, log, history.latestMessageId)
const oldAttachments = fetchOld(channelId, log, history.earliestMessageId)
async function processBatch([batch, cursor], genHistory) { let oldCount = 0;
log.debug({ msg: 'scraped new attachments', attachments: batch.length }) let newCount = 0;
const results = await Promise.allSettled(batch.map(async a => attachments.process(a)))
const inserts = results
.filter(r => r.value)
.map(r => r.value)
const failures = results for await (const attachment of newAttachments) {
.filter(r => r.reason) log.debug({ msg: 'scraped new attachment', attachment: _.omit(attachment, ['guild', 'message', 'channel', 'vision']) })
.map(r => r.reason) const newHistory = { id: history.id, latestMessageId: attachment.msgId }
await attachments.process(attachment, newHistory)
// pretty oldschool, the async iterable from the fetch fn does not have a count
// so we just use a plain old count to see if we should wait
oldCount++
}
if (failures.length) log.error({ failures }, 'batch failures') for await (const attachment of oldAttachments) {
log.info({ inserts: inserts.length }, 'batch success') log.debug({ msg: 'scraped old attachment', attachment: _.omit(attachment, ['guild', 'message', 'channel', 'vision']) })
const newHistory = { id: history.id, earliestMessageId: attachment.msgId }
await attachments.process(attachment, newHistory)
newCount++
}
await memes.addToIndex(inserts.map(i => i.meme)) log.info({ oldCount, newCount }, 'processed attachments')
// yeah cool whatever we processed the whole lot... sure const shouldWait = newCount === 0 && oldCount === 0
const newHistory = await updateHistory(genHistory()) const PROCESS_DELAY_MS = 1000 * 60 // 1 minute
log.debug({ newHistory }, 'updated channel history') if (shouldWait) {
log.info({ PROCESS_DELAY_MS }, 'waiting for new messages')
await setTimeout(PROCESS_DELAY_MS)
}
return inserts return processLoop()
} }
for await (const batch of fetchNew(channelId, log, startingHistory.latestMessageId)) { await processLoop();
const genHistory = cursor => ({ latestMessageId: cursor })
await processBatch(batch, genHistory)
}
for await (const batch of fetchOld(channelId, log, startingHistory.latestMessageId)) {
const genHistory = cursor => ({ earliestMessageId: cursor })
await processBatch(batch, genHistory)
}
} }
return { return {
File renamed without changes.
File renamed without changes.
+25
View File
@@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
+13
View File
@@ -0,0 +1,13 @@
# build step
FROM node:20 AS build-env
WORKDIR /app
COPY . ./
RUN npm i
RUN npm run build
# static server
FROM nginx
WORKDIR /srv/app
COPY --from=build-env /app/build /srv/app
COPY ./nginx.conf /etc/nginx/nginx.conf
+70
View File
@@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
+49
View File
@@ -0,0 +1,49 @@
events {
worker_connections 1024;
}
http {
include mime.types;
sendfile on;
server {
listen 8080;
listen [::]:8080;
autoindex off;
root /srv/app;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
application/atom+xml
application/geo+json
application/javascript
application/x-javascript
application/json
application/ld+json
application/manifest+json
application/rdf+xml
application/rss+xml
application/xhtml+xml
application/xml
font/eot
font/otf
font/ttf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
error_page 404 /;
}
}
+18841
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "memestream-client",
"version": "0.1.0",
"private": true,
"dependencies": {
"@meilisearch/instant-meilisearch": "^0.17.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"instantsearch.css": "^8.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-instantsearch": "^7.7.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"deploy": "docker build . -t registry.strix.systems/memestream-client && docker push registry.strix.systems/memestream-client && kubectl rollout restart deployment memestream-client"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Search memes by text"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>MemeStream</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+172
View File
@@ -0,0 +1,172 @@
:root {
--colour-fg: rgb(158, 149, 137);
--colour-bg: #1a1c1d;
--colour-bg-dark: #131516;
--colour-gray: rgb(60, 65, 68);
--colour-pink: rgb(238, 48, 112);
--colour-pink: rgb(238, 48, 112);
}
html {
font-family: sans-serif;
padding: 1em;
font-size: 16px;
color: var(--colour-fg);
background-color: var(--colour-bg);
}
h1 {
color: var(--colour-pink);
font-size: 3rem;
}
/* reset the fonts on all this shit*/
[class^="ais-"] {
font-size: unset;
}
.ais-ClearRefinements {
margin: 1em 0;
}
.ais-SearchBox {
margin: 1rem 0;
font-size: 2rem;
}
.ais-Pagination {
margin-top: 1em;
}
.ais-InstantSearch {
padding: 0 3rem;
overflow: hidden;
margin: 0 auto;
}
.ais-Hits-list, .ais-InfiniteHits-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 0.5rem;
}
.ais-InfiniteHits-item, .ais-InfiniteResults-item, .ais-Hits-item, .ais-Results-item {
flex: 1;
background-color: var(--colour-bg-dark);
border-radius: 0.5rem;
border: 1px solid var(--colour-gray);
width: unset;
box-shadow: none;
padding: 1rem;
}
.ais-Hits-item img, .ais-InfiniteHits-item img {
margin-right: 1em;
margin-bottom: 0.5em;
max-height: 40rem;
max-width: 100%;
}
.hit {
font-size: 1.25rem;
display: grid;
grid-template-areas:
"img"
"info"
"text";
align-content: flex-start;
align-items: flex-start;
justify-content: flex-start;
height: 100%;
}
.hit .img {
grid-area: img;
display: grid;
justify-content: center;
}
.hit .info {
font-weight: bold;
grid-area: info;
margin: 2rem 0;
}
.hit .text {
}
.ais-SearchBox-input {
height: 4rem;
width: 100%;
padding-left: 3rem;
padding-right: 0.5rem;
background-position: left 1rem top 50%;
background-color: #131516;
border-color: var(--colour-gray);
border-width: 1px;
border-style: solid;
border-radius: 0.5rem;
outline: none;
outline-color: currentcolor;
color: var(--colour-fg);
font-weight: 500;
line-height: 22px;
}
.ais-SearchBox-input:hover {
border-color: var(--colour-pink);
}
.ais-Highlight-highlighted, .ais-Snippet-highlighted {
background-color: var(--colour-pink);
}
.ais-Breadcrumb, .ais-ClearRefinements, .ais-CurrentRefinements, .ais-GeoSearch, .ais-HierarchicalMenu, .ais-Hits, .ais-Results, .ais-HitsPerPage, .ais-ResultsPerPage, .ais-InfiniteHits, .ais-InfiniteResults, .ais-Menu, .ais-MenuSelect, .ais-NumericMenu, .ais-NumericSelector, .ais-Pagination, .ais-Panel, .ais-PoweredBy, .ais-RangeInput, .ais-RangeSlider, .ais-RatingMenu, .ais-RefinementList, .ais-SearchBox, .ais-RelevantSort, .ais-SortBy, .ais-Stats, .ais-ToggleRefinement {
color: rgb(158, 149, 137)
}
.ais-SearchBox-submit{
left: 1rem;
}
.ais-SearchBox-reset {
right: 1rem;
}
.ais-SearchBox-submitIcon path, .ais-SearchBox-resetIcon path {
fill: var(--colour-fg);
color: var(--colour-fg);
}
.ais-Breadcrumb-link, .ais-HierarchicalMenu-link, .ais-Menu-link, .ais-Pagination-link, .ais-RatingMenu-link
.ais-Breadcrumb-link:hover, .ais-HierarchicalMenu-link:hover, .ais-Menu-link:hover, .ais-Pagination-link:hover, .ais-RatingMenu-link:hover {
color: var(--colour-pink);
}
.ais-Pagination-item--selected .ais-Pagination-link, .ais-InfiniteHits-loadMore {
background-color: var(--colour-pink);
border-color: var(--colour-pink);
color: white;
/* color: var(--colour-fg);*/
}
.ais-ClearRefinements-button:hover, .ais-ClearRefinements-button:focus, .ais-CurrentRefinements-reset:hover, .ais-CurrentRefinements-reset:focus, .ais-GeoSearch-redo:hover, .ais-GeoSearch-redo:focus, .ais-GeoSearch-reset:hover, .ais-GeoSearch-reset:focus, .ais-HierarchicalMenu-showMore:hover, .ais-HierarchicalMenu-showMore:focus, .ais-InfiniteHits-loadPrevious:hover, .ais-InfiniteHits-loadPrevious:focus, .ais-InfiniteHits-loadMore:hover, .ais-InfiniteHits-loadMore:focus, .ais-InfiniteResults-loadMore:hover, .ais-InfiniteResults-loadMore:focus, .ais-Menu-showMore:hover, .ais-Menu-showMore:focus, .ais-RefinementList-showMore:hover, .ais-RefinementList-showMore:focus, .ais-RelevantSort-button:hover, .ais-RelevantSort-button:focus {
background-color: var(--colour-pink);
border-color: white;
}
.ais-SortBy {
display: none;
}
@media (max-width:900px) {
.ais-InstantSearch {
padding: 0;
}
.ais-Hits-list {
grid-template-columns: 1fr;
}
}
+76
View File
@@ -0,0 +1,76 @@
import "instantsearch.css/themes/algolia-min.css";
import React from "react";
import {
InstantSearch,
SearchBox,
Highlight,
SortBy,
InfiniteHits,
} from "react-instantsearch";
import "./App.css";
import { instantMeiliSearch } from "@meilisearch/instant-meilisearch";
import { convertSnowflakeToDate } from "./util";
const { searchClient } = instantMeiliSearch(
"https://search.memestream.strix.systems",
"ef006ab414a12b49eb797e0637219d5d6bcf3c84b8195c9fac49f2629713705a",
{
finitePagination: false,
placeholderSearch: true,
}
);
const placeholders = [
"we're so back",
"it's so over",
'freud',
'hegel',
'satire',
'literally me',
'rake',
'we are not the same',
'anarchy',
'goblin',
'wise'
]
// <SortBy items={[
// { label: 'Latest', value: 'memes:attachmentId:desc'}
// ]} />
const placeholder = placeholders[Math.floor(Math.random() * placeholders.length)]
const initialUiState= {
memes: {
query: '',
sortBy: 'memes:postedAt:desc',
}
}
const App = () => (
<div className="ais-InstantSearch">
<h1>MemeStream</h1>
<InstantSearch indexName="memes" searchClient={searchClient} initialUiState={initialUiState} >
<SearchBox autoFocus placeholder={placeholder}/>
<InfiniteHits hitComponent={Hit} />
<SortBy items={[{ label: 'Latest', value: 'memes:attachmentId:desc'}]} />
</InstantSearch>
</div>
);
const Hit = ({ hit }) => (
<div class="hit" key={hit.id}>
<a href={hit.url} class="img" target="_blank" rel="noreferrer">
<img src={hit.url} alt={hit.text} />
</a>
<div className="info">
<div>{hit.tags[0]} #{hit.tags[1]} @{hit.tags[2]}</div>
<div>{convertSnowflakeToDate(hit.attachmentId).toLocaleString()}</div>
</div>
<Highlight className="text" attribute="text" hit={hit} />
</div>
);
export default App;
+8
View File
@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+13
View File
@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+13
View File
@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+9
View File
@@ -0,0 +1,9 @@
export const DISCORD_EPOCH = 1420070400000
// Converts a snowflake ID string into a JS Date object using the provided epoch (in ms), or Discord's epoch if not provided
export function convertSnowflakeToDate(snowflake, epoch = DISCORD_EPOCH) {
// Convert snowflake to BigInt to extract timestamp bits
// https://discord.com/developers/docs/reference#snowflakes
const milliseconds = global.BigInt(snowflake) >> 22n
return new Date(Number(milliseconds) + epoch)
}
+9 -22
View File
@@ -12,15 +12,6 @@ services:
volumes: volumes:
- "./data/pg:/var/lib/postgresql/data" - "./data/pg:/var/lib/postgresql/data"
redis:
image: redis:latest
ports:
- '6379:6379'
command: ['redis-server', '/usr/local/etc/redis/redis.conf']
volumes:
- "./data/redis:/data"
- "./redis.conf:/usr/local/etc/redis/redis.conf"
meili: meili:
image: getmeili/meilisearch:v1.7 image: getmeili/meilisearch:v1.7
ports: ports:
@@ -42,17 +33,13 @@ services:
MINIO_ROOT_USER: '${MINIO_ROOT_USER}' MINIO_ROOT_USER: '${MINIO_ROOT_USER}'
MINIO_ROOT_PASSWORD: '${MINIO_ROOT_PASSWORD}' MINIO_ROOT_PASSWORD: '${MINIO_ROOT_PASSWORD}'
# meilisync: archiver:
# platform: linux/x86_64 build: ./archiver
# image: long2ice/meilisync volumes:
# volumes: - './.env:/app/.env'
# - ./config.yml:/meilisync/config.yml - './archiver/vision-credentials.json:/app/vision-credentials.json'
# nginx: client:
# image: nginx:latest build: ./client
# depends_on: ports:
# - 'api' - 8080:8080
# volumes:
# - './nginx/localhost.nginx.conf:/etc/nginx/conf.d/default.conf'
# ports:
# - '80:80'
-54
View File
@@ -1,54 +0,0 @@
import "dotenv/config"
import fs from 'fs/promises'
import pino from 'pino'
import * as Minio from 'minio'
import Vision from '@google-cloud/vision'
import { PrismaClient } from '@prisma/client'
import { MeiliSearch } from 'meilisearch'
import discordScraper from './src/scraper'
import testAttachment from "./src/test.attachment"
import attachmentService from "./src/attachments"
import memeService from "./src/memes"
// @ts-ignore - ts don't know wtf it talking about
BigInt.prototype.toJSON = function bigIntStringify() {
return this.toString();
};
const VISION_CREDENTIALS = (await import(process.env.GOOGLE_APPLICATION_CREDENTIALS, { assert: { type: "json" } })).default
const log = pino({ base: {} });
const vision = new Vision.ImageAnnotatorClient({ credentials: VISION_CREDENTIALS })
const prisma = new PrismaClient()
const minio = new Minio.Client({
endPoint: process.env.STORAGE_URL,
accessKey: process.env.STORAGE_ACCESS_KEY,
secretKey: process.env.STORAGE_SECRET_KEY,
})
const meili = new MeiliSearch({
host: process.env.MEILI_URL,
apiKey: process.env.MEILI_MASTER_KEY,
})
const memes = await memeService(log, prisma, meili)
const attachments = await attachmentService(log, minio, prisma, memes)
const scraper = discordScraper(process.env.DISCORD_AUTH, vision, minio, prisma, log, attachments, memes)
const CHANNEL_IDS = [
367936426962190336n, // TPM shitposts
1095060717117374687n, // aella memes
]
async function main() {
await Promise.all(CHANNEL_IDS.map(id => scraper.processChannel(id)))
await memes.updateMeili();
}
await main()
-4940
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
appendfsync everysec
-40
View File
@@ -1,40 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@meilisearch/instant-meilisearch/templates/basic_search.css" />
</head>
<body>
<div id="app" class="wrapper"></div>
</body>
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://cdn.jsdelivr.net/npm/react-instantsearch-dom@6.7.0/dist/umd/ReactInstantSearchDOM.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@meilisearch/instant-meilisearch/dist/instant-meilisearch.umd.min.js"></script>
<script>
const { InstantSearch, SearchBox, Hits, Highlight, Configure } = ReactInstantSearchDOM;
const searchClient = instantMeiliSearch(
"http://localhost:7700",
'33e714590199031d076954182c55a47b09ae7546e3dbf6f35862eb99cc950e1e'
);
const App = () => (
React.createElement(InstantSearch, {
indexName: "memes",
searchClient: searchClient
}, [
React.createElement(SearchBox, { key: 1 }),
React.createElement(Hits, { hitComponent: Hit, key: 2 }),
React.createElement(Configure, { hitsPerPage: 10 })]
)
);
const Hit = (props) => (
React.createElement(Highlight, {
attribute: "id",
hit: props.hit
})
);
ReactDOM.createRoot(document.querySelector('#app')).render(App());
console.log(searchClient)
</script>
</html>
-44
View File
@@ -1,44 +0,0 @@
import "dotenv/config"
import fs from 'fs/promises'
import pino from 'pino'
import * as Minio from 'minio'
import Vision from '@google-cloud/vision'
import { PrismaClient } from '@prisma/client'
import { MeiliSearch } from 'meilisearch'
import discordScraper from './scraper'
import attachmentService from "./attachments"
// @ts-ignore - ts don't know wtf it talking about
BigInt.prototype.toJSON = function bigIntStringify() {
return this.toString();
};
const VISION_CREDENTIALS = (await import(process.env.GOOGLE_APPLICATION_CREDENTIALS, { assert: { type: "json" } })).default
const STORAGE_BUCKET = process.env.STORAGE_BUCKET
const log = pino({ base: {} });
const vision = new Vision.ImageAnnotatorClient({ credentials: VISION_CREDENTIALS })
const prisma = new PrismaClient()
const minio = new Minio.Client({
endPoint: process.env.STORAGE_URL,
accessKey: process.env.STORAGE_ACCESS_KEY,
secretKey: process.env.STORAGE_SECRET_KEY,
})
const meili = new MeiliSearch({
host: process.env.MEILI_URL,
apiKey: process.env.MEILI_MASTER_KEY,
})
const attachments = attachmentService(log, minio, prisma)
const scraper = discordScraper(process.env.DISCORD_AUTH, vision, minio, prisma, log)
const CHANNEL_IDS = [
367936426962190336n, // TPM shitposts
1095060717117374687n, // aella memes
]
await Promise.all(CHANNEL_IDS.map(id => scraper.processChannel(id)))