😎

LinyのCSVインポートで大きいCSVファイルを分割してアップロードしてみる

2024/03/22に公開

LinyのCSVアップロード機能

弊社プロダクトのLinyにはCSVエクスポート/インポートという機能があります。
Linyで集計した顧客情報をCSVとして出力し別システムに組み込んだり、逆にCSVとしてインポートしLiny上の顧客情報を一括で変更するという便利な機能です。
しかし、現状だとこのCSVインポート機能ではアップロード容量の制限があります。
CSVエクスポートではその制限以上に出力できるため、エクスポートしたデータをそのままインポートできないということも稀に発生します。
今回はこのCSVデータを分割して、Linyへアップロードできるようにしてみます。

LinyのCSVデータについて気を付けたいポイント

分割するにあたって、注意するポイントが2点あります。

【ポイントその1】保存形式がShift-JIS

CSVエクスポートで出力されるLinyのCSVデータはShift-JISです。

このため、csv-splitter 等でそのまま分割すると文字が壊れた状態で分割されてしまいます。(shift-JISに対応しているツールであればこのようなことはない)

csv-splitter (ファイル) 2000

alt text

【ポイントその2】ヘッダー行が2行

LinyのCSVデータではヘッダー行が2行あります。
alt text

1行目はインポート時にどのデータとしてインポートするかを識別する文字列、2行目は友だち情報やタグといった顧客情報の名前です。
そのため、csv-splitter をつかって分割するとヘッダーが1行目だけだと認識されてしまい、2つ目以降の分割データは2行目がヘッダーとしてセットされていない状態となっています。

csv-splitter (ファイル) 2000

alt text
↑分割したときの2つ目のCSVファイル。
【ポイントその1】の画像と比較してみると分かる通り、ヘッダーであるはずのIDから始まる行が存在していません。

分割コマンドを作ってみる

csv-splitter を元に、npxで実行できるようなスクリプトを作成してみました。

csv-splitter-liny.js
#!/usr/bin/env node

// const fs = require('fs');
// const iconv = require('iconv-lite');
// const csv = require('csv');

const options = require('yargs');

const CsvSplitter = require('./CsvSplitter');

options
    .usage( "\nUsage: $0 <input-file> <max-rows> [options]" )
    .command( "input-file", "File to process", { alias: "input" } )
    .command( "max-rows", "Maximum amount of rows per file", { alias: "max" } )
    .option('o', {
          alias : 'output-dir',
          describe: 'Specify an output directory for the part files, default is current working directory',
          type: 'string',
          nargs: 1
    });

let inputFile = options.argv._[0];
let maxRows = options.argv._[1];
let outputDir = options.argv.o;

if (!inputFile) {
    console.error('\n[Error] {Not enough arguments} No input file specified.');
    options.showHelp();
    process.exit(1);
}

if (!maxRows) {
    console.error('\n[Error] {Not enough arguments} No max-entries amount specified.');
    options.showHelp();
    process.exit(1);
}


CsvSplitter.split(inputFile, maxRows, outputDir);
CsvSplitter.js
const Path = require('path');

const CsvReader = require('./CsvReader');
const CsvWriter = require('./CsvWriter');

const CsvSplitter = function() {};

CsvSplitter.prototype = {

    split: function(filepath, maxEntries, ouputDirectory) {
        this._csvReader = new CsvReader(filepath);

        this._amountOfFiles = Math.ceil((this._csvReader.length() - 1) / maxEntries);
        
        this._panLength = `${this._amountOfFiles}`.length;
        this._baseFileName = Path.basename(filepath, '.csv');

        this._outputDirectory = ouputDirectory || `${process.cwd() || '.'}/csv-splitter-output/`;
        if (this._outputDirectory[this._outputDirectory.length-1] !== '/') {
            this._outputDirectory += '/';
        }

        this._currentFile = 0;

        let csvWriter = new CsvWriter(this._csvReader.getHeaders());

        console.info(`[CsvSplitter] Entries to process: ${this._csvReader.length()}`);
        console.info(`[CsvSplitter] Number of files to be written: ${this._amountOfFiles}`);

        // refactor with generator or stream, so you can use reader.nextLine() without looping indexes. I = 1 is error-prone.
        for (let i = 2; i < this._csvReader.length(); ++i) { //i = 1 to skip header
            if (i % maxEntries === 0) {

                csvWriter.write(this._getNextFileName());
                csvWriter = null;

                console.log(`[CsvSplitter] Wrote ${this._currentFile} / ${this._amountOfFiles} files`);

                if (this._currentFile <= this._amountOfFiles) {
                    csvWriter = new CsvWriter(this._csvReader.getHeaders());
                }
            }

            if (csvWriter) csvWriter.addLine(this._csvReader.getLine(i));
        }

        if (csvWriter) {
            csvWriter.write(this._getNextFileName());
            console.log(`[CsvSplitter] Wrote ${this._currentFile} / ${this._amountOfFiles} files`);
        }

        console.info('[CsvSplitter] - DONE');
    },

    _getNextFileName: function() {
        ++this._currentFile;
        let paddedNumber = this._padNumber(this._currentFile);
        return `${this._outputDirectory}${this._baseFileName}_${paddedNumber}.csv`;
    },

    _padNumber: function(number, digits) {
        let numLength = `${number}`.length;
        let complementaryZeroes = new Array(this._panLength - numLength).fill(0).join('');
        return `${complementaryZeroes}${number}`;
    }

};

module.exports = new CsvSplitter();
CsvReader.js
const readFileSync = require('fs').readFileSync;
const EOL = require('os').EOL;
const iconv = require('iconv-lite')

const CsvReader = function(filepath) {
    this._parseFile(filepath);
    // ヘッダーが複数行あることを考慮
    this._headers = [this._getLine(0),this._getLine(1)];
};

CsvReader.prototype = {

    getHeaders: function() {
        return this._headers;
    },

    getLine: function(index) {
        if (index <= 1 ) throw new Error(`[${this.constructor.name}] - Trying to access line 0, if you want to read the header use \`getHeaders()\``);
        return this._getLine(index);
    },

    length: function() {
        return this._fileLines.length;
    },

    _getLine: function(index) {
        return this._fileLines[index];
    },

    _parseFile: function(filepath) {
        this._fileLines = [];

        try {
            const buffer = readFileSync(filepath);
            this._fileLines = iconv.decode(buffer, 'Shift_JIS').split(EOL).filter(Boolean);
            console.info(`[CsvSplitter] File parsed: ${filepath}, read ${this._fileLines.length} lines`);

        } catch (err) {
            throw new Error(`[${this.constructor.name}] - Unable to read file: ${filepath} \n Original error attached`, err);
        }
    }
};

module.exports = CsvReader;
CsvWriter.js
const writeFileSync = require('fs').writeFileSync;
const Path = require('path');
const EOL = require('os').EOL;
const {mkdirp} = require('mkdirp');
const iconv = require('iconv-lite');


/**
 * 
 * @param {String[]} headers 
 */
const CsvWriter = function(headers) {
    this._fileLines = [];
    if (headers) this.setHeaders(headers);
};

CsvWriter.prototype = {

    setHeaders: function(headerStrings) {
        this._headers = headerStrings;
    },

    addLine: function(line) {
        this._fileLines.push(line);
    },

    write: function(filepath) {
        var combinedLines = this._combineLines(this._headers, this._fileLines);
        this._writeFile(filepath, combinedLines);
    },

    _combineLines: function(headers, lines) {
        var combinedLines = '';
        headers.forEach(h => combinedLines += `${h}${EOL}` );
        lines.forEach(l => combinedLines += `${l}${EOL}`);
        return combinedLines;
    },

    _writeFile: function(filepath, lines) {

        //Safe dir creation, if already exists is no-op
        mkdirp.sync(Path.dirname(filepath));

        if (this._headers.length !== 2) throw new Error(`[${this.constructor.name}] - Can't write ${filepath}, you didn't specify any header.`);
        const buffer = iconv.encode(lines, 'Shift-JIS')
        writeFileSync(filepath, buffer);

    }

};

module.exports = CsvWriter;
package.json
{
  "name": "split-csv-liny",
  "dependencies": {
    "commander": "^12.0.0",
    "iconv-lite": "^0.6.3",
    "mkdirp": "^3.0.1",
    "yargs": "^17.7.2"
  },
  "bin": {
    "csv-splitter-liny": "csv-splitter-liny.js"
  }
}

コマンドで実行

npm i
npx split-csv-liny (ファイル) 2000

できた!

分割結果は…うまくいってそう。
alt text
実際にアップロードしてみると…成功しました!
alt text

終わりに

というわけでいかがでしたでしょうか?
今回行ったCSVの分割作業はテックブログ用に行ったものであり、ユーザーが容量制限でアップロードできなかった時にこの作業を促すものではありません。
最善の解決策はこのような手間を煩わせることなく、ユーザーにLinyを活用頂けることだと考えております。(そのためにも各種機能を少しづつ日々改善しています!)

今後とも弊プロダクトのLinyを応援いただければ幸いです。

ソーシャルデータバンク テックブログ

Discussion