7 Commits

110 changed files with 4985 additions and 1562 deletions
-4
View File
@@ -1,4 +0,0 @@
FROM nginx:1.27.1-alpine
COPY default.conf /etc/nginx/conf.d/default.conf
-37
View File
@@ -1,37 +0,0 @@
server {
listen 80;
server_name localhost;
root /var/www/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
index index.html index.htm index.php;
charset utf-8;
location / {
root /var/www/;
try_files /public/$uri /public/$uri /assets/$uri /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 421 422 423 424 425 426 428 429 431 451 500 501 502 503 504 505 506 507 508 510 511 /error.html;
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
-20
View File
@@ -1,20 +0,0 @@
FROM php:8.4-fpm-alpine
# Install dependencies
RUN apk --no-cache add curl git wget bash dpkg
# Add PHP extensions
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN chmod +x /usr/local/bin/install-php-extensions
RUN install-php-extensions opcache iconv soap
RUN install-php-extensions zip intl fileinfo
RUN install-php-extensions pdo redis mysqli pdo_mysql
RUN install-php-extensions gd
RUN install-php-extensions pgsql pdo_pgsql
# Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer
WORKDIR /var/www
+40
View File
@@ -0,0 +1,40 @@
# api
BASANGO_API_HOST=localhost
BASANGO_API_PORT=3080
BASANGO_API_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
BASANGO_API_KEY=your_api_key_here
BASANGO_API_CRAWLER_TOKEN=dev
BASANGO_API_CRAWLER_ENDPOINT="http://localhost:3080"
BASANGO_API_JWT_SECRET=your_jwt_secret_here
# db
BASANGO_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/app?serverVersion=16&charset=utf8"
BASANGO_DATABASE_LEGACY_HOST="localhost"
BASANGO_DATABASE_LEGACY_PASSWORD="root"
BASANGO_DATABASE_LEGACY_NAME="app"
BASANGO_DATABASE_LEGACY_USER="root"
BASANGO_DATABASE_LEGACY_PORT=3306
# logger
BASANGO_LOGGER_LEVEL=debug
# crawler
BASANGO_CRAWLER_ROOT_PATH=
BASANGO_CRAWLER_DATA_PATH=
BASANGO_CRAWLER_LOGS_PATH=
BASANGO_CRAWLER_CONFIG_PATH=
BASANGO_CRAWLER_UPDATE_DIRECTION=forward
BASANGO_CRAWLER_FETCH_USER_AGENT="Basango/0.1 (+https://github.com/bernard-ng/basango)"
BASANGO_CRAWLER_FETCH_MAX_RETRIES=3
BASANGO_CRAWLER_FETCH_RESPECT_RETRY_AFTER=true
BASANGO_CRAWLER_ASYNC_REDIS_URL="redis://localhost:6379/0"
BASANGO_CRAWLER_ASYNC_TTL_RESULT=3600
BASANGO_CRAWLER_ASYNC_TTL_FAILURE=3600
BASANGO_CRAWLER_ASYNC_QUEUE_LISTING="listing"
BASANGO_CRAWLER_ASYNC_QUEUE_DETAILS="details"
BASANGO_CRAWLER_ASYNC_QUEUE_PROCESSING="processing"
# encryption
BASANGO_ENCRYPTION_KEY=testkey
+16 -20
View File
File diff suppressed because one or more lines are too long
-7
View File
@@ -1,7 +0,0 @@
NODE_ENV=development
BASANGO_API_HOST=localhost
BASANGO_API_PORT=3080
BASANGO_API_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
BASANGO_API_KEY=your_api_key_here
BASANGO_CRAWLER_TOKEN=dev
BASANGO_JWT_SECRET=your_jwt_secret_here
-16
View File
@@ -1,16 +0,0 @@
{
"cors": {
"allowedHeaders": [
"Authorization",
"Content-Type",
"accept-language",
"x-trpc-source",
"x-user-locale",
"x-user-timezone",
"x-user-country"
],
"allowMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
"exposeHeaders": ["Content-Length"],
"maxAge": 86400
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"server": {
"host": "%env(BASANGO_API_HOST)%",
"port": "%env(number:BASANGO_API_PORT)%",
"version": "1.0.0"
}
}
-3
View File
@@ -4,13 +4,10 @@
"@basango/domain": "workspace:*", "@basango/domain": "workspace:*",
"@basango/encryption": "workspace:*", "@basango/encryption": "workspace:*",
"@basango/logger": "workspace:*", "@basango/logger": "workspace:*",
"@devscast/config": "catalog:",
"@hono/node-server": "^1.19.6", "@hono/node-server": "^1.19.6",
"@hono/trpc-server": "^0.4.0", "@hono/trpc-server": "^0.4.0",
"@hono/zod-openapi": "^1.1.4", "@hono/zod-openapi": "^1.1.4",
"@scalar/hono-api-reference": "^0.9.24",
"@trpc/server": "^11.7.1", "@trpc/server": "^11.7.1",
"ai": "^5.0.89",
"camelcase-keys": "^10.0.1", "camelcase-keys": "^10.0.1",
"date-fns": "catalog:", "date-fns": "catalog:",
"hono-rate-limiter": "^0.4.2", "hono-rate-limiter": "^0.4.2",
-45
View File
@@ -1,45 +0,0 @@
import path from "node:path";
import { loadConfig as defineConfig } from "@devscast/config";
import { z } from "zod";
export const PROJECT_DIR = path.resolve(__dirname, "../");
const ServerConfigurationSchema = z.object({
cors: z.object({
allowedHeaders: z.array(z.string()).optional(),
allowMethods: z.array(z.string()).optional(),
exposeHeaders: z.array(z.string()).optional(),
maxAge: z.number().int().min(0).optional(),
origin: z
.array(z.string())
.optional()
.default(["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"]),
}),
server: z.object({
host: z.string().default("localhost"),
port: z.number().int().min(1).max(65535).default(4000),
version: z.string().default("1.0.0"),
}),
});
export const { env, config } = defineConfig({
env: {
knownKeys: [
"BASANGO_API_HOST",
"BASANGO_API_PORT",
"BASANGO_API_ALLOWED_ORIGINS",
"BASANGO_API_KEY",
"BASANGO_CRAWLER_TOKEN",
"BASANGO_JWT_SECRET",
],
path: path.join(PROJECT_DIR, ".env"),
},
schema: ServerConfigurationSchema,
sources: [
path.join(PROJECT_DIR, "config", "server.json"),
path.join(PROJECT_DIR, "config", "cors.json"),
],
});
export type ServerConfiguration = z.infer<typeof ServerConfigurationSchema>;
+8 -48
View File
@@ -1,11 +1,10 @@
import { config } from "@basango/domain/config";
import { trpcServer } from "@hono/trpc-server"; import { trpcServer } from "@hono/trpc-server";
import { OpenAPIHono } from "@hono/zod-openapi"; import { OpenAPIHono } from "@hono/zod-openapi";
import { Scalar } from "@scalar/hono-api-reference";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import { secureHeaders } from "hono/secure-headers"; import { secureHeaders } from "hono/secure-headers";
import { config, env } from "#api/config";
import { routers } from "#api/rest/routers"; import { routers } from "#api/rest/routers";
import { createTRPCContext } from "#api/trpc/init"; import { createTRPCContext } from "#api/trpc/init";
import { appRouter } from "#api/trpc/routers/_app"; import { appRouter } from "#api/trpc/routers/_app";
@@ -18,11 +17,11 @@ app.use(secureHeaders());
app.use( app.use(
"*", "*",
cors({ cors({
allowHeaders: config.cors.allowedHeaders, allowHeaders: config.api.cors.allowedHeaders,
allowMethods: config.cors.allowMethods, allowMethods: config.api.cors.allowMethods,
exposeHeaders: config.cors.exposeHeaders, exposeHeaders: config.api.cors.exposeHeaders,
maxAge: config.cors.maxAge, maxAge: config.api.cors.maxAge,
origin: ["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"], origin: config.api.cors.origin,
}), }),
); );
@@ -34,49 +33,10 @@ app.use(
}), }),
); );
app.doc("/openapi", {
info: {
contact: {
email: "engineering@basango.io",
name: "Basango",
url: "https://basango.io",
},
description: "Basango is a platform that leverages AI to revolutionize news curation.",
license: {
name: "AGPL-3.0 license",
url: "https://github.com/bernard-ng/basango/blob/main/LICENSE",
},
title: "Basango API",
version: "0.0.1",
},
openapi: "3.1.0",
security: [
{
oauth2: [],
},
{ token: [] },
],
servers: [
{
description: "Production API",
url: "https://api.basango.io",
},
],
});
// Register security scheme
app.openAPIRegistry.registerComponent("securitySchemes", "token", {
description: "Default authentication mechanism",
scheme: "bearer",
type: "http",
"x-speakeasy-example": env("BASANGO_API_KEY"),
});
app.get("/", Scalar({ pageTitle: "Basango API", theme: "saturn", url: "/openapi" }));
app.route("/", routers); app.route("/", routers);
export default { export default {
fetch: app.fetch, fetch: app.fetch,
hostname: config.server.host, hostname: config.api.server.host,
port: config.server.port, port: config.api.server.port,
}; };
+2 -3
View File
@@ -1,8 +1,7 @@
import { config } from "@basango/domain/config";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import { env } from "#api/config";
export const withCrawlerAuth: MiddlewareHandler = async (c, next) => { export const withCrawlerAuth: MiddlewareHandler = async (c, next) => {
const token = c.req.header("Authorization"); const token = c.req.header("Authorization");
@@ -10,7 +9,7 @@ export const withCrawlerAuth: MiddlewareHandler = async (c, next) => {
throw new HTTPException(401, { message: "Authorization header required" }); throw new HTTPException(401, { message: "Authorization header required" });
} }
if (token !== env("BASANGO_CRAWLER_TOKEN")) { if (token !== config.api.security.crawlerToken) {
throw new HTTPException(403, { message: "Invalid token" }); throw new HTTPException(403, { message: "Invalid token" });
} }
+2
View File
@@ -1,9 +1,11 @@
import { OpenAPIHono } from "@hono/zod-openapi"; import { OpenAPIHono } from "@hono/zod-openapi";
import { articlesRouter } from "#api/rest/routers/articles"; import { articlesRouter } from "#api/rest/routers/articles";
import { sourcesRouter } from "#api/rest/routers/sources";
const routers: OpenAPIHono = new OpenAPIHono(); const routers: OpenAPIHono = new OpenAPIHono();
routers.route("/articles", articlesRouter); routers.route("/articles", articlesRouter);
routers.route("/sources", sourcesRouter);
export { routers }; export { routers };
+58
View File
@@ -0,0 +1,58 @@
import { getEarliestPublished, getLatestPublished } from "@basango/db/queries";
import {
getSourceUpdateDatesResponseSchema,
getSourceUpdateDatesSchema,
} from "@basango/domain/models";
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import type { Context } from "#api/rest/init";
import { withCrawlerAuth } from "#api/rest/middlewares/crawler";
import { withDatabase } from "#api/rest/middlewares/db";
import { validateResponse } from "#api/utils/response";
const app = new OpenAPIHono<Context>();
app.openapi(
createRoute({
description: "Get the latest and earliest published dates for articles from a specific source.",
method: "post",
middleware: [withCrawlerAuth, withDatabase],
operationId: "GetSourceUpdateDates",
path: "/update-dates",
request: {
body: {
content: {
"application/json": {
schema: getSourceUpdateDatesSchema,
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: getSourceUpdateDatesResponseSchema,
},
},
description: "Source update dates retrieved",
},
},
summary: "Get Source Update Dates",
tags: ["Sources"],
"x-speakeasy-name-override": "getSourceUpdateDates",
}),
async (c) => {
const db = c.get("db");
const input = c.req.valid("json");
const [latest, earliest] = await Promise.all([
getLatestPublished(db, input.name),
getEarliestPublished(db, input.name),
]);
return c.json(validateResponse({ earliest, latest }, getSourceUpdateDatesResponseSchema), 200);
},
);
export const sourcesRouter = app;
+2
View File
@@ -3,12 +3,14 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import { createTRPCRouter } from "#api/trpc/init"; import { createTRPCRouter } from "#api/trpc/init";
import { articlesRouter } from "#api/trpc/routers/articles"; import { articlesRouter } from "#api/trpc/routers/articles";
import { authRouter } from "#api/trpc/routers/auth"; import { authRouter } from "#api/trpc/routers/auth";
import { categoriesRouter } from "#api/trpc/routers/categories";
import { reportsRouter } from "#api/trpc/routers/reports"; import { reportsRouter } from "#api/trpc/routers/reports";
import { sourcesRouter } from "#api/trpc/routers/sources"; import { sourcesRouter } from "#api/trpc/routers/sources";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
articles: articlesRouter, articles: articlesRouter,
auth: authRouter, auth: authRouter,
categories: categoriesRouter,
reports: reportsRouter, reports: reportsRouter,
sources: sourcesRouter, sources: sourcesRouter,
}); });
+1 -1
View File
@@ -13,7 +13,7 @@ export const authRouter = createTRPCRouter({
if (!user || user.isLocked) { if (!user || user.isLocked) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid credentials.", message: "Account is locked",
}); });
} }
+7
View File
@@ -0,0 +1,7 @@
import { getCategories } from "@basango/db/queries";
import { createTRPCRouter, protectedProcedure } from "#api/trpc/init";
export const categoriesRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => getCategories(ctx.db)),
});
+10 -17
View File
@@ -1,15 +1,8 @@
import { Database } from "@basango/db/client"; import { Database } from "@basango/db/client";
import { getUserById } from "@basango/db/queries"; import { getUserById } from "@basango/db/queries";
import { import { config } from "@basango/domain/config";
DEFAULT_ACCESS_TOKEN_TTL,
DEFAULT_REFRESH_TOKEN_TTL,
DEFAULT_TOKEN_AUDIENCE,
DEFAULT_TOKEN_ISSUER,
} from "@basango/domain/constants";
import { type JWTPayload, SignJWT, jwtVerify } from "jose"; import { type JWTPayload, SignJWT, jwtVerify } from "jose";
import { env } from "#api/config";
export type Session = { export type Session = {
user: { user: {
id: string; id: string;
@@ -39,7 +32,7 @@ export type SessionTokens = {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
function getSecretKey() { function getSecretKey() {
return encoder.encode(env("BASANGO_JWT_SECRET")); return encoder.encode(config.api.security.jwtSecret);
} }
export async function getSession(db: Database, accessToken?: string): Promise<Session | null> { export async function getSession(db: Database, accessToken?: string): Promise<Session | null> {
@@ -74,24 +67,24 @@ async function createToken(session: Session, tokenType: TokenType, expiresIn: st
}) })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setIssuedAt() .setIssuedAt()
.setAudience(DEFAULT_TOKEN_AUDIENCE) .setAudience(config.api.security.audience)
.setIssuer(DEFAULT_TOKEN_ISSUER) .setIssuer(config.api.security.issuer)
.setExpirationTime(expiresIn) .setExpirationTime(expiresIn)
.sign(getSecretKey()); .sign(getSecretKey());
} }
export async function createSessionTokens(session: Session): Promise<SessionTokens> { export async function createSessionTokens(session: Session): Promise<SessionTokens> {
const [accessToken, refreshToken] = await Promise.all([ const [accessToken, refreshToken] = await Promise.all([
createToken(session, "access", DEFAULT_ACCESS_TOKEN_TTL), createToken(session, "access", config.api.security.accessTokenTtl),
createToken(session, "refresh", DEFAULT_REFRESH_TOKEN_TTL), createToken(session, "refresh", config.api.security.refreshTokenTtl),
]); ]);
const issuedAt = Date.now(); const issuedAt = Date.now();
const accessTokenExpiresAt = new Date( const accessTokenExpiresAt = new Date(
issuedAt + formatTTL(DEFAULT_ACCESS_TOKEN_TTL), issuedAt + formatTTL(config.api.security.accessTokenTtl),
).toISOString(); ).toISOString();
const refreshTokenExpiresAt = new Date( const refreshTokenExpiresAt = new Date(
issuedAt + formatTTL(DEFAULT_REFRESH_TOKEN_TTL), issuedAt + formatTTL(config.api.security.refreshTokenTtl),
).toISOString(); ).toISOString();
return { return {
@@ -118,8 +111,8 @@ async function verifyToken(
try { try {
const { payload } = await jwtVerify<VerifiedJWTPayload>(token, getSecretKey(), { const { payload } = await jwtVerify<VerifiedJWTPayload>(token, getSecretKey(), {
audience: DEFAULT_TOKEN_AUDIENCE, audience: config.api.security.audience,
issuer: DEFAULT_TOKEN_ISSUER, issuer: config.api.security.issuer,
}); });
if (payload.tokenType !== expectedType) { if (payload.tokenType !== expectedType) {
-21
View File
@@ -1,21 +0,0 @@
# paths
BASANGO_CRAWLER_ROOT_PATH=
BASANGO_CRAWLER_DATA_PATH=
BASANGO_CRAWLER_LOGS_PATH=
BASANGO_CRAWLER_CONFIG_PATH=
# crawler settings
BASANGO_CRAWLER_UPDATE_DIRECTION=forward
BASANGO_CRAWLER_FETCH_USER_AGENT="Basango/0.1 (+https://github.com/bernard-ng/basango)"
BASANGO_CRAWLER_FETCH_MAX_RETRIES=3
BASANGO_CRAWLER_FETCH_RESPECT_RETRY_AFTER=true
BASANGO_CRAWLER_ASYNC_REDIS_URL="redis://localhost:6379/0"
BASANGO_CRAWLER_ASYNC_TTL_RESULT=3600
BASANGO_CRAWLER_ASYNC_TTL_FAILURE=3600
BASANGO_CRAWLER_ASYNC_QUEUE_LISTING="listing"
BASANGO_CRAWLER_ASYNC_QUEUE_DETAILS="details"
BASANGO_CRAWLER_ASYNC_QUEUE_PROCESSING="processing"
BASANGO_CRAWLER_TOKEN="dev"
BASANGO_CRAWLER_BACKEND_API_ENDPOINT="http://localhost:3080/articles"
-41
View File
@@ -1,41 +0,0 @@
{
"fetch": {
"async": {
"prefix": "basango:crawler",
"queues": {
"details": "%env(BASANGO_CRAWLER_ASYNC_QUEUE_DETAILS)%",
"listing": "%env(BASANGO_CRAWLER_ASYNC_QUEUE_LISTING)%",
"processing": "%env(BASANGO_CRAWLER_ASYNC_QUEUE_PROCESSING)%"
},
"redisUrl": "%env(BASANGO_CRAWLER_ASYNC_REDIS_URL)%",
"ttl": {
"default": 600,
"failure": "%env(number:BASANGO_CRAWLER_ASYNC_TTL_FAILURE)%",
"result": "%env(number:BASANGO_CRAWLER_ASYNC_TTL_RESULT)%"
}
},
"client": {
"backoffInitial": 1,
"backoffMax": 30,
"backoffMultiplier": 2,
"followRedirects": true,
"maxRetries": "%env(number:BASANGO_CRAWLER_FETCH_MAX_RETRIES)%",
"respectRetryAfter": "%env(boolean:BASANGO_CRAWLER_FETCH_RESPECT_RETRY_AFTER)%",
"rotate": true,
"timeout": 20,
"userAgent": "%env(BASANGO_CRAWLER_FETCH_USER_AGENT)%",
"verifySsl": true
},
"crawler": {
"direction": "%env(BASANGO_CRAWLER_UPDATE_DIRECTION)%",
"maxWorkers": 5,
"notify": false,
"useMultiThreading": false
}
},
"paths": {
"config": "%env(BASANGO_CRAWLER_CONFIG_PATH)%",
"data": "%env(BASANGO_CRAWLER_DATA_PATH)%",
"root": "%env(BASANGO_CRAWLER_ROOT_PATH)%"
}
}
-210
View File
@@ -1,210 +0,0 @@
{
"sources": {
"html": [
{
"paginationTemplate": "actualite",
"requiresDetails": true,
"requiresRateLimit": false,
"sourceDate": {},
"sourceId": "radiookapi.net",
"sourceKind": "html",
"sourceSelectors": {
"articleBody": ".field-name-body",
"articleCategories": ".views-field-field-cat-gorie a",
"articleDate": "head > meta[property=\"article:published_time\"]",
"articleLink": ".views-field-title a",
"articles": ".view-content > .views-row.content-row",
"articleTitle": "h1.page-header",
"pagination": "ul.pagination > li.pager-last > a"
},
"sourceUrl": "https://www.radiookapi.net",
"supportsCategories": false
},
{
"categories": ["politique", "economie", "culture", "sport", "societe"],
"paginationTemplate": "index.php/category/{category}",
"requiresDetails": true,
"requiresRateLimit": false,
"sourceDate": {},
"sourceId": "7sur7.cd",
"sourceKind": "html",
"sourceSelectors": {
"articleBody": "div[property=\"schema:text\"].field.field--name-body",
"articleDate": "head > meta[property=\"article:published_time\"]",
"articleLink": ".views-field-title a",
"articles": ".view-content > .row.views-row",
"articleTitle": ".views-field-title a",
"pagination": "ul.pagination > li.pager__item.pager__item--last > a"
},
"sourceUrl": "https://7sur7.cd",
"supportsCategories": true
},
{
"paginationTemplate": "articles.html",
"requiresDetails": true,
"requiresRateLimit": false,
"sourceDate": {
"format": "dd.MM.yyyy"
},
"sourceId": "mediacongo.net",
"sourceKind": "html",
"sourceSelectors": {
"articleBody": ".article_ttext",
"articleCategories": "a.color_link",
"articleDate": ".article_other_about",
"articleLink": "a:first-child",
"articles": ".for_aitems > .article_other_item",
"articleTitle": "h1",
"pagination": "div.pagination > div > a:last-child"
},
"sourceUrl": "https://www.mediacongo.net",
"supportsCategories": false
},
{
"paginationTemplate": "actualite",
"requiresDetails": true,
"requiresRateLimit": false,
"sourceDate": {},
"sourceId": "actualite.cd",
"sourceKind": "html",
"sourceSelectors": {
"articleBody": ".views-field.views-field-body .field-content",
"articleCategories": "#actu-cat",
"articleDate": "head > meta[property=\"article:published_time\"]",
"articleLink": "#actu-titre a",
"articles": "#views-bootstrap-taxonomy-term-page-2 > div > div",
"articleTitle": "h1.page-title"
},
"sourceUrl": "https://actualite.cd",
"supportsCategories": false
}
],
"wordpress": [
{
"requiresRateLimit": true,
"sourceId": "beto.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://beto.cd"
},
{ "sourceId": "newscd.net", "sourceKind": "wordpress", "sourceUrl": "https://newscd.net" },
{
"sourceId": "africanewsrdc.net",
"sourceKind": "wordpress",
"sourceUrl": "https://www.africanewsrdc.net"
},
{
"sourceId": "angazainstitute.ac.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://angazainstitute.ac.cd"
},
{ "sourceId": "b-onetv.cd", "sourceKind": "wordpress", "sourceUrl": "https://b-onetv.cd" },
{
"sourceId": "bukavufm.com",
"sourceKind": "wordpress",
"sourceUrl": "https://bukavufm.com"
},
{
"sourceId": "changement7.net",
"sourceKind": "wordpress",
"sourceUrl": "https://changement7.net"
},
{
"sourceId": "congoactu.net",
"sourceKind": "wordpress",
"sourceUrl": "https://congoactu.net"
},
{
"sourceId": "congoindependant.com",
"sourceKind": "wordpress",
"sourceUrl": "https://www.congoindependant.com"
},
{
"sourceId": "congoquotidien.com",
"sourceKind": "wordpress",
"sourceUrl": "https://www.congoquotidien.com"
},
{
"sourceId": "cumulard.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://www.cumulard.cd"
},
{
"sourceId": "environews-rdc.net",
"sourceKind": "wordpress",
"sourceUrl": "https://environews-rdc.net"
},
{
"sourceId": "freemediardc.info",
"sourceKind": "wordpress",
"sourceUrl": "https://www.freemediardc.info"
},
{
"sourceId": "geopolismagazine.org",
"sourceKind": "wordpress",
"sourceUrl": "https://geopolismagazine.org"
},
{
"sourceId": "habarirdc.net",
"sourceKind": "wordpress",
"sourceUrl": "https://habarirdc.net"
},
{ "sourceId": "infordc.com", "sourceKind": "wordpress", "sourceUrl": "https://infordc.com" },
{
"sourceId": "kilalopress.net",
"sourceKind": "wordpress",
"sourceUrl": "https://kilalopress.net"
},
{
"sourceId": "laprosperiteonline.net",
"sourceKind": "wordpress",
"sourceUrl": "https://laprosperiteonline.net"
},
{
"sourceId": "laprunellerdc.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://laprunellerdc.cd"
},
{
"sourceId": "lesmedias.net",
"sourceKind": "wordpress",
"sourceUrl": "https://lesmedias.net"
},
{
"sourceId": "lesvolcansnews.net",
"sourceKind": "wordpress",
"sourceUrl": "https://lesvolcansnews.net"
},
{
"sourceId": "netic-news.net",
"sourceKind": "wordpress",
"sourceUrl": "https://www.netic-news.net"
},
{
"sourceId": "objectif-infos.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://objectif-infos.cd"
},
{
"sourceId": "scooprdc.net",
"sourceKind": "wordpress",
"sourceUrl": "https://scooprdc.net"
},
{
"sourceId": "journaldekinshasa.com",
"sourceKind": "wordpress",
"sourceUrl": "https://www.journaldekinshasa.com"
},
{
"sourceId": "lepotentiel.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://lepotentiel.cd"
},
{ "sourceId": "acturdc.com", "sourceKind": "wordpress", "sourceUrl": "https://acturdc.com" },
{
"sourceId": "matininfos.net",
"sourceKind": "wordpress",
"sourceUrl": "https://matininfos.net"
}
]
}
}
-81
View File
@@ -1,81 +0,0 @@
import path from "node:path";
import {
HtmlSourceConfigSchema,
PageRangeSchema,
TimestampRangeSchema,
UpdateDirectionSchema,
WordPressSourceConfigSchema,
} from "@basango/domain/crawler";
import { loadConfig as defineConfig } from "@devscast/config";
import { z } from "zod";
export const PROJECT_DIR = path.resolve(__dirname, "../");
export const PipelineConfigSchema = z.object({
fetch: z.object({
async: z.object({
prefix: z.string().default("basango:crawler:queue"),
queues: z.object({
details: z.string().default("details"),
listing: z.string().default("listing"),
processing: z.string().default("processing"),
}),
redisUrl: z.string().default("redis://localhost:6379/0"),
ttl: z.object({
default: z.number().int().positive().default(600),
failure: z.number().int().nonnegative().default(3600),
result: z.number().int().nonnegative().default(3600),
}),
}),
client: z.object({
backoffInitial: z.number().nonnegative().default(1),
backoffMax: z.number().nonnegative().default(30),
backoffMultiplier: z.number().positive().default(2),
followRedirects: z.boolean().default(true),
maxRetries: z.number().int().nonnegative().default(3),
respectRetryAfter: z.boolean().default(true),
rotate: z.boolean().default(true),
timeout: z.number().positive().default(20),
userAgent: z.string().default("Basango/0.1 (+https://github.com/bernard-ng/basango)"),
verifySsl: z.boolean().default(true),
}),
crawler: z.object({
category: z.string().optional(),
dateRange: TimestampRangeSchema.optional(),
direction: UpdateDirectionSchema.default("forward"),
isUpdate: z.boolean().default(false),
maxWorkers: z.number().int().positive().default(5),
notify: z.boolean().default(false),
pageRange: PageRangeSchema.optional(),
source: z.union([HtmlSourceConfigSchema, WordPressSourceConfigSchema]).optional(),
useMultiThreading: z.boolean().default(false),
}),
}),
paths: z.object({
config: z.string().default(path.join(PROJECT_DIR, "config")),
data: z.string().default(path.join(PROJECT_DIR, "data", "datasets")),
root: z.string().default(PROJECT_DIR),
}),
sources: z.object({
html: z.array(HtmlSourceConfigSchema).default([]),
wordpress: z.array(WordPressSourceConfigSchema).default([]),
}),
});
export const { config, env } = defineConfig({
cwd: process.cwd(),
env: {
path: path.join(PROJECT_DIR, ".env"),
},
schema: PipelineConfigSchema,
sources: [
path.join(PROJECT_DIR, "config", "pipeline.json"),
path.join(PROJECT_DIR, "config", "sources.json"),
],
});
export type PipelineConfig = z.infer<typeof PipelineConfigSchema>;
export type FetchClientConfig = PipelineConfig["fetch"]["client"];
export type FetchCrawlerConfig = PipelineConfig["fetch"]["crawler"];
export type FetchAsyncConfig = PipelineConfig["fetch"]["async"];
+20 -20
View File
@@ -1,12 +1,12 @@
import { setTimeout as delay } from "node:timers/promises"; import { setTimeout as delay } from "node:timers/promises";
import type { CrawlerHttpOptions } from "@basango/domain/config";
import { import {
DEFAULT_RETRY_AFTER_HEADER, DEFAULT_RETRY_AFTER_HEADER,
DEFAULT_TRANSIENT_HTTP_STATUSES, DEFAULT_TRANSIENT_HTTP_STATUSES,
DEFAULT_USER_AGENT, DEFAULT_USER_AGENT,
} from "@basango/domain/constants"; } from "@basango/domain/constants";
import { FetchClientConfig } from "#crawler/config";
import { UserAgents } from "#crawler/http/user-agent"; import { UserAgents } from "#crawler/http/user-agent";
export type HttpHeaders = Record<string, string>; export type HttpHeaders = Record<string, string>;
@@ -71,7 +71,7 @@ const buildUrl = (url: string, params?: HttpParams): string => {
* @param config - Fetch client configuration * @param config - Fetch client configuration
* @param attempt - Current attempt number * @param attempt - Current attempt number
*/ */
const computeBackoff = (config: FetchClientConfig, attempt: number): number => { const computeBackoff = (config: CrawlerHttpOptions, attempt: number): number => {
const base = Math.min( const base = Math.min(
config.backoffInitial * config.backoffMultiplier ** attempt, config.backoffInitial * config.backoffMultiplier ** attempt,
config.backoffMax, config.backoffMax,
@@ -101,26 +101,26 @@ const parseRetryAfter = (header: string): number => {
* @author Bernard Ngandu <bernard@devscast.tech> * @author Bernard Ngandu <bernard@devscast.tech>
*/ */
export class BaseHttpClient { export class BaseHttpClient {
protected readonly config: FetchClientConfig; protected readonly options: CrawlerHttpOptions;
protected readonly fetchImpl: typeof fetch; protected readonly fetchImpl: typeof fetch;
protected readonly sleep: (ms: number) => Promise<void>; protected readonly sleep: (ms: number) => Promise<void>;
protected readonly headers: HttpHeaders; protected readonly headers: HttpHeaders;
constructor(config: FetchClientConfig, options: HttpClientOptions = {}) { constructor(options: CrawlerHttpOptions, clientOptions: HttpClientOptions = {}) {
this.config = config; this.options = options;
const provider = const provider =
options.userAgentProvider ?? clientOptions.userAgentProvider ??
new UserAgents(config.rotate, config.userAgent ?? DEFAULT_USER_AGENT); new UserAgents(options.rotate, options.userAgent ?? DEFAULT_USER_AGENT);
const userAgent = provider.get() ?? config.userAgent ?? DEFAULT_USER_AGENT; const userAgent = provider.get() ?? options.userAgent ?? DEFAULT_USER_AGENT;
const baseHeaders: HttpHeaders = { "User-Agent": userAgent }; const baseHeaders: HttpHeaders = { "User-Agent": userAgent };
if (options.defaultHeaders) { if (clientOptions.defaultHeaders) {
Object.assign(baseHeaders, options.defaultHeaders); Object.assign(baseHeaders, clientOptions.defaultHeaders);
} }
this.headers = baseHeaders; this.headers = baseHeaders;
this.fetchImpl = options.fetchImpl ?? fetch; this.fetchImpl = clientOptions.fetchImpl ?? fetch;
this.sleep = options.sleep ?? defaultSleep; this.sleep = clientOptions.sleep ?? defaultSleep;
} }
protected buildHeaders(headers?: HttpHeaders): HeadersInit { protected buildHeaders(headers?: HttpHeaders): HeadersInit {
@@ -136,13 +136,13 @@ export class BaseHttpClient {
if (response) { if (response) {
const retryAfter = response.headers.get(retryAfterHeader); const retryAfter = response.headers.get(retryAfterHeader);
if (retryAfter && this.config.respectRetryAfter) { if (retryAfter && this.options.respectRetryAfter) {
waitMs = parseRetryAfter(retryAfter); waitMs = parseRetryAfter(retryAfter);
} }
} }
if (waitMs === 0) { if (waitMs === 0) {
waitMs = computeBackoff(this.config, attempt); waitMs = computeBackoff(this.options, attempt);
} }
if (waitMs > 0) { if (waitMs > 0) {
@@ -161,7 +161,7 @@ export class SyncHttpClient extends BaseHttpClient {
const retryAfterHeader = options.retryAfterHeader ?? DEFAULT_RETRY_AFTER_HEADER; const retryAfterHeader = options.retryAfterHeader ?? DEFAULT_RETRY_AFTER_HEADER;
const target = buildUrl(url, options.params); const target = buildUrl(url, options.params);
const maxAttempts = this.config.maxRetries + 1; const maxAttempts = this.options.maxRetries + 1;
let attempt = 0; let attempt = 0;
let lastError: unknown; let lastError: unknown;
@@ -169,14 +169,14 @@ export class SyncHttpClient extends BaseHttpClient {
const controller = new AbortController(); const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | undefined; let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
try { try {
timeoutHandle = setTimeout(() => controller.abort(), this.config.timeout * 1000); timeoutHandle = setTimeout(() => controller.abort(), this.options.timeout * 1000);
const headers = this.buildHeaders(options.headers); const headers = this.buildHeaders(options.headers);
const init: RequestInit = { const init: RequestInit = {
body: options.data as BodyInit | undefined, body: options.data as BodyInit | undefined,
headers, headers,
method, method,
redirect: this.config.followRedirects ? "follow" : "manual", redirect: this.options.followRedirects ? "follow" : "manual",
signal: controller.signal, signal: controller.signal,
}; };
@@ -189,7 +189,7 @@ export class SyncHttpClient extends BaseHttpClient {
if ( if (
DEFAULT_TRANSIENT_HTTP_STATUSES.includes(response.status as number) && DEFAULT_TRANSIENT_HTTP_STATUSES.includes(response.status as number) &&
attempt < this.config.maxRetries attempt < this.options.maxRetries
) { ) {
await this.maybeDelay(attempt, response, retryAfterHeader); await this.maybeDelay(attempt, response, retryAfterHeader);
attempt += 1; attempt += 1;
@@ -209,12 +209,12 @@ export class SyncHttpClient extends BaseHttpClient {
if (error instanceof DOMException && error.name === "AbortError") { if (error instanceof DOMException && error.name === "AbortError") {
lastError = error; lastError = error;
if (attempt >= this.config.maxRetries) { if (attempt >= this.options.maxRetries) {
throw error; throw error;
} }
} else { } else {
lastError = error; lastError = error;
if (attempt >= this.config.maxRetries) { if (attempt >= this.options.maxRetries) {
throw error; throw error;
} }
} }
+15 -3
View File
@@ -1,8 +1,8 @@
import { config } from "@basango/domain/config";
import { DEFAULT_OPEN_GRAPH_USER_AGENT } from "@basango/domain/constants"; import { DEFAULT_OPEN_GRAPH_USER_AGENT } from "@basango/domain/constants";
import { ArticleMetadata } from "@basango/domain/models"; import { ArticleMetadata } from "@basango/domain/models";
import { parse } from "node-html-parser"; import { parse } from "node-html-parser";
import { config } from "#crawler/config";
import { SyncHttpClient } from "#crawler/http/http-client"; import { SyncHttpClient } from "#crawler/http/http-client";
import { UserAgents } from "#crawler/http/user-agent"; import { UserAgents } from "#crawler/http/user-agent";
import { createAbsoluteUrl } from "#crawler/utils"; import { createAbsoluteUrl } from "#crawler/utils";
@@ -44,7 +44,7 @@ export class OpenGraph {
private readonly client: Pick<SyncHttpClient, "get">; private readonly client: Pick<SyncHttpClient, "get">;
constructor() { constructor() {
const settings = config.fetch.client; const settings = config.crawler.fetch.client;
const provider = new UserAgents(true, DEFAULT_OPEN_GRAPH_USER_AGENT); const provider = new UserAgents(true, DEFAULT_OPEN_GRAPH_USER_AGENT);
this.client = new SyncHttpClient(settings, { this.client = new SyncHttpClient(settings, {
@@ -89,16 +89,28 @@ export class OpenGraph {
root.querySelector("link[rel='canonical']")?.getAttribute("href") ?? null, root.querySelector("link[rel='canonical']")?.getAttribute("href") ?? null,
url ?? null, url ?? null,
]); ]);
const author = pick([extract(root, "article:author"), extract(root, "og:article:author")]);
const publishedAt = pick([
extract(root, "article:published_time"),
extract(root, "og:article:published_time"),
]);
const updatedAt = pick([
extract(root, "article:modified_time"),
extract(root, "og:article:modified_time"),
]);
if (!title && !description && !image && !canonical) { if (!title && !description && !image && !canonical) {
return undefined; return undefined;
} }
return { return {
author,
description, description,
image: createAbsoluteUrl(url, image ?? "") || undefined, image: createAbsoluteUrl(url, image ?? "") || undefined,
publishedAt,
title, title,
updatedAt,
url: createAbsoluteUrl(url, canonical ?? "") || undefined, url: createAbsoluteUrl(url, canonical ?? "") || undefined,
}; } as ArticleMetadata;
} }
} }
+12 -37
View File
@@ -1,35 +1,32 @@
import type { HtmlSourceConfig, WordPressSourceConfig } from "@basango/domain/crawler"; import type { HtmlSourceOptions, WordPressSourceOptions } from "@basango/domain/config";
import { Article } from "@basango/domain/models";
import { logger } from "@basango/logger"; import { logger } from "@basango/logger";
import { UnsupportedSourceKindError } from "#crawler/errors"; import { UnsupportedSourceKindError } from "#crawler/errors";
import { QueueManager, createQueueManager } from "#crawler/process/async/queue"; import { QueueManager, createQueueManager } from "#crawler/process/async/queue";
import { import { DetailsTaskPayload, ListingTaskPayload } from "#crawler/process/async/schemas";
DetailsTaskPayload,
ListingTaskPayload,
ProcessingTaskPayload,
} from "#crawler/process/async/schemas";
import { createPersistors, resolveCrawlerConfig } from "#crawler/process/crawler"; import { createPersistors, resolveCrawlerConfig } from "#crawler/process/crawler";
import { HtmlCrawler } from "#crawler/process/parsers/html"; import { HtmlCrawler } from "#crawler/process/parsers/html";
import { WordPressCrawler } from "#crawler/process/parsers/wordpress"; import { WordPressCrawler } from "#crawler/process/parsers/wordpress";
import { forward } from "#crawler/process/persistence";
import { import {
createTimestampRange, createTimestampRange,
formatPageRange, formatPageRange,
formatTimestampRange, formatTimestampRange,
resolveSourceConfig, resolveSourceConfig,
resolveSourceUpdateDates,
} from "#crawler/utils"; } from "#crawler/utils";
export const collectHtmlListing = async ( export const collectHtmlListing = async (
payload: ListingTaskPayload, payload: ListingTaskPayload,
manager: QueueManager = createQueueManager(), manager: QueueManager = createQueueManager(),
): Promise<number> => { ): Promise<number> => {
const source = resolveSourceConfig(payload.sourceId) as HtmlSourceConfig; const source = resolveSourceConfig(payload.sourceId) as HtmlSourceOptions;
if (source.sourceKind !== "html") { if (source.sourceKind !== "html") {
return await collectWordPressListing(payload, manager); return await collectWordPressListing(payload, manager);
} }
const settings = resolveCrawlerConfig(source, payload); const settings = resolveCrawlerConfig(source, payload);
await resolveSourceUpdateDates(settings);
const crawler = new HtmlCrawler(settings); const crawler = new HtmlCrawler(settings);
const pageRange = settings.pageRange ?? (await crawler.getPagination()); const pageRange = settings.pageRange ?? (await crawler.getPagination());
@@ -63,12 +60,14 @@ export const collectWordPressListing = async (
payload: ListingTaskPayload, payload: ListingTaskPayload,
manager: QueueManager = createQueueManager(), manager: QueueManager = createQueueManager(),
): Promise<number> => { ): Promise<number> => {
const source = resolveSourceConfig(payload.sourceId) as WordPressSourceConfig; const source = resolveSourceConfig(payload.sourceId) as WordPressSourceOptions;
if (source.sourceKind !== "wordpress") { if (source.sourceKind !== "wordpress") {
return await collectHtmlListing(payload, manager); return await collectHtmlListing(payload, manager);
} }
const settings = resolveCrawlerConfig(source, payload); const settings = resolveCrawlerConfig(source, payload);
await resolveSourceUpdateDates(settings);
const crawler = new WordPressCrawler(settings); const crawler = new WordPressCrawler(settings);
const pageRange = settings.pageRange ?? (await crawler.getPagination()); const pageRange = settings.pageRange ?? (await crawler.getPagination());
@@ -99,10 +98,7 @@ export const collectWordPressListing = async (
return queued; return queued;
}; };
export const collectArticle = async ( export const collectArticle = async (payload: DetailsTaskPayload): Promise<unknown> => {
payload: DetailsTaskPayload,
manager: QueueManager = createQueueManager(),
): Promise<unknown> => {
const source = resolveSourceConfig(payload.sourceId); const source = resolveSourceConfig(payload.sourceId);
const settings = resolveCrawlerConfig(source, { const settings = resolveCrawlerConfig(source, {
category: payload.category, category: payload.category,
@@ -116,35 +112,14 @@ export const collectArticle = async (
const crawler = new HtmlCrawler(settings, { persistors }); const crawler = new HtmlCrawler(settings, { persistors });
const html = await crawler.crawl(payload.url); const html = await crawler.crawl(payload.url);
const article = await crawler.fetchOne(html, settings.dateRange); return await crawler.fetchOne(html, settings.dateRange);
await manager.enqueueProcessed({
article,
sourceId: payload.sourceId,
} as ProcessingTaskPayload);
} }
if (source.sourceKind === "wordpress") { if (source.sourceKind === "wordpress") {
const crawler = new WordPressCrawler(settings, { persistors }); const crawler = new WordPressCrawler(settings, { persistors });
const article = await crawler.fetchOne(payload.data ?? {}, settings.dateRange); return await crawler.fetchOne(payload.data ?? {}, settings.dateRange);
await manager.enqueueProcessed({
article,
sourceId: payload.sourceId,
} as ProcessingTaskPayload);
} }
throw new UnsupportedSourceKindError(`Unsupported source kind`); throw new UnsupportedSourceKindError(`Unsupported source kind`);
}; };
export const forwardForProcessing = async (payload: ProcessingTaskPayload): Promise<Article> => {
logger.info({ article: payload.article.title }, "Ready for downstream processing");
try {
logger.info({ article: payload.article.title }, "Forwarding article to API");
await forward(payload.article);
} catch (error) {
logger.error({ error }, "Failed to forward article to API");
}
return payload.article;
};
+22 -35
View File
@@ -1,16 +1,14 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { JobsOptions, Queue, QueueOptions } from "bullmq"; import { type CrawlerAsyncOptions, config } from "@basango/domain/config";
import { JobsOptions, Queue } from "bullmq";
import IORedis from "ioredis"; import IORedis from "ioredis";
import { FetchAsyncConfig, config } from "#crawler/config";
import { import {
DetailsTaskPayload, DetailsTaskPayload,
DetailsTaskPayloadSchema, DetailsTaskPayloadSchema,
ListingTaskPayload, ListingTaskPayload,
ListingTaskPayloadSchema, ListingTaskPayloadSchema,
ProcessingTaskPayload,
ProcessingTaskPayloadSchema,
} from "#crawler/process/async/schemas"; } from "#crawler/process/async/schemas";
import { parseRedisUrl } from "#crawler/utils"; import { parseRedisUrl } from "#crawler/utils";
@@ -20,28 +18,27 @@ export interface QueueBackend<T = unknown> {
export type QueueFactory = ( export type QueueFactory = (
queueName: string, queueName: string,
settings: FetchAsyncConfig, options: CrawlerAsyncOptions,
connection?: IORedis, connection?: IORedis,
) => QueueBackend; ) => QueueBackend;
const defaultQueueFactory: QueueFactory = (queueName, settings, connection) => { const defaultQueueFactory: QueueFactory = (queueName, options, connection) => {
const redisConnection = const redisConnection =
connection ?? connection ??
new IORedis(settings.redisUrl, { new IORedis(options.redisUrl, {
...parseRedisUrl(settings.redisUrl), ...parseRedisUrl(options.redisUrl),
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
}); });
const options: QueueOptions = {
connection: redisConnection,
prefix: settings.prefix,
};
const queue = new Queue(queueName, options); const queue = new Queue(queueName, {
connection: redisConnection,
prefix: options.prefix,
});
return { return {
add: async (name, data, opts) => { add: async (name, data, opts) => {
const job = await queue.add(name, data, { const job = await queue.add(name, data, {
removeOnComplete: settings.ttl.result === 0 ? true : undefined, removeOnComplete: options.ttl.result === 0 ? true : undefined,
removeOnFail: settings.ttl.failure === 0 ? true : undefined, removeOnFail: options.ttl.failure === 0 ? true : undefined,
...opts, ...opts,
}); });
return { id: job.id ?? randomUUID() }; return { id: job.id ?? randomUUID() };
@@ -55,28 +52,27 @@ export interface CreateQueueManagerOptions {
} }
export interface QueueManager { export interface QueueManager {
readonly settings: FetchAsyncConfig; readonly options: CrawlerAsyncOptions;
readonly connection: IORedis; readonly connection: IORedis;
enqueueListing: (payload: ListingTaskPayload) => Promise<{ id: string }>; enqueueListing: (payload: ListingTaskPayload) => Promise<{ id: string }>;
enqueueArticle: (payload: DetailsTaskPayload) => Promise<{ id: string }>; enqueueArticle: (payload: DetailsTaskPayload) => Promise<{ id: string }>;
enqueueProcessed: (payload: ProcessingTaskPayload) => Promise<{ id: string }>;
iterQueueNames: () => string[]; iterQueueNames: () => string[];
queueName: (suffix: string) => string; queueName: (suffix: string) => string;
close: () => Promise<void>; close: () => Promise<void>;
} }
export const createQueueManager = (options: CreateQueueManagerOptions = {}): QueueManager => { export const createQueueManager = (options: CreateQueueManagerOptions = {}): QueueManager => {
const settings = config.fetch.async; const asyncOptions = config.crawler.fetch.async;
const connection = const connection =
options.connection ?? options.connection ??
new IORedis(settings.redisUrl, { new IORedis(asyncOptions.redisUrl, {
...parseRedisUrl(settings.redisUrl), ...parseRedisUrl(asyncOptions.redisUrl),
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
}); });
const factory = options.queueFactory ?? defaultQueueFactory; const factory = options.queueFactory ?? defaultQueueFactory;
const ensureQueue = (queueName: string) => factory(queueName, settings, connection); const ensureQueue = (queueName: string) => factory(queueName, asyncOptions, connection);
return { return {
close: async () => { close: async () => {
@@ -85,25 +81,16 @@ export const createQueueManager = (options: CreateQueueManagerOptions = {}): Que
connection, connection,
enqueueArticle: (payload) => { enqueueArticle: (payload) => {
const data = DetailsTaskPayloadSchema.parse(payload); const data = DetailsTaskPayloadSchema.parse(payload);
const queue = ensureQueue(settings.queues.details); const queue = ensureQueue(asyncOptions.queues.details);
return queue.add("collect_article", data); return queue.add("collect_article", data);
}, },
enqueueListing: (payload) => { enqueueListing: (payload) => {
const data = ListingTaskPayloadSchema.parse(payload); const data = ListingTaskPayloadSchema.parse(payload);
const queue = ensureQueue(settings.queues.listing); const queue = ensureQueue(asyncOptions.queues.listing);
return queue.add("collect_listing", data); return queue.add("collect_listing", data);
}, },
enqueueProcessed: (payload) => { iterQueueNames: () => [asyncOptions.queues.listing, asyncOptions.queues.details],
const data = ProcessingTaskPayloadSchema.parse(payload); options: asyncOptions,
const queue = ensureQueue(settings.queues.processing); queueName: (suffix: string) => `${asyncOptions.prefix}:${suffix}`,
return queue.add("forward_for_processing", data);
},
iterQueueNames: () => [
settings.queues.listing,
settings.queues.details,
settings.queues.processing,
],
queueName: (suffix: string) => `${settings.prefix}:${suffix}`,
settings,
}; };
}; };
+1 -8
View File
@@ -1,5 +1,4 @@
import { PageRangeSchema, TimestampRangeSchema } from "@basango/domain/crawler"; import { PageRangeSchema, TimestampRangeSchema } from "@basango/domain/models";
import { articleSchema } from "@basango/domain/models";
import { z } from "zod"; import { z } from "zod";
export const ListingTaskPayloadSchema = z.object({ export const ListingTaskPayloadSchema = z.object({
@@ -19,11 +18,5 @@ export const DetailsTaskPayloadSchema = z.object({
url: z.url(), url: z.url(),
}); });
export const ProcessingTaskPayloadSchema = z.object({
article: articleSchema,
sourceId: z.string(),
});
export type ListingTaskPayload = z.infer<typeof ListingTaskPayloadSchema>; export type ListingTaskPayload = z.infer<typeof ListingTaskPayloadSchema>;
export type DetailsTaskPayload = z.infer<typeof DetailsTaskPayloadSchema>; export type DetailsTaskPayload = z.infer<typeof DetailsTaskPayloadSchema>;
export type ProcessingTaskPayload = z.infer<typeof ProcessingTaskPayloadSchema>;
+1 -15
View File
@@ -2,11 +2,7 @@ import { logger } from "@basango/logger";
import * as handlers from "#crawler/process/async/handlers"; import * as handlers from "#crawler/process/async/handlers";
import { createQueueManager } from "#crawler/process/async/queue"; import { createQueueManager } from "#crawler/process/async/queue";
import { import { DetailsTaskPayloadSchema, ListingTaskPayloadSchema } from "#crawler/process/async/schemas";
DetailsTaskPayloadSchema,
ListingTaskPayloadSchema,
ProcessingTaskPayloadSchema,
} from "#crawler/process/async/schemas";
import { CrawlingOptions } from "#crawler/process/crawler"; import { CrawlingOptions } from "#crawler/process/crawler";
export const collectListing = async (payload: unknown): Promise<number> => { export const collectListing = async (payload: unknown): Promise<number> => {
@@ -29,16 +25,6 @@ export const collectArticle = async (payload: unknown): Promise<unknown> => {
return result; return result;
}; };
export const forwardForProcessing = async (payload: unknown): Promise<unknown> => {
const data = ProcessingTaskPayloadSchema.parse(payload);
logger.debug({ sourceId: data.sourceId }, "Forwarding article for processing");
const result = await handlers.forwardForProcessing(data);
logger.info({ result }, "Article forwarded for processing");
return result;
};
export const scheduleAsyncCrawl = async (options: CrawlingOptions): Promise<string> => { export const scheduleAsyncCrawl = async (options: CrawlingOptions): Promise<string> => {
const payload = ListingTaskPayloadSchema.parse({ const payload = ListingTaskPayloadSchema.parse({
category: options.category, category: options.category,
+3 -5
View File
@@ -2,7 +2,7 @@ import { QueueEvents, Worker } from "bullmq";
import IORedis from "ioredis"; import IORedis from "ioredis";
import { QueueFactory, QueueManager } from "#crawler/process/async/queue"; import { QueueFactory, QueueManager } from "#crawler/process/async/queue";
import { collectArticle, collectListing, forwardForProcessing } from "#crawler/process/async/tasks"; import { collectArticle, collectListing } from "#crawler/process/async/tasks";
export interface WorkerOptions { export interface WorkerOptions {
queueNames?: string[]; queueNames?: string[];
@@ -36,8 +36,6 @@ export const startWorker = (options: WorkerOptions): WorkerHandle => {
return collectListing(job.data); return collectListing(job.data);
case "collect_article": case "collect_article":
return collectArticle(job.data); return collectArticle(job.data);
case "forward_for_processing":
return forwardForProcessing(job.data);
default: default:
throw new Error(`Unknown job name: ${job.name}`); throw new Error(`Unknown job name: ${job.name}`);
} }
@@ -45,7 +43,7 @@ export const startWorker = (options: WorkerOptions): WorkerHandle => {
{ {
concurrency: options.concurrency ?? 5, concurrency: options.concurrency ?? 5,
connection, connection,
prefix: manager.settings.prefix, prefix: manager.options.prefix,
}, },
); );
@@ -56,7 +54,7 @@ export const startWorker = (options: WorkerOptions): WorkerHandle => {
const queueEvents = new QueueEvents(queueName, { const queueEvents = new QueueEvents(queueName, {
connection, connection,
prefix: manager.settings.prefix, prefix: manager.options.prefix,
}); });
workers.push(worker); workers.push(worker);
+6 -7
View File
@@ -1,7 +1,6 @@
import type { AnySourceConfig } from "@basango/domain/crawler"; import { AnySourceOptions, CrawlerFetchingOptions, config } from "@basango/domain/config";
import logger from "@basango/logger"; import logger from "@basango/logger";
import { FetchCrawlerConfig, config } from "#crawler/config";
import { JsonlPersistor, Persistor } from "#crawler/process/persistence"; import { JsonlPersistor, Persistor } from "#crawler/process/persistence";
import { createPageRange, createTimestampRange } from "#crawler/utils"; import { createPageRange, createTimestampRange } from "#crawler/utils";
@@ -13,11 +12,11 @@ export interface CrawlingOptions {
} }
export const resolveCrawlerConfig = ( export const resolveCrawlerConfig = (
source: AnySourceConfig, source: AnySourceOptions,
options: CrawlingOptions, options: CrawlingOptions,
): FetchCrawlerConfig => { ): CrawlerFetchingOptions => {
return { return {
...config.fetch.crawler, ...config.crawler.fetch.crawler,
category: options.category, category: options.category,
dateRange: createTimestampRange(options.dateRange), dateRange: createTimestampRange(options.dateRange),
pageRange: createPageRange(options.pageRange), pageRange: createPageRange(options.pageRange),
@@ -25,10 +24,10 @@ export const resolveCrawlerConfig = (
}; };
}; };
export const createPersistors = (source: AnySourceConfig): Persistor[] => { export const createPersistors = (source: AnySourceOptions): Persistor[] => {
return [ return [
new JsonlPersistor({ new JsonlPersistor({
directory: config.paths.data, directory: config.crawler.paths.data,
sourceId: source.sourceId, sourceId: source.sourceId,
}), }),
]; ];
+9 -10
View File
@@ -1,8 +1,7 @@
import type { AnySourceConfig } from "@basango/domain/crawler"; import { AnySourceOptions, CrawlerFetchingOptions, config } from "@basango/domain/config";
import { Article } from "@basango/domain/models"; import { Article } from "@basango/domain/models";
import { HTMLElement, parse as parseHtml } from "node-html-parser"; import { HTMLElement, parse as parseHtml } from "node-html-parser";
import { FetchCrawlerConfig, config } from "#crawler/config";
import { SyncHttpClient } from "#crawler/http/http-client"; import { SyncHttpClient } from "#crawler/http/http-client";
import { OpenGraph } from "#crawler/http/open-graph"; import { OpenGraph } from "#crawler/http/open-graph";
import type { Persistor } from "#crawler/process/persistence"; import type { Persistor } from "#crawler/process/persistence";
@@ -12,23 +11,23 @@ export interface CrawlerOptions {
} }
export abstract class BaseCrawler { export abstract class BaseCrawler {
protected readonly settings: FetchCrawlerConfig; protected readonly options: CrawlerFetchingOptions;
protected readonly source: AnySourceConfig; protected readonly source: AnySourceOptions;
protected readonly http: SyncHttpClient; protected readonly http: SyncHttpClient;
protected readonly persistors: Persistor[]; protected readonly persistors: Persistor[];
protected readonly openGraph: OpenGraph; protected readonly openGraph: OpenGraph;
protected constructor(settings: FetchCrawlerConfig, options: CrawlerOptions = {}) { protected constructor(options: CrawlerFetchingOptions, crawlerOptions: CrawlerOptions = {}) {
if (!settings.source) { if (!options.source) {
throw new Error("Crawler requires a bound source"); throw new Error("Crawler requires a bound source");
} }
this.http = new SyncHttpClient(config.fetch.client); this.http = new SyncHttpClient(config.crawler.fetch.client);
this.persistors = options.persistors ?? []; this.persistors = crawlerOptions.persistors ?? [];
this.openGraph = new OpenGraph(); this.openGraph = new OpenGraph();
this.settings = settings; this.options = options;
this.source = settings.source as AnySourceConfig; this.source = options.source as AnySourceOptions;
} }
/** /**
+10 -11
View File
@@ -1,11 +1,10 @@
import type { HtmlSourceConfig, TimestampRange } from "@basango/domain/crawler"; import { CrawlerFetchingOptions, HtmlSourceOptions } from "@basango/domain/config";
import { Article } from "@basango/domain/models"; import { Article, TimestampRange } from "@basango/domain/models";
import { logger } from "@basango/logger"; import { logger } from "@basango/logger";
import { fromUnixTime, getUnixTime, isMatch as isDateMatch, parse } from "date-fns"; import { fromUnixTime, getUnixTime, isMatch as isDateMatch, parse } from "date-fns";
import { HTMLElement } from "node-html-parser"; import { HTMLElement } from "node-html-parser";
import TurndownService from "turndown"; import TurndownService from "turndown";
import { FetchCrawlerConfig } from "#crawler/config";
import { import {
ArticleOutOfDateRangeError, ArticleOutOfDateRangeError,
InvalidArticleError, InvalidArticleError,
@@ -26,21 +25,21 @@ const md = new TurndownService({
* Crawler for generic HTML pages. * Crawler for generic HTML pages.
*/ */
export class HtmlCrawler extends BaseCrawler { export class HtmlCrawler extends BaseCrawler {
readonly source: HtmlSourceConfig; readonly source: HtmlSourceOptions;
private currentNode: string | null = null; private currentNode: string | null = null;
constructor(settings: FetchCrawlerConfig, options: { persistors?: Persistor[] } = {}) { constructor(settings: CrawlerFetchingOptions, options: { persistors?: Persistor[] } = {}) {
super(settings, options); super(settings, options);
if (!settings.source || settings.source.sourceKind !== "html") { if (!settings.source || settings.source.sourceKind !== "html") {
throw new UnsupportedSourceKindError("HtmlCrawler requires a source of kind 'html'"); throw new UnsupportedSourceKindError("HtmlCrawler requires a source of kind 'html'");
} }
this.source = this.settings.source as HtmlSourceConfig; this.source = this.options.source as HtmlSourceOptions;
} }
async fetch(): Promise<void> { async fetch(): Promise<void> {
const pageRange = this.settings.pageRange ?? (await this.getPagination()); const pageRange = this.options.pageRange ?? (await this.getPagination());
const dateRange = this.settings.dateRange; const dateRange = this.options.dateRange;
const selectors = this.source.sourceSelectors; const selectors = this.source.sourceSelectors;
if (!selectors.articles) { if (!selectors.articles) {
@@ -91,7 +90,7 @@ export class HtmlCrawler extends BaseCrawler {
{ url: this.currentNode }, { url: this.currentNode },
"Article out of date range, stopping further processing", "Article out of date range, stopping further processing",
); );
break; process.exit(0); // stop further processing
} }
logger.error({ error, url: this.currentNode }, "Failed to process HTML article"); logger.error({ error, url: this.currentNode }, "Failed to process HTML article");
@@ -218,7 +217,7 @@ export class HtmlCrawler extends BaseCrawler {
*/ */
private applyCategory(template: string): string { private applyCategory(template: string): string {
if (template.includes("{category}")) { if (template.includes("{category}")) {
const replacement = this.settings.category ?? ""; const replacement = this.options.category ?? "";
return template.replace("{category}", replacement); return template.replace("{category}", replacement);
} }
return template; return template;
@@ -297,7 +296,7 @@ export class HtmlCrawler extends BaseCrawler {
* @param selector - The CSS selector * @param selector - The CSS selector
*/ */
private extractCategories(root: HTMLElement, selector?: string | null): string[] { private extractCategories(root: HTMLElement, selector?: string | null): string[] {
if (!selector && this.settings.category) return [this.settings.category.toLowerCase()]; if (!selector && this.options.category) return [this.options.category.toLowerCase()];
if (!selector) return []; if (!selector) return [];
const values: string[] = []; const values: string[] = [];
@@ -1,10 +1,9 @@
import type { PageRange, TimestampRange, WordPressSourceConfig } from "@basango/domain/crawler"; import { CrawlerFetchingOptions, WordPressSourceOptions } from "@basango/domain/config";
import { Article } from "@basango/domain/models"; import { Article, PageRange, TimestampRange } from "@basango/domain/models";
import { logger } from "@basango/logger"; import { logger } from "@basango/logger";
import { fromUnixTime } from "date-fns"; import { fromUnixTime } from "date-fns";
import TurndownService from "turndown"; import TurndownService from "turndown";
import { FetchCrawlerConfig } from "#crawler/config";
import { import {
ArticleOutOfDateRangeError, ArticleOutOfDateRangeError,
InvalidArticleError, InvalidArticleError,
@@ -33,7 +32,7 @@ interface WordPressPost {
* Crawler for WordPress sites using the REST API. * Crawler for WordPress sites using the REST API.
*/ */
export class WordPressCrawler extends BaseCrawler { export class WordPressCrawler extends BaseCrawler {
readonly source: WordPressSourceConfig; readonly source: WordPressSourceOptions;
private categoryMap: Map<number, string> = new Map(); private categoryMap: Map<number, string> = new Map();
public static readonly POST_QUERY = public static readonly POST_QUERY =
@@ -43,7 +42,7 @@ export class WordPressCrawler extends BaseCrawler {
public static readonly TOTAL_PAGES_HEADER = "x-wp-totalpages"; public static readonly TOTAL_PAGES_HEADER = "x-wp-totalpages";
public static readonly TOTAL_POSTS_HEADER = "x-wp-total"; public static readonly TOTAL_POSTS_HEADER = "x-wp-total";
constructor(settings: FetchCrawlerConfig, options: { persistors?: Persistor[] } = {}) { constructor(settings: CrawlerFetchingOptions, options: { persistors?: Persistor[] } = {}) {
super(settings, options); super(settings, options);
if (!settings.source || settings.source.sourceKind !== "wordpress") { if (!settings.source || settings.source.sourceKind !== "wordpress") {
@@ -51,15 +50,15 @@ export class WordPressCrawler extends BaseCrawler {
"WordPressCrawler requires a source of kind 'wordpress'", "WordPressCrawler requires a source of kind 'wordpress'",
); );
} }
this.source = this.settings.source as WordPressSourceConfig; this.source = this.options.source as WordPressSourceOptions;
} }
/** /**
* Fetch and process WordPress posts. * Fetch and process WordPress posts.
*/ */
async fetch(): Promise<void> { async fetch(): Promise<void> {
const pageRange = this.settings.pageRange ?? (await this.getPagination()); const pageRange = this.options.pageRange ?? (await this.getPagination());
const dateRange = this.settings.dateRange; const dateRange = this.options.dateRange;
for (let page = pageRange.start; page <= pageRange.end; page += 1) { for (let page = pageRange.start; page <= pageRange.end; page += 1) {
const endpoint = this.buildEndpointUrl(page); const endpoint = this.buildEndpointUrl(page);
@@ -77,7 +76,7 @@ export class WordPressCrawler extends BaseCrawler {
{ url: node.link }, { url: node.link },
"Article out of date range, stopping further processing", "Article out of date range, stopping further processing",
); );
break; process.exit(0); // stop further processing
} }
logger.error({ error, url: node.link }, "Failed to process WordPress article"); logger.error({ error, url: node.link }, "Failed to process WordPress article");
+34 -7
View File
@@ -1,11 +1,11 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { Article } from "@basango/domain/models"; import { config } from "@basango/domain/config";
import type { Article, SourceUpdateDates } from "@basango/domain/models";
import { md5 } from "@basango/encryption"; import { md5 } from "@basango/encryption";
import logger from "@basango/logger"; import logger from "@basango/logger";
import { config, env } from "#crawler/config";
import { HttpError, SyncHttpClient } from "#crawler/http/http-client"; import { HttpError, SyncHttpClient } from "#crawler/http/http-client";
export interface Persistor { export interface Persistor {
@@ -61,19 +61,46 @@ export const persist = async (
} }
} }
forward(article).catch((error) => {
logger.error({ error }, "Failed to forward article");
});
logger.info({ url: article.link }, "article successfully persisted"); logger.info({ url: article.link }, "article successfully persisted");
return article; return article;
}; };
export const getSourceUpdateDates = async (sourceId: string): Promise<SourceUpdateDates> => {
const client = new SyncHttpClient(config.crawler.fetch.client);
const endpoint = config.crawler.backend.endpoint;
logger.info({ sourceId }, "Fetching source update dates");
const response = await client.post(`${endpoint}/sources/update-dates`, {
headers: {
Authorization: config.crawler.backend.token,
},
json: {
name: sourceId,
},
});
if (response.ok) {
const data = await response.json();
logger.info({ ...data }, "Retrieved source update dates");
return data;
}
logger.error({ sourceId, status: response.status }, "Failed to retrieve source update dates");
return { earliest: new Date(), latest: new Date() };
};
export const forward = async (payload: Partial<Article>): Promise<void> => { export const forward = async (payload: Partial<Article>): Promise<void> => {
const client = new SyncHttpClient(config.fetch.client); const client = new SyncHttpClient(config.crawler.fetch.client);
const endpoint = env("BASANGO_CRAWLER_BACKEND_API_ENDPOINT"); const endpoint = config.crawler.backend.endpoint;
const token = env("BASANGO_CRAWLER_TOKEN");
try { try {
const response = await client.post(endpoint, { const response = await client.post(`${endpoint}/articles`, {
headers: { headers: {
Authorization: `${token}`, Authorization: config.crawler.backend.token,
}, },
json: payload, json: payload,
}); });
+2 -1
View File
@@ -8,12 +8,13 @@ import {
} from "#crawler/process/crawler"; } from "#crawler/process/crawler";
import { HtmlCrawler } from "#crawler/process/parsers/html"; import { HtmlCrawler } from "#crawler/process/parsers/html";
import { WordPressCrawler } from "#crawler/process/parsers/wordpress"; import { WordPressCrawler } from "#crawler/process/parsers/wordpress";
import { resolveSourceConfig } from "#crawler/utils"; import { resolveSourceConfig, resolveSourceUpdateDates } from "#crawler/utils";
export const runSyncCrawl = async (options: CrawlingOptions): Promise<void> => { export const runSyncCrawl = async (options: CrawlingOptions): Promise<void> => {
const source = resolveSourceConfig(options.sourceId); const source = resolveSourceConfig(options.sourceId);
const settings = resolveCrawlerConfig(source, options); const settings = resolveCrawlerConfig(source, options);
const persistors = createPersistors(source); const persistors = createPersistors(source);
await resolveSourceUpdateDates(settings);
const crawler = const crawler =
source.sourceKind === "wordpress" source.sourceKind === "wordpress"
+3 -3
View File
@@ -1,13 +1,13 @@
#! /usr/bin/env bun #!/usr/bin/env bun
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import { config } from "@basango/domain/config";
import type { Article } from "@basango/domain/models"; import type { Article } from "@basango/domain/models";
import { logger } from "@basango/logger"; import { logger } from "@basango/logger";
import { config } from "#crawler/config";
import { forward } from "#crawler/process/persistence"; import { forward } from "#crawler/process/persistence";
const USAGE = ` const USAGE = `
@@ -31,7 +31,7 @@ const main = async (): Promise<void> => {
return; return;
} }
const filePath = path.join(config.paths.data, `${sourceId}.jsonl`); const filePath = path.join(config.crawler.paths.data, `${sourceId}.jsonl`);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
logger.error({ filePath, sourceId }, "Source must be crawled first; JSONL not found"); logger.error({ filePath, sourceId }, "Source must be crawled first; JSONL not found");
+1 -1
View File
@@ -1,4 +1,4 @@
#! /usr/bin/env bun #!/usr/bin/env bun
import { logger } from "@basango/logger"; import { logger } from "@basango/logger";
+1 -1
View File
@@ -1,4 +1,4 @@
#! /usr/bin/env bun #!/usr/bin/env bun
import { logger } from "@basango/logger"; import { logger } from "@basango/logger";
+47 -8
View File
@@ -1,28 +1,32 @@
import {
AnySourceOptions,
HtmlSourceOptions,
WordPressSourceOptions,
config,
} from "@basango/domain/config";
import { DEFAULT_DATE_FORMAT } from "@basango/domain/constants"; import { DEFAULT_DATE_FORMAT } from "@basango/domain/constants";
import { import {
AnySourceConfig,
DateSpecSchema, DateSpecSchema,
HtmlSourceConfig,
PageRange, PageRange,
PageRangeSchema, PageRangeSchema,
PageSpecSchema, PageSpecSchema,
TimestampRange, TimestampRange,
TimestampRangeSchema, TimestampRangeSchema,
WordPressSourceConfig, } from "@basango/domain/models";
} from "@basango/domain/crawler"; import logger from "@basango/logger";
import { format, fromUnixTime, getUnixTime, isMatch, parse } from "date-fns"; import { format, fromUnixTime, getUnixTime, isMatch, parse } from "date-fns";
import type { RedisOptions } from "ioredis"; import type { RedisOptions } from "ioredis";
import { config } from "#crawler/config"; import { getSourceUpdateDates } from "./process/persistence";
/** /**
* Resolve a source configuration by its ID. * Resolve a source configuration by its ID.
* @param id - The source ID * @param id - The source ID
*/ */
export const resolveSourceConfig = (id: string): AnySourceConfig => { export const resolveSourceConfig = (id: string): AnySourceOptions => {
const source = const source =
config.sources.html.find((s: HtmlSourceConfig) => s.sourceId === id) || config.crawler.sources.html.find((s: HtmlSourceOptions) => s.sourceId === id) ||
config.sources.wordpress.find((s: WordPressSourceConfig) => s.sourceId === id); config.crawler.sources.wordpress.find((s: WordPressSourceOptions) => s.sourceId === id);
if (source === undefined) { if (source === undefined) {
throw new Error(`Source '${id}' not found in configuration`); throw new Error(`Source '${id}' not found in configuration`);
@@ -31,6 +35,41 @@ export const resolveSourceConfig = (id: string): AnySourceConfig => {
return source; return source;
}; };
export const resolveSourceUpdateDates = async (settings: {
dateRange?: TimestampRange;
direction: "forward" | "backward";
source?: AnySourceOptions;
}) => {
if (settings.dateRange === undefined && settings.source) {
const dates = await getSourceUpdateDates(settings.source.sourceId);
switch (settings.direction) {
case "backward":
settings.dateRange = {
end: getUnixTime(dates.earliest),
start: getUnixTime(new Date()),
};
logger.info(
{ dateRange: settings.dateRange, sourceId: settings.source.sourceId },
"Set date range start from earliest published date",
);
break;
case "forward":
if (dates.latest) {
settings.dateRange = {
end: getUnixTime(new Date()),
start: getUnixTime(dates.latest),
};
logger.info(
{ dateRange: settings.dateRange, sourceId: settings.source.sourceId },
"Set date range start from latest published date",
);
}
break;
}
}
};
/** /**
* Parse a Redis URL into RedisOptions. * Parse a Redis URL into RedisOptions.
* @param url - The Redis URL (e.g., "redis://:password@localhost:6379/0") * @param url - The Redis URL (e.g., "redis://:password@localhost:6379/0")
+1 -1
View File
@@ -22,7 +22,7 @@
"client-only": "^0.0.1", "client-only": "^0.0.1",
"date-fns": "catalog:", "date-fns": "catalog:",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"next": "catalog:", "next": "16.0.7",
"next-international": "^1.3.1", "next-international": "^1.3.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.7.3", "nuqs": "^2.7.3",
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

@@ -1,19 +1,33 @@
import { Metadata } from "next"; import { Metadata } from "next";
import { ArticlesFeed } from "#dashboard/components/articles-feed"; import { ArticlesFeed } from "#dashboard/components/articles-feed";
import { CategoriesCarousel } from "#dashboard/components/categories-carousel";
import { PageHeader } from "#dashboard/components/shell/page-header";
import { PageLayout } from "#dashboard/components/shell/page-layout"; import { PageLayout } from "#dashboard/components/shell/page-layout";
import { HydrateClient, prefetch, trpc } from "#dashboard/trpc/server"; import { HydrateClient, batchPrefetch, trpc } from "#dashboard/trpc/server";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Articles | Basango Dashboard", title: "Articles | Basango Dashboard",
}; };
export default function Page() { export default function Page() {
prefetch(trpc.articles.list.infiniteQueryOptions({ limit: 12 })); batchPrefetch([
trpc.articles.list.infiniteQueryOptions({ limit: 12 }),
trpc.categories.list.queryOptions(),
]);
return ( return (
<HydrateClient> <HydrateClient>
<PageLayout title="Articles"> <PageLayout
header={
<>
<PageHeader title="Articles" />
<CategoriesCarousel />
</>
}
headersNumber={2}
title="Articles"
>
<ArticlesFeed /> <ArticlesFeed />
</PageLayout> </PageLayout>
</HydrateClient> </HydrateClient>
@@ -1,4 +1,4 @@
import { SidebarInset, SidebarProvider } from "@basango/ui/components/sidebar"; import { SidebarProvider } from "@basango/ui/components/sidebar";
import { AppSidebar } from "#dashboard/components/sidebar/app-sidebar"; import { AppSidebar } from "#dashboard/components/sidebar/app-sidebar";
import { HydrateClient } from "#dashboard/trpc/server"; import { HydrateClient } from "#dashboard/trpc/server";
@@ -9,7 +9,7 @@ export default async function Layout({ children }: { children: React.ReactNode }
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset>{children}</SidebarInset> {children}
</SidebarProvider> </SidebarProvider>
</HydrateClient> </HydrateClient>
); );
@@ -20,6 +20,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
trpc.sources.getById.queryOptions({ id }), trpc.sources.getById.queryOptions({ id }),
trpc.sources.getCategoryShares.queryOptions({ id, limit: 10 }), trpc.sources.getCategoryShares.queryOptions({ id, limit: 10 }),
trpc.sources.getPublications.queryOptions({ id }), trpc.sources.getPublications.queryOptions({ id }),
trpc.categories.list.queryOptions(),
trpc.articles.list.infiniteQueryOptions({ limit: 12, sourceId: id }), trpc.articles.list.infiniteQueryOptions({ limit: 12, sourceId: id }),
]); ]);
@@ -6,6 +6,7 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useCategoryFilterParams } from "#dashboard/hooks/use-category-filter-params";
import { useTRPC } from "#dashboard/trpc/client"; import { useTRPC } from "#dashboard/trpc/client";
import { ArticleCard, ArticleCardSkeleton } from "./article-card"; import { ArticleCard, ArticleCardSkeleton } from "./article-card";
@@ -14,14 +15,14 @@ type ArticlesTableProps = {
sourceId?: string; sourceId?: string;
}; };
const PLACEHOLDER_COUNT = 8;
export function ArticlesFeed({ sourceId }: ArticlesTableProps) { export function ArticlesFeed({ sourceId }: ArticlesTableProps) {
const trpc = useTRPC(); const { selectedCategory } = useCategoryFilterParams();
const trpc = useTRPC();
const query = useInfiniteQuery( const query = useInfiniteQuery(
trpc.articles.list.infiniteQueryOptions( trpc.articles.list.infiniteQueryOptions(
{ {
category: selectedCategory ?? undefined,
limit: 12, limit: 12,
sourceId, sourceId,
}, },
@@ -52,7 +53,7 @@ export function ArticlesFeed({ sourceId }: ArticlesTableProps) {
{isInitialLoading ? ( {isInitialLoading ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: PLACEHOLDER_COUNT }).map((_, index) => ( {Array.from({ length: 8 }).map((_, index) => (
<ArticleCardSkeleton key={index} /> <ArticleCardSkeleton key={index} />
))} ))}
</div> </div>
@@ -0,0 +1,83 @@
"use client";
import { Carousel, CarouselContent, CarouselItem } from "@basango/ui/components/carousel";
import { Skeleton } from "@basango/ui/components/skeleton";
import { cn } from "@basango/ui/lib/utils";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { Show } from "#dashboard/components/shell/show";
import { useCategoryFilterParams } from "#dashboard/hooks/use-category-filter-params";
import { useTRPC } from "#dashboard/trpc/client";
export function CategoriesCarousel() {
const { selectedCategory, setSelectedCategory } = useCategoryFilterParams();
const trpc = useTRPC();
const { data, isLoading } = useQuery(trpc.categories.list.queryOptions());
const categories = data ?? [];
return (
<div className="relative w-full flex items-start border-b py-2 px-4">
<Carousel
className="w-full"
opts={{
align: "start",
containScroll: "trimSnaps",
dragFree: true,
}}
>
<CarouselContent className="-ml-2">
<CarouselItem className="basis-auto pl-2">
<CategoryPill active={!selectedCategory} onClick={() => setSelectedCategory(null)}>
All
</CategoryPill>
</CarouselItem>
<Show
fallback={Array.from({ length: 10 }).map((_, index) => (
<CarouselItem className="basis-auto pl-2" key={`category-skeleton-${index}`}>
<Skeleton className="h-8 w-20 rounded-full bg-muted/70" />
</CarouselItem>
))}
when={!isLoading && data}
>
{categories.map((category) => (
<CarouselItem className="basis-auto pl-2" key={category.id}>
<CategoryPill
active={selectedCategory === category.id}
onClick={() => setSelectedCategory(category.id)}
>
{category.name}
</CategoryPill>
</CarouselItem>
))}
</Show>
</CarouselContent>
</Carousel>
</div>
);
}
type CategoryPillProps = {
active?: boolean;
children: React.ReactNode;
onClick: () => void;
};
function CategoryPill({ active, children, onClick }: CategoryPillProps) {
return (
<button
aria-pressed={active}
className={cn(
"shrink-0 rounded-full border px-3 py-1.5 text-sm font-medium transition",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
active
? "border-foreground bg-foreground text-background shadow-sm"
: "border-border bg-muted/60 text-foreground hover:border-foreground/60",
)}
onClick={onClick}
type="button"
>
{children}
</button>
);
}
@@ -4,11 +4,9 @@ import {
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
} from "@basango/ui/components/breadcrumb"; } from "@basango/ui/components/breadcrumb";
import { Separator } from "@basango/ui/components/separator";
import { SidebarTrigger } from "@basango/ui/components/sidebar"; import { SidebarTrigger } from "@basango/ui/components/sidebar";
import { Show } from "#dashboard/components/shell/show"; import { ThemeToggle } from "../theme-toggle";
import { ThemeToggle } from "#dashboard/components/theme-toggle";
type Props = { type Props = {
title?: string | React.ReactNode; title?: string | React.ReactNode;
@@ -16,20 +14,16 @@ type Props = {
export function PageHeader({ title }: Props) { export function PageHeader({ title }: Props) {
return ( return (
<header className="flex h-16 shrink-0 items-center justify-between gap-2"> <header className="w-full flex justify-between items-center border-b py-2 px-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Breadcrumb>
<Show when={title !== undefined}> <BreadcrumbList>
<Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" /> <BreadcrumbItem className="hidden md:block">
<Breadcrumb> <BreadcrumbPage className="font-bold">{title}</BreadcrumbPage>
<BreadcrumbList> </BreadcrumbItem>
<BreadcrumbItem className="hidden md:block"> </BreadcrumbList>
<BreadcrumbPage>{title}</BreadcrumbPage> </Breadcrumb>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</Show>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ThemeToggle /> <ThemeToggle />
@@ -1,3 +1,4 @@
import { cn } from "@basango/ui/lib/utils";
import React from "react"; import React from "react";
import { PageHeader } from "#dashboard/components/shell/page-header"; import { PageHeader } from "#dashboard/components/shell/page-header";
@@ -6,14 +7,45 @@ interface PageProps {
children: React.ReactNode; children: React.ReactNode;
title?: string | React.ReactNode; title?: string | React.ReactNode;
header?: React.ReactNode; header?: React.ReactNode;
headersNumber?: 1 | 2;
} }
const isEmptyHeader = (header: React.ReactNode | undefined): boolean => {
if (!header) return true;
if (React.isValidElement(header) && header.type === React.Fragment) {
const props = header.props as { children?: React.ReactNode };
if (!props.children) return true;
if (Array.isArray(props.children) && props.children.length === 0) {
return true;
}
}
return false;
};
const height = {
1: "h-[calc(100svh-40px)] lg:h-[calc(100svh-56px)]",
2: "h-[calc(100svh-80px)] lg:h-[calc(100svh-96px)]",
};
export const PageLayout = (props: React.PropsWithChildren<PageProps>) => { export const PageLayout = (props: React.PropsWithChildren<PageProps>) => {
const { title, header = <PageHeader title={title} />, children } = props; const { title, header = <PageHeader title={title} />, headersNumber = 1, children } = props;
return ( return (
<div className="flex flex-1 flex-col gap-4 p-4 pt-0"> <div className="h-svh overflow-hidden lg:p-2 w-full">
{header} <div className="lg:border lg:rounded-md overflow-hidden flex flex-col items-center justify-start h-full w-full">
{children} {header}
<div
className={cn(
"overflow-auto w-full py-2 px-6",
isEmptyHeader(header) ? "h-full" : height[headersNumber as keyof typeof height],
)}
>
{children}
</div>
</div>
</div> </div>
); );
}; };
@@ -0,0 +1,10 @@
import { parseAsString, useQueryState } from "nuqs";
export function useCategoryFilterParams(paramKey = "category") {
const [selectedCategory, setSelectedCategory] = useQueryState(paramKey, parseAsString);
return {
selectedCategory,
setSelectedCategory,
};
}
+75 -74
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "basango", "name": "basango",
@@ -25,13 +26,10 @@
"@basango/domain": "workspace:*", "@basango/domain": "workspace:*",
"@basango/encryption": "workspace:*", "@basango/encryption": "workspace:*",
"@basango/logger": "workspace:*", "@basango/logger": "workspace:*",
"@devscast/config": "catalog:",
"@hono/node-server": "^1.19.6", "@hono/node-server": "^1.19.6",
"@hono/trpc-server": "^0.4.0", "@hono/trpc-server": "^0.4.0",
"@hono/zod-openapi": "^1.1.4", "@hono/zod-openapi": "^1.1.4",
"@scalar/hono-api-reference": "^0.9.24",
"@trpc/server": "^11.7.1", "@trpc/server": "^11.7.1",
"ai": "^5.0.89",
"camelcase-keys": "^10.0.1", "camelcase-keys": "^10.0.1",
"date-fns": "catalog:", "date-fns": "catalog:",
"hono-rate-limiter": "^0.4.2", "hono-rate-limiter": "^0.4.2",
@@ -83,7 +81,7 @@
"client-only": "^0.0.1", "client-only": "^0.0.1",
"date-fns": "catalog:", "date-fns": "catalog:",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"next": "catalog:", "next": "16.0.7",
"next-international": "^1.3.1", "next-international": "^1.3.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.7.3", "nuqs": "^2.7.3",
@@ -145,11 +143,13 @@
"packages/db": { "packages/db": {
"name": "@basango/db", "name": "@basango/db",
"dependencies": { "dependencies": {
"@ai-sdk/google": "^2.0.44",
"@ai-sdk/openai": "^2.0.75",
"@basango/domain": "workspace:*", "@basango/domain": "workspace:*",
"@basango/encryption": "workspace:*", "@basango/encryption": "workspace:*",
"@basango/logger": "workspace:*", "@basango/logger": "workspace:*",
"@date-fns/utc": "^2.1.1", "@date-fns/utc": "^2.1.1",
"@devscast/config": "catalog:", "ai": "^5.0.105",
"date-fns": "catalog:", "date-fns": "catalog:",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
@@ -188,7 +188,7 @@
"packages/logger": { "packages/logger": {
"name": "@basango/logger", "name": "@basango/logger",
"dependencies": { "dependencies": {
"@devscast/config": "catalog:", "@basango/domain": "workspace:*",
"pino": "^10.1.0", "pino": "^10.1.0",
"pino-pretty": "^13.1.2", "pino-pretty": "^13.1.2",
}, },
@@ -221,6 +221,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "catalog:", "date-fns": "catalog:",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "catalog:", "react": "catalog:",
@@ -245,13 +246,13 @@
}, },
}, },
"catalog": { "catalog": {
"@devscast/config": "^1.0.3", "@devscast/config": "^1.1.1",
"@types/bun": "^1.3.1", "@types/bun": "^1.3.1",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"next": "^16.0.0", "next": "^16.0.7",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -260,11 +261,15 @@
"packages": { "packages": {
"@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="], "@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.7", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.16", "@vercel/oidc": "3.0.3" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/AI5AKi4vOK9SEb8Z1dfXkhsJ5NAfWsoJQc96B/mzn2KIrjw5occOjIwD06scuhV9xWlghCoXJT1sQD9QH/tyg=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oVAG6q72KsjKlrYdLhWjRO7rcqAR8CjokAbYuyVZoCO4Uh2PH/VzZoxZav71w2ipwlXhHCNaInGYWNs889MMDA=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.44", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-c5dck36FjqiVoeeMJQLTEmUheoURcGTU/nBT6iJu8/nZiKFT/y8pD85KMDRB7RerRYaaQOtslR2d6/5PditiRw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.75", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ThDHg1+Jes7S0AOXa01EyLBSzZiZwzB5do9vAlufNkoiRHGTH1BmoShrCyci/TUsg4ky1HwbK4hPK+Z0isiE6g=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.16", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lsWQY9aDXHitw7C1QRYIbVGmgwyT98TF3MfM8alNIXKpdJdi+W782Rzd9f1RyOfgRmZ08gJ2EYNDhWNK7RqpEA=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -480,57 +485,57 @@
"@basango/ui": ["@basango/ui@workspace:packages/ui"], "@basango/ui": ["@basango/ui@workspace:packages/ui"],
"@biomejs/biome": ["@biomejs/biome@2.3.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.6", "@biomejs/cli-darwin-x64": "2.3.6", "@biomejs/cli-linux-arm64": "2.3.6", "@biomejs/cli-linux-arm64-musl": "2.3.6", "@biomejs/cli-linux-x64": "2.3.6", "@biomejs/cli-linux-x64-musl": "2.3.6", "@biomejs/cli-win32-arm64": "2.3.6", "@biomejs/cli-win32-x64": "2.3.6" }, "bin": { "biome": "bin/biome" } }, "sha512-oqUhWyU6tae0MFsr/7iLe++QWRg+6jtUhlx9/0GmCWDYFFrK366sBLamNM7D9Y+c7YSynUFKr8lpEp1r6Sk7eA=="], "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P4JWE5d8UayBxYe197QJwyW4ZHp0B+zvRIGCusOm1WbxmlhpAQA1zEqQuunHgSIzvyEEp4TVxiKGXNFZPg7r9Q=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-I4rTebj+F/L9K93IU7yTFs8nQ6EhaCOivxduRha4w4WEZK80yoZ8OAdR1F33m4yJ/NfUuTUbP/Wjs+vKjlCoWA=="], "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-JjYy83eVBnvuINZiqyFO7xx72v8Srh4hsgaacSBCjC22DwM6+ZvnX1/fj8/SBiLuUOfZ8YhU2pfq2Dzakeyg1A=="], "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oK1NpIXIixbJ/4Tcx40cwiieqah6rRUtMGOHDeK2ToT7yUFVEvXUGRKqH0O4hqZ9tW8TcXNZKfgRH6xrsjVtGg=="], "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjPXzy5yN9wusIoX+8Zp4p6cL8r0NzJCXg/4r1KLVveIPXd2jKVlqZ6ZyzEq385WwU3OX5KOwQYLQsOc788waQ=="], "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-QvxB8GHQeaO4FCtwJpJjCgJkbHBbWxRHUxQlod+xeaYE6gtJdSkYkuxdKAQUZEOIsec+PeaDAhW9xjzYbwmOFA=="], "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-YM7hLHpwjdt8R7+O2zS1Vo2cKgqEeptiXB1tWW1rgjN5LlpZovBVKtg7zfwfRrFx3i08aNZThYpTcowpTlczug=="], "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-psgNEYgMAobY5h+QHRBVR9xvg2KocFuBKm6axZWB/aD12NWhQjiVFQUjV6wMXhlH4iT0Q9c3yK5JFRiDC/rzHA=="], "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="],
"@commitlint/cli": ["@commitlint/cli@20.1.0", "", { "dependencies": { "@commitlint/format": "^20.0.0", "@commitlint/lint": "^20.0.0", "@commitlint/load": "^20.1.0", "@commitlint/read": "^20.0.0", "@commitlint/types": "^20.0.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-pW5ujjrOovhq5RcYv5xCpb4GkZxkO2+GtOdBW2/qrr0Ll9tl3PX0aBBobGQl3mdZUbOBgwAexEQLeH6uxL0VYg=="], "@commitlint/cli": ["@commitlint/cli@20.2.0", "", { "dependencies": { "@commitlint/format": "^20.2.0", "@commitlint/lint": "^20.2.0", "@commitlint/load": "^20.2.0", "@commitlint/read": "^20.2.0", "@commitlint/types": "^20.2.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-l37HkrPZ2DZy26rKiTUvdq/LZtlMcxz+PeLv9dzK9NzoFGuJdOQyYU7IEkEQj0pO++uYue89wzOpZ0hcTtoqUA=="],
"@commitlint/config-conventional": ["@commitlint/config-conventional@20.0.0", "", { "dependencies": { "@commitlint/types": "^20.0.0", "conventional-changelog-conventionalcommits": "^7.0.2" } }, "sha512-q7JroPIkDBtyOkVe9Bca0p7kAUYxZMxkrBArCfuD3yN4KjRAenP9PmYwnn7rsw8Q+hHq1QB2BRmBh0/Z19ZoJw=="], "@commitlint/config-conventional": ["@commitlint/config-conventional@20.2.0", "", { "dependencies": { "@commitlint/types": "^20.2.0", "conventional-changelog-conventionalcommits": "^7.0.2" } }, "sha512-MsRac+yNIbTB4Q/psstKK4/ciVzACHicSwz+04Sxve+4DW+PiJeTjU0JnS4m/oOnulrXYN+yBPlKaBSGemRfgQ=="],
"@commitlint/config-validator": ["@commitlint/config-validator@20.0.0", "", { "dependencies": { "@commitlint/types": "^20.0.0", "ajv": "^8.11.0" } }, "sha512-BeyLMaRIJDdroJuYM2EGhDMGwVBMZna9UiIqV9hxj+J551Ctc6yoGuGSmghOy/qPhBSuhA6oMtbEiTmxECafsg=="], "@commitlint/config-validator": ["@commitlint/config-validator@20.2.0", "", { "dependencies": { "@commitlint/types": "^20.2.0", "ajv": "^8.11.0" } }, "sha512-SQCBGsL9MFk8utWNSthdxd9iOD1pIVZSHxGBwYIGfd67RTjxqzFOSAYeQVXOu3IxRC3YrTOH37ThnTLjUlyF2w=="],
"@commitlint/ensure": ["@commitlint/ensure@20.0.0", "", { "dependencies": { "@commitlint/types": "^20.0.0", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", "lodash.snakecase": "^4.1.1", "lodash.startcase": "^4.4.0", "lodash.upperfirst": "^4.3.1" } }, "sha512-WBV47Fffvabe68n+13HJNFBqiMH5U1Ryls4W3ieGwPC0C7kJqp3OVQQzG2GXqOALmzrgAB+7GXmyy8N9ct8/Fg=="], "@commitlint/ensure": ["@commitlint/ensure@20.2.0", "", { "dependencies": { "@commitlint/types": "^20.2.0", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", "lodash.snakecase": "^4.1.1", "lodash.startcase": "^4.4.0", "lodash.upperfirst": "^4.3.1" } }, "sha512-+8TgIGv89rOWyt3eC6lcR1H7hqChAKkpawytlq9P1i/HYugFRVqgoKJ8dhd89fMnlrQTLjA5E97/4sF09QwdoA=="],
"@commitlint/execute-rule": ["@commitlint/execute-rule@20.0.0", "", {}, "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw=="], "@commitlint/execute-rule": ["@commitlint/execute-rule@20.0.0", "", {}, "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw=="],
"@commitlint/format": ["@commitlint/format@20.0.0", "", { "dependencies": { "@commitlint/types": "^20.0.0", "chalk": "^5.3.0" } }, "sha512-zrZQXUcSDmQ4eGGrd+gFESiX0Rw+WFJk7nW4VFOmxub4mAATNKBQ4vNw5FgMCVehLUKG2OT2LjOqD0Hk8HvcRg=="], "@commitlint/format": ["@commitlint/format@20.2.0", "", { "dependencies": { "@commitlint/types": "^20.2.0", "chalk": "^5.3.0" } }, "sha512-PhNoLNhxpfIBlW/i90uZ3yG3hwSSYx7n4d9Yc+2FAorAHS0D9btYRK4ZZXX+Gm3W5tDtu911ow/eWRfcRVgNWg=="],
"@commitlint/is-ignored": ["@commitlint/is-ignored@20.0.0", "", { "dependencies": { "@commitlint/types": "^20.0.0", "semver": "^7.6.0" } }, "sha512-ayPLicsqqGAphYIQwh9LdAYOVAQ9Oe5QCgTNTj+BfxZb9b/JW222V5taPoIBzYnAP0z9EfUtljgBk+0BN4T4Cw=="], "@commitlint/is-ignored": ["@commitlint/is-ignored@20.2.0", "", { "dependencies": { "@commitlint/types": "^20.2.0", "semver": "^7.6.0" } }, "sha512-Lz0OGeZCo/QHUDLx5LmZc0EocwanneYJUM8z0bfWexArk62HKMLfLIodwXuKTO5y0s6ddXaTexrYHs7v96EOmw=="],
"@commitlint/lint": ["@commitlint/lint@20.0.0", "", { "dependencies": { "@commitlint/is-ignored": "^20.0.0", "@commitlint/parse": "^20.0.0", "@commitlint/rules": "^20.0.0", "@commitlint/types": "^20.0.0" } }, "sha512-kWrX8SfWk4+4nCexfLaQT3f3EcNjJwJBsSZ5rMBw6JCd6OzXufFHgel2Curos4LKIxwec9WSvs2YUD87rXlxNQ=="], "@commitlint/lint": ["@commitlint/lint@20.2.0", "", { "dependencies": { "@commitlint/is-ignored": "^20.2.0", "@commitlint/parse": "^20.2.0", "@commitlint/rules": "^20.2.0", "@commitlint/types": "^20.2.0" } }, "sha512-cQEEB+jlmyQbyiji/kmh8pUJSDeUmPiWq23kFV0EtW3eM+uAaMLMuoTMajbrtWYWQpPzOMDjYltQ8jxHeHgITg=="],
"@commitlint/load": ["@commitlint/load@20.1.0", "", { "dependencies": { "@commitlint/config-validator": "^20.0.0", "@commitlint/execute-rule": "^20.0.0", "@commitlint/resolve-extends": "^20.1.0", "@commitlint/types": "^20.0.0", "chalk": "^5.3.0", "cosmiconfig": "^9.0.0", "cosmiconfig-typescript-loader": "^6.1.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "lodash.uniq": "^4.5.0" } }, "sha512-qo9ER0XiAimATQR5QhvvzePfeDfApi/AFlC1G+YN+ZAY8/Ua6IRrDrxRvQAr+YXUKAxUsTDSp9KXeXLBPsNRWg=="], "@commitlint/load": ["@commitlint/load@20.2.0", "", { "dependencies": { "@commitlint/config-validator": "^20.2.0", "@commitlint/execute-rule": "^20.0.0", "@commitlint/resolve-extends": "^20.2.0", "@commitlint/types": "^20.2.0", "chalk": "^5.3.0", "cosmiconfig": "^9.0.0", "cosmiconfig-typescript-loader": "^6.1.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "lodash.uniq": "^4.5.0" } }, "sha512-iAK2GaBM8sPFTSwtagI67HrLKHIUxQc2BgpgNc/UMNme6LfmtHpIxQoN1TbP+X1iz58jq32HL1GbrFTCzcMi6g=="],
"@commitlint/message": ["@commitlint/message@20.0.0", "", {}, "sha512-gLX4YmKnZqSwkmSB9OckQUrI5VyXEYiv3J5JKZRxIp8jOQsWjZgHSG/OgEfMQBK9ibdclEdAyIPYggwXoFGXjQ=="], "@commitlint/message": ["@commitlint/message@20.0.0", "", {}, "sha512-gLX4YmKnZqSwkmSB9OckQUrI5VyXEYiv3J5JKZRxIp8jOQsWjZgHSG/OgEfMQBK9ibdclEdAyIPYggwXoFGXjQ=="],
"@commitlint/parse": ["@commitlint/parse@20.0.0", "", { "dependencies": { "@commitlint/types": "^20.0.0", "conventional-changelog-angular": "^7.0.0", "conventional-commits-parser": "^5.0.0" } }, "sha512-j/PHCDX2bGM5xGcWObOvpOc54cXjn9g6xScXzAeOLwTsScaL4Y+qd0pFC6HBwTtrH92NvJQc+2Lx9HFkVi48cg=="], "@commitlint/parse": ["@commitlint/parse@20.2.0", "", { "dependencies": { "@commitlint/types": "^20.2.0", "conventional-changelog-angular": "^7.0.0", "conventional-commits-parser": "^5.0.0" } }, "sha512-LXStagGU1ivh07X7sM+hnEr4BvzFYn1iBJ6DRg2QsIN8lBfSzyvkUcVCDwok9Ia4PWiEgei5HQjju6xfJ1YaSQ=="],
"@commitlint/read": ["@commitlint/read@20.0.0", "", { "dependencies": { "@commitlint/top-level": "^20.0.0", "@commitlint/types": "^20.0.0", "git-raw-commits": "^4.0.0", "minimist": "^1.2.8", "tinyexec": "^1.0.0" } }, "sha512-Ti7Y7aEgxsM1nkwA4ZIJczkTFRX/+USMjNrL9NXwWQHqNqrBX2iMi+zfuzZXqfZ327WXBjdkRaytJ+z5vNqTOA=="], "@commitlint/read": ["@commitlint/read@20.2.0", "", { "dependencies": { "@commitlint/top-level": "^20.0.0", "@commitlint/types": "^20.2.0", "git-raw-commits": "^4.0.0", "minimist": "^1.2.8", "tinyexec": "^1.0.0" } }, "sha512-+SjF9mxm5JCbe+8grOpXCXMMRzAnE0WWijhhtasdrpJoAFJYd5UgRTj/oCq5W3HJTwbvTOsijEJ0SUGImECD7Q=="],
"@commitlint/resolve-extends": ["@commitlint/resolve-extends@20.1.0", "", { "dependencies": { "@commitlint/config-validator": "^20.0.0", "@commitlint/types": "^20.0.0", "global-directory": "^4.0.1", "import-meta-resolve": "^4.0.0", "lodash.mergewith": "^4.6.2", "resolve-from": "^5.0.0" } }, "sha512-cxKXQrqHjZT3o+XPdqDCwOWVFQiae++uwd9dUBC7f2MdV58ons3uUvASdW7m55eat5sRiQ6xUHyMWMRm6atZWw=="], "@commitlint/resolve-extends": ["@commitlint/resolve-extends@20.2.0", "", { "dependencies": { "@commitlint/config-validator": "^20.2.0", "@commitlint/types": "^20.2.0", "global-directory": "^4.0.1", "import-meta-resolve": "^4.0.0", "lodash.mergewith": "^4.6.2", "resolve-from": "^5.0.0" } }, "sha512-KVoLDi9BEuqeq+G0wRABn4azLRiCC22/YHR2aCquwx6bzCHAIN8hMt3Nuf1VFxq/c8ai6s8qBxE8+ZD4HeFTlQ=="],
"@commitlint/rules": ["@commitlint/rules@20.0.0", "", { "dependencies": { "@commitlint/ensure": "^20.0.0", "@commitlint/message": "^20.0.0", "@commitlint/to-lines": "^20.0.0", "@commitlint/types": "^20.0.0" } }, "sha512-gvg2k10I/RfvHn5I5sxvVZKM1fl72Sqrv2YY/BnM7lMHcYqO0E2jnRWoYguvBfEcZ39t+rbATlciggVe77E4zA=="], "@commitlint/rules": ["@commitlint/rules@20.2.0", "", { "dependencies": { "@commitlint/ensure": "^20.2.0", "@commitlint/message": "^20.0.0", "@commitlint/to-lines": "^20.0.0", "@commitlint/types": "^20.2.0" } }, "sha512-27rHGpeAjnYl/A+qUUiYDa7Yn1WIjof/dFJjYW4gA1Ug+LUGa1P0AexzGZ5NBxTbAlmDgaxSZkLLxtLVqtg8PQ=="],
"@commitlint/to-lines": ["@commitlint/to-lines@20.0.0", "", {}, "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw=="], "@commitlint/to-lines": ["@commitlint/to-lines@20.0.0", "", {}, "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw=="],
"@commitlint/top-level": ["@commitlint/top-level@20.0.0", "", { "dependencies": { "find-up": "^7.0.0" } }, "sha512-drXaPSP2EcopukrUXvUXmsQMu3Ey/FuJDc/5oiW4heoCfoE5BdLQyuc7veGeE3aoQaTVqZnh4D5WTWe2vefYKg=="], "@commitlint/top-level": ["@commitlint/top-level@20.0.0", "", { "dependencies": { "find-up": "^7.0.0" } }, "sha512-drXaPSP2EcopukrUXvUXmsQMu3Ey/FuJDc/5oiW4heoCfoE5BdLQyuc7veGeE3aoQaTVqZnh4D5WTWe2vefYKg=="],
"@commitlint/types": ["@commitlint/types@20.0.0", "", { "dependencies": { "@types/conventional-commits-parser": "^5.0.0", "chalk": "^5.3.0" } }, "sha512-bVUNBqG6aznYcYjTjnc3+Cat/iBgbgpflxbIBTnsHTX0YVpnmINPEkSRWymT2Q8aSH3Y7aKnEbunilkYe8TybA=="], "@commitlint/types": ["@commitlint/types@20.2.0", "", { "dependencies": { "@types/conventional-commits-parser": "^5.0.0", "chalk": "^5.3.0" } }, "sha512-KTy0OqRDLR5y/zZMnizyx09z/rPlPC/zKhYgH8o/q6PuAjoQAKlRfY4zzv0M64yybQ//6//4H1n14pxaLZfUnA=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -538,7 +543,7 @@
"@date-fns/utc": ["@date-fns/utc@2.1.1", "", {}, "sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA=="], "@date-fns/utc": ["@date-fns/utc@2.1.1", "", {}, "sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA=="],
"@devscast/config": ["@devscast/config@1.0.3", "", { "peerDependencies": { "ini": "^6.0.0", "yaml": "^2.8.1", "zod": "^4.1.12" }, "optionalPeers": ["ini", "yaml"] }, "sha512-/FjCA/MV1KR2tY44YBA4tdXNzQgoF75O+RQ4fbzvVWY77PXOama2Hf6YXeLcQsvxfItaXi2cFz8BaaVdqZYS8w=="], "@devscast/config": ["@devscast/config@1.1.1", "", { "peerDependencies": { "ini": "^6.0.0", "yaml": "^2.8.1", "zod": "^4.1.12" }, "optionalPeers": ["ini", "yaml"] }, "sha512-PyGV43m6V8sO66EOsKXWkohisH90rQZIcEgbGB2yVJ+BAfwj1P3rUx3DifpndX/Go8Ng9YbkjCNYBKYk5FwSgQ=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
@@ -780,23 +785,23 @@
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
"@next/env": ["@next/env@16.0.1", "", {}, "sha512-LFvlK0TG2L3fEOX77OC35KowL8D7DlFF45C0OvKMC4hy8c/md1RC4UMNDlUGJqfCoCS2VWrZ4dSE6OjaX5+8mw=="], "@next/env": ["@next/env@16.0.7", "", {}, "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R0YxRp6/4W7yG1nKbfu41bp3d96a0EalonQXiMe+1H9GTHfKxGNCGFNWUho18avRBPsO8T3RmdWuzmfurlQPbg=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-kETZBocRux3xITiZtOtVoVvXyQLB7VBxN7L6EPqgI5paZiUlnsgYv4q8diTNYeHmF9EiehydOBo20lTttCbHAg=="], "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.0.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-hWg3BtsxQuSKhfe0LunJoqxjO4NEpBmKkE+P2Sroos7yB//OOX3jD5ISP2wv8QdUwtRehMdwYz6VB50mY6hqAg=="], "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.0.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-UPnOvYg+fjAhP3b1iQStcYPWeBFRLrugEyK/lDKGk7kLNua8t5/DvDbAEFotfV1YfcOY6bru76qN9qnjLoyHCQ=="], "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.0.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Et81SdWkcRqAJziIgFtsFyJizHoWne4fzJkvjd6V4wEkWTB4MX6J0uByUb0peiJQ4WeAt6GGmMszE5KrXK6WKg=="], "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.0.7", "", { "os": "linux", "cpu": "x64" }, "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qBbgYEBRrC1egcG03FZaVfVxrJm8wBl7vr8UFKplnxNRprctdP26xEv9nJ07Ggq4y1adwa0nz2mz83CELY7N6Q=="], "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.0.7", "", { "os": "linux", "cpu": "x64" }, "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-cPuBjYP6I699/RdbHJonb3BiRNEDm5CKEBuJ6SD8k3oLam2fDRMKAvmrli4QMDgT2ixyRJ0+DTkiODbIQhRkeQ=="], "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.0.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-XeEUJsE4JYtfrXe/LaJn3z1pD19fK0Q6Er8Qoufi+HqvdO4LEPyCxLUt4rxA+4RfYo6S9gMlmzCMU2F+AatFqQ=="], "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.7", "", { "os": "win32", "cpu": "x64" }, "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -944,14 +949,6 @@
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.10.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.2.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA=="], "@reduxjs/toolkit": ["@reduxjs/toolkit@2.10.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.2.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA=="],
"@scalar/core": ["@scalar/core@0.3.22", "", { "dependencies": { "@scalar/types": "0.4.0" } }, "sha512-6lzeRkvgkukSgge35kvxJKiJBny4rdGSaLTNzn/sF1F6JRfUo7I0AgqFxxSZWMD+EG4kGyNxAz0zciDSx2Cjvw=="],
"@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.9.24", "", { "dependencies": { "@scalar/core": "0.3.22" }, "peerDependencies": { "hono": "^4.10.3" } }, "sha512-NjPY3iMm/FqYRXAgr6V7qBhJGbSUQ8hbijFUMuqZo4pIjGEUNLeB5L9U2Gh4cDIPPWeso8mlc16jaX7dV0FrPw=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.5.1", "", { "dependencies": { "zod": "4.1.11" } }, "sha512-8g7s9lPolyDFtijyh3Ob459tpezPuZbkXoFgJwBTHjPZ7ap+TvOJTvLk56CFwxVBVz2BxCzWJqxYyy3FUdeLoA=="],
"@scalar/types": ["@scalar/types@0.4.0", "", { "dependencies": { "@scalar/openapi-types": "0.5.1", "nanoid": "5.1.5", "type-fest": "5.0.0", "zod": "4.1.11" } }, "sha512-vOD1GZez7kPdVA+UQit05QE9dbALfevhK9kqRTsqcPX7FvvZ9eQWSNl1GKmKtmRiAZGThv2agM5AvHRxkH2JSw=="],
"@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
"@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="],
@@ -1034,7 +1031,7 @@
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="], "@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="], "@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="],
@@ -1100,7 +1097,7 @@
"@urql/exchange-retry": ["@urql/exchange-retry@1.3.2", "", { "dependencies": { "@urql/core": "^5.1.2", "wonka": "^6.3.2" } }, "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg=="], "@urql/exchange-retry": ["@urql/exchange-retry@1.3.2", "", { "dependencies": { "@urql/core": "^5.1.2", "wonka": "^6.3.2" } }, "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg=="],
"@vercel/oidc": ["@vercel/oidc@3.0.3", "", {}, "sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg=="], "@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
@@ -1118,7 +1115,7 @@
"aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="],
"ai": ["ai@5.0.89", "", { "dependencies": { "@ai-sdk/gateway": "2.0.7", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.16", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8Nq+ZojGacQrupoJEQLrTDzT5VtR3gyp5AaqFSV3tzsAXlYQ9Igb7QE3yeoEdzOk5IRfDwWL7mDCUD+oBg1hDA=="], "ai": ["ai@5.0.105", "", { "dependencies": { "@ai-sdk/gateway": "2.0.17", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-waQZAvv44KYzys6S3l25ti2jcSuJnkyWFTliSKy3swASL6w6ttPxJTm80d+v9sLWoIxrqE3OwhTJbweNp065fg=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@@ -1218,7 +1215,7 @@
"bullmq": ["bullmq@4.18.3", "", { "dependencies": { "cron-parser": "^4.6.0", "glob": "^8.0.3", "ioredis": "^5.3.2", "lodash": "^4.17.21", "msgpackr": "^1.6.2", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", "uuid": "^9.0.0" } }, "sha512-H8t9vhfHEbJDaXp7aalSTe+Do+tR1nvr+lsT+jQxLhy+FFfFj/0p4aYJzADTNLdEqltuxneLVxCGVg92GkQx4w=="], "bullmq": ["bullmq@4.18.3", "", { "dependencies": { "cron-parser": "^4.6.0", "glob": "^8.0.3", "ioredis": "^5.3.2", "lodash": "^4.17.21", "msgpackr": "^1.6.2", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", "uuid": "^9.0.0" } }, "sha512-H8t9vhfHEbJDaXp7aalSTe+Do+tR1nvr+lsT+jQxLhy+FFfFj/0p4aYJzADTNLdEqltuxneLVxCGVg92GkQx4w=="],
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -1434,6 +1431,12 @@
"electron-to-chromium": ["electron-to-chromium@1.5.249", "", {}, "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg=="], "electron-to-chromium": ["electron-to-chromium@1.5.249", "", {}, "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg=="],
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
"embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@@ -1978,7 +1981,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"next": ["next@16.0.1", "", { "dependencies": { "@next/env": "16.0.1", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.1", "@next/swc-darwin-x64": "16.0.1", "@next/swc-linux-arm64-gnu": "16.0.1", "@next/swc-linux-arm64-musl": "16.0.1", "@next/swc-linux-x64-gnu": "16.0.1", "@next/swc-linux-x64-musl": "16.0.1", "@next/swc-win32-arm64-msvc": "16.0.1", "@next/swc-win32-x64-msvc": "16.0.1", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw=="], "next": ["next@16.0.7", "", { "dependencies": { "@next/env": "16.0.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.7", "@next/swc-darwin-x64": "16.0.7", "@next/swc-linux-arm64-gnu": "16.0.7", "@next/swc-linux-arm64-musl": "16.0.7", "@next/swc-linux-x64-gnu": "16.0.7", "@next/swc-linux-x64-musl": "16.0.7", "@next/swc-win32-arm64-msvc": "16.0.7", "@next/swc-win32-x64-msvc": "16.0.7", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A=="],
"next-international": ["next-international@1.3.1", "", { "dependencies": { "client-only": "^0.0.1", "international-types": "^0.8.1", "server-only": "^0.0.1" } }, "sha512-ydU9jQe+4MohMWltbZae/yuWeKhmp0QKQqJNNi8WCCMwrly03qfMAHw/tWbT2qgAlG++CxF5jMXmGQZgOHeVOw=="], "next-international": ["next-international@1.3.1", "", { "dependencies": { "client-only": "^0.0.1", "international-types": "^0.8.1", "server-only": "^0.0.1" } }, "sha512-ydU9jQe+4MohMWltbZae/yuWeKhmp0QKQqJNNi8WCCMwrly03qfMAHw/tWbT2qgAlG++CxF5jMXmGQZgOHeVOw=="],
@@ -2410,8 +2413,6 @@
"swap-case": ["swap-case@1.1.2", "", { "dependencies": { "lower-case": "^1.1.1", "upper-case": "^1.1.1" } }, "sha512-BAmWG6/bx8syfc6qXPprof3Mn5vQgf5dwdUNJhsNqU9WdPt5P+ES/wQ5bxfijy8zwZgZZHslC3iAsxsuQMCzJQ=="], "swap-case": ["swap-case@1.1.2", "", { "dependencies": { "lower-case": "^1.1.1", "upper-case": "^1.1.1" } }, "sha512-BAmWG6/bx8syfc6qXPprof3Mn5vQgf5dwdUNJhsNqU9WdPt5P+ES/wQ5bxfijy8zwZgZZHslC3iAsxsuQMCzJQ=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
@@ -2470,19 +2471,19 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.6.1", "", { "optionalDependencies": { "turbo-darwin-64": "2.6.1", "turbo-darwin-arm64": "2.6.1", "turbo-linux-64": "2.6.1", "turbo-linux-arm64": "2.6.1", "turbo-windows-64": "2.6.1", "turbo-windows-arm64": "2.6.1" }, "bin": { "turbo": "bin/turbo" } }, "sha512-qBwXXuDT3rA53kbNafGbT5r++BrhRgx3sAo0cHoDAeG9g1ItTmUMgltz3Hy7Hazy1ODqNpR+C7QwqL6DYB52yA=="], "turbo": ["turbo@2.6.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.6.3", "turbo-darwin-arm64": "2.6.3", "turbo-linux-64": "2.6.3", "turbo-linux-arm64": "2.6.3", "turbo-windows-64": "2.6.3", "turbo-windows-arm64": "2.6.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bf6YKUv11l5Xfcmg76PyWoy/e2vbkkxFNBGJSnfdSXQC33ZiUfutYh6IXidc5MhsnrFkWfdNNLyaRk+kHMLlwA=="],
"turbo-darwin-64": ["turbo-darwin-64@2.6.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dm0HwhyZF4J0uLqkhUyCVJvKM9Rw7M03v3J9A7drHDQW0qAbIGBrUijQ8g4Q9Cciw/BXRRd8Uzkc3oue+qn+ZQ=="], "turbo-darwin-64": ["turbo-darwin-64@2.6.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlJJDc1CQ7SK5Y5qnl7AzpkvKSnpkfPmnA+HeU/sgny3oHZckPV2776ebO2M33CYDSor7+8HQwaodY++IINhYg=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.6.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U0PIPTPyxdLsrC3jN7jaJUwgzX5sVUBsKLO7+6AL+OASaa1NbT1pPdiZoTkblBAALLP76FM0LlnsVQOnmjYhyw=="], "turbo-darwin-arm64": ["turbo-darwin-arm64@2.6.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MwVt7rBKiOK7zdYerenfCRTypefw4kZCue35IJga9CH1+S50+KTiCkT6LBqo0hHeoH2iKuI0ldTF2a0aB72z3w=="],
"turbo-linux-64": ["turbo-linux-64@2.6.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eM1uLWgzv89bxlK29qwQEr9xYWBhmO/EGiH22UGfq+uXr+QW1OvNKKMogSN65Ry8lElMH4LZh0aX2DEc7eC0Mw=="], "turbo-linux-64": ["turbo-linux-64@2.6.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cqpcw+dXxbnPtNnzeeSyWprjmuFVpHJqKcs7Jym5oXlu/ZcovEASUIUZVN3OGEM6Y/OTyyw0z09tOHNt5yBAVg=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.6.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MFFh7AxAQAycXKuZDrbeutfWM5Ep0CEZ9u7zs4Hn2FvOViTCzIfEhmuJou3/a5+q5VX1zTxQrKGy+4Lf5cdpsA=="], "turbo-linux-arm64": ["turbo-linux-arm64@2.6.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-MterpZQmjXyr4uM7zOgFSFL3oRdNKeflY7nsjxJb2TklsYqiu3Z9pQ4zRVFFH8n0mLGna7MbQMZuKoWqqHb45w=="],
"turbo-windows-64": ["turbo-windows-64@2.6.1", "", { "os": "win32", "cpu": "x64" }, "sha512-buq7/VAN7KOjMYi4tSZT5m+jpqyhbRU2EUTTvp6V0Ii8dAkY2tAAjQN1q5q2ByflYWKecbQNTqxmVploE0LVwQ=="], "turbo-windows-64": ["turbo-windows-64@2.6.3", "", { "os": "win32", "cpu": "x64" }, "sha512-biDU70v9dLwnBdLf+daoDlNJVvqOOP8YEjqNipBHzgclbQlXbsi6Gqqelp5er81Qo3BiRgmTNx79oaZQTPb07Q=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.6.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-7w+AD5vJp3R+FB0YOj1YJcNcOOvBior7bcHTodqp90S3x3bLgpr7tE6xOea1e8JkP7GK6ciKVUpQvV7psiwU5Q=="], "turbo-windows-arm64": ["turbo-windows-arm64@2.6.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-dDHVKpSeukah3VsI/xMEKeTnV9V9cjlpFSUs4bmsUiLu3Yv2ENlgVEZv65wxbeE0bh0jjpmElDT+P1KaCxArQQ=="],
"turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="], "turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="],
@@ -2816,14 +2817,6 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@scalar/openapi-types/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
"@scalar/types/type-fest": ["type-fest@5.0.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-GeJop7+u7BYlQ6yQCAY1nBQiRSHR+6OdCEtd8Bwp9a3NK3+fWAVjOaPKJDteB9f6cIJ0wt4IfnScjLG450EpXA=="],
"@scalar/types/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q=="],
@@ -2884,8 +2877,6 @@
"bullmq/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "bullmq/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"chrome-launcher/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], "chrome-launcher/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
@@ -2904,6 +2895,8 @@
"connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"cz-conventional-changelog/@commitlint/load": ["@commitlint/load@20.1.0", "", { "dependencies": { "@commitlint/config-validator": "^20.0.0", "@commitlint/execute-rule": "^20.0.0", "@commitlint/resolve-extends": "^20.1.0", "@commitlint/types": "^20.0.0", "chalk": "^5.3.0", "cosmiconfig": "^9.0.0", "cosmiconfig-typescript-loader": "^6.1.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "lodash.uniq": "^4.5.0" } }, "sha512-qo9ER0XiAimATQR5QhvvzePfeDfApi/AFlC1G+YN+ZAY8/Ua6IRrDrxRvQAr+YXUKAxUsTDSp9KXeXLBPsNRWg=="],
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -3278,6 +3271,14 @@
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"cz-conventional-changelog/@commitlint/load/@commitlint/config-validator": ["@commitlint/config-validator@20.0.0", "", { "dependencies": { "@commitlint/types": "^20.0.0", "ajv": "^8.11.0" } }, "sha512-BeyLMaRIJDdroJuYM2EGhDMGwVBMZna9UiIqV9hxj+J551Ctc6yoGuGSmghOy/qPhBSuhA6oMtbEiTmxECafsg=="],
"cz-conventional-changelog/@commitlint/load/@commitlint/resolve-extends": ["@commitlint/resolve-extends@20.1.0", "", { "dependencies": { "@commitlint/config-validator": "^20.0.0", "@commitlint/types": "^20.0.0", "global-directory": "^4.0.1", "import-meta-resolve": "^4.0.0", "lodash.mergewith": "^4.6.2", "resolve-from": "^5.0.0" } }, "sha512-cxKXQrqHjZT3o+XPdqDCwOWVFQiae++uwd9dUBC7f2MdV58ons3uUvASdW7m55eat5sRiQ6xUHyMWMRm6atZWw=="],
"cz-conventional-changelog/@commitlint/load/@commitlint/types": ["@commitlint/types@20.0.0", "", { "dependencies": { "@types/conventional-commits-parser": "^5.0.0", "chalk": "^5.3.0" } }, "sha512-bVUNBqG6aznYcYjTjnc3+Cat/iBgbgpflxbIBTnsHTX0YVpnmINPEkSRWymT2Q8aSH3Y7aKnEbunilkYe8TybA=="],
"cz-conventional-changelog/@commitlint/load/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"expo-modules-autolinking/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "expo-modules-autolinking/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"expo-modules-autolinking/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "expo-modules-autolinking/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
-23
View File
@@ -48,29 +48,6 @@ services:
networks: networks:
- basango_network - basango_network
nginx:
build: .docker/nginx
ports:
- "8000:80"
volumes:
- ./projects/api-legacy/public:/var/www/public:delegated
depends_on:
- php
networks:
- basango_network
php:
user: '${USER_ID:-1000}:${GROUP_ID:-1000}'
build: .docker/php
volumes:
- ./projects/api-legacy:/var/www:delegated
depends_on:
- mariadb
- postgres
- redis
networks:
- basango_network
adminer: adminer:
image: adminer:latest image: adminer:latest
depends_on: depends_on:
+8 -7
View File
@@ -1,12 +1,12 @@
{ {
"catalog": { "catalog": {
"@devscast/config": "^1.0.3", "@devscast/config": "^1.1.1",
"@types/bun": "^1.3.1", "@types/bun": "^1.3.1",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"next": "^16.0.0", "next": "^16.0.7",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -24,16 +24,16 @@
}, },
"devDependencies": { "devDependencies": {
"@basango/tsconfig": "workspace:*", "@basango/tsconfig": "workspace:*",
"@biomejs/biome": "^2.3.6", "@biomejs/biome": "^2.3.8",
"@commitlint/cli": "^20.1.0", "@commitlint/cli": "^20.2.0",
"@commitlint/config-conventional": "^20.0.0", "@commitlint/config-conventional": "^20.2.0",
"@manypkg/cli": "^0.25.1", "@manypkg/cli": "^0.25.1",
"@types/bun": "^1.3.2", "@types/bun": "^1.3.4",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"commitizen": "^4.3.1", "commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0", "cz-conventional-changelog": "^3.3.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"turbo": "^2.6.1", "turbo": "^2.6.3",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"engines": { "engines": {
@@ -52,6 +52,7 @@
"dev:dashboard": "turbo dev --filter=@basango/dashboard", "dev:dashboard": "turbo dev --filter=@basango/dashboard",
"format": "biome format --write && biome check --write && biome lint --write", "format": "biome format --write && biome check --write && biome lint --write",
"lint": "biome check && biome lint && manypkg check", "lint": "biome check && biome lint && manypkg check",
"migrate": "cd packages/db && bunx drizzle-kit migrate",
"prepare": "husky", "prepare": "husky",
"start:api": "turbo start --filter=@basango/api", "start:api": "turbo start --filter=@basango/api",
"start:dashboard": "turbo start --filter=@basango/dashboard", "start:dashboard": "turbo start --filter=@basango/dashboard",
-6
View File
@@ -1,6 +0,0 @@
BASANGO_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/app?serverVersion=16&charset=utf8"
BASANGO_SOURCE_DATABASE_HOST="localhost"
BASANGO_SOURCE_DATABASE_PASS="root"
BASANGO_SOURCE_DATABASE_NAME="app"
BASANGO_SOURCE_DATABASE_USER="root"
+2 -3
View File
@@ -1,10 +1,9 @@
import { config } from "@basango/domain/config";
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
import { env } from "./src/config";
export default defineConfig({ export default defineConfig({
dbCredentials: { dbCredentials: {
url: env("BASANGO_DATABASE_URL"), url: config.database.url,
}, },
dialect: "postgresql", dialect: "postgresql",
out: "./migrations", out: "./migrations",
@@ -0,0 +1,2 @@
ALTER TABLE "article" drop column "tsv";--> statement-breakpoint
ALTER TABLE "article" ADD COLUMN "tsv" "tsvector" GENERATED ALWAYS AS (setweight(to_tsvector('french'::regconfig, COALESCE(title, '')::text), 'A'::"char")) STORED;--> statement-breakpoint
@@ -0,0 +1,20 @@
CREATE TABLE "category" (
"candidates" text[] NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"description" varchar(512),
"embeddings" jsonb,
"id" uuid PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"slug" varchar(255) NOT NULL,
"updated_at" timestamp,
"weight" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
ALTER TABLE "article" ADD COLUMN "category_id" uuid;--> statement-breakpoint
ALTER TABLE "article" ADD COLUMN "clustered" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "unq_category_name" ON "category" USING btree (lower((name)::text));--> statement-breakpoint
CREATE UNIQUE INDEX "unq_category_slug" ON "category" USING btree (lower((slug)::text));--> statement-breakpoint
CREATE INDEX "idx_category_weight" ON "category" USING btree ("weight");--> statement-breakpoint
ALTER TABLE "article" ADD CONSTRAINT "fk_article_category_id" FOREIGN KEY ("category_id") REFERENCES "public"."category"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_article_category_id" ON "article" USING btree ("category_id");--> statement-breakpoint
CREATE INDEX "idx_article_clustered" ON "article" USING btree ("clustered");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -14,6 +14,20 @@
"tag": "0001_init", "tag": "0001_init",
"version": "7", "version": "7",
"when": 1762775267679 "when": 1762775267679
},
{
"breakpoints": true,
"idx": 2,
"tag": "0002_modern_joseph",
"version": "7",
"when": 1763920009482
},
{
"breakpoints": true,
"idx": 3,
"tag": "0003_categories",
"version": "7",
"when": 1764767993880
} }
], ],
"version": "7" "version": "7"
+4 -1
View File
@@ -1,10 +1,12 @@
{ {
"dependencies": { "dependencies": {
"@ai-sdk/google": "^2.0.44",
"@ai-sdk/openai": "^2.0.75",
"@basango/domain": "workspace:*", "@basango/domain": "workspace:*",
"@basango/encryption": "workspace:*", "@basango/encryption": "workspace:*",
"@basango/logger": "workspace:*", "@basango/logger": "workspace:*",
"@date-fns/utc": "^2.1.1", "@date-fns/utc": "^2.1.1",
"@devscast/config": "catalog:", "ai": "^5.0.105",
"date-fns": "catalog:", "date-fns": "catalog:",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
@@ -32,6 +34,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
"sync:categories": "bun ./src/synchronizers/categories.ts",
"sync:data": "bun ./src/synchronizers/data.ts", "sync:data": "bun ./src/synchronizers/data.ts",
"sync:tokens": "bun ./src/synchronizers/tokens.ts", "sync:tokens": "bun ./src/synchronizers/tokens.ts",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
+2 -2
View File
@@ -1,14 +1,14 @@
import { config } from "@basango/domain/config";
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg"; import { Pool } from "pg";
import { env } from "#db/config";
import * as schema from "#db/schema"; import * as schema from "#db/schema";
const isDevelopment = process.env.NODE_ENV === "development"; const isDevelopment = process.env.NODE_ENV === "development";
const pool = new Pool({ const pool = new Pool({
allowExitOnIdle: true, allowExitOnIdle: true,
connectionString: env("BASANGO_DATABASE_URL"), connectionString: config.database.url,
connectionTimeoutMillis: 15_000, connectionTimeoutMillis: 15_000,
idleTimeoutMillis: isDevelopment ? 5_000 : 60_000, idleTimeoutMillis: isDevelopment ? 5_000 : 60_000,
max: isDevelopment ? 8 : 12, max: isDevelopment ? 8 : 12,
-20
View File
@@ -1,20 +0,0 @@
import path from "node:path";
import { loadConfig } from "@devscast/config";
import { z } from "zod";
const PROJECT_DIR = path.resolve(__dirname, "../");
export const { env, config } = loadConfig({
env: {
knownKeys: [
"BASANGO_DATABASE_URL",
"BASANGO_SOURCE_DATABASE_HOST",
"BASANGO_SOURCE_DATABASE_USER",
"BASANGO_SOURCE_DATABASE_PASS",
"BASANGO_SOURCE_DATABASE_NAME",
] as const,
path: path.join(PROJECT_DIR, ".env"),
},
schema: z.object({}),
});
+15 -6
View File
@@ -12,11 +12,12 @@ import {
import { md5 } from "@basango/encryption"; import { md5 } from "@basango/encryption";
import type { SQL } from "drizzle-orm"; import type { SQL } from "drizzle-orm";
import { count, desc, eq, getTableColumns, sql } from "drizzle-orm"; import { count, desc, eq, getTableColumns, sql } from "drizzle-orm";
import { v7 as uuidV7 } from "uuid"; import * as uuid from "uuid";
import { Database } from "#db/client"; import { Database } from "#db/client";
import { getSourceIdByName } from "#db/queries/sources"; import { getSourceIdByName } from "#db/queries/sources";
import { articles, sources } from "#db/schema"; import { articles, categories, sources } from "#db/schema";
import { classifyCategory } from "#db/services/category-classifier";
import { CreateArticleParams, GetArticlesParams } from "#db/types/articles"; import { CreateArticleParams, GetArticlesParams } from "#db/types/articles";
import { GetDistributionsParams, GetPublicationsParams } from "#db/types/shared"; import { GetDistributionsParams, GetPublicationsParams } from "#db/types/shared";
import { import {
@@ -43,20 +44,24 @@ export async function createArticle(db: Database, params: CreateArticleParams) {
const data = { const data = {
...params, ...params,
categories: params.categories ?? [],
hash: md5(params.link), hash: md5(params.link),
id: uuid.v7(),
readingTime: computeReadingTime(params.body), readingTime: computeReadingTime(params.body),
sentiment: "neutral" as Sentiment, sentiment: (params.sentiment ?? "neutral") as Sentiment,
sourceId: await getSourceIdByName(db, params.sourceId), sourceId: await getSourceIdByName(db, params.sourceId),
tokenStatistics: computeTokenStatistics({ tokenStatistics: computeTokenStatistics({
body: params.body, body: params.body,
categories: params.categories, categories: params.categories ?? [],
title: params.title, title: params.title,
}), }),
}; };
data.categoryId = classifyCategory(data).category.id;
const [result] = await db const [result] = await db
.insert(articles) .insert(articles)
.values({ id: uuidV7(), ...data }) .values({ ...data })
.returning({ .returning({
id: articles.id, id: articles.id,
sourceId: articles.sourceId, sourceId: articles.sourceId,
@@ -103,7 +108,7 @@ function buildFilters(params: GetArticlesParams, pagination: PaginationState) {
} }
if (params.category) { if (params.category) {
filters.push(sql`${params.category} = ANY(${articles.categories})`); filters.push(eq(articles.categoryId, params.category));
} }
if (params.search?.trim()) { if (params.search?.trim()) {
@@ -133,11 +138,15 @@ export async function getArticles(db: Database, params: GetArticlesParams) {
const query = db const query = db
.select({ .select({
...getTableColumns(articles), ...getTableColumns(articles),
category: {
...getTableColumns(categories),
},
source: { source: {
...getTableColumns(sources), ...getTableColumns(sources),
}, },
}) })
.from(articles) .from(articles)
.leftJoin(categories, eq(articles.categoryId, categories.id))
.innerJoin(sources, eq(articles.sourceId, sources.id)); .innerJoin(sources, eq(articles.sourceId, sources.id));
const rows = await applyFilters(query, filters) const rows = await applyFilters(query, filters)
+10
View File
@@ -0,0 +1,10 @@
import { asc, desc } from "drizzle-orm";
import { Database } from "#db/client";
import { categories } from "#db/schema";
export async function getCategories(db: Database) {
return db.query.categories.findMany({
orderBy: [desc(categories.weight), asc(categories.name)],
});
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./articles"; export * from "./articles";
export * from "./categories";
export * from "./reports"; export * from "./reports";
export * from "./sources"; export * from "./sources";
export * from "./users"; export * from "./users";
+40 -15
View File
@@ -1,11 +1,11 @@
import { DEFAULT_CATEGORY_SHARES_LIMIT, DEFAULT_TIMEZONE } from "@basango/domain/constants"; import { DEFAULT_CATEGORY_SHARES_LIMIT, DEFAULT_TIMEZONE } from "@basango/domain/constants";
import { ID, Publication, Publications } from "@basango/domain/models"; import { ID, Publication, Publications } from "@basango/domain/models";
import { eq, sql } from "drizzle-orm"; import { eq, max, min, sql } from "drizzle-orm";
import { v7 as uuidV7 } from "uuid"; import * as uuid from "uuid";
import { Database } from "#db/client"; import { Database } from "#db/client";
import { NotFoundError } from "#db/errors"; import { NotFoundError } from "#db/errors";
import { articles, sources } from "#db/schema"; import { articles, categories, sources } from "#db/schema";
import { import {
CategoryShare, CategoryShare,
CategoryShares, CategoryShares,
@@ -32,7 +32,7 @@ export async function getSources(db: Database) {
export async function createSource(db: Database, params: CreateSourceParams) { export async function createSource(db: Database, params: CreateSourceParams) {
const [result] = await db const [result] = await db
.insert(sources) .insert(sources)
.values({ id: uuidV7(), ...params }) .values({ id: uuid.v7(), ...params })
.returning(); .returning();
return result; return result;
@@ -144,20 +144,45 @@ export async function getSourceCategoryShares(
): Promise<CategoryShares> { ): Promise<CategoryShares> {
const data = await db.execute<CategoryShare>(sql` const data = await db.execute<CategoryShare>(sql`
SELECT SELECT
cat AS category, ${categories.id}::text AS "categoryId",
COUNT(*)::int AS count, ${categories.slug} AS slug,
ROUND((COUNT(*)::numeric / SUM(COUNT(*)) OVER ()) * 100, 2) AS percentage ${categories.name} AS category,
FROM ( COUNT(${articles.id})::int AS count,
SELECT NULLIF(BTRIM(c), '') AS cat COALESCE(
FROM ${articles} ROUND((COUNT(*)::numeric / NULLIF(SUM(COUNT(*)) OVER (), 0)) * 100, 2),
CROSS JOIN LATERAL UNNEST(COALESCE(${articles.categories}, ARRAY[]::text[])) AS c 0
WHERE ${articles.sourceId} = ${params.id} )::float AS percentage
) t FROM ${articles}
WHERE cat IS NOT NULL JOIN ${categories} ON ${categories.id} = ${articles.categoryId}
GROUP BY cat WHERE ${articles.sourceId} = ${params.id} AND ${articles.clustered} = true
GROUP BY ${categories.id}, ${categories.slug}, ${categories.name}
ORDER BY count DESC ORDER BY count DESC
LIMIT ${params.limit ?? DEFAULT_CATEGORY_SHARES_LIMIT} LIMIT ${params.limit ?? DEFAULT_CATEGORY_SHARES_LIMIT}
`); `);
return { items: data.rows, total: data.rowCount ?? 0 }; return { items: data.rows, total: data.rowCount ?? 0 };
} }
export async function getLatestPublished(db: Database, source: string): Promise<Date> {
const result = await db
.select({
publishedAt: max(articles.publishedAt),
})
.from(articles)
.innerJoin(sources, eq(articles.sourceId, sources.id))
.where(eq(sources.name, source));
return result[0]?.publishedAt ?? new Date();
}
export async function getEarliestPublished(db: Database, source: string): Promise<Date> {
const result = await db
.select({
publishedAt: min(articles.publishedAt),
})
.from(articles)
.innerJoin(sources, eq(articles.sourceId, sources.id))
.where(eq(sources.name, source));
return result[0]?.publishedAt ?? new Date();
}
+38 -4
View File
@@ -94,11 +94,33 @@ export const sources = pgTable(
], ],
); );
export const categories = pgTable(
"category",
{
candidates: text().array().notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
description: varchar({ length: 512 }),
embeddings: jsonb("embeddings").$type<number[]>(),
id: uuid().primaryKey().notNull(),
name: varchar({ length: 255 }).notNull(),
slug: varchar({ length: 255 }).notNull(),
updatedAt: timestamp("updated_at"),
weight: integer().default(0).notNull(),
},
(table) => [
uniqueIndex("unq_category_name").using("btree", sql`lower((name)::text)`),
uniqueIndex("unq_category_slug").using("btree", sql`lower((slug)::text)`),
index("idx_category_weight").using("btree", table.weight.asc().nullsLast()),
],
);
export const articles = pgTable( export const articles = pgTable(
"article", "article",
{ {
body: text().notNull(), body: text().notNull(),
categories: text().array(), categories: text().array(),
categoryId: uuid("category_id"),
clustered: boolean("clustered").default(false).notNull(),
crawledAt: timestamp("crawled_at").defaultNow().notNull(), crawledAt: timestamp("crawled_at").defaultNow().notNull(),
credibility: jsonb("credibility").$type<Credibility>(), credibility: jsonb("credibility").$type<Credibility>(),
excerpt: varchar({ length: 255 }).generatedAlwaysAs(sql`("left"(body, 200) || '...'::text)`), excerpt: varchar({ length: 255 }).generatedAlwaysAs(sql`("left"(body, 200) || '...'::text)`),
@@ -114,10 +136,7 @@ export const articles = pgTable(
title: varchar({ length: 1024 }).notNull(), title: varchar({ length: 1024 }).notNull(),
tokenStatistics: jsonb("token_statistics").$type<TokenStatistics>(), tokenStatistics: jsonb("token_statistics").$type<TokenStatistics>(),
tsv: tsvector("tsv").generatedAlwaysAs( tsv: tsvector("tsv").generatedAlwaysAs(
sql`( sql`setweight(to_tsvector('french'::regconfig, COALESCE(title, '')::text), 'A'::"char")`,
setweight(to_tsvector('french'::regconfig, COALESCE(title, '')::text), 'A'::"char")
|| setweight(to_tsvector('french'::regconfig, COALESCE(body, ''::text)), 'B'::"char")
)`,
), ),
updatedAt: timestamp("updated_at"), updatedAt: timestamp("updated_at"),
}, },
@@ -126,6 +145,8 @@ export const articles = pgTable(
"gin", "gin",
table.categories.asc().nullsLast().op("array_ops"), table.categories.asc().nullsLast().op("array_ops"),
), ),
index("idx_article_category_id").using("btree", table.categoryId.asc().nullsLast()),
index("idx_article_clustered").using("btree", table.clustered.asc().nullsLast()),
index("gin_article_link_trgm").using("gin", table.link.asc().nullsLast().op("gin_trgm_ops")), index("gin_article_link_trgm").using("gin", table.link.asc().nullsLast().op("gin_trgm_ops")),
index("gin_article_title_trgm").using("gin", table.title.asc().nullsLast().op("gin_trgm_ops")), index("gin_article_title_trgm").using("gin", table.title.asc().nullsLast().op("gin_trgm_ops")),
index("gin_article_tsv").using("gin", table.tsv.asc().nullsLast().op("tsvector_ops")), index("gin_article_tsv").using("gin", table.tsv.asc().nullsLast().op("tsvector_ops")),
@@ -136,6 +157,11 @@ export const articles = pgTable(
table.id.desc().nullsFirst(), table.id.desc().nullsFirst(),
), ),
uniqueIndex("unq_article_hash").using("btree", table.hash.asc().nullsLast()), uniqueIndex("unq_article_hash").using("btree", table.hash.asc().nullsLast()),
foreignKey({
columns: [table.categoryId],
foreignColumns: [categories.id],
name: "fk_article_category_id",
}).onDelete("set null"),
foreignKey({ foreignKey({
columns: [table.sourceId], columns: [table.sourceId],
foreignColumns: [sources.id], foreignColumns: [sources.id],
@@ -428,6 +454,10 @@ export const commentRelations = relations(comments, ({ one }) => ({
export const articleRelations = relations(articles, ({ one, many }) => ({ export const articleRelations = relations(articles, ({ one, many }) => ({
bookmarkArticles: many(bookmarkArticles), bookmarkArticles: many(bookmarkArticles),
category: one(categories, {
fields: [articles.categoryId],
references: [categories.id],
}),
comments: many(comments), comments: many(comments),
source: one(sources, { source: one(sources, {
fields: [articles.sourceId], fields: [articles.sourceId],
@@ -435,6 +465,10 @@ export const articleRelations = relations(articles, ({ one, many }) => ({
}), }),
})); }));
export const categoryRelations = relations(categories, ({ many }) => ({
articles: many(articles),
}));
export const bookmarkArticleRelations = relations(bookmarkArticles, ({ one }) => ({ export const bookmarkArticleRelations = relations(bookmarkArticles, ({ one }) => ({
article: one(articles, { article: one(articles, {
fields: [bookmarkArticles.articleId], fields: [bookmarkArticles.articleId],
@@ -0,0 +1,218 @@
import { logger } from "@basango/logger";
import { desc, eq, inArray, sql } from "drizzle-orm";
import { Database } from "#db/client";
import { articles, categories } from "#db/schema";
import { DEFAULT_CATEGORY } from "#domain/constants";
import { Categories } from "#domain/models";
type CategoryRow = typeof categories.$inferSelect;
type ArticleCategories = Pick<typeof articles.$inferSelect, "categories" | "id">;
type CategoryScore = {
category: (typeof Categories)[number];
matches: number;
score: number;
};
const BATCH_SIZE = 50_000;
const CATEGORY_MAP = new Map(Categories.map((category, index) => [category.slug, index]));
const CANDIDATE_MAP = buildCandidateMap();
const FALLBACK_CATEGORY = Categories.find((category) => category.slug === DEFAULT_CATEGORY)!;
export class CategoryClassifier {
constructor(private readonly db: Database) {}
async classifyPendingArticles(limit: number = BATCH_SIZE) {
const canonical = await this.ensureCanonicalCategories();
if (canonical.size === 0) {
logger.warn("No canonical categories available for clustering");
return { matched: 0, processed: 0, unmatched: 0 };
}
const pending = await this.db
.select({
categories: articles.categories,
id: articles.id,
})
.from(articles)
.where(eq(articles.clustered, false))
.orderBy(desc(articles.publishedAt), desc(articles.id))
.limit(limit);
if (pending.length === 0) {
logger.info("No articles to cluster");
return { matched: 0, processed: 0, unmatched: 0 };
}
let matched = 0;
let unmatched = 0;
const fallbackRow = canonical.get(FALLBACK_CATEGORY.slug);
for (const article of pending) {
const best = classifyCategory(article);
const targetRow = canonical.get(best.category.slug) ?? fallbackRow;
await this.db
.update(articles)
.set({
categoryId: targetRow?.id ?? null,
clustered: true,
updatedAt: sql`now()`,
})
.where(eq(articles.id, article.id));
if (targetRow) {
matched++;
logger.debug(
{
articleId: article.id,
category: best.category.slug,
matches: best.matches,
score: best.score,
},
"Clustered article",
);
} else {
unmatched++;
logger.debug({ articleId: article.id }, "No category match found");
}
}
const processed = pending.length;
logger.info({ matched, processed, unmatched }, "Category clustering run completed");
return { matched, processed, unmatched };
}
private async ensureCanonicalCategories(): Promise<Map<string, CategoryRow>> {
const payload = Categories.map(
(category) =>
({
candidates: category.candidates,
description: category.description ?? null,
embeddings: null,
id: category.id,
name: category.name,
slug: category.slug,
weight: category.weight,
}) satisfies typeof categories.$inferInsert,
);
await this.db.insert(categories).values(payload).onConflictDoNothing();
const existing = await this.db.query.categories.findMany({
where: inArray(
categories.slug,
Categories.map((category) => category.slug),
),
});
const map = new Map<string, CategoryRow>();
for (const row of existing) {
map.set(row.slug, row);
}
if (!map.has(FALLBACK_CATEGORY.slug)) {
logger.warn("Fallback main category is missing from canonical categories");
}
return map;
}
}
export function classifyCategory(article: ArticleCategories): CategoryScore {
const rawCategories = article.categories ?? [];
const normalizedCategories = Array.from(
new Set(
rawCategories
.map((value) => normalizeCategory(value))
.filter((value): value is string => Boolean(value)),
),
);
const scores = new Map<string, CategoryScore>();
for (const normalized of normalizedCategories) {
const categories = CANDIDATE_MAP.get(normalized);
if (!categories) continue;
for (const category of categories) {
const current =
scores.get(category.slug) ??
({
category,
matches: 0,
score: 0,
} satisfies CategoryScore);
current.matches += 1;
current.score += category.weight;
scores.set(category.slug, current);
}
}
if (scores.size === 0) {
return { category: FALLBACK_CATEGORY, matches: 0, score: 0 };
}
const [first, ...rest] = Array.from(scores.values());
const best = rest.reduce<CategoryScore>((winner, candidate) => {
if (candidate.score !== winner.score) {
return candidate.score > winner.score ? candidate : winner;
}
if (candidate.category.weight !== winner.category.weight) {
return candidate.category.weight > winner.category.weight ? candidate : winner;
}
if (candidate.matches !== winner.matches) {
return candidate.matches > winner.matches ? candidate : winner;
}
const winnerOrder = CATEGORY_MAP.get(winner.category.slug) ?? Number.MAX_SAFE_INTEGER;
const candidateOrder = CATEGORY_MAP.get(candidate.category.slug) ?? Number.MAX_SAFE_INTEGER;
return candidateOrder < winnerOrder ? candidate : winner;
}, first ?? { category: FALLBACK_CATEGORY, matches: 0, score: 0 });
return best;
}
function buildCandidateMap(): Map<string, (typeof Categories)[number][]> {
const map = new Map<string, (typeof Categories)[number][]>();
for (const category of Categories) {
for (const candidate of category.candidates) {
const normalized = normalizeCategory(candidate);
if (!normalized) continue;
const existing = map.get(normalized) ?? [];
if (!existing.some((item) => item.slug === category.slug)) {
existing.push(category);
}
map.set(normalized, existing);
}
}
return map;
}
export function normalizeCategory(value?: string | null): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
const normalized = trimmed
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim()
.replace(/\s+/g, " ");
return normalized.length > 0 ? normalized : null;
}
@@ -0,0 +1,18 @@
#!/usr/bin/env bun
import { logger } from "@basango/logger";
import { connectDb } from "#db/client";
import { CategoryClassifier } from "#db/services/category-classifier.js";
async function main() {
const db = await connectDb();
const service = new CategoryClassifier(db);
await service.classifyPendingArticles();
}
main().catch((error) => {
logger.error({ error }, "Category clustering failed");
process.exit(1);
});
+6 -6
View File
@@ -2,10 +2,10 @@
/** biome-ignore-all lint/correctness/noUnusedPrivateClassMembers: false positive */ /** biome-ignore-all lint/correctness/noUnusedPrivateClassMembers: false positive */
import { config } from "@basango/domain/config";
import { RowDataPacket } from "mysql2/promise"; import { RowDataPacket } from "mysql2/promise";
import { Pool, PoolClient } from "pg"; import { Pool, PoolClient } from "pg";
import { env } from "#db/config";
import { computeReadingTime } from "#db/utils/computed"; import { computeReadingTime } from "#db/utils/computed";
type SourceOptions = { type SourceOptions = {
@@ -598,13 +598,13 @@ async function main() {
const engine = new Engine( const engine = new Engine(
{ {
database: env("BASANGO_SOURCE_DATABASE_NAME"), database: config.database.legacy.name,
host: env("BASANGO_SOURCE_DATABASE_HOST"), host: config.database.legacy.host,
password: env("BASANGO_SOURCE_DATABASE_PASS"), password: config.database.legacy.password,
user: env("BASANGO_SOURCE_DATABASE_USER"), user: config.database.legacy.user,
}, },
{ {
database: env("BASANGO_DATABASE_URL"), database: config.database.url,
}, },
); );
+2 -2
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { config } from "@basango/domain/config";
import { Pool } from "pg"; import { Pool } from "pg";
import { env } from "#db/config";
import { computeTokenStatistics } from "#db/utils/computed"; import { computeTokenStatistics } from "#db/utils/computed";
type ArticleRow = { type ArticleRow = {
@@ -114,7 +114,7 @@ class Engine {
} }
async function main() { async function main() {
const engine = new Engine(env("BASANGO_DATABASE_URL")); const engine = new Engine(config.database.url);
try { try {
await engine.synchronize(); await engine.synchronize();
+3 -1
View File
@@ -1,9 +1,11 @@
import { ArticleMetadata, ID, Sentiment, TokenStatistics } from "@basango/domain/models"; import { ArticleMetadata, ID, Sentiment, TokenStatistics } from "@basango/domain/models";
export type CreateArticleParams = { export type CreateArticleParams = {
categoryId?: string | null;
clustered?: boolean;
title: string; title: string;
body: string; body: string;
categories: string[]; categories?: string[];
link: string; link: string;
sourceId: string; sourceId: string;
publishedAt: Date; publishedAt: Date;
+2
View File
@@ -1,9 +1,11 @@
import { DateRange, ID } from "@basango/domain/models"; import { DateRange, ID } from "@basango/domain/models";
export type CategoryShare = { export type CategoryShare = {
categoryId: string;
category: string; category: string;
count: number; count: number;
percentage: number; percentage: number;
slug: string;
}; };
export type CategoryShares = { export type CategoryShares = {
+11 -12
View File
@@ -28,21 +28,20 @@ export const computeTokenCount = (
export const computeTokenStatistics = (data: { export const computeTokenStatistics = (data: {
title: string; title: string;
body: string; body: string;
categories: string[]; categories?: string[];
}): TokenStatistics => { }): TokenStatistics => {
const [title, body, categories, excerpt] = [ const normalizedCategories = data.categories ?? [];
computeTokenCount(data.title), const titleTokens = computeTokenCount(data.title);
computeTokenCount(data.body), const bodyTokens = computeTokenCount(data.body);
computeTokenCount(data.categories.join(",")), const categoryTokens = computeTokenCount(normalizedCategories.join(","));
computeTokenCount(data.body.substring(0, 200)), const excerptTokens = computeTokenCount(data.body.substring(0, 200));
];
return { return {
body, body: bodyTokens,
categories, categories: categoryTokens,
excerpt, excerpt: excerptTokens,
title, title: titleTokens,
total: title + body + categories + excerpt, total: titleTokens + bodyTokens + categoryTokens + excerptTokens,
}; };
}; };
+31
View File
@@ -0,0 +1,31 @@
{
"api": {
"cors": {
"allowedHeaders": [
"Authorization",
"Content-Type",
"accept-language",
"x-trpc-source",
"x-user-locale",
"x-user-timezone",
"x-user-country"
],
"allowMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
"exposeHeaders": ["Content-Length"],
"maxAge": 86400
},
"security": {
"accessTokenTtl": "15m",
"audience": "basango_dashboard",
"crawlerToken": "%env(BASANGO_API_CRAWLER_TOKEN)%",
"issuer": "basango_api",
"jwtSecret": "%env(BASANGO_API_JWT_SECRET)%",
"refreshTokenTtl": "7d"
},
"server": {
"host": "%env(BASANGO_API_HOST)%",
"port": "%env(number:BASANGO_API_PORT)%",
"version": "1.0.0"
}
}
}
+262
View File
@@ -0,0 +1,262 @@
{
"crawler": {
"backend": {
"endpoint": "%env(BASANGO_API_CRAWLER_ENDPOINT)%",
"token": "%env(BASANGO_API_CRAWLER_TOKEN)%"
},
"fetch": {
"async": {
"prefix": "basango:crawler",
"queues": {
"details": "%env(BASANGO_CRAWLER_ASYNC_QUEUE_DETAILS)%",
"listing": "%env(BASANGO_CRAWLER_ASYNC_QUEUE_LISTING)%",
"processing": "%env(BASANGO_CRAWLER_ASYNC_QUEUE_PROCESSING)%"
},
"redisUrl": "%env(BASANGO_CRAWLER_ASYNC_REDIS_URL)%",
"ttl": {
"default": 600,
"failure": "%env(number:BASANGO_CRAWLER_ASYNC_TTL_FAILURE)%",
"result": "%env(number:BASANGO_CRAWLER_ASYNC_TTL_RESULT)%"
}
},
"client": {
"backoffInitial": 1,
"backoffMax": 30,
"backoffMultiplier": 2,
"followRedirects": true,
"maxRetries": "%env(number:BASANGO_CRAWLER_FETCH_MAX_RETRIES)%",
"respectRetryAfter": "%env(boolean:BASANGO_CRAWLER_FETCH_RESPECT_RETRY_AFTER)%",
"rotate": true,
"timeout": 20,
"userAgent": "%env(BASANGO_CRAWLER_FETCH_USER_AGENT)%",
"verifySsl": true
},
"crawler": {
"direction": "%env(BASANGO_CRAWLER_UPDATE_DIRECTION)%",
"maxWorkers": 5,
"notify": false,
"useMultiThreading": false
}
},
"paths": {
"data": "%env(BASANGO_CRAWLER_DATA_PATH)%",
"root": "%env(BASANGO_CRAWLER_ROOT_PATH)%"
},
"sources": {
"html": [
{
"paginationTemplate": "actualite",
"requiresDetails": true,
"requiresRateLimit": false,
"sourceDate": {},
"sourceId": "radiookapi.net",
"sourceKind": "html",
"sourceSelectors": {
"articleBody": ".field-name-body",
"articleCategories": ".views-field-field-cat-gorie a",
"articleDate": "head > meta[property=\"article:published_time\"]",
"articleLink": ".views-field-title a",
"articles": ".view-content > .views-row.content-row",
"articleTitle": "h1.page-header",
"pagination": "ul.pagination > li.pager-last > a"
},
"sourceUrl": "https://www.radiookapi.net",
"supportsCategories": false
},
{
"categories": ["politique", "economie", "culture", "sport", "societe"],
"paginationTemplate": "index.php/category/{category}",
"requiresDetails": true,
"requiresRateLimit": false,
"sourceDate": {},
"sourceId": "7sur7.cd",
"sourceKind": "html",
"sourceSelectors": {
"articleBody": "div[property=\"schema:text\"].field.field--name-body",
"articleDate": "head > meta[property=\"article:published_time\"]",
"articleLink": ".views-field-title a",
"articles": ".view-content > .row.views-row",
"articleTitle": ".views-field-title a",
"pagination": "ul.pagination > li.pager__item.pager__item--last > a"
},
"sourceUrl": "https://7sur7.cd",
"supportsCategories": true
},
{
"paginationTemplate": "articles.html",
"requiresDetails": true,
"requiresRateLimit": false,
"sourceDate": {
"format": "dd.MM.yyyy"
},
"sourceId": "mediacongo.net",
"sourceKind": "html",
"sourceSelectors": {
"articleBody": ".article_ttext",
"articleCategories": "a.color_link",
"articleDate": ".article_other_about",
"articleLink": "a:first-child",
"articles": ".for_aitems > .article_other_item",
"articleTitle": "h1",
"pagination": "div.pagination > div > a:last-child"
},
"sourceUrl": "https://www.mediacongo.net",
"supportsCategories": false
},
{
"paginationTemplate": "actualite",
"requiresDetails": true,
"requiresRateLimit": false,
"sourceDate": {},
"sourceId": "actualite.cd",
"sourceKind": "html",
"sourceSelectors": {
"articleBody": ".views-field.views-field-body .field-content",
"articleCategories": "#actu-cat",
"articleDate": "head > meta[property=\"article:published_time\"]",
"articleLink": "#actu-titre a",
"articles": "#views-bootstrap-taxonomy-term-page-2 > div > div",
"articleTitle": "h1.page-title"
},
"sourceUrl": "https://actualite.cd",
"supportsCategories": false
}
],
"wordpress": [
{
"requiresRateLimit": true,
"sourceId": "beto.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://beto.cd"
},
{ "sourceId": "newscd.net", "sourceKind": "wordpress", "sourceUrl": "https://newscd.net" },
{
"sourceId": "africanewsrdc.net",
"sourceKind": "wordpress",
"sourceUrl": "https://www.africanewsrdc.net"
},
{
"sourceId": "angazainstitute.ac.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://angazainstitute.ac.cd"
},
{ "sourceId": "b-onetv.cd", "sourceKind": "wordpress", "sourceUrl": "https://b-onetv.cd" },
{
"sourceId": "bukavufm.com",
"sourceKind": "wordpress",
"sourceUrl": "https://bukavufm.com"
},
{
"sourceId": "changement7.net",
"sourceKind": "wordpress",
"sourceUrl": "https://changement7.net"
},
{
"sourceId": "congoactu.net",
"sourceKind": "wordpress",
"sourceUrl": "https://congoactu.net"
},
{
"sourceId": "congoindependant.com",
"sourceKind": "wordpress",
"sourceUrl": "https://www.congoindependant.com"
},
{
"sourceId": "congoquotidien.com",
"sourceKind": "wordpress",
"sourceUrl": "https://www.congoquotidien.com"
},
{
"sourceId": "cumulard.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://www.cumulard.cd"
},
{
"sourceId": "environews-rdc.net",
"sourceKind": "wordpress",
"sourceUrl": "https://environews-rdc.net"
},
{
"sourceId": "freemediardc.info",
"sourceKind": "wordpress",
"sourceUrl": "https://www.freemediardc.info"
},
{
"sourceId": "geopolismagazine.org",
"sourceKind": "wordpress",
"sourceUrl": "https://geopolismagazine.org"
},
{
"sourceId": "habarirdc.net",
"sourceKind": "wordpress",
"sourceUrl": "https://habarirdc.net"
},
{
"sourceId": "infordc.com",
"sourceKind": "wordpress",
"sourceUrl": "https://infordc.com"
},
{
"sourceId": "kilalopress.net",
"sourceKind": "wordpress",
"sourceUrl": "https://kilalopress.net"
},
{
"sourceId": "laprosperiteonline.net",
"sourceKind": "wordpress",
"sourceUrl": "https://laprosperiteonline.net"
},
{
"sourceId": "laprunellerdc.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://laprunellerdc.cd"
},
{
"sourceId": "lesmedias.net",
"sourceKind": "wordpress",
"sourceUrl": "https://lesmedias.net"
},
{
"sourceId": "lesvolcansnews.net",
"sourceKind": "wordpress",
"sourceUrl": "https://lesvolcansnews.net"
},
{
"sourceId": "netic-news.net",
"sourceKind": "wordpress",
"sourceUrl": "https://www.netic-news.net"
},
{
"sourceId": "objectif-infos.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://objectif-infos.cd"
},
{
"sourceId": "scooprdc.net",
"sourceKind": "wordpress",
"sourceUrl": "https://scooprdc.net"
},
{
"sourceId": "journaldekinshasa.com",
"sourceKind": "wordpress",
"sourceUrl": "https://www.journaldekinshasa.com"
},
{
"sourceId": "lepotentiel.cd",
"sourceKind": "wordpress",
"sourceUrl": "https://lepotentiel.cd"
},
{
"sourceId": "acturdc.com",
"sourceKind": "wordpress",
"sourceUrl": "https://acturdc.com"
},
{
"sourceId": "matininfos.net",
"sourceKind": "wordpress",
"sourceUrl": "https://matininfos.net"
}
]
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"database": {
"legacy": {
"host": "%env(BASANGO_DATABASE_LEGACY_HOST)%",
"name": "%env(BASANGO_DATABASE_LEGACY_NAME)%",
"password": "%env(BASANGO_DATABASE_LEGACY_PASSWORD)%",
"port": "%env(number:BASANGO_DATABASE_LEGACY_PORT)%",
"user": "%env(BASANGO_DATABASE_LEGACY_USER)%"
},
"url": "%env(BASANGO_DATABASE_URL)%"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"encryption": {
"algorithm": "aes-256-gcm",
"authTagLength": 16,
"bcryptSaltRounds": 12,
"ivLength": 16,
"key": "%env(BASANGO_ENCRYPTION_KEY)%"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"logger": {
"level": "%env(BASANGO_LOGGER_LEVEL)%"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"shared": {
"categorySharesLimit": 10,
"dateFormat": "yyyy-LL-dd",
"dateTimeFormat": "yyyy-LL-dd'T'HH:mm:ss",
"name": "Basango",
"pagination": {
"defaultLimit": 20,
"maxLimit": 100,
"page": 1
},
"publicationGraphDays": 30,
"timezone": "Africa/Lubumbashi"
}
}
+1
View File
@@ -7,6 +7,7 @@
"@basango/tsconfig": "workspace:*" "@basango/tsconfig": "workspace:*"
}, },
"exports": { "exports": {
"./config": "./src/config/index.ts",
"./constants": "./src/constants.ts", "./constants": "./src/constants.ts",
"./crawler": "./src/crawler/index.ts", "./crawler": "./src/crawler/index.ts",
"./models": "./src/models/index.ts" "./models": "./src/models/index.ts"
+29
View File
@@ -0,0 +1,29 @@
import z from "zod";
export const ApiConfigurationSchema = z.object({
cors: z.object({
allowedHeaders: z.array(z.string()).default([]),
allowMethods: z.array(z.string()).default([]),
exposeHeaders: z.array(z.string()).default([]),
maxAge: z.number().int().min(0).optional(),
origin: z
.array(z.string())
.optional()
.default(["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"]),
}),
security: z.object({
accessTokenTtl: z.string(),
audience: z.string(),
crawlerToken: z.string(),
issuer: z.string(),
jwtSecret: z.string(),
refreshTokenTtl: z.string(),
}),
server: z.object({
host: z.string().default("localhost"),
port: z.number().int().min(1).max(65535).default(3080),
version: z.string().default("1.0.0"),
}),
});
export type ApiConfiguration = z.infer<typeof ApiConfigurationSchema>;
+107
View File
@@ -0,0 +1,107 @@
import { z } from "zod";
import { SOURCE_KINDS } from "../constants";
import { PageRangeSchema, TimestampRangeSchema, UpdateDirectionSchema } from "../models";
export const SourceKindSchema = z.enum(SOURCE_KINDS);
export const SourceDateSchema = z.object({
format: z.string().default("yyyy-LL-dd HH:mm"),
});
const SourceOptionsSchema = z.object({
categories: z.array(z.string()).default([]),
requiresDetails: z.boolean().default(false),
requiresRateLimit: z.boolean().default(false),
sourceDate: SourceDateSchema,
sourceId: z.string(),
sourceKind: SourceKindSchema,
sourceUrl: z.url(),
supportsCategories: z.boolean().default(false),
});
export const HtmlSourceOptionsSchema = SourceOptionsSchema.extend({
paginationTemplate: z.string(),
sourceKind: z.literal("html"),
sourceSelectors: z.object({
articleBody: z.string(),
articleCategories: z.string().optional(),
articleDate: z.string(),
articleLink: z.string(),
articles: z.string(),
articleTitle: z.string(),
pagination: z.string().default("ul.pagination > li a"),
}),
});
export const WordPressSourceOptionsSchema = SourceOptionsSchema.extend({
sourceDate: SourceDateSchema.default(SourceDateSchema.parse({ format: "yyyy-LL-dd'T'HH:mm:ss" })),
sourceKind: z.literal("wordpress"),
});
export const CrawlerConfigurationSchema = z.object({
backend: z.object({
endpoint: z.url(),
token: z.string(),
}),
fetch: z.object({
async: z.object({
prefix: z.string().default("basango:crawler:queue"),
queues: z.object({
details: z.string().default("details"),
listing: z.string().default("listing"),
processing: z.string().default("processing"),
}),
redisUrl: z.string().default("redis://localhost:6379/0"),
ttl: z.object({
default: z.number().int().positive().default(600),
failure: z.number().int().nonnegative().default(3600),
result: z.number().int().nonnegative().default(3600),
}),
}),
client: z.object({
backoffInitial: z.number().nonnegative().default(1),
backoffMax: z.number().nonnegative().default(30),
backoffMultiplier: z.number().positive().default(2),
followRedirects: z.boolean().default(true),
maxRetries: z.number().int().nonnegative().default(3),
respectRetryAfter: z.boolean().default(true),
rotate: z.boolean().default(true),
timeout: z.number().positive().default(20),
userAgent: z.string().default("Basango/0.1 (+https://github.com/bernard-ng/basango)"),
verifySsl: z.boolean().default(true),
}),
crawler: z.object({
category: z.string().optional(),
dateRange: TimestampRangeSchema.optional(),
direction: UpdateDirectionSchema.default("forward"),
isUpdate: z.boolean().default(false),
maxWorkers: z.number().int().positive().default(5),
notify: z.boolean().default(false),
pageRange: PageRangeSchema.optional(),
source: z.union([HtmlSourceOptionsSchema, WordPressSourceOptionsSchema]).optional(),
useMultiThreading: z.boolean().default(false),
}),
}),
paths: z.object({
data: z.string(),
root: z.string(),
}),
sources: z.object({
html: z.array(HtmlSourceOptionsSchema).default([]),
wordpress: z.array(WordPressSourceOptionsSchema).default([]),
}),
});
// types
export type SourceKind = z.infer<typeof SourceKindSchema>;
export type SourceDate = z.infer<typeof SourceDateSchema>;
export type HtmlSourceOptions = z.infer<typeof HtmlSourceOptionsSchema>;
export type WordPressSourceOptions = z.infer<typeof WordPressSourceOptionsSchema>;
export type AnySourceOptions = HtmlSourceOptions | WordPressSourceOptions;
export type CrawlerConfiguration = z.infer<typeof CrawlerConfigurationSchema>;
export type CrawlerHttpOptions = CrawlerConfiguration["fetch"]["client"];
export type CrawlerFetchingOptions = CrawlerConfiguration["fetch"]["crawler"];
export type CrawlerAsyncOptions = CrawlerConfiguration["fetch"]["async"];
export type CrawlerBackendOptions = CrawlerConfiguration["backend"];
+15
View File
@@ -0,0 +1,15 @@
import z from "zod";
export const DatabaseConfigurationSchema = z.object({
legacy: z.object({
host: z.string().min(1),
name: z.string().min(1),
password: z.string().min(1),
port: z.number().optional(),
user: z.string().min(1),
}),
url: z.string().min(1),
});
// types
export type DatabaseConfiguration = z.infer<typeof DatabaseConfigurationSchema>;
+18
View File
@@ -0,0 +1,18 @@
import z from "zod";
import {
DEFAULT_AUTH_TAG_LENGTH,
DEFAULT_BCRYPT_SALT_ROUNDS,
DEFAULT_IV_LENGTH,
} from "../constants";
export const EncryptionConfigurationSchema = z.object({
algorithm: z.enum(["aes-128-gcm", "aes-192-gcm", "aes-256-gcm"]),
authTagLength: z.number().nonnegative().default(DEFAULT_AUTH_TAG_LENGTH),
bcryptSaltRounds: z.number().nonnegative().default(DEFAULT_BCRYPT_SALT_ROUNDS),
ivLength: z.number().nonnegative().default(DEFAULT_IV_LENGTH),
key: z.string(),
});
// types
export type EncryptionConfiguration = z.infer<typeof EncryptionConfigurationSchema>;
+72
View File
@@ -0,0 +1,72 @@
import path from "node:path";
import { defineConfig } from "@devscast/config";
import z from "zod";
import { ApiConfigurationSchema } from "./api";
import { CrawlerConfigurationSchema } from "./crawler";
import { DatabaseConfigurationSchema } from "./database";
import { EncryptionConfigurationSchema } from "./encryption";
import { LoggerConfigurationSchema } from "./logger";
import { SharedConfigurationSchema } from "./shared";
export * from "./api";
export * from "./crawler";
export * from "./database";
export * from "./encryption";
export * from "./logger";
export * from "./shared";
const root = path.resolve(__dirname, "../../../../");
const domain = path.join(root, "packages", "domain", "config");
export const { env, config } = defineConfig({
env: {
knownKeys: [
"NODE_ENV",
"BASANGO_API_HOST",
"BASANGO_API_PORT",
"BASANGO_API_ALLOWED_ORIGINS",
"BASANGO_API_KEY",
"BASANGO_API_CRAWLER_TOKEN",
"BASANGO_API_JWT_SECRET",
"BASANGO_DATABASE_URL",
"BASANGO_DATABASE_LEGACY_HOST",
"BASANGO_DATABASE_LEGACY_PASSWORD",
"BASANGO_DATABASE_LEGACY_NAME",
"BASANGO_DATABASE_LEGACY_USER",
"BASANGO_CRAWLER_ROOT_PATH",
"BASANGO_CRAWLER_DATA_PATH",
"BASANGO_CRAWLER_LOGS_PATH",
"BASANGO_CRAWLER_CONFIG_PATH",
"BASANGO_CRAWLER_UPDATE_DIRECTION",
"BASANGO_CRAWLER_FETCH_USER_AGENT",
"BASANGO_CRAWLER_FETCH_MAX_RETRIES",
"BASANGO_CRAWLER_FETCH_RESPECT_RETRY_AFTER",
"BASANGO_CRAWLER_ASYNC_REDIS_URL",
"BASANGO_CRAWLER_ASYNC_TTL_RESULT",
"BASANGO_CRAWLER_ASYNC_TTL_FAILURE",
"BASANGO_CRAWLER_ASYNC_QUEUE_LISTING",
"BASANGO_CRAWLER_ASYNC_QUEUE_DETAILS",
"BASANGO_CRAWLER_ASYNC_QUEUE_PROCESSING",
"BASANGO_ENCRYPTION_KEY",
] as const,
path: path.join(root, ".env"),
},
schema: z.object({
api: ApiConfigurationSchema,
crawler: CrawlerConfigurationSchema,
database: DatabaseConfigurationSchema,
encryption: EncryptionConfigurationSchema,
logger: LoggerConfigurationSchema,
shared: SharedConfigurationSchema,
}),
sources: [
path.join(domain, "api.json"),
path.join(domain, "crawler.json"),
path.join(domain, "database.json"),
path.join(domain, "encryption.json"),
path.join(domain, "logger.json"),
path.join(domain, "shared.json"),
],
});
+8
View File
@@ -0,0 +1,8 @@
import z from "zod";
export const LoggerConfigurationSchema = z.object({
level: z.string().default("info"),
});
// types
export type LoggerConfiguration = z.infer<typeof LoggerConfigurationSchema>;
+17
View File
@@ -0,0 +1,17 @@
import z from "zod";
export const SharedConfigurationSchema = z.object({
categorySharesLimit: z.number().int().min(1).default(10),
dateFormat: z.string(),
dateTimeFormat: z.string(),
name: z.string().default("Basango"),
pagination: z.object({
defaultLimit: z.number().int().min(1).max(100),
maxLimit: z.number().int().min(1).max(100),
page: z.number().int().min(1),
}),
publicationGraphDays: z.number().int().min(1),
timezone: z.string(),
});
export type SharedConfiguration = z.infer<typeof SharedConfigurationSchema>;
+2 -3
View File
@@ -1,10 +1,8 @@
// Domain-specific constants and types
export const BIAS = ["neutral", "slightly", "partisan", "extreme"] as const; export const BIAS = ["neutral", "slightly", "partisan", "extreme"] as const;
export const RELIABILITY = ["trusted", "reliable", "average", "low_trust", "unreliable"] as const; export const RELIABILITY = ["trusted", "reliable", "average", "low_trust", "unreliable"] as const;
export const TRANSPARENCY = ["high", "medium", "low"] as const; export const TRANSPARENCY = ["high", "medium", "low"] as const;
export const SENTIMENT = ["positive", "neutral", "negative"] as const; export const SENTIMENT = ["positive", "neutral", "negative"] as const;
// Crawler-related constants and types
export const UPDATE_DIRECTIONS = ["forward", "backward"] as const; export const UPDATE_DIRECTIONS = ["forward", "backward"] as const;
export const SOURCE_KINDS = ["wordpress", "html"] as const; export const SOURCE_KINDS = ["wordpress", "html"] as const;
@@ -32,5 +30,6 @@ export const DEFAULT_AUTH_TAG_LENGTH = 16;
export const DEFAULT_BCRYPT_SALT_ROUNDS = 12; export const DEFAULT_BCRYPT_SALT_ROUNDS = 12;
export const DEFAULT_TOKEN_AUDIENCE = "basango_dashboard"; export const DEFAULT_TOKEN_AUDIENCE = "basango_dashboard";
export const DEFAULT_TOKEN_ISSUER = "basango_api"; export const DEFAULT_TOKEN_ISSUER = "basango_api";
export const DEFAULT_ACCESS_TOKEN_TTL = "15m"; export const DEFAULT_ACCESS_TOKEN_TTL = "35m";
export const DEFAULT_REFRESH_TOKEN_TTL = "7d"; export const DEFAULT_REFRESH_TOKEN_TTL = "7d";
export const DEFAULT_CATEGORY = "divers-autres";
-47
View File
@@ -1,47 +0,0 @@
import { z } from "zod";
import { SOURCE_KINDS } from "#domain/constants";
// schemas
export const SourceKindSchema = z.enum(SOURCE_KINDS);
export const SourceDateSchema = z.object({
format: z.string().default("yyyy-LL-dd HH:mm"),
});
const SourceConfigSchema = z.object({
categories: z.array(z.string()).default([]),
requiresDetails: z.boolean().default(false),
requiresRateLimit: z.boolean().default(false),
sourceDate: SourceDateSchema,
sourceId: z.string(),
sourceKind: SourceKindSchema,
sourceUrl: z.url(),
supportsCategories: z.boolean().default(false),
});
export const HtmlSourceConfigSchema = SourceConfigSchema.extend({
paginationTemplate: z.string(),
sourceKind: z.literal("html"),
sourceSelectors: z.object({
articleBody: z.string(),
articleCategories: z.string().optional(),
articleDate: z.string(),
articleLink: z.string(),
articles: z.string(),
articleTitle: z.string(),
pagination: z.string().default("ul.pagination > li a"),
}),
});
export const WordPressSourceConfigSchema = SourceConfigSchema.extend({
sourceDate: SourceDateSchema.default(SourceDateSchema.parse({ format: "yyyy-LL-dd'T'HH:mm:ss" })),
sourceKind: z.literal("wordpress"),
});
// types
export type SourceKind = z.infer<typeof SourceKindSchema>;
export type SourceDate = z.infer<typeof SourceDateSchema>;
export type HtmlSourceConfig = z.infer<typeof HtmlSourceConfigSchema>;
export type WordPressSourceConfig = z.infer<typeof WordPressSourceConfigSchema>;
export type AnySourceConfig = HtmlSourceConfig | WordPressSourceConfig;
-2
View File
@@ -1,2 +0,0 @@
export * from "./config";
export * from "./schemas";
+45 -161
View File
@@ -1,185 +1,69 @@
import { z } from "@hono/zod-openapi"; import z from "zod";
import { idSchema, sentimentSchema } from "#domain/models/shared";
import { categorySchema } from "./categories";
import { idSchema, sentimentSchema } from "./shared";
import { sourceSchema } from "./sources"; import { sourceSchema } from "./sources";
// schemas // schemas
export const articleMetadataSchema = z.object({ export const articleMetadataSchema = z.object({
author: z.string().optional().openapi({ author: z.string().optional(),
description: "The author of the article.", description: z.string().optional(),
example: "John Doe", image: z.url().optional(),
}), publishedAt: z.string().optional(),
description: z.string().optional().openapi({ title: z.string().optional(),
description: "A brief description or summary of the article.", updatedAt: z.string().optional(),
example: "This article discusses the latest advancements in AI technology.", url: z.url().optional(),
}),
image: z.url().optional().openapi({
description: "The URL of the main image associated with the article.",
example: "https://example.com/image.jpg",
}),
publishedAt: z.date().optional().openapi({
description: "The publication date of the article as a Date object.",
example: "2023-01-01T00:00:00Z",
}),
title: z.string().optional().openapi({
description: "The title of the article for metadata purposes.",
example: "The Rise of AI",
}),
updatedAt: z.date().optional().openapi({
description: "The last updated date of the article as a Date object.",
example: "2023-01-02T12:00:00Z",
}),
url: z.url().optional().openapi({
description: "The canonical URL of the article.",
example: "https://example.com/article",
}),
}); });
export const tokenStatisticsSchema = z.object({ export const tokenStatisticsSchema = z.object({
body: z.number().optional().default(0).openapi({ body: z.number().optional().default(0),
description: "The number of tokens in the article body.", categories: z.number().optional().default(0),
example: 250, excerpt: z.number().optional().default(0),
}), title: z.number().optional().default(0),
categories: z.number().optional().default(0).openapi({ total: z.number().optional().default(0),
description: "The number of tokens in the article categories.",
example: 3,
}),
excerpt: z.number().optional().default(0).openapi({
description: "The number of tokens in the article excerpt.",
example: 50,
}),
title: z.number().optional().default(0).openapi({
description: "The number of tokens in the article title.",
example: 10,
}),
total: z.number().optional().default(0).openapi({
description: "The total number of tokens in the article.",
example: 313,
}),
}); });
export const articleSchema = z.object({ export const articleSchema = z.object({
body: z.string().min(1).openapi({ body: z.string().min(1),
description: "The main content of the article.", categories: z.array(z.string()).optional().default([]),
example: "This is the body of the article...", category: categorySchema.optional(),
}), categoryId: idSchema.optional(),
categories: z.array(z.string()).openapi({ clustered: z.boolean().default(false),
description: "The categories or tags associated with the article.", createdAt: z.coerce.date(),
example: ["Technology", "AI"], excerpt: z.string().optional(),
}), hash: z.string().min(1),
createdAt: z.date().openapi({
description: "The date and time when the article was created in the system.",
example: "2023-01-01T12:00:00Z",
}),
excerpt: z.string().optional().openapi({
description: "A brief excerpt or summary of the article.",
example: "This article discusses the latest advancements in AI technology.",
}),
hash: z.string().min(1).openapi({
description: "The unique hash of the article link.",
example: "d41d8cd98f00b204e9800998ecf8427e",
}),
id: idSchema, id: idSchema,
image: z.url().optional().openapi({ image: z.url().optional(),
description: "The URL of the main image associated with the article.", link: z.url(),
example: "https://example.com/image.jpg",
}),
link: z.string().url().openapi({
description: "The URL of the article.",
example: "https://example.com/article",
}),
metadata: articleMetadataSchema.optional(), metadata: articleMetadataSchema.optional(),
publishedAt: z.date().openapi({ publishedAt: z.date(),
description: "The publication date of the article as a Date object.", readingTime: z.number().int().min(1),
example: "2023-01-01T00:00:00Z",
}),
readingTime: z.number().int().min(1).openapi({
description: "Estimated reading time of the article in minutes.",
example: 5,
}),
source: sourceSchema.optional(), source: sourceSchema.optional(),
sourceId: z.union([z.uuid(), z.string().min(1)]).openapi({ sourceId: z.union([z.uuid(), z.string().min(1)]),
description: "The unique identifier of the source from which the article was crawled.", title: z.string().min(1),
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
}),
title: z.string().min(1).openapi({
description: "The title of the article.",
example: "The Rise of AI",
}),
tokenStatistics: tokenStatisticsSchema.optional(), tokenStatistics: tokenStatisticsSchema.optional(),
updatedAt: z.date().optional().openapi({ updatedAt: z.coerce.date().optional(),
description: "The date and time when the article was last updated in the system.",
example: "2023-01-02T12:00:00Z",
}),
}); });
// API // API
export const createArticleSchema = z export const createArticleSchema = z.object({
.object({ body: z.string().min(1),
body: z.string().min(1).openapi({ categories: z.array(z.string()).optional().default([]),
description: "The main content of the article.", hash: z.string().min(1),
example: "This is the body of the article...", link: z.url(),
}), metadata: articleMetadataSchema.optional(),
categories: z publishedAt: z.coerce.date(),
.array(z.string()) sourceId: z.string(),
.openapi({ title: z.string().min(1),
description: "The categories or tags associated with the article.", });
example: ["Technology", "AI"],
})
.optional()
.default([]),
hash: z.string().min(1).openapi({
description: "The unique hash of the article link.",
example: "d41d8cd98f00b204e9800998ecf8427e",
}),
link: z.string().url().openapi({
description: "The URL of the article.",
example: "https://example.com/article",
}),
metadata: articleMetadataSchema.optional(),
publishedAt: z
.string()
.refine((value) => !Number.isNaN(Date.parse(value)), {
message: "Invalid date format",
})
.transform((value) => new Date(value))
.openapi({
description: "The publication date of the article in ISO 8601 format.",
example: "2023-01-01T00:00:00Z",
}),
sourceId: z.string().openapi({
description: "The unique identifier of the source from which the article was crawled.",
example: "radiookapi.net",
}),
title: z.string().min(1).openapi({
description: "The title of the article.",
example: "The Rise of AI",
}),
})
.openapi("CreateArticle");
export const createArticleResponseSchema = z export const createArticleResponseSchema = z.object({ id: idSchema, sourceId: idSchema });
.object({ id: idSchema, sourceId: idSchema })
.openapi("CreateArticleResponse");
export const getArticlesSchema = z.object({ export const getArticlesSchema = z.object({
category: z.string().min(1).max(255).optional().openapi({ category: z.string().min(1).max(255).optional(),
description: "Filter articles by a specific category.", cursor: z.string().nullable().optional(),
example: "Technology", limit: z.number().int().min(1).max(100).optional(),
}), search: z.string().max(512).optional(),
cursor: z.string().nullable().optional().openapi({
description: "Optional cursor for fetching the next page of articles.",
}),
limit: z.number().int().min(1).max(100).optional().openapi({
default: 10,
description: "Maximum number of articles to return per page.",
example: 20,
}),
search: z.string().max(512).optional().openapi({
description: "Full-text search query applied to article titles and bodies.",
example: "gouvernement congolais",
}),
sentiment: sentimentSchema.optional(), sentiment: sentimentSchema.optional(),
sourceId: idSchema.optional(), sourceId: idSchema.optional(),
}); });
+4 -12
View File
@@ -1,18 +1,10 @@
import { z } from "@hono/zod-openapi"; import z from "zod";
export const loginSchema = z.object({ export const loginSchema = z.object({
email: z.email().openapi({ email: z.email(),
description: "Email address used to authenticate the user.", password: z.string().min(8),
example: "user@example.com",
}),
password: z.string().min(8).openapi({
description: "Account password.",
example: "••••••••",
}),
}); });
export const refreshSessionSchema = z.object({ export const refreshSessionSchema = z.object({
refreshToken: z.string().min(1).openapi({ refreshToken: z.string().min(1),
description: "Refresh token returned when logging in.",
}),
}); });
+296
View File
@@ -0,0 +1,296 @@
import z from "zod";
import { idSchema } from "./shared";
export const categorySchema = z.object({
candidates: z.array(z.string()),
createdAt: z.coerce.date(),
description: z.string().max(512).optional(),
embeddings: z.array(z.number()).optional(),
id: idSchema,
name: z.string().min(1).max(255),
slug: z.string().min(1).max(255),
updatedAt: z.coerce.date().optional(),
weight: z.number().int(),
});
export type Category = z.infer<typeof categorySchema>;
export const Categories: Category[] = [
{
candidates: [
"accident",
"actualite",
"actualité",
"a-la-une",
"en bref",
"en-clair",
"encontinu",
"flash",
"faits-divers",
"drame",
"enquetes",
"desintox",
"archives",
"insolite",
"national",
"featured",
"related-featured",
"top-featured",
"top-trending",
"news-dont-miss",
"news-just-in",
"la-rdc-a-la-une",
"example-1",
"example-2",
"example-3",
"example-4",
"beto-premium",
"fiches",
"suspension",
],
createdAt: new Date(),
description: "Nouvelles de dernière minute, faits divers et informations générales.",
id: "06930299-71a3-735e-9dcd-055c321f2ca9",
name: "Actualités & Faits Divers",
slug: "actualites-faits-divers",
weight: 4,
},
{
candidates: [
"democratie",
"dialogue entre congolais",
"diplomatie",
"diplomatie-et-securite",
"election",
"élections",
"elections-2023",
"legislatives",
"presidentielle",
"parlement",
"politique",
"serment",
"si j'étais président",
"spécial elections",
"us-politics",
"ukraine-conflict",
"conférence des nations unies",
"nations unies",
"rebellion",
],
createdAt: new Date(),
description: "Élections, gouvernance, institutions, diplomatie et conflits politiques.",
id: "06930299-71a3-7aa5-95a4-a7b39c421255",
name: "Politique & Gouvernement",
slug: "politique-gouvernement",
weight: 10,
},
{
candidates: [
"agrobusiness",
"banking",
"banques-et-finances",
"economico",
"economie",
"économie",
"finances",
"industrie",
"investments",
"mines",
"pme-entrepreneuriat",
"featured-economy",
"featured-markets",
"intl-markets",
"us-business",
"la-une-eco",
"emploi",
],
createdAt: new Date(),
description: "Affaires, marchés financiers, entreprises, banques, emplois et entrepreneuriat.",
id: "06930299-71a3-7c5b-98b0-d58c8308496d",
name: "Économie & Finances",
slug: "economie-finances",
weight: 9,
},
{
candidates: [
"arts",
"culture",
"musique",
"livre",
"livres",
"patrimoine-traditions",
"identité culturelle",
"caricature",
"histoire",
],
createdAt: new Date(),
description: "Art, musique, patrimoine, histoire, littérature et expression culturelle.",
id: "06930299-71a3-7d47-8df2-b201975437f4",
name: "Culture & Arts",
slug: "culture-arts",
weight: 2,
},
{
candidates: ["sport", "sports", "football", "boxe", "can", "okapi sports"],
createdAt: new Date(),
description: "Compétitions sportives nationales et internationales, analyses et résultats.",
id: "06930299-71a3-7e65-9421-b418c8a161b7",
name: "Sports",
slug: "sports",
weight: 5,
},
{
candidates: [
"famille-genre",
"femme",
"jeunes",
"justice",
"criminalite",
"arrestation",
"kidnapping",
"viol",
"vol",
"manifestation",
"marche",
"salubrite",
"denonciation",
"evasion",
"sante",
"santé",
"necrologie",
"education",
"éducation",
"enseignement",
"religion",
"religion-spiritualite",
"message-des-voeux",
"style et beauté",
"societe",
"société",
],
createdAt: new Date(),
description: "Questions sociales, éducation, santé, justice, genre et vie quotidienne.",
id: "06930299-71a3-7f8b-b5a3-413f512ec6d8",
name: "Société & Vie Quotidienne",
slug: "societe-vie-quotidienne",
weight: 6,
},
{
candidates: [
"climat-et-environnement",
"developpement-durable",
"biodiversite",
"ecologico",
"environnement",
"nature",
"eau",
"electricite",
"energie",
"inondation",
"science & env.",
"sciences",
"technologie",
"technologie-innovation",
"mc geek !",
"sur le net",
],
createdAt: new Date(),
description:
"Recherche scientifique, innovation technologique, climat, environnement et énergie.",
id: "06930299-71a4-7096-8a7f-d69920882d95",
name: "Sciences, Technologies & Environnement",
slug: "sciences-technologies-environnement",
weight: 7,
},
{
candidates: [
"afrique",
"congo-brazzaville",
"congolais de l'étranger",
"diaspora",
"euro-zone",
"se-asia",
"middle-east",
"monde",
"world-news",
"grands-lacs",
"bandundu",
"bukavu",
"bunia",
"ituri",
"katanga",
"kinshasa",
"maniema",
"mbujimayi",
"provinces",
"info kin",
"tourisme",
"transport",
"route",
"infrastructures",
"ukraine-conflict",
],
createdAt: new Date(),
description: "Actualités internationales, régions du monde et provinces locales.",
id: "06930299-71a4-724a-8975-ea7d21286c22",
name: "International & Régions",
slug: "international-regions",
weight: 8,
},
{
candidates: [
"analyses",
"opinion",
"opinions",
"tribune",
"grand-angle",
"grande interview",
"le débat",
"lettre-ouverte",
"l'invité de la campagne",
"l'invité du jour",
"émissions",
"magazine",
"magazine un",
"medias",
"communication",
"communications",
"parole aux auditeurs",
"parole d'enfant",
"revue de presse",
"tele-medias",
"multimedia",
"tv",
],
createdAt: new Date(),
description: "Chroniques, analyses, tribunes, programmes et contenus médiatiques.",
id: "06930299-71a4-745b-8813-6bca9c6b3c56",
name: "Opinions & Médias",
slug: "opinions-medias",
weight: 3,
},
{
candidates: [
"beto-premium",
"example-1",
"example-2",
"example-3",
"example-4",
"fiches",
"publicite",
"okapi service",
"petro-chem-example-3",
"sans catégorie",
"uncategorized",
"lefonde",
"jdc",
],
createdAt: new Date(),
description: "Rubriques expérimentales, catégories indéterminées et éléments divers.",
id: "06930299-71a4-756a-948b-e4a244b5887e",
name: "Divers & Autres",
slug: "divers-autres",
weight: 1,
},
];
@@ -1,6 +1,6 @@
import { z } from "zod"; import z from "zod";
import { UPDATE_DIRECTIONS } from "#domain/constants"; import { UPDATE_DIRECTIONS } from "../constants";
// schemas // schemas
export const UpdateDirectionSchema = z.enum(UPDATE_DIRECTIONS); export const UpdateDirectionSchema = z.enum(UPDATE_DIRECTIONS);
+2
View File
@@ -1,5 +1,7 @@
export * from "./articles"; export * from "./articles";
export * from "./auth"; export * from "./auth";
export * from "./categories";
export * from "./crawler";
export * from "./reports"; export * from "./reports";
export * from "./shared"; export * from "./shared";
export * from "./sources"; export * from "./sources";

Some files were not shown because too many files have changed in this diff Show More