Files
tamakyi 6c061a0548 地点补全:按地名与坐标猜活动与类别,只填空
后台新增「地点补全」页,把「名下到访没设活动」和「类别为空」的地点列出来,
给出建议、由站长确认后再写。判断分三层:

- 本地关键词表:零延迟、离线,命中即高置信(「星巴克咖啡」→ 奶茶和甜甜水)
- 高德周边搜索:按坐标问「这里是什么 POI」,比按名字搜准;
  拿到的类型再映射成活动与 MapKit 类别
- 人工:都猜不出的留空,站长自己填;填了列表里没有的名字会自动建成新活动类型

两条底线:**只填空**(已有活动或类别的一律不动);**活动按地点批量设**,
一次把它名下所有没设活动的到访一起填上,否则同一个地点会在统计里分裂成两种活动。

类别写的是 Apple MapKit 的枚举名(真机确认用过 12 种),不是高德分类。

规则上踩过两个坑,都写进注释了:匹配前要先掐掉括号里的地址(否则
「尚优居客栈(大理古城步行街店)」会命中「步行街」),并且按词长优先
(否则「马鞍山公园」会命中「山」);单字关键词一律不要,误命中率太高。
2026-09-19 16:31:01 +08:00

204 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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] }