Explorar el Código

重庆信息价爬虫,数据转换并入库

vian hace 5 años
padre
commit
1839855009

+ 2 - 0
modules/all_models/std_price_info_items.js

@@ -12,8 +12,10 @@ const priceInfoItems = new Schema({
     unit: String,
     taxPrice: String, // 含税价格
     noTaxPrice: String, // 不含税价格
+    // 以下冗余数据为方便前台信息价功能处理
     period: String, // 期数 eg: 2020-05
     area: String, // 地区
+    compilationID: String, // 费用定额
     remark: String
 }, {versionKey: false});
 mongoose.model('std_price_info_items', priceInfoItems, 'std_price_info_items');

+ 0 - 1
modules/all_models/std_price_info_lib.js

@@ -8,7 +8,6 @@ const priceInfoLib = new Schema({
     period: String, // 期数 eg: 2020-05
     area: String, // 地区
     compilationID: String,
-    creator: String,
     createDate: Number,
 }, {versionKey: false});
 mongoose.model('std_price_info_lib', priceInfoLib, 'std_price_info_lib');

+ 1 - 7
operation.js

@@ -130,12 +130,6 @@ schedule.scheduleJob({hour: 10, minute: 0}, function(){
     })
 });
 
-
-// test
-/* const crawler = require('./web/over_write/js/chongqing_2018_price_crawler');
-crawler.crawlData('2020-01', '2020-05'); */
-// test
-
-app.listen(6080, function(){
+app.listen(6080, function () {
     console.log("server started!");
 });

+ 359 - 40
web/over_write/js/chongqing_2018_price_crawler.js

@@ -1,6 +1,7 @@
 /**
+ * @author vian
  * 重庆材料信息价爬虫
- * 由于headless chrome “puppeteer”占用资源比较大,且材料信息价的网站渲染的是静态内容,因此不需要使用puppeteer。
+ * 由于headless chrome “puppeteer”占用资源比较大,且材料信息价的数据是ssr的静态内容,因此不需要使用puppeteer。
  * 数据获取使用cheerio(解析html,可用类jquery语法操作生成的数据)
  */
 
@@ -11,6 +12,22 @@ module.exports = {
 const cheerio = require('cheerio');
 const axios = require('axios');
 const querystring = require('querystring');
+const uuidV1 = require('uuid/v1');
+const mongoose = require('mongoose');
+const { isDef } = require('../../../public/common_util');
+
+const compilationModel = mongoose.model('compilation');
+const priceInfoLibModel = mongoose.model('std_price_info_lib');
+const priceInfoClassModel = mongoose.model('std_price_info_class');
+const priceInfoItemModel = mongoose.model('std_price_info_items');
+
+const isDebug = true;
+
+function debugConsole(str, type = 'log') {
+    if (isDebug) {
+        console[type](str);
+    }
+}
 
 // 页面类型
 const PageType = {
@@ -101,15 +118,15 @@ function getMixedDataBody($, props) {
 
 // 获取提交
 
-const TIME_OUT = 10000;
+const TIME_OUT = 60000;
 
 // 创建axios实例
 const axiosInstance = axios.create({
     baseURL: 'http://www.cqsgczjxx.org/Jgxx/',
     timeout: TIME_OUT,
-    proxy: {
-        host: "127.0.0.1", port: "8888" // fiddler抓包
-    },
+    /* proxy: {
+        host: "127.0.0.1", port: "8888" // Fiddler抓包,需要打开Fiddler否则会报connect error
+    }, */
     headers: {
         'Cache-Control': 'max-age=0',
         'Content-Type': 'application/x-www-form-urlencoded',
@@ -160,18 +177,18 @@ async function loadPage(url, body) {
 }
 
 const monthMap = {
-    '1': '01',
-    '2': '02',
-    '3': '03',
-    '4': '04',
-    '5': '05',
-    '6': '06',
-    '7': '07',
-    '8': '08',
-    '9': '09',
-    '10': '10',
-    '11': '11',
-    '12': '12',
+    '1': '01',
+    '2': '02',
+    '3': '03',
+    '4': '04',
+    '5': '05',
+    '6': '06',
+    '7': '07',
+    '8': '08',
+    '9': '09',
+    '10': '10',
+    '11': '11',
+    '12': '12',
 };
 
 /**
@@ -179,7 +196,7 @@ const monthMap = {
  * @param {String} from - 从哪一期开始 eg: 2020-01
  * @param {String} to - 从哪一期结束 eg: 2020-05
  * @param {Object} $index - cheerio加载的初始页面内容
- * @return {Array[object] || Null} eg: {period: '2020-05', uid: 'XCCXXXXX-XX'}
+ * @return {Array<object> || Null} eg: {period: '2020-05', uid: 'XCCXXXXX-XX'}
  */
 function getPeriodData(from, to, $index) {
     if (from > to) {
@@ -204,7 +221,7 @@ function getPeriodData(from, to, $index) {
             return null;
         }
         list.push({
-            period: `${curYear}-${monthMap[curMonth]}`,
+            period: `${curYear}-${monthMap[curMonth]}`,
             uid
         });
         if (curMonth === 12) {
@@ -253,7 +270,7 @@ const TableType = {
  * 爬取表格数据
  * @param {Object} $page - 页面内容
  * @param {Number} type - 表格类型
- * @return {Array[object]}
+ * @return {Array<object>}
  */
 function crawlTableData($page, type) {
     switch (type) {
@@ -274,7 +291,7 @@ function crawlTableData($page, type) {
  * 爬取表格数据,表格列为:
  * 序号	| 材料名称 | 规格型号 | 单位 | 含税价(元) | 不含税价(元) | 备注
  * @param {Object} $page - 页面内容
- * @return {Array[object]}
+ * @return {Array<object>}
  */
 function crawlNormalTable($page) {
     const colMap = {
@@ -298,14 +315,14 @@ function crawlNormalTable($page) {
             data.push(cur);
         }
     });
-    console.log(data);
+    debugConsole(data);
     return data;
 }
 /**
  * 爬取表格数据,表格列为:
  * 序号 | 科属 | 品名 | 高度(CM) | 干径(CM) | 冠径(CM) | 分枝高(CM) | 单位 | 含税价(元) | 不含税价(元) | 备注
  * @param {Object} $page - 页面内容
- * @return {Array[object]}
+ * @return {Array<object>}
  */
 function crawlGardenTable($page) {
     const colMap = {
@@ -333,7 +350,7 @@ function crawlGardenTable($page) {
             data.push(cur);
         }
     });
-    console.log(data);
+    debugConsole(data);
     return data;
 }
 /**
@@ -341,7 +358,7 @@ function crawlGardenTable($page) {
  * 序号 | 所属区县 | 材料名称 | 规格及型号 | 计量单位 | 含税价(元) | 不含税价(元)
  * @param {Object} $page - 页面内容
  * @param {String} viewSelector - 表格选择器(ID)
- * @return {Array[object]}
+ * @return {Array<object>}
  */
 function crawlAreaTable($page, viewSelector) {
     const colMap = {
@@ -365,7 +382,7 @@ function crawlAreaTable($page, viewSelector) {
             data.push(cur);
         }
     });
-    console.log(data);
+    debugConsole(data);
     return data;
 }
 
@@ -400,6 +417,9 @@ async function crawlPagesData($index, props, pageType, tableType) {
     const rst = [];
     // 获取第一页数据
     rst.push(...crawlTableData($firstPage, tableType));
+    if (!rst.length) { // 第一页都没数据,后续不需要操作了
+        return rst;
+    }
     // 获取除第一页的数据
     // 获取页码
     const pageState = $firstPage(pageStateSelector).text(); // eg: 1/10
@@ -448,11 +468,10 @@ async function crawlPagesData($index, props, pageType, tableType) {
  * @param {String} classID - 工程分类id 
  * @param {Object} $index - 初始页面内容
  * @param {Number} type - 表格类型
- * @return {Array[object]} eg: [{ materialClass: '一、黑色及有色金属', items: [...] }]
+ * @return {Array<object>} eg: [{ materialClass: '一、黑色及有色金属', items: [...] }]
  */
 async function crawlGeneralSubData(period, classID, $index, type) {
     const body = getGeneralDataBody($index, { period, classID });
-    console.time('crawlGeneralSubData');
     const $engineeringClassPage = await loadPage(PageType.GENERAL, body);
     const rst = [];
     if (type === TableType.BUILDING) {
@@ -460,9 +479,9 @@ async function crawlGeneralSubData(period, classID, $index, type) {
         if (!classList.length) {
             throw '无法爬取到材料分类。';
         }
-        console.log(classList);
+        const reg = /[一二三四五六七八九十]+、/;
         for (const materialClass of classList) {
-            const obj = { materialClass, items: [] };
+            const obj = { materialClass: materialClass.replace(reg, ''), items: [] }; // 材料分类去除序号
             obj.items = await crawlPagesData($engineeringClassPage, { period, classID, materialClass }, PageType.GENERAL, type);
             rst.push(obj);
         }
@@ -470,7 +489,7 @@ async function crawlGeneralSubData(period, classID, $index, type) {
         const items = await crawlPagesData($engineeringClassPage, { period, classID, materialClass: '' }, PageType.GENERAL, type);
         rst.push(...items);
     }
-    console.timeEnd('crawlGeneralSubData');
+    return rst;
 
     // 爬取材料分类表
     function crawlMaterialClassList($class) {
@@ -535,7 +554,7 @@ async function crawlGeneralData(period, $index) {
 /**
  * 爬取各区县地方材料工地价格
  * @param {String} period - 期数uid
- * @return {Array[objecy]
+ * @return {Array<object>}
  */
 async function crawlAreaData(period) {
     // 获取各区材料初始页
@@ -547,7 +566,7 @@ async function crawlAreaData(period) {
 /**
  * 爬取预拌砂浆信息价格
  * @param {String} period - 期数uid
- * @return {Array[objecy]
+ * @return {Array<object>}
  */
 async function crawlMixedData(period) {
     // 获取各区材料初始页
@@ -557,14 +576,308 @@ async function crawlMixedData(period) {
 }
 
 /**
- * 
- * @param {String} period 期数 eg: '2020-05'
+ * 转换价格数据(一条源数据可能需要分割成多条数据)
+ * @param {String} libID - 库ID
+ * @param {String} classID - 所属分类ID
+ * @param {String} period - 期数 eg:2020年01月
+ * @param {String} area - 地区
+ * @param {String} compilationID - 费用定额ID
+ * @param {Array<object>} items - 爬取的信息价源数据
+ * @param {Number} tableType - 表格类型
+ * @return {Array<obejct>}
+ */
+function transformPriceItems(libID, classID, period, area, compilationID, items, tableType) {
+    const rst = [];
+    if (tableType === TableType.GARDEN) {
+        // 有的数据 高度(CM) | 干径(CM) | 冠径(CM) | 分枝高(CM) | 不含税价(元) = ‘’ | 14-17 | 大于400 | 200-300 | 430-780
+        // 则此数据需要分为:
+        // 1. { name: 名称-最低价, specs: 干径14-17CM 冠径大于400CM 分枝高200-300CM, noTaxPrice: 430 }
+        // 2. { name: 名称-最高价, specs: 干径14-17CM 冠径大于400CM 分枝高200-300CM, noTaxPrice: 780 }
+        const unit = 'CM';
+        const duplicateReg = /-/;
+        items.forEach(item => {
+            // 拼接规格型号
+            const specsList = [];
+            if (item.height) {
+                specsList.push(`高度${item.height}${unit}`);
+            }
+            if (item.branchDiameter) {
+                specsList.push(`干径${item.branchDiameter}${unit}`);
+            }
+            if (item.crownDiameter) {
+                specsList.push(`冠径${item.crownDiameter}${unit}`);
+            }
+            if (item.branchHeight) {
+                specsList.push(`分枝高${item.branchHeight}${unit}`);
+            }
+            const specs = specsList.join(' ');
+            // 分成最高低价最高价数据
+            const isDuplicate = duplicateReg.test(item.taxPrice) || duplicateReg.test(item.noTaxPrice);
+            if (isDuplicate) {
+                const taxPriceList = item.taxPrice.split('-');
+                const noTaxPriceList = item.noTaxPrice.split('-');
+                const minItem = {
+                    ...item,
+                    name: `${item.name}-最低价`,
+                    specs,
+                    taxPrice: taxPriceList[0],
+                    noTaxPrice: noTaxPriceList[0]
+                };
+                const maxItem = {
+                    ...item,
+                    name: `${item.name}-最高价`,
+                    specs,
+                    taxPrice: taxPriceList[1] || '',
+                    noTaxPrice: noTaxPriceList[1] || ''
+                };
+                rst.push(transfromPriceItem(libID, classID, period, area, compilationID, minItem));
+                rst.push(transfromPriceItem(libID, classID, period, area, compilationID, maxItem));
+            } else {
+                rst.push(transfromPriceItem(libID, classID, period, area, compilationID, item));
+            }
+        })
+    } else {
+        const duplicateReg = /\//;
+        // 有的数据:规格型号 | 含税价(元) | 不含税价(元) = φ6(6.5)/φ8 HPB300 | 4030.00/3880.00 | 3566.37/3433.63,则这条数据需要分成两条数据
+        items.forEach(item => {
+            item.taxPrice = item.taxPrice === '-' ? '' : item.taxPrice;
+            item.noTaxPrice = item.noTaxPrice === '-' ? '' : item.noTaxPrice;
+            const isDuplicate = duplicateReg.test(item.taxPrice) || duplicateReg.test(item.noTaxPrice); // 以价格被分割,作为数据需要分割的判断
+            if (isDuplicate) {
+                // 提取规格型号分割部分和公共部分:Q390/Q420 δ=20-30 => Q390 δ=20-30; Q420 δ=20-30
+                // 获取公共规格型号部分
+                const commonReg = /\s+([^/]*)$/;
+                const commonMatched = item.specs.match(commonReg);
+                const commonSpecs = commonMatched && commonMatched[1] ? ' ' + commonMatched[1] : '';
+                // 获取分割规格型号
+                const specsList = item.specs
+                    .replace(commonReg, '')
+                    .split('/');
+                const taxPriceList = item.taxPrice.split('/');
+                const noTaxPriceList = item.noTaxPrice.split('/');
+                specsList.forEach((specs, index) => {
+                    const newItem = {
+                        ...item,
+                        specs: `${specs}${commonSpecs}`,
+                        taxPrice: taxPriceList[index] || taxPriceList[0],
+                        noTaxPrice: noTaxPriceList[index] || noTaxPriceList[0]
+                    };
+                    if (area) {
+                        newItem.area = area;
+                    }
+                    rst.push(transfromPriceItem(libID, classID, period, area, compilationID, newItem));
+                });
+            } else {
+                rst.push(transfromPriceItem(libID, classID, period, area, compilationID, item));
+            }
+        });
+    }
+    return rst;
+}
+
+// 转换单条的价格数据
+function transfromPriceItem(libID, classID, period, area, compilationID, item) {
+    // 源数据中的规格型号存在多个无意义的空格,合并为一个
+    const reg = /\s{2,}/g;
+    item.specs = item.specs.replace(reg, ' ');
+    return {
+        ID: uuidV1(),
+        libID,
+        classID,
+        code: '',
+        name: item.name,
+        specs: item.specs,
+        unit: item.unit,
+        taxPrice: item.taxPrice,
+        noTaxPrice: item.noTaxPrice,
+        remark: item.remark || '',
+        // 以下冗余数据为方便前台信息价功能处理
+        period,
+        area,
+        compilationID,
+    }
+}
+
+/**
+ * 转换主要材料
+ * @param {String} period - 日期: 2020年01月
+ * @param {String} compilationID - 费用定额ID
  * @param {Object} generalData - 主要材料{ building, garden, energy }
- * @param {Array[object]} areaData - 各地区材料
- * @param {Array[object]} mixedData - 各地区预拌砂浆
+ * @return {Object} { libData, classData, priceData }
+ */
+function transfromGeneralData(period, compilationID, generalData) {
+    const area = '通用';
+    const libData = {
+        ID: uuidV1(),
+        name: `${area}信息价(${period})`,
+        period,
+        area,
+        compilationID,
+        createDate: Date.now(),
+    };
+    const classData = [];
+    let curClassIndex = 0;
+    const priceData = [];
+    const { building, garden, energy } = generalData;
+    handleClassAndItems(building, TableType.BUILDING);
+    // 园林分类数据为:苗木-科属(genera)
+    const gardenRoot = { materialClass: '苗木', treeData: { ID: uuidV1(), ParentID: '-1' } };
+    const gardenData = [gardenRoot];
+    garden.forEach(item => {
+        const pre = gardenData[gardenData.length - 1];
+        if (item.genera !== pre.materialClass) {
+            gardenData.push({ materialClass: item.genera, treeData: { ParentID: gardenRoot.treeData.ID }, items: [item] });
+        } else {
+            pre.items.push(item);
+        }
+    });
+    handleClassAndItems(gardenData, TableType.GARDEN)
+    // 绿色节能分类数据:绿色、节能建筑工程材料
+    const energyData = [{ materialClass: '绿色、节能建筑工程材料', items: energy }];
+    handleClassAndItems(energyData, TableType.ENERGY);
+    return { libData: [libData], classData, priceData };
+
+    function handleClassAndItems(sourceData, tableType) {
+        sourceData.forEach(({ materialClass, treeData, items }) => {
+            const classItem = {
+                ID: treeData && treeData.ID || uuidV1(),
+                ParentID: treeData && treeData.ParentID || '-1',
+                NextSiblingID: treeData && treeData.NextSiblingID || '-1',
+                name: materialClass,
+                libID: libData.ID
+            };
+            // 设置上一个节点数据的NextID
+            let count = 1;
+            let pre = classData[curClassIndex - 1];
+            while (pre && pre.ParentID !== classItem.ParentID) {
+                count++;
+                pre = classData[curClassIndex - count];
+            }
+            if (pre && pre.ParentID === classItem.ParentID) {
+                pre.NextSiblingID = classItem.ID;
+            }
+            curClassIndex++;
+            classData.push(classItem);
+            // 转换价格数据
+            if (items && items.length) {
+                const newItems = transformPriceItems(libData.ID, classItem.ID, period, area, compilationID, items, tableType);
+                newItems.forEach(item => priceData.push(item));
+            }
+        });
+    }
+}
+
+/**
+ * 转换跟地区相关的数据
+ * @param {String} period - 日期: 2020年01月
+ * @param {String} compilationID - 费用定额ID
+ * @param {String} className - 分类名称 
+ * @param {Array<object>} areaData - 各区县地方材料工地价格
+ * @param {Array<object>} mixedData - 预拌砂浆信息价格
  */
-function transfromAndSave(period, generalData, areaData, mixedData) {
+function transformAreaData(period, compilationID, areaData, mixedData) {
+    // 根据地区进行分类,一个地区一个信息价库
+    const data = [];
+    const hashMap = {}; // 保证地区顺序跟网页爬取数据的顺序一致。(object for in无法保证顺序)
+    function hash(area) {
+        if (!isDef(hashMap[area])) {
+            hashMap[area] = Object.keys(hashMap).length
+        }
+        return hashMap[area];
+    }
+    const areaClass = '地方材料信息价';
+    const mixedClass = '预拌商品砂浆';
+    function buildData(sourceData) {
+        sourceData.forEach(item => {
+            const idx = hash(item.area);
+            if (!data[idx]) {
+                data[idx] = { area: item.area, subData: [] };
+            }
+            if (sourceData === areaData) {
+                // 存在地区数据,需要生成分类“地方材料信息价”
+                if (!data[idx].subData[0]) {
+                    data[idx].subData[0] = { className: areaClass, items: [] };
+                }
+                data[idx].subData[0].items.push(item);
+            } else if (sourceData === mixedData) {
+                // 存在地区数据,需要生成分类“地方材料信息价”
+                if (!data[idx].subData[1]) {
+                    data[idx].subData[1] = { className: mixedClass, items: [] };
+                }
+                data[idx].subData[1].items.push(item);
+            }
+        });
+    }
+    buildData(areaData);
+    buildData(mixedData);
+    const libData = [];
+    const classData = [];
+    const priceData = [];
+    data.forEach(({ area, subData }) => {
+        const libItem = {
+            ID: uuidV1(),
+            name: `${area}信息价(${period})`,
+            period,
+            area,
+            compilationID,
+            createDate: Date.now(),
+        };
+        libData.push(libItem);
+        let preClass;
+        subData.forEach(subItem => {
+            if (!subItem) {
+                return;
+            }
+            const { className, items } = subItem;
+            const classItem = {
+                ID: uuidV1(),
+                ParentID: '-1',
+                NextSiblingID: '-1',
+                name: className,
+                libID: libItem.ID
+            };
+            classData.push(classItem);
+            if (preClass) {
+                preClass.NextSiblingID = classItem.ID;
+            }
+            preClass = classItem;
+            const newItems = transformPriceItems(libItem.ID, classItem.ID, period, area, compilationID, items, TableType.AREA);
+            newItems.forEach(item => priceData.push(item));
+        });
+    });
+    return { libData, classData, priceData };
+}
 
+/**
+ * 数据入库
+ * 生成一个通用库及各地区
+ * @param {String} period 期数 eg: '2020年05月'
+ * @param {Object} generalData - 主要材料{ building, garden, energy }
+ * @param {Array<object>} areaData - 各地区材料
+ * @param {Array<object>} mixedData - 各地区预拌砂浆
+ */
+async function save(period, generalData, areaData, mixedData) {
+    const overWriteUrl = '/web/over_write/js/chongqing_2018.js';
+    const compilation = await compilationModel.findOne({ overWriteUrl }, '_id').lean();
+    if (!compilation) {
+        throw '没有找到正确配置overWriteUrl的费用定额。';
+    }
+    const compilationID = compilation._id;
+    // 转换数据
+    const generalSaveData = transfromGeneralData(period, compilationID, generalData);
+    const areaSaveData = transformAreaData(period, compilationID, areaData, mixedData);
+    // 入库
+    const libData = [...generalSaveData.libData, ...areaSaveData.libData];
+    const classData = [...generalSaveData.classData, ...areaSaveData.classData];
+    const priceData = [...generalSaveData.priceData, ...areaSaveData.priceData];
+    // 删除已有的相同期数数据
+    await priceInfoItemModel.deleteMany({ period });
+    await priceInfoClassModel.deleteMany({ period });
+    await priceInfoLibModel.deleteMany({ period });
+    // 插入数据
+    await priceInfoItemModel.insertMany(priceData);
+    await priceInfoClassModel.insertMany(classData);
+    await priceInfoLibModel.insertMany(libData);
 }
 
 /**
@@ -581,19 +894,24 @@ async function crawlData(from, to) {
         if (!periodData) {
             throw '无效的期数区间。';
         }
-        console.log(periodData);
         // 一期一期爬取数据
+        debugConsole('allTime', 'time');
         for (const periodItem of periodData) {
+            debugConsole('peroidTime', 'time');
             // 爬取主要材料信息价格
             const generalData = await crawlGeneralData(periodItem.uid, $index); // 初始页面就是主要材料信息价的页面
             // 爬取各区县地方材料工地价格
             const areaData = await crawlAreaData(periodItem.uid);
             // 爬取预拌砂浆信息价格
             const mixedData = await crawlMixedData(periodItem.uid);
-            
+            // 转换数据并入库
+            await save(periodItem.period, generalData, areaData, mixedData);
             curPeriod = periodItem.period;
+            debugConsole('peroidTime', 'timeEnd');
         }
+        debugConsole('allTime', 'timeEnd');
     } catch (err) {
+        console.log(err);
         // 错误时提示已经成功爬取的期数
         let errTip = '';
         if (curPeriod) {
@@ -602,5 +920,6 @@ async function crawlData(from, to) {
         const errStr = String(err) + errTip;
         console.log(`err`);
         console.log(errStr);
+        throw errStr;
     }
 }