一、爬虫目标
本次爬虫程序的主要目标有:
1.每日自动爬取股票业绩预告(包括年报、一季报、中报、三季报);
2.爬取历史数据;
3.每日自动增量更新,免人工维护;
4.业绩预告分类分级,按照行业,概念可以查询;
5.数据条件查询,列表展示,颜色提示;
二、难点
众所周知,同花顺反爬技术还是非常厉害的,算法加密,请求频率控制等已经阻拦了95%的爬虫,本框架采用go语言编写的其中一个原因就是使用go语言更方便进行请求频率控制,更加简洁,当然更重要的原因是go语言编译生成程序的加密安全性更好,还有部署方便等优点。在本框架中,已经解决了同花顺加密hexinv,和请求频率问题。千万注意控制请求频率,否则会被封IP,我有被腾讯、新浪、东财、同花顺封IP的经历,后续编写stockgo框架后,基本没有发生过。下文会有相关代码片段。
三、效果图
效果图
选一个业绩预增的新能源牛股,效果就更加明显,2020-09-02我们的选股系统发出了买入信号(选股系统后续再讲,有兴趣的朋友可以到wx)
天赐材料
同花顺业绩预告
四、上代码
1.代码结构
代码结构非常简单:
config.go 配置文件解析器
ths_yjyg_parse.go 网页爬取及解析器
ths_yjyg_task.go 业绩预告任务
run目录下面是程序主入口(yjyg_main)
aes.min.js是同花顺hexinv生成程序
使用go调用js生成hexinv
conf下面的config.yaml 配置文件
另外还有model目录中的数据库对象文件
2.model代码
package model
import "time"
// 同花顺业绩预告
type ThsYjyg struct {
ID int `gorm:"primary_key" orm:"column(id);auto"`
Code string `gorm:"index:code_date_jidu,unique;column:code;type:varchar(20);not null"` // 股票代码
StockName string `gorm:"column:stock_name;type:varchar(100);not null"`
Date time.Time `gorm:"index:code_date_jidu,unique;column:date;type:date;not null"` // 日期
Jidu int `gorm:"index:code_date_jidu,unique;column:jidu;type:int(4)"` //季度 1.一季报;2中报;3三季报;4年报
Year int `gorm:"column:year;type:int(8)"` //年
//YgType int `gorm:"index:code_date_type,unique;column:yg_type;type:int(4);not null"` //
YjLevel int `gorm:"column:yj_level;type:int(8);comment:业绩预告级别"`
YjLevelLabel string `gorm:"column:yj_level_label;type:varchar(100);not null;comment:业绩预估类型"`
Sumary string `gorm:"column:sumary;type:varchar(300);not null;comment:业绩预告摘要"`
JlrChange float64 `gorm:"column:jlr_change;type:decimal(19,2);comment:净利润变动幅度%"`
LastJlr float64 `gorm:"column:last_jlr;type:decimal(19,2);comment:上年同期净利润"`
Created time.Time
}
3.网页解析器
网页爬取和解析很简单,我们通过分析同花顺原网页调用,发现每次是通过异步请求获取一段html,页面再动态渲染,参数只有两个,一个是季报日期,一个是分页参数,代码如下,网页解析使用了goquery,非常优秀的开源网页解析工具。
package yjyg
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"stockgo/core/global"
"stockgo/core/util"
"stockgo/model"
"go.uber.org/zap"
"strconv"
"strings"
"time"
)
const URL = "https://data.10jqka.com.cn/ajax/yjyg/date/%s/board/ALL/field/enddate/order/desc/page/%d/ajax/1/free/1/"
var YJTYPES = map[string]int{
"预计增亏": 11,
"业绩大幅下降": 12,
"预计续亏": 14,
"业绩预亏": 16,
"业绩预降": 18, // 小于20,利空消息
"不确定": 20,
"预计减亏": 21,
"业绩预盈": 25, //这个是中性的
"预计扭亏": 31, //30 以上是利好消息
"业绩预增": 34,
"业绩大幅上升": 35,
}
func GetAndParse(date string, pageNo int, year int ,jidu int, mt *global.HttpLimit) *YjygInfo {
url := fmt.Sprintf(URL, date, pageNo)
global.LOG.Info("业绩预告爬取地址: ", zap.String("URL", url))
doc := util.GetHtmlWithHead(url, util.GetThsHeader(), mt) //获取同花顺hexinv
if doc == nil {
return nil
}
return ParseData(doc, pageNo, year, jidu)
}
func ParseData(doc *goquery.Document, pageNo int, year int ,jidu int) *YjygInfo {
pageInfo := getText(doc.Find("body > div.m-page.J-ajax-page > span"))
// 存在没有页码的情况,第一页不满的情况
total:=1
if pageInfo != ""{
arr := strings.Split(pageInfo, "/")
total, _ = strconv.Atoi(arr[1])
}
yjygInfo := YjygInfo{
PageNo: pageNo,
Total: total,
List: make([]model.ThsYjyg, 0),
}
doc.Find("body > table > tbody > tr").Each(func(i int, selection *goquery.Selection) {
yjgg := model.ThsYjyg{
Code: util.FullCode(getText(selection.Find(" td:nth-child(2) > a"))),
StockName: getText(selection.Find(" td:nth-child(3) > a")),
YjLevelLabel: getText(selection.Find(" td:nth-child(4) > span")),
Sumary: getText(selection.Find("td:nth-child(5) > a")),
JlrChange: util.ParseFloatTwo(getText(selection.Find("td:nth-child(6)"))),
Created: time.Now(),
Year: year,
Jidu: jidu,
}
yjgg.Date, _ = time.Parse(util.Date_Format, selection.Find("td.tc.cur").Text())
yjgg.YjLevel = YJTYPES[yjgg.YjLevelLabel]
lastJlrStr := getText(selection.Find("td:nth-child(7)"))
yjgg.LastJlr = getLastJlr(lastJlrStr)
yjygInfo.List = append(yjygInfo.List, yjgg)
})
return &yjygInfo
}
func getText(selection *goquery.Selection) string {
temp := util.DecodeToGBK(selection.Text()) //处理数据编码
temp = strings.TrimSpace(temp) //去除多余空格
//temp = strings.ReplaceAll(temp,"聽"," ") //替换转码导致的乱码,空格变成了聽
return temp
}
func getLastJlr(jlr string) float64 {
if jlr == "-" {
return 0.0
}
if strings.Contains(jlr, "亿") {
jlr = strings.ReplaceAll(jlr, "亿", "")
return util.ParseFloatTwo(jlr) * 10000
} else if strings.Contains(jlr, "万") {
jlr = strings.ReplaceAll(jlr, "万", "")
return util.ParseFloatTwo(jlr)
}
return 0.0
}
type YjygInfo struct {
PageNo int
Total int
List []model.ThsYjyg
}
获取同花顺hexinv请求头部参数代码:
解决了绝大多数用户爬取同花顺的难题,使用基于chorme Driver 方式爬取太不方便了。
package util
import (
"github.com/robertkrimen/otto"
"io/ioutil"
)
//同花顺util
// 使用js生成核心V参数
func getHexinV() string {
//伪造hexinv参数
filePath := "aes.min.js"
//先读入文件内容
bytes, err := ioutil.ReadFile(filePath)
if err != nil {
panic(err)
}
vm := otto.New()
_, err = vm.Run(string(bytes))
if err != nil {
panic(err)
}
value, err := vm.Call("v", nil, "")
if err != nil {
panic(err)
}
return value.String()
}
// 获取一个同花顺头部,每次自动生成一个hexin-v
func GetThsHeader() map[string]string {
header := make(map[string]string)
hexinv := getHexinV()
header["hexin-v"] = hexinv
header["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36"
return header
}
4.爬取任务
任务接口
type StockTaskHandler interface {
GetName() string
DoTask()
}
业绩预告爬取任务
package yjyg
import (
"stockgo/core/global"
"stockgo/core/util"
"stockgo/model"
"go.uber.org/zap"
"strconv"
"time"
)
var dateSuffixMap = map[int]string{
1: "-03-31",
2: "-06-30",
3: "-09-30",
4: "-12-31",
}
//业绩预告
// 一季报4月15号之前发布 ,实际有4-30
//半年度业绩预告:报告期在当年7月15日前; 实际8-27
//第三季度业绩预告:报告期为当年10月15日之前 实际11-25 ,大部分在10-30
//年度业绩预告:报告期在次年1月31日前; 4-30 ;
type ThsYjygTask struct {
Jidu int // 一季报1、中报2、三季报3、年报4
Year int // 年,默认使用当年逻辑
Mode string // 模式[ALL 全量,UPDATE 增量],爬历史数据用ALL模式
Limit *global.HttpLimit
}
func (t *ThsYjygTask) GetName() string {
return "同花顺业绩预告爬取任务"+t.getDate()
}
func (t ThsYjygTask) DoTask() {
if t.Year ==0 {
t.Year=time.Now().Year()
}
//部分预报不需要一直查询
if t.Mode != "ALL" && !needRun(t.Jidu, t.Year) {
return
}
dateStr := t.getDate()
pageNo := 1
total := 1 //总页数,默认为1,先启动起来,然后爬虫修正总页数
// 业务逻辑
// 1.从第一页开始查询,提取出总页数,如果不是下一页,继续PageNO+1 查询
// 2.和数据库历史数据对比,如果数据库有,则不继续下一页查询(全量模式下除外)
for pageNo <= total {
yjygInfo := GetAndParse(dateStr, pageNo, t.Year, t.Jidu, t.Limit)
total = yjygInfo.Total // 每页返回的total都是一样的
pageNo++
// 保存 数据,并判断已存在数目
repeatCount := saveYjygList(yjygInfo.List)
if repeatCount > 0 && t.Mode != "ALL" { //增量模式下,如果当前页是否已经存在重复数据,就不需要再查询下一页了
return
}
}
}
// 如果存在重复数据,返回count+1
func saveYjygList(list []model.ThsYjyg) int {
count := 0
for _, item := range list {
if !saveYjyg(item) {
count++
}
}
return count
}
func saveYjyg(info model.ThsYjyg) bool {
oldYjyg := model.ThsYjyg{}
err := global.Table(global.KLineDb, &info).Where("code=? and date=? and jidu=?", info.Code, info.Date.Format(util.Date_Format), info.Jidu).First(&oldYjyg).Error
if oldYjyg.ID > 0 {
return false
}
err = global.Table(global.KLineDb, &info).Save(&info).Error
if err != nil {
global.LOG.Error("save yjyg error : ", zap.Error(err))
}
return true
}
//根据时间来判断是否需要爬取
func needRun(typ int, year int) bool {
now := time.Now()
month := now.Month()
if typ == 1 {
return month < 5 //一季报最多查询到4月份
} else if typ == 2 {
return month > 3 && month < 9 //中报3-9月
} else if typ == 3 {
return month >= 7 && month < 11 // 三季报7-11月
} else { // 年报 全年都有可能 ,主要集中在4-30前
return true //month <=4 || month >=7 //主要集中在下一年4-30前,有些票是当年就发布了
}
}
func (t ThsYjygTask) getDate() string {
if t.Year == 0 {
t.Year = time.Now().Year()
if time.Now().Month() < 5 && t.Jidu == 4 { //默认情况下,4月份以前的查询的是上一年的年报
t.Year -= 1
}
}
return strconv.Itoa(t.Year) + dateSuffixMap[t.Jidu]
}
func InitDb() {
// 初始化数据库,数据库同步
global.LOG.Debug("初始化数据库表 ............")
global.AutoMigrate(global.KLineDb, &model.ThsYjyg{})
}
爬取任务有两种模式,一种是爬取历史数据,一种是日常每日进行增量爬取,代码非常简单,100行左右
5.主程序
package main
import (
"stockgo/core/engine"
"stockgo/core/global"
"stockgo/core/task"
"stockgo/craw/ths/yjyg"
)
func main() {
var conf yjyg.Conf
engine.Start("", &conf)
defer engine.Close()
yjyg.InitDb() //自动初始化数据库
tasks := task.NewStockTasks()
for _, jidu := range conf.Config.Types {
yjygTask := &yjyg.ThsYjygTask{ //同花顺业绩预告任务
Mode: conf.Config.Mode,
Year: conf.Config.Year,
Jidu: jidu,
Limit: global.Limit,
}
tasks = append(tasks, task.CreateStockTask("同花顺数据爬取--", yjygTask))
}
task.RunStockTask(tasks) //并发运行
}
主程序非常简洁,主要分为几步:
(1) engine引擎加载配置文件并自动连接数据库,并再主程序结束时自动关闭数据库等
(2) 自动初始化数据库,主要是自动生成数据库表
(3)根据配置文件循环创建爬虫任务,并将任务放入task运行器中并行运行
再看看配置文件
# StockGo Global Configuration
# system configuration
system:
name: 同花顺业绩预告数据爬取任务
taskNum: 10 #并发任务数目
limitSped: 3000 #网络请求限制,3000ms允许发出一个请求
# 数据库配置,至少配置一个default,默认数据库
mysql:
- name: default
showSql: false
dataSource: 'stockdev:stock123456@tcp(db.stock.com:3306)/stock_data?charset=utf8&parseTime=True&loc=Local'
- name: kline
showSql: true
dataSource: 'stockdev:stock123456@tcp(db.stock.com:3306)/stock_data?charset=utf8&parseTime=True&loc=Local'
#业绩预告爬取数据,当补全历史数据时候,配置对应年份,mode 设置为ALL,
#其他自动爬取时候,去掉配置,使用空值
yjyg:
year: 0 # 默认0使用系统时间year
mode: UPDATE #ALL 查询所有,全量查询,其他值[update] 增量更新
types: 1,2,3,4 # 1,2,3,4 季报类型
# zap logger configuration(zap 日志配置)
zap:
# 可使用 "debug", "info", "warn", "error", "dpanic", "panic", "fatal",
level: 'info'
# console: 控制台, json: json格式输出
format: 'console'
prefix: '[StockGo]'
director: 'log'
link-name: 'latest.log'
show-line: true
# LowercaseLevelEncoder:小写, LowercaseColorLevelEncoder:小写带颜色,CapitalLevelEncoder: 大写, CapitalColorLevelEncoder: 大写带颜色,
encode-level: 'LowercaseColorLevelEncoder'
stacktrace-key: 'stacktrace'
log-in-console: true
config.go
package yjyg
import "stockgo/core/config"
type Config struct {
Year int `mapstructure:"year" json:"year" yaml:"year"`
Mode string `mapstructure:"mode" json:"mode" yaml:"mode"`
Types []int `mapstructure:"types" json:"types" yaml:"types"`
}
type Conf struct {
Zap config.Zap `mapstructure:"zap" json:"zap" yaml:"zap"`
System config.System `mapstructure:"system" json:"system" yaml:"system"`
MysqlList []config.Mysql `mapstructure:"mysql" json:"mysql" yaml:"mysql"`
Config Config `mapstructure:"yjyg" json:"yjyg" yaml:"yjyg"`
}
func (bc *Conf) GetZap() *config.Zap {
return &bc.Zap
}
func (bc *Conf) GetSystem() *config.System {
return &bc.System
}
func (bc *Conf) GetMysql() []config.Mysql {
return bc.MysqlList
}
6.运行日志
[StockGo]2021/08/27 - 15:57:55.604 info 服务启动 {"name": "同花顺业绩预告数据爬取任务"}
[StockGo]2021/08/27 - 15:57:55.605 info 数据库注册 {"Name": "default", "url": "stockdev:stock123456@tcp(db.stock.com:3306)/stock_data?charset=utf8&parseTime=True&loc=Local", "isLog": false}
[StockGo]2021/08/27 - 15:57:55.606 info 数据库注册 {"Name": "kline", "url": "stockdev:stock123456@tcp(db.stock.com:3306)/stock_data?charset=utf8&parseTime=True&loc=Local", "isLog": true}
[StockGo]2021/08/27 - 15:57:55.607 info 9 begin Task 同花顺数据爬取-- 同花顺业绩预告爬取任务2021-03-31 0
[StockGo]2021/08/27 - 15:57:55.607 info 9 end Task 同花顺数据爬取-- 同花顺业绩预告爬取任务2021-03-31 0
[StockGo]2021/08/27 - 15:57:55.607 info 6 begin Task 同花顺数据爬取-- 同花顺业绩预告爬取任务2021-09-30 2
[StockGo]2021/08/27 - 15:57:55.607 info 4 begin Task 同花顺数据爬取-- 同花顺业绩预告爬取任务2021-12-31 3
[StockGo]2021/08/27 - 15:57:55.607 info 业绩预告爬取地址: {"URL": "https://data.10jqka.com.cn/ajax/yjyg/date/2021-09-30/board/ALL/field/enddate/order/desc/page/1/ajax/1/free/1/"}
[StockGo]2021/08/27 - 15:57:55.607 info 业绩预告爬取地址: {"URL": "https://data.10jqka.com.cn/ajax/yjyg/date/2021-12-31/board/ALL/field/enddate/order/desc/page/1/ajax/1/free/1/"}
[StockGo]2021/08/27 - 15:57:55.607 info 3 begin Task 同花顺数据爬取-- 同花顺业绩预告爬取任务2021-06-30 1
[StockGo]2021/08/27 - 15:57:55.607 info 业绩预告爬取地址: {"URL": "https://data.10jqka.com.cn/ajax/yjyg/date/2021-06-30/board/ALL/field/enddate/order/desc/page/1/ajax/1/free/1/"}
[StockGo]2021/08/27 - 15:57:55.950 info 4 end Task 同花顺数据爬取-- 同花顺业绩预告爬取任务2021-12-31 3
[StockGo]2021/08/27 - 15:57:59.605 info 6 end Task 同花顺数据爬取-- 同花顺业绩预告爬取任务2021-09-30 2
[StockGo]2021/08/27 - 15:58:01.818 info 3 end Task 同花顺数据爬取-- 同花顺业绩预告爬取任务2021-06-30 1
[StockGo]2021/08/27 - 15:58:01.818 info 运行总时长:46 秒
7.数据爬取之后,需要做页面进行数据展示
数据查询和展示框架基于gin-vue-admin二次开发,添加了avue,采用自动配置,自动生成的方式,基本5分钟内搞定,就是页面不够精细,不过也足够使用了,这里不进行多讲,如果有朋友有兴趣,后续我专门写一篇文章介绍。
五、最后
同花顺业绩预报数据是数据爬取中比较简单的,本人框架专注研究欧奈尔和落升,感兴趣的朋友可以一起交流,后续可能也会将当前策略筛选出来的股票分享给大家。