// OpenTween - Client of Twitter // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) // (c) 2008-2011 Moz (@syo68k) // (c) 2008-2011 takeshik (@takeshik) // (c) 2010-2011 anis774 (@anis774) // (c) 2010-2011 fantasticswallow (@f_swallow) // (c) 2011 kim_upsilon (@kim_upsilon) // All rights reserved. // // This file is part of OpenTween. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General public License as published by the Free // Software Foundation; either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General public License // for more details. // // You should have received a copy of the GNU General public License along // with this program. If not, see , or write to // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, // Boston, MA 02110-1301, USA. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Web; namespace OpenTween { /// /// 短縮 URL サービスによる URL の展開・短縮を行うクラス /// public class ShortUrl { private static Lazy _instance; /// /// ShortUrl のインスタンスを取得します /// public static ShortUrl Instance { get { return _instance.Value; } } /// /// 短縮 URL の展開を無効にするか否か /// public bool DisableExpanding { get; set; } /// /// 短縮 URL のキャッシュを定期的にクリアする回数 /// public int PurgeCount { get; set; } public string BitlyId { get; set; } public string BitlyKey { get; set; } private HttpClient http; private ConcurrentDictionary urlCache = new ConcurrentDictionary(); private static readonly Regex HtmlLinkPattern = new Regex(@"( ShortUrlHosts = new HashSet() { "4sq.com", "airme.us", "amzn.to", "bctiny.com", "bit.ly", "bkite.com", "blip.fm", "budurl.com", "cli.gs", "digg.com", "dlvr.it", "fb.me", "feeds.feedburner.com", "ff.im", "flic.kr", "goo.gl", "ht.ly", "htn.to", "icanhaz.com", "is.gd", "j.mp", "linkbee.com", "moi.st", "nico.ms", "nsfw.in", "on.fb.me", "ow.ly", "p.tl", "pic.gd", "qurl.com", "rubyurl.com", "snipurl.com", "snurl.com", "t.co", "tinami.jp", "tiny.cc", "tinyurl.com", "tl.gd", "traceurl.com", "tumblr.com", "twitthis.com", "twurl.nl", "urlenco.de", "ustre.am", "ux.nu", "www.qurl.com", "youtu.be", }; static ShortUrl() { _instance = new Lazy(() => { var handler = new HttpClientHandler { AllowAutoRedirect = false, }; var http = MyCommon.CreateHttpClient(handler); http.Timeout = new TimeSpan(0, 0, seconds: 5); return new ShortUrl(http); }, true); } internal ShortUrl(HttpClient http) { this.DisableExpanding = false; this.PurgeCount = 500; this.BitlyId = ""; this.BitlyKey = ""; this.http = http; } [Obsolete] public string ExpandUrl(string uri) { try { return this.ExpandUrlAsync(new Uri(uri), 10).Result.ToString(); } catch (UriFormatException) { return uri; } } /// /// 短縮 URL を非同期に展開します /// /// 展開するURL /// URLの展開を行うタスク public Task ExpandUrlAsync(Uri uri) { return this.ExpandUrlAsync(uri, 10); } /// /// 短縮 URL を非同期に展開します /// /// 展開するURL /// 再帰的に展開を試みる上限 /// URLの展開を行うタスク public async Task ExpandUrlAsync(Uri uri, int redirectLimit) { if (this.DisableExpanding) return uri; if (redirectLimit <= 0) return uri; if (!uri.IsAbsoluteUri) return uri; try { if (!ShortUrlHosts.Contains(uri.Host)) return uri; Uri expanded; if (this.urlCache.TryGetValue(uri, out expanded)) return expanded; if (this.urlCache.Count > this.PurgeCount) this.urlCache.Clear(); expanded = null; try { expanded = await this.GetRedirectTo(uri) .ConfigureAwait(false); } catch (TaskCanceledException) { } catch (HttpRequestException) { } if (expanded == null || expanded == uri) return uri; this.urlCache[uri] = expanded; var recursiveExpanded = await this.ExpandUrlAsync(expanded, --redirectLimit) .ConfigureAwait(false); // URL1 -> URL2 -> URL3 のように再帰的に展開されたURLを URL1 -> URL3 としてキャッシュに格納する if (recursiveExpanded != expanded) this.urlCache[uri] = recursiveExpanded; return recursiveExpanded; } catch (UriFormatException) { return uri; } } /// /// 短縮 URL を非同期に展開します /// /// /// 不正なURLが渡された場合は例外を投げず uriStr をそのまま返します /// /// 展開するURL /// URLの展開を行うタスク public Task ExpandUrlAsync(string uriStr) { return this.ExpandUrlAsync(uriStr, 10); } /// /// 短縮 URL を非同期に展開します /// /// /// 不正なURLが渡された場合は例外を投げず uriStr をそのまま返します /// /// 展開するURL /// 再帰的に展開を試みる上限 /// URLの展開を行うタスク public async Task ExpandUrlAsync(string uriStr, int redirectLimit) { Uri uri; try { if (!uriStr.StartsWith("http", StringComparison.OrdinalIgnoreCase)) uri = new Uri("http://" + uriStr); else uri = new Uri(uriStr); } catch (UriFormatException) { return uriStr; } var expandedUri = await this.ExpandUrlAsync(uri, redirectLimit) .ConfigureAwait(false); return expandedUri.OriginalString; } [Obsolete] public string ExpandUrlHtml(string html) { return this.ExpandUrlHtmlAsync(html, 10).Result; } /// /// HTML内に含まれるリンクのURLを非同期に展開する /// /// 処理対象のHTML /// URLの展開を行うタスク public Task ExpandUrlHtmlAsync(string html) { return this.ExpandUrlHtmlAsync(html, 10); } /// /// HTML内に含まれるリンクのURLを非同期に展開する /// /// 処理対象のHTML /// 再帰的に展開を試みる上限 /// URLの展開を行うタスク public Task ExpandUrlHtmlAsync(string html, int redirectLimit) { if (this.DisableExpanding) return Task.FromResult(html); return HtmlLinkPattern.ReplaceAsync(html, async m => m.Groups[1].Value + await this.ExpandUrlAsync(m.Groups[2].Value, redirectLimit).ConfigureAwait(false) + m.Groups[3].Value); } /// /// 指定された短縮URLサービスを使用してURLを短縮します /// /// 使用する短縮URLサービス /// 短縮するURL /// 短縮されたURL public Task ShortenUrlAsync(MyCommon.UrlConverter shortenerType, Uri srcUri) { // 既に短縮されている状態のURLであれば短縮しない if (ShortUrlHosts.Contains(srcUri.Host)) return Task.FromResult(srcUri); switch (shortenerType) { case MyCommon.UrlConverter.TinyUrl: return this.ShortenByTinyUrlAsync(srcUri); case MyCommon.UrlConverter.Isgd: return this.ShortenByIsgdAsync(srcUri); case MyCommon.UrlConverter.Twurl: return this.ShortenByTwurlAsync(srcUri); case MyCommon.UrlConverter.Bitly: return this.ShortenByBitlyAsync(srcUri, "bit.ly"); case MyCommon.UrlConverter.Jmp: return this.ShortenByBitlyAsync(srcUri, "j.mp"); case MyCommon.UrlConverter.Uxnu: return this.ShortenByUxnuAsync(srcUri); default: throw new ArgumentException("Unknown shortener.", "shortenerType"); } } private async Task ShortenByTinyUrlAsync(Uri srcUri) { // 明らかに長くなると推測できる場合は短縮しない if ("http://tinyurl.com/xxxxxx".Length > srcUri.OriginalString.Length) return srcUri; var content = new FormUrlEncodedContent(new[] { new KeyValuePair("url", srcUri.OriginalString), }); using (var response = await this.http.PostAsync("http://tinyurl.com/api-create.php", content).ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); if (!Regex.IsMatch(result, @"^https?://")) throw new WebApiException("Failed to create URL.", result); return new Uri(result.TrimEnd()); } } private async Task ShortenByIsgdAsync(Uri srcUri) { // 明らかに長くなると推測できる場合は短縮しない if ("http://is.gd/xxxx".Length > srcUri.OriginalString.Length) return srcUri; var content = new FormUrlEncodedContent(new[] { new KeyValuePair("format", "simple"), new KeyValuePair("url", srcUri.OriginalString), }); using (var response = await this.http.PostAsync("http://is.gd/create.php", content).ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); if (!Regex.IsMatch(result, @"^https?://")) throw new WebApiException("Failed to create URL.", result); return new Uri(result.TrimEnd()); } } private async Task ShortenByTwurlAsync(Uri srcUri) { // 明らかに長くなると推測できる場合は短縮しない if ("http://twurl.nl/xxxxxx".Length > srcUri.OriginalString.Length) return srcUri; var content = new FormUrlEncodedContent(new[] { new KeyValuePair("link[url]", srcUri.OriginalString), }); using (var response = await this.http.PostAsync("http://tweetburner.com/links", content).ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); if (!Regex.IsMatch(result, @"^https?://")) throw new WebApiException("Failed to create URL.", result); return new Uri(result.TrimEnd()); } } private async Task ShortenByBitlyAsync(Uri srcUri, string domain = "bit.ly") { // 明らかに長くなると推測できる場合は短縮しない if ("http://bit.ly/xxxx".Length > srcUri.OriginalString.Length) return srcUri; // bit.ly 短縮機能実装のプライバシー問題の暫定対応 // ログインIDとAPIキーが指定されていない場合は短縮せずにPOSTする // 参照: http://sourceforge.jp/projects/opentween/lists/archive/dev/2012-January/000020.html if (string.IsNullOrEmpty(this.BitlyId) || string.IsNullOrEmpty(this.BitlyKey)) return srcUri; var query = new Dictionary { {"login", this.BitlyId}, {"apiKey", this.BitlyKey}, {"format", "txt"}, {"domain", domain}, {"longUrl", srcUri.OriginalString}, }; var uri = new Uri("https://api-ssl.bitly.com/v3/shorten?" + MyCommon.BuildQueryString(query)); using (var response = await this.http.GetAsync(uri).ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); if (!Regex.IsMatch(result, @"^https?://")) throw new WebApiException("Failed to create URL.", result); return new Uri(result.TrimEnd()); } } private async Task ShortenByUxnuAsync(Uri srcUri) { // 明らかに長くなると推測できる場合は短縮しない if ("http://ux.nx/xxxxxx".Length > srcUri.OriginalString.Length) return srcUri; var query = new Dictionary { {"format", "plain"}, {"url", srcUri.OriginalString}, }; var uri = new Uri("http://ux.nu/api/short?" + MyCommon.BuildQueryString(query)); using (var response = await this.http.GetAsync(uri).ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); if (!Regex.IsMatch(result, @"^https?://")) throw new WebApiException("Failed to create URL.", result); return new Uri(result.TrimEnd()); } } private async Task GetRedirectTo(Uri url) { var request = new HttpRequestMessage(HttpMethod.Head, url); using (var response = await this.http.SendAsync(request).ConfigureAwait(false)) { if (!response.IsSuccessStatusCode) { // ステータスコードが 3xx であれば例外を発生させない if ((int)response.StatusCode / 100 != 3) response.EnsureSuccessStatusCode(); } return response.Headers.Location; } } } }