万籁俱寂,万字将成。
Do1e
© 2024/11/22 ~ 2026/3/23
Powered by Mix Space&
余白 / Yohaku
.
现在这里好像只有你一个人哦~
本站
隐私政策状态监控 ↗
@Do1e
关于邮箱 ↗GitHub ↗Bangumi ↗照片墙 ↗Scholar ↗
友链
Blogroll ↗影汛创新 ↗
Do1e
链接
隐私政策·状态监控·关于·邮箱·GitHub·Bangumi·照片墙·Scholar·Blogroll·影汛创新
© 2024/11/22 ~ 2026/3/23 Powered by Mix Space&
余白 / Yohaku
.
现在这里好像只有你一个人哦~
苏ICP备2024146330号-1
苏公网安备32011302322471号
RSS 订阅·站点地图·
··|
RSS 订阅·站点地图·|··|苏ICP备2024146330号-1苏公网安备32011302322471号
稍候片刻,月出文自明。

南哪充电,从毛坯到完善

· / , , ·244 阅读·2 喜欢
AI 生成的摘要
AI · GEN

南哪充电,从毛坯到完善

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • 欢迎访问我写的 南哪充电-鼓楼 或 南哪充电-仙林

    这一切的起源来自于9月份的某个晚上没有找到充电桩……
    事实上在那之前已经有一个南哪充电的网页了: https://charge.zhuxh.net/

    不过我个人用起来感觉还是差点意思,只能一眼看见哪里还有空闲,但由于充电桩过于不够,想充电的时候很可能是一片全红,或者仅有的绿色离自己很远。
    于是我也准备自己写一个,能够显示预计剩余时间,方便我去提前蹲守 ,卷死你们

    后端爬取数据

    爬虫对我来说还是挺简单的,毕竟也写了好几个爬虫相关项目了,Reqable启动!

    获取充电站ID

    首先闪开来电在一堆请求中筛选出属于南大仙林的充电站点,拿到充电站点的 station_id ,这一步属于纯手抄,具体id如下:

    https://github.com/Do1e/NJUCharge-backend/blob/main/stations.json

    获取每个充电站下插座ID

    上一步只有33个充电站,手抄倒也能接受,不过302个插座ID也要手抄的话还是放过我吧。
    写于2025年6月16日:为啥新增的充电桩好多都是一个充电站就俩插座,导致我今天手抄了好久的station_id。目前仙林校区累计112个充电站,724个插座。
    从 f'https://wemp.issks.com/charge/v1/outlet/station/outlets/{station_id}' 中可以获取每个充电站点的信息,其中包含插座的id。

    https://github.com/Do1e/NJUCharge-backend/blob/main/utils/per_station.py

    终于,来获取每个插座的状态吧

    在上一步我们能拿到每个充电站(比如天文学院)下每一个充电插座的outletNo,这一步就可以根据outletNo,从 f'https://wemp.issks.com/charge/v1/charging/outlet/{outletNo}' 中获取每个插座的具体状态了!

    返回示例:

    CodeBlock Loading...

    其实里面很多信息都是不必要的,我这里把插座名称、预计剩余时间、已使用时间、状态代码(空闲、故障、分钟计费模式、固定金额模式)、是否有错误信息这几个拿下来就好了,同时再算出一个预计可用时间方便前端直接展示(毕竟我前端实在太弱了)。代码如下:

    https://github.com/Do1e/NJUCharge-backend/blob/main/utils/per_outlet.py

    幸运的是,获取状态这一步并不需要token,而每个站点下面的插座完全固定不变。之后更新数据只要根据已有的outletNo重复执行这一步就OK了。

    数据后处理

    我自己部署时用多线程(302条数据基本2秒左右就搞定了,实测下来也不会触发风控)跑,定时每分钟在后端机器上更新一次数据。
    为了方便前端调用,我还在后端将数据按照剩余时间排序,以及将充电站点从原来按xx号机归类改为xx栋:

    https://github.com/Do1e/NJUCharge-backend/blob/main/sort_outlets.py

    第一版前端

    毕竟是前端白痴,我的第一版前端设置是使用Python生成的。>︿<
    发出来给大家笑话一下

    CodeBlock Loading...

    而且界面也是相当简陋,不过好歹还是用GPT帮我生成了一段js代码用于筛选站点

    第二版前端

    所谓的第二版也只是写了几句css尽力去拯救这个界面罢了。

    第三版前端

    我开始捣鼓我的新版个人主页了,既然Mix Space支持写Markdown with JavaScript,我就把/charge.html挂在了个人主页下了,并且也改进了原来的筛选功能,进行筛选的同时会更改URL参数,这样刷新之后也能记住上次用户筛选的站点并直接展示出来了。

    CodeBlock Loading...

    第四版前端

    由于从第一版就用表格展示,从上面的图也能看出来,在更常用的应用场景——手机上的体验属实一言难尽。于是下定决心重构了半天的UI,并且在开头添加了一个统计表格,可用更方便地规划充电目的地了。

    基本所有前后端代码都在下面的github仓库中,欢迎使用但请遵守MIT协议,使用时保留我的版权信息。

    后续小更新

    • 2024-12-01:新增鼓楼校区。
    • 2025-01-07:今天突然发现充电必须充值后按分钟计费了,岂可休。更新了按已使用时间逆序,按照闪开充电提供的说明,这个时间最大为480分钟,至少还是能作为参考大概知道哪个充电桩快结束了。
    • 2025-02-22:分钟计费模式用户可以自选预充值的金额了,因此可以根据这个估算预计可用时间了。但考虑到有些人直接选往高了选以便充满,同时接口返回数值的精度导致最后计算精度较低,因此仅供参考。电度计费模式理论上也可以估计,但考虑到接口中没有办法直接读取功率,因此暂不编写。
    • 2025-06-16:新增鼓楼和仙林多处充电桩。(插座数变化:鼓楼148->308,仙林302->724)
    • 2025-06-22:貌似系统设置的最长充电时间为480分钟,因此基于此对剩余时间的预计做了修改。
    • 2025-09-09:删除历史充电记录功能。
    • 2025-10-11:更新充电桩。
    {
      "code": "1",
      "msg": "成功",
      "data": {
        "userName": null,
        "supportPayType": null,
        "monthlyDetail": {
          "monthlyPlanId": null,
          "renewFlag": false,
          "districtName": null,
          "hasUserMonthlyPlan": 0,
          "whiteMonthly": false,
          "districtWhite": false,
          "hasDistrictMonthlyPlan": 0,
          "monthlyUsedChargingLength": 0,
          "isMonthlyPlanAvailable": 0,
          "availableChargingTime": 0,
          "expiresTime": null,
          "iType": null,
          "iDistrictId": 18877,
          "dLimitPower": null,
          "iParkId": null,
          "wuYou": 0
        },
        "version": 3,
        "business": {
          "businessDays": null,
          "businessInTime": 1,
          "businessopen": 0,
          "tBusinessStart": null,
          "tBusinessEnd": null,
          "businessType": null
        },
        "outlet": {
          "iOutletId": 1435883,
          "vOutletName": "插座7",
          "iState": 1,
          "iCurrentChargingRecordId": 0,
          "vOutletNo": "O230424025883180",
          "iErrorCount": 0
        },
        "station": {
          "iStationId": 161740,
          "iAreaId": 786688,
          "iFullChargingTime": 0,
          "vStationName": "南京大学仙林校区18栋1号机",
          "iState": 1,
          "iHardWareState": "在线",
          "hardWareState": 1
        },
        "billListDtoList": [
          {
            "billingType": 4,
            "billingTypeName": "固定金额模式",
            "proAmount": 1.0,
            "startPriceCountIndex": 0,
            "propertyList": [
              {
                "iPowerLimitStr": 0,
                "iPowerLimitEnd": 120,
                "dFeePerMin": 1.0,
                "dFeePerHour": 60.0,
                "iHour": 6.0,
                "dDisCountFeePerMin": null,
                "dDisCountFeePerHour": null,
                "vStartTime": null,
                "vEndTime": null,
                "iType": 3
              },
              {
                "iPowerLimitStr": 121,
                "iPowerLimitEnd": 900,
                "dFeePerMin": 1.0,
                "dFeePerHour": 60.0,
                "iHour": 5.0,
                "dDisCountFeePerMin": null,
                "dDisCountFeePerHour": null,
                "vStartTime": null,
                "vEndTime": null,
                "iType": 3
              },
              {
                "iPowerLimitStr": 0,
                "iPowerLimitEnd": 900,
                "dFeePerMin": 2.0,
                "dFeePerHour": 120.0,
                "iHour": 10.0,
                "dDisCountFeePerMin": null,
                "dDisCountFeePerHour": null,
                "vStartTime": null,
                "vEndTime": null,
                "iType": 3
              }
            ],
            "isDefaultBilling": 1,
            "showMaxPowerInfo": 1
          }
        ],
        "staff": {
          "tBeginTime": null,
          "tEndTime": null,
          "isDisFree": 0,
          "isFree": 0,
          "freeType": 0,
          "ruleTimes": null
        },
        "banners": [
          {
            "iBannerId": 342,
            "iType": 1,
            "vImgUrl": "/skoms/doc/2023-03-08/it01on9v1gr9o5mo.png",
            "vHref": "https://api.issks.com/issksh5/?#/activityPage/pages/yearCardPage/yearCardPage",
            "iImgUrlType": 1,
            "iLinkMiniApp": 0,
            "vOriginalId": "",
            "vMiniAppId": null,
            "iLinkType": "EXTERNAL_LINK",
            "iSecond": 0,
            "vRemark": null
          },
          {
            "iBannerId": 404,
            "iType": 1,
            "vImgUrl": "/skoms/doc/2024-11-19/xdaecvf5cijrldxu.jpg",
            "vHref": "https://mp.weixin.qq.com/s/SwNDfydkbIvfmrki3G3FMQ",
            "iImgUrlType": 1,
            "iLinkMiniApp": 0,
            "vOriginalId": "",
            "vMiniAppId": null,
            "iLinkType": "EXTERNAL_LINK",
            "iSecond": 0,
            "vRemark": null
          },
          {
            "iBannerId": 427,
            "iType": 1,
            "vImgUrl": "/skoms/doc/2024-11-08/5g190u60qsvrb2oe.jpg",
            "vHref": "https://api.issks.com/issksh5/?#/sonPage/pages/batteryReport/batteryReport",
            "iImgUrlType": 1,
            "iLinkMiniApp": 0,
            "vOriginalId": "",
            "vMiniAppId": null,
            "iLinkType": "EXTERNAL_LINK",
            "iSecond": 0,
            "vRemark": null
          },
          {
            "iBannerId": 384,
            "iType": 1,
            "vImgUrl": "/skoms/doc/2024-11-15/yu1xh86jqzy7wb5x.jpg",
            "vHref": "https://shop-sksop.issks.com",
            "iImgUrlType": 1,
            "iLinkMiniApp": 1,
            "vOriginalId": "gh_6f1e4731d3ad",
            "vMiniAppId": "wx14dcf42b12d3f02c",
            "iLinkType": "EXTERNAL_LINK",
            "iSecond": 0,
            "vRemark": null
          },
          {
            "iBannerId": 347,
            "iType": 1,
            "vImgUrl": "/skoms/doc/2023-04-10/asurox92j5of1vfb.gif",
            "vHref": "https://zf.shanghcat.com/tdpl/index?cid=94825049&pln=14580873",
            "iImgUrlType": 1,
            "iLinkMiniApp": 0,
            "vOriginalId": "",
            "vMiniAppId": null,
            "iLinkType": "EXTERNAL_LINK",
            "iSecond": 0,
            "vRemark": null
          },
          {
            "iBannerId": 420,
            "iType": 1,
            "vImgUrl": "/skoms/doc/2024-09-29/b9skyf6l9ul268wq.jpg",
            "vHref": "https://mp.weixin.qq.com/s/HKuy_UFI5y2832YVOLTtzQ",
            "iImgUrlType": 1,
            "iLinkMiniApp": 0,
            "vOriginalId": "",
            "vMiniAppId": null,
            "iLinkType": "EXTERNAL_LINK",
            "iSecond": 0,
            "vRemark": null
          }
        ],
        "popups": null,
        "floatBanner": null,
        "powerFee": null,
        "usedMonthly": 0,
        "type": 0,
        "pageViewType": "common",
        "curTime": 1732984774218,
        "registerMobile": null,
        "presetLastTime": 0,
        "restmin": 0,
        "usedmin": 0,
        "usedfee": null,
        "currentUser": null,
        "cardfunds": 0,
        "funds": 0.0,
        "safeOpenFlag": 1,
        "closeWuyouSwitch": 0,
        "safeOpenFee": 0.09,
        "safeChargingOpen": 0,
        "nowBillingType": 0,
        "electric": 0,
        "chargingBeginTime": null,
        "normalMonthParkRecord": 0,
        "smartMonthParkRecord": 0,
        "chargeDiscount": null,
        "fixedAmount": {
          "amountList": [
            1.0,
            2.0
          ],
          "defaultAmountIndex": 1,
          "defaultPowerIndex": 2
        },
        "universityProperty": {
          "options": [
            "1",
            "2",
            "3"
          ],
          "maxOption": "20",
          "minOption": "3"
        },
        "noticeType": 0,
        "noticeContent": null,
        "availableNotice": 0,
        "urlLink": null,
        "available": 0,
        "userSelectAmount": null,
        "isCloseWuYou": 0,
        "canCopy": null,
        "nationalStandard": 0,
        "buttonType": 1,
        "helpMobile": null,
        "title": null,
        "managerPriceIsHour": 0,
        "activityContent": null,
        "qrcoed": 0,
        "subscribed": 0,
        "districtId": 18877,
        "showMinute": 0,
        "tags": [
          "UNIVERSITY"
        ],
        "secondaryCardGuide": 0,
        "secondaryCardNum": null,
        "averageAmount": null,
        "secondaryCardMinAmount": null,
        "text": null,
        "alipayUrl": "https://t.bfr2.top/p18OoKF",
        "weather": null,
        "weatherType": null,
        "tianMu": false
      },
      "success": true
    }
    
    PYTHON
    import json
    
    with open("output/outlets.json", "r", encoding="utf-8") as f:
        outlets = json.load(f)
    
    keys = ["station", "name", "restmin", "available_time", "usedmin", "msg", "update_time"]
    station_options = set()
    for outlet in outlets:
        station_options.add(outlet["station"])
    station_options = list(station_options)
    station_options.sort()
    
    
    html = '<div class="filter-container"><label for="filter-station">选择站点:</label><select id="filter-station"><option value="">All</option>'
    for station in station_options:
        html += f'<option value="{station}">{station}</option>'
    html += "</select></div>"
    
    html += "<table>\n<thead>\n<tr>\n"
    for key in keys:
        html += f"<th>{key}</th>"
    html += "</tr>\n</thead>\n<tbody>\n"
    
    for outlet in outlets:
        html += "<tr>"
        for key in keys:
            if key == "msg" and outlet[key] == "空闲":
                html += f'<td class="status-available">{outlet[key]}</td>'
            elif key == "msg" and outlet[key] == "故障":
                html += f'<td class="status-error">{outlet[key]}</td>'
            elif key == "msg":
                html += f'<td class="status-busy">{outlet[key]}</td>'
            elif key == "restmin" and outlet[key] < 20:
                html += f'<td class="status-available">{outlet[key]}</td>'
            else:
                html += f'<td class="tdnormal"><span>{outlet[key]}</span></td>'
        html += "</tr>\n"
    html += "</tbody>\n</table>"
    
    with open("html_template.html", "r", encoding="utf-8") as f:
        template = f.read()
        html = template.replace("{{table}}", html)
    
    with open("index.html", "w", encoding="utf-8") as f:
        f.write(html)
    
    var filter = document.getElementById('filter-station');
    var urlParams = new URLSearchParams(window.location.search);
    var initialFilter = urlParams.get('filter') || '';
    filter.value = initialFilter;
    filterTable();
    
    filter.addEventListener('change', function () {
      var selectedValue = filter.value;
      if (selectedValue === '') {
        urlParams.delete('filter');
      } else {
        urlParams.set('filter', selectedValue);
      }
      if (urlParams.toString() === '') {
        window.history.replaceState({}, '', location.pathname);
      } else {
        window.history.replaceState({}, '', `${location.pathname}?${urlParams}`);
      }
      filterTable();
    });
    
    function filterTable() {
      var rows = document.getElementsByTagName('tr');
      for (var i = 1; i < rows.length; i++) {
        var row = rows[i];
        var name = row.children[0].textContent.toLowerCase();
        var nameFilter = filter.value.toLowerCase();
        if (name.indexOf(nameFilter) !== -1 || nameFilter === '') {
          row.style.display = 'table-row';
        } else {
          row.style.display = 'none';
        }
      }
    }