1. 程式人生 > 其它 >資料採集實戰(一)-- 鏈家網成交資料 (by puppeteer)

資料採集實戰(一)-- 鏈家網成交資料 (by puppeteer)

概述

最近在學習python的各種資料分析庫,為了嘗試各種庫中各種分析演算法的效果,陸陸續續爬取了一些真實的資料來。

順便也練習練習爬蟲,踩了不少坑,後續將採集的經驗逐步分享出來,希望能給後來者一些參考,也希望能夠得到先驅者的指點!

採集工具

其實基本沒用過什麼現成的採集工具,都是自己通過編寫程式碼來採集,雖然耗費一些時間,但是感覺靈活度高,可控性強,遇到問題時解決的方法也多。

一般根據網站的情況,如果提供API最好,直接寫程式碼通過訪問API來採集資料。
如果沒有API,就通過解析頁面(html)來獲取資料。

本次採集的資料是鏈家網上的成交資料,因為是學習用,所以不會去大規模的採集,只採集了南京各個區的成交資料。

採集使用puppeteer庫,Puppeteer 是一個 Node 庫,它提供了高階的 API 並通過 DevTools 協議來控制 Chrome(或Chromium)。
通俗來說就是一個 headless chrome 瀏覽器: https://github.com/puppeteer/puppeteer

通過 puppeteer,可以模擬網頁的手工操作方式,也就是說,理論上,能通過瀏覽器正常訪問看到的內容就能採集到。

採集過程

其實資料採集的程式碼並不複雜,時間主要花在頁面的分析上了。

鏈家網的成交資料不用登入也可以訪問,這樣就省了很多的事情。
只要找出南京市各個區的成交資料頁面的URL,然後訪問就行。

頁面分析

下面以棲霞區的成交頁面為例,分析我們可能需要的資料。

頁面URL: https://nj.lianjia.com/chengjiao/qixia/

根據頁面,可以看出重複的主要是紅框內的資料,其中銷售人員的姓名涉及隱私,我們不去採集。
採集的資料分類為:(有的戶型可能沒有下面列的那麼全,缺少房屋優勢欄位,甚至成交價格欄位等等)

  1. name: 小區名稱和房屋概要,比如:新城香悅瀾山 3室2廳 87.56平米
  2. houseInfo: 房屋朝向和裝修情況,比如:南 北 | 精裝
  3. dealDate: 成交日期,比如:2021.06.14
  4. totalPrice: 成交價格(單位: 萬元),比如:338萬
  5. positionInfo: 樓層等資訊,比如:中樓層(共5層) 2002年建塔樓
  6. unitPrice: 成交單價,比如:38603元/平
  7. advantage: 房屋優勢,比如:房屋滿五年
  8. listPrice: 掛牌價格,比如:掛牌341萬
  9. dealCycleDays: 成交週期,比如:成交週期44天

核心程式碼

鏈家網上採集房產成交資料很簡單,我在採集過程中遇到的唯一的限制就是根據檢索條件,只返回100頁的資料,每頁30條。
也就是說,不管什麼檢索條件,鏈家網只返回前3000條資料。
可能這也是鏈家網控制伺服器訪問壓力的一個方式,畢竟如果是正常使用者訪問的話,一般也不會看3000條那麼多,返回100頁資料綽綽有餘。

為了獲取想要的資料,只能自己設計下檢索條件,保證每個檢索條件下的資料不超過3000條,最後自己合併左右的採集結果,去除重複資料。

這裡,只演示如何採集資料,具體檢索條件的設計,有興趣根據自己需要的資料嘗試下即可,沒有統一的方法。

通過puppeteer採集資料,主要步驟很簡單:

  1. 啟動瀏覽器,開啟頁面
  2. 解析當前頁面,獲取需要的資料(也就是上面列出的9個欄位的資料)
  3. 進入下一頁
  4. 如果是最後一頁,則退出程式
  5. 如果不是最後一頁,進入步驟2

初始化並啟動頁面

import puppeteer from "puppeteer";

(async () => {
  // 啟動頁面,得到頁面物件
  const page = await startPage();
})();

// 初始化瀏覽器
const initBrowser = async () => {
  const browser = await puppeteer.launch({
    args: ["--no-sandbox", "--start-maximized"],
    headless: false,
    userDataDir: "./user_data",
    ignoreDefaultArgs: ["--enable-automation"],
    executablePath:
      "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
  });

  return browser;
};

// 啟動頁面
const startPage = async (browser) => {
  const page = await browser.newPage();
  await page.setViewport({ width: 1920, height: 1080 });

  return page;
};

採集資料

import puppeteer from "puppeteer";

(async () => {
  // 啟動頁面,得到頁面物件
  const page = await startPage();
  
  // 採集資料
  await nanJin(page);
})();

const mapAreaPageSize = [
  // { url: "https://nj.lianjia.com/chengjiao/gulou", name: "gulou", size: 2 }, // 測試用
  { url: "https://nj.lianjia.com/chengjiao/gulou", name: "gulou", size: 30 },
  { url: "https://nj.lianjia.com/chengjiao/jianye", name: "jianye", size: 20 },
  {
    url: "https://nj.lianjia.com/chengjiao/qinhuai",
    name: "qinhuai",
    size: 29,
  },
  { url: "https://nj.lianjia.com/chengjiao/xuanwu", name: "xuanwu", size: 14 },
  {
    url: "https://nj.lianjia.com/chengjiao/yuhuatai",
    name: "yuhuatai",
    size: 14,
  },
  { url: "https://nj.lianjia.com/chengjiao/qixia", name: "qixia", size: 14 },
  {
    url: "https://nj.lianjia.com/chengjiao/jiangning",
    name: "jiangning",
    size: 40,
  },
  { url: "https://nj.lianjia.com/chengjiao/pukou", name: "pukou", size: 25 },
  { url: "https://nj.lianjia.com/chengjiao/liuhe", name: "liuhe", size: 4 },
  { url: "https://nj.lianjia.com/chengjiao/lishui", name: "lishui", size: 4 },
];

// 南京各區成交資料
const nanJin = async (page) => {
  for (let i = 0; i < mapAreaPageSize.length; i++) {
    const areaLines = await nanJinArea(page, mapAreaPageSize[i]);

    // 分割槽寫入csv
    await saveContent(
      `./output/lianjia`,
      `${mapAreaPageSize[i].name}.csv`,
      areaLines.join("\n")
    );
  }
};

const nanJinArea = async (page, m) => {
  let areaLines = [];
  for (let i = 1; i <= m.size; i++) {
    await page.goto(`${m.url}/pg${i}`);
    // 等待頁面載入完成,這是顯示總套數的div
    await page.$$("div>.total.fs");
    await mouseDown(page, 800, 10);

    // 解析頁面內容
    const lines = await parseLianjiaData(page);
    areaLines = areaLines.concat(lines);

    // 儲存頁面內容
    await savePage(page, `./output/lianjia/${m.name}`, `page-${i}.html`);
  }

  return areaLines;
};

// 解析頁面內容
// 1. name: 小區名稱和房屋概要
// 2. houseInfo: 房屋朝向和裝修情況
// 3. dealDate: 成交日期
// 4. totalPrice: 成交價格(單位: 萬元)
// 5. positionInfo: 樓層等資訊
// 6. unitPrice: 成交單價
// 7. advantage: 房屋優勢
// 8. listPrice: 掛牌價格
// 9. dealCycleDays: 成交週期
const parseLianjiaData = async (page) => {
  const listContent = await page.$$(".listContent>li");

  let lines = [];
  for (let i = 0; i < listContent.length; i++) {
    try {
      const name = await listContent[i].$eval(
        ".info>.title>a",
        (node) => node.innerText
      );
      const houseInfo = await listContent[i].$eval(
        ".info>.address>.houseInfo",
        (node) => node.innerText
      );
      const dealDate = await listContent[i].$eval(
        ".info>.address>.dealDate",
        (node) => node.innerText
      );
      const totalPrice = await listContent[i].$eval(
        ".info>.address>.totalPrice>.number",
        (node) => node.innerText
      );
      const positionInfo = await listContent[i].$eval(
        ".info>.flood>.positionInfo",
        (node) => node.innerText
      );
      const unitPrice = await listContent[i].$eval(
        ".info>.flood>.unitPrice>.number",
        (node) => node.innerText + "元/平"
      );
      let advantage = "";
      try {
        advantage = await listContent[i].$eval(
          ".info>.dealHouseInfo>.dealHouseTxt>span",
          (node) => node.innerText
        );
      } catch (err) {
        console.log("err is ->", err);
        advantage = "";
      }

      const [listPrice, dealCycleDays] = await listContent[i].$$eval(
        ".info>.dealCycleeInfo>.dealCycleTxt>span",
        (nodes) => nodes.map((n) => n.innerText)
      );

      console.log("name: ", name);
      console.log("houseInfo: ", houseInfo);
      console.log("dealDate: ", dealDate);
      console.log("totalPrice: ", totalPrice);
      console.log("positionInfo: ", positionInfo);
      console.log("unitPrice: ", unitPrice);
      console.log("advantage: ", advantage);
      console.log("listPrice: ", listPrice);
      console.log("dealCycleDays: ", dealCycleDays);
      lines.push(
        `${name},${houseInfo},${dealDate},${totalPrice},${positionInfo},${unitPrice},${advantage},${listPrice},${dealCycleDays}`
      );
    } catch (err) {
      console.log("資料解析失敗:", err);
    }
  }

  return lines;
};

我是把要採集的頁面列在 const mapAreaPageSize 這個變數中,其中 url 是頁面地址,size 是訪問多少頁(根據需要,並不是每個檢索條件都要訪問100頁)。

採集資料的核心在 parseLianjiaData 函式中,通過 chrome 瀏覽器的debug模式,找到每個資料所在的頁面位置。
puppeteer提供強大的html 選擇器功能,通過html元素的 idclass 可以很快定位資料的位置(如果用過jQuery,很容易就能上手)。
這樣,可以避免寫複雜的正則表示式,提取資料更方便。

採集之後,我最後將資料輸出成 csv 格式。

注意事項

爬取資料只是為了研究學習使用,本文中的程式碼遵守:

  1. 如果網站有 robots.txt,遵循其中的約定
  2. 爬取速度模擬正常訪問的速率,不增加伺服器的負擔
  3. 只獲取完全公開的資料,有可能涉及隱私的資料絕對不碰