1 // OpenTween - Client of Twitter
2 // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) <kiri.feather@gmail.com>
3 // (c) 2008-2011 Moz (@syo68k)
4 // (c) 2008-2011 takeshik (@takeshik) <http://www.takeshik.org/>
5 // (c) 2010-2011 anis774 (@anis774) <http://d.hatena.ne.jp/anis774/>
6 // (c) 2010-2011 fantasticswallow (@f_swallow) <http://twitter.com/f_swallow>
7 // (c) 2011 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
8 // All rights reserved.
10 // This file is part of OpenTween.
12 // This program is free software; you can redistribute it and/or modify it
13 // under the terms of the GNU General public License as published by the Free
14 // Software Foundation; either version 3 of the License, or (at your option)
17 // This program is distributed in the hope that it will be useful, but
18 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
19 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General public License
22 // You should have received a copy of the GNU General public License along
23 // with this program. If not, see <http://www.gnu.org/licenses/>, or write to
24 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
25 // Boston, MA 02110-1301, USA.
28 using System.Collections.Concurrent;
29 using System.Collections.Generic;
30 using System.Diagnostics.CodeAnalysis;
32 using System.Net.Http;
34 using System.Text.RegularExpressions;
35 using System.Threading;
36 using System.Threading.Tasks;
38 using OpenTween.Connection;
43 /// 短縮 URL サービスによる URL の展開・短縮を行うクラス
47 private static Lazy<ShortUrl> _instance;
50 /// ShortUrl のインスタンスを取得します
52 public static ShortUrl Instance
54 get { return _instance.Value; }
58 /// 短縮 URL の展開を無効にするか否か
60 public bool DisableExpanding { get; set; }
63 /// 短縮 URL のキャッシュを定期的にクリアする回数
65 public int PurgeCount { get; set; }
67 public string BitlyId { get; set; }
68 public string BitlyKey { get; set; }
70 private HttpClient http;
71 private ConcurrentDictionary<Uri, Uri> urlCache = new ConcurrentDictionary<Uri, Uri>();
73 private static readonly Regex HtmlLinkPattern = new Regex(@"(<a href="")(.+?)("")");
75 private static readonly HashSet<string> ShortUrlHosts = new HashSet<string>()
89 "feeds.feedburner.com",
130 _instance = new Lazy<ShortUrl>(() => new ShortUrl(), true);
133 [SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope")]
135 : this(CreateDefaultHttpClient())
137 Networking.WebProxyChanged += (o, e) =>
139 var newClient = CreateDefaultHttpClient();
140 var oldClient = Interlocked.Exchange(ref this.http, newClient);
145 internal ShortUrl(HttpClient http)
147 this.DisableExpanding = false;
148 this.PurgeCount = 500;
156 public string ExpandUrl(string uri)
160 return this.ExpandUrlAsync(new Uri(uri), 10).Result.ToString();
162 catch (UriFormatException)
169 /// 短縮 URL を非同期に展開します
171 /// <param name="uri">展開するURL</param>
172 /// <returns>URLの展開を行うタスク</returns>
173 public Task<Uri> ExpandUrlAsync(Uri uri)
175 return this.ExpandUrlAsync(uri, 10);
179 /// 短縮 URL を非同期に展開します
181 /// <param name="uri">展開するURL</param>
182 /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
183 /// <returns>URLの展開を行うタスク</returns>
184 public async Task<Uri> ExpandUrlAsync(Uri uri, int redirectLimit)
186 if (this.DisableExpanding)
189 if (redirectLimit <= 0)
192 if (!uri.IsAbsoluteUri)
197 if (!ShortUrlHosts.Contains(uri.Host) && !IsIrregularShortUrl(uri))
201 if (this.urlCache.TryGetValue(uri, out expanded))
204 if (this.urlCache.Count > this.PurgeCount)
205 this.urlCache.Clear();
210 expanded = await this.GetRedirectTo(uri)
211 .ConfigureAwait(false);
213 catch (TaskCanceledException) { }
214 catch (HttpRequestException) { }
216 if (expanded == null || expanded == uri)
219 this.urlCache[uri] = expanded;
221 var recursiveExpanded = await this.ExpandUrlAsync(expanded, --redirectLimit)
222 .ConfigureAwait(false);
224 // URL1 -> URL2 -> URL3 のように再帰的に展開されたURLを URL1 -> URL3 としてキャッシュに格納する
225 if (recursiveExpanded != expanded)
226 this.urlCache[uri] = recursiveExpanded;
228 return recursiveExpanded;
230 catch (UriFormatException)
237 /// 短縮 URL を非同期に展開します
240 /// 不正なURLが渡された場合は例外を投げず uriStr をそのまま返します
242 /// <param name="uriStr">展開するURL</param>
243 /// <returns>URLの展開を行うタスク</returns>
244 public Task<string> ExpandUrlAsync(string uriStr)
246 return this.ExpandUrlAsync(uriStr, 10);
250 /// 短縮 URL を非同期に展開します
253 /// 不正なURLが渡された場合は例外を投げず uriStr をそのまま返します
255 /// <param name="uriStr">展開するURL</param>
256 /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
257 /// <returns>URLの展開を行うタスク</returns>
258 public async Task<string> ExpandUrlAsync(string uriStr, int redirectLimit)
264 if (!uriStr.StartsWith("http", StringComparison.OrdinalIgnoreCase))
265 uri = new Uri("http://" + uriStr);
267 uri = new Uri(uriStr);
269 catch (UriFormatException)
274 var expandedUri = await this.ExpandUrlAsync(uri, redirectLimit)
275 .ConfigureAwait(false);
277 return expandedUri.OriginalString;
281 public string ExpandUrlHtml(string html)
283 return this.ExpandUrlHtmlAsync(html, 10).Result;
287 /// HTML内に含まれるリンクのURLを非同期に展開する
289 /// <param name="html">処理対象のHTML</param>
290 /// <returns>URLの展開を行うタスク</returns>
291 public Task<string> ExpandUrlHtmlAsync(string html)
293 return this.ExpandUrlHtmlAsync(html, 10);
297 /// HTML内に含まれるリンクのURLを非同期に展開する
299 /// <param name="html">処理対象のHTML</param>
300 /// <param name="redirectLimit">再帰的に展開を試みる上限</param>
301 /// <returns>URLの展開を行うタスク</returns>
302 public Task<string> ExpandUrlHtmlAsync(string html, int redirectLimit)
304 if (this.DisableExpanding)
305 return Task.FromResult(html);
307 return HtmlLinkPattern.ReplaceAsync(html, async m =>
308 m.Groups[1].Value + await this.ExpandUrlAsync(m.Groups[2].Value, redirectLimit).ConfigureAwait(false) + m.Groups[3].Value);
312 /// 指定された短縮URLサービスを使用してURLを短縮します
314 /// <param name="shortenerType">使用する短縮URLサービス</param>
315 /// <param name="srcUri">短縮するURL</param>
316 /// <returns>短縮されたURL</returns>
317 public Task<Uri> ShortenUrlAsync(MyCommon.UrlConverter shortenerType, Uri srcUri)
319 // 既に短縮されている状態のURLであれば短縮しない
320 if (ShortUrlHosts.Contains(srcUri.Host))
321 return Task.FromResult(srcUri);
323 switch (shortenerType)
325 case MyCommon.UrlConverter.TinyUrl:
326 return this.ShortenByTinyUrlAsync(srcUri);
327 case MyCommon.UrlConverter.Isgd:
328 return this.ShortenByIsgdAsync(srcUri);
329 case MyCommon.UrlConverter.Twurl:
330 return this.ShortenByTwurlAsync(srcUri);
331 case MyCommon.UrlConverter.Bitly:
332 return this.ShortenByBitlyAsync(srcUri, "bit.ly");
333 case MyCommon.UrlConverter.Jmp:
334 return this.ShortenByBitlyAsync(srcUri, "j.mp");
335 case MyCommon.UrlConverter.Uxnu:
336 return this.ShortenByUxnuAsync(srcUri);
338 throw new ArgumentException("Unknown shortener.", "shortenerType");
342 private async Task<Uri> ShortenByTinyUrlAsync(Uri srcUri)
344 // 明らかに長くなると推測できる場合は短縮しない
345 if ("http://tinyurl.com/xxxxxx".Length > srcUri.OriginalString.Length)
348 var content = new FormUrlEncodedContent(new[]
350 new KeyValuePair<string, string>("url", srcUri.OriginalString),
353 using (var response = await this.http.PostAsync("http://tinyurl.com/api-create.php", content).ConfigureAwait(false))
355 response.EnsureSuccessStatusCode();
357 var result = await response.Content.ReadAsStringAsync()
358 .ConfigureAwait(false);
360 if (!Regex.IsMatch(result, @"^https?://"))
361 throw new WebApiException("Failed to create URL.", result);
363 return new Uri(result.TrimEnd());
367 private async Task<Uri> ShortenByIsgdAsync(Uri srcUri)
369 // 明らかに長くなると推測できる場合は短縮しない
370 if ("http://is.gd/xxxx".Length > srcUri.OriginalString.Length)
373 var content = new FormUrlEncodedContent(new[]
375 new KeyValuePair<string, string>("format", "simple"),
376 new KeyValuePair<string, string>("url", srcUri.OriginalString),
379 using (var response = await this.http.PostAsync("http://is.gd/create.php", content).ConfigureAwait(false))
381 response.EnsureSuccessStatusCode();
383 var result = await response.Content.ReadAsStringAsync()
384 .ConfigureAwait(false);
386 if (!Regex.IsMatch(result, @"^https?://"))
387 throw new WebApiException("Failed to create URL.", result);
389 return new Uri(result.TrimEnd());
393 private async Task<Uri> ShortenByTwurlAsync(Uri srcUri)
395 // 明らかに長くなると推測できる場合は短縮しない
396 if ("http://twurl.nl/xxxxxx".Length > srcUri.OriginalString.Length)
399 var content = new FormUrlEncodedContent(new[]
401 new KeyValuePair<string, string>("link[url]", srcUri.OriginalString),
404 using (var response = await this.http.PostAsync("http://tweetburner.com/links", content).ConfigureAwait(false))
406 response.EnsureSuccessStatusCode();
408 var result = await response.Content.ReadAsStringAsync()
409 .ConfigureAwait(false);
411 if (!Regex.IsMatch(result, @"^https?://"))
412 throw new WebApiException("Failed to create URL.", result);
414 return new Uri(result.TrimEnd());
418 private async Task<Uri> ShortenByBitlyAsync(Uri srcUri, string domain = "bit.ly")
420 // 明らかに長くなると推測できる場合は短縮しない
421 if ("http://bit.ly/xxxx".Length > srcUri.OriginalString.Length)
424 // bit.ly 短縮機能実装のプライバシー問題の暫定対応
425 // ログインIDとAPIキーが指定されていない場合は短縮せずにPOSTする
426 // 参照: http://sourceforge.jp/projects/opentween/lists/archive/dev/2012-January/000020.html
427 if (string.IsNullOrEmpty(this.BitlyId) || string.IsNullOrEmpty(this.BitlyKey))
430 var query = new Dictionary<string, string>
432 {"login", this.BitlyId},
433 {"apiKey", this.BitlyKey},
436 {"longUrl", srcUri.OriginalString},
439 var uri = new Uri("https://api-ssl.bitly.com/v3/shorten?" + MyCommon.BuildQueryString(query));
440 using (var response = await this.http.GetAsync(uri).ConfigureAwait(false))
442 response.EnsureSuccessStatusCode();
444 var result = await response.Content.ReadAsStringAsync()
445 .ConfigureAwait(false);
447 if (!Regex.IsMatch(result, @"^https?://"))
448 throw new WebApiException("Failed to create URL.", result);
450 return new Uri(result.TrimEnd());
454 private async Task<Uri> ShortenByUxnuAsync(Uri srcUri)
456 // 明らかに長くなると推測できる場合は短縮しない
457 if ("http://ux.nx/xxxxxx".Length > srcUri.OriginalString.Length)
460 var query = new Dictionary<string, string>
463 {"url", srcUri.OriginalString},
466 var uri = new Uri("http://ux.nu/api/short?" + MyCommon.BuildQueryString(query));
467 using (var response = await this.http.GetAsync(uri).ConfigureAwait(false))
469 response.EnsureSuccessStatusCode();
471 var result = await response.Content.ReadAsStringAsync()
472 .ConfigureAwait(false);
474 if (!Regex.IsMatch(result, @"^https?://"))
475 throw new WebApiException("Failed to create URL.", result);
477 return new Uri(result.TrimEnd());
481 private bool IsIrregularShortUrl(Uri uri)
483 // Flickrの https://www.flickr.com/photo.gne?short=... 形式のURL
484 // flic.kr ドメインのURLを展開する途中に経由する
485 if (uri.Host.EndsWith("flickr.com", StringComparison.OrdinalIgnoreCase) &&
486 uri.PathAndQuery.StartsWith("/photo.gne", StringComparison.OrdinalIgnoreCase))
492 private async Task<Uri> GetRedirectTo(Uri url)
494 var request = new HttpRequestMessage(HttpMethod.Head, url);
496 using (var response = await this.http.SendAsync(request).ConfigureAwait(false))
498 if (!response.IsSuccessStatusCode)
500 // ステータスコードが 3xx であれば例外を発生させない
501 if ((int)response.StatusCode / 100 != 3)
502 response.EnsureSuccessStatusCode();
505 var redirectedUrl = response.Headers.Location;
507 if (redirectedUrl == null)
510 if (redirectedUrl.IsAbsoluteUri)
511 return redirectedUrl;
513 return new Uri(url, redirectedUrl);
517 [SuppressMessage("Microsoft.Reliability", "CA2000:DisposeObjectsBeforeLosingScope")]
518 private static HttpClient CreateDefaultHttpClient()
520 var handler = new HttpClientHandler
522 AllowAutoRedirect = false,
525 var http = Networking.CreateHttpClient(handler);
526 http.Timeout = TimeSpan.FromSeconds(5);