最初、正規表現講座として書いていた…はずだった。
しかし、いざ書いてみるとJavaScriptの内容もガンガンに出てきてしまった。
更に、具体例をとことん深堀りするので、それに特化した内容に…
…だったら、その解説にしてしまえばいいのでは?
というわけで、今回はTwitterアナリティクスのCSVを、JavaScriptで、正規表現を使いつつ綺麗に分割する方法を解説していこう。
厳密にやろうとするとかなり細かく見ていく必要がある。
最初に結論だけ載せてしまうが、理解できれば別の内容にも応用が利く。
是非、理解していってほしい。
結論
もう先にこれを書いてしまう。
想定としては、HTMLのテキストエリアにTwitterアナリティクスのCSVをテキストとしてコピペして、それをJavaScriptで分割していく。
両方とも同じディレクトリに入れれば動くので、とりあえず分割だけしたい方は、これをコピペしよう。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Twitterアナリティクス分割</title>
<script src="./sample.js"></script>
<style>
td {
border: 1px solid #000000;
}
</style>
</head>
<body>
<textarea id="input-text"></textarea>
<input type="submit" onclick="doSample()" value="送信">
<div id="output"></div>
</body>
</html>
function doSample(){
var str = getText();
var table = splitCSV_REGEXP(str);
output(table);
}
function getText(){
var res = document.getElementById("input-text").value;
return res;
}
function splitCSV_REGEXP(str){
var line = splitLine(str);
var res = [];
for(var i = 0; i < line.length; i++){
res[i] = splitData(line[i]);
}
return res;
}
function splitLine(str){
var res = [];
var index1 = str.search(/[^"]("")*"\n/);
var count = 0;
while(index1 >= 0){
var index2 = str.indexOf("\n", index1 + 1);
res[count] = str.substr(0, index2);
str = str.substr(index2 + 1);
count++;
index1 = str.search(/[^"]("")*"\n/);
}
res[count] = str;
return res;
}
function splitData(str){
var res = [];
var index1 = str.search(/[^"]("")*",/);
var count = 0;
while(index1 >= 0){
var index2 = str.indexOf(",", index1 + 1);
res[count] = str.substr(1, index2 - 2);
res[count] = res[count].replace(/""/g, '"');
str = str.substr(index2 + 1);
count++;
index1 = str.search(/[^"]("")*",/);
}
res[count] = str.substr(1, str.length - 2);
res[count] = res[count].replace(/""/g, '"');
return res;
}
function output(table){
var res = "<table>";
for(var i = 0; i < table.length; i++){
res += "<tr>";
for(var j = 0; j < table[i].length; j++){
res += "<td>";
res += table[i][j];
res += "</td>"
}
res += "</tr>";
}
res += "</table>";
document.getElementById("output").innerHTML = res;
}
前提知識
解説の前に、理解のために必要な前提知識を載せておく。
- HTML
- 最低限のページを表示するためのタグ
- 入力フォーム
- 外部JavaScriptファイルの読み込み
- 各タグへ付与する
id
オプション
- JavaScript
- 変数、配列
- 条件分岐
- 繰り返し処理
- 関数、メソッド
- HTMLとの値の受け渡し
- 正規表現
- 繰り返し(
*
) - 文字クラス(
[
と]
で囲むもの) - 特殊文字(
\
をつけて表すもの)
- 繰り返し(
このあたりを理解した上で、以下をご覧いただきたい。
通常のCSV形式のテキストの場合
そもそも…
「CSVとは何ぞや」という人のために解説しよう。分かっている人は飛ばしてほしい。
CSVは、データをテーブル形式で表現する形式のこと。
各データをコンマもしくはタブで区切り、各行は改行で区切る。
通常は、ファイルにそのまま.csv
という拡張子をつけて保存されている。
これはExcelで開くこともできる。特に設定をしていなければ、ファイルのアイコンがExcelのものになっているだろう。
しかし、今回はExcelを使わず、プログラムで読み込んでいく。
ファイルをテキストエディタにドラッグ&ドロップすればテキストファイルとして開けるので、それで見てみよう。
そして、ここからはデータの区切りにはコンマを使用しているとする。
タブ区切りでも、基本的な考え方は全て同じ。
今回の前提
最初、ファイルをアップロードするところから書き始めたのだが、これが結構長くなりそうだった。
それが無くとも1万字を超える内容になってしまったので、今回は見送ろう。
方法は調べれば出てくるので、できる人はチャレンジして欲しい。
今回は、ファイルの中身を一つのテキストで読み込ませる方式にしよう。
方針は以下の通りだ。
- HTML上のテキストエリアにCSVの中身を記載
- 送信ボタンを押す
- JavaScriptで、一つのテキスト情報として取得
また、HTMLとJavaScriptは別ファイルに書く前提でサンプルソースを書いていく。
それぞれ、「sample.html」、「sample.js」という名前にして、同じディレクトリに入れれば動くようにしてあるので、是非試してみて欲しい。
HTML
では早速、HTMLから組んでみよう。
多少結果を見やすくするために、CSSをstyle
タグで書いているが、気になる人は別ファイルにしてもらっても全然問題ない。
…本来はそっちの方がいいのだが、今回は本筋とは離れすぎてしまうので、そのあたりは気にしないでおく。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Twitterアナリティクス分割</title>
<script src="./sample.js"></script>
<style>
td {
border: 1px solid #000000;
}
</style>
</head>
<body>
<textarea id="input-text"></textarea>
<input type="submit" onclick="doSample()" value="送信">
<div id="output"></div>
</body>
</html>
14行目のtextarea
タグで、そのままテキストエリアを表示させる。
このとき、id
を付けているので、JavaScript側でこのIDを指定することで内容を取得できる。
15行目で、ボタンの作成だ。onclick
オプションで呼び出す関数を指定している。
今回は、呼び出す関数をdoSample()
とした。
なお、11行目に中身の無いdiv
タグがあるが、JavaScriptでここに結果を表示するために入れてあるものだ。
JavaScript
では、その呼び出されるJavaScript。まずは直接呼び出されるdoSample()
関数を見ていく。
function doSample(){
var str = getText();
var table = splitCSV(str);
output(table);
}
三つの関数を順番に実行するようにした。
- HTMLから情報を持ってくる
getText()
関数 - 文字列をCSVとして、配列に変換する
splitCSV()
関数 - 配列をテーブルとして出力する
output()
関数
さあ、順番に組んでいこう。
getText()関数
この関数では、テキストエリアに入力された文字列を返す。
function getText(){
var res = document.getElementById("input-text").value;
return res;
}
やっている内容は、すでにJavaScript講座で解説している。
分からない人は、そちらを見て欲しい。
今更聞けないJavaScript講座第4回「関数、画面との値の受け渡し」 | Shino’s Mind Archive
splitCSV()関数
ここからが本番…なのだが、まずは正規表現を使わない場合から。
まず、JavaScriptで用意されているメソッドをご紹介しよう。
文字列.split(分割方法)
こうすることで、文字列を、分割方法のところに入れた文字で区切ることができる。
その指定した文字列が消えることに注意。
その結果は、配列として戻ってくる。
通常は単純に文字列を入れて、それで区切ることが多い。
実際、CSVも入力を制限すれば、正規表現なんて使わなくても綺麗に分割できる。
その制限とは、以下の通り。
- 一つのデータ内に、コンマが含まれていない
- 一つのデータ内に、改行が含まれていない
つまり、区切りに使用されている文字がデータに入っていないとすれば、正規表現を使わなくてもいい。
その場合のソースは以下の通りになる。
function splitCSV(str){
var line = str.split("\n");
var res = [];
for(var i = 0; i < line.length; i++){
res[i] = line[i].split(",");
}
return res;
}
まず、2行目。何やら\n
というやつで区切っている。
この\n
は、改行を表している。
これはエスケープシーケンスというもので、そう書く決まりだと思っておいてもらえれば十分だ。
で、ここで改行ごとに区切られた配列が、変数line
に格納される。
次に、5行目。ここでは、各行について,
で区切り、それを結果用の配列res
に入れている。
こうすることで、一つのデータを配列の一つの要素として取り出せるのだ。
一旦、先に出力してしまいたいので、このまま先に進もう。
output()関数
ここでは、上のsplitCSV()
関数で処理した配列を受け取り、テーブルにして表示することをする。
これも詳細は以前JavaScript講座で解説しているので、関数の紹介だけ。
function output(table){
var res = "<table>";
for(var i = 0; i < table.length; i++){
res += "<tr>";
for(var j = 0; j < table[i].length; j++){
res += "<td>";
res += table[i][j];
res += "</td>"
}
res += "</tr>";
}
res += "</table>";
document.getElementById("output").innerHTML = res;
}
ここまでで、とりあえず出力はできたと思う。
試しに、以下のようなデータをテキストエリアに入力してみよう。
a,b,c,d,e
aa,bb,cc,dd,ee
aaa,bbb,ccc,ddd,eee
これで、それぞれがテーブルの1要素になっていればOK。
TwitterアナリティクスのCSVの場合
お待たせした。ここからが本番だ。
上の例で分割できないCSVファイルの例
さて、正規表現を使わない場合には、以下のような制限があった。
- 一つのデータ内に、コンマが含まれていない
- 一つのデータ内に、改行が含まれていない
これで読み込めないCSVファイルももちろんある。
今回解説するTwitterアナリティクスのCSVがそうだ。
そもそもこれを知らない方向けに説明しておこう。
Twitterにはアナリティクスという機能があり、そこでは自分のツイートに関する様々な情報が確認できる。
また、そのデータをCSVファイルでダウンロードもできるのだ。
気になる人は、PCで以下の手順を行うとダウンロードできるのでやってみて欲しい。
- Twitter公式にログインしてアクセス
- 左のメニュー内「もっと見る」→「アナリティクス」をクリック
→別タブでアナリティクス画面が表示される - 画面上部「ツイート」をクリック
→ツイートアクティビティが表示される - 画面右上の期間を指定し、「データをエクスポート」をクリック
→指定した期間のCSVファイルがダウンロードできる
で、そのCSVファイルの中には、ツイートのIDやURL、ツイート内容にそのツイートがRT、いいねされた数など、1ツイートごとに40種類ものデータが格納されている。
…そう、このツイートの部分が問題だ。
そのまま上で実装した関数に突っ込んでしまうと、例えばツイートの中で改行していたり、コンマを使用していたりすると、そこで形が崩れてしまう。
以前、Twitterで実際に呟いてみた。その内容は以下の通り。
このようにツイートの中でコンマが使用されていると、そこでデータが区切りになってしまう。
また、このツイートは改行も入っているので、行も想定外のところで切り替わってしまう。
これを防ぐために、条件が二つ設定されている。それを書いておこう。
- 一つのデータは、ダブルクォーテーションで囲まれる。
- 一つのデータ内にダブルクォーテーションが含まれる場合、そのダブルクォーテーションは二つ重ねる。
つまりどういうことかというと、上で出したツイートは、TwitterアナリティクスのCSVでは以下のようになっているということだ。
40カラムもあるとそれだけで長くなってしまうので、ツイート本文と、その前後二つだけ抜き出してみよう。
"https://twitter.com/shino_20191228/status/1235802545076858880","テスト用ツイートです。
""データ1"",""データ2""
""データ3"",""データ4""","2020-03-06 05:41 +0000"
これは、ツイートの中にコンマ、改行も入っているので分かりづらいが、以下3つのデータのみ含まれている。
- ツイートのURL
- ツイート本文
- ツイート日時
しかし、これをそのまま上のプログラムに突っ込んでしまうと…三つのデータにはならないことは想像できると思う。
実際に入れてみた結果は以下の通りだ。
三つのデータなのに、よくわからないことになってしまっている。
では、これを綺麗にCSVへ分割していこう。
分割の考え方と処理
さて、二つの段階に分けて考えよう。
- 行の区切りとなる改行を判別して、分割する
- 各行について、データの区切りとなるコンマを判別して、分割する
こんな感じだ。一つずつ見ていく。
行の区切りを判別して、分割する
これを判別する前に、そもそも改行がどのように含まれるかを見ていこう。大きく2パターン。
まず、当然だが行の区切り。この時は、前の文字はダブルクォーテーション1個だ。
で、今回のTwitterアナリティクスではありえないのだが、行の最後のデータ内末尾に元々ダブルクォーテーションが入っていた場合は、ダブルクォーテーション奇数個+改行のような形になっている。
次に、一つのデータ内に改行が含まれている場合。この時は、前のダブルクォーテーションはデータ内のものなので、0個の場合も含めて必ず偶数個になる。
要するに、ダブルクォーテーション奇数個の次に改行が来る部分を探せばいいというわけだ。
では、これを踏まえて、分割の考え方を以下のように直そう。
- 奇数個のダブルクォーテーション+改行を探す。
- その改行部分を探す
- そこで区切る
これをするため、JavaScriptでは上で紹介したsplit
メソッド以外の方法を用いる。
文字列.search(探索方法)
split
と非常に似ているが、返ってくるものが異なる。
こちらでは、その探索方法に入れたものに合致した場合、その合致した部分の先頭が0から数えて何文字目か、という数字が返ってくる。
更に、見つからなかった場合は、決まって-1が返ってくる。これは使うので覚えておいて欲しい。
例として、以下のようなものを実行したとしよう。
var res = "abcde".search("c");
console.log(res);
この場合、cは2文字目(※注:aが0文字目)なので、コンソールには2と出力される。
で、上では文字列を入れたが、正規表現も入れることができる。
正規表現に置き換えると、以下のようになる。
var res = "abcde".search(/c/);
console.log(res);
このように、スラッシュで囲んで正規表現を書けば、その正規表現に合致した先頭が何文字目かを探すことができる。
では、頭の体操だ。
奇数個のダブルクォーテーション+改行を探したい。
改行は\n
で表せるので、あとは奇数個のダブルクォーテーションを表現できれば、それの後ろに\n
をくっつけるだけ。
(奇数個のダブルクォーテーション)\n
では、この「奇数個のダブルクォーテーション」はどう表せばいいのだろうか…
正解は、以下の通り。
("")*"
二つのダブルクォーテーションが0回以上続き、その後ろにもう一回ダブルクォーテーションが続く。
偶数+1なので、奇数になる。
なので、まずはこれと改行をくっつけよう。
("")*"\n
これで完成…ではない。
これだと、この更に前にダブルクォーテーションがあってもマッチしてしまう。
なので、そこはダブルクォーテーション以外が来るように、[^"]
を入れる。否定の文字クラスだ。
[^"]("")*"\n
これで、確実に行の切り替えとなる改行を探すことができる。
…のだが、まだちょっと工夫が必要だ。
これで取得できるのは、改行が何文字目か…ではなく、その前のダブルクォーテーションの、更に前の文字が何番目か、だ。
だから、そこからさらに改行が何番目かを見なければいけない。
ちょっと複雑になってきた。いったん、現状を整理しよう。
今、上の正規表現で取得した数字番目以降の文字列は、以下のようになっている。
(何か一文字)+(ダブルクォーテーション奇数個)+改行+次の行の内容
つまり、上で取得した数字以降で、初めて改行が出てくる場所を探せばよくなる。
この何か一文字の部分が改行だといけないので、取得した数字+1番目から探せばいい。
で、これは一文字なので無理に正規表現を使わなくてもいいだろう。そこで、別のJavaScriptメソッドを使う。
文字列.indexOf(探す文字列, 探し始める位置)
これを使うと、ある文字が、その文字列の何番目に初めて出てくるかを探すことができる。
上のsearch
メソッドと非常に似ているが、異なるのは二つ目の引数を入れられること。
これで、その箇所以降で初めて出てくる場所…というように指定ができる。
ちなみに、二つ目は省略可能で、省略したら先頭から探す。
また、見つからなかった場合は、search
メソッドと同じく、-1が返ってくる。
具体的には、search
メソッドで取得した位置の次から、最初に出てくる改行を探したいので…
var index1 = 文字列.search(/[^"]("")*"\n/);
var index2 = 文字列.indexOf("\n", index1 + 1);
これで、ようやくCSVとしての改行が何文字目かを取得できた。
最後に、先頭からその改行までを抜き出してあげれば、ようやく1行抜き出しが完了だ。
これも、別の関数を使おう。
文字列.substr(開始位置, 終了位置)
こうすることで、文字列の開始位置から終了位置までを抜き出すことができる。
なお、終了位置を省略した場合は、開始位置から末尾までを抜き出せる。
上までのものと併せて、CSVが入った文字列str
から、先頭行のデータを抜き出す処理は以下のようになる。
var index1 = str.search(/[^"]("")*"\n/);
var index2 = str.indexOf("\n", index1 + 1);
var line = str.substr(0, index2);
最後、変数line
に先頭1行分のデータが入っている。
あとは、これをデータが無くなるまで繰り返せばいい。
というわけで、CSV全体の文字列str
を受け取り、それをCSVとして行ごとに分割した配列を返す関数を作ると、以下の通りになる。
function splitLine(str){
var res = [];
var count = 0;
var index1 = str.search(/[^"]("")*"\n/);
while(index1 >= 0){
var index2 = str.indexOf("\n", index1 + 1);
res[count] = str.substr(0, index2);
str = str.substr(index2 + 1);
count++;
index1 = str.search(/[^"]("")*"\n/);
}
res[count] = str;
return res;
}
処理の流れは以下の通り。
- 結果格納用変数
res
を用意 - 今何行目を見ているかを表す変数
count
を用意し、0を入れておく - とりあえず区切るべき改行の部分を正規表現で探し、その数字を
index1
に格納 - 区切るべき改行がある間(
index1
が0以上の間)、以下を繰り返す- 正確な改行の位置を探し、
index2
に格納 res[count]
に、str
の先頭から改行までを抜き出して代入str
を、4-2で抜き出した以降の部分に切り抜いておく- 行を一個進めるために
count
をインクリメント - 再度区切るべき改行の部分を正規表現で探し、その数字を
index1
に入れなおす
- 正確な改行の位置を探し、
- 残った文字列を最後の行に追加
res
を返す
これでやっと1行ごとに配列に入れることができた。
各行について、データの区切りとなるコンマを判別して、分割する
後は、各行それぞれについて、同じ考え方でコンマについても区切ってあげればいい。
方針は上の改行と全く同じなので、結果の関数だけ載せておこう。
入力str
は、上で区切ったCSVの1行とする。
function splitData(str){
var res = [];
var index1 = str.search(/[^"]("")*",/);
var count = 0;
while(index1 >= 0){
var index2 = str.indexOf(",", index1 + 1);
res[count] = str.substr(0, index2);
str = str.substr(index2 + 1);
count++;
index1 = str.search(/[^"]("")*",/);
}
res[count] = str;
return res;
}
さて、もう二つ処理を加えておこう。
一つ目に、条件で「各データはダブルクォーテーションで囲む」というものがあった。
これは確実に各データを区切るためのものなので、区切ってしまえば不要になる。
なので、一つのデータが区切れたら、その時点で先頭と末尾のダブルクォーテーションを削ってしまおう。
1文字目から末尾の手前…つまり、最初の状態から1文字減らして抜き出せばいいので、上の7行目を以下のように直してあげる。
注意して欲しいのは、0から数え始めているので末尾の文字が(文字数-1)番目になるということ。
そこからさらに1を引くことで、最後1文字を切り捨てられる。
res[count] = str.substr(1, index2 - 2);
また、最後の文字列も同じように切り抜いてあげよう。今度は12行目を以下のように直す。
res[count] = str.substr(1, str.length - 2);
これで、データの前後に入っているダブルクォーテーションを外すことができた。
そして、もう一つの処理。今度は、「データ内のダブルクォーテーションを二つ重ねる」というもの。
これも、全部無くしてあげよう。そのために、また新しいJavaScriptメソッドを使用する。
文字列.replace(置換対象, 置換後)
これで、文字列のうち置換対象に当てはまったものを置換後の内容に置き換える。
これを使えばいいのだが…ここで一つテクニック。
置換対象には文字列でも正規表現でもいいのだが、そのまま書くと、先頭一つしか置換できない。
そこで、正規表現に限った内容だが、正規表現を囲むスラッシュの後ろにg
を入れてあげる。
そうすることで、その置換対象に当てはまった箇所全てに対して、置換をしてあげることができる。
今回はダブルクォーテーション2個連続したものを1個にしたいので…
文字列.replace(/""/g, '"')
このようにしてあげればいい。
なお、JavaScriptにおいてダブルクォーテーションは文字列を囲む場合に使用するものだったので、それをさらにダブルクォーテーションで囲もうとするとおかしなことになってしまう。
文字列を囲むのには、実はシングルクォーテーションも使える。
ダブルクォーテーションが文字列に入っている場合は、シングルクォーテーションで文字列全体を囲むことで解決できるのだ。
さて、この内容を、データを抜き出した部分に適用してあげよう。
すると、コンマで区切るsplitData
関数は最終的に以下のようになる。
function splitData(str){
var res = [];
var index1 = str.search(/[^"]("")*",/);
var count = 0;
while(index1 >= 0){
var index2 = str.indexOf(",", index1 + 1);
res[count] = str.substr(1, index2 - 2);
res[count] = res[count].replace(/""/g, '"');
str = str.substr(index2 + 1);
count++;
index1 = str.search(/[^"]("")*",/);
}
res[count] = str.substr(1, str.length - 2);
res[count] = res[count].replace(/""/g, '"');
return res;
}
これで、データ部分もしっかりと分割し、更にダブルクォーテーション周りの処理も完了だ。
最終的な処理
ここまでの内容を使って、実際にCSVを分割する関数を書き換えていく。
改めて、JavaScript側のソースを全部載せよう。
function doSample(){
var str = getText();
var table = splitCSV_REGEXP(str);
output(table);
}
function getText(){
var res = document.getElementById("input-text").value;
return res;
}
function splitCSV_REGEXP(str){
var line = splitLine(str);
var res = [];
for(var i = 0; i < line.length; i++){
res[i] = splitData(line[i]);
}
return res;
}
function splitLine(str){
var res = [];
var index1 = str.search(/[^"]("")*"\n/);
var count = 0;
while(index1 >= 0){
var index2 = str.indexOf("\n", index1 + 1);
res[count] = str.substr(0, index2);
str = str.substr(index2 + 1);
count++;
index1 = str.search(/[^"]("")*"\n/);
}
res[count] = str;
return res;
}
function splitData(str){
var res = [];
var index1 = str.search(/[^"]("")*",/);
var count = 0;
while(index1 >= 0){
var index2 = str.indexOf(",", index1 + 1);
res[count] = str.substr(1, index2 - 2);
res[count] = res[count].replace(/""/g, '"');
str = str.substr(index2 + 1);
count++;
index1 = str.search(/[^"]("")*",/);
}
res[count] = str.substr(1, str.length - 2);
res[count] = res[count].replace(/""/g, '"');
return res;
}
function output(table){
var res = "<table>";
for(var i = 0; i < table.length; i++){
res += "<tr>";
for(var j = 0; j < table[i].length; j++){
res += "<td>";
res += table[i][j];
res += "</td>"
}
res += "</tr>";
}
res += "</table>";
document.getElementById("output").innerHTML = res;
}
とても長くなってしまったが、これでTwitterアナリティクスのCSVが綺麗に分割できるようになった。
これで終わり…ならよかったのだが、最後に一つ、気になる部分がある。
出力した時、一つのデータの中に改行が入っていた場合でも、出力後は改行されていない。
なぜだろうか。本筋の文字分割とは関係なくなってしまうので、オマケで解説しよう。
おわりに:TwitterアナリティクスのCSV分割
厳密にやろうとするとここまで長くなってしまう。
しかし、一個一個やっていることは単純だ。
これを理解しておけば、他のCSVへも応用が利くはずなので、是非挑戦してもらいたい。
また、HTMLとJavaScriptの入力部分を改変すれば、ファイルをアップロードしてそれを表示みたいなこともできる。
…実は裏でそれもやっているので、どこかで解説をしよう。
最後に、一つ残っていた改行問題をオマケとして解説して、今回は終わりとさせてもらう。
こんな長々と読んでくれてありがとう。
オマケ:改行問題
最後の問題だ。
文字列の中には改行\n
が入っているのに、見た目は改行されない。
なぜだろうか。HTMLの基本を思い出してほしい。
…そう、HTMLでの改行は、<br>
が必要だった。
それなら、データを持ってきたときに、改行\n
を<br>
に置換してあげればいい。
というわけで、JavaScriptのデータ出力部分をちょっと細工してあげよう。
output
関数の内部、実際のデータをtd
タグ内に入れる部分を、以下のように変えてあげればいい。
res += table[i][j].replace(/\n/g, "<br>");
この部分は、上の説明が理解できていれば問題ないはずだ。
よく改行入りのツイートをしている人は、参考にして欲しい。
コメント