地点补全:按地名与坐标猜活动与类别,只填空
后台新增「地点补全」页,把「名下到访没设活动」和「类别为空」的地点列出来, 给出建议、由站长确认后再写。判断分三层: - 本地关键词表:零延迟、离线,命中即高置信(「星巴克咖啡」→ 奶茶和甜甜水) - 高德周边搜索:按坐标问「这里是什么 POI」,比按名字搜准; 拿到的类型再映射成活动与 MapKit 类别 - 人工:都猜不出的留空,站长自己填;填了列表里没有的名字会自动建成新活动类型 两条底线:**只填空**(已有活动或类别的一律不动);**活动按地点批量设**, 一次把它名下所有没设活动的到访一起填上,否则同一个地点会在统计里分裂成两种活动。 类别写的是 Apple MapKit 的枚举名(真机确认用过 12 种),不是高德分类。 规则上踩过两个坑,都写进注释了:匹配前要先掐掉括号里的地址(否则 「尚优居客栈(大理古城步行街店)」会命中「步行街」),并且按词长优先 (否则「马鞍山公园」会命中「山」);单字关键词一律不要,误命中率太高。
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
|
||||
// 用例直接用站点里真实存在的地名,规则改坏了这里立刻会响。
|
||||
func TestGuessActivityByName(t *testing.T) {
|
||||
cases := []struct{ name, want string }{
|
||||
{"星巴克咖啡", "奶茶和甜甜水"},
|
||||
{"后谷咖啡", "奶茶和甜甜水"},
|
||||
{"花溪十里河滩绿道", "散步"},
|
||||
{"马鞍山公园", "散步"},
|
||||
{"河池市体育馆", "户外活动"},
|
||||
{"喜洲古镇洱海环境监测站", "旅游景点"},
|
||||
{"十里河滩旅游度假区-孔子圣像", "旅游景点"},
|
||||
{"尚优居客栈(大理古城步行街店)", "酒店"}, // 括号里是地址,主体才是客栈
|
||||
{"鑫圣餐厅", "餐厅"},
|
||||
{"彭大花·白族小花园餐厅(广武路店)", "餐厅"},
|
||||
{"大昌盛超市", "小商店"},
|
||||
{"中国福利彩票", "彩票购买点"},
|
||||
|
||||
// 猜不出来的必须留空,交给高德或者人工——宁可空着也别瞎填
|
||||
{"蜜雪冰城", ""},
|
||||
{"昆明站", ""}, // 单字「站」误命中太多,交给高德
|
||||
{"独山路28-17号", ""}, // 路名里有「山」但不是山
|
||||
{"银滩镇", ""},
|
||||
{"傣思蜜清真手抓饭", ""},
|
||||
{"何孃孃小锅米线", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := guessActivityByName(c.name); got != c.want {
|
||||
t.Errorf("guessActivityByName(%q) = %q,期望 %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessCategoryFromAmapType(t *testing.T) {
|
||||
cases := []struct{ typ, want string }{
|
||||
{"餐饮服务;咖啡厅", "MKPOICategoryCafe"},
|
||||
{"餐饮服务;中餐厅", "MKPOICategoryRestaurant"},
|
||||
{"餐饮服务;快餐厅", "MKPOICategoryRestaurant"},
|
||||
{"购物服务;购物中心", "MKPOICategoryStore"},
|
||||
{"购物服务;便利店", "MKPOICategoryStore"},
|
||||
// 小类优先:「风景名胜」是大类,这地方其实是个公园
|
||||
{"风景名胜;公园", "MKPOICategoryPark"},
|
||||
{"住宿服务;宾馆酒店", "MKPOICategoryHotel"},
|
||||
{"交通设施服务;地铁站", "MKPOICategoryPublicTransport"},
|
||||
{"交通设施服务;机场", "MKPOICategoryAirport"},
|
||||
{"体育休闲服务;运动场馆", "MKPOICategoryStadium"},
|
||||
{"医疗保健服务;综合医院", "MKPOICategoryHospital"},
|
||||
|
||||
// 大类命中、小类不认识时退回大类
|
||||
{"购物服务;从未见过的子类", "MKPOICategoryStore"},
|
||||
{"", ""},
|
||||
{"未知;未知", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := guessCategoryFromAmapType(c.typ); got != c.want {
|
||||
t.Errorf("guessCategoryFromAmapType(%q) = %q,期望 %q", c.typ, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessActivityFromAmapType(t *testing.T) {
|
||||
cases := []struct{ typ, want string }{
|
||||
{"餐饮服务;咖啡厅", "奶茶和甜甜水"},
|
||||
{"餐饮服务;小吃快餐", "美食街"},
|
||||
{"购物服务;便利店", "小商店"},
|
||||
{"住宿服务;宾馆酒店", "酒店"},
|
||||
{"风景名胜;公园", "散步"},
|
||||
{"体育休闲服务;运动场馆", "户外活动"},
|
||||
{"未知;未知", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := guessActivityFromAmapType(c.typ); got != c.want {
|
||||
t.Errorf("guessActivityFromAmapType(%q) = %q,期望 %q", c.typ, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 类别值必须是真机能认的枚举名,别写出别的形状。
|
||||
func TestKnownMKCategoriesShape(t *testing.T) {
|
||||
for c := range knownMKCategories {
|
||||
if len(c) <= len("MKPOICategory") || c[:13] != "MKPOICategory" {
|
||||
t.Errorf("类别 %q 不符合 MKPOICategory<名字> 的形状", c)
|
||||
}
|
||||
}
|
||||
if !mkCategoryKnown("MKPOICategoryRestaurant") {
|
||||
t.Error("真机用过的类别应被认得")
|
||||
}
|
||||
if mkCategoryKnown("MKPOICategoryCafe") {
|
||||
t.Error("这一次新猜的类别不该被算作「真机用过」")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimParens(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"尚优居客栈(大理古城步行街店)", "尚优居客栈"},
|
||||
{"三哥·思茅菜(北区店)", "三哥·思茅菜"},
|
||||
{"没有括号的名字", "没有括号的名字"},
|
||||
{"开头就是括号的", "开头就是括号的"}, // 整串都是括号开头时不截断,避免截成空串
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := trimParens(c.in); got != c.want {
|
||||
t.Errorf("trimParens(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user