Files
IPSExtractor/.codex_tmp_apartments/build_apartments_workbook.mjs
T

311 lines
14 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const workspace = "C:/Users/erik1/Documents/IPSExtractor";
const sourceDir = path.join(workspace, ".codex_tmp_apartments");
const outputDir = path.join(workspace, "outputs", "apartamentos_rentpana_20260830");
const outputPath = path.join(outputDir, "apartamentos_visibles_rentpana.xlsx");
const pageSize = 50;
const pages = [];
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
const text = await fs.readFile(path.join(sourceDir, `page_${pageIndex}.json`), "utf8");
const rows = JSON.parse(text);
pages.push({ page: pageIndex + 1, offset: pageIndex * pageSize, rows });
}
const apartments = pages.flatMap((page) => page.rows);
if (apartments.length !== 101) throw new Error(`Se esperaban 101 registros y llegaron ${apartments.length}`);
const ids = apartments.map((row) => row.id);
if (new Set(ids).size !== apartments.length) throw new Error("Se detectaron IDs duplicados en la extracción");
const columns = Object.keys(apartments[0]);
const expectedColumns = 34;
if (columns.length !== expectedColumns) throw new Error(`Se esperaban ${expectedColumns} columnas y llegaron ${columns.length}`);
const normalize = (key, value) => {
if (value === null || value === undefined) return null;
if (key === "created_at" || key === "updated_at") {
const date = new Date(`${value}T00:00:00Z`);
return Number.isNaN(date.getTime()) ? String(value) : date;
}
if (Array.isArray(value)) return value.join(" | ");
if (typeof value === "object") return JSON.stringify(value);
return value;
};
const colLetter = (oneBased) => {
let n = oneBased;
let result = "";
while (n > 0) {
const remainder = (n - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
n = Math.floor((n - 1) / 26);
}
return result;
};
const workbook = Workbook.create();
const summary = workbook.worksheets.add("Resumen");
const dataSheet = workbook.worksheets.add("Apartamentos");
const photosSheet = workbook.worksheets.add("Fotos");
const paginationSheet = workbook.worksheets.add("Paginacion");
const navy = "#123047";
const teal = "#0F766E";
const cyan = "#DFF4F1";
const pale = "#F4F7F9";
const gold = "#E8B44F";
const white = "#FFFFFF";
const ink = "#1F2937";
const muted = "#60707D";
const border = "#D8E1E7";
for (const sheet of [summary, dataSheet, photosSheet, paginationSheet]) {
sheet.showGridLines = false;
}
// Hoja principal con todos los campos devueltos por la API.
const dataMatrix = [columns, ...apartments.map((row) => columns.map((key) => normalize(key, row[key])))];
const dataEndCol = colLetter(columns.length);
const dataEndRow = dataMatrix.length;
dataSheet.getRange(`A1:${dataEndCol}${dataEndRow}`).values = dataMatrix;
dataSheet.freezePanes.freezeRows(1);
dataSheet.freezePanes.freezeColumns(2);
const apartmentTable = dataSheet.tables.add(`A1:${dataEndCol}${dataEndRow}`, true, "ApartmentsTable");
apartmentTable.style = "TableStyleMedium2";
apartmentTable.showFilterButton = true;
dataSheet.getRange(`A1:${dataEndCol}1`).format = {
fill: navy,
font: { bold: true, color: white },
rowHeight: 28,
verticalAlignment: "center",
};
dataSheet.getRange(`A2:${dataEndCol}${dataEndRow}`).format = {
font: { color: ink, size: 10 },
verticalAlignment: "top",
};
const widths = {
id: 36, user_id: 36, user_name: 22, user_phone: 16, address: 34, description: 60,
bedrooms: 12, bathrooms: 12, area_m2: 12, monthly_rent: 16, available_rooms: 16,
building_amenities: 38, apartment_features: 44, included_services: 36, photo_urls: 65,
created_at: 14, updated_at: 14, show_phone: 12, show_profile: 13, is_visible: 12,
priority: 11, listing_type: 14, city: 17, area: 24, short_id: 13,
smart_rental_enabled: 20, parent_apartment_id: 36, tenant_user_id: 36,
tenant_name: 20, tenant_phone: 16, tenant_gender: 15, tenant_age: 12,
is_paid: 11, seo_slug: 38,
};
columns.forEach((key, index) => {
const letter = colLetter(index + 1);
dataSheet.getRange(`${letter}:${letter}`).format.columnWidth = widths[key] ?? 16;
});
for (const key of ["description", "building_amenities", "apartment_features", "included_services", "photo_urls"]) {
const index = columns.indexOf(key) + 1;
const letter = colLetter(index);
dataSheet.getRange(`${letter}2:${letter}${dataEndRow}`).format.wrapText = true;
}
dataSheet.getRange(`A2:${dataEndCol}${dataEndRow}`).format.rowHeight = 42;
for (const key of ["created_at", "updated_at"]) {
const letter = colLetter(columns.indexOf(key) + 1);
dataSheet.getRange(`${letter}2:${letter}${dataEndRow}`).format.numberFormat = "yyyy-mm-dd";
}
for (const key of ["bedrooms", "bathrooms", "area_m2", "available_rooms", "priority", "tenant_age"]) {
const letter = colLetter(columns.indexOf(key) + 1);
dataSheet.getRange(`${letter}2:${letter}${dataEndRow}`).format.numberFormat = "#,##0";
}
const rentLetter = colLetter(columns.indexOf("monthly_rent") + 1);
dataSheet.getRange(`${rentLetter}2:${rentLetter}${dataEndRow}`).format.numberFormat = '"$"#,##0';
// Una fila por foto para facilitar filtros, descarga y revisión.
const photoRows = [];
for (const apartment of apartments) {
const urls = Array.isArray(apartment.photo_urls) ? apartment.photo_urls : [];
urls.forEach((url, index) => photoRows.push([
apartment.id,
apartment.short_id,
apartment.city,
Array.isArray(apartment.area) ? apartment.area.join(" | ") : apartment.area,
apartment.address,
index + 1,
url,
]));
}
const photoMatrix = [["apartment_id", "short_id", "city", "area", "address", "photo_index", "photo_url"], ...photoRows];
photosSheet.getRange(`A1:G${photoMatrix.length}`).values = photoMatrix;
photosSheet.freezePanes.freezeRows(1);
photosSheet.freezePanes.freezeColumns(2);
const photosTable = photosSheet.tables.add(`A1:G${photoMatrix.length}`, true, "PhotosTable");
photosTable.style = "TableStyleMedium2";
photosTable.showFilterButton = true;
photosSheet.getRange("A1:G1").format = { fill: navy, font: { bold: true, color: white }, rowHeight: 28 };
photosSheet.getRange(`A2:G${photoMatrix.length}`).format = { font: { color: ink, size: 10 }, rowHeight: 22 };
photosSheet.getRange("A:A").format.columnWidth = 36;
photosSheet.getRange("B:B").format.columnWidth = 13;
photosSheet.getRange("C:C").format.columnWidth = 16;
photosSheet.getRange("D:D").format.columnWidth = 24;
photosSheet.getRange("E:E").format.columnWidth = 36;
photosSheet.getRange("F:F").format.columnWidth = 12;
photosSheet.getRange("G:G").format.columnWidth = 90;
photosSheet.getRange(`F2:F${photoMatrix.length}`).format.numberFormat = "#,##0";
// Control detallado de las tres páginas consultadas.
const paginationRows = pages.map((page) => {
const from = page.offset;
const to = page.offset + page.rows.length - 1;
return [
page.page,
page.offset,
pageSize,
`${from}-${to}`,
page.rows.length,
"200 OK",
page.rows[0]?.id ?? null,
page.rows.at(-1)?.id ?? null,
];
});
const paginationMatrix = [["pagina", "offset", "limite", "rango_respuesta", "filas", "estado_http", "primer_id", "ultimo_id"], ...paginationRows];
paginationSheet.getRange("A1:H4").values = paginationMatrix;
paginationSheet.getRange("A5:D5").merge();
paginationSheet.getRange("A5:D5").values = [["Total descargado"]];
paginationSheet.getRange("E5").formulas = [["=SUM(E2:E4)"]];
paginationSheet.getRange("F5:H5").merge();
paginationSheet.getRange("F5:H5").values = [["Validado contra Content-Range: 101"]];
paginationSheet.getRange("A1:H1").format = { fill: navy, font: { bold: true, color: white }, rowHeight: 28 };
paginationSheet.getRange("A2:H4").format = { fill: white, font: { color: ink }, rowHeight: 24 };
paginationSheet.getRange("A5:H5").format = { fill: cyan, font: { bold: true, color: navy }, rowHeight: 26 };
paginationSheet.getRange("A1:H5").format.borders = { preset: "all", style: "thin", color: border };
paginationSheet.getRange("A2:F5").format.horizontalAlignment = "center";
paginationSheet.getRange("A:C").format.columnWidth = 12;
paginationSheet.getRange("D:D").format.columnWidth = 20;
paginationSheet.getRange("E:E").format.columnWidth = 12;
paginationSheet.getRange("F:F").format.columnWidth = 16;
paginationSheet.getRange("G:H").format.columnWidth = 38;
paginationSheet.getRange("A2:C5").format.numberFormat = "#,##0";
paginationSheet.freezePanes.freezeRows(1);
// Resumen ejecutivo y trazabilidad de la extracción.
summary.getRange("A1:F2").merge();
summary.getRange("A1:F2").values = [["Apartamentos visibles · RentPana"]];
summary.getRange("A1:F2").format = {
fill: navy,
font: { bold: true, color: white, size: 20 },
horizontalAlignment: "left",
verticalAlignment: "center",
};
summary.getRange("A3:F3").merge();
summary.getRange("A3:F3").values = [["Extracción completa, paginada y validada contra el total informado por la API"]];
summary.getRange("A3:F3").format = { fill: navy, font: { color: "#D9EDF2", size: 11 }, rowHeight: 24 };
summary.getRange("A5:B5").merge();
summary.getRange("A5:B5").values = [["Apartamentos"]];
summary.getRange("C5:D5").merge();
summary.getRange("C5:D5").values = [["Páginas API"]];
summary.getRange("E5:F5").merge();
summary.getRange("E5:F5").values = [["Fotos"]];
summary.getRange("A6:B7").merge();
summary.getRange("A6:B7").formulas = [["=COUNTA('Apartamentos'!$A$2:$A$102)"]];
summary.getRange("C6:D7").merge();
summary.getRange("C6:D7").formulas = [["=COUNTA('Paginacion'!$A$2:$A$4)"]];
summary.getRange("E6:F7").merge();
summary.getRange("E6:F7").formulas = [[`=COUNTA('Fotos'!$A$2:$A$${photoMatrix.length})`]];
summary.getRange("A5:F5").format = { fill: teal, font: { bold: true, color: white }, horizontalAlignment: "center", rowHeight: 24 };
summary.getRange("A6:F7").format = { fill: cyan, font: { bold: true, color: navy, size: 20 }, horizontalAlignment: "center", verticalAlignment: "center" };
summary.getRange("A5:F7").format.borders = { preset: "outside", style: "thin", color: border };
summary.getRange("A9:F9").merge();
summary.getRange("A9:F9").values = [["Control de extracción"]];
summary.getRange("A9:F9").format = { fill: teal, font: { bold: true, color: white }, rowHeight: 26 };
const extractedAt = new Date();
const controlRows = [
["Fecha y hora (UTC)", extractedAt, null, null, null, null],
["Origen", "https://srv99857.rentpana.com/rest/v1/apartments", null, null, null, null],
["Filtro", "is_visible = true", null, null, null, null],
["Orden estable", "id ascendente", null, null, null, null],
["Tamaño de página", pageSize, null, null, null, null],
["Total informado por API", 101, null, null, null, null],
["IDs únicos validados", new Set(ids).size, null, null, null, null],
["Duplicados detectados", apartments.length - new Set(ids).size, null, null, null, null],
["Columnas de origen", columns.length, null, null, null, null],
["Credenciales incluidas", "No", null, null, null, null],
];
summary.getRange("A10:F19").values = controlRows;
for (let row = 10; row <= 19; row += 1) summary.getRange(`B${row}:F${row}`).merge();
summary.getRange("A10:A19").format = { fill: pale, font: { bold: true, color: muted }, rowHeight: 24 };
summary.getRange("B10:F19").format = { fill: white, font: { color: ink }, rowHeight: 24, wrapText: true, horizontalAlignment: "left" };
summary.getRange("A10:F19").format.borders = { preset: "outside", style: "thin", color: border };
summary.getRange("B10").format.numberFormat = "yyyy-mm-dd hh:mm:ss";
summary.getRange("B14:B18").format.numberFormat = "#,##0";
summary.getRange("A21:F21").merge();
summary.getRange("A21:F21").values = [["Contenido del archivo"]];
summary.getRange("A21:F21").format = { fill: teal, font: { bold: true, color: white }, rowHeight: 26 };
const contentsRows = [
["Apartamentos", "Todos los 34 campos de origen; filtros activados y encabezado congelado."],
["Fotos", "Una fila por URL de foto, vinculada por apartment_id y short_id."],
["Paginacion", "Offset, límite, rango, filas, estado y IDs extremos por página."],
];
summary.getRange("A22:F24").values = contentsRows.map(([a, b]) => [a, b, null, null, null, null]);
for (let row = 22; row <= 24; row += 1) summary.getRange(`B${row}:F${row}`).merge();
summary.getRange("A22:A24").format = { fill: pale, font: { bold: true, color: navy }, rowHeight: 28 };
summary.getRange("B22:F24").format = { fill: white, font: { color: ink }, wrapText: true, rowHeight: 28 };
summary.getRange("A22:F24").format.borders = { preset: "outside", style: "thin", color: border };
summary.getRange("A:A").format.columnWidth = 24;
summary.getRange("B:F").format.columnWidth = 18;
summary.getRange("A1:F24").format.font.name = "Aptos";
summary.freezePanes.freezeRows(3);
await fs.mkdir(outputDir, { recursive: true });
const checks = [];
checks.push((await workbook.inspect({
kind: "table",
range: "Resumen!A1:F24",
include: "values,formulas",
tableMaxRows: 24,
tableMaxCols: 6,
maxChars: 6000,
})).ndjson);
checks.push((await workbook.inspect({
kind: "table",
range: "Paginacion!A1:H5",
include: "values,formulas",
tableMaxRows: 8,
tableMaxCols: 8,
maxChars: 3000,
})).ndjson);
checks.push((await workbook.inspect({
kind: "match",
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
options: { useRegex: true, maxResults: 300 },
summary: "final formula error scan",
})).ndjson);
const previews = [
["Resumen", "A1:F24", "preview_resumen.png"],
["Apartamentos", "A1:J10", "preview_apartamentos_1.png"],
["Apartamentos", "K1:AH8", "preview_apartamentos_2.png"],
["Fotos", "A1:G12", "preview_fotos.png"],
["Paginacion", "A1:H5", "preview_paginacion.png"],
];
for (const [sheetName, range, fileName] of previews) {
const blob = await workbook.render({ sheetName, range, scale: 1.4, format: "png" });
await fs.writeFile(path.join(outputDir, fileName), new Uint8Array(await blob.arrayBuffer()));
}
const output = await SpreadsheetFile.exportXlsx(workbook);
await output.save(outputPath);
console.log(JSON.stringify({
outputPath,
apartments: apartments.length,
uniqueIds: new Set(ids).size,
photos: photoRows.length,
pages: pages.length,
columns: columns.length,
checks,
previews: previews.map((item) => path.join(outputDir, item[2])),
}, null, 2));