记一次某猫商品信息抓取
某猫商品信息抓取
需求背景
- 需要根据某店铺商品列表获取所有SKU的标题、价格、规格等信息
尝试方案一:PHP + CURL
- 使用PHP的CURL库,模拟浏览器请求,获取商品列表页面
碰到的问题
- 目标地址参数进行了签名校验,我翻了一下源码,大概是根据 token+’&’+时间戳+’&’+固定appKey+’&’+数据 进行哈希编码
- 这其中有三个变量我没有猜出来: token、数据的格式 和 哈希算法(从32位数上猜应该是md5,但也不一定)
- 我抓包后采用各种组合方式,穷举了一下,但是没有成功
// 某猫地址的签名生成位置代码
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:获取商品列表中的详情页链接
- 通过 document.getElementsByClassName() 获取到商品列表
- 将详情页链接生成 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:获取商品详情页的规格、价格等信息
- 原本想借由下载的链接直接通过 window.open() 获取商品详情,但发现品牌列表页和详情页不在相同域名下,跨域问题导致无法获取详情页的 window.document 对象
- 于是尝试在其中一个详情页的控制台执行 javascript 代码来解决跨域问题
- 剩下的就是属性遍历逻辑问题,由于每次点击后商品规格都会变化,如果要考虑全部情况会让逻辑非常复杂,我只考虑在第一页获取全部规格的情况
- 将上一个脚本下载的文件内容复制到本段代码的 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 函数。
各位有经验的朋友如果有什么建议或更好的方案,也请帮忙指教,我好在下次的类似任务中进行改进。
推荐文章: