使用 R 语言爬取全部上市公司的年报数据

上市公司年报的获取方法很多,今天我们一起学习下如何从巨潮资讯网爬取,非常好的一点是,巨潮资讯网并不反爬,所以爬取起来简单很多。

爬取的网站网址是:http://www.cninfo.com.cn/new/commonUrl/pageOfSearch?url=disclosure/list/search

从网站右侧的筛选窗口可以筛选报告下载。所以我的爬取思路是,首先获取全部可选的上市公司列表,然后逐个公司爬取其历年年报的链接然后再下载。

获取所有可选的上市公司列表

通过网页分析,可以找到可选的上市公司列表数据在这里:

是一个 json 格式的文件,可以使用 jsonlite 处理:

library(tidyverse)
# 所有可选的公司:http://www.cninfo.com.cn/new/data/szse_stock.json
library(jsonlite)
fromJSON("http://www.cninfo.com.cn/new/data/szse_stock.json") -> lst
lst$stockList %>%
as_tibble() %>%
mutate(stext = paste0(code, ",", orgId)) -> codelist

codelist

#> # A tibble: 5,622 × 6
#> code pinyin category orgId zwjc stext
#> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 000001 payh A股 gssz0000001 平安银行 000001,gssz0000001
#> 2 000002 wka A股 gssz0000002 万科A 000002,gssz0000002
#> 3 000004 ghwa A股 gssz0000004 国华网安 000004,gssz0000004
#> 4 000005 stxy A股 gssz0000005 ST星源 000005,gssz0000005
#> 5 000006 szya A股 gssz0000006 深振业A 000006,gssz0000006
#> 6 000007 stqx A股 gssz0000007 *ST全新 000007,gssz0000007
#> 7 000008 szgt A股 gssz0000008 神州高铁 000008,gssz0000008
#> 8 000009 zgba A股 gssz0000009 中国宝安 000009,gssz0000009
#> 9 000010 mlst A股 gssz0000010 美丽生态 000010,gssz0000010
#> 10 000011 swya A股 gssz0000011 深物业A 000011,gssz0000011
#> # ℹ 5,612 more rows

一共是 5622 家上市公司。

爬取单家公司的年报链接

以平安银行近三年的年报为例:

对着 query 右键复制 curl 代码:

curl 'http://www.cninfo.com.cn/new/hisAnnouncement/query' \
-H 'Accept: */*' \
-H 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8' \
-H 'Connection: keep-alive' \
-H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
-H 'Cookie: JSESSIONID=CEC404925A4CD863DB2CD110942E1766; insert_cookie=45380249; _sp_ses.2141=*; routeId=.uc2; _sp_id.2141=b13ee9ff-2493-4f42-a7f5-089a2990e863.1694410049.9.1694788568.1694748531.2d165bfc-a649-4d35-9e23-4e3b6304e9d5' \
-H 'Origin: http://www.cninfo.com.cn' \
-H 'Referer: http://www.cninfo.com.cn/new/commonUrl/pageOfSearch?url=disclosure/list/search' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' \
-H 'X-Requested-With: XMLHttpRequest' \
--data-raw 'pageNum=1&pageSize=30&column=szse&tabName=fulltext&plate=&stock=000001%2Cgssz0000001&searchkey=&secid=&category=category_ndbg_szsh&trade=&seDate=2020-09-15~2023-09-16&sortName=&sortType=&isHLtitle=true' \
--compressed \
--insecure

curl 语句是在 DOS 窗口或者终端里面运行的,可以使用这个网页应用把 curl 代码转换成 R 语言代码:https://curlconverter.com/r/

require(httr)

cookies = c(
`JSESSIONID` = "CEC404925A4CD863DB2CD110942E1766",
`insert_cookie` = "45380249",
`_sp_ses.2141` = "*",
`routeId` = ".uc2",
`_sp_id.2141` = "b13ee9ff-2493-4f42-a7f5-089a2990e863.1694410049.9.1694788568.1694748531.2d165bfc-a649-4d35-9e23-4e3b6304e9d5"
)

headers = c(
`Accept` = "*/*",
`Accept-Language` = "zh-CN,zh;q=0.9,en;q=0.8",
`Connection` = "keep-alive",
`Content-Type` = "application/x-www-form-urlencoded; charset=UTF-8",
`Origin` = "http://www.cninfo.com.cn",
`Referer` = "http://www.cninfo.com.cn/new/commonUrl/pageOfSearch?url=disclosure/list/search",
`User-Agent` = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
`X-Requested-With` = "XMLHttpRequest"
)

data = list(
`pageNum` = "1",
`pageSize` = "30",
`column` = "szse",
`tabName` = "fulltext",
`plate` = "",
`stock` = "000001,gssz0000001",
`searchkey` = "",
`secid` = "",
`category` = "category_ndbg_szsh",
`trade` = "",
`seDate` = "2020-09-15~2023-09-16",
`sortName` = "",
`sortType` = "",
`isHLtitle` = "true"
)

res <- httr::POST(url = "http://www.cninfo.com.cn/new/hisAnnouncement/query", httr::add_headers(.headers=headers), httr::set_cookies(.cookies = cookies), body = data, encode = "form", config = httr::config(ssl_verifypeer = FALSE))

我们可以试试把 seDate 修改成 “2000-01-01~2023-09-16”(pageSize 也可以试试修改成 100,不过没什么效果):

data = list(
`pageNum` = "1",
`pageSize` = "30",
`column` = "szse",
`tabName` = "fulltext",
`plate` = "",
`stock` = "000001,gssz0000001",
`searchkey` = "",
`secid` = "",
`category` = "category_ndbg_szsh",
`trade` = "",
`seDate` = "2000-01-01~2023-09-16",
`sortName` = "",
`sortType` = "",
`isHLtitle` = "true"
)

res <- httr::POST(url = "http://www.cninfo.com.cn/new/hisAnnouncement/query", httr::add_headers(.headers=headers), httr::set_cookies(.cookies = cookies), body = data, encode = "form", config = httr::config(ssl_verifypeer = FALSE))

content(res) -> ls

ls$announcements %>%
transpose() %>%
as_tibble() %>%
unnest() %>%
filter(!str_detect(announcementTitle, "摘要")) %>%
select(announcementTitle, adjunctUrl) %>%
mutate(adjunctUrl = paste0("http://static.cninfo.com.cn/", adjunctUrl)) -> df1

df1
#> # A tibble: 15 × 2
#> announcementTitle adjunctUrl
#> <chr> <chr>
#> 1 2022年年度报告 http://static.cninfo.com.cn/finalpage/2023-03-09/121607295…
#> 2 2021年年度报告 http://static.cninfo.com.cn/finalpage/2022-03-10/121253341…
#> 3 2020年年度报告 http://static.cninfo.com.cn/finalpage/2021-02-02/120922437…
#> 4 2019年年度报告 http://static.cninfo.com.cn/finalpage/2020-02-14/120730548…
#> 5 2018年年度报告 http://static.cninfo.com.cn/finalpage/2019-03-07/120588106…
#> 6 2017年年度报告 http://static.cninfo.com.cn/finalpage/2018-03-15/120447715…
#> 7 2016年年度报告 http://static.cninfo.com.cn/finalpage/2017-03-17/120316751…
#> 8 2015年年度报告 http://static.cninfo.com.cn/finalpage/2016-03-10/120203339…
#> 9 2014年年度报告 http://static.cninfo.com.cn/finalpage/2015-03-13/120069456…
#> 10 2013年年度报告 http://static.cninfo.com.cn/finalpage/2014-03-07/63646360.…
#> 11 2012年年度报告 http://static.cninfo.com.cn/finalpage/2013-03-08/62193049.…
#> 12 2011年年度报告 http://static.cninfo.com.cn/finalpage/2012-03-09/60645730.…
#> 13 2010年年度报告 http://static.cninfo.com.cn/finalpage/2011-02-25/59043233.…
#> 14 2009年年度报告 http://static.cninfo.com.cn/finalpage/2010-03-12/57679912.…
#> 15 2008年年度报告 http://static.cninfo.com.cn/finalpage/2009-03-20/50366297.…

这样就得到了第一页的结果,类似的方法再获取第二页的:

data = list(
`pageNum` = "2",
`pageSize` = "30",
`column` = "szse",
`tabName` = "fulltext",
`plate` = "",
`stock` = "000001,gssz0000001",
`searchkey` = "",
`secid` = "",
`category` = "category_ndbg_szsh",
`trade` = "",
`seDate` = "2000-01-01~2023-09-16",
`sortName` = "",
`sortType` = "",
`isHLtitle` = "true"
)

res <- httr::POST(url = "http://www.cninfo.com.cn/new/hisAnnouncement/query", httr::add_headers(.headers=headers), httr::set_cookies(.cookies = cookies), body = data, encode = "form", config = httr::config(ssl_verifypeer = FALSE))

content(res) -> ls

ls$announcements %>%
transpose() %>%
as_tibble() %>%
unnest() %>%
filter(!str_detect(announcementTitle, "摘要")) %>%
select(announcementTitle, adjunctUrl) %>%
mutate(adjunctUrl = paste0("http://static.cninfo.com.cn/", adjunctUrl)) -> df2

df2
#> # A tibble: 7 × 2
#> announcementTitle adjunctUrl
#> <chr> <chr>
#> 1 2007年年度报告 http://static.cninfo.com.cn/finalpage/2008-03-20/38090…
#> 2 2006年年度报告 http://static.cninfo.com.cn/finalpage/2007-03-22/21676…
#> 3 深发展A2005年年度报告 http://static.cninfo.com.cn/finalpage/2006-04-01/16677…
#> 4 深发展A2004年年度报告 http://static.cninfo.com.cn/finalpage/2005-04-26/15288…
#> 5 深发展A2003年年度报告 http://static.cninfo.com.cn/finalpage/2004-04-15/13907…
#> 6 深发展A2002年年度报告 http://static.cninfo.com.cn/finalpage/2003-04-24/10621…
#> 7 深发展A2001年年度报告 http://static.cninfo.com.cn/finalpage/2002-04-18/57325…

不过可能会有多页,所以还是循环比较好:

lapply(1:3, function(x){
data = list(
`pageNum` = x,
`pageSize` = "30",
`column` = "szse",
`tabName` = "fulltext",
`plate` = "",
`stock` = "000001,gssz0000001",
`searchkey` = "",
`secid` = "",
`category` = "category_ndbg_szsh",
`trade` = "",
`seDate` = "2000-01-01~2023-09-11",
`sortName` = "",
`sortType` = "",
`isHLtitle` = "true"
)

httr::POST(url = "http://www.cninfo.com.cn/new/hisAnnouncement/query",
httr::add_headers(.headers=headers),
httr::set_cookies(.cookies = cookies),
body = data, encode = "form",
config = httr::config(ssl_verifypeer = FALSE)) %>%
content() -> ls

if (length(ls$announcements) > 0) {
ls$announcements %>%
transpose() %>%
as_tibble() %>%
unnest() %>%
filter(!str_detect(announcementTitle, "摘要")) %>%
select(announcementTitle, adjunctUrl) %>%
mutate(adjunctUrl = paste0("http://static.cninfo.com.cn/", adjunctUrl),
code = "000001")
}
}) %>%
bind_rows() -> dfall

dfall
#> # A tibble: 22 × 3
#> announcementTitle adjunctUrl code
#> <chr> <chr> <chr>
#> 1 2022年年度报告 http://static.cninfo.com.cn/finalpage/2023-03-09/121… 0000…
#> 2 2021年年度报告 http://static.cninfo.com.cn/finalpage/2022-03-10/121… 0000…
#> 3 2020年年度报告 http://static.cninfo.com.cn/finalpage/2021-02-02/120… 0000…
#> 4 2019年年度报告 http://static.cninfo.com.cn/finalpage/2020-02-14/120… 0000…
#> 5 2018年年度报告 http://static.cninfo.com.cn/finalpage/2019-03-07/120… 0000…
#> 6 2017年年度报告 http://static.cninfo.com.cn/finalpage/2018-03-15/120… 0000…
#> 7 2016年年度报告 http://static.cninfo.com.cn/finalpage/2017-03-17/120… 0000…
#> 8 2015年年度报告 http://static.cninfo.com.cn/finalpage/2016-03-10/120… 0000…
#> 9 2014年年度报告 http://static.cninfo.com.cn/finalpage/2015-03-13/120… 0000…
#> 10 2013年年度报告 http://static.cninfo.com.cn/finalpage/2014-03-07/636… 0000…
#> # ℹ 12 more rows

这样我们就得到了一家公司所有年份的年报文件链接了。

爬取全部上市公司的年报链接

循环所有的上市公司代码即可,这里仅展示前 6 家公司的:

dir.create("rds")
lapply(1:6, function(i){
print(codelist$zwjc[i])
# 每个公司检索 5 页
lapply(1:5, function(x){
data = list(
`pageNum` = x,
`pageSize` = "30",
`column` = "szse",
`tabName` = "fulltext",
`plate` = "",
`stock` = codelist$stext[i],
`searchkey` = "",
`secid` = "",
`category` = "category_ndbg_szsh",
`trade` = "",
`seDate` = "1990-01-01~2023-09-16",
`sortName` = "",
`sortType` = "",
`isHLtitle` = "true"
)

httr::POST(url = "http://www.cninfo.com.cn/new/hisAnnouncement/query",
httr::add_headers(.headers=headers),
httr::set_cookies(.cookies = cookies),
body = data, encode = "form",
config = httr::config(ssl_verifypeer = FALSE)) %>%
content() -> ls

if (length(ls$announcements) > 0) {
ls$announcements %>%
transpose() %>%
as_tibble() %>%
unnest() %>%
filter(!str_detect(announcementTitle, "摘要")) %>%
select(announcementTitle, adjunctUrl) %>%
mutate(adjunctUrl = paste0("http://static.cninfo.com.cn/", adjunctUrl),
code = codelist$code[i]) -> tempdf
tempdf %>%
write_rds(paste0("rds/", i, "_", x, ".rds"))
return(tempdf)
}
}) %>%
bind_rows()
}) %>%
bind_rows() -> dfall

这里为了避免爬取过程中出现问题,我把每家公司爬取的结果也分别保存到了 rds 文件夹中,方便再次循环的时候跳过这些已经爬取好的。再次循环可以这样:

# 再次循环,跳过成功的:
dir.create("rds")
lapply(1:6, function(i){
print(codelist$zwjc[i])
# 每个公司检索 5 页
lapply(1:5, function(x){
if(!file.exists(paste0("rds/", i, "_", x, ".rds"))) {
data = list(
`pageNum` = x,
`pageSize` = "30",
`column` = "szse",
`tabName` = "fulltext",
`plate` = "",
`stock` = codelist$stext[i],
`searchkey` = "",
`secid` = "",
`category` = "category_ndbg_szsh",
`trade` = "",
`seDate` = "1990-01-01~2023-09-16",
`sortName` = "",
`sortType` = "",
`isHLtitle` = "true"
)

httr::POST(url = "http://www.cninfo.com.cn/new/hisAnnouncement/query",
httr::add_headers(.headers=headers),
httr::set_cookies(.cookies = cookies),
body = data, encode = "form",
config = httr::config(ssl_verifypeer = FALSE)) %>%
content() -> ls

if (length(ls$announcements) > 0) {
ls$announcements %>%
transpose() %>%
as_tibble() %>%
unnest() %>%
filter(!str_detect(announcementTitle, "摘要")) %>%
select(announcementTitle, adjunctUrl) %>%
mutate(adjunctUrl = paste0("http://static.cninfo.com.cn/", adjunctUrl),
code = codelist$code[i]) -> tempdf
tempdf %>%
write_rds(paste0("rds/", i, "_", x, ".rds"))
return(tempdf)
}
}
}) %>%
bind_rows()
}) -> res

# 合并所有的 rds 文件
fs::dir_ls("rds") %>%
lapply(readr::read_rds) %>%
bind_rows() -> dfall
dfall

#> # A tibble: 161 × 3
#> announcementTitle adjunctUrl code
#> <chr> <chr> <chr>
#> 1 2022年年度报告 http://static.cninfo.com.cn/finalpage/2023-03-09/121… 0000…
#> 2 2021年年度报告 http://static.cninfo.com.cn/finalpage/2022-03-10/121… 0000…
#> 3 2020年年度报告 http://static.cninfo.com.cn/finalpage/2021-02-02/120… 0000…
#> 4 2019年年度报告 http://static.cninfo.com.cn/finalpage/2020-02-14/120… 0000…
#> 5 2018年年度报告 http://static.cninfo.com.cn/finalpage/2019-03-07/120… 0000…
#> 6 2017年年度报告 http://static.cninfo.com.cn/finalpage/2018-03-15/120… 0000…
#> 7 2016年年度报告 http://static.cninfo.com.cn/finalpage/2017-03-17/120… 0000…
#> 8 2015年年度报告 http://static.cninfo.com.cn/finalpage/2016-03-10/120… 0000…
#> 9 2014年年度报告 http://static.cninfo.com.cn/finalpage/2015-03-13/120… 0000…
#> 10 2013年年度报告 http://static.cninfo.com.cn/finalpage/2014-03-07/636… 0000…
#> # ℹ 151 more rows

这样会更加稳健。

爬取到的结果中还有很多是补充报告或者英文报告等,这些文件如果不需要的话可以筛选出来删除:

# 这些是不需要的
dfall %>%
filter(str_detect(announcementTitle, "英文|补充"))

#> # A tibble: 9 × 3
#> announcementTitle adjunctUrl code
#> <chr> <chr> <chr>
#> 1 2006年年度报告(英文版)(调整后) http://static.cninfo.com.cn/finalpag… 0000…
#> 2 2006年年度报告(英文版) http://static.cninfo.com.cn/finalpag… 0000…
#> 3 万 科 B2005年年度报告(英文) http://static.cninfo.com.cn/finalpag… 0000…
#> 4 万 科 A2004年年度报告的补充公告 http://static.cninfo.com.cn/finalpag… 0000…
#> 5 万 科 A2004年年度报告(英文) http://static.cninfo.com.cn/finalpag… 0000…
#> 6 万 科 A2003年年度报告(英文版) http://static.cninfo.com.cn/finalpag… 0000…
#> 7 万 科 A2002年年度报告(英文版) http://static.cninfo.com.cn/finalpag… 0000…
#> 8 万 科 B2001年年度报告(英文版) http://static.cninfo.com.cn/finalpag… 0000…
#> 9 ST 星 源2004年年度报告的补充公告 http://static.cninfo.com.cn/finalpag… 0000…

dfall %>%
filter(!str_detect(announcementTitle, "英文|补充")) %>%
rename(title = announcementTitle, url = adjunctUrl) %>%
mutate(year = str_extract(title, "\\d{4}")) %>%
mutate(title = paste0(code, "_", year, "_", title)) -> dfall

dfall

#> # A tibble: 152 × 4
#> title url code year
#> <chr> <chr> <chr> <chr>
#> 1 000001_2022_2022年年度报告 http://static.cninfo.com.cn/finalpage… 0000… 2022
#> 2 000001_2021_2021年年度报告 http://static.cninfo.com.cn/finalpage… 0000… 2021
#> 3 000001_2020_2020年年度报告 http://static.cninfo.com.cn/finalpage… 0000… 2020
#> 4 000001_2019_2019年年度报告 http://static.cninfo.com.cn/finalpage… 0000… 2019
#> 5 000001_2018_2018年年度报告 http://static.cninfo.com.cn/finalpage… 0000… 2018
#> 6 000001_2017_2017年年度报告 http://static.cninfo.com.cn/finalpage… 0000… 2017
#> 7 000001_2016_2016年年度报告 http://static.cninfo.com.cn/finalpage… 0000… 2016
#> 8 000001_2015_2015年年度报告 http://static.cninfo.com.cn/finalpage… 0000… 2015
#> 9 000001_2014_2014年年度报告 http://static.cninfo.com.cn/finalpage… 0000… 2014
#> 10 000001_2013_2013年年度报告 http://static.cninfo.com.cn/finalpage… 0000… 2013
#> # ℹ 142 more rows

另外,里面还有一些同一年两份文件的情况,一份是更新前的,一份是更新后的。这种由于命名方式多种多样,所以得谨慎删除。不过这些更新的内容往往都很少,使用更新前的还是更新后的实际上对文本分析的结果几乎没有影响,所以也不用太在意。

批量下载 pdf 文件

如果有时间的话,可以更加认真的筛选要下载的 pdf 文件。筛选好之后就可以下载了,也很简单:

dir.create("pdf")
lapply(1:nrow(dfall), function(x){
if(!file.exists(paste0("pdf/", dfall$title[x], ".pdf"))) {
print(x)
try({
download.file(dfall$url[x], paste0("pdf/", dfall$title[x], ".pdf"), mode = "wb")
})
}
}) -> res

如果要下载的 pdf 文件很多,可以考虑使用多线程下载,效率很快很多。

# 多线程下载
library(parallel)
# 查看可用核心数
detectCores()

makeCluster(36) -> cl
clusterExport(cl, "dfall")
parLapply(cl, 1:nrow(dfall), function(x){
if(!file.exists(paste0("pdf/", dfall$title[x], ".pdf"))) {
print(x)
try({
download.file(dfall$url[x], paste0("pdf/", dfall$title[x], ".pdf"), mode = "wb")
})
}
}) -> res

下载完之后可能还需要手动检查有没有下载失败的再重新下载。

文件管理

完整下载所有的年报后会得到巨多的 pdf 文件,难以管理,这里我们可以使用代码索引文件筛选自己需要的:

fs::dir_ls("pdf") %>%
as.character() %>%
as_tibble() -> fl

# 提取代码和年份
fl %>%
tidyr::extract(col = value, into = c("code", "year"),
regex = "/(.*)_(\\d{4})_", remove = F)

#> # A tibble: 151 × 3
#> value code year
#> <chr> <chr> <chr>
#> 1 pdf/000001_2001_深发展A2001年年度报告.pdf 000001 2001
#> 2 pdf/000001_2002_深发展A2002年年度报告.pdf 000001 2002
#> 3 pdf/000001_2003_深发展A2003年年度报告.pdf 000001 2003
#> 4 pdf/000001_2004_深发展A2004年年度报告.pdf 000001 2004
#> 5 pdf/000001_2005_深发展A2005年年度报告.pdf 000001 2005
#> 6 pdf/000001_2006_2006年年度报告.pdf 000001 2006
#> 7 pdf/000001_2007_2007年年度报告.pdf 000001 2007
#> 8 pdf/000001_2008_2008年年度报告.pdf 000001 2008
#> 9 pdf/000001_2009_2009年年度报告.pdf 000001 2009
#> 10 pdf/000001_2010_2010年年度报告.pdf 000001 2010
#> # ℹ 141 more rows

点击这里跳转到 RStata 短书平台获取附件:使用 R 语言爬取全部上市公司的年报数据

评论