用Python從影像查詢系統中挖出性別錯亂的報告:從原理分析到程式碼實現
之前在泌尿輪科時見過一份沒把“前列腺”一段刪除的女性患者報告。現在想到可以用Python爬蟲從放射影像系統中自動篩選出類似錯誤的報告。
開發工具
這是一個Python 3 指令碼,在如下環境除錯完成。
Microsoft Windows 7 32位:醫院辦公室最高階的系統,XP安裝新版Anaconda和PyCharm時有問題。部分程式碼先在我自己的win10 64電腦上完成。
Anaconda3-5。0。1-Windows-x86。exe:牆內使用者可從清華大學開源軟體映象站下載
PyCharm-community-2016。3。3。exe:2017年的社群版安裝包提示錯誤,只能安裝舊一版。若在自己電腦上,可用專業版,edu郵箱使用者福利。
pycryptodome和pdfminer。six模組: Anaconda已內建其他所需模板,只需要離線安裝這一個。pycryptodome是pdfminer。six的依賴環境,需要行安裝。手動安裝時使用whl格式最方便,不用解壓,pip install 一下就好了。
通用電氣醫療放射資訊系統軟體::一個網頁版的查詢系統
IE + Fiddler: 這個查詢系統只能用IE,Fiddler用來監控客戶端與伺服器的通訊情況,以及查詢相關引數的位置。
Hide Wizard
思路分析:
醫院用的是通用電氣醫療放射資訊系統軟體,先登入網頁,搜尋條件,再從搜尋結果中點開PDF連結。因此,主要流程就是先模擬登入,爬取搜尋結果,進行篩選,最後用解析PDF內容。
需要解決的問題有:
一、網頁結構分析
通用電氣醫療放射資訊系統的放射影像查詢首頁是: /webreport/index。jsp
登入頁面是/webreport/login。jsp
登入之後查詢的頁面是:/webreport/mainframe。jsp
跟朗珈病理查詢系統類似,它由三個框架組成,查詢框架中的onclick事件之後,請求結果會出現在檢查列表框架,檢查列表框架中點選再顯示在歷史檢查框架或彈出新的報告或影象視窗
二、網頁登入
網頁登入是擋在所有網頁爬蟲面前的第一步,解決不了這一步,後面的內容都不用設計了。之前我爬的病理系統是相容chrome的,在chrome使用F12檢視網路資訊就行了。而這次的GE系統只能在IE 10相容性檢視以下執行(chrome雖可登入,但看不到完整查詢介面)。
IE 11 、IE 10的F12沒有chrome的那麼友好,而且不能切換IE的版本。折騰過程中發現QQ瀏覽器的F12也算是神器,它跟IE的開發者工具介面相同,但它可以切換各種IE版本及相容性檢視。
但真正算得上抓包神器的,應該是Fiddler。它可在win7下直接執行,不用配置,執行之後可以抓取所有程式的網路情況。
對於每一個抓到的網路請求和響應,提供Headers、TextView、WebView、Cookies、Raw、JSON、XML等各種檢視和格式。WebView就是直接解析HTML,和瀏覽器一樣,當你從python中模擬瀏覽器傳送請求時,可以在Fiddler中非常直觀地看到請求結果。
這個網頁的登入非常簡單,沒有驗證碼,什麼反爬蟲的限制都沒有。經過反覆測試,確定了這個網站的登入模式:用一個隨便的初始cookies從 /webreport/login。jsp 獲取一個新的cookies,登入的資訊就只保留在cookies中。
def
get_cookies
():
headers
=
{
‘Accept’
:
‘image/jpeg, application/x-ms-application, image/gif, application/xaml+xml, image/pjpeg, application/x-ms-xbap, application/vnd。ms-excel, application/vnd。ms-powerpoint, application/msword, */*’
,
#‘Accept’: ‘*/*’,
‘Referer’
:
‘http://*******/webreport/login。jsp’
,
‘Accept-Language’
:
‘zh-CN’
,
‘User-Agent’
:
‘Mozilla/4。0 (compatible; MSIE 7。0; Windows NT 6。1; Trident/6。0; SLCC2; 。NET CLR 2。0。50727; 。NET CLR 3。5。30729; 。NET CLR 3。0。30729; Media Center PC 6。0)’
,
‘Content-Type’
:
‘application/x-www-form-urlencoded’
,
‘Accept-Encoding’
:
‘gzip, deflate’
,
‘Connection’
:
‘Keep-Alive’
,
‘Content-Length’
:
‘136’
,
‘DNT’
:
‘1’
,
‘Host’
:
‘******’
,
‘Pragma’
:
‘no-cache’
,
‘Cookie’
:
‘JSESSIONID=EF35D21E9D426258B720AD7C5EEE6B2E’
#隨便一個初始cookies
}
request_url
=
‘http://*/webreport/loginAction。jsp’
params_data
=
{
‘monitorRes’
:
‘’
,
‘monitorResSF’
:
‘’
,
‘subAction’
:
‘login’
,
‘failCounts’
:
‘1’
,
‘lastUserName’
:
‘*’
,
‘userName’
:
‘*’
,
‘password’
:
‘*’
,
‘hospital’
:
‘1’
,
‘loginButton’
:
‘%B5%C7++%C2%BC’
}
response
=
requests
。
post
(
request_url
,
data
=
params_data
,
headers
=
headers
)
(
‘JSESSIONID={0}’
。
format
(
response
。
cookies
[
‘JSESSIONID’
]))
return
‘JSESSIONID={0}’
。
format
(
response
。
cookies
[
‘JSESSIONID’
])
唯一比較特別的是,這個系統隔段時間會自動退出,此時用舊的cookies就無法獲取查詢結果了,需要重新獲取一個cookies。
由於一眼看不出這cookies的過期時間,於是,就簡單粗暴地設定為每次發起新一天的查詢就直接請求一個新的cookies,同時設定獲取html之後再判斷一下會話是否過期,過期就直接再請求一次:
if
re
。
search
(
‘top\。location\。replace’
,
html
)
!=
None
:
cookies
=
get_cookies
()
headers
[
‘Cookie’
]
=
cookies
三、查詢引數提交
折騰了好久,發現需要先將中文引數encode為GBK,才能正確地請求資料。
params_data
=
{
‘eventaction’
:
‘search’
,
‘2’
:
‘’
,
‘3’
:
‘’
,
‘4’
:
‘’
,
‘5’
:
‘’
,
‘6’
:
date
。
strftime
(
‘%Y-%m-
%d
’
),
‘7’
:
‘6’
,
# 已稽核
‘sex’
:
sex
。
encode
(
‘GBK’
),
# ‘sex’:‘男’,
‘level’
:
‘’
,
‘department’
:
department
。
encode
(
‘GBK’
),
#charset=GBK
# ‘department’: ‘’,
‘modality’
:
‘’
}
response
=
requests
。
post
(
request_url
,
data
=
params_data
,
headers
=
headers
)
四、獲取查詢結果的html
透過Fiddle抓包發現,查詢結果的首頁是request_url =‘/webreport/WebReportAction。do?ran=0。9087775843843873’ ,這個ran引數不知道是什麼意義,但是貌似可以重複使用,有可能是記錄客戶端資訊的吧
超過200條結果就會自動分頁,分頁的查詢結果頁面是 /webreport/searchreportlist。jsp
有意思的是,傳送於一個POST請求之後,查詢的資訊儲存於cookies中,請求第2頁之後的資訊,只需要用POST時用的cookies去GET就行了
#從首頁讀取總頁數,若有多頁就繼續請求分頁資料並進行合併
page_sum
=
eval
(
re
。
search
(
r
‘當前(\d+)/(\d+)頁’
,
html
)[
2
])
(
page_sum
)
if
page_sum
>
1
:
for
page
in
range
(
2
,
page_sum
+
1
):
page_table
=
get_page
(
page
,
cookies
)
五、從html中獲取查詢資料,並進行資料清洗
因為想從查詢結果中篩選出CT和MR,並下載pdf進行分析,所以html中感興趣的資料就是檢查專案、檢查號(用於作為資料ID)以及pdf連結。查詢結果是一個html表格,pdf連結最有特點,直接用一個正則表示式就能匹配出來:
pdflinks
=
re
。
compile
(
r
‘openReport\(
\’
(。*?\。pdf)。*?(\d{2,})。*?\)‘
,
re
。
S
)
。
findall
(
html
)
#匹配所有字元,連結後大於2位的一串數字是檢查號;\1是pdf連結,\2是檢查號
難點在於檢查專案和檢查號欄位,它們的
from lxml import etree
模組也能匹配出來,但結果的處理仍然不夠方便。最後搜尋parse html table發現pandas直接一個
read_html
方法就能直接把html裡面的表格轉換為DataFrame,非常震驚。DataFrame是科學計算包pandas的兩種資料格式之一,就相當於一個放在記憶體中的Excel,基本上Excel能實現的功能,它都能做到。
import
pandas
as
pd
tables
=
pd
。
read_html
(
html
)
sp500_table
=
tables
[
0
]
sp500_table
。
index
=
sp500_table
[
2
]
#第3列資料是檢查號,把它設為index
於是也順便把pdf連結轉換為pandas的Series資料形式,並和DataFrame進行合併(利用了pandas的自動對齊功能)
from
pandas
import
Series
s
=
Series
({
eval
(
pdflink
[
1
]):
pdflink
[
0
]
for
pdflink
in
pdflinks
})
# 這裡用eval方法把檢查號轉為整數,方便後面與表格合併,因為pandas把表格裡面的檢查號讀取為float64
#也可以用下面的方法,先建立Series,再把s。index轉為int64或float64,都能與後面表格合併
# s = Series({pdflink[1]:pdflink[0] for pdflink in pdflinks})
# s。index= pd。to_numeric(s。index,downcast=’integer‘,errors=’ignore‘) #或downcast=’float64‘
sp500_table
[
’pdflink‘
]
=
s
#將連結合併入文字表格
sp500_table
=
sp500_table
。
dropna
(
how
=
’all‘
)
#刪除全空的行
DataFrame的合併也是非常方便,分頁的DataFrame資料直接append就能垂直拼接
sp500_table
=
sp500_table
。
append
(
get_page
(
page
,
cookies
))
篩選出CT和MR檢查直接用現成的
srt。contains
就行:
sp500_table
=
sp500_table
。
loc
[
sp500_table
[
12
]
。
str
。
contains
(
’CT|MR‘
)]
六、下載PDF
從查詢結果的頁面點選查詢結果之後,觸發JavaScript,彈出一個新的頁面,顯示pdf。然而,html原始碼和新頁面的連結中不能直接看出pdf的直達連結。接下來通用的方法應該是分析網頁的JS,用selenium+PhatomJS模擬瀏覽器點選,去獲取這個url。這也是一個比較蛋疼的過程。但是,我突然想到電子病歷中就有報告的直達連結,跑過去一個,發現是個FTP連結,而且碼農們做介面時FTP的使用者名稱和密碼也不擋一下。用這個使用者名稱和密碼直接就登入了儲存放射科資料的FTP,激動!這FTP裡面image一個資料夾,report一個資料夾。
report裡面每個月又分幾個資料夾,每個子資料夾下面就是檢查報告的pdf,pdf是以報告的Id來命名的。(每個患者的每頂檢查都有一個放射號和檢查號,每份報告有一個報告號,有時幾個檢查會合並在一份報告裡面發)。
此時,也可以不用網頁爬蟲了,直接把這波pdf下載回去分析就行了。但要實現本專案的目的還是先分析html、篩選之後再下載pdf比較快,因為解析pdf速度比較慢。從ftp下載pdf就非常簡直了,直接上網去下載一個現成的指令碼就行了,例如:
http://www。
cnblogs。com/hfclytze/p/
ftplib。html
,模組也是Anaconda自帶的。pdf的連結地址可以從html中獲取。我是採取下一個檔案之後分析處理完再下載另一個pdf的方法。
七、解析並處理pdf
從pdf中提取源資料實際上非常困難,而且這還是中文pdf,還要面對一堆編碼的問題。幸好這個影像報告的PDF檔案都製作精良,可以直接複製文字的。
但是解析pdf或從pdf中提取text的python3模組並不好找,python2最常用的是pdfminer。一開始我找了個移植的pdfminer。3k,測試了一個報告PDF,發現可以解析,於是非常興奮,專案終於可以終於了,立即執行爬蟲,下載分析。
問題來了,下載了幾個月發現,每個月總有那麼幾篇報告解析錯誤,但手動開啟報告是沒問題的,還可以直接複製。每月幾篇,那麼一年的資料就有幾十篇,手動開啟檢視就不能體現爬蟲的優勢了。
於是又分析測試了好幾個pythonpdf包,包括tabula-py、pdfrw、textract、pdf2txt都不行。最後還是找到pdfminer的python3移植包pdfminer。six(大概是使用版本移植工具six做的吧),可以完善解析所有的PDF報告。
但這個pdfminer。six的文件寫得非常差,就只有一個命令列的使用方法。在python中呼叫命令列工具去執行另一個py指令碼,顯然不夠優雅,最後自己看了一個原始碼,並從StackOverFlow中找到一個可行的py指令碼(
https://
stackoverflow。com/a/466
95574/9095642
)。
import
pdfminer。settings
pdfminer
。
settings
。
STRICT
=
False
import
pdfminer。high_level
import
pdfminer。layout
from
pdfminer。image
import
ImageWriter
import
io
def
extract_raw_text
(
pdf_filename
):
output
=
io
。
StringIO
()
laparams
=
pdfminer
。
layout
。
LAParams
()
# Using the defaults seems to work fine
with
open
(
pdf_filename
,
“rb”
)
as
pdffile
:
pdfminer
。
high_level
。
extract_text_to_fp
(
pdffile
,
output
,
laparams
=
laparams
)
return
output
。
getvalue
()
在這裡,英文搜尋的優勢得到充分體現,用中文搜尋結果,有十幾二十個頁面全是某篇沒有用的文章,大概是他一文多發之後又被其他各種網頁抄去了。
八、分析過濾pdf
這一步非常簡單,直接用從pdf的文字內容中搜索關鍵字,根據匹配結果決定保留pdf檔案還是刪除pdf
def
process_pdf
(
exam
,
localpath
,
get_count
=
0
,
pass_count
=
0
,
pdf_error
=
0
):
results
=
extract_raw_text
(
localpath
)
# if results。find(’前列腺‘) != -1:
# if re。search(’前列腺|睪丸‘,results): # is not None
if
re
。
search
(
’子宮|卵巢‘
,
results
):
# is not None
# print(’{0} get‘。format(exam))
logger
。
error
(
’{0} get‘
。
format
(
exam
))
try
:
os
。
rename
(
localpath
,
localpath
[
0
:
-
4
]
+
’_{0}_marked。pdf‘
。
format
(
exam
))
except
:
try
:
os
。
rename
(
localpath
,
localpath
[
0
:
-
4
]
+
’_{0}_marked。pdf‘
。
format
(
random
。
sample
(
’adcdefgh‘
,
5
)))
except
Exception
as
renameerror
:
logger
。
error
(
renameerror
)
get_count
+=
1
elif
results
。
find
(
’診斷‘
)
!=
-
1
:
# print(’exam pass‘)
logger
。
info
(
’{0} pass‘
。
format
(
exam
))
os
。
remove
(
localpath
)
pass_count
+=
1
else
:
logger
。
error
(
’{0} pdf parse failed‘
。
format
(
exam
))
pdf_error
+=
1
try
:
os
。
rename
(
localpath
,
localpath
[
0
:
-
4
]
+
’_{0}_parsefailed。pdf‘
。
format
(
exam
))
except
:
try
:
os
。
rename
(
localpath
,
localpath
[
0
:
-
4
]
+
’_parsefailed_{0}。pdf‘
。
format
(
random
。
sample
(
’adcdefgh‘
,
5
)))
except
Exception
as
renameerror
:
logger
。
error
(
renameerror
)
return
get_count
,
pass_count
,
pdf_error
九、結果記錄
由於這個爬蟲專案執行起來比較久,所以需要動態記錄執行的記錄,確保意外退出時之後的工作記錄得到儲存。發現logging模組就能實現這個目的,但好的教程也不好找(
http://www。
cnblogs。com/yyds/p/6901
864。html
)。
# http://www。csuldw。com/2016/11/05/2016-11-05-simulate-zhihu-login/
import
logging
“”“#EXAMPLE
logger = createLogger(’mylogger‘, ’temp/logger。log‘)
logger。debug(’logger debug message‘)
logger。info(’logger info message‘)
logger。warning(’logger warning message‘)
logger。error(’logger error message‘)
logger。critical(’logger critical message‘)
”“”
def
createLogger
(
logger_name
=
’mylogger‘
,
log_file
=
’log。ini‘
,
error_logfile
=
’error_log。ini‘
):
# 建立一個logger
logger
=
logging
。
getLogger
(
logger_name
)
logger
。
setLevel
(
logging
。
INFO
)
# 建立一個handler,用於寫入日誌檔案
fh
=
logging
。
FileHandler
(
log_file
)
# 再建立一個handler,用於輸出到控制檯
ch
=
logging
。
StreamHandler
()
# 定義handler的輸出格式formatter
formatter
=
logging
。
Formatter
(
’
%(asctime)s
|
%(name)s
|
%(levelname)s
|
%(filename)s
[:
%(lineno)d
] |
%(message)s
‘
)
fh
。
setFormatter
(
formatter
)
ch
。
setFormatter
(
formatter
)
# 給logger新增handler
logger
。
addHandler
(
fh
)
logger
。
addHandler
(
ch
)
#建立一個handler,只收錄error級別的日誌
error_handler
=
logging
。
FileHandler
(
error_logfile
)
error_handler
。
setLevel
(
logging
。
ERROR
)
error_handler
。
setFormatter
(
formatter
)
logger
。
addHandler
(
error_handler
)
return
logger
logger
=
createLogger
()
if
__name__
==
’__main__‘
:
# e = ’Errorsjflsdf‘
# index = 2
# logger。error(’\n{1}\n{0}‘。format(e,index))
# #Logger。exception()將會輸出堆疊追蹤資訊
# logger。exception(’{1}\n{0}‘。format(e,index))
# logger。debug(’debug message‘)
# logger。info(’info message‘)
# logger。warning(’warning message‘)
logger
。
error
(
’error message‘
)
# logger。critical(’critical message‘)
raise
EOFError
#就算髮生了錯誤,前面的log也已被寫入檔案
十、除錯與執行
使用模組化程式設計,將上述功能元件寫在不同的py檔案裡面,分別完成除錯,最後包裝成函式,互相import。
多用try語句。一開始沒給整個大程式加一個try,設定了半夜再爬一年的資料的,結果搜尋了4天的資料就退出了,浪費了晚上的時間。後來發現原因是2016年7月之前的外院片子會診報告是無法察看的。
實際執行中發現,用4核i3 CPU的電腦大概1分多鐘可以分析完約200份報告,就是說6個小時就能查完1年的資料;而雙核奔騰CPU則需要雙倍的時間。這裡可以考慮用多執行緒或程序處理的方法,但需要進一步學習。
原始碼實現
原始碼已上傳到GitHub(點選閱讀原文訪問)。流程圖就不畫了。
關於原始碼的分析,可以參考程式碼中的註解,如有不理解的地方,可在評論中提出。
執行結果
不能公開。但是可以上交給國家。
More
其實都提取到純文字了,下一步可以把抓取到的資料儲存到一個數據庫中,方便查詢。然而,如果不能實際某個目的,那麼這些資料只是垃圾,建立資料庫沒有意義。
原文發表於微信公眾號:用Python從影像查詢系統中挖出性別錯亂的報告:從原理分析到程式碼實現