-
Notifications
You must be signed in to change notification settings - Fork 1
/
scraper.js
513 lines (438 loc) · 16.8 KB
/
scraper.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
// puppeteer-extra is a drop-in replacement for puppeteer,
// it augments the installed puppeteer with plugin functionality
const puppeteer = require('puppeteer-extra')
// add stealth plugin and use defaults (all evasion techniques)
const StealthPlugin = require('puppeteer-extra-plugin-stealth')
puppeteer.use(StealthPlugin())
const fs = require('fs')
const { promisify } = require('util')
const exists = promisify(fs.stat)
const path = require('path')
const { Console } = require('console')
let didReload = false
const downloadsInProgress = []
const fromTargetUrl = res => res.url().endsWith('consultaPublica.xhtml')
const hasDisplay = 'contains(@style, "display: block")'
const sequence = [
'inicio',
'sujetosObligados',
'obligaciones',
'tarjetaInformativa'
]
/**
* Navega en reversa el sitio.
* Por ejemplo si queremos regresar de las descargas de una org
* al listado de organizaciones.
* @param {Page} page
* @param {string} nextLocation
*/
async function backTo (page, nextLocation) {
const url = page.url()
console.log('Actualmente estoy en', url.split('/').slice(-1)[0])
const [base, target] = url.split('#')
// Nota: target es el # actual
const nextLocationIndex = sequence.indexOf(nextLocation)
const targetIndex = sequence.indexOf(target)
// Si la ubicación deseada (nextLocation) es la misma o enfrente
// salimos del método
if (nextLocationIndex - targetIndex >= 0) {
console.log('Ya estamos en la ubicación deseada')
return true
}
const navigationSteps = sequence
.slice(nextLocationIndex, targetIndex)
.reverse()
console.log('Navegando', navigationSteps.join('->'))
for (let i in navigationSteps) {
await page.goto(`${base}#${navigationSteps[i]}`)
}
}
async function takeTo (page, nextLocation, stateCode, params) {
const { organizationName, organizationIndex, year } = params
const url = page.url()
console.log('Actualmente estoy en', url.split('/').slice(-1)[0])
const [base, target] = url.split('#')
// Nota: target es el # actual
const nextLocationIndex = sequence.indexOf(nextLocation)
const targetIndex = sequence.indexOf(target)
if (nextLocationIndex - targetIndex <= 0) {
return await backTo(page, nextLocation)
}
// Pa' delante (sin contar el inicio)
const steps = sequence.slice(targetIndex + 1, nextLocationIndex + 1)
for (let i in steps) {
const step = steps[i]
console.log(`navegando a #${step}`)
switch (step) {
case 'sujetosObligados':
await navigateToOrganizations(page, stateCode)
break
case 'obligaciones':
await navigateToObligations(page, organizationName, organizationIndex)
break
case 'tarjetaInformativa':
await navigateToInformationCard(page, year)
break
}
}
}
/**
* Consigue el archivo Excel de contratos para una organization
* @param {Object} page de Puppeteer.Page
* @param {String} organizationName se puede usar nombre ('Secretaría de Educación Pública (SEP)')
* @param {Number} organizationIndex o se puede usar índice (42)
* @param {Number} year
*/
async function getContract (page, organizationName = null, organizationIndex = 0, year = 2021, type) {
let selection
if (type === 1) {
selection = "Procedimientos de adjudicación directa"
} else {
selection = "Procedimientos de licitación pública e invitación a cuando menos tres personas"
}
// Espera a que carge la página de documentos
await page.waitForXPath('//div[@id="formListaFormatos:listaSelectorFormatos"]')
const typeCheckbox = await page.$x('//label[contains(@class, "containerCheck")]')
if (typeCheckbox.length) {
let found = false
for (let option of typeCheckbox) {
let text = await page.evaluate(el => el.innerText, option);
if (text == selection) {
await option.click()
found = true
break
}
}
if (found === false) {
const msg = `Opción '${selection}' no encontrada para ${organizationName}; brincando...`
console.log(msg)
throw new Error(msg)
}
} else {
// Algunas instituciones no tienen este selector, porque solamente están disponibles descargas para Procedimientos de adjudicación directa
if (!type) {
const msg = `No se encontraron contratos del tipo '${selection}' para ${organizationName}; brincando...`
console.log(msg)
throw new Error(msg)
}
}
await page.waitForSelector('div.capaBloqueaPantalla', { hidden: true })
// Selecciona todos en Periodo de actualización
const periodsCheckboxes = await page.$x('//label[contains(@for, "formInformacionNormativa:checkPeriodos")]')
let foundPeriods = false
for (let option of periodsCheckboxes) {
let text = await page.evaluate(el => el.innerText, option);
if (text == "Seleccionar todos") {
await option.click()
foundPeriods = true
break
}
}
console.log('Seleccionamos todos los periodos de actualización')
// Consultar
const queryButton = await page.$x('//a[contains(text(), "CONSULTAR")]')
await queryButton[0].click()
// Scroll up
await page.evaluate(_ => {
window.scrollTo(0, 400)
})
// Espera a que el bloqueo de pantalla de la consulta se quite
await page.waitForSelector('div.capaBloqueaPantalla', { hidden: true })
await page.waitForTimeout(1000)
// Si no hay resultados nos brincamos la organización
const downloadCounter = await page.$x('//span[contains(text(), "Se encontraron")]/..')
const counterText = await downloadCounter[0].evaluate(node => node.innerText)
const match = counterText.match(/Se encontraron (\d+) resultados/) || []
const count = Number(match[1])
console.log(`Se encontraron ${count} resultados en la consulta`)
if (count === 0) return false
// Seleccionar opción de descargar
const downloadButton = await page.waitForXPath('//a[contains(text(), "DESCARGAR")]')
await downloadButton.click()
// Seleccionar opción de descargar en la modal
const downloadLabel = await page.waitForXPath('//label[contains(text(), "Descargar")]')
try {
await downloadLabel.click()
} catch (e) {
throw new Error('No se encontró el botón de descarga en el modal')
}
// Para hacer click en el dropdown menu en cada iteración
const dropdown = await page.waitForXPath('//button[@data-id="formModalRangos:rangoExcel"]')
// Espera a que cargue el rango de formatos
// y obtiene las opciones
await page.waitForXPath('//select[@id="formModalRangos:rangoExcel"]')
const options = await page.$x('//select[@id="formModalRangos:rangoExcel"]/option')
// Descargar cada opcion disponible
for (let i in options) {
const [text, value] = await options[i].evaluate(node => [node.text, node.value])
console.log('Opción encontrada:', text, value)
// Excepto el primer elemento que dice "Seleccionar" cuyo valor es -1
if (value === '-1') continue
// Selecciona esta opción del rango
await dropdown.click()
const optionSpan = await page.$x(`//a/span[contains(text(), "${text}")]`)
await optionSpan[0].click()
// Descarga archivo Excel
const downloadExcel = await page.waitForXPath('//input[@id="formModalRangos:btnDescargaExcel"]')
await downloadExcel.click()
console.log('Rango seleccionado')
// Esperamos 90s a que el servidor responda a nuestra petición de descarga
// El listener <responseHandler> agregará el archivo a la lista
// de descargas pendientes, y esperaremos a que terminen antes de continuar.
const downloadRequest = await page.waitForResponse(async r => {
return fromTargetUrl(r) && r.status() === 200
}, { timeout: 90000 })
if (!downloadRequest.ok()) {
console.log('No contestó el servidor con éxito')
return false
};
if (didReload === true) {
// Algunas organizaciones no se pueden descargar, más que por email
// entonces la página reinicia y muestra un modal
const sizePopup = await page.waitForXPath(`//div[@id="modalAvisoError" and ${hasDisplay}]`)
if (sizePopup) {
const errorDiv = await page.$x(`//div[@id="modalAvisoError"]`)
const errorMsg = await errorDiv[0].evaluate(node => node.innerText)
console.log(errorMsg.trim().split('.')[0])
}
const continuar = await page.waitForSelector('#modalCSV > div > div > div > div:nth-child(2) > div > button')
await continuar.evaluate(b => b.click())
const cerrar = await page.waitForSelector('#modalAvisoError > div > div > div > div:nth-child(2) > div > button')
await cerrar.evaluate(b => b.click())
// Resetear la variable
didReload = false
return true
}
await page.waitForTimeout(1000)
await Promise.all(downloadsInProgress)
}
// Wait again for any remaining download to get to the queue (esp. the last one)
await page.waitForTimeout(1000)
await Promise.all(downloadsInProgress)
// Quita la ventana modal
const modal = await page.waitForSelector('#modalRangos')
await modal.evaluate(b => b.click())
await page.waitForSelector('div.capaBloqueaPantalla', { hidden: true })
return true
}
/**
* Inspecciona respuestas para buscar el nombre del archivo a descargar
* Agrega también una {Promise} de descarga (ver toDownload) a la
* lista global de descargas pendientes.
* @params {Response) res
* @params {string} dest_dir
* @return {string|null} filename
*/
function responseHandler (res, dest_dir) {
if (fromTargetUrl(res)) {
const headers = res.headers()
// Si es un excel, registramos el nombre y monitoreamos la descarga
if (headers['content-type'] === 'application/vnd.ms-excel') {
didReload = false
// Si pedimos un excel, checar el nombre
const match = headers['content-disposition'].match(/filename\="(.*)"/) || []
const filename = match[1]
console.log('Descargando', filename)
// Marcamos la descarga como pendiente
downloadsInProgress.push(toDownload(filename, dest_dir))
return filename
} else if (((headers['cache-control'] || '') != 'no-cache') && ((headers['content-length'] || '0') === '0') && ((headers['set-cookie'] || '').endsWith('path=/'))) {
didReload = true
}
}
return null
}
/**
* Prepara la configuración común de la página a escrapear
* @param {Object} puppeeter.Browser
* @param {Object} opts
* @returns {Object} puppeeter.Page
*/
async function getPage (browser, opts) {
const page = await browser.newPage()
const timeout = opts.timeout || 60000
const dest_dir = opts.downloads_dir
// Descarga archivos en la carpeta local
await page._client.send('Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath: dest_dir
})
await page.setRequestInterception(true)
page.on('request', interceptedRequest => {
// No tiene caso desperdiciar ancho de banda en imágenes
if (['.jpg', '.png', '.svg'].some(ext => interceptedRequest.url().endsWith(ext))) {
interceptedRequest.abort()
} else {
interceptedRequest.continue()
}
})
await page.setViewport({ width: 1200, height: 1000 })
page.setDefaultTimeout(timeout)
page.on('response', (response) => responseHandler(response, dest_dir))
return page
}
/**
* Getting from #inicio to #sujetosObligados
*/
async function navigateToOrganizations (page, stateCode) {
// Click en el filtro "Estado o Federación"
const filter = await page.waitForSelector('#filaSelectEF > div.col-md-4 > div > button > span.filter-option.pull-left')
await filter.click()
// Selecciona el estado dropdown (Default: segundo elemento del dropdown: "Federación")
const fed = await page.waitForSelector(`#filaSelectEF > div.col-md-4 > div > div > ul > li:nth-child(${stateCode + 1}) > a`)
await fed.click()
}
/**
* Selecciona del dropdown la organización
* @param {Page} page
* @param {string} orgId
*/
async function selectNextOrganization (page, orgId) {
let msg
const dropdownButton = await page.$x('//button[@data-id="formEntidadFederativa:cboSujetoObligado"]')
if (dropdownButton.length) {
await dropdownButton[0].click()
const dropdownOrg = await page.$x(`//a/span[normalize-space(text())='${orgId}']`)
if (!dropdownOrg.length) {
msg = `No encontramos la institución '${orgId}' en el dropdown; brincando...`
console.log(msg)
throw new Error(msg)
} else if (dropdownOrg.length == 1) {
await dropdownOrg[0].click()
} else {
await dropdownOrg[1].click()
}
await page.waitForSelector('div.capaBloqueaPantalla', { hidden: true })
} else {
msg = 'No encontramos el dropdown de organizaciones'
console.log(msg)
throw new Error(msg)
}
}
/**
* Getting from #sujetosObligados to #obligaciones
*/
async function navigateToObligations (page, organizationName = null, organizationIndex = 0) {
// Seleccionamos la institución desde el dropdown
const institutionDropdown = await page.waitForSelector('#tooltipInst > div > button')
await institutionDropdown.click()
console.log('Objetivo:', organizationName)
// Hacemos click en la organización de interés
const dropdownOrg = await page.$x(`//a/span[normalize-space(text())='${organizationName}']`)
if (!dropdownOrg.length) {
const msg = `No encontramos la institución '${organizationName}' en el dropdown`
console.log(msg)
throw new Error(msg)
} else if (dropdownOrg.length == 1) {
await dropdownOrg[0].click()
} else {
await dropdownOrg[1].click()
}
}
/**
* Getting from #obligaciones to #tarjetaInformativa
*/
async function navigateToInformationCard (page, year = 2021) {
await page.waitForXPath('//form[@id="formListaObligaciones"]')
// Espera a que el bloqueo de pantalla de la consulta se quite
await page.waitForSelector('div.capaBloqueaPantalla', { hidden: true })
// Algunas organizaciones no tendrán sección de contratos
let contractsLabel = []
// Otras muestran un popup, así que hay que asegurarnos de cerrarlo
const noContractsPopup = await page.$x(`//div[@id="modalSinObligaciones" and ${hasDisplay}]`)
if (noContractsPopup.length) {
await noContractsPopup[0].click()
} else {
// Ahora queremos cargar la sección de "CONTRATOS DE OBRAS, BIENES, Y SERVICIOS".
// El elemento a clickear tiene un id con una terminación numérica que
// no se repite entre renders.
// Es por esto que vamos a buscar el label con la etiqueta CONTRATOS DE OBRAS...
// y luego obtener una referencia al ancestro que sí es clickeable.
await page.waitForXPath('//div[@class="tituloObligacion"]')
contractsLabel = await page.$x('//label[contains(text(), "CONTRATOS DE OBRAS, BIENES Y SERVICIOS")]')
}
if (!contractsLabel.length) {
const msg = 'No hay contratos para esta organización'
console.log(msg)
throw new Error(msg)
} else {
await contractsLabel[0].click()
// Selecciona el año del dropdown
const period = await page.waitForXPath('//select[@id="formEntidadFederativa:cboEjercicio"]')
const selection = await (await period.getProperty("value")).jsonValue();
if (selection != year) {
await period.select(String(year))
console.log('Seleccionamos el año', year)
// Hacer clic en "CONTRATOS DE OBRAS, BIENES, Y SERVICIOS" de nuevo
await page.waitForXPath('//div[@class="tituloObligacion"]')
contractsLabel = await page.$x('//label[contains(text(), "CONTRATOS DE OBRAS, BIENES Y SERVICIOS")]')
await contractsLabel[0].click()
}
}
}
async function startBrowser (params) {
let options = params || {}
if (options.development) {
options = {
// devtools: true,
headless: false,
ignoreHTTPSErrors: true,
slowMo: 250,
args: [
"--no-sandbox",
"--no-zygote",
"--single-process",
"--window-position=000,000"
]
}
} else {
options = {
ignoreHTTPSErrors: true,
slowMo: 250,
args: [
"--no-sandbox",
"--no-zygote",
"--single-process",
"--window-position=000,000"
]
}
}
const browser = await puppeteer.launch(options)
return browser
}
function toDownload (filename, dest_dir, timeoutSeconds = 60, intervalSeconds = 1) {
return new Promise((resolve, reject) => {
let interval
let timeout
const filepath = path.join(dest_dir, filename)
timeout = setTimeout(() => {
clearInterval(interval)
const error = `No hemos podido descargar ${filename} en menos de 60s`
console.log(error)
return reject(error)
}, timeoutSeconds * 1000)
interval = setInterval(async () => {
try {
await exists(filepath)
clearTimeout(timeout)
clearInterval(interval)
const success = `Se ha descargado ${filename}`
console.log(success)
return resolve(success)
} catch (e) {
console.log(filepath, 'aun no existe')
}
}, intervalSeconds * 1000)
})
}
module.exports = {
backTo,
downloadsInProgress,
getContract,
getPage,
navigateToOrganizations,
selectNextOrganization,
startBrowser,
takeTo,
toDownload
}