PubMedから医療AI論文を抽出してSlackに投稿する
元ネタ
一部slackのAPI仕様が変わってこともあり、微修正した。
Google Apps Script+Slack Appsで完結するようになった.
1. Pubmedの検索クエリを反映したRSSを作成する
適当な単語で検索した後に,検索窓の下に出現する"Create RSS"からRSSのURLが得られる. Core Clinical Journalでフィルタリングしようと思ったが,RSSにはフィルターは効かなかった.そもそもCore Clinical Journalは雑誌をカスタマイズできないので,最初から自分で作ったほうが良さそう.
下記を参考に,各分野で上から1-2個の雑誌をor検索に入れた条件式を作成した.advanced searchに行くとGUIで作成できる.
Impact factorで分野の違いが考慮されていないわけがなかろうが(笑)[第二版] | 水道研究者の生態
((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((("The New England journal of medicine"[Journal]) OR "Lancet (London, England)"[Journal]) OR "JAMA"[Journal]) OR "BMJ (Clinical research ed.)"[Journal]) OR "Annals of internal medicine"[Journal]) OR "Journal of clinical oncology : official journal of the American Society of Clinical Oncology"[Journal]) OR "The Lancet. Oncology"[Journal]) OR "Nature medicine"[Journal]) OR "Circulation"[Journal]) OR "Blood"[Journal]) OR "European heart journal"[Journal]) OR "Gastroenterology"[Journal]) OR "Nature neuroscience"[Journal]) OR "Immunity"[Journal]) OR "British journal of anaesthesia"[Journal]) OR "Anesthesiology"[Journal]) OR "Clinical chemistry"[Journal]) OR "Clinical infectious diseases : an official publication of the Infectious Diseases Society of America"[Journal]) OR "Critical care medicine"[Journal]) OR "Critical care (London, England)"[Journal]) OR "The British journal of dermatology"[Journal]) OR "Diabetes care"[Journal]) OR "Diabetes"[Journal]) OR "Resuscitation"[Journal]) OR ("The Journal of clinical endocrinology and metabolism"[Journal])) OR ("MMWR. Morbidity and mortality weekly report"[Journal])) OR "International journal of epidemiology"[Journal]) OR "American journal of epidemiology"[Journal]) OR "Epidemiology (Cambridge, Mass.)"[Journal]) OR "Nature genetics"[Journal]) OR ("American journal of obstetrics and gynecology"[Journal])) OR "Leukemia"[Journal]) OR "Nature immunology"[Journal]) OR "Journal of medical Internet research"[Journal]) OR "Journal of the American Medical Informatics Association : JAMIA"[Journal]) OR "Journal of biomedical informatics"[Journal]) OR "International journal of medical informatics"[Journal]) OR "The Lancet. Neurology"[Journal]) OR "Neurology"[Journal]) OR "Brain : a journal of neurology"[Journal]) OR "Journal of neurosurgery"[Journal]) OR "Neurosurgery"[Journal]) OR "World neurosurgery"[Journal]) OR "The American journal of clinical nutrition"[Journal]) OR "Ophthalmology"[Journal]) OR "The American journal of sports medicine"[Journal]) OR "Pain"[Journal]) OR "Acta neuropathologica"[Journal]) OR "The Journal of pathology"[Journal]) OR "Pediatrics"[Journal]) OR "Biological psychiatry"[Journal]) OR "Psychological science"[Journal]) OR "Journal of general internal medicine"[Journal]) OR ("BMC pregnancy and childbirth"[Journal])) OR ("American journal of respiratory and critical care medicine"[Journal])) OR "American journal of public health"[Journal]) OR "Radiology"[Journal]) OR ("Fertility and sterility"[Journal])) OR ("Archives of physical medicine and rehabilitation"[Journal])) OR "Annals of the rheumatic diseases"[Journal]) OR ("Journal of personality and social psychology"[Journal])) OR "Annals of surgery"[Journal]) OR "Environmental health perspectives"[Journal]) OR ("American journal of transplantation : official journal of the American Society of Transplantation and the American Society of Transplant Surgeons"[Journal])) OR "PLoS neglected tropical diseases"[Journal]) OR "European urology"[Journal]) OR "Stroke"[Journal]) AND (((machine learning) OR deep learning) OR artificial intelligence)
参考までに作成した検索式を載せるが,かなり偏っているのでご自身で作成したほうが良いと思う.ちなみに"reinforcement learning"は,生物学的な強化学習そのものがたくさんヒットするので工夫のしどころ(とりあえず諦めた).
slackにはRSSリーダーもあるが,このRSSをそこに直接渡すと上記のようにまとめられてしまう.これはちょっと今ひとつ.
2. slack側の設定
旧tokenはもう推奨されないらしいので、下記の中ごろを参照してSlack Appsを作成した.
今回は通知するだけなので,Incoming Webhookのみ有効にすれば良い.Install Appsのところに,RESTに必要なURLが載っている.認証は必要なときに確認されるので画面に沿って進める.なお,投稿時のユーザー名はAppsで作成した標準のユーザー名が使用され,変更できなくなったらしい.
なお,APIの解説ページにslackのGUIアプリからはたどり着けなかったので,"api slack"などと検索してAPIのサイトに行くのが良いと思う.
3. Google Apps Scriptの作成
あとは元ネタの微修正.
変更点1
Incoming WebhookはPOSTで叩けるので,標準ライブラリのUrlFetchApp.fetchが使える.このため追加ライブラリのインストールが不要になった.(REST APIとJSONを使ったことがなかったで,この部分が地味に大変だった.blockのみをpayloadに入れていてうまく行かず1時間くらい溶かしたorz.1メッセージあたりの最大block数50の制約もトラップ.)
結局のところ,公式のdocumentが一番有用だったと思う.payload直下のtextは fallback string であり,メッセージがうまく読み込めなかったときやアラートなどに使用されるらしい.このtextを指定していないと,no_text errorが出続ける(出続けた).
Reference: Message payloads | Slack
変更点2
スプレッドシートを別で作るのがなんとなく嫌だったので,parameterを使って更新確認をしている.すべてテキストとして保存されてしまい,連番を管理するのがやや面倒だったので,','で区切って配列を実現している.GUIDの仕様が変わって上記の文字が含まれるようになると破綻するはず.
変更点3
英日翻訳したタイトルもつけた.
var title_ja = LanguageApp.translate(title 'en', 'ja')
これだけでテキストの翻訳ができる.至れりつくせり😊
変更点4
公開日も載せるようにした.CDATAセクションという,構造化されてないベタ打ち部分に入っているのがちょっと面倒.abstractも同様にして取得できるが,slackで眺めるには情報過多になりかねないので,タイトルから興味を持ったらリンクから飛ぶことで良しとした.
変更点5
deplecaitedが懸念されるAttatchmentではなく,Blockにした.これによってカスタマイズの幅が広がった.markdown記法に則ればいろいろ調整が効く模様.attatchmentではcolorを指定することで使えたブロックごとの左側の縦棒が使えない(これは公式でもattachmentからblockへ変更によってできなくなった唯一のことだと記載されている).
引用にすると色の指定はできないものの縦棒が出現して少し視認性が良いような気がするのでそうしてある.
慣れた人なら一瞬なのだろうけど,結構苦労したので最後にスクリプトを載せておく.Google Apps Scriptに貼り付けてHookURLとRSSのURLを変更すれば動くはず.
function PubMedSlack() { var HookURL = 'https://hooks.slack.com/services/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; var N_IN_BLOCK = 5; var properties = PropertiesService.getScriptProperties(); var properties_dict = properties.getProperties(); // [keyword, RSS_URL]を要素に持つリスト。keywordはスクリプト内で一意にしてください。 var RSS_l = [['machine learning', 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/erss.cgi?rss_guid=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'], ['test', 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/erss.cgi?rss_guid=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx']]; for (var i = 0; i < RSS_l.length; i++) { var keyword = RSS_l[i][0]; var feedURL = RSS_l[i][1]; var lastFetched = ['']; if (properties_dict[keyword]){ var lastFetched = properties_dict[keyword].split(','); }; //lastFetched = ['']//連続して試したいときはコメント解除を // Fetch try { var rssText = UrlFetchApp.fetch(feedURL).getContentText(); // たまにPubMedが404エラーを出すことがあるのでtryで回避 } catch (e) { continue; // もしエラーが出てもとりあえず無視(次回fetchできれば良しとする) } // Parse var rss = XmlService.parse(rssText); var items = rss.getRootElement().getChildren('channel')[0].getChildren('item'); // Check new items var news = []; for each(var item in items) { if (lastFetched.indexOf(item.getChild('guid').getText()) < 0) { news.push(item); } } if (news.length > 0) { // Prepare blocks var blocks = [{ type: "section", text: { type:"mrkdwn", text:'Here are ' + news.length + ' new papers for *"' + keyword + '"* :eyes:' } }]; var txt = ''; var j = 1; for each(var item in news) { var title = item.getChild('title').getText(); var title_ja = LanguageApp.translate(title,'en','ja'); var authors = item.getChild('author').getText().replace(/^\s+/, '').split(','); // なぜか先頭にあるスペースを削除 var author = authors[0]; if (authors.length>1){ author += ', _et al._'; }; var journal = item.getChild('category').getText() + '.'; var link = item.getChild('link').getText(); var description = item.getChild('description').getText(); var date = description.split('</p>')[1].split('. ')[1]; // var abstract = description.split('</p>')[3]; txt += '>*<'+link+'|'+title+'>*\n>'+title_ja+'\n>'+ author + ' ' + '_*' + journal + '*_ ' + date + '\n \n'; //5記事ごとに1block生成する //1記事ごとにブロックを生成するとブロック数の上限50にひっかかることがある //maximum length for the 'text' in a block is 3,000 characters. //messageあたりは100,000characters //1記事1メッセージしないのは無料版slackでメッセージ数を節約するため if ((j%N_IN_BLOCK)==0){ var l = txt.length; blocks.push({ type: "section", text: { type:"mrkdwn", text: txt } }); txt = ''; }; j += 1; }; if ((j%N_IN_BLOCK)!=1){ blocks.push({ type: "section", text: { type:"mrkdwn", text: txt } }); }; var payload = { text: 'Here are ' + news.length + ' new papers for *"' + keyword + '"* :eyes:', //failback sentence blocks: blocks }; // Post Slack on Incoming Hook var options = { method: "post", headers: {"Content-type": "application/json"}, payload: JSON.stringify(payload) }; UrlFetchApp.fetch(HookURL, options); // Record fetched items var guids = []; for each(var item in items) { guids.push(item.getChild('guid').getText()); }; properties.setProperty(keyword, guids.join(',')); }; }; };