Сегодня я делюсь, пожалуй, самой сложной вещью, которую я построил с OpenWhisk. Хотя я горжусь этим, я напомню людям, что я все еще новичок в этом мире, так что имейте это в виду, когда я буду объяснять, что я сделал.

Много лет назад, около семи (черт возьми), я создал демо-версию ColdFusion, которая анализировала локальные данные 911 и локально сохраняла их в базе данных: Proof of Concept 911 Viewer.

Я использовал Yahoo Pipe, чтобы получить HTML-данные, предоставленные веб-сайтом местной полиции, и преобразовать их во что-то, что я мог хранить. Это не обязательно была ракетостроение, но строить было весело. Было еще веселее, когда я забыл, что автоматизировал его, и вернулся через несколько месяцев, чтобы просмотреть все собранные данные: Обновите мой 911 Viewer.

Эта демонстрация недавно пришла мне на ум, и я подумал, что было бы неплохо попробовать построить ее с помощью OpenWhisk. Имея это в виду, я построил следующее:

  • Во-первых, действие, которое анализирует данные.
  • Во-вторых, действие, которое принимает входные данные, проверяет, существуют ли данные в хранилище данных Cloudant, а затем, если нет, добавляет их.
  • Последовательность для соединения двух.
  • Расписание на основе Cron для периодической проверки данных.

Это звучит как много и довольно сложно, но разбивка на составные части/функции упростила работу и позволила мне попробовать некоторые части OpenWhisk, с которыми я еще не играл, в частности аспект триггера Cron. Давайте рассмотрим это шаг за шагом.

Разбор необработанных данных HTML

Данные, которые я анализирую, находятся на http://lafayette911.org. Как видите, это таблица отчетов об инцидентах:

Я начал с быстрого просмотра исходного кода, чтобы увидеть, как был создан HTML. Оказалось, что таблица управлялась iframe, указывающим на http://apps.lafayettela.gov/L911/default.aspx. Глядя на исходный код, я увидел, что данные были переданы Ajax-вызовом http://apps.lafayettela.gov/L911/Service2.svc/getTrafficIncidents. Я был взволнован, потому что на мгновение подумал, что мне не придется ничего анализировать. Оказывается, JSON на самом деле был отформатирован HTML (я немного уменьшил его):

{"d":" <center><a href=\"#KEY\">KEY<\/a><table border=0 bgcolor=\"white\"><tr bgcolor=\"#99FF99\"><td><b>&nbsp;<a href='http:\/\/maps.google.com\/maps?q=2909+NW+EVANGELINE+THROUGHWAY+,LAFAYETTE+LA' target='_new'>2909 NW EVANGELINE TW<\/a>&nbsp;<BR>&nbsp;LAFAYETTE,LA&nbsp;<\/b><\/td><td><b>Vehicle Accident w\/ Injuries<\/b><\/td><td><b>02\/14\/2017 - 11:59 AM<\/b><\/td><td><b>P F M <\/b><\/td><\/tr><tr bgcolor=\"#FFFF99\"><td><b>&nbsp;<a href='http:\/\/maps.google.com\/maps?q=1100+SE+EVANGELINE+THROUGHWAY+,LAFAYETTE+LA' target='_new'>1100 SE EVANGELINE TW<\/a>&nbsp;<BR>&nbsp;LAFAYETTE,LA&nbsp;<\/b><\/td><td><b>Vehicle Accident w\/ Injuries<\/b><\/td><td><b>02\/14\/2017 - 11:40 AM<\/b><\/td><td><b>P F M <\/b><\/td><\/tr><\/table><small>Data Updated at 02\/14\/2017 - 1:12:38 PM <\/small><\/center><script>$('dateline').innerHTML = '02\/14\/2017 - 1:12:38 PM'; <\/script>"}

Вздох

Хорошо, к счастью, я знал, как с этим работать. В прошлом году я сделал демонстрацию, включающую парсинг веб-страниц с помощью Cheerio (Парсинг веб-страницы в Node с помощью Cheerio), и я знал, что это работает хорошо, поэтому мои действия были сосредоточены на работе с этим. Помните, что для включения случайного npm, упакованного с помощью OpenWhisk, вы должны использовать заархивированное действие, которое включает каталог package.json и node_modules. Это немного больше работы, но незначительно.

Другой немного сложный аспект заключался в том, что я хотел геокодировать адреса. Для этого я использовал превосходный Google Geocode API, который является частью Maps SDK. Вот и вся акция.

let cheerio = require('cheerio');
let request = require('request');
function main(args) {
    return new Promise((resolve, reject) => {
        request('http://apps.lafayettela.gov/L911/Service2.svc/getTrafficIncidents', {method:'post'}, function(err, response, body) {
            if(err) reject(err);
            let results = [];
            // body is a json packet, html is in d
            let $ = cheerio.load(JSON.parse(body).d);
            let channels = $('tr');
            //channel 0 is the header
            for(let i=1;i<channels.length;i++) {
                let channelRow = channels.get(i);
                let cells = $(channelRow).children();
                //console.log(channelRow);
                let loc = $(cells.get(0)).text().trim();
                let reason = $(cells.get(1)).text().trim();
                let timestamp = $(cells.get(2)).text().trim();
                let [daypart,timepart] = timestamp.split(' - ');
                let incidentDate = new Date(daypart + ' '+timepart);
                let assisting = $(cells.get(3)).text().trim().split(' ');
                //package it up
                results.push({location:loc, reason:reason, timestamp:incidentDate, assisting: assisting});
            }
            /*
            New logic - for each, geocode
            */
            let promises = [];
            results.forEach(function(res) {
                console.log('need to work on '+res.location);
                promises.push(new Promise( (resolve, reject) => {
                    let geourl = 'https://maps.googleapis.com/maps/api/geocode/json?address='+encodeURIComponent(res.location);
                    console.log(geourl);
                    request(geourl, function(err, response, body) {
                        if(err) reject(err);
                        let geoResult = {};
                        let geodata = JSON.parse(body);
                        if(geodata.status === 'OK') {
                            geoResult.geostatus = true;
                            geoResult.geo = geodata.results[0].geometry.location;
                        } else {
                            geoResult.geostatus = false;
                        }
                        resolve(geoResult);
                        //console.log(body);
                    });
                }));
            });
            Promise.all(promises).then(function(geodata) {
                console.log('done with all promises');
                //brittle code here, geodata len != results
                for(var i=0;i<geodata.length;i++) {
                    results[i].geo = geodata[i];
                }
                resolve({ traffic:results });
            });

        });
    });
}
exports.main = main;

Итак, сверху — мы начинаем с общего запроса данных. Получив это, мы можем попросить Cheerio превратиться в DOM, как HTML в браузере. Затем я беру все строки таблицы, а затем извлекаю ячейки внутри каждой строки. Я немного манипулирую временем, чтобы превратить его в данные JavaScript и преобразовать «вспомогательную» ячейку в массив.

Следующая часть немного сложнее. Мне нужно геокодировать все адреса, и это включает в себя N асинхронных процессов. Поэтому я использую массив обещаний, чтобы получить все результаты, а затем обновить исходные данные. К сожалению, похоже, что у сервиса есть проблема с пересечениями. Так, например, авария в «Джонстон и Камелия» неправильно геокодирована, хотя ссылки на карту с сайта работают нормально. Это может быть моя вина. Иногда это срабатывало, иногда нет.

В итоге я получаю хороший набор данных:

{
        "traffic": [
                {
                        "location": "LEE AV & E CYPRESS ST  LAFAYETTE,LA",
                        "reason": "Vehicle Accident",
                        "timestamp": "2017-02-14T19:08:00.000Z",
                        "assisting": [
                                "P",
                                "M"
                        ],
                        "geo": {
                                "geostatus": true,
                                "geo": {
                                        "lat": 30.2256757,
                                        "lng": -92.0149277
                                }
                        }
                },
                {
                        "location": "E UNIVERSITY AV & W PINHOOK RD  LAFAYETTE,LA",
                        "reason": "Vehicle Accident",
                        "timestamp": "2017-02-14T19:02:00.000Z",
                        "assisting": [
                                "S",
                                "P"
                        ],
                        "geo": {
                                "geostatus": true,
                                "geo": {
                                        "lat": 30.21055,
                                        "lng": -92.0097742
                                }
                        }
                },
                {
                        "location": "6801  JOHNSTON ST  LAFAYETTE,LA",
                        "reason": "Vehicle Accident",
                        "timestamp": "2017-02-14T19:00:00.000Z",
                        "assisting": [
                                "P"
                        ],
                        "geo": {
                                "geostatus": true,
                                "geo": {
                                        "lat": 30.150066,
                                        "lng": -92.0934762
                                }
                        }
                },
                {
                        "location": "W PINHOOK RD &  BENDEL RD  LAFAYETTE,LA",
                        "reason": "Vehicle Accident",
                        "timestamp": "2017-02-14T18:44:00.000Z",
                        "assisting": [
                                "P"
                        ],
                        "geo": {
                                "geostatus": true,
                                "geo": {
                                        "lat": 30.1990935,
                                        "lng": -92.0163944
                                }
                        }
                }
        ]
}

Неплохо! Хорошо, переходим ко второму шагу — сохранению данных.

Сохранение данных с помощью Cloudant

Для хранения данных я предоставил новый сервис Cloudant с Bluemix. OpenWhisk может автоматически подбирать новые сервисы Cloudant и добавлять в вашу учетную запись пакет с действиями/триггерами для взаимодействия с этим сервисом. Чтобы работать с этими действиями, я создал свое собственное действие, которому поручено обрабатывать ввод данных, проверять, являются ли они новыми, а затем добавлять их. Вот это действие.

var openWhisk = require('openwhisk');
var ow = openWhisk({
    apihost:'openwhisk.ng.bluemix.net',
    api_key:'my secret is so secret it doesnt know it is a secret'
});
var actionBase = '/[email protected]_My Space/Bluemix_Cloudant Traffic_Credentials-1';
function main(args) {
    /*
    hard coded for now
    args.traffic = [
        {
            location:"W CONGRESS ST &  CAJUNDOME BL  LAFAYETTE,LA",
            reason:"Flood",
            timestamp:"2017-02-08T20:59:00.000Z"
        },
        {
            location:"ssss W CONGRESS ST &  CAJUNDOME BL  LAFAYETTE,LA",
            reason:"Vehicle Accident",
            timestamp:"2017-02-08T20:59:00.000Z"
        },
        {
            location:"W CONGRESS ST &  CAJUNDOME BL  LAFAYETTE,LA",
            reason:"Monster",
            timestamp:"2017-02-08T20:59:00.000Z"
        },
    ];
    */
    if(!args.traffic) args.traffic = [];
    
    return new Promise((resolve, reject) => {
        let promises = [];
        args.traffic.forEach(function(d) {
            promises.push(addIfNew(d));
        });
        Promise.all(promises).then((results) => {
            console.log('all done like a boss');
            resolve({results:results});
        });
    });
}
function addIfNew(d) {
    return new Promise((resolve, reject) => {
        
        ow.actions.invoke({
            actionName:actionBase+'/exec-query-find',
            blocking:true,
            params:{
                "dbname":"traffic",
                "query":
                    {
                    "selector": {
                        "location": {
                        "$eq": d.location
                        },
                        "reason":{
                        "$eq":d.reason
                        },
                        "timestamp":{
                        "$eq":d.timestamp
                        }
                    },
                    "fields": [
                        "_id"
                    ]
                    }
            }
        }).then(function(res) {
            let numMatches = res.response.result.docs.length;
            if(numMatches === 0) {
                console.log('data is new, so add it');
                ow.actions.invoke({
                    actionName:actionBase+'/write',
                    blocking:true,
                    params:{
                        "dbname":"traffic",
                        "doc":d
                    }
                }).then(function(res) {
                    resolve({result:1});
                }); 
            } else {
                resolve({result:0});
            }
        });
        
    });
    
}
exports.main = main;

Сверху — обратите внимание, что я использую пакет OpenWhisk. Это в основном позволяет мне использовать OpenWhisk из моего действия так же, как я использую его из CLI. Это все еще кажется мне… немного неправильным, но я, честно говоря, не знаю другого способа сделать это. Теоретически я мог бы просто совершать REST-вызовы прямо в свой сервис Cloudant, но пока я собираюсь использовать пакет. Я определенно думаю, что в будущем я буду делать здесь что-то по-другому.

Обратите внимание, что в основном разделе у меня есть некоторые жестко закодированные данные, закомментированные. Во время тестирования именно так я справился с вводом демонстрационных данных в действие. В конце концов, все сводится к блоку addIfNew. Мои навыки Cloudant несколько слабы, но моя логика работала хорошо. Я запрашиваю местоположение, причину и отметку времени, но не вспомогательные данные, поскольку я не был уверен, смогу ли я запросить такие значения массива. На случай, если две аварии произойдут одновременно в одном и том же месте, но с разными спасателями, я просто предположу, что вся мультивселенная рушится, и жизнь, какой мы ее знаем, почти закончилась. (Эй, мне не придется писать модульные тесты!)

Если совпадений не возвращается, я просто передаю данные в действие записи и — все!

Соединение точек

Давайте резюмируем. У меня есть действие, которое может сосать строку HTML и превращать ее в данные. У меня есть второе действие, которое может принять этот ввод и сохранить его, если он новый. Теперь нам нужно собрать это вместе, запланировать и запустить с этим.

Во-первых, соединить их тривиально — просто используйте последовательность! Я назвал свой handleTraffic и просто передал ему имя двух моих действий — getTraffic и addTraffic. Команда выглядит так:

wsk action create handleTraffic --sequence getTraffic,addTrafic

Затем я отправил вызов последовательности, чтобы убедиться, что она работает. Помните, детские шаги.

Хорошо — здесь все становится немного сложнее. Я начал с создания триггера Тревожный. Это триггер, доступный на платформе Bluemix OpenWhisk, который позволяет вам определить время срабатывания на основе Cron. Я создал свой так:

wsk trigger create checkTraffic --feed /whisk.system/alarms/alarm --param cron "5 * * * *"

Я всегда находил Cron непонятным синтаксисом, поэтому я использовал http://crontab-generator.org, чтобы сгенерировать для меня строку.

Все это делает будильник — даже через 5 минут сработает триггер. Но само по себе это ничего не дает. Чтобы заставить его что-то делать, я сделал правило. Правило просто гласило: когда срабатывает checkTraffic, запускать мою последовательность. Я назвал свое правило newTrafficRule, потому что у меня нет воображения.

Пользовательский интерфейс OpenWhisk в Bluemix отлично справляется с этой задачей, хотя, хоть убей, я не могу понять, как настроить исходный Cron. Я думаю, это то, что вы хотите убедиться, что вы не забыли.

Я планирую более подробно рассказать о пользовательском интерфейсе OpenWhisk позже, но хочу отметить, что монитор был чрезвычайно полезным, когда я работал над этой демонстрацией. Это позволило мне видеть, как мои действия срабатывают в режиме реального времени, и наблюдать за их результатами.

Заворачивать

В целом, это работает довольно хорошо. Я обнаружил, что существует ограничение на количество запросов, которые я могу выполнять в секунду на бесплатном уровне Cloudant, но в платной учетной записи, конечно, такой проблемы не будет. У меня это работает уже несколько дней (хотя изначально у меня не было геокодирования), и я приближаюсь к 400 точкам данных. Я планирую оставить это на некоторое время и вернуться, как только у меня будет добрая тысяча или около того записей, и немного повеселиться, нарисовав диаграммы / проанализировав данные.

Эта статья первоначально была опубликована в моем блоге по адресу: https://www.raymondcamden.com/2017/02/14/collecting-911-data-openwhisk-cron-triggers