一度に1つのことを(リーダブルコードより)

こんにちは。3行コードを書くとそれより前に書いたコードの記憶が飛んでいる池田です。
決して口開けて空眺めながらコードを書いているわけではないと弁明させてほしいんですが、それでも1日経てばもう終わりです。昨日書いたコードの記憶は消し飛び、「このコード書いたの誰だよ…。…俺か…」と自己嫌悪の波に飲まれ……ることはありませんが、自分の書いたコードの解釈に体力を使うことになります。過去の自分に苦しめられることほど腹の立つことはありません。ではどうすれば良いか。読みやすいコードを書けばいいんです。簡単ですね。何が簡単なんだ。

リーダブルコード――より良いコードを書くためのシンプルで実践的なテクニック

https://www.oreilly.co.jp/books/images/picture_large978-4-87311-565-8.jpeg これは私が新卒の頃に借りて読んだ本です。新卒の頃は今の100分の1程度の技術力しかありませんでしたし、初版も2012年(原著は2011年)と古めですが、それでもこの本は非常に読みやすかったです。それから数年経ち、改めてまた読みたくなったので自分で買いました。この本の内容は現在も通用し、全て重要で、どの部分が一番であるといった優劣は付け難いんですが、今回はこの本の中から私が一番気を付けていることを紹介します。タイトルにもある一度に1つのことをです。

一度に1つのことを

本よりの引用ですが、以下の2つのJavaScriptコードを見比べてみてください。vote_changedはユーザがブログで投票するときに使われる関数です。初版が10年近く前なのでconstではなくvarを使っていたりしますが、原文ママで載せています。皆さんはconstを、定数ではどうしようもないときだけletを使用してください。letのほうが読みやすいし、くらいの理由ならconstを使ってください(過激派)。
var vote_changed = function(old_vote, new_vote) {
    var score = get_score();

    if (new_vote !== old_vote) {
        if (new_vote === 'Up') {
            score += (old_vote === 'Down' ? 2 : 1);
        } else if (new_vote === 'Down') {
            score -= (old_vote === 'Up' ? 2 : 1);
        } else if (new_vote === '') {
            score += (old_vote === 'Up' ? -1 : 1);
        }
    }
    
    set_score(score);
}
var vote_value = function (vote) {
    if (vote === 'Up') {
        return +1;
    }
    if (vote === 'Down') {
        return -1;
    }
    return 0;
}

var vote_changed = function (old_vote, new_vote) {
    var score = get_score();
    
    score -= vote_value(old_vote); // 古い値を削除する
    score += vote_value(new_vote); // 新しい値を追加する
    
    set_score(score);
}
恐らく下のほうが読みやすいと感じたと思います。
「下のほうはコメントがあるからずるい!」という意見が聞こえてきました。ではscore += (old_vote === 'Down' ? 2 : 1);にコメントを付けるとすると、どう付ければ良いでしょうか。少なくとも「古い値を削除する」だけでは実態に合っていません。下は一度に「古い値を削除する」というひとつのことが行われているからこそ、これほど単純なコメントを付けるだけで済んでいます。
バグがあったとしても、上の方は「一番最初の条件分岐か?それとも2個目?それとも三項演算子?」と考えることが多くありますが、下の方は「多分vote_valueの条件分岐だろう」くらいには当たりを付けることができます。

最後に

今回書いた内容はとても基本的なことで、「この世に生を受けたときからこんなの知ってるわ」という方もいると思います。実際、この本の内容は非常に基本的なことが紹介されていて、ドメイン駆動設計クリーンアーキテクチャといった完璧な秩序をプロジェクトにもたらす内容、というものではないと思います。ただ、この本の内容を完璧に守ったコードがあったとしたら、それは高尚な設計思想が使われていなくても非常に読みやすいコードだと思います。
簡単な内容で200ページとちょっとなので、一度図書館とかで借りて流し読みしてみましょう。それだけでもきっと明日の自分が書くコードが少しだけ晴れ晴れとして見えると思います。

ついでに

株式会社イメージ・マジックではエンジニアを募集しています。PHP未経験者でも大丈夫です。
「PHPはちょっとなあ…」という方もいるかもしれませんが、弊社ではSymfonyというMVCフレームワークを採用しており、JavaのSpring BootやRuby on Railsと同じような書き味ですし、何より使いやすいです。PHPはわかりませんが、Symfonyのことはきっと好きになれると思います。

puppeteerでスクレイピングしてブログの投稿を検知する

こんにちは、イメージ・マジックの池田です。
今回はタイトルにもある通り、puppeteerのスクレイピング(とついでにブログの投稿検知)についてやっていきます。なぜなら最近プライベートで触ったからという、とても個人的な理由です。

puppeteerとは?

puppeteerというのは、Node.jsを用いてスクレイピングをするライブラリです。
はい、死ぬほど雑な説明をしました。違うわ!とまではいかないまでもお叱りを受けそうですね。詳しくは公式サイトをご覧ください。公式の英語サイトに投げる暴挙。とにかくNode.jsでスクレイピングのできるライブラリです。しかもChromeを作っているGoogle製。使わない理由はありませんね。

どうやって使うの?

  1. Node.jsの最新版をインストールします
  2. 好きな場所にフォルダを作り、npm init -yと打ち、npm i puppeteerします
用意はこれだけです。試しに公式サイトに載っているコードを実行してみましょう。同じフォルダにexample.jsを作成し、以下のコードを打ち込み、PowerShellなどからnode example.jsで実行してみてください。
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  await page.screenshot({ path: 'example.png' });

  await browser.close();
})();
同じフォルダにexample.pngというファイルが作成されたと思います。ファイルには見知らぬサイトのスクリーンショットが。puppeteerがブラウザを画面に出したりせずに撮ってきてくれたスクリーンショットです。実際にこのページにアクセスしてみると、画像そのままのサイトが表示されたと思います。
これを応用することで、ブログの投稿を検知することが可能となります。RSSがあればこういうことをしなくても大丈夫だったりしますが、そもそもRSSが下火だったり、RSSを提供するようなサイトじゃなかったりもしますからね。

プログラムの流れ

前提条件:昨日や一週間前に取ってきたタイトル一覧を手元に持っている(持っていなければ、下記手順の1~2、5だけを行います)
  1. ブログにアクセスする
  2. タイトル一覧を取得する
  3. 手元で持っているタイトル一覧と照合する
  4. 手元にないタイトルがあれば、そのタイトルを通知する
  5. 最新のタイトル一覧を手元に保存する
  6. おわり
簡単ですね。これをTypeScriptで書いたコードが下記です。私はVisual Studio Codeと組み合わせて使うTypeScriptが大好きなので、本来ならばこれの宣伝もしたいのですが、TypeScriptの素晴らしさは恐らく全ての生物が既に知っているので、ここでは取り扱いません。
import puppeteer, { Page } from "puppeteer";
import { readFileSync, writeFileSync } from "fs";
import csvParse = require("csv-parse/lib/sync")
import csvStringify = require("csv-stringify/lib/sync")
import { difference } from "./setUtils";

const CURRENT_FILE_NAME = 'current.csv';
const TITLE_SELECTOR = '.entry-title';

const readCurrentCsv = (): Set<string> | null => {
  try {
    const file = readFileSync(CURRENT_FILE_NAME);
    return new Set((csvParse(file) as Array<Array<string>>).flat());
  } catch (error) {
    // Error NO ENTryらしいです
    if (error.code === 'ENOENT') {
      return null;
    } else {
      throw new Error("知らんエラー。君誰?");
    }
  }
}

const fetchTitles = async (page: Page): Promise<Set<string>> => {
  const elements = await page.$$(TITLE_SELECTOR);
  return new Set(await Promise.all(elements.map(async ele => (await ele.getProperty('textContent'))!.jsonValue())) as Array<string>);
}

const writeCsv = (newTitles: Set<string>) => {
  writeFileSync(CURRENT_FILE_NAME, csvStringify([...newTitles].map(title => [title])));
}

const notify = (diffTitles: Set<string>) => {
  console.info(`
「${[...diffTitles].join('、')}」が開発者ブログに投稿されました。見てみましょう!
https://techblog.imagemagic.jp/
`.trim());
  // みたいのを通知したりしたいよね
}

const main = async (page: Page) => {
  const currentTitles = readCurrentCsv();
  const newTitles = await fetchTitles(page);

  if (currentTitles === null) {
    writeCsv(newTitles);
    return;
  }

  const diffTitles = difference(newTitles, currentTitles);
  if (!diffTitles.size) {
    return;
  }

  notify(diffTitles);
  writeCsv(newTitles);
}

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://techblog.imagemagic.jp/');

  try {
    await main(page);
  } finally {
    await browser.close();
  }
})();
main関数の中身を見れば、先程のプログラムの流れと大体同じなことがわかると思います。このプログラムをたとえばAWSのLambdaに乗せて、CloudWatchで定期的に叩くなどすれば、差分検知通知システムの完成です。
細かいコードの解説は省きます。皆さんも興味のあるサイト(たとえば本テックブログ!)をスクレイピングしたりして、実際に触ってみてください。

※スクレイピングは少し油断するとアクセス負荷を相手のサーバーにかけてしまったり、そもそもスクレイピングを許可していないサイトもあります。使い方を間違えれば違法行為です。この辺りのサイトを見つつ、用法用量を守って正しくお使いください。