SPA网站SEO完美解决方案

96
北方蜘蛛
2017.02.24 21:09* 字数 1251

郑重声明,此文只是提供了一个大概思路,程序代码还需要优化,思路大概正确,但是应用生产环境需要小心。

顺便推荐下自己广告业余项目,一个码农专用的搜索引擎
(https://www.1024ma.com/so/juejin?keyword=deno)

单页面SEO一直是让人比较头疼的问题,为了解决这个问题在网上搜到了大概几种方式,最终受到启发得出一个比较优秀的方案。

先说说网上的办法,有的是为了兼容谷歌,用的是#!的方式来给搜索引擎抓取,还有提交sitemap种种麻烦。

还有一种比较蛋疼的是在开发一个服务端渲染页面的应用,根据爬虫UA让nginx代理到后端渲染页面的服务器,这种似乎可以解决问题,然而比较蛋疼的你需要维护两套系统,给开发和维护都增加额外的工作非常这不建议。

下面是我个人认为最优的方案,简单来说,需要借助phantomjs,好像被这个问题困扰的码农似乎早就知道这个东西,不过我的用法与他们略有不同。当爬虫抓取页面,那我们就把他带到phantomjs渲染好的静态html,注意,这个渲染的方法是抓取原来SPA页面的代码并运行JS,生成一个与SPA一模一样的网站,并且url(要使用这种模式 html5 history api)保持与spa完全一致。是的就这么简单就解决了,代价是与需要腾出来一个服务器搭建一个nginx作为web服务器。

随着蜘蛛爬取的次数越来越多,随之产生的静态文件也越来多,不要怕,你只要硬盘足够大就可以了,弱这是你的网站文件已经达到已经的数量级,那时我想搜索引擎已经有了原生的解决方案,我想这不会等太久。当然纯的静态文件有一个弊端,就是网站改版之后会产生页面与SPA不一致的现象,从而可能被搜索降级,好在解决这个问题非常的简单,那就是每一个更新结点,我们写个任务定时更新所有的静态页面,就算你页面很多也没什么问题,跑个几个小时也就差不多了,毕竟网站的大规模改版并不是很频繁的事情。如果想要完全避免这个问题,也是有办法的,那就是从爬虫开始爬的时候使用phantomjs每一次都重新生成页面,也就解决了不一致的问题。然而虽然是解决了这个问题,又带来了另一个问题,从新生成页面是相对耗时的,访问速度下降,对seo多少产生了一点影响。所以还是推荐手动先生成的办法。

另外关于已经收录的网页,当用户从搜索引擎点过来的时候,如果是spa页面的话,ajax重新加载大量JS逻辑代码,并执行spa路由调取相应的页面,也是相对耗时的。此时我们可以通过入口的nginx转发到phantomjs的服务器去读那些已经生成好的html,那速度就快很多了。

到此这个问题就真的得到解决了。前端开发小伙伴就可以无痛愉快的开发写写单页面应用了。

流程图

流程图

入口机器Nginx

server {
        listen       80;
        server_name  www.abc.com ;
        location / {
            proxy_set_header  Host            $host:$proxy_port;
            proxy_set_header  X-Real-IP       $remote_addr;
            proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
            #web server
           #当UA里面含有Baiduspider的时候,流量Nginx以反向代理的形式,将流量传递给spider_server
            if ($http_user_agent ~* "Baidu") {
                   proxy_pass http://www.seo.com:82;
             }   
           proxy_pass http://www.aba.com.8:8080;  
        }
    }

web nginx

server {
        listen       8080;
        server_name  www.abc.com ;
        location / {
            root  E:\web;
            index  index.html index.htm;
            try_files $uri $uri/ /index.html;
                
        }
     location /api {
            proxy_pass http://192.1681.9:1335;  
        }
    }

SEO nginx

listen       82;
        server_name  www.seo.com ;
        location / {
           #先访问自己的静态页面如果有直接显示
            root  E:\seo;
            index  index.html index.htm;
            try_files  $uri $uri/index.html $uri.html @mongrel;
        }
        #如果没有文件就把url发给node
        location @mongrel{
             proxy_pass    http://192.1681.9:3000;
        }
    }

node express


// ExpressJS调用方式
var express = require('express');
var app = express();
var fs = require('fs');
var path = require("path");
var mkdirsSync = function(dirname) {
    //console.log(dirname);
    if (fs.existsSync(dirname)) {
        return true;
    } else {
        if (mkdirsSync(path.dirname(dirname))) {
            fs.mkdirSync(dirname);
            return true;
        }
    }
};
// 引入NodeJS的子进程模块
var child_process = require('child_process');
app.get('*', function(req, res) {
    // 完整web服务器URL
    var url = req.protocol + '://' + '192.168.1.8:8080' + req.originalUrl;
    //console.log(req.originalUrl);
    var filePath = "";
    var fileName = "";
    if (req.originalUrl == '/') {
        fileName = 'index';
        filePath = "e:/seo/";
    } else {
        var pathArray = req.originalUrl.split('/');
        fileName = pathArray[pathArray.length - 1];
        filePath = "e:/seo/" + req.originalUrl.replace(fileName, "") + '/';
    }
    console.log(filePath + "....");
    // 预渲染后的页面字符串容器
    var content = '';
    // 开启一个phantomjs子进程
    //应当先蒋策seo服务器是否存在改文件如果有直接跳转
    var phantom = child_process.spawn('phantomjs', ['spider.js', url]);
    // 设置stdout字符编码
    phantom.stdout.setEncoding('utf8');
    // 监听phantomjs的stdout,并拼接起来
    phantom.stdout.on('data', function(data) {
        content += data.toString();
    });
    // 监听子进程退出事件
    phantom.on('exit', function(code) {
        switch (code) {
            case 1:
                console.log('加载失败');
                res.send('加载失败');
                break;
            case 2:
                console.log('加载超时: ' + url);
                res.send(content);
                break;
            default:
                var w_data = content;
                var w_data = new Buffer(w_data);
                /**
                 * filename, 必选参数,文件名
                 * data, 写入的数据,可以字符或一个Buffer对象
                 * [options],flag,mode(权限),encoding
                 * callback 读取文件后的回调函数,参数默认第一个err,第二个data 数据
                 */
                mkdirsSync(filePath, 0777);
                console.log(filePath + fileName + '.html');
                fs.writeFile(filePath + fileName + '.html', w_data, { flag: 'w' }, function(err) {
                    if (err) {
                        console.error(err);
                    } else {
                        console.log('写入成功');
                    }
                });
                res.send(content);
                break;
        }
    });
});
app.listen(3000);

phantomjs

/*global phantom*/
"use strict";

// 单个资源等待时间,避免资源加载后还需要加载其他资源
var resourceWait = 500;
var resourceWaitTimer;

// 最大等待时间
var maxWait = 5000;
var maxWaitTimer;

// 资源计数
var resourceCount = 0;

// PhantomJS WebPage模块
var page = require('webpage').create();

// NodeJS 系统模块
var system = require('system');

// 从CLI中获取第二个参数为目标URL
var url = system.args[1];

// 设置PhantomJS视窗大小
page.viewportSize = {
    width: 1280,
    height: 1014
};

// 获取镜像
var capture = function(errCode) {

    // 外部通过stdout获取页面内容
    console.log(page.content);

    // 清除计时器
    clearTimeout(maxWaitTimer);

    // 任务完成,正常退出
    phantom.exit(errCode);

};

// 资源请求并计数
page.onResourceRequested = function(req) {
    resourceCount++;
    clearTimeout(resourceWaitTimer);
};

// 资源加载完毕
page.onResourceReceived = function(res) {

    // chunk模式的HTTP回包,会多次触发resourceReceived事件,需要判断资源是否已经end
    if (res.stage !== 'end') {
        return;
    }

    resourceCount--;

    if (resourceCount === 0) {

        // 当页面中全部资源都加载完毕后,截取当前渲染出来的html
        // 由于onResourceReceived在资源加载完毕就立即被调用了,我们需要给一些时间让JS跑解析任务
        // 这里默认预留500毫秒
        resourceWaitTimer = setTimeout(capture, resourceWait);

    }
};

// 资源加载超时
page.onResourceTimeout = function(req) {
    resouceCount--;
};

// 资源加载失败
page.onResourceError = function(err) {
    resourceCount--;
};

// 打开页面
page.open(url, function(status) {

    if (status !== 'success') {

        phantom.exit(1);

    } else {

        // 当改页面的初始html返回成功后,开启定时器
        // 当到达最大时间(默认5秒)的时候,截取那一时刻渲染出来的html
        maxWaitTimer = setTimeout(function() {

            capture(2);

        }, maxWait);

    }

});

本机测试本机测试通过 可以使用postman等其他工具模拟爬虫

最近更新

最近发现本文还是有一定的浏览量,不过我们自己的项目还是使用一种更为简单的办法,就是启动另一个express应用,使用axios读取后端api,将页面从服务端渲染出来,然后利用nginx 判断user-agent 是否包含spider字符串,来转发到相同路径的 express应用,经过几个月的沉淀,从百度收录来看大概提高了2倍的量。这样做的好处是,可以在express应用的模板中简化html代码量,不会太多spa中过多的冗余标签,属性,使代码更简洁,对seo更友好,也有一定的劣势就是访问速度上可能会比真正的静态html慢一点点,如果缓存做的好,这点速度可以忽略。

笔记
Web note ad 1