311 lines
14 KiB
JavaScript
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));
|