记一次某猫商品信息抓取

某猫商品信息抓取

需求背景

  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 函数。

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

《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 10
zds

可刑,可狱不可求

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

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

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

放着 py 不用是吧

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

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