网络编程

HPDell

请求

使用 urllib3 库发送请求和处理响应。

确定 URL

只有确定了 URL ,才能使用库发送请求。 通常有两种方式:根据文档构造、从页面获取。

根据文档构造

适用于网络接口,有文档说明接口如何使用。 例如,查看“高德地图搜索 POI1” 的接口文档

base_url = 'https://restapi.amap.com'
query = {
  'keywords': '北京大学',
  'city': 'beijing',
  'offset': 20,
  'page': 1,
  'extensions': 'all'
}
query_string = '&'.join([f'{k}={v}' for k, v in query.items()])
f'{base_url}/v3/place/text?{query_string}'
'https://restapi.amap.com/v3/place/text?keywords=北京大学&city=beijing&offset=20&page=1&extensions=all'

有一个库函数可以直接实现这个过程,并且进行了额外的处理。

from urllib.parse import urlencode
query_string = urlencode(query)
print(f'{base_url}/v3/place/text?{query_string}')
https://restapi.amap.com/v3/place/text?keywords=%E5%8C%97%E4%BA%AC%E5%A4%A7%E5%AD%A6&city=beijing&offset=20&page=1&extensions=all

转义字符

在 URL 中一些特殊字符(如 , + | 等)需要使用转义字符表示,避免产生冲突。 例如如果参数值有 & 就需要转义,否则就会被认为是一个新的参数。 而且早期 URL 仅支持 ASCII 编码,像中文这样无法用 ASCII 码表示的就只能进行转义。

从页面获取

如果不是访问接口,而是直接访问页面,那么页面中就会包含很多链接。 例如下面这个网页每个房源的标题就都包含一个链接。

<dd>
  <h4 class="clearfix">
    <a ps="1_1_60" href="/chushou/3_183860422.htm" target="_blank" data_channel="2,2"
      title="小两口房,低价出售,好房不等人,先到先得!!">
      <span class="tit_shop"> 小两口房,低价出售,好房不等人,先到先得!!</span>
    </a>
  </h4>
  <p class="tel_shop"> 1室1厅 <i>|</i> 59.99㎡ <i>|</i>
    <a class="link_rk" href="//baike.fang.com/item/中层/12851744" target="_blank">中层</a>
    (共11层) <i>|</i> 西向 <i>|</i> 2012年建 <i>|</i>
    <span class="people_name">
      <a nofollow="" href="/agentshop/1165372.html"
        title="访问[李庆孟]的个人网上店铺,查看更多房源" target="_blank">李庆孟
      </a>
    </span>
  </p>
</dd>

发送请求

urllib3 包中的 request 函数负责发送请求并解析 HTTP 响应。

import urllib3
res_hp_list = urllib3.request("GET", "https://zz.esf.fang.com/")
if res_hp_list.status == 200:
  print(res_hp_list.data[:1000].decode())
<!DOCTYPE html><html><head><title>【郑州二手房|郑州二手房出售】 - 郑州房天下</title><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta http-equiv="pragma" content="no-cache" /><meta http-equiv="Cache-Control" content="no-cache, must-revalidate" /><meta name="mobile-agent" content="format=html5;url=https://m.fang.com/esf/zz/"><meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" /><meta name="renderer" content="webkit" /><meta http-equiv="Content-Language" content="zh-cn" /><meta property="og:type" content="image" /><meta property="og:image" content="//imgwcs3.soufunimg.com/news/2020_09/17/d9530978-ffc2-44a7-84e8-6be1bdcce6b3.png" /><link rel="dns-prefetch" href="//cdnsfb.soufunimg.com" /><link rel="dns-prefetch" href="//esf.js.soufunimg.com" /><link rel="dns-prefetch" href="//img1n.soufunimg.com" /><link rel="dns-prefetch" href="//img11.soufunimg.com" /><link rel="dns-prefetch" href="//js.ub.fang.com" /><link rel="dns-prefetch" href="

带参数的请求

from pathlib import Path
res_hp_item = urllib3.request(
  "GET",
  "https://zz.esf.fang.com/chushou/3_183860422.htm",
  fields={'channel': '2,2', 'psid': '1_1_60'}
)
if res_hp_item.status == 200:
  print(res_hp_item.data[:1000].decode())
<!DOCTYPE html><html><head><title>小两口房,低价出售,好房不等人,先到先得!!,郑州郑东新区CBD豫航泊郡二手房一室 - 房天下</title><meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" /><meta name="renderer" content="webkit" /><link rel="dns-prefetch" href="//cdnsfb.soufunimg.com" /><link rel="dns-prefetch" href="//esf.js.soufunimg.com" /><link rel="dns-prefetch" href="//img1n.soufunimg.com" /><link rel="dns-prefetch" href="//img11.soufunimg.com" /><link rel="dns-prefetch" href="//js.ub.fang.com" /><link rel="dns-prefetch" href="//clickn.fang.com" /><link rel="dns-prefetch" href="//countubn.3g.fang.com" /><link rel="dns-prefetch" href="//countpvn.light.fang.com" /><link rel="dns-prefetch" href="//www.google-analytics.com" /><meta name="mobile-agent" content="format=html5;url=https://m.fang.com/esf/zz/AGT_183860422.html"><link rel="alternate" media="only screen and(max-width:640px)" href="https://m.fang.com/esf/zz/AGT_183860422.html"><met

带参数的 POST 请求

如果是 POST 请求,那么查询字符串只能手动生成

encoded_args = urlencode({"arg": "value"})
url = "https://httpbin.org/post?" + encoded_args
resp = urllib3.request("POST", url, fields={"field": "value"})

此时 fields 参数的值将作为 FormData 格式的请求体传输。

传递 JSON 数据

如果请求中需要发送 JSON 数据,使用 json 参数

resp = urllib3.request(
    "POST",
    "https://httpbin.org/post",
    json={"attribute": "value"},
    headers={"Content-Type": "application/json"}
)

处理响应

request() 函数的返回值就是解析后的响应,通过检查 statusdata 就可以获取状态码和响应数据。

处理状态码

res_test_status = urllib3.request(
  "GET",
  "https://httpbin.org/post"
)
if res_test_status.status == 200:
  print("Success")
else:
  print(f"Status: {res_test_status.status}")
Status: 405

解析 JSON 响应

通常情况下,直接以字符串的方式处理响应体是非常困难的。 由于响应体大多都遵循一定的格式,所以可以将其解析为相应的类对象,则会更方便,这个过程是“反序列化”。 JSON 格式的响应体可以直接用 .json() 函数转换为字典对象,

res_test_json = urllib3.request(
  "GET",
  "https://api.xygeng.cn/one"
)
if res_test_json.status == 200:
  print(res_test_json.json())
{'code': 200, 'data': {'id': 3002, 'tag': '偏爱', 'name': '佚名', 'origin': '枫雨断肠人', 'content': '我爱你,如果我想要的,一开始你就给了我,那我就失去了,与你纠缠不休的理由,你也知道,我没有多余的故事,也只有你这一件往事,再见', 'created_at': '2019-05-12T11:11:06.000Z', 'updated_at': '2022-03-09T08:42:10.000Z'}, 'error': None, 'updateTime': 1717603799442}

解析 HTML 响应

HTML 本身是一种 XML 格式的扩展,通常我们使用 parsel 等库进行解析, 使用 XPATH 或者 CSS Selector 提取数据。

from parsel import Selector
if res_hp_list.status == 200:
  s = Selector(text=res_hp_list.data.decode())
  hp_list = s.xpath('//div[@class="shop_list shop_list_4"]/dl/dd/h4/a/span/text()')
  print([x.get().strip() for x in hp_list[:5]])
['吉祥花园 3室2厅2卫 精装 179万元 131.3平米', '一梯两户 小高层 带电梯 大三房 得房率高', '燕庄地铁口 大四房 中间楼层 南北通透 小区带空中花园', '融创金水府 3室2厅2卫 精装 243万元', '融创金水府 3室2厅1卫 精装 196万元']
if res_hp_list.status == 200:
  s = Selector(text=res_hp_list.data.decode())
  hp_pages = s.css('div.shop_list dl dd h4 a::attr(href)').getall()
  print(hp_pages[:5])
['/chushou/3_183858652.htm', '/chushou/3_183858647.htm', '/chushou/3_183858404.htm', '/chushou/3_183840914.htm', '/chushou/3_183858420.htm']

反序列化为类对象

如果返回值不是 JSON 格式而是 XML 格式,那么就不能直接转化为字典了。 有的时候字典使用起来不太方便,例如要不断地查看返回样例确定返回数据的结构以确定键名。 这时可以使用 xsdata 包提供的反序列化的功能将返回值直接转化为 Python 类对象。

from xsdata.formats.dataclass.parsers import XmlParser
from xsdata.formats.dataclass.parsers.config import ParserConfig

@dataclass
class RSS:
    channel: RSSChannel = field(metadata={'type': 'Element'})

def feed_parse(feed: FeedSource):
    rss_res: res.HTTPResponse = http.request('GET', feed.url)
    if rss_res.status == 200:
        rss_body = ''.join([x for x in rss_res.data.decode() if x.isprintable()])
        rss = xmlparser.from_string(rss_body, RSS)
        torrent_upload_batch(rss.channel.item, feed)

案例:高德地图 POI 获取

高德地图 API

什么是 API

API 的全称是“应用程序编程接口”,通常是指软件与软件之间的交互方式,通常由一个程序(调用方)通过接口调用另一个程序。 “接口”类似于一种规范,调用方只要按照这种规范使用这个接口,就能实现相应的功能,而不需要关心这个功能具体是如何实现的。

图为硬件接口,但与软件接口的概念类似。

高德开放平台

大多数手机软件之所以可以工作,是因为有服务器提供了接口(也称服务),使很多操作可以通过服务器进行。 高德将高德地图所使用的一些接口开放了出来,使得其他软件也可以使用,这就是高德开放平台。

若要使用高德开放平台提供的服务,需要先进行开发者认证,并创建密钥(key)。

设计思路

流程图可以帮助我们理清爬虫编写的思路。

  • 城市和类别需要通过编码表获取
  • 通过不断构造 URL 中的查询字符串就能不断获取下一页
  • 总数可以通过第一页中的结果得知
  • 所有数据可以保存成 CSV 文件

flowchart TD
  A[确定城市和类别] --> B
  B[获取第1页并提取总数] --> C
  C{是否已获取所有 POI} -->|是| D
  C -->|否| E
  D[获取下一页] --> C
  E[保存所有数据]

基本爬虫

POI 分类编码和城市编码

POI 分类编码分为:大类、中类、小类。 每一级用两位数字进行编码,例如“公共停车场”的编码是 15090415 表示“交通设施服务”,09 表示停车场,04 表示公共停车场。

提示

由于高德 POI 查询超过 100 页的时候会有问题,所以通常我们选择最相关的中类和小类,以减少该类 POI 的数量。

城市可以直接使用中文或拼音,也可以使用编码,如郑州为 410100

获取一页数据

from pathlib import Path
from urllib3 import request

MY_KEY = Path('./key_amap.txt').read_text().strip()
GET = "GET"
BASE_URL = 'https://restapi.amap.com/v3/place/text'
BASE_QS = {
  'key': MY_KEY,
  'types': '150900',
  'city': '410100',
  'citylimit': True,
  'offset': 20
}

def get_page(page):
  resp = request(GET, BASE_URL, fields={
    **BASE_QS,
    'page': page
  })
  if resp.status == 200:
    res = resp.json()
    if res['status'] == "1":
      total_count = res['count']
      pois = res['pois']
      return (int(total_count), pois)
    else:
      raise ValueError("Response status is zero", res['info'])
  else:
    raise ValueError("Request failed", resp.status)

total, pois = get_page(1)
",".join([x['name'] for x in pois[:5]])
'只有河南·戏剧幻城小车停车场,杉杉奥特莱斯广场·郑州停车场,银基动物王国停车楼,郑州东站P2停车场,郑州东站P1停车场'

整体逻辑

import json
from time import sleep

def page_num(page_size, total):
  return total // page_size + int(total % page_size > 0)

def get_all():
  total_count, pois = get_page(1)
  pages = page_num(BASE_QS['offset'], total_count)
  for i in range(2, pages + 1):
    pois.extend(get_page(i)[1])
    sleep(0.1)
  Path("./assets/parkings.json").write_text(json.dumps(pois, ensure_ascii=False))

get_all()

检查数据

poi_data = json.loads(Path("./assets/parkings.json").read_text())
for item in poi_data[:10]:
  print('{id},{name},{location}'.format_map(item))
B0HGP7UGL5,只有河南·戏剧幻城小车停车场,114.003961,34.799213
B0GK27JD5L,杉杉奥特莱斯广场·郑州停车场,114.029116,34.781271
B0GDR7C4UR,银基动物王国停车楼,113.543218,34.483879
B0FFHOERPC,郑州东站P2停车场,113.782003,34.756080
B0FFG2RZP2,郑州东站P1停车场,113.777863,34.757017
B0FFF5V5OD,河南博物院南门西停车场,113.671751,34.787435
B017316LTR,绿博园北门西停车场,113.926538,34.759211
B017316BLZ,郑州国际会展中心东北停车场,113.728744,34.774586
B01730I1ZG,始祖山天中门停车场,113.534903,34.344233
B0G2KRVC24,郑州新郑国际机场5号停车楼,113.843872,34.527225

改进爬虫

现在这个爬虫有几个问题:

  • 没有进行异常处理,一旦无法正常获取数据,爬虫会终止。
  • 所有数据在爬取完毕才保存,一旦中间出现异常,所有数据都会丢失(即时存档)。
  • 爬虫中断后,无法恢复爬虫,只能从头开始爬取(断点续爬)。
  • 爬虫只适用于这一种情况,如果要换一个城市或类型,则只能修改源代码(参数化)。
  • key 的调用次数有上限,无法利用多个 key 提高每日爬取量。

这些问题将在后面逐步解决。