From 1119d97a5a4a9d1ea6b0349c2d45c207b78e27a5 Mon Sep 17 00:00:00 2001 From: shiaho <222622008+shiaho777@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:34:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E9=BD=90=E5=AE=98=E6=96=B9=20?= =?UTF-8?q?v4.1.0=20=E5=8D=8F=E8=AE=AE=EF=BC=88=E5=A4=9A=E7=BA=A7=E4=B8=8B?= =?UTF-8?q?=E6=8B=89/=E9=9A=90=E8=97=8F=E5=8D=A0=E4=BD=8D=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移植 wjx/provider/multilevel.py:解析 hfLevelData、拉取 setmenuselnew 选项、按文本路径提交 - 解析阶段识别 type=1 + verify=多级下拉,转为 type=7 并保存 provider_page_raw - 过滤隐藏且无输入框的 type=9 gapfill 占位节点(对标 f7a5094) - 提交准备阶段响应停止信号,避免停止后仍 POST(对标 131355b) - 兼容桌面端 multilevel_dropdown 配置导入,运行时按下拉处理 - 版本升至 0.2.0,对齐官方 4.1.0 --- AGENTS.md | 3 +- README.md | 11 +- SurveyController.xcodeproj/project.pbxproj | 16 +- SurveyController/App/AppVersion.swift | 4 +- SurveyController/Core/Engine/RunEngine.swift | 4 + .../Core/Model/ExecutionConfig.swift | 6 +- .../Provider/Wjx/WjxAnswerBuilder.swift | 25 +- .../Provider/Wjx/WjxHtmlParser.swift | 25 +- .../Provider/Wjx/WjxMultilevel.swift | 263 ++++++++++++++++++ .../Provider/Wjx/WjxProvider.swift | 10 +- .../WjxMultilevelTests.swift | 181 ++++++++++++ 11 files changed, 517 insertions(+), 31 deletions(-) create mode 100644 SurveyController/Provider/Wjx/WjxMultilevel.swift create mode 100644 SurveyControllerTests/WjxMultilevelTests.swift diff --git a/AGENTS.md b/AGENTS.md index bd48455..647ed94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - **项目性质**:SurveyController 官方 Windows 桌面端的 **macOS 端独立移植版** - **GitHub 仓库**:https://github.com/SurveyController/SurveyController-Mac - **License**:GPL-3.0(与官方一致) -- **当前版本**:v0.1.0(开发中,逐步对齐官方 v4.0.6) +- **当前版本**:v0.2.0(开发中,逐步对齐官方 v4.1.0) ## 官方源仓库 @@ -118,6 +118,7 @@ xcodebuild -project SurveyController.xcodeproj -scheme SurveyController \ - `core/config/`(schema + codec v6)→ `Core/Model/RuntimeConfig.swift` + `Data/ConfigCodec.swift`(与桌面版配置文件互通) - `core/questions/`(概率/文本/一致性查询)+ `providers/http_logic.py` → `Core/Questions/` - `wjx/provider/`(html_parser* + http_runtime 纯函数 + regexes + multiple_limits)→ `Provider/Wjx/` +- `wjx/provider/multilevel.py` → `Provider/Wjx/WjxMultilevel.swift`(多级下拉解析、选项拉取、提交映射) - `wjx/provider/answering_builders.py` → `Provider/Wjx/WjxAnswerBuilder.swift`(简化点见下) - `core/task/task_context.py` + engine → `Core/Model/ExecutionConfig.swift` + `Core/Engine/RunEngine.swift` - `network/proxy/session/` + `session_policy.py` → `Core/Backend/BackendClient.swift` + `Core/Proxy/ProxyPool.swift` diff --git a/README.md b/README.md index 4636a8e..aff36a3 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,11 @@ 1. **原生 Mac 体验** - 纯 Swift + SwiftUI 构建,六步向导式流程,macOS 14+ 原生界面 2. **多平台支持** - v0.1 起支持问卷星全链路,腾讯问卷、Credamo见数后续版本跟进 -3. **定制答案比例** - 自定义各选项权重与多选题命中概率分布,实时显示归一化比例 -4. **指定ip地区** - 省市级地区选择,支持随机IP或指定特定地区IP提交 -5. **配置导入导出** - 与 Windows 桌面版配置文件互通,跨设备同步 -6. **运行体验** - 并发槽位、暂停/继续、完成系统通知、日志按次存档、防休眠 +3. **问卷星新题型** - 对齐官方 v4.1.0,支持多级下拉题与隐藏空占位题过滤 +4. **定制答案比例** - 自定义各选项权重与多选题命中概率分布,实时显示归一化比例 +5. **指定ip地区** - 省市级地区选择,支持随机IP或指定特定地区IP提交 +6. **配置导入导出** - 与 Windows 桌面版配置文件互通,跨设备同步 +7. **运行体验** - 并发槽位、暂停/继续、完成系统通知、日志按次存档、防休眠 ## 演示视频 @@ -57,7 +58,7 @@ open SurveyController.xcodeproj # Xcode 中 Cmd+R 运行 # 或命令行构建 xcodebuild -project SurveyController.xcodeproj -scheme SurveyController build -# 运行单元测试(86 个用例) +# 运行单元测试(102 个用例) xcodebuild -project SurveyController.xcodeproj -scheme SurveyController test ``` diff --git a/SurveyController.xcodeproj/project.pbxproj b/SurveyController.xcodeproj/project.pbxproj index cfa5fc8..d7f807b 100644 --- a/SurveyController.xcodeproj/project.pbxproj +++ b/SurveyController.xcodeproj/project.pbxproj @@ -317,7 +317,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 10102; + CURRENT_PROJECT_VERSION = 10200; DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; @@ -331,7 +331,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1.2; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = "com.surveycontroller.mac"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -346,7 +346,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 10102; + CURRENT_PROJECT_VERSION = 10200; DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = NO; @@ -360,7 +360,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.1.2; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = "com.surveycontroller.mac"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -373,9 +373,9 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10102; + CURRENT_PROJECT_VERSION = 10200; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.1.2; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = "com.surveycontroller.mac.tests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -389,9 +389,9 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10102; + CURRENT_PROJECT_VERSION = 10200; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.1.2; + MARKETING_VERSION = 0.2.0; PRODUCT_BUNDLE_IDENTIFIER = "com.surveycontroller.mac.tests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; diff --git a/SurveyController/App/AppVersion.swift b/SurveyController/App/AppVersion.swift index 9b9970c..4c6f743 100644 --- a/SurveyController/App/AppVersion.swift +++ b/SurveyController/App/AppVersion.swift @@ -6,9 +6,9 @@ import Foundation /// 应用版本与 GitHub 仓库信息。 public enum AppVersion { /// 当前版本(与 MARKETING_VERSION 同步)。 - public static let version = "0.1.2" + public static let version = "0.2.0" /// 当前对齐的官方桌面端版本。 - public static let alignedOfficialVersion = "4.0.6" + public static let alignedOfficialVersion = "4.1.0" public static let githubOwner = "SurveyController" public static let githubRepo = "SurveyController-Mac" diff --git a/SurveyController/Core/Engine/RunEngine.swift b/SurveyController/Core/Engine/RunEngine.swift index 03e6e33..fd6bed7 100644 --- a/SurveyController/Core/Engine/RunEngine.swift +++ b/SurveyController/Core/Engine/RunEngine.swift @@ -271,6 +271,10 @@ public final class RunEngine: @unchecked Sendable { rng: localRng ) context.submitProxyLeaseFactory = leaseFactory + context.isStopRequested = { [weak self] in + guard let self else { return true } + return self.queue.sync { self.stopRequested } + } do { let ok = try await submitHandler(config, context) diff --git a/SurveyController/Core/Model/ExecutionConfig.swift b/SurveyController/Core/Model/ExecutionConfig.swift index cb8aa8a..66b4be2 100644 --- a/SurveyController/Core/Model/ExecutionConfig.swift +++ b/SurveyController/Core/Model/ExecutionConfig.swift @@ -136,8 +136,10 @@ public enum ExecutionConfigBuilder { } static func normalizedEntryType(_ entry: QuestionEntry) -> String { - // text / multi_text 由配置创建时写入 question_type(对标桌面端向导产物),此处不做推断 - entry.questionType + // text / multi_text 由配置创建时写入 question_type(对标桌面端向导产物),此处不做推断。 + // 多级下拉在桌面端是独立编辑器类型,运行时按下拉处理(对标 runtime_preparation)。 + if entry.questionType == "multilevel_dropdown" { return "dropdown" } + return entry.questionType } static func asProbList(_ value: Any?) -> Any { diff --git a/SurveyController/Provider/Wjx/WjxAnswerBuilder.swift b/SurveyController/Provider/Wjx/WjxAnswerBuilder.swift index acdb22b..b127896 100644 --- a/SurveyController/Provider/Wjx/WjxAnswerBuilder.swift +++ b/SurveyController/Provider/Wjx/WjxAnswerBuilder.swift @@ -19,30 +19,33 @@ public enum WjxAnswerBuilder { let config = state.config let (entryType, configIndex) = configEntry + let action: AnswerAction? switch entryType { case "single": - return try buildSingleAction(question, configIndex, state: &state, rng: rng) + action = try buildSingleAction(question, configIndex, state: &state, rng: rng) case "multiple": - return try buildMultipleAction(question, configIndex, state: &state, rng: rng) + action = try buildMultipleAction(question, configIndex, state: &state, rng: rng) case "dropdown": - return try buildDropdownAction(question, configIndex, state: &state, rng: rng) + action = try buildDropdownAction(question, configIndex, state: &state, rng: rng) case "text", "multi_text": - return buildTextAction(question, configIndex, config: config, rng: rng) + action = buildTextAction(question, configIndex, config: config, rng: rng) case "location": - return nil + action = nil case "matrix": - return try buildMatrixAction(question, configIndex, config: config, rng: rng) + action = try buildMatrixAction(question, configIndex, config: config, rng: rng) case "scale": - return try buildScoreLikeAction(question, configIndex, config: config, answerType: "scale", rng: rng) + action = try buildScoreLikeAction(question, configIndex, config: config, answerType: "scale", rng: rng) case "score": - return try buildScoreLikeAction(question, configIndex, config: config, answerType: "score", rng: rng) + action = try buildScoreLikeAction(question, configIndex, config: config, answerType: "score", rng: rng) case "slider": - return buildSliderAction(question, configIndex, config: config) + action = buildSliderAction(question, configIndex, config: config) case "order": - return buildOrderAction(question, rng: rng) + action = buildOrderAction(question, rng: rng) default: - return nil + action = nil } + // 对标 multilevel_answer_action:多级下拉把选项索引换成文本路径。 + return try action.map { try WjxMultilevel.applyAnswer($0, question: question) } } static func runtimeOptionTexts(_ question: SurveyQuestionMeta) -> [String] { diff --git a/SurveyController/Provider/Wjx/WjxHtmlParser.swift b/SurveyController/Provider/Wjx/WjxHtmlParser.swift index 5a7dcce..6787955 100644 --- a/SurveyController/Provider/Wjx/WjxHtmlParser.swift +++ b/SurveyController/Provider/Wjx/WjxHtmlParser.swift @@ -73,6 +73,16 @@ public enum WjxHtmlParser { if typeCode != "11" && looksLikeReorder(questionDiv) { typeCode = "11" } + // 对标 _question_div_is_empty_gapfill_placeholder:隐藏且无输入框的填空占位节点直接跳过。 + if isHiddenEmptyGapfillPlaceholder(questionDiv, typeCode: typeCode) { + continue + } + // 对标 is_multilevel:type=1 且带「多级下拉」标记的输入框,按 type=7 处理。 + let multilevelInput = try questionDiv.select("input[verify=\"多级下拉\"]").first() + let isMultilevel = typeCode == "1" && multilevelInput != nil + if isMultilevel { + typeCode = "7" + } let isDescription = looksLikeDescription(questionDiv, typeCode: typeCode) let isRequired = questionIsRequired(questionDiv) var isRating = false @@ -137,7 +147,7 @@ public enum WjxHtmlParser { (sliderMin, sliderMax, sliderStep) = extractSliderRange(questionDiv, questionNumber: questionNumber) } - let textInputCount = countTextInputs(questionDiv) + let textInputCount = isMultilevel ? 0 : countTextInputs(questionDiv) let textInputLabels = textInputCount > 1 ? extractTextInputLabels(questionDiv) : [] let hasGapfill = normalizeAttr(questionDiv.attrText("gapfill")) == "1" let isTextLikeQuestion = shouldTreatAsTextLike( @@ -160,6 +170,10 @@ public enum WjxHtmlParser { "display_num": displayNum ?? NSNull(), "title": titleText, "type_code": typeCode, + "provider_type": isMultilevel ? WjxMultilevel.providerType : typeCode, + "provider_page_raw": isMultilevel + ? ["cid": (try? multilevelInput?.attr("cid")) ?? ""] + : NSNull(), "options": optionCount, "rows": metadata.matrixRows, "row_texts": metadata.rowTexts, @@ -429,6 +443,15 @@ public enum WjxHtmlParser { return false } + /// 对标 _question_div_is_empty_gapfill_placeholder。 + static func isHiddenEmptyGapfillPlaceholder(_ questionDiv: Element, typeCode: String) -> Bool { + guard typeCode == "9" else { return false } + guard normalizeAttr(questionDiv.attrText("gapfill")) == "1" else { return false } + guard ["", "0"].contains(normalizeAttr(questionDiv.attrText("relation"))) else { return false } + guard selfOrAncestorsHidden(questionDiv) else { return false } + return countTextInputs(questionDiv) == 0 + } + static func hasQuestionAncestor(_ questionDiv: Element, until fieldset: Element) -> Bool { var current = questionDiv.parent() while let element = current, element != fieldset { diff --git a/SurveyController/Provider/Wjx/WjxMultilevel.swift b/SurveyController/Provider/Wjx/WjxMultilevel.swift new file mode 100644 index 0000000..3d43eb7 --- /dev/null +++ b/SurveyController/Provider/Wjx/WjxMultilevel.swift @@ -0,0 +1,263 @@ +// 对标 surveycore/wjx/provider/multilevel.py +// 问卷星多级下拉题:解析 hfLevelData 层级数据、拉取选项、把作答索引转换为提交文本。 + +import Foundation +import SwiftSoup + +public struct WjxMultilevelError: LocalizedError { + public let message: String + public var errorDescription: String? { message } + + init(_ message: String) { self.message = message } +} + +public enum WjxMultilevel { + + public static let providerType = "multilevel_dropdown" + + /// 问卷星多级下拉路径分隔符(U+2010,非普通连字符)。 + private static let splitChar = "‐" + /// 选项文本末尾的树形展示符号,提交值需要保留、展示文本需要去掉。 + private static let displaySuffixes = Set( + "┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇" + ) + private static let markerRegex = try! NSRegularExpression(pattern: #"╰[0-9]+╯"#) + private static let externalDataRegex = try! NSRegularExpression( + pattern: #"levelDataExternalUrl\s*=\s*['\"][^'\"]+"# + ) + private static let activityIdRegex = try! NSRegularExpression( + pattern: #"\bvar\s+activityId\s*=\s*(?:DecodeId\((\d+)\)|(\d+))"# + ) + private static let decodeIdRegex = try! NSRegularExpression( + pattern: #"function\s+DecodeId\([^)]*\)\s*\{\s*return\s+\w+\s*\^\s*(\d+)"# + ) + + public struct Options { + public let optionTexts: [String] + public let submitValues: [String] + public let levelLabels: [String] + } + + // MARK: - 层级数据解析(对标 parse_multilevel_options) + + public static func parseOptions(fromHtml html: String, encoded: Bool = false) throws -> Options { + let doc = try SwiftSoup.parse(html) + guard let dataInput = try doc.select("input#hfLevelData").first() else { + throw WjxMultilevelError("问卷星多级下拉未返回 hfLevelData") + } + for script in try doc.select("script").array() { + let text = (try? script.data()) ?? "" + if firstMatch(externalDataRegex, in: text) != nil { + throw WjxMultilevelError("问卷星多级下拉暂不支持外部数据源") + } + } + + var data = try dataInput.attr("value") + if encoded { + data = data.removingPercentEncoding ?? data + } + let separatorRange = data.range(of: "〒") + let levels = separatorRange.map { String(data[..<$0.lowerBound]) } ?? data + let labels = separatorRange.map { String(data[$0.upperBound...]) } ?? "" + let columns = levels.components(separatedBy: "|") + guard !levels.isEmpty, (1...6).contains(columns.count) else { + throw WjxMultilevelError("问卷星多级下拉层级数据为空或超过六级") + } + + var paths: [[String]] = [] + var terminalPaths: [[String]] = [] + var previousOptions: Set = [] + + for (levelIndex, column) in columns.enumerated() { + let lines = column + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + if levelIndex == 0 { + guard !lines.isEmpty, !lines.contains(where: { $0.hasPrefix("---") }) else { + throw WjxMultilevelError("问卷星多级下拉首级选项无效") + } + paths = lines.map { [$0] } + previousOptions = Set(lines) + continue + } + + var groups: [String: [String]] = [:] + var parent = "" + for line in lines { + if line.hasPrefix("---") { + parent = String(line.dropFirst(3)) + guard previousOptions.contains(parent) else { + throw WjxMultilevelError("问卷星多级下拉父选项不存在:\(parent)") + } + groups[parent] = [] + } else if !parent.isEmpty { + groups[parent, default: []].append(line) + } else { + throw WjxMultilevelError("问卷星多级下拉子选项缺少父选项") + } + } + + var nextPaths: [[String]] = [] + for path in paths { + guard let children = groups[path.last ?? ""], !children.isEmpty else { + throw WjxMultilevelError("问卷星多级下拉缺少子选项:\(path.last ?? "")") + } + let hasTerminalChild = children.contains { child in + guard let range = child.range(of: "々") else { return false } + return range.lowerBound != child.startIndex + } + if hasTerminalChild { + terminalPaths.append(path) + } else { + nextPaths.append(contentsOf: children.map { path + [$0] }) + } + } + paths = nextPaths + previousOptions = Set(groups.values.flatMap { $0 }) + } + + let finalPaths = terminalPaths + paths + var submitValues: [String] = [] + var optionTexts: [String] = [] + for path in finalPaths { + let values = path.map { stripMarkers($0) } + submitValues.append(values.joined(separator: splitChar)) + optionTexts.append(values.map { stripDisplaySuffix($0) }.joined(separator: splitChar)) + } + return Options( + optionTexts: optionTexts, + submitValues: submitValues, + levelLabels: labels.isEmpty ? [] : labels.components(separatedBy: "|") + ) + } + + // MARK: - 拉取选项(对标 load_multilevel_options) + + public static func loadOptions( + _ questions: [SurveyQuestionMeta], + html: String, + url: String, + client: HTTPClient + ) async throws -> [SurveyQuestionMeta] { + guard questions.contains(where: { $0.providerType == providerType }) else { + return questions + } + + let doc = try SwiftSoup.parse(html) + let scripts = try doc.select("script").array() + .map { (try? $0.data()) ?? "" } + .joined(separator: "\n") + + guard let activityMatch = firstMatch(activityIdRegex, in: scripts) else { + throw WjxMultilevelError("问卷星多级下拉缺少 activityId") + } + let encodedActivityId = activityMatch.count > 1 ? activityMatch[1] : "" + let plainActivityId = activityMatch.count > 2 ? activityMatch[2] : "" + let activityId: Int + if !encodedActivityId.isEmpty { + guard let decodeMatch = firstMatch(decodeIdRegex, in: scripts), + decodeMatch.count > 1, + let xorValue = Int(decodeMatch[1]), + let encoded = Int(encodedActivityId) else { + throw WjxMultilevelError("问卷星多级下拉无法解析 activityId") + } + activityId = encoded ^ xorValue + } else { + guard let parsed = Int(plainActivityId) else { + throw WjxMultilevelError("问卷星多级下拉无法解析 activityId") + } + activityId = parsed + } + + let menuUrl = urljoin(url, "/joinnew/setmenuselnew.aspx") + var headers = defaultHttpHeaders + headers["Referer"] = url + + var result = questions + for index in result.indices where result[index].providerType == providerType { + var question = result[index] + var raw = (question.providerPageRaw as? [String: Any]) ?? [:] + let cid = JSONCoercion.asTrimmedString(raw["cid"]) + + var params = [ + "activityid": String(activityId), + "ct": "5", + "pos": String(question.num), + ] + if !cid.isEmpty { params["cid"] = cid } + + var components = URLComponents(string: menuUrl) + components?.queryItems = params + .sorted { $0.key < $1.key } + .map { URLQueryItem(name: $0.key, value: $0.value) } + guard let requestUrl = components?.url?.absoluteString else { + throw WjxMultilevelError("问卷星多级下拉选项地址无效") + } + + let response = try await client.get(requestUrl, headers: headers) + try response.raiseForStatus() + let options = try parseOptions(fromHtml: response.text, encoded: !cid.isEmpty) + + question.optionTexts = options.optionTexts + question.options = options.optionTexts.count + raw["submit_values"] = options.submitValues + raw["level_labels"] = options.levelLabels + question.providerPageRaw = raw + result[index] = question + } + return result + } + + // MARK: - 作答映射(对标 multilevel_answer_action) + + /// 多级下拉按文本路径提交,而不是选项序号。 + public static func applyAnswer(_ action: AnswerAction, question: SurveyQuestionMeta) throws -> AnswerAction { + guard question.providerType == providerType else { return action } + guard let raw = question.providerPageRaw as? [String: Any], + let submitValues = raw["submit_values"] as? [String], + let selectedIndex = action.selectedIndices.first else { + throw WjxMultilevelError("问卷星第\(question.num)题多级下拉缺少提交数据") + } + guard 0 <= selectedIndex, selectedIndex < submitValues.count else { + throw WjxMultilevelError("问卷星第\(question.num)题多级下拉选项越界") + } + var updated = action + updated.kind = "text" + updated.textValues = [submitValues[selectedIndex]] + return updated + } + + // MARK: - 私有工具 + + private static func stripMarkers(_ value: String) -> String { + guard !value.isEmpty else { return value } + let range = NSRange(value.startIndex.. String { + guard value.count > 1, let last = value.last, displaySuffixes.contains(last) else { + return value + } + return String(value.dropLast()) + } + + private static func firstMatch(_ regex: NSRegularExpression, in text: String) -> [String]? { + let range = NSRange(text.startIndex.. String { + guard let baseURL = URL(string: base), + let joined = URL(string: path, relativeTo: baseURL) else { + return path + } + return joined.absoluteURL.absoluteString + } +} diff --git a/SurveyController/Provider/Wjx/WjxProvider.swift b/SurveyController/Provider/Wjx/WjxProvider.swift index b0ea5e9..0d82ee8 100644 --- a/SurveyController/Provider/Wjx/WjxProvider.swift +++ b/SurveyController/Provider/Wjx/WjxProvider.swift @@ -41,7 +41,10 @@ public enum WjxProvider { } try WjxHtmlParser.raiseWjxPageStateErrors(html) - let questions = try WjxHtmlParser.parseSurveyQuestions(fromHtml: html) + let parsedQuestions = try WjxHtmlParser.parseSurveyQuestions(fromHtml: html) + let questions = try await WjxMultilevel.loadOptions( + parsedQuestions, html: html, url: normalizedUrl, client: client + ) let title = WjxHtmlParser.extractSurveyTitle(fromHtml: html) ?? "问卷星问卷" return WjxParseResult(title: title, questions: questions) } @@ -56,6 +59,8 @@ public enum WjxProvider { public var proxyAddress: String? /// 提交前惰性获取随机 IP(返回代理地址或 nil) public var submitProxyLeaseFactory: (() async throws -> String?)? + /// 停止信号(对标 brush_wjx_http 的 stop_signal):准备阶段收到停止时不再 POST。 + public var isStopRequested: (@Sendable () -> Bool)? public var rng: RandomSource public var now: () -> Date @@ -66,6 +71,7 @@ public enum WjxProvider { userAgentProfile: UserAgentProfile? = nil, proxyAddress: String? = nil, submitProxyLeaseFactory: (() async throws -> String?)? = nil, + isStopRequested: (@Sendable () -> Bool)? = nil, rng: RandomSource = SystemRandomSource(), now: @escaping () -> Date = Date.init ) { @@ -75,6 +81,7 @@ public enum WjxProvider { self.userAgentProfile = userAgentProfile self.proxyAddress = proxyAddress self.submitProxyLeaseFactory = submitProxyLeaseFactory + self.isStopRequested = isStopRequested self.rng = rng self.now = now } @@ -162,6 +169,7 @@ public enum WjxProvider { if let factory = context.submitProxyLeaseFactory { submitProxyAddress = try await factory() ?? submitProxyAddress } + if context.isStopRequested?() == true { return false } if submitProxyAddress?.isEmpty == true { submitProxyAddress = nil } if config.randomProxyIpEnabled && submitProxyAddress == nil { throw SubmitProxyUnavailableError("提交前未获取到随机 IP") diff --git a/SurveyControllerTests/WjxMultilevelTests.swift b/SurveyControllerTests/WjxMultilevelTests.swift new file mode 100644 index 0000000..e118adb --- /dev/null +++ b/SurveyControllerTests/WjxMultilevelTests.swift @@ -0,0 +1,181 @@ +// 对标 surveycore tests/test_wjx_multilevel.py + +import XCTest +@testable import SurveyController + +final class WjxMultilevelTests: XCTestCase { + + private let levelData = """ + 华南地区 + 华北地区|---华南地区 + 湖南省 + 广东省 + ---华北地区 + 河北省 + 山东省|---湖南省 + 长沙市 + 株洲市 + ---广东省 + 广州市 + 深圳市 + ---河北省 + 石家庄市 + 保定区 + ---山东省 + 济南市 + 青岛市|---长沙市 + 天心区 + 岳麓区 + ---株洲市 + 荷塘区 + 芦淞区 + ---广州市 + 天河区 + 白云区 + ---深圳市 + 罗湖区 + 龙华区 + ---石家庄市 + 长安区 + 新华区 + ---保定区 + 竞秀区 + 莲池区 + ---济南市 + 历下区 + 章丘区 + ---青岛市 + 黄岛区 + 崂山区〒大区|省份|城市|区县 + """ + + private func menuHtml() -> String { + let escaped = levelData + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + return "" + } + + private func pageHtml() -> String { + var questions = "" + for num in 1...10 { + questions += "
" + } + return """ + 多级下拉样本 - 问卷星 +
+ \(questions) + +
+
11.
请选择:
+ +
+
+ + + """ + } + + func test_multilevel_paths_follow_parents_and_use_wjx_separator() throws { + let options = try WjxMultilevel.parseOptions(fromHtml: menuHtml()) + XCTAssertEqual(options.optionTexts.count, 16) + XCTAssertEqual(options.optionTexts.first, "华南地区‐湖南省‐长沙市‐天心区") + XCTAssertEqual(options.optionTexts.last, "华北地区‐山东省‐青岛市‐崂山区") + XCTAssertFalse(options.optionTexts.contains("华南地区‐河北省‐长沙市‐天心区")) + XCTAssertEqual(options.submitValues, options.optionTexts) + XCTAssertEqual(options.levelLabels, ["大区", "省份", "城市", "区县"]) + } + + func test_multilevel_preserves_submission_markers_and_decodes_cid_data() throws { + let data = "总部╰1╯┌|---总部╰1╯┌\n研发部〒公司|部门" + let escaped = data + .replacingOccurrences(of: "\"", with: """) + .addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? data + let html = "" + let options = try WjxMultilevel.parseOptions(fromHtml: html, encoded: true) + XCTAssertEqual(options.optionTexts, ["总部‐研发部"]) + XCTAssertEqual(options.submitValues, ["总部┌‐研发部"]) + } + + func test_multilevel_stops_at_hidden_child_levels() throws { + let data = "甲\n乙|---甲\n无下级々\n---乙\n分部|---无下级々\n占位々\n---分部\n研发部" + let escaped = data.replacingOccurrences(of: "\"", with: """) + let options = try WjxMultilevel.parseOptions(fromHtml: "") + XCTAssertEqual(options.submitValues, ["甲", "乙‐分部‐研发部"]) + } + + func test_parser_detects_multilevel_and_skips_hidden_empty_gapfill() throws { + let questions = try WjxHtmlParser.parseSurveyQuestions(fromHtml: pageHtml()) + XCTAssertEqual(questions.count, 11) + XCTAssertFalse(questions.contains { $0.num == 11 }) + let question = try XCTUnwrap(questions.last) + XCTAssertEqual(question.num, 12) + XCTAssertEqual(question.displayNum, 11) + XCTAssertEqual(question.typeCode, "7") + XCTAssertEqual(question.providerType, "multilevel_dropdown") + XCTAssertEqual(question.textInputs, 0) + XCTAssertFalse(question.isTextLike) + let raw = try XCTUnwrap(question.providerPageRaw as? [String: Any]) + XCTAssertEqual(raw["cid"] as? String, "") + } + + func test_multilevel_answer_action_submits_path_instead_of_index() throws { + let options = try WjxMultilevel.parseOptions(fromHtml: menuHtml()) + let question = SurveyQuestionMeta( + num: 12, + title: "多级下拉", + typeCode: "7", + options: options.optionTexts.count, + optionTexts: options.optionTexts, + providerType: WjxMultilevel.providerType, + providerPageRaw: [ + "submit_values": options.submitValues, + "level_labels": options.levelLabels, + ] + ) + let action = AnswerAction( + questionNum: 12, kind: "choice", inputType: "radio", + selectedIndices: [15], selectedTexts: [options.optionTexts[15]], recordType: "dropdown" + ) + let mapped = try WjxMultilevel.applyAnswer(action, question: question) + XCTAssertEqual(mapped.kind, "text") + XCTAssertEqual(mapped.textValues, ["华北地区‐山东省‐青岛市‐崂山区"]) + XCTAssertEqual( + try WjxSubmitCodec.submitdataFromActions([mapped], questions: [question]), + "12$华北地区‐山东省‐青岛市‐崂山区" + ) + } + + func test_config_round_trip_preserves_provider_page_raw() throws { + let options = try WjxMultilevel.parseOptions(fromHtml: menuHtml()) + let question = SurveyQuestionMeta( + num: 12, + title: "多级下拉", + typeCode: "7", + options: options.optionTexts.count, + optionTexts: options.optionTexts, + providerType: WjxMultilevel.providerType, + providerPageRaw: [ + "cid": "abc", + "submit_values": options.submitValues, + "level_labels": options.levelLabels, + ] + ) + var config = RuntimeConfig() + config.questionsInfo = [question] + + let payload = ConfigCodec.serializeRuntimeConfig(config) + let restored = try ConfigCodec.deserializeRuntimeConfig(payload) + let restoredQuestion = try XCTUnwrap(restored.questionsInfo.first) + XCTAssertEqual(restoredQuestion.providerType, "multilevel_dropdown") + let raw = try XCTUnwrap(restoredQuestion.providerPageRaw as? [String: Any]) + XCTAssertEqual(raw["cid"] as? String, "abc") + XCTAssertEqual((raw["submit_values"] as? [String])?.count, 16) + } +}