南哪充电，从毛坯到完善

https://www.do1e.cn/posts/code/njucharge

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

这一切的起源来自于 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}' 中获取每个插座的具体状态了！

返回示例：

Copy
{
"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 生成的。＞︿＜
发出来给大家笑话一下

Copy
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 = ' 选择站点: All '
for station in station_options:
html += f' {station} '
html += " "

html += " \n \n \n"
for key in keys:
html += f" {key} "
html += " \n \n \n"

for outlet in outlets:
html += " "
for key in keys:
if key == "msg" and outlet[key] == "空闲":
html += f' {outlet[key]} '
elif key == "msg" and outlet[key] == "故障":
html += f' {outlet[key]} '
elif key == "msg":
html += f' {outlet[key]} '
elif key == "restmin" and outlet[key] < 20:
html += f' {outlet[key]} '
else:
html += f' {outlet[key]} '
html += " \n"
html += " \n "

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 代码用于筛选站点

第二版前端

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

第三版前端

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

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

第四版前端

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

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

后续小更新

2024-12-01：新增鼓楼校区。

2025-01-07：今天突然发现充电必须充值后按分钟计费了，岂可休。更新了按已使用时间逆序，按照闪开充电提供的说明，这个时间最大为 480 分钟，至少还是能作为参考大概知道哪个充电桩快结束了。

2025-02-22：分钟计费模式用户可以自选预充值的金额了，因此可以根据这个估算预计可用时间了。但考虑到有些人直接选往高了选以便充满，同时接口返回数值的精度导致最后计算精度较低，因此仅供参考。电度计费模式理论上也可以估计，但考虑到接口中没有办法直接读取功率，因此暂不编写。

2025-06-16：新增鼓楼和仙林多处充电桩。（插座数变化：鼓楼 148->308，仙林 302->724）

2025-06-22：貌似系统设置的最长充电时间为 480 分钟，因此基于此对剩余时间的预计做了修改。