Wie mache ich ein SPA SEO crawlbar?


143

Ich habe daran gearbeitet, wie ein SPA mithilfe der Anweisungen von Google von Google gecrawlt werden kann . Obwohl es einige allgemeine Erklärungen gibt, konnte ich nirgendwo ein gründlicheres Schritt-für-Schritt-Tutorial mit tatsächlichen Beispielen finden. Nachdem ich dies abgeschlossen habe, möchte ich meine Lösung teilen, damit auch andere sie nutzen und möglicherweise weiter verbessern können.
Ich verwende MVCmit WebapiControllern und Phantomjs auf der Serverseite und Durandal auf der Clientseite mit push-stateaktiviert; Ich verwende Breezejs auch für die Client-Server-Dateninteraktion, die ich dringend empfehle, aber ich werde versuchen, eine ausreichend allgemeine Erklärung zu geben, die auch Menschen hilft, andere Plattformen zu verwenden.


40
In Bezug auf das "Off-Thema" - ein Web-App-Programmierer muss einen Weg finden, wie er seine App für SEO crawlen kann. Dies ist eine Grundvoraussetzung im Web. Dabei geht es nicht um das Programmieren an sich, sondern um das Thema "praktische, beantwortbare Probleme, die nur in der Programmierbranche auftreten", wie unter stackoverflow.com/help/on-topic beschrieben . Es ist ein Problem für viele Programmierer ohne klare Lösungen im gesamten Web. Ich hatte gehofft, anderen zu helfen, und Stunden investiert, um es hier zu beschreiben. Negative Punkte zu motivieren, motiviert mich sicherlich nicht, wieder zu helfen.
strahlend

3
Wenn der Schwerpunkt auf der Programmierung liegt und nicht auf Schlangenöl / Geheimsauce SEO Voodoo / Spam, dann kann es perfekt aktuell sein. Wir mögen auch Selbstantworten, bei denen sie das Potenzial haben, für zukünftige Leser langfristig nützlich zu sein. Dieses Frage-Antwort-Paar scheint beide Tests zu bestehen. (Einige der Hintergrunddetails könnten die Frage besser ausarbeiten, als in die Antwort aufgenommen zu werden, aber das ist ziemlich geringfügig)
Flexo

6
+1, um Abstimmungen zu mildern. Unabhängig davon, ob Q / A besser als Blog-Post geeignet wäre, ist die Frage für Durandal relevant und die Antwort ist gut recherchiert.
RainerAtSpirit

2
Ich bin damit einverstanden, dass SEO heutzutage ein wichtiger Bestandteil des täglichen Lebens der Entwickler ist und definitiv als Thema im Stackoverflow betrachtet werden sollte!
Kim D.

Abgesehen davon , dass Sie den gesamten Prozess selbst implementieren, können Sie SnapSearch snapsearch.io ausprobieren , mit dem dieses Problem im Wesentlichen als Service behoben wird .
CMCDragonkai

Antworten:


121

Bevor Sie beginnen, stellen Sie bitte sicher, dass Sie verstehen, was Google benötigt , insbesondere die Verwendung hübscher und hässlicher URLs. Nun sehen wir uns die Implementierung an:

Client-Seite

Auf der Clientseite haben Sie nur eine einzige HTML-Seite, die über AJAX-Aufrufe dynamisch mit dem Server interagiert. Darum geht es bei SPA. Alle aTags auf der Clientseite werden dynamisch in meiner Anwendung erstellt. Wir werden später sehen, wie diese Links für Googles Bot auf dem Server sichtbar gemacht werden. Jedes dieser aTags muss in der Lage sein, ein pretty URLim hrefTag zu haben, damit der Google-Bot es crawlen kann. Sie möchten nicht, dass das hrefTeil verwendet wird, wenn der Client darauf klickt (obwohl Sie möchten, dass der Server es analysieren kann, werden wir das später sehen), da wir möglicherweise nicht möchten, dass eine neue Seite geladen wird. Nur um einen AJAX-Aufruf zu tätigen und einige Daten in einem Teil der Seite anzuzeigen und die URL über Javascript zu ändern (z. B. mit HTML5 pushstateoder mit Durandaljs). Also haben wir beide einehrefAttribut für Google sowie das, auf onclickdas der Job ausgeführt wird, wenn der Benutzer auf den Link klickt. Da push-stateich jetzt keine #URL verwenden möchte , kann ein typisches aTag folgendermaßen aussehen:
<a href="http://www.xyz.com/#!/category/subCategory/product111" onClick="loadProduct('category','subCategory','product111')>see product111...</a>

"Kategorie" und "Unterkategorie" sind wahrscheinlich andere Ausdrücke wie "Kommunikation" und "Telefone" oder "Computer". und "Laptops" für ein Elektrogerätegeschäft. Offensichtlich würde es viele verschiedene Kategorien und Unterkategorien geben. Wie Sie sehen können, befindet sich der Link direkt auf die Kategorie, Unterkategorie und das Produkt, nicht als zusätzliche Parameter für eine bestimmte "Store" -Seite wie z http://www.xyz.com/store/category/subCategory/product111. Das liegt daran, dass ich kürzere und einfachere Links bevorzuge. Dies bedeutet, dass es keine Kategorie mit demselben Namen wie eine meiner "Seiten" geben wird, dh "
Ich werde nicht darauf eingehen, wie man die Daten über AJAX (das onclickTeil) lädt , sie auf Google sucht, es gibt viele gute Erklärungen. Das einzig Wichtige, das ich hier erwähnen möchte, ist, dass wenn der Benutzer auf diesen Link klickt, die URL im Browser folgendermaßen aussehen soll:
http://www.xyz.com/category/subCategory/product111. Und diese URL wird nicht an den Server gesendet! Denken Sie daran, dies ist ein SPA, in dem die gesamte Interaktion zwischen dem Client und dem Server über AJAX erfolgt, überhaupt keine Links! Alle "Seiten" werden auf der Clientseite implementiert, und die unterschiedliche URL ruft den Server nicht auf (der Server muss wissen, wie mit diesen URLs umgegangen wird, wenn sie als externe Links von einer anderen Site zu Ihrer Site verwendet werden.) Wir werden das später auf der Serverseite sehen. Nun, das wird von Durandal wunderbar gehandhabt. Ich kann es nur empfehlen, aber Sie können diesen Teil auch überspringen, wenn Sie andere Technologien bevorzugen. Wenn Sie sich dafür entscheiden und wie ich auch MS Visual Studio Express 2012 für das Web verwenden, können Sie das Durandal Starter Kit installieren und dort etwa Folgendesshell.js verwenden:

define(['plugins/router', 'durandal/app'], function (router, app) {
    return {
        router: router,
        activate: function () {
            router.map([
                { route: '', title: 'Store', moduleId: 'viewmodels/store', nav: true },
                { route: 'about', moduleId: 'viewmodels/about', nav: true }
            ])
                .buildNavigationModel()
                .mapUnknownRoutes(function (instruction) {
                    instruction.config.moduleId = 'viewmodels/store';
                    instruction.fragment = instruction.fragment.replace("!/", ""); // for pretty-URLs, '#' already removed because of push-state, only ! remains
                    return instruction;
                });
            return router.activate({ pushState: true });
        }
    };
});

Hier sind einige wichtige Dinge zu beachten:

  1. Die erste Route (mit route:'') ist für die URL, die keine zusätzlichen Daten enthält, d http://www.xyz.com. H. Auf dieser Seite laden Sie allgemeine Daten mit AJAX. Auf adieser Seite befinden sich möglicherweise überhaupt keine Tags. Sie sollten das folgende Tag hinzufügen, damit der Google-Bot weiß, was damit zu tun ist :
    <meta name="fragment" content="!">. Mit diesem Tag transformiert Googles Bot die URL, zu www.xyz.com?_escaped_fragment_=der wir später sehen werden.
  2. Die "Über" -Route ist nur ein Beispiel für einen Link zu anderen "Seiten", die Sie möglicherweise in Ihrer Webanwendung wünschen.
  3. Der schwierige Teil ist nun, dass es keine Kategorie-Route gibt und es möglicherweise viele verschiedene Kategorien gibt, von denen keine eine vordefinierte Route hat. Hier mapUnknownRouteskommt es ins Spiel. Es ordnet diese unbekannten Routen der 'Store'-Route zu und entfernt auch alle'! ' von der URL, falls es sich um eine pretty URLvon Googles Suchmaschine generierte handelt. Die 'store'-Route nimmt die Informationen in der' fragment'-Eigenschaft und führt den AJAX-Aufruf aus, um die Daten abzurufen, anzuzeigen und die URL lokal zu ändern. In meiner Anwendung lade ich nicht für jeden solchen Aufruf eine andere Seite. Ich ändere nur den Teil der Seite, auf dem diese Daten relevant sind, und ändere auch die URL lokal.
  4. Beachten Sie, pushState:truewas Durandal anweist, Push-Status-URLs zu verwenden.

Dies ist alles, was wir auf der Client-Seite brauchen. Es kann auch mit Hash-URLs implementiert werden (in Durandal entfernen Sie einfach die pushState:truedafür). Der komplexere Teil (zumindest für mich ...) war der Serverteil:

Serverseite

Ich benutze MVC 4.5auf der Serverseite mit WebAPIControllern. Der Server muss tatsächlich drei Arten von URLs verarbeiten: die von Google generierten - sowohl prettyals uglyauch eine "einfache" URL mit demselben Format wie die im Browser des Clients angezeigte. Schauen wir uns an, wie das geht:

Hübsche und einfache URLs werden vom Server zunächst so interpretiert, als würde versucht, auf einen nicht vorhandenen Controller zu verweisen. Der Server sieht so etwas wie http://www.xyz.com/category/subCategory/product111und sucht nach einem Controller namens 'category'. Daher web.configfüge ich die folgende Zeile hinzu, um diese an einen bestimmten Fehlerbehandlungscontroller umzuleiten:

<customErrors mode="On" defaultRedirect="Error">
    <error statusCode="404" redirect="Error" />
</customErrors><br/>

Dadurch wird die URL in Folgendes umgewandelt : http://www.xyz.com/Error?aspxerrorpath=/category/subCategory/product111. Ich möchte, dass die URL an den Client gesendet wird, der die Daten über AJAX lädt. Der Trick hier besteht darin, den Standard-Index-Controller so aufzurufen, als würde er nicht auf einen Controller verweisen. Dazu füge ich der URL vor allen Parametern 'category' und 'subCategory' einen Hash hinzu . Die Hash-URL erfordert keinen speziellen Controller außer dem Standard-Index-Controller. Die Daten werden an den Client gesendet, der dann den Hash entfernt und die Informationen nach dem Hash verwendet, um die Daten über AJAX zu laden. Hier ist der Code des Fehlerbehandlungs-Controllers:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;

using System.Web.Routing;

namespace eShop.Controllers
{
    public class ErrorController : ApiController
    {
        [HttpGet, HttpPost, HttpPut, HttpDelete, HttpHead, HttpOptions, AcceptVerbs("PATCH"), AllowAnonymous]
        public HttpResponseMessage Handle404()
        {
            string [] parts = Request.RequestUri.OriginalString.Split(new[] { '?' }, StringSplitOptions.RemoveEmptyEntries);
            string parameters = parts[ 1 ].Replace("aspxerrorpath=","");
            var response = Request.CreateResponse(HttpStatusCode.Redirect);
            response.Headers.Location = new Uri(parts[0].Replace("Error","") + string.Format("#{0}", parameters));
            return response;
        }
    }
}


Aber was ist mit den hässlichen URLs ? Diese werden vom Google-Bot erstellt und sollten einfachen HTML-Code zurückgeben, der alle Daten enthält, die der Benutzer im Browser sieht. Dafür benutze ich Phantomjs . Phantom ist ein kopfloser Browser, der das tut, was der Browser auf der Clientseite tut - aber auf der Serverseite. Mit anderen Worten, Phantom weiß (unter anderem), wie man eine Webseite über eine URL abruft, analysiert, einschließlich des gesamten darin enthaltenen Javascript-Codes (sowie des Abrufs von Daten über AJAX-Aufrufe), und gibt Ihnen den reflektierten HTML-Code zurück das DOM. Wenn Sie MS Visual Studio Express verwenden, möchten viele Phantom über diesen Link installieren .
Aber zuerst, wenn eine hässliche URL an den Server gesendet wird, müssen wir sie abfangen. Zu diesem Zweck habe ich dem Ordner 'App_start' die folgende Datei hinzugefügt:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;

namespace eShop.App_Start
{
    public class AjaxCrawlableAttribute : ActionFilterAttribute
    {
        private const string Fragment = "_escaped_fragment_";

        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            var request = filterContext.RequestContext.HttpContext.Request;

            if (request.QueryString[Fragment] != null)
            {

                var url = request.Url.ToString().Replace("?_escaped_fragment_=", "#");

                filterContext.Result = new RedirectToRouteResult(
                    new RouteValueDictionary { { "controller", "HtmlSnapshot" }, { "action", "returnHTML" }, { "url", url } });
            }
            return;
        }
    }
}

Dies wird von 'filterConfig.cs' auch in 'App_start' aufgerufen:

using System.Web.Mvc;
using eShop.App_Start;

namespace eShop
{
    public class FilterConfig
    {
        public static void RegisterGlobalFilters(GlobalFilterCollection filters)
        {
            filters.Add(new HandleErrorAttribute());
            filters.Add(new AjaxCrawlableAttribute());
        }
    }
}

Wie Sie sehen können, leitet 'AjaxCrawlableAttribute' hässliche URLs an einen Controller mit dem Namen 'HtmlSnapshot' weiter. Hier ist dieser Controller:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace eShop.Controllers
{
    public class HtmlSnapshotController : Controller
    {
        public ActionResult returnHTML(string url)
        {
            string appRoot = Path.GetDirectoryName(AppDomain.CurrentDomain.BaseDirectory);

            var startInfo = new ProcessStartInfo
            {
                Arguments = String.Format("{0} {1}", Path.Combine(appRoot, "seo\\createSnapshot.js"), url),
                FileName = Path.Combine(appRoot, "bin\\phantomjs.exe"),
                UseShellExecute = false,
                CreateNoWindow = true,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                RedirectStandardInput = true,
                StandardOutputEncoding = System.Text.Encoding.UTF8
            };
            var p = new Process();
            p.StartInfo = startInfo;
            p.Start();
            string output = p.StandardOutput.ReadToEnd();
            p.WaitForExit();
            ViewData["result"] = output;
            return View();
        }

    }
}

Das zugehörige viewist sehr einfach, nur eine Codezeile:
@Html.Raw( ViewBag.result )
Wie Sie im Controller sehen können, lädt Phantom eine Javascript-Datei, die createSnapshot.jsunter einem von mir erstellten Ordner namens benannt ist seo. Hier ist diese Javascript-Datei:

var page = require('webpage').create();
var system = require('system');

var lastReceived = new Date().getTime();
var requestCount = 0;
var responseCount = 0;
var requestIds = [];
var startTime = new Date().getTime();

page.onResourceReceived = function (response) {
    if (requestIds.indexOf(response.id) !== -1) {
        lastReceived = new Date().getTime();
        responseCount++;
        requestIds[requestIds.indexOf(response.id)] = null;
    }
};
page.onResourceRequested = function (request) {
    if (requestIds.indexOf(request.id) === -1) {
        requestIds.push(request.id);
        requestCount++;
    }
};

function checkLoaded() {
    return page.evaluate(function () {
        return document.all["compositionComplete"];
    }) != null;
}
// Open the page
page.open(system.args[1], function () { });

var checkComplete = function () {
    // We don't allow it to take longer than 5 seconds but
    // don't return until all requests are finished
    if ((new Date().getTime() - lastReceived > 300 && requestCount === responseCount) || new Date().getTime() - startTime > 10000 || checkLoaded()) {
        clearInterval(checkCompleteInterval);
        var result = page.content;
        //result = result.substring(0, 10000);
        console.log(result);
        //console.log(results);
        phantom.exit();
    }
}
// Let us check to see if the page is finished rendering
var checkCompleteInterval = setInterval(checkComplete, 300);

Ich möchte mich zuerst bei Thomas Davis für die Seite bedanken, auf der ich den Basiscode erhalten habe :-).
Sie werden hier etwas Seltsames bemerken: Phantom lädt die Seite so lange neu, bis die checkLoaded()Funktion true zurückgibt. Warum ist das so? Dies liegt daran, dass mein spezifisches SPA mehrere AJAX-Aufrufe ausführt, um alle Daten abzurufen und im DOM auf meiner Seite zu platzieren. Das Phantom kann nicht wissen, wann alle Aufrufe abgeschlossen sind, bevor es mir die HTML-Reflektion des DOM zurückgibt. Was ich hier getan habe, ist <span id='compositionComplete'></span>, dass ich nach dem letzten AJAX-Aufruf ein hinzufüge , sodass ich weiß, dass das DOM abgeschlossen ist, wenn dieses Tag vorhanden ist. Ich mache das als Reaktion auf Durandals compositionCompleteEreignis, siehe hierfür mehr. Wenn dies nicht innerhalb von 10 Sekunden geschieht, gebe ich auf (es sollte höchstens eine Sekunde dauern). Der zurückgegebene HTML-Code enthält alle Links, die der Benutzer im Browser sieht. Das Skript funktioniert nicht ordnungsgemäß, da die <script>im HTML-Snapshot vorhandenen Tags nicht auf die richtige URL verweisen. Dies kann auch in der Javascript-Phantomdatei geändert werden, aber ich denke nicht, dass dies notwendig ist, da der HTML-Snapshort nur von Google verwendet wird, um die aLinks abzurufen und kein Javascript auszuführen. diese Links tun Bezug ziemlich URL, und wenn die Tat, wenn Sie versuchen , das HTML - Snapshot in einem Browser zu sehen, werden Sie JavaScript - Fehler , aber alle Links bekommen ordnungsgemäß funktionieren und Sie auf den Server direkt wieder mit einem hübschen URL dieses Mal die voll funktionsfähige Seite bekommen.
Das ist es. Jetzt weiß der Server, wie man mit hübschen und hässlichen URLs umgeht, wobei der Push-Status sowohl auf dem Server als auch auf dem Client aktiviert ist. Alle hässlichen URLs werden mit Phantom gleich behandelt, sodass für jeden Anruftyp kein separater Controller erstellt werden muss.
Eine Sache, die Sie vielleicht lieber ändern möchten, ist nicht, einen allgemeinen Aufruf von "Kategorie / Unterkategorie / Produkt" zu tätigen, sondern einen "Shop" hinzuzufügen, damit der Link ungefähr so ​​aussieht : http://www.xyz.com/store/category/subCategory/product111. Dadurch wird das Problem in meiner Lösung vermieden, dass alle ungültigen URLs so behandelt werden, als würden sie tatsächlich an den 'Index'-Controller aufgerufen, und ich nehme an, dass diese dann innerhalb des' Store'-Controllers behandelt werden können, ohne dass die web.configoben gezeigte hinzugefügt wird .


Ich habe eine kurze Frage, ich glaube, ich habe das jetzt zum Laufen gebracht, aber wenn ich meine Website bei Google einreiche und Links zu Google, Site Maps usw. gebe, muss ich google mysite.com/# geben ! oder nur mysite.com und google fügen das entkommene_fragment hinzu, weil ich es im meta-tag habe?
Ccorrin

ccorrin - nach meinem besten wissen müssen Sie google nichts geben; Der Bot von Google findet Ihre Website und sucht darin nach hübschen URLs (vergessen Sie nicht, auf der Startseite auch das Meta-Tag hinzuzufügen, da es möglicherweise keine URLs enthält). Die hässliche URL mit dem entkommenen_Fragment wird immer nur von Google hinzugefügt - Sie sollten sie niemals selbst in Ihre HTML-Dateien einfügen. und danke für die unterstützung :-)
strahlend

danke Björn & Sandra :-) Ich arbeite an einer besseren Version dieses Dokuments, die auch Informationen zum Zwischenspeichern von Seiten enthält, um den Prozess zu beschleunigen und dies bei der allgemeineren Verwendung zu tun, bei der die URL die enthält Name des Controllers; Ich werde es veröffentlichen, sobald es fertig ist
strahlend

Dies ist eine großartige Erklärung !!. Ich habe es implementiert und arbeite wie ein Zauber in meiner localhost-Devbox. Das Problem besteht bei der Bereitstellung auf Azure-Websites, da die Website einfriert und nach einiger Zeit ein 502-Fehler angezeigt wird. Haben Sie eine Idee, wie Sie Phantomjs in Azure bereitstellen können? ... Danke ( testypv.azurewebsites.net/?_escaped_fragment_=home/about )
yagopv

Ich habe keine Erfahrung mit Azure-Websites, aber ich denke, dass der Überprüfungsprozess für das vollständige Laden der Seite möglicherweise nie abgeschlossen ist, sodass der Server weiterhin versucht, die Seite immer wieder ohne Erfolg neu zu laden. Vielleicht liegt dort das Problem (obwohl diese Überprüfungen zeitlich begrenzt sind und möglicherweise nicht vorhanden sind)? versuche 'return true' zu setzen; als erste Zeile in 'checkLoaded ()' und sehen, ob es einen Unterschied macht.
strahlend


4

Hier ist ein Link zu einer Screencast-Aufnahme aus meiner Ember.js-Schulungsklasse, die ich am 14. August in London veranstaltet habe. Es beschreibt eine Strategie sowohl für Ihre clientseitige Anwendung als auch für Ihre serverseitige Anwendung und zeigt live, wie die Implementierung dieser Funktionen Ihrer JavaScript-Single-Page-App auch für Benutzer mit deaktiviertem JavaScript eine angemessene Verschlechterung verleiht .

Es verwendet PhantomJS, um das Crawlen Ihrer Website zu unterstützen.

Kurz gesagt, die erforderlichen Schritte sind:

  • Haben Sie eine gehostete Version der Webanwendung, die Sie crawlen möchten? Diese Site muss ALLE Daten enthalten, die Sie in der Produktion haben
  • Schreiben Sie eine JavaScript-Anwendung (PhantomJS Script), um Ihre Website zu laden
  • Fügen Sie index.html (oder „/“) zur Liste der zu crawlenden URLs hinzu
    • Pop die erste URL, die der Crawling-Liste hinzugefügt wurde
    • Seite laden und DOM rendern
    • Suchen Sie auf der geladenen Seite nach Links, die auf Ihre eigene Website verweisen (URL-Filterung).
    • Fügen Sie diesen Link einer Liste von "crawlbaren" URLs hinzu, falls diese noch nicht gecrawlt wurden
    • Speichern Sie das gerenderte DOM in einer Datei im Dateisystem, entfernen Sie jedoch zuerst ALLE Skript-Tags
    • Erstellen Sie am Ende eine Sitemap.xml-Datei mit den gecrawlten URLs

Sobald dieser Schritt abgeschlossen ist, liegt es an Ihrem Backend, die statische Version Ihres HTML-Codes als Teil des Noscript-Tags auf dieser Seite bereitzustellen. Auf diese Weise können Google und andere Suchmaschinen jede einzelne Seite Ihrer Website crawlen, obwohl Ihre App ursprünglich eine Einzelseiten-App ist.

Link zum Screencast mit allen Details:

http://www.devcasts.io/p/spas-phantomjs-and-seo/#


0

Sie können Ihren eigenen Dienst zum Vorrendern Ihres SPA mit dem Dienst Prerender verwenden oder erstellen. Sie können es auf seiner Website prerender.io und in seinem Github-Projekt überprüfen (es verwendet PhantomJS und rendert Ihre Website für Sie).

Es ist sehr einfach zu beginnen. Sie müssen nur Crawler-Anforderungen an den Dienst umleiten, und sie erhalten das gerenderte HTML.


2
Während dieser Link die Frage beantworten kann, ist es besser, die wesentlichen Teile der Antwort hier aufzunehmen und den Link als Referenz bereitzustellen. Nur-Link-Antworten können ungültig werden, wenn sich die verknüpfte Seite ändert. - Von der Überprüfung
timgeb

2
Du hast recht. Ich habe meinen Kommentar aktualisiert ... Ich hoffe jetzt ist es genauer.
gabrielperales

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.