railsのメール処理で文字列に含まれるURLをaタグ付きに変換したいことありました。
ちょっと調べてみると、URI.extract
を使うと文字列のURLが簡単に取得できる。。。割と簡単にできそうやん、と思って書いてみたら、実は罠が結構あって嵌ってしまったので復習がてら書いてみることにしました。
TL;DR
最終的なコードは下記にすることで解決しました。どうやってこれにたどり着いたのか?なぜこうすると良いのかを後述して行きます。
def convert_url_to_a_element(text)
uri_reg = URI.regexp(%w[http https])
text.gsub(uri_reg) { %{<a href='#{$&}' target='_blank'>#{$&}</a>} }
end
text = 'url1: http://hogehoge.com/hoge url2: http://hogehoge.com/fuga'
convert_url_to_a_element(text)
=> "url1: <a href='http://hogehoge.com/hoge' target='_blank'>http://hogehoge.com/hoge</a> url2: <a href='http://hogehoge.com/fuga' target='_blank'>http://hogehoge.com/fuga</a>"
アンチパターン
まずは最初に間違っていた処理の書き方です。
とはいえ、これでも下記のようなテキストであれば問題なく処理ができてしまいます。だからこそ今回すぐにこの書き方の罠に気づくことができていませんでした。。。
def convert_url_to_a_element(text)
URI.extract(text, %w[http https]).uniq.each do |url|
sub_text = "<a href='#{url}' target='_blank'>#{url}</a>"
text.gsub(url, sub_text)
end
text
end
text = 'url1: http://hogehoge.com url2: http://fugafuga.com'
convert_url_to_a_element(text)
=> 'url1: http://hogehoge.com url2: http://fugafuga.com'
URI.extract
を使うと下記のようにURL形式の文字列を全て取得することができる。
text = 'url1: http://hogehoge.com url2: http://fugafuga.com'
URI.extract(text, %w[http https])
=> ["http://hogehoge.com", "http://fugafuga.com"]
これをeachで回して置換しています。しかしながら、下記のように同じドメイン名のURL2種類で実施すると。。。
text = 'url1: http://hogehoge.com/hoge url2: http://hogehoge.com'
convert_url_to_a_element(text)
=> "url1: <a href='<a href='http://hogehoge.com' target='_blank'>http://hogehoge.com</a>/hoge' target='_blank'><a href='http://hogehoge.com' target='_blank'>http://hogehoge.com</a>/hoge</a> url2: <a href='http://hogehoge.com' target='_blank'>http://hogehoge.com</a>"
なんかめっちゃ崩れてる。。。
原因
原因は、2回目の置換にてaタグ変換後のテキストに対しても置換処理を行ってしまったためです。
このように、上記の書き方では同一ホスト名のURLが2つ以上あるとうまく動作しないという落とし穴があります。
対応策
URI.extract
で取得した文字列をeachで回すのではなく、正規表現を取得してgsubのパターンに正規表現を使って置換させることで、二重置換を防ぐことができます。
def convert_url_to_a_element(text)
uri_reg = URI.regexp(%w[http https])
text.gsub(uri_reg) { %{<a href='#{$&}' target='_blank'>#{$&}</a>} }
end
補足メモ
URI.regexpについて
URI.regexp
は指定したスキーマのURL文字列のパターンを正規表現で返すメソッドです。正規表現とは、文字列ものなので、自分で書くことも可能ですがそれをサクッと作ってくれるのがこのメソッドです。
返り値をみるとわかると思いますが、これを自分で1から書く気にはなれませんでした。。。
URI.regexp(%w[http https])
=> /(?=(?-mix:http|https):)
([a-zA-Z][\-+.a-zA-Z\d]*): (?# 1: scheme)
(?:
((?:[\-_.!~*'()a-zA-Z\d;?:@&=+$,]|%[a-fA-F\d]{2})(?:[\-_.!~*'()a-zA-Z\d;\/?:@&=+$,\[\]]|%[a-fA-F\d]{2})*) (?# 2: opaque)
|
(?:(?:
\/\/(?:
(?:(?:((?:[\-_.!~*'()a-zA-Z\d;:&=+$,]|%[a-fA-F\d]{2})*)@)? (?# 3: userinfo)
(?:((?:(?:[a-zA-Z0-9\-.]|%\h\h)+|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[(?:(?:[a-fA-F\d]{1,4}:)*(?:[a-fA-F\d]{1,4}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|(?:(?:[a-fA-F\d]{1,4}:)*[a-fA-F\d]{1,4})?::(?:(?:[a-fA-F\d]{1,4}:)*(?:[a-fA-F\d]{1,4}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))?)\]))(?::(\d*))?))? (?# 4: host, 5: port)
|
((?:[\-_.!~*'()a-zA-Z\d$,;:@&=+]|%[a-fA-F\d]{2})+) (?# 6: registry)
)
|
(?!\/\/)) (?# XXX: '\/\/' is the mark for hostport)
(\/(?:[\-_.!~*'()a-zA-Z\d:@&=+$,]|%[a-fA-F\d]{2})*(?:;(?:[\-_.!~*'()a-zA-Z\d:@&=+$,]|%[a-fA-F\d]{2})*)*(?:\/(?:[\-_.!~*'()a-zA-Z\d:@&=+$,]|%[a-fA-F\d]{2})*(?:;(?:[\-_.!~*'()a-zA-Z\d:@&=+$,]|%[a-fA-F\d]{2})*)*)*)? (?# 7: path)
)(?:\?((?:[\-_.!~*'()a-zA-Z\d;\/?:@&=+$,\[\]]|%[a-fA-F\d]{2})*))? (?# 8: query)
)
(?:\#((?:[\-_.!~*'()a-zA-Z\d;\/?:@&=+$,\[\]]|%[a-fA-F\d]{2})*))? (?# 9: fragment)
/x
gsubについて
gsubメソッド自体は正規表現ではなく文字列を渡しても置換することができます。前者の場合では単純に取得したURL文字列をeachで渡して置換しているのですが、その結果、同じドメインが含まれるURLなんかだと、aタグ変換後の文字列に対しても置換処理が実行されてしまい、変な文字列になってしまうようです。
考えてみりゃそりゃそうか。。。って感じですがこの対策が案外思いつかなくて悩みました。まずはgsub
text.gsub!(uri_reg) { %{<a href="#{$&}">#{$&}</a>} }
URI.extractについて
まず、最初に使ったURI.extract
だが、スキーマを指定することでテキスト内からURL文字列のみを取得することができる。今回は最終的には使わなかったが、URL文字列のみをシンプルに取得したいのであれば便利そうでした。
text = 'aaaaa http://xxx.com/hoge bbbbb http://xxx.com'
URI.extract(text, %w[http https])
=> ["http://xxx.com/hoge" "http://xxx.com"]
まとめ
- aタグ変換を行うのであればgsubも正規表現でパターンマッチングした上で置換した方が良さそう
- 正規表現そのものは
URI.regexp
を使うと簡単に取得することができる
と、紆余曲折ありましたが良いコードになったんじゃないかと思います。
もっと他に良い書き方があったりしたら是非とも教えていただきたいです。