使用 Selenium 抓取 Google 趋势的热门搜索排行榜

本文以 Google 趋势为例,总结在抓取全动态网页信息时遇到的几个问题及对应的解决方法。包括如何等待动态获取的内容加载完成,以及当搜索到的对象不在可视范围内不可被点击等。

注意:本文内容具有时效性,只保证在撰写当时是正确可用的,Google 的网站更新变化后,代码的抓取结果不可预测。

另外,业余实习僧,非专业码农,纯属给自己写备忘录,技术层面难登大雅之堂,见谅。

背景

Google 趋势热门搜索排行榜 是个有趣的网页,如字面所示,它提供了全球各地在指定历史年月的热门搜索的关键字榜单,按排名算每个种类提供最多的 10 个,若有并列则向后顺延。通过分析上面的数据,可以对网络流行趋势和社会热点有一个大概的把握。

网页本身不提供内容下载通道,手动整理相当低效,于是自然而然的应了那句老话——能用代码解决的问题不要复制粘贴——好吧,这只是我一家之言。

这是个全动态渲染的网页,禁用 JavaScript 后一片空白,查看源代码发现其中 80% 的部分是 JS 脚本,HTML 只占很少一部分。用传统的抓取静态网页解析 HTML 标签的办法无法获取其中的内容,需要专门的处理手段。

最著名的莫过于使用 Selenium WebDriver 引擎来驱动实体浏览器对网页进行解析,然后从浏览器的结果中提取信息。

关于如何上手使用这一框架的教程一大堆,你转我的我转你的,搜索一大片所获得的还是写差不多的内容,不是很具体和详细。我在实际使用过程中遇到了两个大坑,因为很少有人给出简单有效的解决办法,所以花了不少时间才得以解决。现在把个人经验总结于此,以来日后自己忘了可以回查,二来如果有幸能帮助到有同样困惑的人,也算好事一桩。

准备工作

环境:Python 3.5、Selenium 3.0.2、ChromeDriver,具体配置方法从略。


Google 趋势的热门搜索排行榜的地址是https://trends.google.com/trends/topcharts,在其后用#作为分隔来添加参数,geo表示 地区date表示 时间(年月),不同参数用&隔开,例如查询 美国 2016 年 9 月 的排行榜,就在 URL 后添加#geo=US&date=201609。这是基本的 URL 约定,不再赘述。


添加引用

from selenium import webdriver

定义网页引擎并打开指定页面

driver = webdriver.Chrome()
driver.get("https://trends.google.com/trends/topcharts#geo=US&date=201611")

解决等待页面内容加载的问题

抓取内容需要等待目标元素被加载后才可以进行,否则会引起无法定位元素的异常。在静态网页中,页面加载结束后所有的内容就都已经存在在浏览器中,但是在动态加载的网页中,页面加载完毕后,动态加载的元素不一定已经被获取,需要确保目标元素已经完成加载后在进行抓取操作。

在网上查询解决方案时大多为很鸡肋的“硬方法”, 即人为将程序暂停一段时间,等待页面加载完成。

import time

driver = webdriver.Chrome()
driver.get("https://trends.google.com/trends/topcharts#geo=US&date=201611")

# Wait for completion.
time.sleep(3)

# Extract information.

这样做弊病很多,一方面由于网络环境的不确定性,程序无法确保在规定等待时间结束后目标元素已经加载完成;另一方面如果在指定时间内就已经加载完成,则会造成不必要的时间浪费。无论哪一种都不是理想的解决思路。

应该使用 Selenium 框架提供的官方解决方案,由检测目标元素的可见性确定加载是否完成,阻塞程序然后再进行下一步的处理。

alecxe, MrE - StakOverflow
You need to do this step by step checking the visibility of the elements you are going to interact with using Explicit Waits, do not use time.sleep() - it is not reliable and error-prone.

为此,新增引用

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

指定第一个要查找的目标元素,在这里也就是 Google 趋势页面上的一个分类的名字,用 XPath 来定位,并且使用官方提供的“等待直到”方法来等待目标元素加载完成

xpath = '//*[@id="djs-trending"]/div/a/div[1]/div/span'
element = WebDriverWait(driver, 10).until(EC.visibility_of_element_located((By.XPATH, xpath)))

如此,程序在寻找目标元素时会被阻塞,直到在浏览器中能够找到该元素,也即该元素加载完成,即可恢复执行,进行后面的操作。

实际上,除了 显式等待 以外还有 隐式等待 可以使用,这两点的用法在 官方文档 中有详细的说明。比起显式等待来说,隐式等待更有“一劳永逸”的效果,只要进行如下设置

# Set timeout to 10 seconds.
driver.implicitly_wait(10)

即可在后续的操作中的每一步都进行加载完成与否的检验,比显式等待要清爽得多。

解决目标元素不在可视范围内无法点击的问题

个别时候,并不是任何时候,在获取到目标元素后,对其发送点击事件或者键盘事件时,会提示元素无法接收该事件,事件会被其他元素拦截或者找不到该对象。在确定无疑不是新弹出的上层元素将其覆盖的情况下,这可能是因为目标元素没有出现在浏览器可见范围内而导致的。

并不清楚背后的原理,但是解决思路简单暴力——将目标元素滚动到可视范围内来。可以通过对可接受事件的元素发送按键事件来模拟向下滚动,也可以通过 JS 来实现。最为精准而安全的措施是直接将对象滚动到可视范围的最顶端,类似页面内书签的定位

# Scroll element to the top edge of the view.
driver.execute_script("return arguments[0].scrollIntoView();", element)

而后再进行键鼠事件操作即可。

完整代码

# Get top 10 keywords in https://trends.google.com/trends/topcharts
# Boss Ox / 2017.02.20 / Beijing @ByteDance

import threading
import time

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

# Settings.
URL = 'https://trends.google.com/trends/topcharts#geo=US&date='
SaveFolder = r'F:\Project\Python\GoogleTrends' + '\\'
ConcurrentNumber = 5

# Date to fetch.
Dates = [
    '201611',
    '201610',
    '201609',
    '201608',
    '201607',
    '201606',
    '201605',
    '201604',
    '201603',
    '201602',
    '201601'
]

# Genres to fetch, acquired by category text in page, by XPath.
Genres = [
    '//*[@id="djs-trending"]/div/a/div[1]/div/span',
    '//*[@id="people-trending"]/div/a/div[1]/div/span',
    '//*[@id="authors-trending"]/div/a/div[1]/div/span',
    '//*[@id="childrens_tv_programs-trending"]/div/a/div[1]/div/span',
    '//*[@id="animals-trending"]/div/a/div[1]/div/span',
    '//*[@id="countries-trending"]/div/a/div[1]/div/span',
    '//*[@id="books-trending"]/div/a/div[1]/div/span',
    '//*[@id="cities-trending"]/div/a/div[1]/div/span',
    '//*[@id="celestial_objects-trending"]/div/a/div[1]/div/span',
    '//*[@id="whiskey-top"]/div/a/div[1]/div/span',
    '//*[@id="fast_food_restaurants-trending"]/div/a/div[1]/div/span',
    '//*[@id="governmental_bodies-top"]/div/a/div[1]/div/span',
    '//*[@id="politicians-trending"]/div/a/div[1]/div/span',
    '//*[@id="fashion_labels-top"]/div/a/div[1]/div/span',
    '//*[@id="baseball_players-trending"]/div/a/div[1]/div/span',
    '//*[@id="baseball_teams-trending"]/div/a/div[1]/div/span',
    '//*[@id="songs-top"]/div/a/div[1]/div/span',
    '//*[@id="automobile_models-trending"]/div/a/div[1]/div/span',
    '//*[@id="auto_companies-top"]/div/a/div[1]/div/span',
    '//*[@id="games-top"]/div/a/div[1]/div/span',
    '//*[@id="actors-trending"]/div/a/div[1]/div/span',
    '//*[@id="dog_breeds-trending"]/div/a/div[1]/div/span',
    '//*[@id="sports_teams-trending"]/div/a/div[1]/div/span',
    '//*[@id="films-trending"]/div/a/div[1]/div/span',
    '//*[@id="tv_shows-trending"]/div/a/div[1]/div/span',
    '//*[@id="reality_shows-trending"]/div/a/div[1]/div/span',
    '//*[@id="scientists-trending"]/div/a/div[1]/div/span',
    '//*[@id="basketball_players-trending"]/div/a/div[1]/div/span',
    '//*[@id="basketball_teams-top"]/div/a/div[1]/div/span',
    '//*[@id="us_governors-top"]/div/a/div[1]/div/span',
    '//*[@id="foods-top"]/div/a/div[1]/div/span',
    '//*[@id="energy_companies-top"]/div/a/div[1]/div/span',
    '//*[@id="medicines-top"]/div/a/div[1]/div/span',
    '//*[@id="soccer_players-trending"]/div/a/div[1]/div/span',
    '//*[@id="soccer_teams-trending"]/div/a/div[1]/div/span',
    '//*[@id="sports_cars-trending"]/div/a/div[1]/div/span',
    '//*[@id="programming_languages-top"]/div/a/div[1]/div/span',
    '//*[@id="athletes-trending"]/div/a/div[1]/div/span',
    '//*[@id="financial_companies-top"]/div/a/div[1]/div/span',
    '//*[@id="retail_companies-top"]/div/a/div[1]/div/span',
    '//*[@id="teen_pop_artists-trending"]/div/a/div[1]/div/span',
    '//*[@id="musicians-trending"]/div/a/div[1]/div/span',
    '//*[@id="beverages-top"]/div/a/div[1]/div/span',
    '//*[@id="colleges_universities-trending"]/div/a/div[1]/div/span',
    '//*[@id="cocktails-top"]/div/a/div[1]/div/span'
]

# Fetch information in each genre on date.
def getTrendsOnDate(month):
    url = URL + month
    driver = webdriver.Chrome() # PhantomJS can fail extracting second item. DKW.
    results = {}

    try:
        for genre in Genres:
            # Load page.
            driver.get(url)

            # Wait for completion.
            element = WebDriverWait(driver, 10).until(EC.visibility_of_element_located((By.XPATH, genre)))

            # Find genre.
            element = driver.find_element_by_xpath(genre)
            if element != None:
                # Get genre text.
                genre_text = element.text

                # Scroll down to element
                driver.execute_script('return arguments[0].scrollIntoView();', element)

                # Open genre sub-page.
                element.click()

                # Wait for completion.
                first_item_xpath = '/html/body/div[23]/div[2]/div/div[1]/div/span/div/span[1]/div/a'
                WebDriverWait(driver, 10).until(EC.visibility_of_element_located((By.XPATH, first_item_xpath)))

                # Extract information of top 10 items.
                items = []
                for i in range(1, 11):
                    item_xpath = '/html/body/div[23]/div[2]/div/div[%d]/div/span/div/span[1]/div/a' % (i)
                    element = driver.find_element_by_xpath(item_xpath)
                    items.append(element.text)

                # Store results.
                results[genre_text] = items
            else:
                # Genre not found, skip this genre.
                pass
    except:
        # Anything wrong happens, just output what we have till now.
        pass

    # Output results.
    outputResults(month, results)

    # Close driver.
    driver.quit()

def outputResults(month, results):
    filename = SaveFolder + month + '.txt'

    try:
        with open(filename, 'w', encoding= 'utf_8_sig') as file:
            for result in results:
                for item in results[result]:
                    line = '%s\t%s'%(result, item)
                    file.writelines(line + '\n')
        print('[ %s ] Completed.'%(month))
    except Exception as e:
        print('[ %s ] Error on writing file %s.\n           %s'%(month, filename, e.args))

# Program Entrance.
while len(Dates) > 0:
    # Get data on target date.
    target = Dates.pop()
    print('[ %s ] task started.' % (target))
    task = threading.Thread(target= getTrendsOnDate, args= {target, })
    task.start()

    # Limit concurrent thread number.
    while threading.activeCount() > ConcurrentNumber:
        time.sleep(0.2)

总结

这段代码还有很多待完善的地方,比如巨大的方法应该被拆分重构,对页面的解析容错度较小,性能有待优化,以及采用 PhantomJS 引擎时莫名其妙的信息丢失问题等。但是秉承着“先实现功能解决问题,再花精力想如何做好”的观念,有了能用的工具我就挺开心的了哈哈哈。

虽然 Python 解释器的 GIL 机制使多线程性能大打折扣,但聊胜于无,多开之后的执行效率还是有明显提升的。

一句心得:多花时间研究官方文档。


一点题外话:新学期刚开始,选了一门“计算社会学”课程作为选修,成功以经济学院学生身份打入信息学院内部,课后闲聊竟偶遇在 Programmer at RUC 群里认识的好友,也是缘分。比起我这三天打鱼两天晒网的懒散人士,人家对计算机科学学习的兴趣可是浓厚多了,谈起我没学过的数据结构和算法,真是惭愧不如。同学简书账号 CarbonCheney,写了不少深度技术文章,值得一看。

参考与引用

  1. http://stackoverflow.com/questions/27934945/selenium-move-to-element-does-not-always-mouse-hover
  2. http://blog.likewise.org/2015/04/scrolling-to-an-element-with-the-python-bindings-for-selenium-webdriver/
  3. http://selenium-python.readthedocs.io/waits.html
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,753评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,668评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,090评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,010评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,054评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,806评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,484评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,380评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,873评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,021评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,158评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,838评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,499评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,044评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,159评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,449评论 3 374
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,136评论 2 356

推荐阅读更多精彩内容