Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1142ddeb5b | ||
|
|
7a505e7d5c | ||
|
|
3cfe6b3d54 | ||
|
|
e165ed8a24 | ||
|
|
9931732ded | ||
|
|
c987ba01b0 | ||
|
|
99ccd8a1bd | ||
|
|
c3a5309726 | ||
|
|
f517eb7a1f | ||
|
|
2cbfb03eb6 | ||
|
|
4097b05cf9 | ||
|
|
3c5e7d9aee | ||
|
|
ecd8f78fe4 | ||
|
|
74c2a4597c | ||
|
|
d3cc44a959 | ||
|
|
544b7085ed | ||
|
|
ed0a42eeb3 | ||
|
|
29a3bcce39 | ||
|
|
07cfa6df06 | ||
|
|
03f17ac2f1 | ||
|
|
805809fa93 | ||
|
|
763b225fc2 | ||
|
|
396b288ea8 | ||
|
|
66228047f4 |
No files matched your search
@@ -0,0 +1,16 @@
|
||||
.env
|
||||
*.env
|
||||
.env*
|
||||
|
||||
.venv/
|
||||
vision-credentials.json
|
||||
__pycache__/
|
||||
|
||||
node_modules
|
||||
|
||||
# drop the workspace file cause it leaks info about personal machines
|
||||
*.sublime-workspace
|
||||
|
||||
data/
|
||||
meili_data/
|
||||
data.bak/
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
.env
|
||||
*.env
|
||||
.env*
|
||||
|
||||
.venv/
|
||||
gimmeme-d052de2b9472.json
|
||||
vision-credentials.json
|
||||
__pycache__/
|
||||
|
||||
node_modules
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# memestream
|
||||
|
||||
## TODO
|
||||
|
||||
[ ] Backfill last N images from a channel
|
||||
[ ] Set oldest_img_id for channel
|
||||
[ ]
|
||||
[ ] change downloader to url type
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:20
|
||||
WORKDIR /usr/src/memestream
|
||||
COPY . /usr/src/memestream
|
||||
RUN npm i
|
||||
RUN npx prisma generate
|
||||
|
||||
CMD npm start
|
||||
@@ -0,0 +1,5 @@
|
||||
# memestream
|
||||
|
||||
## TODO
|
||||
|
||||
[ ] change downloader to url type
|
||||
@@ -0,0 +1,344 @@
|
||||
// index.ts
|
||||
import "dotenv/config";
|
||||
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";
|
||||
|
||||
// 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();
|
||||
};
|
||||
var 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 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
|
||||
];
|
||||
async function migrateDb() {
|
||||
const output = await exec("prisma migrate deploy");
|
||||
log.info(output);
|
||||
}
|
||||
await migrateDb();
|
||||
await Promise.all(CHANNEL_IDS.map((id) => scraper2.processChannel(id)));
|
||||
}
|
||||
main().then(() => true).catch((e) => console.error(e));
|
||||
@@ -0,0 +1,66 @@
|
||||
import "dotenv/config"
|
||||
|
||||
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)
|
||||
const log = pino({ base: {} });
|
||||
|
||||
async function main() {
|
||||
const VISION_CREDENTIALS = JSON.parse(process.env.VISION_CREDENTIALS)
|
||||
|
||||
const vision = new Vision.ImageAnnotatorClient({ credentials: VISION_CREDENTIALS })
|
||||
const prisma = new PrismaClient()
|
||||
const minio = new Minio.Client({
|
||||
endPoint: process.env.STORAGE_ENDPOINT,
|
||||
accessKey: process.env.STORAGE_ACCESS_KEY,
|
||||
secretKey: process.env.STORAGE_SECRET_KEY,
|
||||
useSSL: false,
|
||||
port: 9000,
|
||||
})
|
||||
|
||||
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, 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 => log.error(e))
|
||||
Generated
+2360
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "memestream",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "esrun index.ts",
|
||||
"util:reset": "esrun reset-meili.ts",
|
||||
"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",
|
||||
"deploy": "docker build . -t registry.strix.systems/memestream-archiver && docker push registry.strix.systems/memestream-archiver && kubectl rollout restart deployment memestream-archiver",
|
||||
"test": "esrun src/scraper.test.ts | pino-pretty"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@google-cloud/vision": "^4.2.0",
|
||||
"@prisma/client": "^5.12.1",
|
||||
"axios": "^1.6.8",
|
||||
"dotenv": "^16.4.1",
|
||||
"lodash": "^4.17.21",
|
||||
"meilisearch": "^0.38.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"minio": "^7.1.3",
|
||||
"pino": "^8.20.0",
|
||||
"pino-pretty": "^11.0.0",
|
||||
"sharp": "^0.33.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.0",
|
||||
"typescript": "^5.3.3",
|
||||
"esbuild": "^0.20.2",
|
||||
"esrun": "^3.2.26",
|
||||
"prisma": "^5.12.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "attachments" (
|
||||
"id" BIGINT NOT NULL,
|
||||
"msgId" BIGINT NOT NULL,
|
||||
"channelId" BIGINT NOT NULL,
|
||||
"memeId" UUID NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"contentType" TEXT NOT NULL,
|
||||
"message" JSONB NOT NULL,
|
||||
"guild" JSONB NOT NULL,
|
||||
"channel" JSONB NOT NULL,
|
||||
"vision" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "memes" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"url" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"tags" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "memes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "attachments_memeId_key" ON "attachments"("memeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "attachments_channelId_idx" ON "attachments"("channelId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_memeId_fkey" FOREIGN KEY ("memeId") REFERENCES "memes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[attachmentId]` on the table `memes` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "memes" ADD COLUMN "attachmentId" BIGINT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "memes_attachmentId_key" ON "memes"("attachmentId");
|
||||
@@ -0,0 +1,9 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "channelhistory" (
|
||||
"id" BIGINT NOT NULL,
|
||||
"earliestMessageId" BIGINT NOT NULL,
|
||||
"latestMessageId" BIGINT NOT NULL,
|
||||
"backfillComplete" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "channelhistory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `backfillComplete` on the `channelhistory` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "channelhistory" DROP COLUMN "backfillComplete";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,66 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
// include for node docker image
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_URL")
|
||||
}
|
||||
|
||||
model Attachment {
|
||||
id BigInt @id
|
||||
msgId BigInt
|
||||
channelId BigInt
|
||||
|
||||
memeId String @unique @db.Uuid
|
||||
meme Meme @relation(fields: [memeId], references: [id], onDelete: Restrict)
|
||||
|
||||
url String
|
||||
contentType String
|
||||
|
||||
message Json
|
||||
guild Json
|
||||
channel Json
|
||||
vision Json
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
postedAt DateTime
|
||||
|
||||
@@index([channelId])
|
||||
@@map("attachments")
|
||||
}
|
||||
|
||||
model Meme {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
url String
|
||||
text String
|
||||
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())
|
||||
updatedAt DateTime @default(now())
|
||||
|
||||
// required to prevent the same attachment being uploaded multiple times
|
||||
// nullable because maybe will have memes that don't come from a discord attachment
|
||||
attachmentId BigInt? @unique
|
||||
attachment Attachment?
|
||||
|
||||
@@map("memes")
|
||||
}
|
||||
|
||||
model ChannelHistory {
|
||||
id BigInt @id
|
||||
earliestMessageId BigInt
|
||||
latestMessageId BigInt
|
||||
|
||||
// we do this because you have to always wrap table names in "" if they have capitals
|
||||
@@map("channelhistory")
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import "dotenv/config"
|
||||
|
||||
import pino from 'pino'
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { MeiliSearch } from 'meilisearch'
|
||||
|
||||
import memeService from "./src/memes"
|
||||
|
||||
BigInt.prototype.toJSON = function bigIntStringify() {
|
||||
return this.toString();
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const log = pino({ base: {} });
|
||||
const prisma = new PrismaClient()
|
||||
const meili = new MeiliSearch({
|
||||
host: process.env.MEILI_URL,
|
||||
apiKey: process.env.MEILI_MASTER_KEY,
|
||||
})
|
||||
|
||||
const memes = await memeService(log, prisma, meili)
|
||||
return memes.resetMeili()
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => true)
|
||||
.catch(e => console.error(e))
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import _ from 'lodash';
|
||||
import axios from 'axios'
|
||||
import fs from 'fs/promises'
|
||||
import mime from 'mime-types'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import type Minio from 'minio'
|
||||
import type { Logger } from 'pino'
|
||||
import type { PrismaClient, Attachment, ChannelHistory } from '@prisma/client'
|
||||
|
||||
import type { MemeService } from './memes'
|
||||
|
||||
const STORAGE_BUCKET = process.env.STORAGE_BUCKET
|
||||
|
||||
export const IMAGE_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]
|
||||
|
||||
export const VIDEO_TYPES = [
|
||||
'video/mp4',
|
||||
'video/quicktime'
|
||||
]
|
||||
|
||||
export function isImage(a: Attachment) {
|
||||
return IMAGE_TYPES.includes(a.contentType)
|
||||
}
|
||||
|
||||
export function objectName(a: Attachment) {
|
||||
if (isImage(a))
|
||||
return `${a.memeId}.webp`
|
||||
return `${a.memeId}.${mime.extension(a.contentType)}`
|
||||
}
|
||||
|
||||
export default async function attachmentService(log: Logger, minio: Minio.Client, prisma: PrismaClient, memes: MemeService) {
|
||||
const BUCKET_POLICY = await fs.readFile('./storage-bucket-policy.json', { encoding: 'utf8' })
|
||||
|
||||
async function ensureStorageBucketExists() {
|
||||
const exists = await minio.bucketExists(STORAGE_BUCKET)
|
||||
if (exists) return true
|
||||
await minio.makeBucket(STORAGE_BUCKET)
|
||||
await minio.setBucketPolicy(STORAGE_BUCKET, BUCKET_POLICY)
|
||||
}
|
||||
|
||||
async function storeAttachment(a: Attachment) {
|
||||
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: Attachment) {
|
||||
return minio.removeObject(STORAGE_BUCKET, objectName(a))
|
||||
}
|
||||
|
||||
async function process(a: Attachment, history: Partial<ChannelHistory>) {
|
||||
try {
|
||||
const results = await prisma.$transaction(async prisma => {
|
||||
const meme = memes.fromAttachment(a)
|
||||
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 } })
|
||||
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: 60000 })
|
||||
} catch (e) {
|
||||
|
||||
// this happens pretty regularly it seems
|
||||
// when ppl reply to an attachment it is included in their message
|
||||
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')
|
||||
}
|
||||
|
||||
// deletes the upload if we got that far
|
||||
// (probably not)
|
||||
await deleteAttachment(a)
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
return {
|
||||
process,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type AttachmentService = Awaited<ReturnType<typeof attachmentService>>
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { PrismaClient, Attachment, Meme } from '@prisma/client';
|
||||
import type { Meilisearch } from 'meilisearch';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import _ from 'lodash'
|
||||
|
||||
import * as attachments from './attachments'
|
||||
|
||||
// where to post the imgs to
|
||||
const STORAGE_BUCKET = process.env.STORAGE_BUCKET
|
||||
// the host the client will fetch the img from
|
||||
const STORAGE_HOST = process.env.STORAGE_HOST
|
||||
|
||||
// name for the key the client uses
|
||||
const MEILI_SEARCH_KEY_UUID = process.env.MEILI_SEARCH_KEY_UUID
|
||||
const MEILI_SEARCH_KEY_NAME = process.env.MEILI_SEARCH_KEY_NAME
|
||||
const MEILI_INDEX = process.env.MEILI_INDEX || 'memes'
|
||||
|
||||
function memeUrl(a: Attachment) {
|
||||
const STORAGE_ENDPOINT = process.env.STORAGE_ENDPOINT
|
||||
|
||||
// thisis broken
|
||||
// needs to be actual url
|
||||
|
||||
const u = new URL(`${STORAGE_BUCKET}/${attachments.objectName(a)}`, `${STORAGE_HOST}`)
|
||||
return u.href
|
||||
}
|
||||
|
||||
export default async function memeService(baseLog: Logger, prisma: PrismaClient, meili: Meilisearch) {
|
||||
const log = baseLog.child({ service: 'meme' })
|
||||
|
||||
function fromAttachment(a: Attachment) {
|
||||
const text = a.vision.fullTextAnnotation?.text || ''
|
||||
const tags = [
|
||||
a.guild.name,
|
||||
a.channel.name,
|
||||
a.message.author.username,
|
||||
]
|
||||
|
||||
const m: Meme = {
|
||||
id: a.memeId,
|
||||
url: memeUrl(a),
|
||||
text,
|
||||
tags: tags,
|
||||
attachmentId: a.id,
|
||||
postedAt: a.postedAt,
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
async function ensureMeiliSearchKey() {
|
||||
const keys = await meili.getKeys()
|
||||
const searchKey = keys.results.find(k => k.name === MEILI_SEARCH_KEY_NAME)
|
||||
if (searchKey) {
|
||||
log.info(`Public search key=${searchKey.key}`)
|
||||
return searchKey
|
||||
}
|
||||
const created = meili.createKey({
|
||||
name: MEILI_SEARCH_KEY_NAME,
|
||||
actions: ['search', 'documents.get', 'indexes.get', 'stats.get', 'version'],
|
||||
indexes: [MEILI_INDEX],
|
||||
// never expire
|
||||
expiresAt: null,
|
||||
uid: MEILI_SEARCH_KEY_UUID,
|
||||
description: 'Memestream - Public Search Key'
|
||||
})
|
||||
return created
|
||||
}
|
||||
|
||||
async function ensureMeiliIndex() {
|
||||
const task = await meili.createIndex(MEILI_INDEX, { primaryKey: 'id' })
|
||||
const result = await meili.waitForTask(task.taskUid)
|
||||
log.debug(result);
|
||||
const index = await meili.index(MEILI_INDEX)
|
||||
await index.updateSortableAttributes(['postedAt'])
|
||||
return index;
|
||||
}
|
||||
|
||||
// BLOWS IT UP
|
||||
async function resetMeili() {
|
||||
const delTask = await meili.deleteIndex(MEILI_INDEX)
|
||||
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 _.chunk(memes, 1000)) {
|
||||
const task = await index.updateDocuments(batch)
|
||||
const res = await meili.waitForTask(task.taskUid)
|
||||
log.info({ res }, 'added meme batch')
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(m: Meme) {
|
||||
const index = meili.index(MEILI_INDEX)
|
||||
const doc = ({ ...m, postedAt: m.postedAt.valueOf() })
|
||||
return index.updateDocuments([doc])
|
||||
}
|
||||
|
||||
await ensureMeiliIndex();
|
||||
await ensureMeiliSearchKey()
|
||||
|
||||
// BAM
|
||||
// await resetMeili();
|
||||
|
||||
|
||||
return {
|
||||
ensureMeiliIndex,
|
||||
resetMeili,
|
||||
fromAttachment,
|
||||
persist,
|
||||
}
|
||||
}
|
||||
|
||||
export type MemeService = Awaited<ReturnType<typeof memeService>>
|
||||
@@ -0,0 +1,220 @@
|
||||
import { setTimeout } from 'timers/promises'
|
||||
import axios from 'axios'
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
import type { PrismaClient, Attachment } from '@prisma/client';
|
||||
import type { ImageAnnotatorClient } from '@google-cloud/vision';
|
||||
import type { Logger } from 'pino';
|
||||
import type Minio from 'minio'
|
||||
|
||||
import type { AttachmentService } from './attachments'
|
||||
import { IMAGE_TYPES, VIDEO_TYPES } from './attachments'
|
||||
|
||||
// TODO
|
||||
// also fetch embeds and their descriptions
|
||||
|
||||
function convertSnowflakeToDate(snowflake) {
|
||||
const DISCORD_EPOCH = 1420070400000
|
||||
const milliseconds = global.BigInt(snowflake) >> 22n
|
||||
return new Date(Number(milliseconds) + DISCORD_EPOCH)
|
||||
}
|
||||
|
||||
export default async function discordScraper(
|
||||
discordAuth,
|
||||
vision: ImageAnnotatorClient,
|
||||
prisma: PrismaClient,
|
||||
baseLog: Logger,
|
||||
attachments: AttachmentService
|
||||
) {
|
||||
const headers = {
|
||||
'Authorization': discordAuth,
|
||||
}
|
||||
|
||||
async function fetchChannelMessages(channelId) {
|
||||
const url = `https://discord.com/api/v9/channels/${channelId}/messages&limit=100`
|
||||
const res = await axios.get(url, { headers })
|
||||
return res.data
|
||||
}
|
||||
|
||||
// returns msgs in desc id order
|
||||
async function fetchChannelMessagesAfter(channelId, lastMessageId) {
|
||||
const url = `https://discord.com/api/v9/channels/${channelId}/messages?after=${lastMessageId}&limit=100`
|
||||
const res = await axios.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 axios.get(url, { headers })
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function fetchGuildDetails(guildId) {
|
||||
const url = `https://discord.com/api/v9/guilds/${guildId}`
|
||||
const res = await axios.get(url, { headers })
|
||||
return _.omit(res.data, ['emojis', 'stickers', 'roles', 'features'])
|
||||
}
|
||||
|
||||
async function fetchChannel(channelId) {
|
||||
const url = `https://discord.com/api/v9/channels/${channelId}`
|
||||
const res = await axios.get(url, { headers })
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function detectText(attachment: Attachment) {
|
||||
// bail out of anything not an image
|
||||
if (!IMAGE_TYPES.includes(attachment.contentType)) return {}
|
||||
|
||||
const annotateRequest = {
|
||||
image: {
|
||||
source: {
|
||||
imageUri: attachment.url
|
||||
}
|
||||
},
|
||||
features: [{ type: 'TEXT_DETECTION' }],
|
||||
}
|
||||
const res = await vision.annotateImage(annotateRequest)
|
||||
// comes back as an array
|
||||
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) {
|
||||
const channel = await fetchChannel(channelId)
|
||||
const guild = await fetchGuildDetails(channel.guild_id)
|
||||
const log = baseLog.child({ guild: guild.name, channel: channel.name, channelId })
|
||||
|
||||
log.info("processing...")
|
||||
|
||||
async function filterAttachments(messages): Promise<Attachment[]> {
|
||||
const acceptedContentTypes = [
|
||||
...IMAGE_TYPES,
|
||||
// ...VIDEO_TYPES,
|
||||
]
|
||||
|
||||
const validAttachments = messages
|
||||
.filter(m => m.attachments)
|
||||
// bundle message and attachment together
|
||||
.flatMap(m => m.attachments.map(a => [a, m]))
|
||||
.filter(([a, m]) => acceptedContentTypes.includes(a.content_type));
|
||||
|
||||
log.info({ validAttachments: validAttachments.length }, 'attachments found in messages')
|
||||
|
||||
return Promise.all(validAttachments
|
||||
.map(async ([a, m]) => {
|
||||
const attachment: 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: undefined,
|
||||
updatedAt: undefined
|
||||
}
|
||||
|
||||
// actually assign vision here
|
||||
// so we can pass an Attachment to detectText
|
||||
return { ...attachment, vision: await detectText(attachment) }
|
||||
}))
|
||||
}
|
||||
|
||||
async function* fetchNew(channelId, log, newMessages): AsyncIterable<Attachment> {
|
||||
// reverse the messages because we want to yield them in chronological order
|
||||
// the api call returns them most recent first
|
||||
const messages = newMessages.reverse()
|
||||
if (messages.length === 0) {
|
||||
log.info('reached latest message')
|
||||
}
|
||||
|
||||
yield* await filterAttachments(messages)
|
||||
}
|
||||
|
||||
async function* fetchOld(channelId, log, oldMessages): AsyncIterable<Attachment> {
|
||||
if (oldMessages.length === 0) {
|
||||
log.info('reached beginning of history')
|
||||
}
|
||||
|
||||
yield* await filterAttachments(oldMessages)
|
||||
}
|
||||
|
||||
const ensureHistory = async () => {
|
||||
const stored = await prisma.channelHistory.findFirst({ where: { id: channelId }})
|
||||
if (stored) return stored
|
||||
|
||||
const channel = await fetchChannel(channelId)
|
||||
const lastMessageId = channel.last_message_id
|
||||
return await prisma.channelHistory.create({ data:
|
||||
{ latestMessageId: lastMessageId, earliestMessageId: lastMessageId, id: channelId }
|
||||
})
|
||||
}
|
||||
|
||||
async function processLoop() {
|
||||
// updated by the attachment process
|
||||
const history = await ensureHistory()
|
||||
|
||||
const newMessages = await fetchChannelMessagesAfter(channelId, history.latestMessageId)
|
||||
const newAttachments = fetchNew(channelId, log, newMessages)
|
||||
const latestMessageId = _.first(newMessages) ? BigInt(_.first(newMessages).id) : 0n
|
||||
if (latestMessageId) await prisma.channelHistory.update({ data: { latestMessageId }, where: { id: history.id }})
|
||||
log.info({ messages: newMessages.length, latestMessageId }, 'fetched new messages')
|
||||
|
||||
const oldMessages = await fetchChannelMessagesBefore(channelId, history.earliestMessageId)
|
||||
const oldAttachments = fetchOld(channelId, log, oldMessages)
|
||||
const earliestMessageId = _.first(oldMessages) ? BigInt(_.first(oldMessages).id) : 0n
|
||||
if (earliestMessageId) await prisma.channelHistory.update({ data: { earliestMessageId }, where: { id: history.id }})
|
||||
log.info({ messages: oldMessages.length, earliestMessageId }, 'fetched older messages')
|
||||
|
||||
let oldCount = 0;
|
||||
let newCount = 0;
|
||||
|
||||
for await (const attachment of newAttachments) {
|
||||
log.info({ msg: 'scraped new attachment', attachment: _.omit(attachment, ['guild', 'message', 'channel', 'vision']) })
|
||||
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++
|
||||
}
|
||||
|
||||
for await (const attachment of oldAttachments) {
|
||||
log.info({ 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++
|
||||
}
|
||||
|
||||
log.info({ oldCount, newCount }, 'processed attachments')
|
||||
|
||||
const shouldWait = newCount === 0 && oldCount === 0
|
||||
const PROCESS_DELAY_MS = 1000 * 60 // 1 minute
|
||||
if (shouldWait) {
|
||||
log.info({ PROCESS_DELAY_MS }, 'waiting for new messages')
|
||||
await setTimeout(PROCESS_DELAY_MS)
|
||||
}
|
||||
|
||||
return processLoop()
|
||||
}
|
||||
|
||||
await processLoop();
|
||||
}
|
||||
|
||||
return {
|
||||
processChannel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
export default {
|
||||
id: 1227338263132635217n,
|
||||
msgId: 1227338263010873456n,
|
||||
channelId: 367936426962190336n,
|
||||
url: "https://cdn.discordapp.com/attachments/367936426962190336/1227338263132635217/WOW.jpg?ex=66280ad4&is=661595d4&hm=023a7a708935cf1c0cab01085486b939e1c08f92789f7a9b5c0c8ac5914c401c&",
|
||||
contentType: "image/jpeg",
|
||||
message: {
|
||||
id: "1227338263010873456",
|
||||
type: 0,
|
||||
content: "",
|
||||
channel_id: "367936426962190336",
|
||||
author: {
|
||||
id: "505353587174408193",
|
||||
username: "tgcharlet",
|
||||
avatar: "e800ffab640eb8684020054301668f79",
|
||||
discriminator: "0",
|
||||
public_flags: 0,
|
||||
flags: 0,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
global_name: "Chehrlet",
|
||||
avatar_decoration_data: null,
|
||||
banner_color: null,
|
||||
clan: null
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
id: "1227338263132635217",
|
||||
filename: "WOW.jpg",
|
||||
size: 55508,
|
||||
url: "https://cdn.discordapp.com/attachments/367936426962190336/1227338263132635217/WOW.jpg?ex=66280ad4&is=661595d4&hm=023a7a708935cf1c0cab01085486b939e1c08f92789f7a9b5c0c8ac5914c401c&",
|
||||
proxy_url: "https://media.discordapp.net/attachments/367936426962190336/1227338263132635217/WOW.jpg?ex=66280ad4&is=661595d4&hm=023a7a708935cf1c0cab01085486b939e1c08f92789f7a9b5c0c8ac5914c401c&",
|
||||
width: 1080,
|
||||
height: 1022,
|
||||
content_type: "image/jpeg",
|
||||
content_scan_version: 1,
|
||||
placeholder: "YPcFD4LU/YlNhpxWh4iLiJhn+iRpT8AH",
|
||||
placeholder_version: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
memeId: "7456180d-fa94-4465-bcb6-ea80524c67e8",
|
||||
vision: {
|
||||
textAnnotations: [],
|
||||
localizedObjectAnnotations: [],
|
||||
safeSearchAnnotation: null,
|
||||
imagePropertiesAnnotation: null,
|
||||
error: null,
|
||||
cropHintsAnnotation: null,
|
||||
fullTextAnnotation: {
|
||||
text: "damn thats crazy"
|
||||
},
|
||||
webDetection: null,
|
||||
productSearchResults: null,
|
||||
context: null
|
||||
},
|
||||
guild: {
|
||||
id: "239638846688460802",
|
||||
name: "The Philosopher's Meme",
|
||||
icon: "bda0d70d3bb2ec4df2dee343029d990d",
|
||||
description: "An applied philosophical community of practice for creatives, intellectuals, researchers, and developers. https://tpm.ac",
|
||||
home_header: null,
|
||||
splash: "0d80f531b01ab5abc8d153386f2af3e6",
|
||||
discovery_splash: "b35170de857b5eb58b1065131afa0826",
|
||||
banner: null,
|
||||
owner_id: "103361901899038720",
|
||||
application_id: null,
|
||||
region: "us-south",
|
||||
afk_channel_id: null,
|
||||
afk_timeout: 300,
|
||||
system_channel_id: "934578834534182953",
|
||||
system_channel_flags: 0,
|
||||
widget_enabled: true,
|
||||
widget_channel_id: "301188489490202626",
|
||||
verification_level: 3,
|
||||
default_message_notifications: 1,
|
||||
mfa_level: 1,
|
||||
explicit_content_filter: 2,
|
||||
max_presences: null,
|
||||
max_members: 500000,
|
||||
max_stage_video_channel_users: 50,
|
||||
max_video_channel_users: 25,
|
||||
vanity_url_code: null,
|
||||
premium_tier: 1,
|
||||
premium_subscription_count: 3,
|
||||
preferred_locale: "en-US",
|
||||
rules_channel_id: "301188489490202626",
|
||||
safety_alerts_channel_id: "837815188970537031",
|
||||
public_updates_channel_id: "837815188970537031",
|
||||
hub_type: null,
|
||||
premium_progress_bar_enabled: true,
|
||||
latest_onboarding_question_id: "1136794072313507860",
|
||||
nsfw: false,
|
||||
nsfw_level: 0,
|
||||
incidents_data: null,
|
||||
inventory_settings: null,
|
||||
embed_enabled: true,
|
||||
embed_channel_id: "301188489490202626"
|
||||
},
|
||||
channel: {
|
||||
id: "367936426962190336",
|
||||
type: 0,
|
||||
last_message_id: "1227404638450483340",
|
||||
flags: 0,
|
||||
last_pin_timestamp: "2022-07-12T12:05:47+00:00",
|
||||
guild_id: "239638846688460802",
|
||||
name: "shitposts_memes",
|
||||
parent_id: "1023698856191471667",
|
||||
rate_limit_per_user: 0,
|
||||
topic: "memes, shitposts; memeing, shitposting",
|
||||
position: 38,
|
||||
nsfw: false,
|
||||
icon_emoji: {
|
||||
id: null,
|
||||
name: "🤪"
|
||||
},
|
||||
theme_color: null
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"Action": [
|
||||
"s3:GetObject"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::memestream/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -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
|
||||
@@ -0,0 +1,19 @@
|
||||
# build step
|
||||
FROM node:20 AS build-env
|
||||
WORKDIR /usr/src/memestream-client
|
||||
|
||||
# super annoying
|
||||
# ARG REACT_APP_MEILI_ENDPOINT
|
||||
# ARG REACT_APP_MEILI_KEY
|
||||
# ENV REACT_APP_MEILI_ENDPOINT $REACT_APP_MEILI_ENDPOINT
|
||||
# ENV REACT_APP_MEILI_KEY $REACT_APP_MEILI_KEY
|
||||
|
||||
COPY . ./
|
||||
RUN npm i
|
||||
RUN npm run build
|
||||
|
||||
# static server
|
||||
FROM nginx
|
||||
WORKDIR /srv/app
|
||||
COPY --from=build-env /usr/src/memestream-client/build /srv/app
|
||||
COPY ./nginx.conf /etc/nginx/nginx.conf
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
# build step
|
||||
FROM node:20 AS build-env
|
||||
WORKDIR /usr/src/memestream-client
|
||||
|
||||
COPY . ./
|
||||
RUN npm i
|
||||
CMD npm start
|
||||
@@ -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 /;
|
||||
}
|
||||
}
|
||||
Generated
+18841
File diff suppressed because it is too large.
Load diff
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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, .ais-InfiniteHits-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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 MEILI_ENDPOINT = process.env.REACT_APP_MEILI_ENDPOINT ||
|
||||
"https://search.memestream.strix.systems"
|
||||
|
||||
const MEILI_KEY = process.env.REACT_APP_MEILI_KEY ||
|
||||
'040586799aee83b633a4935d8c0e045e9fde9e78cff0dfe542ee10583272d40d'
|
||||
|
||||
console.log({ MEILI_ENDPOINT, MEILI_KEY })
|
||||
|
||||
const { searchClient } = instantMeiliSearch(
|
||||
MEILI_ENDPOINT,
|
||||
MEILI_KEY,
|
||||
{
|
||||
finitePagination: false,
|
||||
placeholderSearch: true,
|
||||
attributesToSnippet: ['text:40'],
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const placeholders = [
|
||||
"we're so back",
|
||||
"it's so over",
|
||||
'freud',
|
||||
'hegel',
|
||||
'satire',
|
||||
'literally me',
|
||||
'rake',
|
||||
'we are not the same',
|
||||
'anarchy',
|
||||
'goblin',
|
||||
'wise',
|
||||
'look inside',
|
||||
'gorilla warfare',
|
||||
]
|
||||
|
||||
|
||||
// <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 className="hit" key={hit.id}>
|
||||
<a href={hit.url} className="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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 |
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
client:
|
||||
image: registry.strix.systems/memestream-client
|
||||
build:
|
||||
context: ./client
|
||||
env_file:
|
||||
- .env.client.prod
|
||||
+24
-39
@@ -1,34 +1,39 @@
|
||||
---
|
||||
|
||||
services:
|
||||
archiver:
|
||||
image: registry.strix.systems/memestream-archiver
|
||||
command: npm run dev
|
||||
build: ./archiver
|
||||
env_file:
|
||||
- .env.archiver
|
||||
volumes:
|
||||
- './archiver:/usr/src/memestream'
|
||||
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: ./dev.Dockerfile
|
||||
env_file:
|
||||
- .env.client.dev
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- './client:/usr/src/memestream-client'
|
||||
|
||||
pg:
|
||||
image: postgres:latest
|
||||
environment:
|
||||
POSTGRES_USER: '${POSTGRES_USER}'
|
||||
POSTGRES_DB: '${POSTGRES_DB}'
|
||||
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}'
|
||||
env_file:
|
||||
- .env.pg
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- "./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:
|
||||
image: getmeili/meilisearch:v1.7
|
||||
env_file:
|
||||
- .env.meili
|
||||
ports:
|
||||
- '7700:7700'
|
||||
volumes:
|
||||
- './data/meili:/meili_data'
|
||||
environment:
|
||||
MEILI_MASTER_KEY: '${MEILI_MASTER_KEY}'
|
||||
|
||||
minio:
|
||||
image: quay.io/minio/minio
|
||||
@@ -36,23 +41,3 @@ services:
|
||||
- 9000:9000
|
||||
- 9001:9001
|
||||
command: ['minio', 'server', '/data', '--console-address', ':9001']
|
||||
volumes:
|
||||
- './data/minio:/data'
|
||||
environment:
|
||||
MINIO_ROOT_USER: '${MINIO_ROOT_USER}'
|
||||
MINIO_ROOT_PASSWORD: '${MINIO_ROOT_PASSWORD}'
|
||||
|
||||
# meilisync:
|
||||
# platform: linux/x86_64
|
||||
# image: long2ice/meilisync
|
||||
# volumes:
|
||||
# - ./config.yml:/meilisync/config.yml
|
||||
|
||||
# nginx:
|
||||
# image: nginx:latest
|
||||
# depends_on:
|
||||
# - 'api'
|
||||
# volumes:
|
||||
# - './nginx/localhost.nginx.conf:/etc/nginx/conf.d/default.conf'
|
||||
# ports:
|
||||
# - '80:80'
|
||||
@@ -1,31 +0,0 @@
|
||||
import base58 from "bs58";
|
||||
import "dotenv/config"
|
||||
import * as helpers from "@solana-developers/helpers";
|
||||
import * as web3 from "@solana/web3.js";
|
||||
|
||||
const emoKey = helpers.getKeypairFromEnvironment("EMO");
|
||||
const emoAddr = new web3.PublicKey(emoKey.publicKey);
|
||||
|
||||
const ntrKey = helpers.getKeypairFromEnvironment("NTR");
|
||||
const ntrAddr = new web3.PublicKey(ntrKey.publicKey);
|
||||
|
||||
const payer = emoKey
|
||||
const connection = new web3.Connection(web3.clusterApiUrl("devnet"));
|
||||
|
||||
const balance = await connection.getBalance(emoAddr);
|
||||
|
||||
const tx = new web3.Transaction();
|
||||
const TX_COST = 5000;
|
||||
|
||||
const instr = web3.SystemProgram.transfer({
|
||||
fromPubkey: emoAddr,
|
||||
toPubkey: ntrAddr,
|
||||
lamports: balance - TX_COST,
|
||||
});
|
||||
|
||||
tx.add(instr);
|
||||
|
||||
const sig = await web3.sendAndConfirmTransaction(connection, tx, [emoKey]);
|
||||
|
||||
console.log(sig);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import "dotenv/config"
|
||||
|
||||
import * as Minio from 'minio'
|
||||
import Vision from '@google-cloud/vision'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
import discordScraper from './src/scraper'
|
||||
|
||||
const VISION_CREDENTIALS = (await import(process.env.GOOGLE_APPLICATION_CREDENTIALS, { assert: { type: "json" } })).default
|
||||
const STORAGE_BUCKET = process.env.STORAGE_BUCKET
|
||||
|
||||
const minio = new Minio.Client({
|
||||
endPoint: process.env.STORAGE_URL,
|
||||
accessKey: process.env.STORAGE_ACCESS_KEY,
|
||||
secretKey: process.env.STORAGE_SECRET_KEY,
|
||||
})
|
||||
const vision = new Vision.ImageAnnotatorClient({ credentials: VISION_CREDENTIALS })
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function storageBucketExists() {
|
||||
const exists = await minio.bucketExists(STORAGE_BUCKET)
|
||||
if (exists) return true
|
||||
await minio.makeBucket(STORAGE_BUCKET)
|
||||
}
|
||||
|
||||
console.log(await storageBucketExists())
|
||||
|
||||
// BIGINTS
|
||||
// must be strings
|
||||
const CHANNEL_IDS = [
|
||||
'367936426962190336', // TPM shitposts
|
||||
'1095060717117374687', // aella memes
|
||||
]
|
||||
|
||||
const scraper = discordScraper(process.env.DISCORD_AUTH, vision, minio)
|
||||
|
||||
const msgs = await Promise.all(CHANNEL_IDS.map(c => scraper.getChannelAttachments(c)))
|
||||
console.log(msgs)
|
||||
const downloads = await Promise.all(msgs.flat().map(scraper.downloadAttachment))
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
"folders":
|
||||
[
|
||||
{
|
||||
"path": ".",
|
||||
},
|
||||
{
|
||||
"path": "/home/ntr/memestream"
|
||||
"path": "."
|
||||
},
|
||||
],
|
||||
"build_systems": [
|
||||
@@ -13,5 +10,9 @@
|
||||
"name": "Memestream - npm start",
|
||||
"cmd": ["npm", "start"],
|
||||
},
|
||||
],
|
||||
{
|
||||
"name": "Memestream - npm test",
|
||||
"cmd": ["npm", "test"],
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
-4375
File diff suppressed because it is too large.
Load diff
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "memestream",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "esrun --watch index.ts",
|
||||
"migrate:make": "prisma migrate dev --name",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@google-cloud/vision": "^4.2.0",
|
||||
"@prisma/client": "^5.12.1",
|
||||
"axios": "^1.6.8",
|
||||
"dotenv": "^16.4.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"minio": "^7.1.3",
|
||||
"sharp": "^0.33.3",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esrun": "^3.2.26",
|
||||
"prisma": "^5.12.1"
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
// include for node docker image
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_URL")
|
||||
}
|
||||
|
||||
model Attachment {
|
||||
id BigInt @id
|
||||
msgId BigInt
|
||||
channelId BigInt
|
||||
|
||||
memeId String @unique @db.Uuid
|
||||
meme Meme @relation(fields: [memeId], references: [id], onDelete: Restrict)
|
||||
|
||||
url String
|
||||
contentType String
|
||||
|
||||
message Json
|
||||
guild Json
|
||||
channel Json
|
||||
vision Json
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([channelId])
|
||||
@@map("attachments")
|
||||
}
|
||||
|
||||
model Meme {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
url String
|
||||
text String
|
||||
tags Json
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
attachment Attachment?
|
||||
|
||||
@@map("memes")
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
appendfsync everysec
|
||||
-40
@@ -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>
|
||||
-195
@@ -1,195 +0,0 @@
|
||||
import path from 'path'
|
||||
|
||||
import axios from 'axios'
|
||||
import mime from 'mime-types'
|
||||
|
||||
import type { PrismaClient, Attachment } from '@prisma/client';
|
||||
import type { ImageAnnotatorClient } from '@google-cloud/vision';
|
||||
import type Minio from 'minio'
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
const IMAGE_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]
|
||||
|
||||
const VIDEO_TYPES = [
|
||||
'video/mp4',
|
||||
'video/quicktime'
|
||||
]
|
||||
|
||||
const STORAGE_BUCKET = process.env.STORAGE_BUCKET
|
||||
|
||||
export default function scraper(discordAuth, vision: ImageAnnotatorClient, minio: Minio.Client) {
|
||||
const headers = {
|
||||
'Authorization': discordAuth,
|
||||
}
|
||||
|
||||
async function fetchChannelMessages(channelId) {
|
||||
const url = `https://discord.com/api/v9/channels/${channelId}/messages`
|
||||
const res = await axios.get(url, { headers })
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function fetchGuildDetails(guildId) {
|
||||
const url = `https://discord.com/api/v9/guilds/${guildId}`
|
||||
const res = await axios.get(url, { headers })
|
||||
const keys_to_remove = ['emojis', 'stickers', 'roles']
|
||||
keys_to_remove.forEach(k => delete res[k])
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function fetchChannel(channelId) {
|
||||
const url = `https://discord.com/api/v9/channels/${channelId}`
|
||||
const res = await axios.get(url, { headers })
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function detectText(attachment: Attachment) {
|
||||
// bail out of anything not an image
|
||||
if (!IMAGE_TYPES.includes(attachment.contentType)) return {}
|
||||
|
||||
const annotateRequest = {
|
||||
image: {
|
||||
source: {
|
||||
imageUri: attachment.url
|
||||
}
|
||||
},
|
||||
features: [{ type: 'TEXT_DETECTION' }],
|
||||
}
|
||||
const res = await vision.annotateImage(annotateRequest)
|
||||
// comes back as an array
|
||||
return res[0] || {}
|
||||
}
|
||||
|
||||
async function downloadAttachment(a: Attachment) {
|
||||
const download = await axios.get(a.url, { responseType: 'stream' })
|
||||
const isImage = IMAGE_TYPES.includes(a.contentType)
|
||||
|
||||
const objectName = () => {
|
||||
if (isImage)
|
||||
return `${a.memeId}.webp`
|
||||
|
||||
return `${a.memeId}.${mime.extension(a.contentType)}`
|
||||
}
|
||||
|
||||
const stream = async () => {
|
||||
if (isImage) {
|
||||
const converter = sharp().webp()
|
||||
download.data.pipe(converter)
|
||||
return converter
|
||||
}
|
||||
return download.data
|
||||
}
|
||||
|
||||
return minio.putObject(STORAGE_BUCKET, objectName(), await stream())
|
||||
}
|
||||
|
||||
function filterAttachments(messages, guild, channel) {
|
||||
const acceptedContentTypes = [
|
||||
...IMAGE_TYPES,
|
||||
...VIDEO_TYPES,
|
||||
]
|
||||
|
||||
return Promise.all(messages
|
||||
.filter(m => m.attachments)
|
||||
// bundle message and attachment together
|
||||
.map(m => m.attachments.map(a => [a, m]))
|
||||
.flat()
|
||||
.filter(([a, m]) => acceptedContentTypes.includes(a.content_type))
|
||||
.map(async ([a, m]) => {
|
||||
const attachment: Attachment = {
|
||||
id: BigInt(a.id),
|
||||
msgId: BigInt(m.id),
|
||||
channelId: BigInt(m.channel_id),
|
||||
url: a.url,
|
||||
contentType: a.content_type,
|
||||
message: m,
|
||||
|
||||
// create this ahead of time to associate with our copy of the media
|
||||
memeId: crypto.randomUUID(),
|
||||
|
||||
// @ts-ignore it's fine
|
||||
vision: {},
|
||||
guild,
|
||||
channel,
|
||||
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined
|
||||
}
|
||||
|
||||
const visionData = await detectText(attachment)
|
||||
return { ...attachment, vision: visionData }
|
||||
}))
|
||||
}
|
||||
|
||||
async function getChannelAttachments(channelId) {
|
||||
try {
|
||||
const channel = await fetchChannel(channelId)
|
||||
const guild = await fetchGuildDetails(channel.guild_id)
|
||||
const messages = await fetchChannelMessages(channelId)
|
||||
|
||||
return filterAttachments(messages, guild, channel);
|
||||
|
||||
// return { messages, channel, guild }
|
||||
} catch (e) {
|
||||
console.error('scrape error', {
|
||||
err: e.toJSON(),
|
||||
channelId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getChannelAttachments,
|
||||
downloadAttachment,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// async def download_attachment(attachment: Attachment):
|
||||
// async with http_session.get(attachment.url) as response:
|
||||
// if response.status == 200:
|
||||
// (filename, ext) = attachment.file_details()
|
||||
// object_name = attachment.object_name()
|
||||
|
||||
// print(f"uploading object_name={object_name} filename={filename} ext={ext}")
|
||||
// res_bytes = await response.read()
|
||||
|
||||
// # make a webp conversion for obvious image types
|
||||
// if attachment.convert_to_webp():
|
||||
// # have to read the whole img before converting to webp
|
||||
// image = Image.open(BytesIO(res_bytes))
|
||||
// webp_output = BytesIO()
|
||||
// image.save(webp_output, format='WebP')
|
||||
// webp_output.seek(0)
|
||||
// return await storage.put_object(Bucket=STORAGE_BUCKET, Key=object_name, Body=webp_output)
|
||||
|
||||
// return await storage.put_object(Bucket=STORAGE_BUCKET, Key=object_name, Body=res_bytes)
|
||||
|
||||
// def create_attachments(message, channel, guild):
|
||||
// urls = [(parse_url(attachment['url']), attachment['id']) for attachment in message['attachments']]
|
||||
|
||||
// def generate_attachment(url: Url, attachmentId: str):
|
||||
// if not url.path:
|
||||
// return None
|
||||
|
||||
// (filename, ext) = os.path.splitext(os.path.basename(url.path))
|
||||
// extensions = ['jpg', 'png', 'jpeg', 'webp', 'mov', 'gif', 'mp4']
|
||||
// if ext.lower()[1:] not in extensions:
|
||||
// return None
|
||||
|
||||
// return Attachment(
|
||||
// id=uuid4(),
|
||||
// attachmentId=int(attachmentId),
|
||||
// msgId=int(message['id']),
|
||||
// message=message,
|
||||
// guild=guild,
|
||||
// url=url.url,
|
||||
// channel=channel
|
||||
// )
|
||||
|
||||
// return [generate_attachment(*url) for url in urls]
|
||||
@@ -0,0 +1,6 @@
|
||||
import "dotenv/config"
|
||||
|
||||
import createServices from "./services"
|
||||
const { memes } = await createServices()
|
||||
await memes.resetMeili();
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import base58 from "bs58";
|
||||
import "dotenv/config"
|
||||
import * as helpers from "@solana-developers/helpers";
|
||||
import * as web3 from "@solana/web3.js";
|
||||
|
||||
const emoKey = helpers.getKeypairFromEnvironment("EMO");
|
||||
const emoAddr = new web3.PublicKey(emoKey.publicKey);
|
||||
|
||||
const payer = emoKey
|
||||
const connection = new web3.Connection(web3.clusterApiUrl("devnet"));
|
||||
|
||||
const PING_PROGRAM_ADDRESS = new web3.PublicKey('ChT1B39WKLS8qUrkLvFDXMhEJ4F1XZzwUNHUt4AU9aVa')
|
||||
const PING_PROGRAM_DATA_ADDRESS = new web3.PublicKey('Ah9K7dQ8EHaZqcAsgBW8w37yN2eAy3koFmUn4x3CJtod')
|
||||
|
||||
const transaction = new web3.Transaction()
|
||||
const programId = new web3.PublicKey(PING_PROGRAM_ADDRESS)
|
||||
const pingProgramDataId = new web3.PublicKey(PING_PROGRAM_DATA_ADDRESS)
|
||||
|
||||
|
||||
// const instruction = new web3.TransactionInstruction({
|
||||
// keys: [
|
||||
// {
|
||||
// pubkey: pingProgramDataId,
|
||||
// isSigner: false,
|
||||
// isWritable: true
|
||||
// },
|
||||
// ],
|
||||
// programId
|
||||
// });
|
||||
|
||||
// transaction.add(instruction)
|
||||
|
||||
// const signature = await web3.sendAndConfirmTransaction(
|
||||
// connection,
|
||||
// transaction,
|
||||
// [payer]
|
||||
// )
|
||||
|
||||
// console.log(`✅ Transaction completed! Signature is ${signature}`)
|
||||
Reference in new issue
Block a user