無駄と文化

実用的ブログ

JavaScript のデータを CSV で保存する

意外と需要のある JavaScript のデータを CSV として保存するスニペットを書き留めます。

var data = [
  ['name'  , 'age', 'gender'],
  ['Andrew', 26   , 'male'  ],
  ['Lisa'  , 21   , 'female'],
  ['Fred'  , 41   , 'male'  ],
]

このような多重配列を元にして、

f:id:todays_mitsui:20170423135440p:plain

このような CSV を保存します。

ちなみに、

var data = [
  {name: 'Andrew', age:26   , gender: 'male'  },
  {name: 'Lisa'  , age:21   , gender: 'female'},
  {name: 'Fred'  , age:41   , gender: 'male'  },
]

このような オブジェクトの配列 にも対応させました。


んで、
最初に書いておきますが、 Mac版 Excel には対応していない CSV を扱っています 。ご容赦ください。


コード

さっそくドン、

class CSV {
  constructor(data, keys = false) {
    this.ARRAY  = Symbol('ARRAY')
    this.OBJECT = Symbol('OBJECT')

    this.data = data

    if (CSV.isArray(data)) {
      if (0 == data.length) {
        this.dataType = this.ARRAY
      } else if (CSV.isObject(data[0])) {
        this.dataType = this.OBJECT
      } else if (CSV.isArray(data[0])) {
        this.dataType = this.ARRAY
      } else {
        throw Error('Error: 未対応のデータ型です')
      }
    } else {
      throw Error('Error: 未対応のデータ型です')
    }

    this.keys = keys
  }

  toString() {
    if (this.dataType === this.ARRAY) {
      return this.data.map((record) => (
        record.map((field) => (
          CSV.prepare(field)
        )).join(',')
      )).join('\n')
    } else if (this.dataType === this.OBJECT) {
      const keys = this.keys || Array.from(this.extractKeys(this.data))

      const arrayData = this.data.map((record) => (
        keys.map((key) => record[key])
      ))

      console.log([].concat([keys], arrayData))

      return [].concat([keys], arrayData).map((record) => (
        record.map((field) => (
          CSV.prepare(field)
        )).join(',')
      )).join('\n')
    }
  }

  save(filename = 'data.csv') {
    if (!filename.match(/\.csv$/i)) { filename = filename + '.csv' }

    console.info('filename:', filename)
    console.table(this.data)

    const csvStr = this.toString()

    const bom     = new Uint8Array([0xEF, 0xBB, 0xBF]);
    const blob    = new Blob([bom, csvStr], {'type': 'text/csv'});
    const url     = window.URL || window.webkitURL;
    const blobURL = url.createObjectURL(blob);

    let a      = document.createElement('a');
    a.download = decodeURI(filename);
    a.href     = blobURL;
    a.type     = 'text/csv';

    a.click();
  }

  extractKeys(data) {
    return new Set([].concat(...this.data.map((record) => Object.keys(record))))
  }

  static prepare(field) {
    return '"' + (''+field).replace(/"/g, '""') + '"'
  }

  static isObject(obj) {
    return '[object Object]' === Object.prototype.toString.call(obj)
  }

  static isArray(obj) {
    return '[object Array]' === Object.prototype.toString.call(obj)
  }
}

CSV というクラスを定義しています。
使い方はこのように、

(new CSV(data)).save('foobar.csv')

調子こいて スプレッド演算子Set などを多用しているので、比較的新しい Chrome とかでないと動かないかも知れませんね。


CSV の仕様

CSV はとてもシンプルな仕様です。
フィールド(Excel でいうところのセル)をカンマ , で区切ったものがレコードになります。
レコード同士は改行 \n で区切ります。

フィールドに ,\n値として 含まれる場合は、それがフィールドやレコードの区切り文字ではないことを示すためにフィールド全体をダブルクォート " で囲む必要があります。
さらに " で囲ったフィールドの中に " が値として含まれる場合は " 自体をエスケープしてあげる必要があります。エスケープは " を二つ重ねて "" に置換することで行います。


文字コード

CSV ファイルを保存する際の文字コードについては特に規定されていませんが、 日本語を含む CSV を Excel で開きたい 場合には少々のテクニックを要します。

採用する文字コードの選択肢はいくつかありますが、

  1. Shift_JIS
  2. BOM 付き UTF-8
  3. BOM 付き UTF-16LE

今回は 2. BOM 付き UTF-8 を採用しています。
ただし、そうやって保存した CSV は Mac 版の Excel で開くと文字化けします


日本語を含む CSV を Excel で正しく開かせるためのテクニックについては、「CSV Unicode Excel」などのフレーズで検索していただけると闇が垣間見られると思います。


元データを用意する

もうこの記事で伝えたいことの本題は終わっているんですが、データを用意する方法にも軽く触れておきます。

例えば、ここに食べログの東京都内のラーメン屋の検索結果画面がありまして、

f:id:todays_mitsui:20170423135518p:plain

インスペクタとにらめっこしまして、

f:id:todays_mitsui:20170423135538p:plain

jQuery などを駆使してこのようなコードを書きますと、

const data = $('.list-rst').map(function() {
  const $this = $(this)

  const name         = $this.find('.list-rst__rst-name a').text()
  const score        = parseFloat($this.find('.list-rst__rating-val').text())
  const reviewCount  = parseInt($this.find('.list-rst__rvw-count-num').text(), 10)
  const dinnerBudget = $this.find('.cpy-dinner-budget-val').text()
  const lunchBudget  = $this.find('.cpy-lunch-budget-val').text()
  const holiday      = $this.find('.list-rst__holiday-datatxt').text()
  const comment      = $this.find('.list-rst__pr-title').text().trim()
  const searchWord   = $this.find('.list-rst__search-word .list-rst__search-word-item').map(function() {
    return $(this).text().trim()
  }).get()

  return {
    name,
    score,
    reviewCount,
    dinnerBudget,
    lunchBudget,
    holiday,
    comment,
    searchWord,
  }
}).get()

すると、このようなデータが取れますので、

f:id:todays_mitsui:20170423135554p:plain

先ほどの CSV クラスとしてインスタンス化して保存すると、

(new CSV(data)).save('ramen.csv')

f:id:todays_mitsui:20170423135606p:plain

このようなダイアログが開いてデータを保存できるわけですね。

Excel で開くと、

f:id:todays_mitsui:20170423135620p:plain

はい、このように。


まとめ

数ヶ月後に『あー、このサイトの情報テキトーに CSV 保存してぇ』という場面に出くわすであろう自分に捧げます。


私からは以上です。