Laden...

WebClient mit HTTP-POST- und Cookie-Unterstützung

Erstellt von dN!3L vor 14 Jahren Letzter Beitrag vor 14 Jahren 30.108 Views
dN!3L Themenstarter:in
2.891 Beiträge seit 2004
vor 14 Jahren
WebClient mit HTTP-POST- und Cookie-Unterstützung

Beschreibung:
Für (z.B.) den Anwendungsfall, dass man automatisiert Inhalte einer Webseite herunterladen möchte, sich vorher aber anmelden muss (per HTTP-Post). Oder einfach so zum screen scraping.
Wurde zwar schon vielfach nachgefragt und gelöst, aber hier nun eine schöne API dafür 😉 Kümmert sich auch um Anmeldecookies und kann invalide SSL-Zertifikate ignorieren.

Beispiel:

ExtendedWebClient extendedWebClient  = new ExtendedWebClient();

// Anmelden per POST; anonymer Typ als Parameterobjekt
extendedWebClient.Post("http://www.example.com/login.php",new
	{
		username = "carnivore",
		password = "secret_password"
	});

// Parametrisierte GET-Anfrage ausführen; Dictionary für Parameter
string content = extendedWebClient.Get("http://www.example.com/search.php",new Dictionary<string,string>
	{
		{ "searchstring", "http post application/x-www-form-urlencoded" },
		{ "maxresults" , 25 }
	});

spezielle Features:* einfaches POST und GET

  • Nachbearbeiten der WebRequests möglich (z.B. User-Agent setzen)
  • Parameter einfach als Objekte möglich
  • Cookies für aufeinanderfolgende Requests berücksichtigen
  • auch vom Betriebssystem als unsicher betrachtete (SSL-)Zertifikate erlauben (ExtendedWebClient.IgnoreInvalidCertificates = true)
  • ggf. Cookies für gesamte Domain gültig machen (ForceApplyCookiesToDirectories)
  • MultiPart-Post mit Dateien als Inhalt (FormFile als Parametertyp verwenden)
  • tatsächlichen Dateinamen für Dateidownloads ermitteln (GetFilenameFromWebServer)

Code:
:::


using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Security;
using System.Reflection;
using System.Text;
using System.Web;

/// <summary>
/// Erweitert den System.Net.WebClient um Funktionen zum expliziten Ausführen von HTTP-GET- und HTTP-POST-Anfragen, bei denen Cookies für eine Session bestehen bleiben.
/// </summary>
//[System.Diagnostics.DebuggerStepThrough]
public class ExtendedWebClient : WebClient
{
	private readonly CookieContainer cookieContainer = new CookieContainer();    // um bei Login-Szenarien auch eingeloggt zu bleiben


	/// <summary>
	/// Tritt ein, wenn der HttpWebRequest für eine Anfrage erstellt wurde.
	/// </summary>
	public event Action<HttpWebRequest> HttpWebRequestCreated;


	/// <summary>
	/// Ruft einen Wert ab, der bestimmt, ob die Gültigkeit von Cookies immer auf Verzeichnisebene gesetzt wird, oder legt diesen fest.
	/// </summary>
	public bool ForceApplyCookiesToDirectories { get; set; }


	/// <summary>
	/// Ruft einen Wert ab, der bestimmt, ob auch vom Betriebssystem als unsicher betrachtete (SSL-)Zertifikate erlaubt werden, oder legt diesen fest,
	/// </summary>
	public static bool IgnoreInvalidCertificates
	{
		get => (ServicePointManager.ServerCertificateValidationCallback==ExtendedWebClient.ignoreInvalidCertificateValidationCallback);
		set => ServicePointManager.ServerCertificateValidationCallback = (value) ? ExtendedWebClient.ignoreInvalidCertificateValidationCallback : null;
	}
	private static readonly RemoteCertificateValidationCallback ignoreInvalidCertificateValidationCallback = delegate { return true; };    // akzeptiert alle Zertifikate



	/// <summary>
	/// Erstellt eine neue Instanz der ExtendedWebClient-Klasse
	/// </summary>
	public ExtendedWebClient()
		=> this.Encoding = Encoding.UTF8;




	/// <summary>
	/// Gibt ein WebRequest-Objekt für die angegebene Ressource zurück.
	/// </summary>
	/// <param name="address">Ein URI, der die anzufordernde Ressource identifiziert.</param>
	/// <returns>Ein neues WebRequest-Objekt für die angegebene Ressource.</returns>
	protected override WebRequest GetWebRequest(Uri address)
	{
		WebRequest webRequest = base.GetWebRequest(address);

		// CookieConainer immer mit einhängen
		if (webRequest is HttpWebRequest httpWebRequest)
			lock (this)
			{
				httpWebRequest.CookieContainer = this.cookieContainer;
				this.HttpWebRequestCreated?.Invoke(httpWebRequest);
			}

		return webRequest;
	}


	/// <summary>
	/// Gibt die WebResponse für die angegebene WebRequest zurück.
	/// </summary>
	/// <param name="webRequest">Eine WebRequest, mit der die Antwort abgerufen wird. </param>
	/// <returns>Eine WebResponse mit der Antwort auf die angegebene WebRequest. </returns>
	protected override WebResponse GetWebResponse(WebRequest webRequest)
	{
		WebResponse webResponse = base.GetWebResponse(webRequest);
		if (webResponse is HttpWebResponse httpWebResponse)
			lock (this)
			{
				Debug.WriteLine(httpWebResponse.StatusCode+"\t"+webRequest.RequestUri);

				// Bug umgehen, bei dem der CookieContainer keine Cookies speichert, die eine Beschränkung auf einen Host mit voranstehendem "." hat
				foreach (Cookie cookie in httpWebResponse.Cookies)
					this.cookieContainer.Add(cookie);

				// anderen Bug umgehen, bei dem nicht alle nötigen Cookies übertragen werden
				if (this.ForceApplyCookiesToDirectories)
					foreach (Cookie cookie in this.cookieContainer.GetCookies(webRequest.RequestUri))
					{
						if (cookie.Path.Length>1 && cookie.Path.Contains("/") && !cookie.Path.EndsWith("/"))
							cookie.Path = cookie.Path.Remove(cookie.Path.LastIndexOf('/') + 1);
						this.cookieContainer.Add(cookie);
					}
			}

		return webResponse;
	}


	/// <summary>
	/// Liest den Inhalt einer WebResponse als Zeichenkettenrepräsentation aus.
	/// </summary>
	/// <param name="webResponse">Eine WebResponse.</param>
	/// <returns>Den Inhalt einer WebResponse als Zeichenkettenrepräsentation.</returns>
	public string ReadWebResponse(WebResponse webResponse)
	{
		using (StreamReader streamReader = new StreamReader(webResponse.GetResponseStream(),this.Encoding))
			return streamReader.ReadToEnd();
	}



	/// <summary>
	/// Gibt das Ergebnis einer HTTP-POST-Anfrage an die angegebene Ressource zurück.
	/// </summary>
	/// <param name="url">Ein URI, der die anzufordernde Ressource identifiziert.</param>
	/// <param name="parameters">Ein Objekt, dessen Properties als POST-Parameter benutzt werden.</param>
	/// <returns>Das Ergebnis einer HTTP-POST-Anfrage an die angegebene Ressource (als Zeichenkettenrepräsentation).</returns>
	public string Post(string url,object parameters)
		=> this.Post(url,ExtendedWebClient.ObjectToDictionary(parameters));


	/// <summary>
	/// Gibt das Ergebnis einer HTTP-POST-Anfrage an die angegebene Ressource zurück.
	/// </summary>
	/// <param name="url">Ein URI, der die anzufordernde Ressource identifiziert.</param>
	/// <param name="parameters">Eine Auflistung der POST-Parameter.</param>
	/// <returns>Das Ergebnis einer HTTP-POST-Anfrage an die angegebene Ressource (als Zeichenkettenrepräsentation).</returns>
	public string Post(string url,IDictionary<string,object> parameters)
	{
		using (WebResponse response = this.GetWebResponse(this.Post(new Uri(url),parameters)))
			return this.ReadWebResponse(response);
	}


	/// <summary>
	/// Gibt das Ergebnis einer HTTP-POST-Anfrage an die angegebene Ressource zurück.
	/// </summary>
	/// <param name="uri">Ein URI, der die anzufordernde Ressource identifiziert.</param>
	/// <param name="parameters">Eine Auflistung der POST-Parameter.</param>
	/// <returns>Das Ergebnis einer HTTP-POST-Anfrage an die angegebene Ressource (als Zeichenkettenrepräsentation).</returns>
	public WebRequest Post(Uri uri,IDictionary<string,object> parameters)
	{
		WebRequest webRequest = this.GetWebRequest(uri);
		webRequest.Method = "POST";

		// "einfaches" POST
		if (!parameters.Values.Any(p => p is FormFile))
		{
			string paramsString = ExtendedWebClient.CreateParamString(parameters);
			byte[] content = this.Encoding.GetBytes(paramsString);

			webRequest.ContentType = "application/x-www-form-urlencoded";
			webRequest.ContentLength = content.Length;

			using (Stream requestStream = webRequest.GetRequestStream())
				requestStream.Write(content,0,content.Length);

			return webRequest;
		}
		// Multipart-POST
		else
		{
			string boundary = "---------------------------" + DateTime.Now.Ticks.ToString("x");
			byte[] boundaryBytes = this.Encoding.GetBytes("\r\n--" + boundary + "\r\n");

			webRequest.ContentType = "multipart/form-data; boundary=" + boundary;

			using (Stream requestStream = webRequest.GetRequestStream())
			{
				foreach (KeyValuePair<string,object> parameter in parameters)
				{
					requestStream.Write(boundaryBytes,0,boundaryBytes.Length);
					if (parameter.Value is FormFile formFile)
					{
						string header = "Content-Disposition: form-data; name=\"" + parameter.Key + "\"; filename=\"" + formFile.Name + "\"\r\nContent-Type: " + formFile.ContentType + "\r\n\r\n";
						byte[] bytes = this.Encoding.GetBytes(header);
						requestStream.Write(bytes,0,bytes.Length);

						int bytesRead;
						if (formFile.Stream==null)
						{
							bytes = File.ReadAllBytes(formFile.FilePath);
							requestStream.Write(bytes,0,bytes.Length);
						}
						else
						{
							byte[] buffer = new byte[32768];
							while ((bytesRead = formFile.Stream.Read(buffer,0,buffer.Length)) != 0)
								requestStream.Write(buffer,0,bytesRead);
						}
					}
					else
					{
						string data = "Content-Disposition: form-data; name=\"" + parameter.Key + "\"\r\n\r\n" + parameter.Value;
						byte[] bytes = this.Encoding.GetBytes(data);
						requestStream.Write(bytes,0,bytes.Length);
					}
				}

				byte[] trailer = this.Encoding.GetBytes("\r\n--" + boundary + "--\r\n");
				requestStream.Write(trailer,0,trailer.Length);
				requestStream.Close();
			}

			return webRequest;
		}
	}



	/// <summary>
	/// Gibt das Ergebnis einer HTTP-GET-Anfrage an die angegebene Ressource zurück.
	/// </summary>
	/// <param name="url">Ein URI, der die anzufordernde Ressource identifiziert.</param>
	/// <param name="parameters">Ein Objekt, dessen Properties als GET-Parameter benutzt werden.</param>
	/// <returns>Das Ergebnis einer HTTP-GET-Anfrage an die angegebene Ressource (als Zeichenkettenrepräsentation).</returns>
	public string Get(string url,object parameters = null)
		=> this.Get(url,ExtendedWebClient.ObjectToDictionary(parameters));


	/// <summary>
	/// Gibt das Ergebnis einer HTTP-GET-Anfrage an die angegebene Ressource zurück.
	/// </summary>
	/// <param name="url">Ein URI, der die anzufordernde Ressource identifiziert.</param>
	/// <param name="parameters">Eine Auflistung der GET-Parameter.</param>
	/// <returns>Das Ergebnis einer HTTP-GET-Anfrage an die angegebene Ressource (als Zeichenkettenrepräsentation).</returns>
	public string Get(string url,IDictionary<string,object> parameters)
	{
		using (WebResponse response = this.GetWebResponse(this.Get(new Uri(url),parameters)))
			return this.ReadWebResponse(response);
	}


	/// <summary>
	/// Gibt ein WebRequest-Objekt für eine HTTP-GET-Anfrage an die angegebene Ressource zurück.
	/// </summary>
	/// <param name="uri">Ein URI, der die anzufordernde Ressource identifiziert.</param>
	/// <param name="parameters">Eine Zeichenkettenrepräsentation aller Parameter-Wertepaare.</param>
	/// <returns>Ein neues WebRequest-Objekt für die angegebene Ressource.</returns>
	private WebRequest Get(Uri uri,IDictionary<string,object> parameters)
	{
		if (parameters.Any())
			uri = new Uri(uri.OriginalString + "?" + ExtendedWebClient.CreateParamString(parameters));
		WebRequest webRequest = this.GetWebRequest(uri);
		webRequest.Method = "GET";
		return webRequest;
	}



	/// <summary>
	/// Ruft den konkreten Namen einer Datei ab
	/// </summary>
	/// <param name="url">die URL, unter der die Datei verfügbar ist</param>
	public string GetFilenameFromWebServer(string url)
	{
		WebRequest webRequest = this.GetWebRequest(new Uri(url));
		webRequest.Method = "HEAD";
		using (WebResponse webResponse = webRequest.GetResponse())
			if (!String.IsNullOrEmpty(webResponse.Headers["Content-Disposition"]))
				return webResponse.Headers["Content-Disposition"].Substring(webResponse.Headers["Content-Disposition"].IndexOf("filename=") + 9).Replace("\"","");
		return null;
	}



	/// <summary>
	/// Erstellt eine Auflistung, die alle öffentlichen Eigenschaften eines Objekts als Schlüssel/Wertpaare enthält
	/// </summary>
	/// <param name="value">Ein Objekt, für das die Auflistung erstellt werden soll.</param>
	/// <returns>Eine Auflistung, die alle öffentlichen Eigenschaften eines Objekts als Schlüssel/Wertpaare enthält.</returns>
	private static Dictionary<string,object> ObjectToDictionary(object value)
	{
		Dictionary<string,object> result = new Dictionary<string,object>();
		if (value!=null)
			foreach (PropertyInfo propertyInfo in value.GetType().GetProperties())
				if (propertyInfo.CanRead && propertyInfo.GetIndexParameters().Length==0)
					result.Add(propertyInfo.Name,propertyInfo.GetValue(value,null));
		return result;
	}


	/// <summary>
	/// Erstellt die Zeichenkettenrepräsentation für Parameter-Wertepaare.
	/// </summary>
	/// <param name="parameters">Eine Auflistung der Parameter-Wertepaare.</param>
	/// <returns>Die Zeichenkettenrepräsentation für Parameter-Wertepaare.</returns>
	private static string CreateParamString(IDictionary<string,object> parameters)
		=> String.Join("&",parameters.Select(kvp => HttpUtility.UrlEncode(kvp.Key) + "=" + HttpUtility.UrlEncode(kvp.Value+String.Empty)));



	/// <summary>
	/// Stellt Informationen über eine File-Objekt, das als Multipart-Post übertragen wird, bereit
	/// </summary>
	public class FormFile
	{
		/// <summary> Ruft den Namen der Datei ab oder legt diesen fest </summary>
		public string Name { get; set; }

		/// <summary> Ruft den Content-Type der Datei ab oder legt diesen fest </summary>
		public string ContentType { get; set; }

		/// <summary> Ruft den Pfad der Datei ab oder legt diesen fest </summary>
		public string FilePath { get; set; }

		/// <summary> Ruft den Stream der Datei ab oder legt diesen fest </summary>
		public Stream Stream { get; set; }
	}
}

EDITS: Cookie-Bug, ungültige Zertifikate erlauben. Danke an jaensen! Beispielcode für VS2005. Encoding, Hack für Gültigkeitsbereiche von Cookies. Event zum Editieren des HttpWebRequests.

Schlagwörter: GET, POST, WebRequest, HttpWebRequest, Login, Cookies, Scraping

2.760 Beiträge seit 2006
vor 14 Jahren

Was früher oder später hier noch als Frage kommen wird möchte ich gleich mal vorweg nehmen. Wäre cool wenn du das deinem Snippet spendieren könntest:

  • Vom OS als unsicher erachtete SSL-Zertifikate erlauben (ServicePointManager.ServerCertificateValidationCallback)
  • Der Cookie-container hat einen Bug wenn das das Cookie eine Beschränkung auf einen Host mit voranstehendem "." hat (Wird benutzt um das Cookie für alle Subdomains gültig zu machen) umgangen werden kann dieser in dem in ReadWebResponse() alle Cookies aus der CookieCollection manuell dem Container hinzugefügt werden (Add-Methode des Cookie-Container)

Dann gibt es noch exotische Fälle in denen man AllowUnsafeHeaderParsing aktivieren muss was allerdings ohne Umweg über reflection nur in der App.config geht. Dazu mehr hier: MSDN: AllowUnsafeHeaderParsing via Code

Manchmal ist es auch noch nötig die richtige Http-Version, einen User-Agent string oder einen Referer mitzuschicken.

dN!3L Themenstarter:in
2.891 Beiträge seit 2004
vor 14 Jahren

Hallo jaensen,

danke für das Feedback.

Der Cookie-container hat einen Bug wenn das das Cookie eine Beschränkung auf einen Host mit voranstehendem "." hat [...]

Dürfte jetzt gehen.

Vom OS als unsicher erachtete SSL-Zertifikate erlauben (ServicePointManager.ServerCertificateValidationCallback)

Dazu muss man was genau machen? 🤔

Manchmal ist es auch noch nötig die richtige Http-Version, einen User-Agent string oder einen Referer mitzuschicken.

Für sowas gibt es dann ja noch die Get(...)- und Post(...)-Überladungen, die erstmal nur einen WebRequest zurückgeben. Da kann man dann selbst noch basteln. 😉

Beste Grüße,
dN!3L

2.760 Beiträge seit 2006
vor 14 Jahren

Dazu muss man was genau machen?

Eine Methode schreiben die diesem Delegate entspricht: RemoteCertificateValidationCallback und einfach true zurückliefern. Das ganze sollte man über ein bool-Property steuerbar machen so das man die Validierung wenn benötigt einfach ausschalten kann.


if (_ignoreCertificate)
   ServicePointManager.ServerCertificateValidationCallback = new RemoteCertificateValidationCallback(ServerCertificateCallback);

protected static bool ServerCertificateCallback(object sender, X509Certificate certificate, X509Chain chain, System.Net.Security.SslPolicyErrors sslPolicyErrors)
{
   return true;
}

dN!3L Themenstarter:in
2.891 Beiträge seit 2004
vor 14 Jahren

Hallo jaensen,

RemoteCertificateValidationCallback und einfach true zurückliefern. Das ganze sollte man über ein bool-Property steuerbar machen so das man die Validierung wenn benötigt einfach ausschalten kann.

So, ist jetzt auch mit drin (static bool IgnoreInvalidCertificates). Schaltet die Zertifikatsvalidierung dann aber global aus (da sich der ServicePointManager ja nicht nur um einen einzigen WebRequest kümmert)...

Gruß,
dN!3L

2.760 Beiträge seit 2006
vor 14 Jahren

Das ist leider so, da kann man nicht wirklich was machen.

2.760 Beiträge seit 2006
vor 14 Jahren

Dachte mir ich poste einfach mal ein paar hilfreiche Links zum Thema dazu wie man mit dem durch das Snippet angefragten Daten weiterarbeiten kann:

Wenn dann mal doch irgendwas nicht so klappt wie man sich das vorstellt dann kann man die Fehler meistens relativ schnell mit Fiddler identifizieren, Wireshark geht zwar auch aber Fiddler stellt den Traffic übersichtlicher dar.

M
116 Beiträge seit 2006
vor 14 Jahren

jaensen, Vielen, Vielen Dank!!
Genau das hat mir gefehlt 😃 Super! Ich war immer irgendwie zu faul....
Als HTML-Parser nutze ich persönlich lieber den MIL HTML Parser, aber ich denke das ist Geschmacks- bzw. Nutzensache! Aber der Vollständigkeit halber erwähne ich ihn mal.

2.760 Beiträge seit 2006
vor 14 Jahren

Habe ich auch lange Zeit selbst benutzt muss aber sagen das der SgmlReader besser ist (sprich bei weniger validem HTML stabiler ist wo der MIL HTML Parser schon mal die Grätsche macht).

P.S:

Super! Ich war immer irgendwie zu faul....

Das Snippet ist allerdings von dN!3L

[EDIT]
Ein weiterer HTML Parser der auch recht gute Resultate liefert wäre auch: DOL HTML Parser

dN!3L Themenstarter:in
2.891 Beiträge seit 2004
vor 14 Jahren

So,

da es bei ein paar Webseiten Probleme mit den Gültigkeitsbereichen von Cookies gab (Cookies wurden nicht an Seite übergeben), gibt es jetzt die Option ForceApplyCookiesToDirectories. Diese sorgt dafür, dass Cookie-Gültigkeitsbereiche für Dateien so geändert werden, dass sie dann für das gesamte Verzeichnis dieser Datei gelten.
Also falls es das Problem gibt, dass man trotz vollständiger Angaben doch nicht eingeloggt bleibt, einfach mal ForceApplyCookiesToDirectories auf true setzen.

dN!3L

dN!3L Themenstarter:in
2.891 Beiträge seit 2004
vor 14 Jahren

Hallo zusammen,

ich hab noch ein kleines Event hinzugefügt, damit der HttpWebRequest, der für die Anfragen benutzt wird, leicht nachbearbeitet werden kann.


ExtendedWebClient webClient = new ExtendedWebClient();
webClient.HttpWebRequestCreated += delegate(HttpWebRequest httpWebRequest)
{
	httpWebRequest.UserAgent = "...";
	httpWebRequest.Referer = "...";
};

dN!3L