Abruf von Reporting-Daten des Amazon Affiliate Programms mit WordPress

Für ein Kundenprojekt beschäftigte ich mich mit dem Abruf der Reporting-Daten des Amazon Affiliate Programms. Soweit mir bekannt, bekommen nur die Top 100 des jeweiligen Landes Zugriff zu dieser Möglichkeit, Daten abzurufen. Eine Dokumentation dazu gibt es nicht. Eine Google Suche zu diesen Informationen liefert nur sehr wenig bis keine Ergebnisse. Ich hab’s trotzdem geschafft herauszufinden, wie man die Daten mit WordPress abrufen kann. Und hier steht, wie es funktioniert:

Digest Authentication

Unter https://assoc-datafeeds-eu.amazon.com/datafeed/listReports findet man die URL, mit der man seine Daten abrufen kann. Anmelden muss man sich mit dem Amazon Associate Tag sowie dem Passwort.

Tricky ist, dass Amazon statt einer Basic-Authentication (die mit WordPress bzw. PHP relativ einfach umzusetzen ist) eine andere Methode nutzt. Sie nennt sich Digest Access Autentication. Blöderweise ist diese Methode sehr umständlich, wie man weiter unten sehen wird.

Beschrieben wird die Methode ganz genau in der RFC2069. Auf der englischen Wikipedia findet man eine Kurzfassung, die alles wesentliche enthält.

Digest Header anfordern

Hier eine Beispiel-Funktion, die den www-authenticate Header abruft und eine gültige Rückanfrage erstellt. Weiter unten die nummerierte Beschreibung zu den einzelnen Codebestandteilen.

Als Beispiel-URL dient folgende URL: https://assoc-datafeeds-eu.amazon.com/datafeed/getReport?filename=%1$s-earnings-report-%2$s.tsv.gz wobei %1$s der Amazon Associate Tag ist und %2$s das Datum im Format YYYYMMDD.

<?php
/**
 * Creates the digest header for amazon requests.
 *
 * @param string $url
 *
 * @global array $ab_reporting_digest
 *
 * @return string|\WP_Error
 */
function wtf_api_amazon_get_digest_header( $url ) {

	/**
	 * 1. Cache
	 * Use a global variable to reuse the header information later
	 */
	global $ab_reporting_digest;

	if ( isset( $ab_reporting_digest ) && isset( $ab_reporting_digest['header'] ) && ! empty( $ab_reporting_digest['header'] ) ) {
		$header = $ab_reporting_digest['header'];

	} else {
		/**
		 * 2. Create a HEAD request
		 */
		$args = array(
			'timeout'     => defined( 'DOING_CRON' ) && DOING_CRON ? 60 : 10,
			'redirection' => 0,
		);

		$response = wp_remote_head( $url, $args );

		if ( wp_remote_retrieve_response_code( $response ) == 404 ) {
			return new WP_Error( 'amazon_report_get_404', __( 'Failed to fetch digest header from Amazon.', 'wtf' ) );
		}

		$header = wp_remote_retrieve_header( $response, 'www-authenticate' );

		if ( empty( $header ) ) {
			return new WP_Error( 'amazon_report_get_digest', __( 'Failed to fetch digest header from Amazon.', 'wtf' ) );
		}

		$ab_reporting_digest['header'] = $header;
	}

	/**
	 * 3. Read the bits from the www-authenticate header
	 */
	preg_match_all( '#(([\w]+)=["]?([^\s"]+))#', $header, $matches );

	$server_bits = array();

	foreach ( $matches[2] as $i => $key ) {
		$server_bits[ $key ] = $matches[3][ $i ];
	}

	/**
	 * 4. Create a nonce count
	 */
	$nc = '00000001';

	if ( isset( $ab_reporting_digest ) ) {
		if ( ! isset( $ab_reporting_digest['nc'] ) ) {
			$ab_reporting_digest['nc'] = 0;
		}
		$ab_reporting_digest['nc'] ++;

		$nc = zeroise( $ab_reporting_digest['nc'], 8 );
	}


	/**
	 * 5. Get the query params
	 */
	$path = parse_url( $url );
	$path = sprintf( '%s%s%s', $path['path'], ! empty( $path['query'] ) ? '?' : '', $path['query'] );


	/**
	 * 6. Create client nonce
	 */
	$client_nonce = uniqid();


	/**
	 * 7. Create HA1
	 */
	$ha1 = md5( sprintf( '%s:%s:%s', AB_AMAZON_ASSOCIATE_TAG, $server_bits['realm'], AB_AMAZON_REPORTING_PW ) );

	# if md5-sess is ON this must be set, additionally
	#$ha1 = md5( sprintf( '%s:%s:%s', $ha1, $server_bits['nonce'], $server_bits['cnonce'] ) );

	/**
	 * 8. Create HA2
	 */
	$ha2 = md5( 'GET:' . $path );


	/**
	 * 9. Fill the response bits
	 */
	$response_bits = array(
		$ha1,
		$server_bits['nonce'],
		$nc,
		$client_nonce,
		$server_bits['qop'],
		$ha2,
	);


	/**
	 * 10. Build the response header string
	 */

	$digest_header_values = array(
		'username' => sprintf( '"%s"', AB_AMAZON_ASSOCIATE_TAG ),
		'realm'    => sprintf( '"%s"', $server_bits['realm'] ),
		'nonce'    => sprintf( '"%s"', $server_bits['nonce'] ),
		'uri'      => sprintf( '"%s"', $path ),
		'response' => sprintf( '"%s"', md5( implode( ':', $response_bits ) ) ),
		'opaque'   => sprintf( '"%s"', $server_bits['opaque'] ),
		'qop'      => $server_bits['qop'],
		'nc'       => $nc,
		'cnonce'   => sprintf( '"%s"', $client_nonce ),
	);

	$digest_header = 'Digest ';

	foreach ( $digest_header_values as $key => $value ) {
		$digest_header .= sprintf( '%s=%s, ', $key, $value );
	}

	$digest_header = rtrim( $digest_header, ', ' );
	
	return $digest_header;
}

?>

1.Cache

Falls man mehrere Dateien hintereinander abrufen will, muss die Authentifizierung nicht mehrfach ausgeführt werden. Deswegen speichere ich den Header in eine globale Variable zwischen.

2. HEAD-Request

Falls noch keine Daten im Cache (der globalen Variablen) liegen, muss der Auth-Header abgerufen werden. Hierfür reicht ein HEAD-Request, da wir nur den www-authenticate-Teil benötigen.

3. Die einzelnen Teile auslesen

Da der www-authenticate Header so etwas in der Art zurück gibt, muss er in die einzelnen Bestandteile zerlegt werden, damit wir sie weiterverwenden können:

Digest realm="DataFeeds", qop="auth", nonce="1454161314516:f4cc99bf77c5dc681ba17684d1806365", opaque="5005C90E3AD0B8C92403BD3B959E2ADB"

4. Nonce Count

Der Nonce-Count ist letztlich nur ein acht-stelliger Zähler der mit jeder weiteren Anfrage erhöht werden muss.

5. Pfad und Query-Parameter

Hier wird nur der Pfad zur Datei sowie deren Query-Parameter benötigt. Wir zerlegen die $url und bauen sie neu.

6. Client Nonce

Der so genannte „Client Nonce“ ist ein reiner Zufallswert.

7. HA1 erstellen

Wie man sieht setzt sich der HA1 wie folgt zusammen: HA1=MD5(username:realm:password)

Ausgeklammert: falls Amazon einen md5-sess Header verlangen würde, müsste man den gesamten String noch einmal mit MD5 „verschlüsseln“:

HA1=MD5(MD5(username:realm:password):nonce:cnonce)

Da das aber aktuell nicht der Fall ist, lassen wir das an dieser Stelle.

8. HA2 erstellen

Der HA2-Schlüssen setzt sich wie folgt zusammen, wenn der qop-Wert = auth ist:

HA2=MD5(method:digestURI)

Falls qop = auth-int wäre (was bei Amazon aktuell ebenfalls nicht der Fall ist) würde er sich so zusammensetzen:

HA2=MD5(method:digestURI:MD5(entityBody))

9. Response-Array zusammensetzen

Hier werden alle Daten in ein Array zusammengefasst. Achtung: Die Reihenfolge ist wichtig. Werden die Daten in falscher Reihenfolge angegeben wird nichts funktionieren.

10. Den String zusammensetzen

Aus dem Array bauen wir jetzt einen einzigen String. Achtung tricky: man muss beachten, dass einige Parameter Anführungszeichen haben und manche nicht.

Datei herunterladen

Wenn wir den Digest-Header haben, können wir die Anfrage zum Herunterladen stellen. Achtung: auch das ist wieder tricky. Amazon macht intern eine Weiterleitung. Hier wieder eine Beispiel-Funktion dazu:

<?php
/**
 * Get reporting data from one day.
 *
 * @param int $timestamp
 *
 * @return float|\WP_Error
 */
function wtf_api_amazon_report_get_day( $timestamp ) {

	/**
	 * 1. Build the URL
	 */
	$url = sprintf(
		'https://assoc-datafeeds-eu.amazon.com/datafeed/getReport?filename=%s-earnings-report-%s.tsv.gz',
		WTF_AMAZON_ASSOCIATE_TAG,
		date( 'Ymd', $timestamp )
	);


	/**
	 * 2. Fetch the digest header (fetches a new header or use a previous one
	 */
	$digest = wtf_api_amazon_get_digest_header( $url );

	if ( is_wp_error( $digest ) ) {
		return $digest;
	}


	/**
	 * 3. Make request using the digest header.
	 * ATTENTION: Do not follow redirects here
	 */
	$args = array(
		'headers'     => array(
			'Authorization' => $digest,
		),
		'timeout'     => defined( 'DOING_CRON' ) && DOING_CRON ? 60 : 10,
		# do not follow redirection as this will lead to an authorization error
		'redirection' => 0,
	);

	unset( $digest );

	$response = wp_remote_get( $url, $args );

	if ( is_wp_error( $response ) ) {
		return $response;
	}


	/**
	 * 4. Fetch the redirect location
	 */
	$location = wp_remote_retrieve_header( $response, 'location' );

	if ( empty( $location ) ) {
		return new WP_Error( 'amazon_report_get_day', __( 'Could not fetch the location to get the downloadable CSV.', 'wtf' ) );
	}

	$location = esc_url_raw( $location );


	/**
	 * 5. Make request to the redirect location.
	 * For this, delete the redirection and headers-fields
	 *
	 */
	unset( $args['headers'], $args['redirection'] );

	$args['stream'] = true;

	$response = wp_remote_get( $location, $args );

	unset( $location );

	if ( is_wp_error( $response ) ) {
		return $response;
	}

	if ( ! isset( $response['filename'] ) ) {
		return new WP_Error( 'amazon_report_get_day', __( 'No file to stream.', 'wtf' ) );
	}

	if ( ! is_file( $response['filename'] ) ) {
		return new WP_Error( 'amazon_report_get_day', __( 'Could not open CSV file.', 'wtf' ) );
	}

	/**
	 * 6. Do wtf you want with this data...
	 */

	$file_path = $response['filename'];

	unset( $response );

	/**
	 * GZIP open the file
	 */
	$file = gzopen( $file_path, 'r' );

	unset( $file_path );

	if ( false === $file ) {
		return new WP_Error( 'amazon_report_get_day', __( 'Could not read temp CSV file.', 'wtf' ) );
	}

	/**
	 * Fetch CSV
	 */
	$csv = fgetcsv( $file, 0, "\t" );

	if ( ! is_array( $csv ) ) {
		fclose( $file );

		return new WP_Error( 'amazon_report_get_day', __( 'Could not read CSV.', 'allesbeste' ) );
	}


}

?>

1. URL zusammensetzen

Wie weiter oben schon erwähnt ist der erste Parameter der Amazon Associate Tag. Der Zweite ist das Datum im Format YYYYMMDD.

2. Digest Header

Um die Anfrage zu authentifizieren benötigen wir den richtigen Digest-Header (siehe Funktion oben).

3. Request senden

Wir setzen den Digest-Header.

Achtung tricky: der Parameter redirection muss unbedingt auf 0 gestellt werden. Amazon sendet mit dieser Anfrage nämlich nicht die Datei selbst sondern erst einen Redirect. Und hier liegt der Hund begraben. Amazon verarbeitet die Anfrage intern mit einer eigenen API. WordPress (oder curl?) folgt dem Redirect und sendet hier nochmal den Digest-Header mit. Das ist aber falsch. Der Download schlägt fehl.

4. Redirect Location abfangen

Deshalb muss man die redirection selbst auslesen, den Header löschen und dann noch eine Anfrage stellen.

5. Endgültiger Download der Datei

Mit der dritten (und letzten) Anfrage kann nun die Datei heruntergeladen werden.

6. Date Verarbeiten

Die GZIP-Datei wird mithilfe der Funktion gzopen() geöffnet. Danach kann sie z.B. mittels fgetcsv() verarbeitet werden.

Fazit

Wie man sieht muss man beim Abrufen von Daten bei Amazon dreimal einen Request senden um an die endgültigen Daten zu kommen. Allerdings nur beim ersten Request. Bei allen weiteren Anfragen lässt sich ein Request (Anfordern des Digest-Header) sparen.

Tricky ist darüber hinaus, dass der Download der Datei über den Redirect nicht direkt erfolgen kann. Deshalb muss er abgefangen werden.