Do1e

Do1e

github
email

南哪充電,從毛坯到完善

此文由 Mix Space 同步更新至 xLog
為獲得最佳瀏覽體驗,建議訪問原始鏈接
https://www.do1e.cn/posts/code/njucharge



歡迎訪問我寫的 南哪充電 - 鼓樓南哪充電 - 仙林

這一切的起源來自於 9 月份的某個晚上沒有找到充電樁……
事實上在那之前已經有一個南哪充電的網頁了: https://charge.zhuxh.net/

image

不過我個人用起來感覺還是差點意思,只能一眼看見哪裡還有空閒,但由於充電樁過於不夠,想充電的時候很可能是一片全紅,或者僅有的綠色離自己很遠。
於是我也準備自己寫一個,能夠顯示預計剩餘時間,方便我去提前蹲守 ||,卷死你們 ||

後端爬取數據#

爬蟲對我來說還是挺簡單的,畢竟也寫了好幾個爬蟲相關項目了,Reqable啟動!

獲取充電站 ID#

首先閃開來電在一堆請求中篩選出屬於南大仙林的充電站點,拿到充電站點的 station_id ,這一步屬於純手抄,具體 id 如下:

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

獲取每個充電站下插座 ID#

上一步只有 33 個充電站,手抄倒也能接受,不過 302 個插座 ID 也要手抄的話還是放過我吧。
f'https://wemp.issks.com/charge/v1/outlet/station/outlets/{station_id}' 中可以獲取每個充電站點的信息,其中包含插座的 id。
注意,這一步必須在請求頭中添加 token(一串issks_開頭的字符串)。代碼如下:

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

終於,來獲取每個插座的狀態吧#

在上一步我們能拿到每個充電站(比如天文學院)下每一個充電插座的outletNo,這一步就可以根據outletNo,從 f'https://wemp.issks.com/charge/v1/charging/outlet/{outletNo}' 中獲取每個插座的具體狀態了!

返回示例:

{
  "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
}

其實裡面很多信息都是不必要的,我這裡把插座名稱、預計剩餘時間、已使用時間、狀態代碼(空閒、故障、分鐘計費模式、固定金額模式)、是否有錯誤信息這幾個拿下來就好了,同時再算出一個預計可用時間方便前端直接展示(畢竟我前端實在太弱了)。代碼如下:

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 生成的。>︿<
發出來給大家笑話一下

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)

而且界面也是相當簡陋,不過好歹還是用 GPT 幫我生成了一段 js 代碼用於篩選站點

image

第二版前端#

所謂的第二版也只是寫了幾句 css 盡力去拯救這個界面罷了。

image

第三版前端#

我開始捣鼓我的新版個人主頁了,既然Mix Space支持寫 Markdown with JavaScript,我就把 /charge.html 掛在了個人主頁下了,並且也改進了原來的篩選功能,進行篩選的同時會更改 URL 參數,這樣刷新之後也能記住上次用戶篩選的站點並直接展示出來了。

image

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';
    }
  }
}

在設計這一版前端顏色的時候,一開始想着按照紅綠燈配色(紅黃綠)表示預計剩餘時間的三種狀態,但腦子已經被《敗犬女主太多了》佔據。這裡面的三種代表色似乎也能很好地表達出類似的意思,于是到其官網上把配色借了過來。

image

第四版前端#

由於從第一版就用表格展示,從上面的圖也能看出來,在更常用的應用場景 —— 手機上的體驗屬實一言難盡。于是下定決心重構了半天的 UI,並且在開頭添加了一個統計表格,可用更方便地規劃充電目的地了。

image

基本所有前後端代碼都在下面的 github 倉庫中,歡迎使用但請遵守 MIT 協議,使用時保留我的版權信息。

後續小更新#

  • 2024-12-01 16:02:新增鼓樓校區
  • 2025-01-07 19:44:今天突然發現充電必須充值後按分鐘計費了,豈可休。更新了按已使用時間逆序,按照閃開充電提供的說明,這個時間最大為 480 分鐘,至少還是能作為參考大概知道哪個充電樁快結束了。
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。