OSDN Git Service

#27313 ピリオドで終わるページが取得できない問題の暫定対応(警告メッセージの追加)
[wptscs/wpts.git] / Wptscs / Websites / MediaWiki.cs
1 // ================================================================================================
2 // <summary>
3 //      MediaWikiのウェブサイト(システム)をあらわすモデルクラスソース</summary>
4 //
5 // <copyright file="MediaWiki.cs" company="honeplusのメモ帳">
6 //      Copyright (C) 2012 Honeplus. All rights reserved.</copyright>
7 // <author>
8 //      Honeplus</author>
9 // ================================================================================================
10
11 namespace Honememo.Wptscs.Websites
12 {
13     using System;
14     using System.Collections.Generic;
15     using System.IO;
16     using System.Xml;
17     using System.Xml.Serialization;
18     using Honememo.Utilities;
19     using Honememo.Wptscs.Models;
20     using Honememo.Wptscs.Properties;
21     using Honememo.Wptscs.Utilities;
22
23     /// <summary>
24     /// MediaWikiのウェブサイト(システム)をあらわすモデルクラスです。
25     /// </summary>
26     public class MediaWiki : Website, IXmlSerializable
27     {
28         #region private変数
29         
30         /// <summary>
31         /// 名前空間情報取得用にアクセスするAPI。
32         /// </summary>
33         private string namespacePath;
34
35         /// <summary>
36         /// 記事のXMLデータが存在するパス。
37         /// </summary>
38         private string exportPath;
39
40         /// <summary>
41         /// リダイレクトの文字列。
42         /// </summary>
43         private string redirect;
44
45         /// <summary>
46         /// テンプレートの名前空間を示す番号。
47         /// </summary>
48         private int? templateNamespace;
49
50         /// <summary>
51         /// カテゴリの名前空間を示す番号。
52         /// </summary>
53         private int? categoryNamespace;
54
55         /// <summary>
56         /// 画像の名前空間を示す番号。
57         /// </summary>
58         private int? fileNamespace;
59
60         /// <summary>
61         /// Wikipedia書式のシステム定義変数。
62         /// </summary>
63         /// <remarks>初期値は http://www.mediawiki.org/wiki/Help:Magic_words を参照</remarks>
64         private IList<string> magicWords;
65
66         /// <summary>
67         /// MediaWikiの名前空間の情報。
68         /// </summary>
69         private IDictionary<int, IList<string>> namespaces = new Dictionary<int, IList<string>>();
70
71         #endregion
72
73         #region コンストラクタ
74
75         /// <summary>
76         /// コンストラクタ(MediaWiki全般)。
77         /// </summary>
78         /// <param name="language">ウェブサイトの言語。</param>
79         /// <param name="location">ウェブサイトの場所。</param>
80         public MediaWiki(Language language, string location) : this()
81         {
82             // メンバ変数の初期設定
83             this.Language = language;
84             this.Location = location;
85         }
86
87         /// <summary>
88         /// コンストラクタ(Wikipedia用)。
89         /// </summary>
90         /// <param name="language">ウェブサイトの言語。</param>
91         public MediaWiki(Language language) : this()
92         {
93             // メンバ変数の初期設定
94             // ※ オーバーロードメソッドを呼んでいないのは、languageがnullのときに先にエラーになるから
95             this.Language = language;
96             this.Location = String.Format(Settings.Default.WikipediaLocation, language.Code);
97         }
98
99         /// <summary>
100         /// コンストラクタ(シリアライズ or 拡張用)。
101         /// </summary>
102         protected MediaWiki()
103         {
104             this.WebProxy = new AppDefaultWebProxy();
105             this.DocumentationTemplates = new List<string>();
106         }
107
108         #endregion
109
110         #region 設定ファイルに初期値を持つプロパティ
111         
112         /// <summary>
113         /// MediaWiki名前空間情報取得用にアクセスするAPI。
114         /// </summary>
115         /// <remarks>値が指定されていない場合、デフォルト値を返す。</remarks>
116         public string NamespacePath
117         {
118             get
119             {
120                 if (String.IsNullOrEmpty(this.namespacePath))
121                 {
122                     return Settings.Default.MediaWikiNamespacePath;
123                 }
124
125                 return this.namespacePath;
126             }
127
128             set
129             {
130                 this.namespacePath = value;
131             }
132         }
133
134         /// <summary>
135         /// 記事のXMLデータが存在するパス。
136         /// </summary>
137         /// <remarks>値が指定されていない場合、デフォルト値を返す。</remarks>
138         public string ExportPath
139         {
140             get
141             {
142                 if (String.IsNullOrEmpty(this.exportPath))
143                 {
144                     return Settings.Default.MediaWikiExportPath;
145                 }
146
147                 return this.exportPath;
148             }
149
150             set
151             {
152                 this.exportPath = value;
153             }
154         }
155
156         /// <summary>
157         /// リダイレクトの文字列。
158         /// </summary>
159         /// <remarks>値が指定されていない場合、デフォルト値を返す。</remarks>
160         public string Redirect
161         {
162             get
163             {
164                 if (String.IsNullOrEmpty(this.redirect))
165                 {
166                     return Settings.Default.MediaWikiRedirect;
167                 }
168
169                 return this.redirect;
170             }
171
172             set
173             {
174                 this.redirect = value;
175             }
176         }
177
178         /// <summary>
179         /// テンプレートの名前空間を示す番号。
180         /// </summary>
181         /// <remarks>値が指定されていない場合、デフォルト値を返す。</remarks>
182         public int TemplateNamespace
183         {
184             get
185             {
186                 return this.templateNamespace ?? Settings.Default.MediaWikiTemplateNamespace;
187             }
188
189             set
190             {
191                 this.templateNamespace = value;
192             }
193         }
194
195         /// <summary>
196         /// カテゴリの名前空間を示す番号。
197         /// </summary>
198         /// <remarks>値が指定されていない場合、デフォルト値を返す。</remarks>
199         public int CategoryNamespace
200         {
201             get
202             {
203                 return this.categoryNamespace ?? Settings.Default.MediaWikiCategoryNamespace;
204             }
205
206             set
207             {
208                 this.categoryNamespace = value;
209             }
210         }
211
212         /// <summary>
213         /// 画像の名前空間を示す番号。
214         /// </summary>
215         /// <remarks>値が指定されていない場合、デフォルト値を返す。</remarks>
216         public int FileNamespace
217         {
218             get
219             {
220                 return this.fileNamespace ?? Settings.Default.MediaWikiFileNamespace;
221             }
222
223             set
224             {
225                 this.fileNamespace = value;
226             }
227         }
228
229         /// <summary>
230         /// Wikipedia書式のシステム定義変数。
231         /// </summary>
232         /// <remarks>値が指定されていない場合、デフォルト値を返す。</remarks>
233         public IList<string> MagicWords
234         {
235             get
236             {
237                 if (this.magicWords == null)
238                 {
239                     string[] w = new string[Settings.Default.MediaWikiMagicWords.Count];
240                     Settings.Default.MediaWikiMagicWords.CopyTo(w, 0);
241                     return w;
242                 }
243
244                 return this.magicWords;
245             }
246
247             set
248             {
249                 this.magicWords = value;
250             }
251         }
252
253         #endregion
254
255         #region それ以外のプロパティ
256
257         /// <summary>
258         /// MediaWikiの名前空間の情報。
259         /// </summary>
260         /// <remarks>値が指定されていない場合、サーバーから情報を取得。</remarks>
261         public IDictionary<int, IList<string>> Namespaces
262         {
263             get
264             {
265                 lock (this.namespaces)
266                 {
267                     // 値が設定されていない場合、サーバーから取得して初期化する
268                     // ※ コンストラクタ等で初期化していないのは、通信の準備が整うまで行えないため
269                     // ※ MagicWordsがnullでこちらが空で若干条件が違うのは、あちらは設定ファイルに
270                     //    保存する設定だが、こちらは設定ファイルに保存しない基本的に読み込み用の設定だから。
271                     if (this.namespaces.Count > 0)
272                     {
273                         return this.namespaces;
274                     }
275
276                     // APIのXMLデータをMediaWikiサーバーから取得
277                     XmlDocument xml = new XmlDocument();
278                     using (Stream reader = this.WebProxy.GetStream(new Uri(new Uri(this.Location), this.NamespacePath)))
279                     {
280                         xml.Load(reader);
281                     }
282
283                     // ルートエレメントまで取得し、フォーマットをチェック
284                     XmlElement rootElement = xml["api"];
285                     if (rootElement == null)
286                     {
287                         // XMLは取得できたが空 or フォーマットが想定外
288                         throw new InvalidDataException("parse failed");
289                     }
290
291                     // クエリーを取得
292                     XmlElement queryElement = rootElement["query"];
293                     if (queryElement == null)
294                     {
295                         // フォーマットが想定外
296                         throw new InvalidDataException("parse failed");
297                     }
298
299                     // ネームスペースブロックを取得、ネームスペースブロックまでは必須
300                     XmlElement namespacesElement = queryElement["namespaces"];
301                     if (namespacesElement == null)
302                     {
303                         // フォーマットが想定外
304                         throw new InvalidDataException("parse failed");
305                     }
306
307                     // ネームスペースを取得
308                     foreach (XmlNode node in namespacesElement.ChildNodes)
309                     {
310                         XmlElement namespaceElement = node as XmlElement;
311                         if (namespaceElement != null)
312                         {
313                             try
314                             {
315                                 int id = Decimal.ToInt16(Decimal.Parse(namespaceElement.GetAttribute("id")));
316                                 IList<string> values = new List<string>();
317                                 values.Add(namespaceElement.InnerText);
318                                 this.namespaces[id] = values;
319
320                                 // あればシステム名?も設定
321                                 string canonical = namespaceElement.GetAttribute("canonical");
322                                 if (!String.IsNullOrEmpty(canonical))
323                                 {
324                                     values.Add(canonical);
325                                 }
326                             }
327                             catch (Exception e)
328                             {
329                                 // キャッチしているのは、万が一想定外の書式が返された場合に、完璧に動かなくなるのを防ぐため
330                                 System.Diagnostics.Debug.WriteLine("MediaWiki.Namespaces > 例外発生 : " + e);
331                             }
332                         }
333                     }
334
335                     // ネームスペースエイリアスブロックを取得、無い場合も想定
336                     XmlElement aliasesElement = queryElement["namespacealiases"];
337                     if (aliasesElement != null)
338                     {
339                         // ネームスペースエイリアスを取得
340                         foreach (XmlNode node in aliasesElement.ChildNodes)
341                         {
342                             XmlElement namespaceElement = node as XmlElement;
343                             if (namespaceElement != null)
344                             {
345                                 try
346                                 {
347                                     int id = Decimal.ToInt16(Decimal.Parse(namespaceElement.GetAttribute("id")));
348                                     IList<string> values = new List<string>();
349                                     if (this.namespaces.ContainsKey(id))
350                                     {
351                                         values = this.namespaces[id];
352                                     }
353
354                                     values.Add(namespaceElement.InnerText);
355                                 }
356                                 catch (Exception e)
357                                 {
358                                     // キャッチしているのは、万が一想定外の書式が返された場合に、完璧に動かなくなるのを防ぐため
359                                     System.Diagnostics.Debug.WriteLine("MediaWiki.Namespaces > 例外発生 : " + e);
360                                 }
361                             }
362                         }
363                     }
364                 }
365
366                 return this.namespaces;
367             }
368
369             set
370             {
371                 // ※必須な情報が設定されていない場合、ArgumentNullExceptionを返す
372                 if (value == null)
373                 {
374                     throw new ArgumentNullException("namespaces");
375                 }
376
377                 this.namespaces = value;
378             }
379         }
380
381         /// <summary>
382         /// Template:Documentation(言語間リンク等を別ページに記述するためのテンプレート)に相当するページ名。
383         /// </summary>
384         /// <remarks>空の場合、その言語版にはこれに相当する機能は無いものとして扱う。</remarks>
385         public IList<string> DocumentationTemplates
386         {
387             get;
388             protected set;
389         }
390
391         /// <summary>
392         /// Template:Documentationで指定が無い場合に参照するページ名。
393         /// </summary>
394         /// <remarks>
395         /// ほとんどの言語では[[/Doc]]の模様。
396         /// 空の場合、明示的な指定が無い場合は参照不能として扱う。
397         /// </remarks>
398         public string DocumentationTemplateDefaultPage
399         {
400             get;
401             set;
402         }
403
404         /// <summary>
405         /// Template:仮リンク(他言語へのリンク)で書式化するためのフォーマット。
406         /// </summary>
407         /// <remarks>空の場合、その言語版にはこれに相当する機能は無いor使用しないものとして扱う。</remarks>
408         public string LinkInterwikiFormat
409         {
410             get;
411             set;
412         }
413
414         /// <summary>
415         /// Template:Langで書式化するためのフォーマット。
416         /// </summary>
417         /// <remarks>空の場合、その言語版にはこれに相当する機能は無いor使用しないものとして扱う。</remarks>
418         public string LangFormat
419         {
420             get;
421             set;
422         }
423
424         /// <summary>
425         /// このクラスで使用するWebアクセス用Proxyインスタンス。
426         /// </summary>
427         /// <remarks>setterはユニットテスト用に公開。</remarks>
428         public IWebProxy WebProxy
429         {
430             protected get;
431             set;
432         }
433
434         #endregion
435
436         #region 公開メソッド
437
438         /// <summary>
439         /// ページを取得。
440         /// </summary>
441         /// <param name="title">ページタイトル。</param>
442         /// <returns>取得したページ。</returns>
443         /// <exception cref="FileNotFoundException">ページが存在しない場合。</exception>
444         /// <exception cref="NotSupportedException">末尾がピリオドのページの場合(既知の不具合への対応)。</exception>
445         /// <remarks>ページの取得に失敗した場合(通信エラーなど)は、その状況に応じた例外を投げる。</remarks>
446         public override Page GetPage(string title)
447         {
448             // fileスキームの場合、記事名からファイルに使えない文字をエスケープ
449             // ※ 仕組み的な処理はWebsite側に置きたいが、向こうではタイトルだけを抽出できないので
450             string escapeTitle = title;
451             if (new Uri(this.Location).IsFile)
452             {
453                 escapeTitle = FormUtils.ReplaceInvalidFileNameChars(title);
454             }
455
456             // URIを生成
457             Uri uri = new Uri(new Uri(this.Location), StringUtils.FormatDollarVariable(this.ExportPath, escapeTitle));
458             if (uri.OriginalString.EndsWith("."))
459             {
460                 // 末尾がピリオドのページが取得できない既知の不具合への暫定対応
461                 // 対処方法が不明なため、せめて例外を投げて検知する
462                 throw new NotSupportedException(title + " is not suppoted");
463             }
464
465             // ページのXMLデータをMediaWikiサーバーから取得
466             XmlDocument xml = new XmlDocument();
467             try
468             {
469                 using (Stream reader = this.WebProxy.GetStream(uri))
470                 {
471                     xml.Load(reader);
472                 }
473             }
474             catch (System.Net.WebException e)
475             {
476                 // 404エラーによるページ取得失敗は詰め替えて返す
477                 if (this.IsNotFound(e))
478                 {
479                     throw new FileNotFoundException("page not found", e);
480                 }
481
482                 throw e;
483             }
484
485             // ルートエレメントまで取得し、フォーマットをチェック
486             XmlElement rootElement = xml["mediawiki"];
487             if (rootElement == null)
488             {
489                 // XMLは取得できたが空 or フォーマットが想定外
490                 throw new InvalidDataException("parse failed");
491             }
492
493             // ページの解析
494             XmlElement pageElement = rootElement["page"];
495             if (pageElement == null)
496             {
497                 // ページ無し
498                 throw new FileNotFoundException("page not found");
499             }
500
501             // ページ名、ページ本文、最終更新日時
502             // ※ 一応、各項目が無くても動作するようにする
503             string pageTitle = XmlUtils.InnerText(pageElement["title"], title);
504             string text = null;
505             DateTime? time = null;
506             XmlElement revisionElement = pageElement["revision"];
507             if (revisionElement != null)
508             {
509                 text = XmlUtils.InnerText(revisionElement["text"], null);
510                 XmlElement timeElement = revisionElement["timestamp"];
511                 if (timeElement != null)
512                 {
513                     time = new DateTime?(DateTime.Parse(timeElement.InnerText));
514                 }
515             }
516
517             // ページ情報を作成して返す
518             return new MediaWikiPage(this, pageTitle, text, time);
519         }
520
521         /// <summary>
522         /// 指定された文字列がWikipediaのシステム変数に相当かを判定。
523         /// </summary>
524         /// <param name="text">チェックする文字列。</param>
525         /// <returns><c>true</c> システム変数に相当。</returns>
526         public bool IsMagicWord(string text)
527         {
528             string s = text != null ? text : String.Empty;
529
530             // {{CURRENTYEAR}}や{{ns:1}}みたいなパターンがある
531             foreach (string variable in this.MagicWords)
532             {
533                 if (s == variable || s.StartsWith(variable + ":"))
534                 {
535                     return true;
536                 }
537             }
538
539             return false;
540         }
541
542         /// <summary>
543         /// <see cref="LinkInterwikiFormat"/> を渡された記事名, 言語, 他言語版記事名, 表示名で書式化した文字列を返す。
544         /// </summary>
545         /// <param name="title">記事名。</param>
546         /// <param name="lang">言語。</param>
547         /// <param name="langTitle">他言語版記事名。</param>
548         /// <param name="label">表示名。</param>
549         /// <returns>書式化した文字列。<see cref="LinkInterwikiFormat"/>が未設定の場合<c>null</c>。</returns>
550         public string FormatLinkInterwiki(string title, string lang, string langTitle, string label)
551         {
552             if (String.IsNullOrEmpty(this.LinkInterwikiFormat))
553             {
554                 return null;
555             }
556
557             return StringUtils.FormatDollarVariable(this.LinkInterwikiFormat, title, lang, langTitle, label);
558         }
559
560         /// <summary>
561         /// <see cref="LangFormat"/> を渡された言語, 文字列で書式化した文字列を返す。
562         /// </summary>
563         /// <param name="lang">言語。</param>
564         /// <param name="text">文字列。</param>
565         /// <returns>書式化した文字列。<see cref="LangFormat"/>が未設定の場合<c>null</c>。</returns>
566         /// <remarks>
567         /// この<para>lang</para>と<see cref="Language"/>のコードは、厳密には一致しないケースがあるが
568         /// (例、simple→en)、2012年2月現在の実装ではそこまで正確さは要求していない。
569         /// </remarks>
570         public string FormatLang(string lang, string text)
571         {
572             if (String.IsNullOrEmpty(this.LangFormat))
573             {
574                 return null;
575             }
576
577             return StringUtils.FormatDollarVariable(this.LangFormat, lang, text);
578         }
579         
580         #endregion
581
582         #region XMLシリアライズ用メソッド
583
584         /// <summary>
585         /// シリアライズするXMLのスキーマ定義を返す。
586         /// </summary>
587         /// <returns>XML表現を記述するXmlSchema。</returns>
588         public System.Xml.Schema.XmlSchema GetSchema()
589         {
590             return null;
591         }
592
593         /// <summary>
594         /// XMLからオブジェクトをデシリアライズする。
595         /// </summary>
596         /// <param name="reader">デシリアライズ元のXmlReader</param>
597         public void ReadXml(XmlReader reader)
598         {
599             XmlDocument xml = new XmlDocument();
600             xml.Load(reader);
601
602             // Webサイト
603             // ※ 以下、基本的に無かったらNGの部分はいちいちチェックしない。例外飛ばす
604             XmlElement siteElement = xml.DocumentElement;
605             this.Location = siteElement.SelectSingleNode("Location").InnerText;
606
607             using (XmlReader r = XmlReader.Create(
608                 new StringReader(siteElement.SelectSingleNode("Language").OuterXml), reader.Settings))
609             {
610                 this.Language = new XmlSerializer(typeof(Language)).Deserialize(r) as Language;
611             }
612
613             this.NamespacePath = XmlUtils.InnerText(siteElement.SelectSingleNode("NamespacePath"));
614             this.ExportPath = XmlUtils.InnerText(siteElement.SelectSingleNode("ExportPath"));
615             this.Redirect = XmlUtils.InnerText(siteElement.SelectSingleNode("Redirect"));
616
617             string text = XmlUtils.InnerText(siteElement.SelectSingleNode("TemplateNamespace"));
618             if (!String.IsNullOrEmpty(text))
619             {
620                 this.TemplateNamespace = int.Parse(text);
621             }
622
623             text = XmlUtils.InnerText(siteElement.SelectSingleNode("CategoryNamespace"));
624             if (!String.IsNullOrEmpty(text))
625             {
626                 this.CategoryNamespace = int.Parse(text);
627             }
628
629             text = XmlUtils.InnerText(siteElement.SelectSingleNode("FileNamespace"));
630             if (!String.IsNullOrEmpty(text))
631             {
632                 this.FileNamespace = int.Parse(text);
633             }
634
635             // システム定義変数
636             IList<string> variables = new List<string>();
637             foreach (XmlNode variableNode in siteElement.SelectNodes("MagicWords/Variable"))
638             {
639                 variables.Add(variableNode.InnerText);
640             }
641
642             if (variables.Count > 0)
643             {
644                 // 初期値の都合上、値がある場合のみ
645                 this.MagicWords = variables;
646             }
647
648             // Template:Documentationの設定
649             this.DocumentationTemplates = new List<string>();
650             foreach (XmlNode docNode in siteElement.SelectNodes("DocumentationTemplates/DocumentationTemplate"))
651             {
652                 this.DocumentationTemplates.Add(docNode.InnerText);
653                 XmlElement docElement = docNode as XmlElement;
654                 if (docElement != null)
655                 {
656                     // ※ XML上DefaultPageはテンプレートごとに異なる値を持てるが、
657                     //    そうした例を見かけたことがないため、代表で一つの値のみ使用
658                     //    (複数値が持てるのも、リダイレクトが存在するためその対策として)
659                     this.DocumentationTemplateDefaultPage = docElement.GetAttribute("DefaultPage");
660                 }
661             }
662
663             this.LinkInterwikiFormat = XmlUtils.InnerText(siteElement.SelectSingleNode("LinkInterwikiFormat"));
664             this.LangFormat = XmlUtils.InnerText(siteElement.SelectSingleNode("LangFormat"));
665         }
666
667         /// <summary>
668         /// オブジェクトをXMLにシリアライズする。
669         /// </summary>
670         /// <param name="writer">シリアライズ先のXmlWriter</param>
671         public void WriteXml(XmlWriter writer)
672         {
673             writer.WriteElementString("Location", this.Location);
674             new XmlSerializer(this.Language.GetType()).Serialize(writer, this.Language);
675
676             // MediaWiki固有の情報
677             // ※ 設定ファイルに初期値を持つものは、プロパティではなく値から出力
678             writer.WriteElementString("NamespacePath", this.namespacePath);
679             writer.WriteElementString("ExportPath", this.exportPath);
680             writer.WriteElementString("Redirect", this.redirect);
681             writer.WriteElementString(
682                 "TemplateNamespace",
683                 this.templateNamespace.HasValue ? this.templateNamespace.ToString() : String.Empty);
684             writer.WriteElementString(
685                 "CategoryNamespace",
686                 this.templateNamespace.HasValue ? this.categoryNamespace.ToString() : String.Empty);
687             writer.WriteElementString(
688                 "FileNamespace",
689                 this.templateNamespace.HasValue ? this.fileNamespace.ToString() : String.Empty);
690
691             // システム定義変数
692             writer.WriteStartElement("MagicWords");
693             if (this.magicWords != null)
694             {
695                 foreach (string variable in this.magicWords)
696                 {
697                     writer.WriteElementString("Variable", variable);
698                 }
699             }
700
701             // Template:Documentationの設定
702             writer.WriteEndElement();
703             writer.WriteStartElement("DocumentationTemplates");
704             foreach (string doc in this.DocumentationTemplates)
705             {
706                 writer.WriteStartElement("DocumentationTemplate");
707                 writer.WriteAttributeString("DefaultPage", this.DocumentationTemplateDefaultPage);
708                 writer.WriteValue(doc);
709                 writer.WriteEndElement();
710             }
711
712             writer.WriteEndElement();
713             writer.WriteElementString("LinkInterwikiFormat", this.LinkInterwikiFormat);
714             writer.WriteElementString("LangFormat", this.LangFormat);
715         }
716
717         #endregion
718     }
719 }