Criando um bot de notícias para o Telegram usando Scrapy e Firebase
Problema
Eu costumo pegar com frequência a rodovia Régis Bittencourt e o que acontece com frequência é o trânsito parar completamente no meio do nada e sem acesso à internet, então eu fico sem a mínima noção do que está acontecendo e em quanto tempo conseguirei chegar ao meu destino.
Pensando nisso, decidi escrever um pequeno bot
para o Telegram que publica num canal as notícias da estrada! Como de costume no NULL on error, vou explicar como fiz.
Web scraping
O primeiro passo é extrair as informações do site. Eu optei por utilizar o framework Scrapy, por alguns motivos que ficarão bem claros abaixo e por ter bastante experiência escrevendo web crawlers com o Scrapy - eu não pretendo escrever um tutorial a respeito neste artigo, isso ficará para uma próxima oportunidade.
Antes de tudo, eu preciso definir o que eu quero extrair; isso é feito definindo uma classe com N propriedades herdando de scrapy.Item
class Entry(Item):
uid = Field()
spider = Field()
timestamp = Field()
content = Field()
Como é possível notar, a aranha, ou crawler, ficou bem simples, mas vou explicar cada parte a seguir.
class RegisSpider(CrawlSpider):
name = 'regis'
allowed_domains = ['autopistaregis.com.br']
start_urls = ['http://www.autopistaregis.com.br/?link=noticias.todas']
rules = (
Rule(LinkExtractor(allow=r'\?link=noticias.?ver*'), callback='parse_news'),
)
def parse_news(self, response):
loader = EntryLoader(item=Entry(), response=response)
loader.add_xpath('content', '//*[@id="noticia"]/p[not(position() > last() -3)]//text()')
return loader.load_item()
A propriedade start_urls
indica onde a aranha vai iniciar a varredura de páginas
Após isso, definimos algumas regras. Vou usar um LinkExtractor, que, como o próprio nome diz é um componente para extrair links seguindo uma regra das páginas encontradas. Nesse caso eu usei uma expressão regular que bate com todas as URLS de notícias do site, e defino um callback que será chamado para cada página, chamado parse_news
.
LinkExtractor(allow=r'\?link=noticias.?ver*'), callback='parse_news')
Então é aqui que a mágica toda acontece: passei algum tempo analisando o código fonte da página e usando o inspetor web para poder gerar um xpath que bata com notícia, excluindo as informações extras na página.
XPath
O XPath é uma forma de atravessar o HTML e extrair alguma informação específica. É uma linguagem bem poderosa. Nesse caso eu usei a expressão [not(position() > last() -3)]
para excluir os últimos 3 parágrafos marcados pela tag <p>
, que o site sempre coloca como uma forma de rodapé. Infelizmente, nem sempre os sites seguem boas práticas, o que me facilitaria e muito a extração dos dados!
loader.add_xpath('content', '//*[@id="noticia"]/p[not(position() > last() -3)]//text()')
Os outros campos, como ID da noticía e timestamp são “extraídos” usando um middleware chamado scrapy-magicfields, desta maneira:
MAGIC_FIELDS = {
'uid': "$response:url,r'id=(\d+)'",
'spider': '$spider:name',
'timestamp': "$time",
}
O próximo passo é rodar o web crawler periodicamente. Eu usei o sistema de cloud do Scrapinghub, que é a empresa que desenvolve o Scrapy e outras ferramentas de scraping; nele, eu posso configurar para rodar de tempos em tempos o crawler. No meu caso, eu configurei para rodar a cada 30 minutos,
Mesmo que possível, eu não posso publicar diretamente, apenas as novas notícias, caso contrário, toda vez que o crawler rodar eu estaria poluindo o canal com as notícias repetidas. Então eu decidi salvar num banco de dados intermediário para conseguir distinguir o que é novo do que já foi indexado.
Persistência
Eis que entra o Firebase, e sua nova funcionalidade chamada de functions, com o qual, eu posso escrever uma função que reage a determinados eventos no banco de dados - por exemplo, quando um novo dado é inserido.
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const request = require("request-promise");
const buildUrl = require("build-url");
admin.initializeApp(functions.config().firebase);
exports.notifyChannel = functions.database.ref("/news/{what}/{uid}").onCreate((event) => {
const config = functions.config().telegram;
const url = buildUrl("https://api.telegram.org", {
path: `bot${config.bot.token}/sendMessage`,
queryParams: {
chat_id: config.channel.chat_id,
},
});
return request({
method: "POST",
uri: url,
resolveWithFullResponse: true,
body: {
text: event.data.val().content,
},
json: true,
}).then((response) => {
if (response.statusCode === 200) {
return response.body.id;
}
throw response.body;
});
});
Essa função é bem simples; basicamente, em qualquer evento de onCreate ela é chamada, então faço uma chamada POST
na API do Telegram com o nome do canal, token do bot e conteúdo, que, no caso, é o texto da notícia.
E como os itens são salvos no Firebase?
Resposta: Recentemente, o Firebase lançou uma API para acessar a SDK usando Python, então eu escrevi um item pipeline chamado scrapy-firebase que usa essa API para escrever no banco de dados do Firebase, a cada item coletado do Scrapy, o método process_item do pipeline é chamado, e nesse método é salvo o item no Firebase.
class FirebasePipeline(BaseItemExporter):
def load_spider(self, spider):
self.crawler = spider.crawler
self.settings = spider.settings
def open_spider(self, spider):
self.load_spider(spider)
configuration = {
'credential': credentials.Certificate(filename),
'options': {'databaseURL': self.settings['FIREBASE_DATABASE']}
}
firebase_admin.initialize_app(**configuration)
self.ref = db.reference(self.settings['FIREBASE_REF'])
def process_item(self, item, spider):
item = dict(self._get_serialized_fields(item))
child = self.ref.child('/'.join([item[key] for key in self.keys]))
child.set(item)
return item
Próximos passos
Ao mesmo tempo em que eu notifico o canal do Telegram, estou usando o Cloud Natural Language API para classificar a notícia, e, em seguida, salvo no BigQuery. Após algum tempo, acredito que será possível usar o BigQuery
para determinar quais trechos, quando e o quê costuma dar mais problemas à rodovia, através de data mining!
Código-fonte: https://github.com/skhaz/highway-overseer