郑重声明,此文只是提供了一个大概思路,程序代码还需要优化,思路大概正确,但是应用生产环境需要小心。
顺便推荐下自己广告业余项目,一个码农专用的搜索引擎
(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慢一点点,如果缓存做的好,这点速度可以忽略。
网友评论
我的报错:Unhandled promise rejection ReferenceError: Can't find variable: Promise
配置了polyfill(依然不行),可能是我配错了。