上一篇博客 "爬蟲讓我再次在女同學面前長臉了~(現實版真實案例)" 說到了幫女同學批量下載試題,我把文章同步到了CSDN,竟然有41個贊 + 21個評論 + 155個收藏,難道大家和我的目的都一樣:爬蟲 liao mei ?
本篇文章主要介紹如何「快速」抓取淘寶商品信息,從幾個維度統計並且進行可視化,本次案例使用關鍵字 「安踏籃球鞋男鞋」,(僅僅是因為我最近買了一雙鞋子,然後就想到了這個商品而已)
2. 故事的背景沒有故事,沒有背景,就是突然想。。。
3. 爬蟲的分類?先來接地氣幾個詞,一般來說爬蟲可以分為 【通用爬蟲 、垂直爬蟲】,那麼是如何定義的呢?
通用爬蟲:通用爬蟲不需要理會網站哪些資源是需要的,哪些是不需要的,一併抓取並將其文本部分做索引(比如:百度爬蟲、搜狗爬蟲。。。)垂直爬蟲:垂直爬蟲往往在某一領域具有其專注性,並且垂直爬蟲往往只需要其中一部分具有垂直性的資源,所以垂直爬蟲相比通用爬蟲更加精確,這也是比較常見的爬蟲。如果上面的解釋還不夠接地氣的話,舉個簡單例子小明想找一個女朋友,但是小明對女朋友要求為 null,只要 [ 是一個女的都行 ] 那種,這就可以理解為通用爬蟲;但是小明如果眼光比較挑,要求女朋友必須是 [ 身高165+,體重90,大眼睛,長頭髮,小蠻腰 ],這就可以理解為垂直爬蟲。
4. 淘寶對爬蟲有哪些限制?淘寶爬蟲,很多文章都在講解淘寶登錄,然後分析ua參數等,以前我也很執著去分析過,真的挺難,後面我就沒有繼續跟下去了,就使用瀏覽器驅動實現登錄,如 selenium (需要修改參數,否則淘寶能識別) 或者 JxBrowser 等等。。。
除開淘寶登錄來看,淘寶的底線還是比較低的,暫時沒有很高防線,你攜帶 cookie 即可,沒錯就是攜帶「餅乾」給淘寶,他就給你通行了,所以本篇博客是通過攜帶 cookie 進行快速獲取到數據的,(個人:有時間就去學習逆向分析,但是真正實際爬的話,使用最低的成本獲取最高的效益,巧勁最大化)
題外話,雖然說一般的大廠不輕易封IP (誤傷太大),但如果真的想海量採集淘寶商品數據的話,還必須要花點時間研究反爬策略的,否則很難進行海量抓取,因為文章後面有可能是有點觸發反爬了 (後面再說)
5. 抓包如果在電腦首次上淘寶的話,你想搜索商品是必須要求登錄的,淘寶的要求登錄就不是我上一篇文章(撩妹佳文)那種假登錄限制了,而且整體接口都要求必須攜帶憑證(cookie)訪問的了,這裡介紹一個巧勁,因為如果使用登錄的 cookie 訪問淘寶的話,淘寶實際上是知道你是誰的,有你訪問的記錄的,然後你上淘寶可能會給你推送商品的,甚至對你帳號進行限制 、反爬等,因此寫一個爬蟲沒必要搭上那麼大的風險吧,所以登錄完成之後,就退出,這個時候瀏覽器還是可以正常搜索產品的,意思就是說不要使用登錄的 cookie 進行爬取數據。。。
五個抓包步驟如下
觀察 json 結構:複製上面的抓包的數據,然後進行 json 視圖查看
如何分頁?
上面的抓包只是一頁的數據,那麼如何抓取淘寶搜索其他頁呢?我可以直接告訴你的是,通過在 url 傳遞一個 s={最後一個商品的位置} 實現的分頁...
好吧 我們來簡單看一下吧:
在上面抓包中,我們並沒有看到 url 傳遞了 s 這個參數然後我們先來點擊一下 "下一頁",多點幾次觀察幾次,你就知道了 s 參數的變化了觀察 s 變化,第二頁s=44,第三頁s=88,第四頁s=132,...
首頁沒有 s 參數
第二頁:https://s.taobao.com/search?q=%E5%AE%89%E8%B8%8F%E7%AF%AE%E7%90%83%E9%9E%8B%E7%94%B7%E9%9E%8B&imgfile=&js=1&stats_click=search_radio_all%3A1&initiative_id=staobaoz_20200705&ie=utf8&bcoffset=3&ntoffset=3&p4ppushleft=1%2C48&s=44
第三頁:https://s.taobao.com/search?q=%E5%AE%89%E8%B8%8F%E7%AF%AE%E7%90%83%E9%9E%8B%E7%94%B7%E9%9E%8B&imgfile=&js=1&stats_click=search_radio_all%3A1&initiative_id=staobaoz_20200705&ie=utf8&bcoffset=0&ntoffset=6&p4ppushleft=1%2C48&s=88
第三頁:https://s.taobao.com/search?q=%E5%AE%89%E8%B8%8F%E7%AF%AE%E7%90%83%E9%9E%8B%E7%94%B7%E9%9E%8B&imgfile=&js=1&stats_click=search_radio_all%3A1&initiative_id=staobaoz_20200705&ie=utf8&bcoffset=0&ntoffset=6&p4ppushleft=1%2C48&s=132來到這裡,可以去掉其他不必要的參數,結論是:https://s.taobao.com/search?q={url encode keywords}&s={開始位置}
6. 編碼經過上面繁瑣的步驟,終於可以著手編碼了,因為爬取數據之後需要進行可視化,所以數據肯定是需要持久化的,為了方便就保存到 csv 文件中。
逗號分隔值(Comma-Separated Values,CSV,有時也稱為字符分隔值,因為分隔字符也可以不是逗號),其文件以純文本形式存儲表格數據(數字和文本)。純文本意味著該文件是一個字符序列,不含必須像二進位數字那樣被解讀的數據。
在代碼裡注釋得很清楚的了,這裡直接貼代碼出來即可
為了簡潔,我貼出主要的代碼,想要獲取整體源碼項目的話,關注微信公告號( it_loading 回復 "淘寶爬蟲" 即可)
FastHttpClient.java
https://blog.csdn.net/JinglongSource/article/details/107136862
TaoBaoSpider1.java
import cn.shaines.spider.util.FastHttpClient;
import cn.shaines.spider.util.FastHttpClient.Response;
import cn.shaines.spider.util.FastHttpClient.ResponseHandler;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.JSONPath;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
/**
* @author for.houyu@qq.com
*/
@Slf4j
public class TaoBaoSpider1 {
private static final String listUrlTemplate = "https://s.taobao.com/search?q=${keywords}&s=${index}";
private static String cookie = "enc=l5xju4OJMzTwwtBkVJWbvFPcQ%2B6n6%2FWdRaE4iECxQOQiEtA45RBOyXQu0gZbOcIVEO6oxrRWvtu6mgSv4JZa8w%3D%3D; thw=cn; hng=CN%7Czh-CN%7CCNY%7C156; sgcookie=ESMDjIxrMIKwi48qmS8xP; tfstk=cCOGBNOa3dW_xoWHFf16ovEkfpHRZKNVqIRBTCGS8wNPBsdFiO2UUAat-GxMM-1..; tracknick=; cna=8XsbF0X0cVkCAbcg0Pt/qWLy; t=bb72251ba9e04aa4ffe5119a746b1f35; v=0; cookie2=1d43fd087e0cf8b4268f2e8ddcd4aea0; _tb_token_=e30835115bdfe; alitrackid=www.taobao.com; lastalitrackid=www.taobao.com; JSESSIONID=07CB217DBB9367EF2E0CCF7AF29AA9A0; isg=BDo6WaueMbQrb7zN4rxs3mhmi2Bc677FZ6MxoUQ_y0y9N99xLH6N1UYBh8PrpzZd; l=eBMkD1V4QZHXKy_vBO5whurza77ONdAfCsPzaNbMiInca6ZFN1uJuNQqG5uBldtjgtfj7etrb3kJjRUpziUdg2HvCbKrCyCk6Yp6-";
private FastHttpClient httpClient;
private BufferedWriter writer;
private Set<String> filter;
public static void main(String[] args) throws Exception {
// 搜索關鍵字
final String keywords = "安踏籃球鞋男鞋";
// 每頁44條數據
final int limit = 44;
TaoBaoSpider1 spider = new TaoBaoSpider1();
spider.init(keywords);
for (int page = 0; page <= 99; page++) {
log.info("正在準備下載頁數: {}", page + 1);
String html = spider.getListHtml(keywords, page * limit);
List<Goods> list = spider.parse(html);
log.info("解析得到數量: [{}]", list.size());
if (list.isEmpty()) {
break;
}
list = spider.doFilter(list);
log.info("過濾後數量: [{}]", list.size());
list.forEach(v -> {
List<String> row = Arrays.asList(v.getRaw_title(), v.getView_price(), v.getView_fee(), v.getNick(), v.getItem_loc(), v.getView_sales(), v.getPic_url(), v.getDetail_url(), v.getComment_url(), v.getShopLink(), "_" + v.getNid(), "_" + v.getPid());
spider.writeRow(row);
});
// 睡眠3 ~ 10秒
Thread.sleep(ThreadLocalRandom.current().nextLong(3000, 10000));
log.info("\r\n");
}
}
private List<Goods> doFilter(List<Goods> list) {
list = list.stream().filter(v -> !filter.contains(v.getNid())).collect(Collectors.toList());
filter.addAll(list.stream().map(Goods::getNid).collect(Collectors.toSet()));
return list;
}
/**
* 寫入一行數據
* @param row 一行數據
*/
protected void writeRow(List<String> row) {
// 寫入一行數據, csv的一行格式為,分割的, 但是這裡使用","分割,主要就是為了統一作為字符串
// 如:
// "姓名","年齡"
// "張三","123"
String line = row.stream().map(v -> v.replace("\"", "\"\"").replace(",", ",,")).collect(Collectors.joining("\",\"", "\"", "\""));
try {
writer.write(line);
writer.newLine();
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 解析html
* @param html the html
*/
protected List<Goods> parse(String html) {
if (StringUtils.isEmpty(html) || (html.contains("登錄頁面") && html.contains("全登陸不允許iframe嵌入"))) {
throw new RuntimeException("獲取列表HTML失敗,請檢查,如更新cookie等...");
}
String script = Arrays.stream(StringUtils.substringsBetween(html, "<script", "</script>"))
// 過濾包含 g_page_config 和 auctions 的 script 腳本
.filter(v -> v.contains("g_page_config") && v.contains("itemlist"))
// 獲取第一個符合條件的腳本(原則來上說這裡只能返回一個, 否則說明上面的過濾不嚴謹)
.findFirst()
// 如果沒有匹配的就拋異常, 說明解析頁面失敗
.orElseThrow(() -> new RuntimeException("解析頁面失敗,請檢查更新代碼"));
// 觀察 script 內部其實是一個json格式的字符串, 因此找到分割點進行切割字符串返回一個json串
String json = StringUtils.substringBetween(script, "g_page_config", "g_srp_init");
json = json.substring(json.indexOf("{"), json.lastIndexOf("}") + 1);
// 使用 JSONPath 實現快速解析json, 這裡的意思是查找n級 itemlist 下的 n級auctions (說明:alibaba fastjson 就有 JSONPath 的實現)
Object eval = JSONPath.eval(JSON.parseObject(json), "$..itemlist..auctions");
if (!(eval instanceof List)) {
throw new RuntimeException("解析JSON列表失敗, 請檢查更新代碼");
}
List<JSONObject> auctions = (List<JSONObject>) eval;
// 轉換為目標對象 Goods
List<Goods> result = auctions.stream().map(v -> v.toJavaObject(Goods.class)).collect(Collectors.toList());
// result.forEach(System.out::println);
return result;
}
/**
* 根據關鍵字獲取列表HTML
* @param keywords 關鍵字 如: 安踏籃球鞋男鞋
* @param index 開始位置, 如0, 每頁顯示44條數據, 因此0, 44, 88, ...
*/
protected String getListHtml(String keywords, int index) {
// 中文進行URL編碼
keywords = FastHttpClient.encodeURLText(keywords, StandardCharsets.UTF_8);
String url = listUrlTemplate.replace("${keywords}", keywords).replace("${index}", index + "");
// 創建一個GET請求
HttpGet httpGet = httpClient.buildGet(url);
// 執行請求, 獲取響應
Response<String> response = httpClient.execute(httpGet, ResponseHandler.ofString());
return response.getData();
}
/**
* 資源初始化
*/
protected void init(String keywords) {
// 初始化 httpClient 並且設置 cookie 每次請求都會攜帶cookie信息
httpClient = FastHttpClient.builder().setCookie(cookie).build();
filter = new HashSet<>(1024);
try {
// 初始化 CSV 文件呢
File file = Paths.get(System.getProperty("user.dir"), "temp", "spider", keywords + ".csv").toFile();
file.getParentFile().mkdirs();
writer = new BufferedWriter(new FileWriter(file, false));
// 準備header
List<String> header = Arrays.asList("標題", "單價", "運費", "店名", "發貨地址", "售量", "首頁圖", "明細地址", "評論地址", "購買地址", "nid", "pid");
this.writeRow(header);
} catch (IOException e) {
e.printStackTrace();
}
}
public static class Goods {
/** nid */
private String nid;
/** pid */
private String pid;
/** 標題 */
private String raw_title;
/** 首頁圖 */
private String pic_url;
/** 明細地址 */
private String detail_url;
/** 單價 */
private String view_price;
/** 運費 */
private String view_fee;
/** 發貨地址 */
private String item_loc;
/** 售量 */
private String view_sales;
/** 店名 */
private String nick;
/** 評論地址 */
private String comment_url;
/** 購買地址 */
private String shopLink;
public String getNid() {
return nid;
}
public void setNid(String nid) {
this.nid = nid;
}
public String getPid() {
return pid;
}
public void setPid(String pid) {
this.pid = pid;
}
public String getRaw_title() {
return raw_title;
}
public void setRaw_title(String raw_title) {
this.raw_title = raw_title;
}
public String getPic_url() {
return pic_url;
}
public void setPic_url(String pic_url) {
this.pic_url = pic_url;
}
public String getDetail_url() {
return detail_url;
}
public void setDetail_url(String detail_url) {
this.detail_url = detail_url;
}
public String getView_price() {
return view_price;
}
public void setView_price(String view_price) {
this.view_price = view_price;
}
public String getView_fee() {
return view_fee;
}
public void setView_fee(String view_fee) {
this.view_fee = view_fee;
}
public String getItem_loc() {
return item_loc;
}
public void setItem_loc(String item_loc) {
this.item_loc = item_loc;
}
public String getView_sales() {
return view_sales;
}
public void setView_sales(String view_sales) {
this.view_sales = view_sales;
}
public String getNick() {
return nick;
}
public void setNick(String nick) {
this.nick = nick;
}
public String getComment_url() {
return comment_url;
}
public void setComment_url(String comment_url) {
this.comment_url = comment_url;
}
public String getShopLink() {
return shopLink;
}
public void setShopLink(String shopLink) {
this.shopLink = shopLink;
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder("Goods{");
sb.append("nid='").append(nid).append('\'');
sb.append(", pid='").append(pid).append('\'');
sb.append(", raw_title='").append(raw_title).append('\'');
sb.append(", pic_url='").append(pic_url).append('\'');
sb.append(", detail_url='").append(detail_url).append('\'');
sb.append(", view_price='").append(view_price).append('\'');
sb.append(", view_fee='").append(view_fee).append('\'');
sb.append(", item_loc='").append(item_loc).append('\'');
sb.append(", view_sales='").append(view_sales).append('\'');
sb.append(", nick='").append(nick).append('\'');
sb.append(", comment_url='").append(comment_url).append('\'');
sb.append(", shopLink='").append(shopLink).append('\'');
sb.append('}');
return sb.toString();
}
}
}
7. 運行代碼效果如下
文件保存路徑默認在項目路徑 $/temp/spider/xxx.csv
一共爬了2074條數據,爬到大概50頁的時候,發現返回的數據都是重複的了(根據nid過濾)大概猜測原因:
淘寶限制返回數量(雖然寫著99頁,實際真實客戶也不可能是真的翻了99頁找商品...)8. 數據可視化以下可視化數據僅供學習參考,不具備任何依據判斷,不承擔任何責任。
1. 淘寶 - 安踏籃球鞋男鞋 - 發貨地詞雲圖
2. 淘寶 - 安踏籃球鞋男鞋 - 店鋪售量衝浪榜
僅展示店鋪售量1900+以上店鋪,不上榜的店鋪納入 「其他」
3. 淘寶 - 安踏籃球鞋男鞋 - 價格&售量表1
4. 淘寶 - 安踏籃球鞋男鞋 - 價格&售量表2
9. 擴展上面的代碼完成一個關鍵字的搜索,頂多可以算一個爬蟲,並不能算一個完整的爬蟲系統,一個爬蟲系統應該具備以下的幾個組件。(待完善)