后台新增「地点补全」页,把「名下到访没设活动」和「类别为空」的地点列出来, 给出建议、由站长确认后再写。判断分三层: - 本地关键词表:零延迟、离线,命中即高置信(「星巴克咖啡」→ 奶茶和甜甜水) - 高德周边搜索:按坐标问「这里是什么 POI」,比按名字搜准; 拿到的类型再映射成活动与 MapKit 类别 - 人工:都猜不出的留空,站长自己填;填了列表里没有的名字会自动建成新活动类型 两条底线:**只填空**(已有活动或类别的一律不动);**活动按地点批量设**, 一次把它名下所有没设活动的到访一起填上,否则同一个地点会在统计里分裂成两种活动。 类别写的是 Apple MapKit 的枚举名(真机确认用过 12 种),不是高德分类。 规则上踩过两个坑,都写进注释了:匹配前要先掐掉括号里的地址(否则 「尚优居客栈(大理古城步行街店)」会命中「步行街」),并且按词长优先 (否则「马鞍山公园」会命中「山」);单字关键词一律不要,误命中率太高。
204 lines
11 KiB
Go
204 lines
11 KiB
Go
package web
|
||
|
||
import "strings"
|
||
|
||
// 地点补全的判定规则:地名怎么猜活动、高德 POI 类型怎么猜活动和类别。
|
||
//
|
||
// 两条原则:
|
||
// - **只填空**:调用方只在原值为空时才用这里的建议;
|
||
// - **拿不准就别猜**:宁可标成「待确认」让人看一眼,也不要瞎填一个看起来像样的值,
|
||
// 错的活动会直接污染统计与筛选,比空着更难发现。
|
||
//
|
||
// 词表按**定义顺序**匹配,先命中的赢——所以具体的词要排在笼统的词前面。
|
||
|
||
// actHint 是一条「关键词 → 活动类型」的映射,关键词出现在地名里就算命中。
|
||
type actHint struct{ kw, val string }
|
||
|
||
// nameActHints 从地名猜活动。活动名必须与站点里已有的 31 个完全一致,
|
||
// 否则调用方会把它当成「需要新建的类型」。
|
||
var nameActHints = []actHint{
|
||
{"咖啡", "奶茶和甜甜水"}, {"奶茶", "奶茶和甜甜水"}, {"茶艺", "奶茶和甜甜水"},
|
||
{"书店", "学习"}, {"图书馆", "学习"}, {"学校", "学习"}, {"大学", "学习"},
|
||
{"博物馆", "纪念馆"}, {"纪念馆", "纪念馆"}, {"陈列馆", "纪念馆"},
|
||
{"动物园", "动物园"}, {"海洋馆", "动物园"},
|
||
{"沙滩", "沙滩"}, {"海滨", "沙滩"}, {"海滩", "沙滩"},
|
||
{"度假区", "旅游景点"}, {"古镇", "旅游景点"}, {"景区", "旅游景点"},
|
||
{"游客中心", "旅游景点"}, {"索道", "旅游景点"},
|
||
{"公园", "散步"}, {"绿道", "散步"}, {"步道", "散步"}, {"广场", "散步"}, {"栈道", "散步"},
|
||
{"步行街", "步行街"}, {"老街", "步行街"},
|
||
{"小吃", "美食街"}, {"美食", "美食街"}, {"夜市", "美食街"}, {"大排档", "美食街"},
|
||
{"餐厅", "餐厅"}, {"饭店", "餐厅"}, {"酒楼", "餐厅"}, {"食府", "餐厅"}, {"火锅", "餐厅"},
|
||
{"菜市场", "市场"}, {"农贸", "市场"}, {"市场", "市场"},
|
||
{"超市", "小商店"}, {"便利店", "小商店"}, {"小卖部", "小商店"}, {"商店", "小商店"},
|
||
{"商场", "商场"}, {"购物", "商场"}, {"百货", "商场"},
|
||
{"公交", "公共交通"}, {"地铁", "公共交通"}, {"车站", "公共交通"}, {"机场", "公共交通"},
|
||
{"高铁", "公共交通"}, {"码头", "公共交通"}, {"客运", "公共交通"},
|
||
{"民宿", "酒店"}, {"客栈", "酒店"}, {"宾馆", "酒店"}, {"酒店", "酒店"}, {"度假", "酒店"},
|
||
{"医院", "一般公共场所"}, {"诊所", "一般公共场所"}, {"政务", "一般公共场所"}, {"服务中心", "一般公共场所"},
|
||
{"体育", "户外活动"}, {"运动", "户外活动"}, {"健身", "户外活动"}, {"露营", "户外活动"},
|
||
{"骑行", "骑行目的地"}, {"自行车", "骑行目的地"},
|
||
{"租车", "租车"}, {"汽车租赁", "租车"},
|
||
{"彩票", "彩票购买点"},
|
||
{"4S", "车辆维修"}, {"维修", "车辆维修"}, {"汽修", "车辆维修"},
|
||
{"停车", "暂且休息"}, {"服务区", "暂且休息"}, {"休息区", "暂且休息"},
|
||
{"公司", "工作"}, {"办公", "工作"}, {"产业园", "工作"}, {"工厂", "工作"},
|
||
{"塔", "地标"}, {"大桥", "地标"}, {"地标", "地标"}, {"牌坊", "地标"},
|
||
{"乡镇", "乡镇"}, {"村委会", "乡镇"}, {"街道办", "乡镇"},
|
||
{"山峰", "山"}, {"峡谷", "山"}, {"雪山", "山"},
|
||
}
|
||
|
||
// amapActHints 从高德 POI 类型猜活动(高德给的是「大类;小类」形式,按子串匹配)。
|
||
var amapActHints = []actHint{
|
||
{"咖啡厅", "奶茶和甜甜水"}, {"茶艺馆", "奶茶和甜甜水"}, {"冷饮店", "奶茶和甜甜水"},
|
||
{"图书馆", "学习"}, {"学校", "学习"}, {"高等院校", "学习"}, {"中学", "学习"}, {"小学", "学习"},
|
||
{"博物馆", "纪念馆"}, {"展览馆", "纪念馆"}, {"科教文化", "纪念馆"},
|
||
{"动物园", "动物园"}, {"水族馆", "动物园"},
|
||
{"海滩", "沙滩"}, {"海滨浴场", "沙滩"},
|
||
{"风景名胜", "旅游景点"}, {"公园广场", "旅游景点"}, {"寺庙道观", "旅游景点"},
|
||
{"公园", "散步"}, {"城市广场", "散步"},
|
||
{"特色街区", "步行街"},
|
||
{"小吃快餐", "美食街"}, {"美食街", "美食街"}, {"餐饮相关", "美食街"},
|
||
{"中餐厅", "餐厅"}, {"外国餐厅", "餐厅"}, {"餐厅", "餐厅"}, {"快餐厅", "餐厅"},
|
||
{"综合市场", "市场"}, {"农副产品市场", "市场"}, {"菜市场", "市场"},
|
||
{"便利店", "小商店"}, {"超市", "小商店"}, {"专卖店", "小商店"}, {"小商品市场", "小商店"},
|
||
{"商场", "商场"}, {"购物中心", "商场"}, {"百货商场", "商场"},
|
||
{"地铁站", "公共交通"}, {"公交车站", "公共交通"}, {"火车站", "公共交通"},
|
||
{"机场", "公共交通"}, {"港口码头", "公共交通"}, {"长途汽车站", "公共交通"},
|
||
{"宾馆酒店", "酒店"}, {"旅馆招待所", "酒店"}, {"度假村", "酒店"},
|
||
{"综合医院", "一般公共场所"}, {"专科医院", "一般公共场所"}, {"诊所", "一般公共场所"},
|
||
{"政府机关", "一般公共场所"}, {"社会团体", "一般公共场所"},
|
||
{"运动场馆", "户外活动"}, {"健身中心", "户外活动"}, {"露营地", "户外活动"},
|
||
{"汽车租赁", "租车"}, {"汽车维修", "车辆维修"}, {"汽车服务", "车辆维修"},
|
||
{"彩票", "彩票购买点"},
|
||
{"停车场", "暂且休息"}, {"服务区", "暂且休息"},
|
||
{"公司", "工作"}, {"写字楼", "工作"}, {"产业园区", "工作"},
|
||
{"旅游景点", "旅游景点"},
|
||
{"商务住宅", "家"}, {"住宅区", "家"},
|
||
|
||
// 一级大类兜底
|
||
{"餐饮服务", "餐厅"},
|
||
{"购物服务", "商场"},
|
||
{"住宿服务", "酒店"},
|
||
{"交通设施服务", "公共交通"},
|
||
}
|
||
|
||
// mkHints 从高德 POI 类型猜 MapKit 类别。
|
||
//
|
||
// 值必须是真机能认的枚举名(`MKPOICategory` + 驼峰)。已确认真机用过的是这 12 种:
|
||
// Restaurant / Store / PublicTransport / Hotel / Landmark / FoodMarket / Beach /
|
||
// Museum / Campground / Zoo / Stadium / Park。其余取 MapKit 的公开枚举名,
|
||
// 万一写错,rond 那边只会显示成原始串,不会损坏数据。
|
||
var mkHints = []actHint{
|
||
{"咖啡厅", "MKPOICategoryCafe"}, {"茶艺馆", "MKPOICategoryCafe"}, {"冷饮店", "MKPOICategoryCafe"},
|
||
{"糕饼店", "MKPOICategoryBakery"}, {"面包", "MKPOICategoryBakery"},
|
||
{"图书馆", "MKPOICategoryLibrary"},
|
||
{"学校", "MKPOICategorySchool"}, {"中学", "MKPOICategorySchool"}, {"小学", "MKPOICategorySchool"},
|
||
{"高等院校", "MKPOICategoryUniversity"},
|
||
{"博物馆", "MKPOICategoryMuseum"}, {"展览馆", "MKPOICategoryMuseum"},
|
||
{"动物园", "MKPOICategoryZoo"}, {"水族馆", "MKPOICategoryAquarium"},
|
||
{"海滩", "MKPOICategoryBeach"}, {"海滨浴场", "MKPOICategoryBeach"},
|
||
{"风景名胜", "MKPOICategoryLandmark"}, {"寺庙道观", "MKPOICategoryLandmark"},
|
||
{"公园", "MKPOICategoryPark"}, {"城市广场", "MKPOICategoryLandmark"},
|
||
{"露营地", "MKPOICategoryCampground"},
|
||
{"综合市场", "MKPOICategoryFoodMarket"}, {"农副产品市场", "MKPOICategoryFoodMarket"},
|
||
{"便利店", "MKPOICategoryStore"}, {"超市", "MKPOICategoryStore"},
|
||
{"专卖店", "MKPOICategoryStore"}, {"商场", "MKPOICategoryStore"}, {"购物中心", "MKPOICategoryStore"},
|
||
{"地铁站", "MKPOICategoryPublicTransport"}, {"公交车站", "MKPOICategoryPublicTransport"},
|
||
{"火车站", "MKPOICategoryPublicTransport"}, {"港口码头", "MKPOICategoryPublicTransport"},
|
||
{"长途汽车站", "MKPOICategoryPublicTransport"},
|
||
{"机场", "MKPOICategoryAirport"},
|
||
{"宾馆酒店", "MKPOICategoryHotel"}, {"旅馆招待所", "MKPOICategoryHotel"}, {"度假村", "MKPOICategoryHotel"},
|
||
{"综合医院", "MKPOICategoryHospital"}, {"专科医院", "MKPOICategoryHospital"},
|
||
{"诊所", "MKPOICategoryHospital"}, {"药房", "MKPOICategoryPharmacy"},
|
||
{"运动场馆", "MKPOICategoryStadium"}, {"健身中心", "MKPOICategoryFitnessCenter"},
|
||
{"电影院", "MKPOICategoryMovieTheater"}, {"剧场", "MKPOICategoryTheater"},
|
||
{"汽车租赁", "MKPOICategoryCarRental"}, {"加油站", "MKPOICategoryGasStation"},
|
||
{"停车场", "MKPOICategoryParking"},
|
||
{"银行", "MKPOICategoryBank"}, {"自动取款机", "MKPOICategoryAtm"},
|
||
{"邮局", "MKPOICategoryPostOffice"}, {"派出所", "MKPOICategoryPolice"},
|
||
{"中餐厅", "MKPOICategoryRestaurant"}, {"外国餐厅", "MKPOICategoryRestaurant"},
|
||
{"快餐厅", "MKPOICategoryRestaurant"}, {"餐厅", "MKPOICategoryRestaurant"},
|
||
|
||
// 高德的一级大类兜底:小类不认识时至少落到大类上
|
||
{"餐饮服务", "MKPOICategoryRestaurant"},
|
||
{"购物服务", "MKPOICategoryStore"},
|
||
{"住宿服务", "MKPOICategoryHotel"},
|
||
{"体育休闲服务", "MKPOICategoryStadium"},
|
||
{"医疗保健服务", "MKPOICategoryHospital"},
|
||
{"风景名胜", "MKPOICategoryLandmark"},
|
||
{"交通设施服务", "MKPOICategoryPublicTransport"},
|
||
{"金融保险服务", "MKPOICategoryBank"},
|
||
}
|
||
|
||
// trimParens 只看括号外的主体:「尚优居客栈(大理古城步行街店)」是个客栈,
|
||
// 括号里那串只是地址,不该拿它来判断。
|
||
func trimParens(s string) string {
|
||
if i := strings.IndexAny(s, "(([【"); i > 0 {
|
||
return s[:i]
|
||
}
|
||
return s
|
||
}
|
||
|
||
// matchHint 取**最长的**命中词——越长的词越具体。
|
||
//
|
||
// 不用「定义顺序」也不用「出现位置」:前者要求把词表排成一件容易出错的事,
|
||
// 后者会让「马鞍山公园」命中「山」而不是「公园」。长度相同则取先定义的。
|
||
func matchHint(hints []actHint, s string) string {
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
best, bestLen := "", 0
|
||
for _, h := range hints {
|
||
if len(h.kw) > bestLen && strings.Contains(s, h.kw) {
|
||
best, bestLen = h.val, len(h.kw)
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
// matchNameHint 是地名专用的入口:先掐掉括号里的地址再匹配。
|
||
func matchNameHint(hints []actHint, s string) string {
|
||
return matchHint(hints, trimParens(s))
|
||
}
|
||
|
||
// guessActivityByName 从地名猜活动,命中表示置信度高。
|
||
func guessActivityByName(name string) string { return matchNameHint(nameActHints, name) }
|
||
|
||
// matchAmapType 在高德类型串里找映射。高德给的是「大类;小类」,
|
||
// **小类更具体**(「风景名胜;公园」该按公园算,不是风景名胜),所以先试小类再退回大类。
|
||
func matchAmapType(hints []actHint, t string) string {
|
||
if t == "" {
|
||
return ""
|
||
}
|
||
if i := strings.LastIndexByte(t, ';'); i >= 0 {
|
||
if v := matchHint(hints, t[i+1:]); v != "" {
|
||
return v
|
||
}
|
||
return matchHint(hints, t[:i])
|
||
}
|
||
return matchHint(hints, t)
|
||
}
|
||
|
||
func guessActivityFromAmapType(t string) string { return matchAmapType(amapActHints, t) }
|
||
|
||
func guessCategoryFromAmapType(t string) string { return matchAmapType(mkHints, t) }
|
||
|
||
// knownMKCategories 是真机确认用过的类别,界面上标一下,方便一眼看出哪些是「新猜的」。
|
||
var knownMKCategories = map[string]bool{
|
||
"MKPOICategoryRestaurant": true,
|
||
"MKPOICategoryStore": true,
|
||
"MKPOICategoryPublicTransport": true,
|
||
"MKPOICategoryHotel": true,
|
||
"MKPOICategoryLandmark": true,
|
||
"MKPOICategoryFoodMarket": true,
|
||
"MKPOICategoryBeach": true,
|
||
"MKPOICategoryMuseum": true,
|
||
"MKPOICategoryCampground": true,
|
||
"MKPOICategoryZoo": true,
|
||
"MKPOICategoryStadium": true,
|
||
"MKPOICategoryPark": true,
|
||
}
|
||
|
||
// mkCategoryKnown 报告这个类别真机是否用过。
|
||
func mkCategoryKnown(c string) bool { return knownMKCategories[c] }
|