はじめに
まぁ、業務システム(勤怠管理)でマウスぽちぽちが嫌だったんですよね。
クローリングでよくある事
基本的に、人間がマウスでぽちぽちするシステムなので、機械が操作することは考えられていない。そこでまさかまだ見えてもいないボタンを押すことなんてないよねって前提があったりするので、人に合わせて動かすことを考えると、きちんと押せるようになったらとか入力できるようになったら何かをするようなタイミングの制御が必要となります。
seleniumには当然そのような仕組みが用意されています。
2、3番目のドキュメントでは、2つの方法が示されています。ただ闇雲に一定時間を待ちますというのが暗黙の待機で、別にseleniumさんに任せなくてもtime.sleepでもいいじゃんて奴。
まぁ、タイピングコストや入れ忘れとか無くなるので、楽といえば楽ですが、ネットワークの状況やPC負荷の状況で応答速度が異なる場合に期待した結果が得られない場合があるため、本来的には明示的な待機が望ましい。でも、内部的に一定期間(default 0.5s)でポーリングしているので若干処理負荷的には高くなります。
待機の仕方
で、上記ドキュメントに書いてあるサンプルでは以下のように示されています。
# 一つ目
element = WebDriverWait(driver, 10).until(lambda x: x.find_element_by_id(“someId”))
# 2、3番目
try:
element = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.ID, "myDynamicElement"))
)
finally:
# 例外処理
待機条件のカスタマイズ
さらに、こちらのドキュメントには、カスタマイズ方法が記載されています。
presence_of_element_locatedなどのExceptedConditionsで定義されたヘルパクラスもこの仕組みを利用しています。
ただ、この方法、もう少し面倒な処理ならわかりますが、提示されている程度の簡単な処理であれば、もっと簡単にできます。
条件が一致しない場合に False を返す call メソッドを持つクラスを使用してカスタム待機条件を作成できます。
といった条件さえ守れば良いので、lambda式とか使えば良いです。(一番最初のドキュメントに記載されていますね。)
def proc(driver, type, name, cname):
# カスタマイズしたい処理を作成、戻りは条件不一致時にFalse、うまくいった場合要素を返却する
element = driver.find_element(type, name)
if cname in element.get_attribute("class"):
return element
else:
return False
wait = WebDriverWait(driver, 10)
try:
element = wait.until(lambda drv: proc(drv, By.ID, 'myNewInput', 'myCSSClass'))
except TimeoutException:
print("timeout..")
sys.exit()
lamda式の例はこちらのドキュメントにも記載されています。このドキュメントはソースへのリンクがあるので、非常に有用かと思います。こちらを参照すると、単にあるエレメントが見つかったらそれを取得するのではなく、さらに所定の属性を持っているものを取得するといった条件を追加できる点がポイントとなります。
本題?
さて、最初のサンプルではIDがDOMに存在することを確認したものですが、例えば確認したい要素がclassやnameの場合もあり、そのページや構成によって待つべき要素が変わるわけです。
その際に、一々これはBy.CLASS_NAMEで、これはBy.NAMEといった識別子を(コーディング上は)意識せずに済ませたいと思ったわけです。もちろん、クロールする際には、この要素はIDで引っ張れるから楽だとか、クラスだから一意に識別するにはどうするかといったことを考える必要はありますが。
なので取り敢えず、前述の例を元にちょっとしたwait処理を作って見た。
ちゃんと確認してないけど。
def wait(drv, sec, selector):
def chk(selector):
elem = drv.find_element(By.ID, selector)
if elem:
return elem
elem = drv.find_element(By.CLASS_NAME, selector)
#print("css:",type(elem), elem)
if elem:
return elem
elem = drv.find_element(By.XPATH, selector)
if elem:
return elem
elem = drv.find_elements(By.ID, selector)
if elem:
return elem
elem = drv.find_elements(By.CLASS_NAME, selector)
if elem:
return elem
return False
try:
elem = WebDriverWait(drv, sec).until(
lambda _: chk(selector)
)
return elem
except TimeoutException:
print(f"wait timeout.. {selector} not found")
return None
elem = wait(driver, 10, "elem_name")
if not elem:
print("wow, unknown error.")
てな感じですが、ちょっとイマイチchkが冗長で、何回もfind_elementをしている点が許せない感じでしょうか。さらには取得できた要素がリストの場合もあるという。。
そういう意味では、考え方をちょっと変えた方が良いようです。
また、stackoverflow では、独自クラスを作成する解答例が提示されています。チェック部分をリストで渡しておいて、どれかがヒットすればOKという感じです。なかなかスマートですので、この考え方できちんと動作するように整理してしまいましょう。
最終形
それっぽいwait実装
class AnyEc:
""" Use with WebDriverWait to combine expected_conditions
in an OR.
"""""
def __init__(self, *args):
if type(args) is tuple:
lval = list(args)
else:
lval = args
self.ecs = []
for v in lval:
if type(v) is list:
self.ecs += v
else:
self.ecs.append(v)
print("ecs type: ", type(self.ecs))
def __call__(self, driver):
#print("ecs: ", self.ecs)
for fn, param in self.ecs:
r = fn(param)
print("param: ", param, r)
if r :
return r
return False
def wait_any(drv, sec, *args):
try:
elem = WebDriverWait(drv, sec).until(
AnyEc(*args)
)
return elem
except TimeoutException:
print(f"wait timeout.. {args} not found")
return False
使い方
def make_css_selector(key):
value = []
value += ['[id="%s"]' % key]
value += ['#%s' % key]
value += [key]
value += ['[name="%s"]' % key]
value += [".%s" % key]
return value
# 利用サンプル
# アクセスするurl
url='https://ja.stackoverflow.com/'
# 探したいタグ
str='question-mini-list h3'
# お任せ部分
val = make_css_selector(str)
fn = [(driver.find_elements_by_css_selector, x) for x in val]
driver = webdriver.Chrome()
driver.get(url)
try :
# 探したいタグが見つかるまで待つ、十秒を超えたらタイムアウト
elem = wait_any(driver, 10, fn)
for e in elem:
print(e.text)
finally:
driver.close()
driver.quit()
結局そんなにスマートっぽくないですね
余談
本来はXPathをorで結合して一発でできるようにしたかったのですが、結局XPathへの変換が面倒なので断念しました
ちなみに、find_elementのソースを見て見ましょう。
https://seleniumhq.github.io/selenium/docs/api/py/_modules/selenium/webdriver/remote/webdriver.html#WebDriver.find_element
if self.w3c:
if by == By.ID:
by = By.CSS_SELECTOR
value = '[id="%s"]' % value
elif by == By.TAG_NAME:
by = By.CSS_SELECTOR
elif by == By.CLASS_NAME:
by = By.CSS_SELECTOR
value = ".%s" % value
elif by == By.NAME:
by = By.CSS_SELECTOR
value = '[name="%s"]' % value
return self.execute(Command.FIND_ELEMENT, {
'using': by,
'value': value})['value']
実は、ほぼCSS_SELECTORに差し変わっています。ですので、XPathで指定する必要がないのであれば、これを利用して、一つのfindで済むはずと考えましたのですが、なんかうまくいかないので、ここで断念した次第です。