记一次某猫商品信息抓取

某猫商品信息抓取

需求背景

  1. 需要根据某店铺商品列表获取所有SKU的标题、价格、规格等信息

尝试方案一:PHP + CURL

  1. 使用PHP的CURL库,模拟浏览器请求,获取商品列表页面

碰到的问题

  1. 目标地址参数进行了签名校验,我翻了一下源码,大概是根据 token+’&’+时间戳+’&’+固定appKey+’&’+数据 进行哈希编码
  2. 这其中有三个变量我没有猜出来: token、数据的格式 和 哈希算法(从32位数上猜应该是md5,但也不一定)
  3. 我抓包后采用各种组合方式,穷举了一下,但是没有成功
// 某猫地址的签名生成位置代码
    if (d.H5Request === !0) {
    var f = "//" + (d.prefix ? d.prefix + "." : "") + (d.subDomain ? d.subDomain + "." : "") + d.mainDomain + "/h5/" + c.api.toLowerCase() + "/" + c.v.toLowerCase() + "/"
        , g = c.appKey || ("waptest" === d.subDomain ? "4272" : "12574478")
        , i = (new Date).getTime()
        , j = h(d.token + "&" + i + "&" + g + "&" + c.data)
        , k = {
        jsv: z,
        appKey: g,
        t: i,
        sign: j
    }
    }
// PHP 穷举过程代码
function generate_sign($token, $t, $appKey, $data) {
    $sign = md5($token . "&" . $t . "&" . $appKey . "&" . $data);
    return $sign;
}

$known_sign = "0563ec1444926804a3f8ed7cb71ee814"; // 从抓包中获取到的正确签名
$t = "1715059428868"; // 从抓包中获取到的时间戳
$appKey = "12574478"; // 从抓包中获取到的 appKey 从Javascript代码中看 web 端的 appKey 是固定的

$tokens = []; // 从 cookie 或其他地方获取所有可能的 token
$datas = []; // 从其他地方获取所有可能的 data

foreach ($tokens as $token) {
    foreach ($datas as $data) {
        $sign = generate_sign($token, $t, $appKey, $data);
        if ($sign == $known_sign) {
            echo "Found match: token = $token, data = $data\n";
        }
    }
}

尝试方案二:控制台执行 JavaScript

由于不是长期项目,不用考虑代码留存问题,所以可以直接在控制台执行 JavaScript 代码

步骤 1:获取商品列表中的详情页链接

  1. 通过 document.getElementsByClassName() 获取到商品列表
  2. 将详情页链接生成 csv 格式数据并下载到本地
var links = document.getElementsByClassName('item-name J_TGoldData');
var hrefs = [];
for (var offset=0;offset<links.length && offset<60; offset++) { // 只获取前60个商品的详情页链接,后面的是推荐商品列表
    hrefs.push(links[offset].href);
}
// 将详情页链接生成 csv 格式数据并下载到本地
var csvContent = "data:text/csv;charset=utf-8,";
csvContent += hrefs.join('\n');
var encodedUri = encodeURI(csvContent);
var linkDownload = document.createElement("a");
linkDownload.setAttribute("href", encodedUri);
linkDownload.setAttribute("download", "links.csv");
document.body.appendChild(linkDownload);
linkDownload.click();

记一次某猫商品信息抓取

步骤 2:获取商品详情页的规格、价格等信息

  1. 原本想借由下载的链接直接通过 window.open() 获取商品详情,但发现品牌列表页和详情页不在相同域名下,跨域问题导致无法获取详情页的 window.document 对象
  2. 于是尝试在其中一个详情页的控制台执行 javascript 代码来解决跨域问题
  3. 剩下的就是属性遍历逻辑问题,由于每次点击后商品规格都会变化,如果要考虑全部情况会让逻辑非常复杂,我只考虑在第一页获取全部规格的情况
  4. 将上一个脚本下载的文件内容复制到本段代码的 linkStr 变量中,然后执行代码
var linkStr = '' + // 采集链接,每行一个

+'';
var links = linkStr.split('\n');
var data = [];
var win = null;
var linkCurrent = '';
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}
async function processLinks(links) {
    for (const link of links) {
        linkCurrent = link.trim();
        await processLink();
    }
}

function getCombinations(array) {
    function fork(i, t) {
        if (i === array.length) {
            result.push(t);
            return;
        }
        for (let j = 0; j < array[i].length; j++) {
            fork(i + 1, t.concat([array[i][j]]));
        }
    }
    let result = [];
    fork(0, []);
    return result;
}
async function collectData(ifNullSkuForce = false) {
    const itemIdMatch = win.location.search.match(/&id=([^&]+)/);
    const itemId = itemIdMatch ? itemIdMatch[1] : '';
    const skuIdMatch = win.location.search.match(/&skuId=([^&]+)/);
    const skuId = skuIdMatch ? skuIdMatch[1] : '';

    console.log('itemId:', itemId, 'skuId:', skuId);

    if (!itemId) { // 未到最终页面 或者 无法获取 itemId
        return 0;
    }

    if (!ifNullSkuForce && !skuId) { // 未到最终页面 或者 无法获取 skuId 且不强制收集
        return 0;
    }

    if (data.some((item) => item.itemId === itemId && item.skuId === skuId)) { // 已经收集过说明点击失败 可能需要重试
        return 1;
    }

    await sleep(1000);
    const doc = win.document;
    const specEls = doc.querySelectorAll('.skuCate');
    const priceEls = doc.getElementsByClassName('Price--priceText--2nLbVda');
    const titleEl = doc.getElementsByClassName('ItemHeader--mainTitle--3CIjqW5')[0];
    const priceList = [];
    const specList = [];

    for (let offsetSpec = 0; offsetSpec < specEls.length; offsetSpec++) {
        let specTitle = specEls[offsetSpec].querySelector('.skuCateText').innerText.replace(':', '');
        let selectedSpecEl = specEls[offsetSpec].querySelector('.selectSkuText');
        if (selectedSpecEl) {
            let specValue = selectedSpecEl.innerText;
            specList.push({title: specTitle, values: specValue});
        }
    }
    // 多个价格收集
    for (let offsetPrice = 0; offsetPrice < priceEls.length; offsetPrice++) {
        priceList.push(priceEls[offsetPrice].innerText);
    }
    let itemData = {
        itemId: itemId,
        skuId: skuId,
        title: titleEl.innerText,
        prices: priceList,
        specifications: specList
    };
    data.push(itemData);
    // 收集数据成功
    return 2;
}
function processClick(depth = 0, start = 0){
    const doc = win.document;
    const query = `.skuCate:nth-child(${depth + 1}) .skuItem:nth-child(${start + 1})`;
    const targetEl = doc.querySelector(query);

    if (!targetEl) {
        return false;
    }

    if (targetEl.className.indexOf('current') !== -1) {
        return true;
    }

    if (targetEl.className.indexOf('disabled') === -1) {
        targetEl.click();
        return true;
    }

    return false;
}

async function processLink() {
    console.log('linkCurrent:', linkCurrent);
    if (!linkCurrent || linkCurrent === '' || linkCurrent === '0') {
        return;
    }
    if (win) {
        win.location.href = linkCurrent;
    } else {
        win = open(linkCurrent, 'item');
    }
    await sleep(1000);
    const doc = win.document;
    const specEls = doc.querySelectorAll('.skuCate');
    if (specEls.length === 0) {
        await  collectData(true);
        return;
    }
    const specElOffsets = [];

    for (let depth = 0; depth < specEls.length; depth++) {
        const specValueEls = specEls[depth].querySelectorAll('.skuItem');
        specElOffsets[depth] = [];
        for (let start = 0;start < specValueEls.length; start++) {
            specElOffsets[depth][start] = start;
        }
    }
    const plans = getCombinations(specElOffsets);
    const stepsCount = specElOffsets[specElOffsets.length - 1].length; // 最后一级规格的数量
    let step = 0;
    for (let plan of plans) {
        console.log('step:', step,step % stepsCount);
        if (step > 0 && 0 === step % stepsCount) {
            win.location.href = linkCurrent;
        }
        step++;
        let depth = 0;
        let clickState = false;
        for(let start of plan) {
            if (clickState) {
                await sleep(1000);
            }
            console.log('depth:', depth, 'start:', start);
            clickState = processClick(depth, start);
            depth ++;
        }

        if (clickState === false) {
            continue;
        }

        await collectData();
        await sleep(1000);
    }
}
await processLinks(links);
// 将数据生成 JSON 格式文件并下载到本地,因为对JavaScript不熟悉,所以先下载再使用PHP进行整理
var jsonContent = "data:text/json;charset=utf-8,";
jsonContent += JSON.stringify(data);
var encodedUri = encodeURI(jsonContent);
var linkDownload = document.createElement("a");
linkDownload.setAttribute("href", encodedUri);
linkDownload.setAttribute("download", "data.json");
document.body.appendChild(linkDownload);
linkDownload.click();

记一次某猫商品信息抓取

记一次某猫商品信息抓取

步骤 3:整理数据

1. 随便找个近期做的用 Laravel 框架的实验项目,用 fast-excel 库导出数据到 Excel 文件

<?php

namespace App\Console\Commands;

use Generator;
use Illuminate\Console\Command;
use JsonException;
use OpenSpout\Common\Exception\InvalidArgumentException;
use OpenSpout\Common\Exception\IOException;
use OpenSpout\Common\Exception\UnsupportedTypeException;
use OpenSpout\Writer\Exception\WriterNotOpenedException;
use Rap2hpoutre\FastExcel\FastExcel;

class SpiderDataExportCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'app:spider-data-export {inputFilePattern} {outputFile}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = '蜘蛛数据导出';

    /**
     * 表头
     *
     * @var array<string>
     */
    protected array $headers = [];

    /**
     * Execute the console command.
     *
     * @return int 返回码
     *
     * @throws JsonException
     * @throws IOException
     * @throws InvalidArgumentException
     * @throws UnsupportedTypeException
     * @throws WriterNotOpenedException
     */
    public function handle(): int
    {
        $outputFile = $this->argument('outputFile');
        (new FastExcel($this->dataProvider()))->export($outputFile);

        return 0;
    }

    /**
     * 数据提供器
     *
     * @return Generator 数据
     *
     * @throws JsonException
     */
    protected function dataProvider(): Generator
    {
        $inputFilePattern = $this->argument('inputFilePattern'); // storage/data*.json
        foreach (glob($inputFilePattern) as $inputFile) {
            if (!is_file($inputFile)) {
                continue;
            }
            $content = file_get_contents($inputFile);
            $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
            foreach ($data as $row) {
                $this->headers = array_unique(
                    array_merge(
                        $this->headers, array_keys($this->getRow($row))
                    )
                );
            }
        }
        foreach (glob($inputFilePattern) as $inputFile) {
            if (!is_file($inputFile)) {
                continue;
            }
            $content = file_get_contents($inputFile);
            $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
            foreach ($data as $row) {
                yield $this->getRow($row);
            }
        }

        yield [];
    }

    /**
     * 获取行数据(扁平化)
     *
     * @param array<string, string|array<int,array<string,string>>> $row 行数据
     * @return array<string, string> 行数据
     * @throws JsonException
     */
    protected function getRow(array $row): array
    {
        $data = [
            '商品 ID' => data_get($row, 'itemId', ''),
            'SKU ID' => data_get($row, 'skuId', ''),
            '标题' => data_get($row, 'title', ''),
            '原价' => data_get($row, 'prices.0', '0'),
            '优惠价' => data_get($row, 'prices.1', '0'),
        ];
        foreach (data_get($row, 'specifications', []) as $specItem) {
            $title = data_get($specItem, 'title');
            $value = data_get($specItem, 'values');
            $data[$title] = $value;
        }
        $result =  [
            ... array_map(fn ($value) => '', array_flip($this->headers)),
            ... $data,
        ];

        $this->info(json_encode($result, JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE));
        return $result;
    }
}

执行代码

# 手动处理了一下结果文件,将通过 JavaScript 生成的文件重命名为 storage/data${pageNo}.json
sail artisan app:spider-data-export 'storage/data*.json' storage/export.xlsx

结果收录于 export.xlsx 文件中。

记一次某猫商品信息抓取

后记

由于我很少做抓取相关程序,对这方面的知识储备不足,这回也算是临阵磨枪。对 Javascript 也不熟悉,所以代码风格还是尽量参照 PHP 的来写,比如那个 sleep 函数。

各位有经验的朋友如果有什么建议或更好的方案,也请帮忙指教,我好在下次的类似任务中进行改进。

《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 10
zds

可刑,可狱不可求

1周前 评论
sanders (楼主) 1周前
Imuyu 1周前
sanders (楼主) 1周前
zds (作者) 1周前
MissYou-Coding

抓取这个内容,后续怎样使用呢。

1周前 评论
sanders (楼主) 1周前

放着 py 不用是吧

1周前 评论
sanders (楼主) 1周前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!