UNPKG

hkopendata

Version:

Access different Opendata API and data in Hong Kong

607 lines (581 loc) 23.6 kB
// https://www.td.gov.hk/filemanager/en/content_1408/opendata/franchised_and_licensed_ferry_time_and_fare_tables_dataspec_eng.pdf // https://www.hkkfeta.com/datagovhk/HKKF_ETA_API_Specification.pdf // https://www.hongkongwatertaxi.com.hk/csv/Watertaxi_FortuneFerry_ETA_API_Specification_and_Data_Dictionary.pdf // https://www.sunferry.com.hk/eta/SunFerry_ETA_API_Specification_and_Data_Dictionary.pdf const cmn = require("../../common"); const moment = require("../../moment"); const UnitValue = require("../../_class").UnitValue; const BASE_URL = "https://www.td.gov.hk/filemanager/{lang}/content_{cid}/opendata/ferry_{route}_{type}table_{langFile}.csv"; const FFCL_BASE_URL = "https://www.hongkongwatertaxi.com.hk/csv/{route}{type}table_{langFile}.csv"; const ETA_BASE_URL = { HKKF: "https://www.hkkfeta.com/opendata/eta/{section}", SUNF: "https://www.sunferry.com.hk/eta/?route={section}", FFCL: "https://www.hongkongwatertaxi.com.hk/eta/?route={section}", } const FERRY = {}; const ROUTES = ["central_skw", "central_ysw", "central_pc", "central_mw", "pc_mw_cmw_cc", "central_cc", "central_db", "mawan_c", "mawan_tw", "np_hh", "np_klnc", "np_ktak", "swh_kt", "swh_skt", "db_mw", "tm_tc_slw_to", "abd_skw", "abd_ysw", "c_hh", "mls_tm", "mls_tpc", "tm_wsp", "npkt_mw", "np_kt", "WaterTaxi"] const VALID = { type: /^(route|route-stop|time|fare|eta)$/, lang: /^(en|tc|sc)$/, } const VALID_OPT = { stop: /^[A-z0-9]{6}$/, } const PARAMS = { type: "route", lang: "en", cid: "1408" } const SEARCH_CONFIG = { value: { type: { accepted: ["route", "route-stop", "time", "fare", "eta"] }, lang: { accepted: { tc: "tc", sc: "sc", en: "en" } } }, } function beforeSearch() { if (Object.keys(FERRY).length === 0 && (!VALID_OPT.route || !SEARCH_CONFIG.value.route)) { let _ferry = cmn.GetDataJson("hk-ferry"); for (let key in _ferry) FERRY[key] = _ferry[key]; if ("route" in FERRY) { FERRY.route.filter(v => ROUTES.indexOf(v.code) == -1).map(v => ROUTES.push(v.code)); } VALID_OPT.route = new RegExp(`^(${ROUTES.join("|")})$`); SEARCH_CONFIG.value.route = { accepted: ROUTES } } } function validateParameters(params) { params = cmn.ParseSearchFields(params, SEARCH_CONFIG); let result = cmn.ValidateParameters(params, VALID, VALID_OPT); if (!result.error && Object.keys(FERRY).length == 0) { result.error = true; result.message = "Please download and place `hk-ferry.json` to `/.hkopendata/data` correctly." } if (!result.error && params.type != "route" && !("route" in params)) { result.error = true; result.message = "Missing route"; } if (!result.error && (!FERRY.route || !FERRY.route.find(v => v.code === params.route))) { result.error = true; result.message = "Invalid route"; } if (!result.error && params.route && /^(mls_tm|mls_tpc|tm_wsp)$/.test(params.route)) { params.cid = "4912"; } if (!result.error && params.type === "eta") { if ("section" in params) { let route = FERRY.route.find(v => v.code === params.route); if (!("direction" in route) || !route.direction[params.section]) { result.error = true; result.message = "Invalid route section"; } else { params.section = route.direction[params.section]; let company = FERRY.company.find(v => v.code === route.company); if ("eta" in company && company.eta in ETA_BASE_URL) { params.baseUrl = ETA_BASE_URL[company.eta]; } else { result.error = true; result.message = "No ETA info for this route"; } } } else { result.error = true; result.message = "Missing route section"; } } if (!result.error) { let temp = {}; if (params.route === "WaterTaxi" && (params.type === "fare" || params.type === "time")) { temp.langFile = params.lang == "sc" ? "chs" : params.lang == "tc" ? temp.langFile = "cht" : "eng"; temp.type = params.type.toCapitalCase(); temp.baseUrl = FFCL_BASE_URL; temp.csvOpts = { delimiter: "\t", from_line: 2, encoding: "utf-16" }; } else { temp.langFile = params.lang == "en" ? "eng" : "chi"; temp.baseUrl = params.baseUrl || BASE_URL; } result.data = { ...params, ...temp, } } return result; } function search(data, opts) { beforeSearch(); return new Promise((resolve, reject) => { let processed = validateParameters({ ...PARAMS, ...data }); if (processed.error) { reject(processed); } else { let { baseUrl, csvOpts, ...params } = processed.data; let promise; if (/route/.test(params.type)) { promise = getRouteData(params); } else if (params.type === "eta") { promise = cmn.APIRequest(cmn.ReplaceURL(baseUrl, params)); } else { promise = cmn.CSVFetch(cmn.ReplaceURL(baseUrl, params), csvOpts); } promise .then((res) => { resolve(processData(res, params)) }) .catch((err) => reject(err)) } }) } function convert(type, code) { if (type in FERRY) { let temp = FERRY[type].filter(v => v.code == code); if (temp.length > 0) { let res = { ...temp[0] } delete res.code; return res; } } return code; } function getRouteData(params) { return new Promise((resolve, reject) => { let temp = [], error; if (!params.route) { temp = FERRY.route; } else { let c = convert("route", params.route); if (typeof c === "string") error = "Invalid route"; else temp.push(c); } if (error) reject(error); else { resolve(temp.map(v => processRouteData(v))); } }) } function processTimeString(str, freq) { let arr = str.split("-"); if (arr.length == 1) { return new UnitValue({ type: "time", category: "min", value: parseFloat(arr[0]), }); } return { min: new UnitValue({ type: "time", category: "min", value: parseFloat(arr[freq ? 1 : 0]), }), max: new UnitValue({ type: "time", category: "min", value: parseFloat(arr[freq ? 0 : 1]), }) } } function processRouteData(data) { let res = {}; for (let key in data) { if (/^(origin|destination)$/.test(key)) { res[key] = convert("pier", data[key]); delete res[key].loc; } else if (key == "company") { res[key] = convert(key, data[key]); res.companyCode = data[key]; } else if (key == "stops") { res[key] = data[key].map(v => convert("pier", v).loc) } else if (key == "duration") { res[key] = []; for (let sec in data[key]) { let piers = sec.split("-").map(v => convert("pier", v)), temp = { origin: piers[0], destination: piers[1], }; delete temp.origin.loc; delete temp.destination.loc; if (typeof data[key][sec] === "string") { temp.ordinaryFerry = processTimeString(data[key][sec]); } else if (typeof data[key][sec] === "object") { for (let k in data[key][sec]) { temp[k + (k == "roundTrip" ? "" : "Ferry")] = processTimeString(data[key][sec][k]); } } res[key].push(temp) } } else if (!/^(code)$/.test(key)) { res[key] = data[key]; } } return res; } function processData(data, params) { let result = []; if (params.type == "route") { result = data.map(v => { delete v.stops; delete v.remarks; return v; }) } else if (params.type == "route-stop") { result = data[0].stops; } else if (params.type.toLowerCase() == "time") { let temp = {}, temp2 = {}, route = convert("route", params.route), stops = (route.stops || []).map(code => { let pier = convert("pier", code); delete pier.loc; let str = Object.keys(pier).map(k => pier[k]).join("|"); pier.regexp = new RegExp(`(${str})`, "i"); return pier; }), remarks = []; data.body.map(row => { let dir = row[0].replace("Chueung Chau", "Cheung Chau"), noRemark = typeof row[3] === "undefined" || row[3].trim() === ""; if (!(dir in temp)) temp[dir] = {}; if (!(row[1] in temp[dir])) temp[dir][row[1]] = []; temp[dir][row[1]].push(moment(row[2].replace(/(\d+)\.(\d+)/, '$1:$2').replace(/\./g, ""), ["HH:mm A", "Hmm A"]).format("HH:mm") + (noRemark ? "" : `[${row[3]}]`)) if (!noRemark) remarks.push(row[3]); }) remarks = remarks.filter((v, i, l) => v != "" && l.indexOf(v) == i).sort(); if (route.remarks && route.remarks.time) { remarks = remarks.map(v => { let t = {}; for (let lang in route.remarks.time[v]) { t[lang] = `[${v}] ${route.remarks.time[v][lang]}`; } return t; }); } for (let dir in temp) { let arr = stops.filter(v => dir.search(v.regexp) != -1) .sort((a, b) => dir.search(a.regexp) - dir.search(b.regexp)), origin = { ...arr[0] }, destination = { ...arr[arr.length - 1] }, timetable = { mon: [], tue: [], wed: [], thu: [], fri: [], sat: [], sun: [], ph: [], }; delete origin.regexp; delete destination.regexp; for (let days in temp[dir]) { let available = strToWeekday(days); for (let d in available) { if (available[d]) timetable[d] = timetable[d].concat(temp[dir][days]); } } for (let d in timetable) { timetable[d] = timetable[d].sort(); } let tkey = `${origin.en}-${destination.en}`; if (!(tkey in temp2)) { temp2[tkey] = { origin, destination, timetable, remarks } } else { for (let d in timetable) { temp2[tkey].timetable[d] = temp2[tkey].timetable[d].concat(timetable[d]).sort(); } } } for (let tkey in temp2) { result.push(temp2[tkey]); } } else if (params.type.toLowerCase() == "fare") { let temp = {}, temp2 = {}, route = convert("route", params.route); data.body .filter(v => v.join("").trim().length > 10) .map(row => { let routePart, ferryType, payment, point; if (params.route == "central_pc") { routePart = row.shift(); ferryType = row.splice(2, 1)[0].trim(); } else if (/^(central_mw|central_cc)$/.test(params.route)) { ferryType = row.splice(2, 1)[0].trim(); } else if (params.route == "pc_mw_cmw_cc") { routePart = row.shift(); row.splice(1, 0, "") } else if (params.route == "central_db") { payment = row.splice(2, 1)[0]; point = row.splice(3, 1)[0]; } else if (/^(db_mw|abd_skw|abd_ysw|mls_tm|tm_wsp|npkt_mw)$/.test(params.route)) { routePart = row.splice(1, 1)[0]; } else if (params.route == "tm_tc_slw_to") { routePart = row.splice(1, 1)[0].trim(); ferryType = row.splice(2, 1)[0].trim(); } else if (params.route == "WaterTaxi") { routePart = row.splice(2, 1)[0].split(/\s+(or|或)\s+/i)[0].trim(); ferryType = row.splice(1, 1)[0].trim(); row.splice(1, 0, '') } let type = row[0].replace(/(multi)-/ig, "$1_").split("-").map(v => v.trim()), ticket = type.shift().replace(/(multi)_/ig, "$1-"), passenger = type.join("-").replace(/(multi)_/ig, "$1-"), amount = { fare: parseFloat(point) || parseFloat(row[2].replace("$", "")) || 0 }, ticketRemark = ticket.match(/[((](.+)[))]/), passCheck = (str) => { if (/65|elderly|長者|长者/i.test(str)) { str = "elderly"; } else if (/child|小童/i.test(str) && /adult|成人/i.test(str) && !/陪同|accompan(ied|y)/i.test(str)) { str = "allPassenger"; } else if (/child|小童/i.test(str)) { str = /12/i.test(str) ? "child" : "baby"; } else if (/adult|成人/i.test(str)) { str = "adult"; } else if (/student|學生|学生/i.test(str)) { str = "student"; } else if (/disabilities|disabled|殘疾|残疾/i.test(str)) { str = "disability"; } return str; }; if (/^(mawan_c|mawan_tw)$/.test(params.route)) { payment = row[3] == "1" ? "八達通" : "非登記八達通"; row[3] = ""; } else if (params.route === "WaterTaxi" && row[3]) { payment = row[3]; row[3] = ""; } if (ferryType && ferryType != "-") amount.ferryType = ferryType; if (!routePart && typeof route !== "string") { let o = convert("pier", route.origin), d = convert("pier", route.destination) routePart = `${o[params.lang]}${params.lang == "en" ? " - " : "—"}${d[params.lang]}`; } amount.routePart = routePart || ""; if (/bicycle|單車|单车/i.test(ticket)) { ticket = "bicycle"; } else if (/freight|貨物|货物/i.test(ticket)) { ticket = "freight"; } else if (/foreign domestic helper|外籍家庭傭工|外籍家庭佣工/i.test(ticket)) { ticket = "specialFare"; passenger = "domesticHelper" } else if (!passenger) { ticket = passCheck(ticket) } ticket = ticket.replace(/[((](.+)[))]/, ''); if (passenger) { amount.passenger = passCheck(passenger); } if (payment) { let types = []; if (/cash|現金|现金/i.test(payment)) { types.push("cash"); } if (/octopus|八達通|八达通/i.test(payment)) { types.push(/non-registered|非登記|非登记/i.test(payment) ? "octopus" : "regedOctopus"); } if (/transport card|T卡|per single journey|外籍家庭傭工|外籍家庭佣工/i.test(payment)) { payment = ""; } amount.payment = types.length === 1 ? types[0] : payment; } if (!(ticket in temp)) temp[ticket] = {}; if (!(row[1] in temp[ticket])) temp[ticket][row[1]] = []; if (row[3]) { amount.remarks = row[3].split(",").map(v => { let r = v.trim(); if (route.remarks && route.remarks.fare && /^\d+$/.test(r) && r in route.remarks.fare) { let remark = {}; for (let lang in route.remarks.fare[r]) { remark[lang] = `[${r}] ${route.remarks.fare[r][lang]}`; } r = { ...remark } } return r; }) if (/transport card|T卡/i.test(ticket)) { amount.remarks.push({ "en": "Transport Card (A): $1310 : 1550 Point, Transport Card (B): $858 : 930 Point", "tc": "T卡 (A): $1310 : 1550 儲點, T卡 (B): $858 : 930 儲點", "sc": "T卡 (A): $1310 : 1550 储点, T卡 (B): $858 : 930 储点", }) } } if (ticketRemark) amount.remarks = (amount.remarks || []).concat(ticketRemark[1]); temp[ticket][row[1]].push(amount); }) for (let ticket in temp) { for (let time in temp[ticket]) { temp[ticket][time].map(item => { let fare = item.fare; if ("remarks" in item) { fare = { amount: item.fare, remarks: item.remarks, } } if (!(item.routePart in temp2)) temp2[item.routePart] = {}; if (!(ticket in temp2[item.routePart])) temp2[item.routePart][ticket] = {}; let t = temp2[item.routePart][ticket], tArr = []; if (item.payment) { if (!(item.payment in t)) t[item.payment] = {}; t = t[item.payment]; tArr.push(item.payment); } if (item.passenger) { if (!(item.passenger in t)) t[item.passenger] = {}; t = t[item.passenger]; tArr.push(item.passenger); } if (item.ferryType) { if (!(item.ferryType in t)) t[item.ferryType] = {}; t = t[item.ferryType]; tArr.push(item.ferryType); } if (time) t[time] = fare; else { if (tArr.length > 0) { t = temp2[item.routePart][ticket]; tArr.map((v, i) => { if (i == tArr.length - 1) { t[v] = fare; } else { t = t[v]; } }) } else { temp2[item.routePart][ticket] = fare; } } }) } } for (let part in temp2) { result.push({ route: part, fare: { ...temp2[part] } }) } } else if (params.type === "eta") { result = data.data.map(item => { let temp = {}; for (let key in item) { if (!item[key]) continue; let m; if (/eta/i.test(key)) { temp.eta = item[key].length === 5 ? moment(item[key], "HH:mm") : moment(item[key]); } else if (key === "depart_time") { temp.etd = item[key].length === 5 ? moment(item[key], "HH:mm") : moment(item[key]); } else if ((m = key.match(/^([a-z_]+)_(en|tc|sc)/i))) { if (!(m[1] in temp)) temp[m[1]] = {}; temp[m[1]][m[2]] = item[key]; } } return temp; }) } return result; } function strToWeekday(str) { let regexWeek = { mon: /mondays?|mon|weekday|一/gi, tue: /tuesdays?|tue|weekday|二/gi, wed: /wednesdays?|wed|weekday|三/gi, thu: /thursdays?|thu|weekday|四/gi, fri: /fridays?|fri|weekday|五/gi, sat: /saturdays?|sat|weekend|六/gi, sun: /sundays?|sun|weekend|日/gi, ph: /(public|general) holiday|公(衆|眾|众)假期/gi }, available = { mon: false, tue: false, wed: false, thu: false, fri: false, sat: false, sun: false, ph: false, }, tempStr = str, replaceStr = [], except = [], m; tempStr = tempStr.replace("星期一 至 星期一", "星期一").replace(/星期日 \(公(衆|眾|众)假期除外\)/, "星期日及公眾假期"); if (/daily|每天|每日/i.test(tempStr)) { for (let key in available) { available[key] = true; } return available; } for (let day in regexWeek) { if (m = tempStr.match(regexWeek[day])) { tempStr = tempStr.replace(regexWeek[day], `{${replaceStr.length}}`); replaceStr.push(day); } } if (m = tempStr.match(/except \{(\d+)\}/i)) { except.push(replaceStr[m[1]]); tempStr = tempStr.replace(m[0], "") } else if (m = tempStr.match(/\{(\d+)\}除外/i)) { except.push(replaceStr[m[1]]); tempStr = tempStr.replace(m[0], "") } if (m = tempStr.match(/(\{(\d+)\}(to|至| )+\{(\d+)\})/)) { let ok = false, st = replaceStr[m[2]], ed = replaceStr[m[4]]; for (let key in available) { if (key == st) ok = true; if (ok) available[key] = true; if (key == ed) ok = false; } tempStr = tempStr.replace(m[1], ""); } while (m = tempStr.match(/(\{(\d+)\})/)) { for (let key in available) { if (key == replaceStr[m[2]]) { available[key] = true; tempStr = tempStr.replace(m[1], ""); } } } except.map(day => available[day] = false) return available; } module.exports = search;