ResourceSearcher.vb 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. Public Class ResourceSearcher
  2. Private Const RESULT_PAGE_SIZE = 40
  3. Private Const MSG_NO_CHINESE_RESULT As String = "无搜索结果,请尝试搜索其英文名称"
  4. '输入
  5. Public Class SearchRequest
  6. '结果要求
  7. ''' <summary>
  8. ''' 结果存储器,会从中获取需加载的页面等。
  9. ''' 加载后也应输出到此处。
  10. ''' </summary>
  11. Public Storage As SearchResult
  12. ''' <summary>
  13. ''' 应当尽量达成的结果数量。
  14. ''' </summary>
  15. Public TargetResultCount As Integer
  16. ''' <summary>
  17. ''' 根据加载位置记录,是否还可以继续获取内容。
  18. ''' </summary>
  19. Public ReadOnly Property CanContinue As Boolean
  20. Get
  21. If Tag.StartsWithF("/") OrElse Not Sources.HasFlagF(ResourcePlatforms.CurseForge) Then Storage.CurseForgeTotal = 0
  22. If Tag.EndsWithF("/") OrElse Not Sources.HasFlagF(ResourcePlatforms.Modrinth) Then Storage.ModrinthTotal = 0
  23. If Storage.CurseForgeTotal = -1 OrElse Storage.ModrinthTotal = -1 Then Return True
  24. Return Storage.CurseForgeOffset < Storage.CurseForgeTotal OrElse Storage.ModrinthOffset < Storage.ModrinthTotal
  25. End Get
  26. End Property
  27. '输入内容
  28. ''' <summary>
  29. ''' 筛选资源种类。
  30. ''' </summary>
  31. Public Type As ResourceTypes
  32. ''' <summary>
  33. ''' 筛选资源标签。空字符串代表不限制。格式例如 "406/worldgen",分别是 CurseForge 和 Modrinth 的 ID。
  34. ''' </summary>
  35. Public Tag As String = ""
  36. ''' <summary>
  37. ''' 筛选 Mod 加载器类别。
  38. ''' <see cref="ModLoaders.None"/> 代表不筛选。
  39. ''' 对于 Modrinth 请求,支持多个枚举值。
  40. ''' </summary>
  41. Public ModLoaders As ModLoaders = ModLoaders.None
  42. ''' <summary>
  43. ''' 筛选 MC 版本。
  44. ''' </summary>
  45. Public GameVersion As String = Nothing
  46. ''' <summary>
  47. ''' 搜索的初始内容。
  48. ''' </summary>
  49. Public SearchText As String = Nothing
  50. ''' <summary>
  51. ''' 允许的来源。
  52. ''' </summary>
  53. Public Sources As ResourcePlatforms = ResourcePlatforms.Any
  54. Public Sub New(Type As ResourceTypes, Storage As SearchResult, TargetResultCount As Integer)
  55. Me.Type = Type
  56. Me.Storage = Storage
  57. Me.TargetResultCount = TargetResultCount
  58. End Sub
  59. '构造请求
  60. ''' <summary>
  61. ''' 获取对应的 CurseForge API 请求链接。
  62. ''' </summary>
  63. Public Function GetCurseForgeAddress(SearchText As String, IgnoreModLoaderFilter As Boolean) As String
  64. If Tag.StartsWithF("/") Then Storage.CurseForgeTotal = 0
  65. '应用筛选参数
  66. Dim Address As String = $"https://api.curseforge.com/v1/mods/search?gameId=432&sortField=2&sortOrder=desc&pageSize={RESULT_PAGE_SIZE}"
  67. Select Case Type
  68. Case ResourceTypes.Mod
  69. Address += "&classId=6"
  70. Case ResourceTypes.ModPack
  71. Address += "&classId=4471"
  72. Case ResourceTypes.DataPack
  73. Address += "&classId=6945"
  74. Case ResourceTypes.Shader
  75. Address += "&classId=6552"
  76. Case ResourceTypes.ResourcePack
  77. Address += "&classId=12"
  78. End Select
  79. If Tag <> "" Then Address += "&categoryId=" & Tag.BeforeFirst("/")
  80. If ModLoaders <> ModLoaders.None AndAlso Not IgnoreModLoaderFilter Then Address += "&modLoaderType=" & Resource.ToCurseForgeModLoaderType(ModLoaders.Flags.First)
  81. If Not String.IsNullOrEmpty(GameVersion) Then Address += "&gameVersion=" & GameVersion
  82. If Not String.IsNullOrEmpty(SearchText) Then Address += "&searchFilter=" & StringUtils.UrlEscape(SearchText)
  83. If Storage.CurseForgeOffset > 0 Then Address += "&index=" & Storage.CurseForgeOffset
  84. Return Address
  85. End Function
  86. ''' <summary>
  87. ''' 获取对应的 Modrinth API 请求链接。
  88. ''' </summary>
  89. Public Function GetModrinthAddress(SearchText As String, IgnoreModLoaderFilter As Boolean) As String
  90. If Tag.EndsWithF("/") Then Storage.ModrinthTotal = 0
  91. '应用筛选参数
  92. Dim Address As String = $"https://api.modrinth.com/v2/search?limit={RESULT_PAGE_SIZE}&index=relevance"
  93. If Not String.IsNullOrEmpty(SearchText) Then Address += "&query=" & StringUtils.UrlEscape(SearchText)
  94. If Storage.ModrinthOffset > 0 Then Address += "&offset=" & Storage.ModrinthOffset
  95. 'facets=[["categories:'game-mechanics'"],["categories:forge","categories:fabric"],["versions:1.19.3"],["project_type:mod"]]
  96. Dim Facets As New List(Of String)
  97. Facets.Add($"[""project_type:{Type.ToString.Lower}""]")
  98. If Not String.IsNullOrEmpty(Tag) Then Facets.Add($"[""categories:'{Tag.AfterLast("/")}'""]")
  99. If ModLoaders <> ModLoaders.None AndAlso Not IgnoreModLoaderFilter Then
  100. Facets.Add($"[""categories:{ModLoaders.Flags.Select(Function(f) f.ToString.Lower).Join(""",""categories:")}""]")
  101. End If
  102. If Not String.IsNullOrEmpty(GameVersion) Then Facets.Add($"[""versions:'{GameVersion}'""]")
  103. Address += "&facets=[" & String.Join(",", Facets) & "]"
  104. Return Address
  105. End Function
  106. '相同判断
  107. Public Overrides Function Equals(obj As Object) As Boolean
  108. Dim request = TryCast(obj, SearchRequest)
  109. Return request IsNot Nothing AndAlso
  110. Type = request.Type AndAlso TargetResultCount = request.TargetResultCount AndAlso
  111. Tag = request.Tag AndAlso ModLoaders = request.ModLoaders AndAlso Sources = request.Sources AndAlso
  112. GameVersion = request.GameVersion AndAlso SearchText = request.SearchText
  113. End Function
  114. Public Shared Operator =(left As SearchRequest, right As SearchRequest) As Boolean
  115. Return EqualityComparer(Of SearchRequest).Default.Equals(left, right)
  116. End Operator
  117. Public Shared Operator <>(left As SearchRequest, right As SearchRequest) As Boolean
  118. Return Not left = right
  119. End Operator
  120. Public Overrides Function GetHashCode() As Integer
  121. Return (Type, Tag, ModLoaders, GameVersion, SearchText, Sources).GetHashCode()
  122. End Function
  123. End Class
  124. '输出
  125. Public Class SearchResult
  126. '加载位置记录
  127. Public CurseForgeOffset As Integer = 0
  128. Public CurseForgeTotal As Integer = -1
  129. Public ModrinthOffset As Integer = 0
  130. Public ModrinthTotal As Integer = -1
  131. '结果列表
  132. ''' <summary>
  133. ''' 可供展示的所有工程的列表。
  134. ''' </summary>
  135. Public Results As New List(Of ResourceProject)
  136. ''' <summary>
  137. ''' 当前的错误信息。如果没有则为 Nothing。
  138. ''' </summary>
  139. Public ErrorMessage As String = Nothing
  140. End Class
  141. ''' <summary>
  142. ''' 根据搜索请求获取一系列的工程列表。
  143. ''' </summary>
  144. Public Shared Function GetLoader(InputDelegate As Func(Of SearchRequest)) As LoaderTask(Of SearchRequest, Integer)
  145. Return New LoaderTask(Of SearchRequest, Integer)("社区资源获取", AddressOf Run, InputDelegate) With {.ReloadTimeout = 60 * 1000}
  146. End Function
  147. Private Shared Sub Run(Task As LoaderTask(Of SearchRequest, Integer))
  148. Dim Request As SearchRequest = Task.Input
  149. Dim Storage = Request.Storage '避免其他线程对 Request.Storage 重新进行了赋值
  150. #Region "前置检查"
  151. If Storage.Results.Count >= Request.TargetResultCount Then
  152. Logger.Info($"已有 {Storage.Results.Count} 个结果,多于所需的 {Request.TargetResultCount} 个结果,结束处理")
  153. Return
  154. ElseIf Not Request.CanContinue Then
  155. If Not Storage.Results.Any() Then
  156. Throw New Exception("没有符合条件的结果")
  157. Else
  158. Logger.Info($"已有 {Storage.Results.Count} 个结果,少于所需的 {Request.TargetResultCount} 个结果,但无法继续获取,结束处理")
  159. Return
  160. End If
  161. End If
  162. #End Region
  163. #Region "中文搜索"
  164. Dim RawSearchText As String = If(Request.SearchText, "").Trim
  165. RawSearchText = RawSearchText.Lower
  166. Logger.Info($"工程列表搜索原始文本:{RawSearchText}")
  167. RawSearchText = StrConv(RawSearchText, VbStrConv.SimplifiedChinese) '繁体转简体
  168. Dim IsChineseSearch As Boolean =
  169. RawSearchText.RegexCheck("[\u4e00-\u9fbb]") AndAlso Not String.IsNullOrEmpty(RawSearchText) AndAlso
  170. (Request.Type = ResourceTypes.Mod OrElse Request.Type = ResourceTypes.DataPack) '目前仅对 Mod 和数据包进行中文搜索,注意整合包的名称可能已经有中文了
  171. Dim CurseForgeAltSearchText As String = Nothing, ModrinthAltSearchText As String = Nothing, ModrinthSlugs As New List(Of String) '从中文转为英文的替代搜索内容
  172. If IsChineseSearch Then
  173. '帮助方法:从搜索项提取可能的英文单词
  174. Dim ExtractWords =
  175. Function(Result As SearchEntry(Of WikiEntry), Source As ResourcePlatforms) As IEnumerable(Of String)
  176. '从各个可能的来源提取候选
  177. Dim Candidates As New List(Of String)
  178. If Result.Item.Slugs.ContainsKey(Source) Then
  179. Candidates.Add(Result.Item.Slugs(Source).
  180. Replace("-", " ").Replace("/", " "))
  181. End If
  182. If Result.Item.ChineseName IsNot Nothing Then
  183. Candidates.Add(Result.Item.ChineseName.
  184. AfterLast(" (").TrimEnd(") ").BeforeFirst(" - ").Replace("-", " ").Replace("/", " ").Replace(":", " ").Replace("(", " ").Replace(")", ""))
  185. End If
  186. '分词、清洗、去重
  187. Candidates = Candidates.
  188. SelectMany(Function(c) c.Split(" ")).
  189. Select(Function(w) w.TrimStart("{[(").TrimEnd("}])").Lower).
  190. Where(
  191. Function(w)
  192. If w.Length <= 1 Then Return False '单字或空白
  193. If {"the", "of", "mod", "and", "forge", "fabric", "for", "quilt", "neoforge"}.Contains(w) Then Return False '常见词
  194. If Val(w) > 0 Then Return False '数字
  195. If Not w.IsAsciiOnly Then Return False '非纯英文
  196. Return True
  197. End Function).Distinct.ToList
  198. '如果一个词可以由其他词拼成,则去掉这个词(例如将 ender io enderio 的 enderio 剔除,只保留 ender io)
  199. Dim CanForm As Func(Of String, Boolean) = Nothing
  200. CanForm = Function(s) Candidates.Contains(s) OrElse Candidates.Any(Function(c) s.StartsWith(c) AndAlso CanForm(s.Substring(c.Length)))
  201. Candidates = Candidates.Where(Function(w) Not Candidates.Any(Function(c) c.Length < w.Length AndAlso w.StartsWith(c) AndAlso CanForm(w.Substring(c.Length)))).ToList()
  202. Return Candidates
  203. End Function
  204. Dim GetSearchEntries =
  205. Iterator Function(Source As ResourcePlatforms)
  206. For Each Entry In WikiEntry.All.Value
  207. If Not Entry.Slugs.ContainsKey(Source) Then Continue For
  208. If Entry.ChineseName IsNot Nothing Then
  209. Yield New SearchEntry(Of WikiEntry) With {.Item = Entry, .SearchSource = New List(Of SearchSource) From {
  210. New SearchSource(StrConv(Entry.ChineseName.BeforeFirst(" ("), VbStrConv.SimplifiedChinese).Split("/"c, True), 1), '部分 Mod 有别名
  211. New SearchSource(StrConv(Entry.ChineseName.AfterFirst(" ("), VbStrConv.SimplifiedChinese) & Entry.Slugs(Source), 0.5)
  212. }}
  213. Else
  214. Yield New SearchEntry(Of WikiEntry) With {.Item = Entry, .SearchSource = New List(Of SearchSource) From {
  215. New SearchSource(Entry.Slugs(Source), 0.5)
  216. }}
  217. End If
  218. Next
  219. End Function
  220. 'CurseForge
  221. If Request.Sources.HasFlagF(ResourcePlatforms.CurseForge) Then
  222. '数据库搜索
  223. Static CurseForgeSearchEntries As List(Of SearchEntry(Of WikiEntry)) = GetSearchEntries(ResourcePlatforms.CurseForge).ToList()
  224. Dim CurseForgeSearchResults = Search(CurseForgeSearchEntries, RawSearchText, 100, 0.25)
  225. If CurseForgeSearchResults.Any Then
  226. '选取目标(CurseForge 要求每个词都必须匹配上,所以只能选择一个 Mod 进行搜索)
  227. Dim CurseForgeTarget =
  228. If(CurseForgeSearchResults.First.AbsoluteRight,
  229. CurseForgeSearchResults.Where(Function(s) s.AbsoluteRight).ToList, '优先使用所有完全匹配的
  230. CurseForgeSearchResults.MaxByAll(Function(s) s.Similarity)). '其次使用所有相似度最高的
  231. MaxBy(Function(s) s.Item.Popularity) '然后从中选择最受欢迎的那一个
  232. '后处理
  233. CurseForgeAltSearchText = ExtractWords(CurseForgeTarget, ResourcePlatforms.CurseForge).Join(" ")
  234. Logger.Warn($"中文搜索关键词(CurseForge):{CurseForgeAltSearchText}")
  235. End If
  236. End If
  237. 'Modrinth
  238. If Request.Sources.HasFlagF(ResourcePlatforms.Modrinth) Then
  239. '数据库搜索
  240. Static ModrinthSearchEntries As List(Of SearchEntry(Of WikiEntry)) = GetSearchEntries(ResourcePlatforms.Modrinth).ToList()
  241. Dim ModrinthSearchResults = Search(ModrinthSearchEntries, RawSearchText, 100, 0.25)
  242. If ModrinthSearchResults.Any Then
  243. '分词
  244. Dim WordWeights As New Dictionary(Of String, Double) '各个单词及其出现的权重
  245. For Each Result In ModrinthSearchResults
  246. For Each Word In ExtractWords(Result, ResourcePlatforms.Modrinth)
  247. If Not WordWeights.ContainsKey(Word) Then WordWeights.Add(Word, 0)
  248. Dim Similarity = If(Result.SearchSource.Any(Function(s) s.Aliases.Contains(RawSearchText)), 1000, Result.Similarity) '完全匹配为 1000
  249. WordWeights(Word) += Similarity * Result.Item.Popularity '权重 += 相似度 * 受欢迎程度
  250. Next
  251. Next
  252. ModrinthAltSearchText = WordWeights.MaxBy(Function(w) w.Value).Key
  253. Logger.Warn($"中文搜索关键词(Modrinth):{ModrinthAltSearchText}")
  254. '直接请求工程
  255. ModrinthSlugs = ModrinthSearchResults.Take(100).Select(Function(r) r.Item.Slugs(ResourcePlatforms.Modrinth)).ToList
  256. End If
  257. End If
  258. '结束
  259. If String.IsNullOrEmpty(CurseForgeAltSearchText) AndAlso String.IsNullOrEmpty(ModrinthAltSearchText) AndAlso Not ModrinthSlugs.Any Then
  260. Throw New Exception(MSG_NO_CHINESE_RESULT)
  261. End If
  262. End If
  263. Task.Progress = 0.05
  264. #End Region
  265. Dim RealResults As New List(Of ResourceProject)
  266. NextPage:
  267. Dim RawResults As New List(Of ResourceProject)
  268. #Region "从 CurseForge 和 Modrinth 获取结果列表,存储于 RawResults"
  269. Dim WorkThreads As New List(Of Thread)
  270. Dim Errors As New ConcurrentBag(Of (Ex As Exception, Source As ResourcePlatforms))
  271. '在 1.14-,部分老 Mod 没有设置支持的加载器,因此添加 Forge 筛选就会出现遗漏
  272. '所以,在发起请求时不筛选加载器,然后在返回的结果中自行筛除不是 Forge 的 Mod
  273. Dim IgnoreModLoaderFilter = Request.ModLoaders = ModLoaders.Forge AndAlso McVersion.VersionToDrop(Request.GameVersion) < 140
  274. Dim SearchResults As New ConcurrentBag(Of ResourceProject)
  275. Try
  276. 'CurseForge 搜索
  277. If Request.Sources.HasFlagF(ResourcePlatforms.CurseForge) AndAlso
  278. Not (Storage.CurseForgeTotal > -1 AndAlso Storage.CurseForgeTotal <= Storage.CurseForgeOffset) AndAlso '剩余的未显示的搜索结果不足
  279. (Not IsChineseSearch OrElse (IsChineseSearch AndAlso Not String.IsNullOrEmpty(CurseForgeAltSearchText))) Then '如果是中文搜索,就只在有对应搜索关键词的时候才继续
  280. WorkThreads.Add(RunInNewThread(
  281. Sub()
  282. Try
  283. '获取工程列表
  284. Dim CurseForgeUrl As String = Request.GetCurseForgeAddress(If(CurseForgeAltSearchText, RawSearchText), IgnoreModLoaderFilter)
  285. Logger.Info($"开始 CurseForge 搜索:{CurseForgeUrl}")
  286. Dim RequestResult As JObject = DlModRequest(CurseForgeUrl)
  287. Dim ProjectList As New List(Of ResourceProject)
  288. For Each JsonEntry As JObject In RequestResult("data")
  289. Dim Project As New ResourceProject(JsonEntry)
  290. If Request.Type = ResourceTypes.ResourcePack AndAlso Project.Tags.Contains("数据包") Then Continue For 'CurseForge 将一些数据包分类成了资源包
  291. If Request.Type <> ResourceTypes.Any AndAlso Not Project.Types.HasFlagF(Request.Type) Then Continue For '过滤分区不匹配的搜索结果(#8265)
  292. ProjectList.Add(Project)
  293. Next
  294. '更新结果
  295. ProjectList.ForEach(Sub(p) SearchResults.Add(p))
  296. Storage.CurseForgeOffset += RequestResult("data").Count
  297. Storage.CurseForgeTotal = RequestResult("pagination")("totalCount").ToObject(Of Integer)
  298. Logger.Info($"从 CurseForge 搜索到了 {ProjectList.Count} 个工程(总计已获取 {Storage.CurseForgeOffset} 个,共 {Storage.CurseForgeTotal} 个)")
  299. Catch ex As Exception
  300. Logger.Warn(ex, "CurseForge 搜索失败")
  301. Storage.CurseForgeTotal = -1
  302. Errors.Add((ex, ResourcePlatforms.CurseForge))
  303. End Try
  304. If Task.Progress < 0.75 Then Task.Progress += 0.25 '可能重复加载多页,所以不能直接给够
  305. End Sub, "CurseForge Search"))
  306. End If
  307. 'Modrinth 搜索
  308. If Request.Sources.HasFlagF(ResourcePlatforms.Modrinth) AndAlso
  309. Not (Storage.ModrinthTotal > -1 AndAlso Storage.ModrinthTotal <= Storage.ModrinthOffset) AndAlso '剩余的未显示的搜索结果不足
  310. (Not IsChineseSearch OrElse (IsChineseSearch AndAlso Not String.IsNullOrEmpty(ModrinthAltSearchText))) Then '如果是中文搜索,就只在有对应搜索关键词的时候才继续
  311. WorkThreads.Add(RunInNewThread(
  312. Sub()
  313. Try
  314. Dim ModrinthUrl As String = Request.GetModrinthAddress(If(ModrinthAltSearchText, RawSearchText), IgnoreModLoaderFilter)
  315. Logger.Info($"开始 Modrinth 搜索:{ModrinthUrl}")
  316. Dim RequestResult As JObject = DlModRequest(ModrinthUrl)
  317. Dim ProjectList As New List(Of ResourceProject)
  318. For Each JsonEntry As JObject In RequestResult("hits")
  319. ProjectList.Add(New ResourceProject(JsonEntry))
  320. Next
  321. '更新结果
  322. ProjectList.ForEach(Sub(p) SearchResults.Add(p))
  323. Storage.ModrinthOffset += RequestResult("hits").Count
  324. Storage.ModrinthTotal = RequestResult("total_hits").ToObject(Of Integer)
  325. Logger.Info($"从 Modrinth 搜索到了 {ProjectList.Count} 个工程(总计已获取 {Storage.ModrinthOffset} 个,共 {Storage.ModrinthTotal} 个)")
  326. Catch ex As Exception
  327. Logger.Warn(ex, "Modrinth 搜索失败")
  328. Storage.ModrinthTotal = -1
  329. Errors.Add((ex, ResourcePlatforms.Modrinth))
  330. End Try
  331. If Task.Progress < 0.75 Then Task.Progress += 0.25 '可能重复加载多页,所以不能直接给够
  332. End Sub, "Modrinth Search"))
  333. End If
  334. 'Modrinth 直接获取工程
  335. If Request.Sources.HasFlagF(ResourcePlatforms.Modrinth) AndAlso
  336. Not (Storage.ModrinthTotal > -1 AndAlso Storage.ModrinthTotal <= Storage.ModrinthOffset) AndAlso '剩余的未显示的搜索结果不足
  337. ModrinthSlugs.Any Then '有直接获取的 Slug
  338. WorkThreads.Add(RunInNewThread(
  339. Sub()
  340. Try
  341. Dim ModrinthUrl As String = $"https://api.modrinth.com/v2/projects?ids=[""{ModrinthSlugs.Join(""",""")}""]"
  342. Logger.Info($"开始 Modrinth 直接获取:{ModrinthUrl}")
  343. Dim ProjectList As New List(Of ResourceProject)
  344. For Each JsonEntry As JObject In DlModRequest(ModrinthUrl)
  345. Dim Project As New ResourceProject(JsonEntry)
  346. '应用筛选
  347. If Request.Type <> ResourceTypes.Any AndAlso Not Project.Types.HasFlagF(Request.Type) Then Continue For
  348. If Not String.IsNullOrEmpty(Request.Tag) AndAlso
  349. Not JsonEntry("categories").Any(Function(c) c.ToString = Request.Tag.AfterLast("/")) Then Continue For 'Project.Tags 已经转换成中文了,只能从 json 判
  350. If Request.ModLoaders <> ModLoaders.None AndAlso Not IgnoreModLoaderFilter AndAlso
  351. Not Project.ModLoaders.Flags.Intersect(Request.ModLoaders.Flags).Any() Then Continue For
  352. If Not String.IsNullOrEmpty(Request.GameVersion) AndAlso
  353. Not Project.UnsafeGameVersions.Any(Function(d) d = Request.GameVersion) Then Continue For
  354. ProjectList.Add(Project)
  355. Next
  356. If ProjectList.Any AndAlso Request.ModLoaders <> ModLoaders.None AndAlso Not IgnoreModLoaderFilter AndAlso
  357. Not String.IsNullOrEmpty(Request.GameVersion) Then
  358. Dim ProjectIds = ProjectList.Select(Function(p) p.Id).Join(""",""project_id:")
  359. Dim Loaders = Request.ModLoaders.Flags.Select(Function(f) f.ToString.Lower).Join(""",""categories:")
  360. Dim FilterUrl = $"https://api.modrinth.com/v2/search?limit={ProjectList.Count}&facets=" &
  361. $"[[""project_id:{ProjectIds}""],[""categories:{Loaders}""],[""versions:{Request.GameVersion}""]]"
  362. Dim MatchedIds = DlModRequest(FilterUrl)("hits").Select(Function(h) h("project_id").ToString).ToList
  363. ProjectList.KeepIf(Function(p) MatchedIds.Contains(p.Id))
  364. End If
  365. '更新结果
  366. ProjectList.ForEach(Sub(p) SearchResults.Add(p))
  367. Logger.Info($"从 Modrinth 直接获取到了 {ProjectList.Count} 个工程")
  368. ModrinthSlugs.Clear() '防止重试/加载下一页时重复获取
  369. Catch ex As Exception
  370. Logger.Warn(ex, "Modrinth 直接获取失败")
  371. Errors.Add((ex, ResourcePlatforms.Modrinth))
  372. End Try
  373. If Task.Progress < 0.75 Then Task.Progress += 0.25 '可能重复加载多页,所以不能直接给够
  374. End Sub, "Modrinth Get"))
  375. End If
  376. '等待线程结束
  377. For Each Thread In WorkThreads
  378. Thread.Join()
  379. If Task.IsCanceled Then Return '会自动触发 Finally 以清理线程
  380. Next
  381. RawResults = New List(Of ResourceProject)(SearchResults)
  382. '仅保留兼容 Forge 的 Mod,或老版本中没有标注任何 Mod Loader 的 Mod
  383. If IgnoreModLoaderFilter Then
  384. RawResults.KeepIf(Function(p) p.ModLoaders = ModLoaders.None OrElse p.ModLoaders.HasFlagF(ModLoaders.Forge))
  385. End If
  386. '确保存在结果
  387. Storage.ErrorMessage = Nothing
  388. If Not RawResults.Any() Then
  389. If Errors.Any() Then
  390. Throw Errors.First.Ex
  391. Else
  392. If IsChineseSearch AndAlso Not (Request.Type = ResourceTypes.Mod OrElse Request.Type = ResourceTypes.DataPack) Then
  393. Throw New Exception(MSG_NO_CHINESE_RESULT)
  394. ElseIf Request.Sources = ResourcePlatforms.CurseForge AndAlso Request.Tag.StartsWithF("/") Then
  395. Throw New Exception("CurseForge 不兼容所选的类型")
  396. ElseIf Request.Sources = ResourcePlatforms.Modrinth AndAlso Request.Tag.EndsWithF("/") Then
  397. Throw New Exception("Modrinth 不兼容所选的类型")
  398. Else
  399. Throw New Exception("没有搜索结果")
  400. End If
  401. End If
  402. ElseIf Errors.Any() Then
  403. '有结果但是有错误
  404. If Errors.Any(Function(e) e.Source = ResourcePlatforms.CurseForge) Then
  405. Storage.ErrorMessage = $"无法连接到 CurseForge,所以目前仅显示了来自 Modrinth 的内容,搜索结果可能不全。{vbCrLf}请稍后再试,或使用 VPN 改善网络环境。"
  406. Else
  407. Storage.ErrorMessage = $"无法连接到 Modrinth,所以目前仅显示了来自 CurseForge 的内容,搜索结果可能不全。{vbCrLf}请稍后再试,或使用 VPN 改善网络环境。"
  408. End If
  409. End If
  410. Finally
  411. For Each Thread In WorkThreads
  412. If Thread.IsAlive Then Thread.Interrupt()
  413. Next
  414. End Try
  415. #End Region
  416. #Region "提取非重复项,存储于 RealResults"
  417. '将 CurseForge 排在 Modrinth 的前面,避免加载结束顺序不同导致排名不同
  418. RawResults = RawResults.Where(Function(x) x.Platform = ResourcePlatforms.CurseForge).Concat(RawResults.Where(Function(x) x.Platform <> ResourcePlatforms.CurseForge)).ToList
  419. '去重
  420. RawResults = RawResults.Distinct(Function(a, b) a.IsLike(b)).ToList
  421. '已有内容去重
  422. RawResults = RawResults.Where(Function(r) Not RealResults.Any(Function(b) r.IsLike(b)) AndAlso
  423. Not Storage.Results.Any(Function(b) r.IsLike(b))).ToList
  424. '加入列表
  425. RealResults.AddRange(RawResults)
  426. Logger.Info($"去重、筛选后累计新增结果 {RealResults.Count} 个(目前已有结果 {Storage.Results.Count} 个)")
  427. #End Region
  428. #Region "检查结果数量,如果不足且可继续,会继续加载下一页"
  429. If RealResults.Count + Storage.Results.Count < Request.TargetResultCount Then
  430. Logger.Info($"总结果数需求最少 {Request.TargetResultCount} 个,仅获得了 {RealResults.Count + Storage.Results.Count} 个")
  431. If Request.CanContinue AndAlso Not Errors.Any Then '如果有某个源失败则不再重试,这时候重试可能导致无限循环
  432. Logger.Info("将继续尝试加载下一页")
  433. GoTo NextPage
  434. Else
  435. Logger.Info("无法继续加载,将强制结束")
  436. End If
  437. End If
  438. #End Region
  439. #Region "将结果排序并添加"
  440. Dim Scores As New Dictionary(Of ResourceProject, Double) '排序分
  441. Dim GetDownloadCountMult =
  442. Function(Project As ResourceProject) As Double
  443. Select Case Request.Type
  444. Case ResourceTypes.Mod, ResourceTypes.ModPack
  445. Return If(Project.Platform = ResourcePlatforms.CurseForge, 1, 5)
  446. Case ResourceTypes.DataPack
  447. Return If(Project.Platform = ResourcePlatforms.CurseForge, 10, 1)
  448. Case ResourceTypes.ResourcePack, ResourceTypes.Shader
  449. Return If(Project.Platform = ResourcePlatforms.CurseForge, 1, 4)
  450. Case Else
  451. Return 1
  452. End Select
  453. End Function
  454. If String.IsNullOrEmpty(RawSearchText) Then
  455. '如果没有搜索文本,按下载量将结果排序
  456. For Each Result As ResourceProject In RealResults
  457. Scores(Result) = Result.DownloadCount * GetDownloadCountMult(Result)
  458. Next
  459. Else
  460. '如果有搜索文本,按关联度将结果排序
  461. '排序分 = 搜索相对相似度 (1) + 下载量权重 (对数,10 亿时为 1) + 有中文名 (0.2)
  462. Dim SearchEntries As New List(Of SearchEntry(Of ResourceProject))
  463. For Each Result As ResourceProject In RealResults
  464. Scores(Result) = If(Result.WikiId > 0, 0.2, 0) +
  465. Math.Log10(Math.Max(Result.DownloadCount, 1) * GetDownloadCountMult(Result)) / 9
  466. SearchEntries.Add(New SearchEntry(Of ResourceProject) With {.Item = Result, .SearchSource = New List(Of SearchSource) From {
  467. New SearchSource(If(IsChineseSearch, Result.TranslatedName, Result.RawName).Split("/"c, True), 1),
  468. New SearchSource(Result.Description, 0.05)}})
  469. Next
  470. Dim SearchResult = Search(SearchEntries, RawSearchText, 10000, -1)
  471. For Each OneResult In SearchResult
  472. Scores(OneResult.Item) +=
  473. If(OneResult.AbsoluteRight, 10, OneResult.Similarity) /
  474. If(SearchResult.First.AbsoluteRight, 10, SearchResult.First.Similarity) '最高 1 分的相似度分
  475. Next
  476. End If
  477. '根据排序分得出结果并添加
  478. If Task.IsCanceled Then Throw New OperationCanceledException '#8246
  479. Storage.Results.AddRange(
  480. Scores.OrderByDescending(Function(s) s.Value).Select(Function(r) r.Key))
  481. #End Region
  482. End Sub
  483. End Class