Compare commits

...

6 commits

Author SHA1 Message Date
4db1f0b09e mejores errores 2024-09-14 20:36:08 -03:00
ddb202dc93 importer: idcomercio 2024-09-14 18:18:07 -03:00
1f9daaa016 caché 2024-09-14 18:12:47 -03:00
4b2c360e17 arreglar mapa 2024-09-14 18:04:43 -03:00
9aff44b3d9 malvinas
fixes las malvinas son argentinas #48
2024-09-14 17:58:13 -03:00
245dc2eb02 mejorar búsqueda 2024-09-14 17:23:33 -03:00
12 changed files with 1683 additions and 70 deletions

Binary file not shown.

View file

@ -61,6 +61,7 @@ async function importSucursales(
async function importDataset(dir: string) { async function importDataset(dir: string) {
console.log(dir); console.log(dir);
const date = basename(dir).match(/(\d{4}-\d{2}-\d{2})/)![1]; const date = basename(dir).match(/(\d{4}-\d{2}-\d{2})/)![1];
const id_comercio = basename(dir).match(/comercio-sepa-(\d+)/)![1];
// TODO: parsear "Ultima actualizacion" al final del CSV y insertarlo en la tabla datasets // TODO: parsear "Ultima actualizacion" al final del CSV y insertarlo en la tabla datasets
// { // {
@ -73,7 +74,7 @@ async function importDataset(dir: string) {
await sql.begin(async (sql) => { await sql.begin(async (sql) => {
let datasetId: number; let datasetId: number;
const res = const res =
await sql`insert into datasets (name, date) values (${basename(dir)}, ${date}) returning id`; await sql`insert into datasets (name, date, id_comercio) values (${basename(dir)}, ${date}, ${id_comercio}) returning id`;
datasetId = res[0].id; datasetId = res[0].id;
const comercios: Papa.ParseResult<{ comercio_cuit: string }> = Papa.parse( const comercios: Papa.ParseResult<{ comercio_cuit: string }> = Papa.parse(

View file

@ -41,6 +41,7 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@maplibre/maplibre-gl-leaflet": "^0.0.22",
"@sentry/sveltekit": "^8.30.0", "@sentry/sveltekit": "^8.30.0",
"@types/node": "^22.5.0", "@types/node": "^22.5.0",
"bits-ui": "^0.21.13", "bits-ui": "^0.21.13",
@ -49,6 +50,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3", "leaflet.markercluster": "^1.5.3",
"lucide-svelte": "^0.441.0", "lucide-svelte": "^0.441.0",
"maplibre-gl": "^4.7.0",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwind-variants": "^0.2.1" "tailwind-variants": "^0.2.1"

View file

@ -1,33 +1,41 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<head> <title>Preciazo</title>
<meta charset="utf-8" /> <meta
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> name="description"
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> content="Busca precios de productos en distintas cadenas de supermercados en Argentina."
/>
<meta content="index, follow" name="robots" />
<meta property="og:title" content="Preciazo" />
<meta
property="og:description"
content="Busca precios de productos en distintas cadenas de supermercados en Argentina."
/>
<meta property="og:image" content="https://preciazo.nulo.in/favicon.png" />
<meta property="og:url" content="https://preciazo.nulo.in" />
<meta property="og:type" content="website" />
<title>Preciazo</title> <meta name="twitter:card" content="summary" />
<meta name="description" content="Busca precios de productos en distintas cadenas de supermercados en Argentina." /> <meta name="twitter:site" content="@esoesnulo" />
<meta content="index, follow" name="robots" /> <meta name="twitter:title" content="Preciazo" />
<meta property="og:title" content="Preciazo" /> <meta
<meta property="og:description" name="twitter:description"
content="Busca precios de productos en distintas cadenas de supermercados en Argentina." /> content="Busca precios de productos en distintas cadenas de supermercados en Argentina."
<meta property="og:image" content="https://preciazo.nulo.in/favicon.png" /> />
<meta property="og:url" content={`https://preciazo.nulo.in${data.pathname}`} /> <meta name="twitter:image" content="https://preciazo.nulo.in/favicon.png" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" /> %sveltekit.head%
<meta name="twitter:site" content="@esoesnulo" /> </head>
<meta name="twitter:title" content="Preciazo" />
<meta name="twitter:description"
content="Busca precios de productos en distintas cadenas de supermercados en Argentina." />
<meta name="twitter:image" content="https://preciazo.nulo.in/favicon.png" />
%sveltekit.head% <body data-sveltekit-preload-data="hover">
</head> <div style="display: contents">%sveltekit.body%</div>
</body>
<body data-sveltekit-preload-data="hover"> </html>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -1,14 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount, tick } from 'svelte'; import { onDestroy, onMount, tick } from 'svelte';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import type L from 'leaflet'; import style from './map_style.json';
import L from 'leaflet';
import 'maplibre-gl';
import '@maplibre/maplibre-gl-leaflet';
let mapEl: HTMLDivElement; let mapEl: HTMLDivElement;
export let mapMap: (map: L.Map, l: typeof L) => void; export let mapMap: (map: L.Map, l: typeof L) => void;
let map: L.Map | undefined; let map: L.Map | undefined;
onMount(async () => { onMount(async () => {
const L = await import('leaflet');
// Set up initial map center and zoom level // Set up initial map center and zoom level
map = L.map(mapEl, { map = L.map(mapEl, {
center: [-34.599722222222, -58.381944444444], // EDIT latitude, longitude to re-center map center: [-34.599722222222, -58.381944444444], // EDIT latitude, longitude to re-center map
@ -17,17 +19,14 @@
// tap: false // tap: false
}); });
/* Control panel to display map layers */ L.maplibreGL({
// var controlLayers = L.control.layers( null, null, { style: style as any,
// position: "topright", attribution:
// collapsed: false '&copy; <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a>, &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
// }).addTo(map); } as any).addTo(map);
// display Carto basemap tiles with light features and labels // display Carto basemap tiles with light features and labels
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { // L.tileLayer.provider('Stadia.AlidadeSmoothBackground').addTo(map);
attribution:
'&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attribution">CARTO</a>'
}).addTo(map);
mapMap(map, L); mapMap(map, L);
}); });

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { page } from '$app/stores';
import Button from '$lib/components/ui/button/button.svelte';
import { ArrowLeftIcon, HomeIcon } from 'lucide-svelte';
</script>
<div class="mx-auto flex max-w-screen-sm flex-col items-center justify-center gap-4 p-4">
<h1 class="flex text-5xl font-bold">Error {$page.status} :(</h1>
<p>{$page.error?.message}</p>
<div class="flex gap-2">
<Button
on:click={() => {
window.history.back();
}}
class="flex items-center gap-2"
>
<ArrowLeftIcon class="h-4 w-4" />
Volver
</Button>
<Button variant="outline" href="/" class="flex items-center gap-2">
<HomeIcon class="h-4 w-4" />
Ir al inicio
</Button>
</div>
</div>

View file

@ -1,7 +1,7 @@
import { sql } from '$lib/server/db'; import { sql } from '$lib/server/db';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async ({ setHeaders }) => {
// https://www.cybertec-postgresql.com/en/postgresql-count-made-fast/ // https://www.cybertec-postgresql.com/en/postgresql-count-made-fast/
const count = await sql` const count = await sql`
SELECT reltuples::bigint SELECT reltuples::bigint
@ -9,6 +9,10 @@ export const load: PageServerLoad = async () => {
WHERE relname = 'precios'; WHERE relname = 'precios';
`; `;
setHeaders({
'Cache-Control': 'public, max-age=600'
});
return { return {
count: count[0].reltuples count: count[0].reltuples
}; };

View file

@ -2,7 +2,8 @@ import { db } from '$lib/server/db';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { datasets, precios, sucursales } from '$lib/server/db/schema'; import { datasets, precios, sucursales } from '$lib/server/db/schema';
import { and, eq, sql } from 'drizzle-orm'; import { and, eq, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ params }) => { import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, setHeaders }) => {
const id = BigInt(params.id); const id = BigInt(params.id);
const preciosRes = await db const preciosRes = await db
.select({ .select({
@ -47,6 +48,14 @@ ORDER BY d1.id_comercio)
) )
.leftJoin(datasets, eq(datasets.id, precios.id_dataset)); .leftJoin(datasets, eq(datasets.id, precios.id_dataset));
setHeaders({
'Cache-Control': 'public, max-age=600'
});
if (preciosRes.length == 0) {
return error(404, `Producto ${params.id} no encontrado`);
}
// const precios = await sql< // const precios = await sql<
// { // {
// productos_precio_lista: number; // productos_precio_lista: number;

View file

@ -0,0 +1 @@
export const ssr = false;

View file

@ -2,7 +2,7 @@ import { db } from '$lib/server/db';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params, setHeaders }) => {
// const latestDatasetsSq = db.$with('latest_datasets').as( // const latestDatasetsSq = db.$with('latest_datasets').as(
// db.select({ // db.select({
// id: datasets.id, // id: datasets.id,
@ -16,11 +16,17 @@ export const load: PageServerLoad = async ({ params }) => {
// // 'datasets.id_comercio = latest_datasets.id_comercio AND datasets.date = latest_datasets.max_date' // // 'datasets.id_comercio = latest_datasets.id_comercio AND datasets.date = latest_datasets.max_date'
// )) // ))
const query = params.query; const query = params.query
.replaceAll(/á/giu, 'a')
.replaceAll(/é/giu, 'e')
.replaceAll(/í/giu, 'i')
.replaceAll(/ó/giu, 'o')
.replaceAll(/ú/giu, 'u')
.replaceAll(/ñ/giu, 'n');
const productos = await db.execute<{ const productos = await db.execute<{
id_producto: string; id_producto: string;
productos_descripcion: string; productos_descripcion: string;
productos_marca: string; productos_marca: string | null;
in_datasets_count: number; in_datasets_count: number;
}>(sql` }>(sql`
SELECT id_producto, productos_descripcion, productos_marca, SELECT id_producto, productos_descripcion, productos_marca,
@ -46,7 +52,7 @@ WHERE p.id_producto = index.id_producto) as in_datasets_count
const existingProduct = acc.find((p) => p.id_producto === producto.id_producto); const existingProduct = acc.find((p) => p.id_producto === producto.id_producto);
if (existingProduct) { if (existingProduct) {
existingProduct.descriptions.push(producto.productos_descripcion); existingProduct.descriptions.push(producto.productos_descripcion);
existingProduct.marcas.add(producto.productos_marca); if (producto.productos_marca) existingProduct.marcas.add(producto.productos_marca);
existingProduct.in_datasets_count = Math.max( existingProduct.in_datasets_count = Math.max(
existingProduct.in_datasets_count, existingProduct.in_datasets_count,
producto.in_datasets_count producto.in_datasets_count
@ -55,7 +61,7 @@ WHERE p.id_producto = index.id_producto) as in_datasets_count
acc.push({ acc.push({
id_producto: producto.id_producto, id_producto: producto.id_producto,
descriptions: [producto.productos_descripcion], descriptions: [producto.productos_descripcion],
marcas: new Set([producto.productos_marca]), marcas: new Set(producto.productos_marca ? [producto.productos_marca] : []),
in_datasets_count: producto.in_datasets_count in_datasets_count: producto.in_datasets_count
}); });
} }
@ -69,6 +75,10 @@ WHERE p.id_producto = index.id_producto) as in_datasets_count
}> }>
); );
setHeaders({
'Cache-Control': 'public, max-age=600'
});
// 'latest_datasets', // 'latest_datasets',
// sql` // sql`
// WITH latest_datasets AS ( // WITH latest_datasets AS (

View file

@ -8,6 +8,17 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
export let data: PageData; export let data: PageData;
function parseMarcas(marcas: readonly string[]) {
const x = marcas
.map((m) => m.trim().replaceAll(/['`´]/g, ''))
.filter((m) => !['sin marca', 'VARIOS'].includes(m))
.filter((m) => m.length > 0);
if (x.length === 0) {
return ['n/a'];
}
return Array.from(new Set(x));
}
</script> </script>
<svelte:head> <svelte:head>
@ -21,28 +32,31 @@
</Button> </Button>
<SearchBar /> <SearchBar />
<h1 class="my-2 text-2xl font-bold">Resultados para "{data.query}"</h1> <h1 class="my-2 text-2xl font-bold">Resultados para "{data.query}"</h1>
{#each data.collapsedProductos as producto} {#if data.collapsedProductos.length === 0}
<a href={`/id_producto/${producto.id_producto}`} class="my-2 block"> <p class="my-2 text-gray-600">
<Card.Root class="transition-colors duration-200 hover:bg-gray-100"> No se encontraron resultados para "{data.query}". Tené en cuenta que actualmente, el algoritmo
<Card.Header class="block px-3 py-2 pb-0"> de búsqueda es muy básico. Probá buscando palabras clave como "alfajor", "ketchup" o
<Badge "lenteja".
>{Array.from(producto.marcas) </p>
.filter((m) => !['sin marca', 'VARIOS'].includes(m)) {:else}
.filter((m) => m?.trim().length > 0) {#each data.collapsedProductos as producto}
.join('/')}</Badge <a href={`/id_producto/${producto.id_producto}`} class="my-2 block">
> <Card.Root class="transition-colors duration-200 hover:bg-gray-100">
<Badge variant="outline">en {producto.in_datasets_count} cadenas</Badge> <Card.Header class="block px-3 py-2 pb-0">
<Badge variant="outline">EAN {producto.id_producto}</Badge> <Badge>{parseMarcas(Array.from(producto.marcas)).join('/')}</Badge>
</Card.Header> <Badge variant="outline">en {producto.in_datasets_count} cadenas</Badge>
<Card.Content class="px-3 py-2"> <Badge variant="outline">EAN {producto.id_producto}</Badge>
{#each producto.descriptions as description} </Card.Header>
<span>{description}</span> <Card.Content class="px-3 py-2">
{#if description !== producto.descriptions[producto.descriptions.length - 1]} {#each producto.descriptions as description}
<span class="text-gray-500"></span>{' '} <span>{description}</span>
{/if} {#if description !== producto.descriptions[producto.descriptions.length - 1]}
{/each} <span class="text-gray-500"></span>{' '}
</Card.Content> {/if}
</Card.Root> {/each}
</a> </Card.Content>
{/each} </Card.Root>
</a>
{/each}
{/if}
</div> </div>