From fe4d8d3c657510e1d1d19b4db1a7269a138fb8ed Mon Sep 17 00:00:00 2001 From: LING71671 <1739677116@qq.com> Date: Wed, 11 Nov 2026 23:39:18 +0800 Subject: [PATCH] feat: align Python parse metadata and multilevel dropdowns --- README.md | 2 + api/openapi.yaml | 9 +- docs/sdk/api.md | 7 + docs/sdk/examples.md | 4 + docs/sdk/index.md | 2 + docs/sdk/schemas.md | 14 + pkg/restapi/parse_parity_test.go | 95 +++ pkg/surveycore/config/reversefill/excel.go | 19 +- pkg/surveycore/internal/answerplan/plan.go | 12 +- pkg/surveycore/internal/defaults/questions.go | 3 + .../internal/providers/tencent/logic.go | 4 +- .../internal/providers/tencent/normalize.go | 31 +- .../providers/tencent/normalize_options.go | 2 +- .../internal/providers/tencent/parser_test.go | 4 +- .../internal/providers/wjx/answer.go | 23 +- pkg/surveycore/internal/providers/wjx/http.go | 6 +- .../internal/providers/wjx/metadata.go | 42 +- .../internal/providers/wjx/multilevel.go | 204 +++++ .../internal/providers/wjx/multilevel_test.go | 44 + .../internal/providers/wjx/options.go | 2 +- .../internal/providers/wjx/parser.go | 82 +- .../internal/providers/wjx/parser_test.go | 16 +- pkg/surveycore/internal/providers/wjx/run.go | 8 +- .../internal/providers/wjx/types.go | 5 +- .../internal/psychometrics/ordinal.go | 6 + pkg/surveycore/model/clone.go | 39 + pkg/surveycore/model/clone_test.go | 8 + pkg/surveycore/model/question_types.go | 10 +- pkg/surveycore/model/survey_types.go | 2 + pkg/surveycore/parse_parity_test.go | 166 ++++ pkg/surveycore/runtime/config.go | 3 +- pkg/surveycore/runtime/parse.go | 43 +- pkg/surveycore/runtime/run.go | 1 + .../testdata/generate_parse_fixtures.py | 101 +++ pkg/surveycore/testdata/python-parse.json | 769 ++++++++++++++++++ tests/consumer/sdk_test.go | 13 + 36 files changed, 1718 insertions(+), 83 deletions(-) create mode 100644 pkg/restapi/parse_parity_test.go create mode 100644 pkg/surveycore/internal/providers/wjx/multilevel.go create mode 100644 pkg/surveycore/internal/providers/wjx/multilevel_test.go create mode 100644 pkg/surveycore/parse_parity_test.go create mode 100644 pkg/surveycore/testdata/generate_parse_fixtures.py create mode 100644 pkg/surveycore/testdata/python-parse.json diff --git a/README.md b/README.md index f8b3eac..8cff99c 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ api_key = "" 批量运行可用 `for result, err := range client.RunResults(ctx, cfg)` 按完成顺序读取结果,`attempt` 从 1 开始。目标数表示尝试次数;每次失败保留结果并继续后续尝试,不自动重试。除第一次外,每次尝试开始前都会等待配置中的随机提交间隔。原生 Go 配置显式启用 `failStop` 时仍会在连续失败达到阈值后停止。HTTP 任务用 `GET /api/v1/tasks/{id}/results?after=0&limit=200` 读取同样的逐次结果。 +解析结果包含规范化的 `url`,支持省略 `https://` 的问卷链接,并将见数短链接转换为 `answer.html#/s/...`。题目保留原始题型、分页信息及显示题号;Python 的 `dataclasses.asdict(survey)` 可直接作为单次提交的 `survey` 快照。问卷星多级下拉会读取完整层级选项,并保留实际提交值。具体字段及兼容说明见 [数据结构](docs/sdk/schemas.md#surveydefinition)。 + ## 错误响应 请求级错误统一返回稳定错误码、用户消息和调试详情: diff --git a/api/openapi.yaml b/api/openapi.yaml index 149b2bf..652a233 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -303,6 +303,7 @@ components: properties: provider: { $ref: '#/components/schemas/Provider' } title: { type: string } + url: { type: string, format: uri, description: Normalized parse URL; optional for manually supplied legacy snapshots } questions: { type: array, items: { $ref: '#/components/schemas/QuestionMeta' } } QuestionMeta: type: object @@ -322,6 +323,7 @@ components: provider_question_id: { type: string } provider_page_id: { type: string } provider_type: { type: string } + provider_page_raw: { description: Original provider page value or multilevel dropdown metadata; null when absent } required: { type: boolean } is_description: { type: boolean } is_location: { type: boolean } @@ -339,9 +341,9 @@ components: display_conditions: { type: array, items: { $ref: '#/components/schemas/DisplayCondition' } } has_dependent_display_logic: { type: boolean } controls_display_targets: { type: array, items: { $ref: '#/components/schemas/DisplayControl' } } - slider_min: { type: string } - slider_max: { type: string } - slider_step: { type: string } + slider_min: { type: [number, string, 'null'] } + slider_max: { type: [number, string, 'null'] } + slider_step: { type: [number, string, 'null'] } question_media: { type: array, items: { $ref: '#/components/schemas/QuestionMedia' } } multi_min_limit: { type: [integer, 'null'] } multi_max_limit: { type: [integer, 'null'] } @@ -371,6 +373,7 @@ components: condition_mode: { type: string } condition_option_indices: { type: array, items: { type: integer } } condition_row_index: { type: [integer, 'null'] } + raw_relation: { type: string } DisplayControl: type: object additionalProperties: false diff --git a/docs/sdk/api.md b/docs/sdk/api.md index 8cea3ef..0129d0d 100644 --- a/docs/sdk/api.md +++ b/docs/sdk/api.md @@ -53,10 +53,15 @@ Content-Type: application/json { "provider": "wjx", "title": "用户满意度调查", + "url": "https://www.wjx.cn/vm/example.aspx", "questions": [] } ``` +解析会补齐链接的协议并统一协议、主机名大小写,保留查询参数;见数短链接转换为 `answer.html#/s/...`。返回的 `url` 是规范化后的链接。Go 继续校验 HTTP/HTTPS、平台域名及问卷路径。 + +题目中的 `num` 为原始题号,`display_num` 为页面显示题号,二者可能不同。腾讯问卷保留说明题,并将同页说明内容合并到后续作答题;默认配置跳过说明题。问卷星普通题的 `provider_type` 保留平台代码,多级下拉使用 `multilevel_dropdown`。 + ## 生成默认配置 ```http @@ -136,6 +141,8 @@ Content-Type: application/json 接口同步执行一次并返回 `SubmissionResult`,不创建持久化任务。目标数和并发数固定为 1,不等待任务提交间隔,不自动重试提交。省略题目策略时自动生成默认策略。耗时包含本次解析、准备和平台请求。 +`survey` 接受 Python `dataclasses.asdict()` 导出的问卷快照,包括 `url`、数字或 null 的滑块范围、原始分页字段和多级下拉提交值。题目绑定使用原始题号及平台 ID;传入的配置与快照不会被修改。 + ```json { "attempt": 0, diff --git a/docs/sdk/examples.md b/docs/sdk/examples.md index 35a82cf..347fc73 100644 --- a/docs/sdk/examples.md +++ b/docs/sdk/examples.md @@ -57,6 +57,8 @@ if result != nil { 该调用固定执行一次。已有问卷快照可通过 `Survey` 传入。原始 `cfg` 中的目标数和并发数保持原值。 +`client.Parse(ctx, url)` 返回的 `definition.SurveyURL` 是规范化链接,JSON 字段为 `url`。数字滑块范围和 `ProviderPageRaw` 保留在快照中;不要用 `DisplayNum` 替代 `Num` 绑定答案。 + ### Go 逐次提交结果 ```go @@ -194,6 +196,8 @@ result = response.json() print(result["success"], result["elapsed_seconds"], result["error"]) ``` +已有 Python 问卷快照时,可传入 `json={"config": serialize_config(config), "survey": dataclasses.asdict(survey)}`。Go 会保留分页、滑块范围和多级下拉提交值。 + ### 上传二维码 JavaScript: diff --git a/docs/sdk/index.md b/docs/sdk/index.md index c439ea2..bd97ecf 100644 --- a/docs/sdk/index.md +++ b/docs/sdk/index.md @@ -18,6 +18,8 @@ go get github.com/SurveyController/SurveyCore@ `Submit` 使用 `surveycore.SubmissionRequest`,其中 `Config` 是 `*model.RunRequest`,`Survey` 是可选的 `*model.SurveyDefinition`。它固定提交一次并返回 `SubmissionResult`;失败时同时返回可用 `errors.Is` 或 `ClassifySubmissionError` 判断的错误。参数中的目标数、并发数和提交间隔不会触发额外提交。 +`Parse` 返回的快照包含规范化链接 `SurveyURL`(JSON `url`)、原始题型、分页与显示题号,可接收 Python 导出的问卷快照。滑块范围支持数字、字符串或 null;问卷星多级下拉包含完整选项路径及提交值。 + `RunResults` 返回 `iter.Seq2[SubmissionResult, error]`,按完成顺序产生结果,尝试编号从 1 开始。单次失败保存在结果的 `error` 中,迭代继续;准备失败或调用方取消通过迭代错误返回。提前 `break` 会取消未完成的请求并等待工作线程退出。`Run` 和 `RunWithEvents` 返回汇总,存在失败时在运行结束后返回首个错误;`RunWithEvents` 的 `event.Result` 包含逐次结果。 批量运行以 `target` 为尝试次数,受 `threads` 并发限制,不自动重试。第 2 次起,每次尝试开始前均在并发槽内等待 `submitInterval` 范围内的随机秒数。显式 `failStop`、停止操作或取消可以使运行提前结束。Python v1 配置导入时 `failStop` 为 false。 diff --git a/docs/sdk/schemas.md b/docs/sdk/schemas.md index 764ba36..b3fdc4c 100644 --- a/docs/sdk/schemas.md +++ b/docs/sdk/schemas.md @@ -119,10 +119,24 @@ outline: deep |---|---|---| | `provider` | `string` | 问卷平台。 | | `title` | `string` | 问卷标题。 | +| `url` | `string` | 解析后的规范化链接;Go 字段名为 `SurveyURL`,手动构造旧快照时可省略。 | | `questions` | `QuestionMeta[]` | 题目元数据。 | `QuestionMeta` 包含题号、题型、选项、分页、必填状态、显示逻辑、跳题逻辑、媒体和平台原始 ID。字段名以 `POST /api/v1/surveys/parse` 返回值为准。 +| 字段 | 类型 | 说明 | +|---|---|---| +| `num` / `display_num` | `integer` / `integer` 或 null | 原始题号及显示题号。腾讯说明题占用原始编号,说明题的显示编号为空。绑定答案时使用原始题号或平台 ID。 | +| `provider_type` | `string` | 平台原始题型。问卷星为 `"3"`、`"4"` 等代码,多级下拉为 `"multilevel_dropdown"`。运行层也继续接受旧快照中的 `single`、`multiple` 等名称。默认配置的 `answers.questions[].question_type` 仍使用统一题型。 | +| `provider_page_raw` | JSON 值 | 腾讯原始 `page`,或问卷星多级下拉的 `cid`、`submit_values`、`level_labels`;没有时为 null。Go 字段为 `any`,复制快照时会深拷贝嵌套元数据。 | +| `slider_min` / `slider_max` / `slider_step` | `number`、`string` 或 null | 解析时返回数字或 null,也接受旧 Go 快照中的字符串。Go 字段为 `any`。 | + +Python `dataclasses.asdict(SurveyDefinition)` 可用于 HTTP 单次提交的 `survey`,或反序列化为 Go 的 `model.SurveyDefinition`。Go 额外保留每题的 `provider`;可选空集合可能省略。问卷星多级下拉按父子关系展开选项,显示文字与提交值分别保存;空的隐藏填空占位不会出现在题目列表中,提交时仍保留空位置。外部数据源及超过六级的层级数据会返回解析错误。 + +腾讯问卷未解析出明确逻辑时保留 `logic_parse_status: "unknown"`。腾讯量表、矩阵量表等已有不支持范围保持原有解析错误。 + +问卷星显示条件保留 `raw_relation`,并将 `题号,选项号` 转为从 0 开始的 `condition_option_indices`,同时为来源题目填充 `controls_display_targets`。空条件或 `-1` 不会生成显示约束。 + ## SubmissionRequest 单次提交使用 `{ "config": ..., "survey": ... }`。Go SDK 类型为 `surveycore.SubmissionRequest`。 diff --git a/pkg/restapi/parse_parity_test.go b/pkg/restapi/parse_parity_test.go new file mode 100644 index 0000000..48f2b06 --- /dev/null +++ b/pkg/restapi/parse_parity_test.go @@ -0,0 +1,95 @@ +package restapi + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/SurveyController/SurveyCore/internal/service" + "github.com/SurveyController/SurveyCore/pkg/surveycore" + "github.com/SurveyController/SurveyCore/pkg/surveycore/model" +) + +func TestHTTPParseConfigAndPythonSnapshots(t *testing.T) { + data, err := os.ReadFile("../surveycore/testdata/python-parse.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + Cases []struct { + Name string `json:"name"` + URL string `json:"url"` + Responses map[string]json.RawMessage `json:"responses"` + Expected json.RawMessage `json:"expected"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + for _, test := range fixture.Cases { + t.Run(test.Name, func(t *testing.T) { + s, err := New(Config{DBPath: t.TempDir() + "/parse.db"}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + transport := submissionTestTransport(func(request *http.Request) (*http.Response, error) { + body := test.Responses[request.URL.Path] + if request.Method == http.MethodPost { + body = []byte("10") + } else if body == nil { + return nil, fmt.Errorf("unexpected request: %s", request.URL) + } else { + var html string + if json.Unmarshal(body, &html) == nil { + body = []byte(html) + } + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: request}, nil + }) + s.manager = service.NewManager(s.store, surveycore.New(surveycore.WithHTTPClient(&http.Client{Transport: transport}))) + requestBody, _ := json.Marshal(map[string]string{"url": test.URL}) + var expected model.SurveyDefinition + if err := json.Unmarshal(test.Expected, &expected); err != nil { + t.Fatal(err) + } + for _, path := range []string{"/api/v1/surveys/parse", "/api/v1/configs"} { + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(requestBody)))) + if rec.Code != http.StatusOK { + t.Fatalf("path=%s status=%d body=%s", path, rec.Code, rec.Body.String()) + } + var definition model.SurveyDefinition + if strings.HasSuffix(path, "/configs") { + var config model.RunRequest + if err := json.Unmarshal(rec.Body.Bytes(), &config); err != nil { + t.Fatal(err) + } + definition = config.SurveyDefinition + } else if err := json.Unmarshal(rec.Body.Bytes(), &definition); err != nil { + t.Fatal(err) + } + if definition.Provider != expected.Provider || definition.SurveyURL != expected.SurveyURL || len(definition.Questions) != len(expected.Questions) { + t.Fatalf("HTTP lost survey metadata: %#v", definition) + } + } + if expected.Provider == "wjx" { + body, _ := json.Marshal(map[string]any{ + "config": map[string]any{"schema_version": 1, "url": test.URL, "reliability_mode": false, "answer_duration": []int{0, 0}}, + "survey": test.Expected, + }) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/submissions", strings.NewReader(string(body)))) + var result surveycore.SubmissionResult + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil || rec.Code != http.StatusOK || !result.Success { + t.Fatalf("Python snapshot status=%d body=%s error=%v", rec.Code, rec.Body.String(), err) + } + } + }) + } +} diff --git a/pkg/surveycore/config/reversefill/excel.go b/pkg/surveycore/config/reversefill/excel.go index 7760ec6..04011e9 100644 --- a/pkg/surveycore/config/reversefill/excel.go +++ b/pkg/surveycore/config/reversefill/excel.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/SurveyController/SurveyCore/pkg/surveycore/internal/defaults" "github.com/SurveyController/SurveyCore/pkg/surveycore/model" "github.com/xuri/excelize/v2" ) @@ -127,7 +128,7 @@ func parseQuestionAnswer(question model.QuestionMeta, columns []Column, row RawR } func inferQuestionType(question model.QuestionMeta) string { - if question.ProviderType != "" { + if question.ProviderType != "" && question.ProviderType != question.TypeCode && question.ProviderType != "multilevel_dropdown" { switch question.ProviderType { case "radio", "single": return "single" @@ -137,24 +138,12 @@ func inferQuestionType(question model.QuestionMeta) string { return "matrix" case "text", "textarea": return "text" - } - return "" - } - switch question.TypeCode { - case "3": - return "single" - case "5": - return "scale" - case "6": - return "matrix" - case "7": - return "dropdown" - default: - if question.IsTextLike || question.TextInputs > 0 { + case "multi_text": return "text" } return "" } + return defaults.QuestionType(question) } func valuesByColumn(row []string) map[int]string { diff --git a/pkg/surveycore/internal/answerplan/plan.go b/pkg/surveycore/internal/answerplan/plan.go index dab506f..ce39c8f 100644 --- a/pkg/surveycore/internal/answerplan/plan.go +++ b/pkg/surveycore/internal/answerplan/plan.go @@ -113,7 +113,11 @@ func BuildActionWithOptions(question model.QuestionMeta, entry model.QuestionStr } case "slider": action.Kind = kind - action.SliderValue = firstNonEmpty(firstProbabilityText(entry.Probabilities), question.SliderMin, defaultSliderValue) + minimum := "" + if question.SliderMin != nil { + minimum = fmt.Sprint(question.SliderMin) + } + action.SliderValue = firstNonEmpty(firstProbabilityText(entry.Probabilities), minimum, defaultSliderValue) case "text": action.Kind = "text" count := maxInt(1, question.TextInputs) @@ -135,7 +139,13 @@ func BuildActionWithOptions(question model.QuestionMeta, entry model.QuestionStr func normalizeKind(question model.QuestionMeta, entry model.QuestionStrategy) string { kind := strings.TrimSpace(string(entry.QuestionType)) if kind == "" { + if question.IsSliderMatrix { + return "matrix" + } kind = strings.TrimSpace(question.ProviderType) + if kind == question.TypeCode || kind == "multilevel_dropdown" { + kind = "" + } } switch kind { case "single", "multiple", "dropdown", "scale", "score", "matrix", "order", "slider", "text", "multi_text": diff --git a/pkg/surveycore/internal/defaults/questions.go b/pkg/surveycore/internal/defaults/questions.go index 775c096..8702e85 100644 --- a/pkg/surveycore/internal/defaults/questions.go +++ b/pkg/surveycore/internal/defaults/questions.go @@ -3,6 +3,9 @@ package defaults import "github.com/SurveyController/SurveyCore/pkg/surveycore/model" func QuestionType(question model.QuestionMeta) string { + if question.IsSliderMatrix { + return "matrix" + } switch question.ProviderType { case "single", "multiple", "dropdown", "scale", "score", "matrix", "order", "slider", "text": return question.ProviderType diff --git a/pkg/surveycore/internal/providers/tencent/logic.go b/pkg/surveycore/internal/providers/tencent/logic.go index 2153de2..52497bb 100644 --- a/pkg/surveycore/internal/providers/tencent/logic.go +++ b/pkg/surveycore/internal/providers/tencent/logic.go @@ -77,7 +77,7 @@ func attachLogicMetadata(rawQuestions []map[string]any, questions []model.Questi question.LogicStatus = model.LogicParseStatusUnknown } } else { - question.LogicStatus = model.LogicParseStatusNone + question.LogicStatus = model.LogicParseStatusUnknown } questions[idx] = question } @@ -98,7 +98,7 @@ func attachLogicMetadata(rawQuestions []map[string]any, questions []model.Questi } if raw["hidden"] != nil && raw["hidden"] != "" { questions[idx].HasDisplayCondition = true - if questions[idx].LogicStatus == model.LogicParseStatusNone { + if questions[idx].LogicStatus != model.LogicParseStatusComplete { questions[idx].LogicStatus = model.LogicParseStatusUnknown } } diff --git a/pkg/surveycore/internal/providers/tencent/normalize.go b/pkg/surveycore/internal/providers/tencent/normalize.go index 1e1ed7f..6670c5b 100644 --- a/pkg/surveycore/internal/providers/tencent/normalize.go +++ b/pkg/surveycore/internal/providers/tencent/normalize.go @@ -39,13 +39,6 @@ func buildDefinition(rawQuestions []map[string]any, rawTitle string) (model.Surv if err := rejectBlockedQuestions(normalized); err != nil { return model.SurveyDefinition{}, err } - questions := make([]model.QuestionMeta, 0, len(normalized)) - for _, question := range normalized { - if question.IsDescription { - continue - } - questions = append(questions, question) - } title := normalizeTitle(rawTitle) if title == "" { title = "腾讯问卷" @@ -53,7 +46,7 @@ func buildDefinition(rawQuestions []map[string]any, rawTitle string) (model.Surv return model.SurveyDefinition{ Provider: model.ProviderQQ, Title: title, - Questions: questions, + Questions: normalized, }, nil } @@ -69,7 +62,8 @@ func standardizeQuestions(rawQuestions []map[string]any) []model.QuestionMeta { result = append(result, question) continue } - question.Num = displayNum + number := displayNum + question.DisplayNum = &number displayNum++ if len(pendingDescriptions) > 0 { question = mergeDescriptions(question, pendingDescriptions) @@ -115,6 +109,7 @@ func normalizeQuestion(raw map[string]any, fallbackNum int, pageMap map[string]i ProviderID: strings.TrimSpace(stringValue(raw["id"])), ProviderPageID: strings.TrimSpace(stringValue(raw["page_id"])), ProviderType: providerType, + ProviderPageRaw: raw["page"], Required: boolValue(raw["required"]), IsDescription: isDescription, IsRating: providerType == "nps" || providerType == "star", @@ -122,7 +117,7 @@ func normalizeQuestion(raw map[string]any, fallbackNum int, pageMap map[string]i TextInputs: textInputs(providerType), IsTextLike: providerType == "text" || providerType == "textarea", IsMultiText: false, - QuestionLogic: model.QuestionLogic{LogicStatus: model.LogicParseStatusNone}, + QuestionLogic: model.QuestionLogic{LogicStatus: model.LogicParseStatusUnknown}, MultiMinLimit: multiMin, MultiMaxLimit: multiMax, ForcedTexts: []string{}, @@ -154,6 +149,7 @@ func pageMapKey(question map[string]any) string { func mergeDescriptions(question model.QuestionMeta, descriptions []model.QuestionMeta) model.QuestionMeta { var titles []string var descriptionsText []string + var media []model.QuestionMedia for _, description := range descriptions { if description.Page != question.Page { continue @@ -164,6 +160,7 @@ func mergeDescriptions(question model.QuestionMeta, descriptions []model.Questio if strings.TrimSpace(description.Description) != "" { descriptionsText = append(descriptionsText, strings.TrimSpace(description.Description)) } + media = append(media, description.QuestionMedia...) } if len(titles) > 0 { titles = append(titles, question.Title) @@ -175,6 +172,20 @@ func mergeDescriptions(question model.QuestionMeta, descriptions []model.Questio } question.Description = strings.TrimSpace(strings.Join(descriptionsText, "\n")) } + seen := map[string]bool{} + var merged []model.QuestionMedia + for _, item := range append(media, question.QuestionMedia...) { + index := -1 + if item.Index != nil { + index = *item.Index + } + key := fmt.Sprintf("%s\x00%d\x00%s", item.Scope, index, item.SourceURL) + if !seen[key] { + merged = append(merged, item) + seen[key] = true + } + } + question.QuestionMedia = merged return question } diff --git a/pkg/surveycore/internal/providers/tencent/normalize_options.go b/pkg/surveycore/internal/providers/tencent/normalize_options.go index 498b8dd..245b879 100644 --- a/pkg/surveycore/internal/providers/tencent/normalize_options.go +++ b/pkg/surveycore/internal/providers/tencent/normalize_options.go @@ -126,7 +126,7 @@ func unsupportedReason(providerType string) string { if label := blockedRuntimeProviderTypes[providerType]; label != "" { return "当前版本暂不支持腾讯问卷" + label + "题" } - if supportedProviderTypes[providerType] { + if supportedProviderTypes[providerType] || providerType == "description" { return "" } if providerType == "" { diff --git a/pkg/surveycore/internal/providers/tencent/parser_test.go b/pkg/surveycore/internal/providers/tencent/parser_test.go index cfe90e8..1a3736a 100644 --- a/pkg/surveycore/internal/providers/tencent/parser_test.go +++ b/pkg/surveycore/internal/providers/tencent/parser_test.go @@ -26,7 +26,7 @@ func TestParserParsesTencentQuestions(t *testing.T) { if definition.Provider != model.ProviderQQ || definition.Title != "消费问卷" { t.Fatalf("definition = %#v", definition) } - if len(definition.Questions) != 4 { + if len(definition.Questions) != 5 { t.Fatalf("question count = %d", len(definition.Questions)) } first := definition.Questions[0] @@ -43,7 +43,7 @@ func TestParserParsesTencentQuestions(t *testing.T) { if len(second.QuestionMedia) == 0 { t.Fatalf("media = %#v", second.QuestionMedia) } - matrix := definition.Questions[3] + matrix := definition.Questions[4] if matrix.Title != "说明文字 请评分" || matrix.Description != "请认真阅读" { t.Fatalf("merged matrix = %#v", matrix) } diff --git a/pkg/surveycore/internal/providers/wjx/answer.go b/pkg/surveycore/internal/providers/wjx/answer.go index c100a2b..9edf34d 100644 --- a/pkg/surveycore/internal/providers/wjx/answer.go +++ b/pkg/surveycore/internal/providers/wjx/answer.go @@ -40,7 +40,7 @@ func cloneOptionFills(src map[int]string) map[int]string { return dst } -func buildSubmitData(questions []model.QuestionMeta, planned []model.AnswerAction) (string, error) { +func buildSubmitData(questions []model.QuestionMeta, planned []model.AnswerAction, emptyNumbers ...int) (string, error) { actions := make(map[int]answerAction, len(planned)) for _, action := range planned { wjxAction := toWJXAction(action) @@ -75,16 +75,35 @@ func buildSubmitData(questions []model.QuestionMeta, planned []model.AnswerActio orderedNums = append(orderedNums, questionNum) } } + empty := make(map[int]bool, len(emptyNumbers)) + for _, number := range emptyNumbers { + if number <= 0 { + continue + } + empty[number] = true + if _, exists := seen[number]; !exists { + seen[number] = struct{}{} + orderedNums = append(orderedNums, number) + } + } sort.Ints(orderedNums) parts := make([]string, 0, len(orderedNums)) for _, questionNum := range orderedNums { var answer string if action, ok := actions[questionNum]; ok { + if question := questionByNum[questionNum]; question.ProviderType == multilevelDropdown { + value, err := multilevelAnswer(question, action.Indices) + if err != nil { + return "", err + } + action.Kind = "text" + action.Texts = []string{value} + } answer = actionAnswer(action) if answer == "" { return "", fmt.Errorf("第%d题没有生成可提交答案", questionNum) } - } else { + } else if !empty[questionNum] { question, ok := questionByNum[questionNum] if !ok { continue diff --git a/pkg/surveycore/internal/providers/wjx/http.go b/pkg/surveycore/internal/providers/wjx/http.go index 4517b38..3117f0b 100644 --- a/pkg/surveycore/internal/providers/wjx/http.go +++ b/pkg/surveycore/internal/providers/wjx/http.go @@ -36,11 +36,15 @@ func (p Parser) getHTML(ctx context.Context, surveyURL string) (string, error) { } func fetchHTML(ctx context.Context, client httpDoer, surveyURL string, userAgent string) (string, error) { + return fetchHTMLWithReferer(ctx, client, surveyURL, surveyURL, userAgent) +} + +func fetchHTMLWithReferer(ctx context.Context, client httpDoer, surveyURL, referer, userAgent string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, surveyURL, nil) if err != nil { return "", err } - for key, value := range requestHeaders(surveyURL, userAgent) { + for key, value := range requestHeaders(referer, userAgent) { req.Header.Set(key, value) } resp, err := client.Do(req) diff --git a/pkg/surveycore/internal/providers/wjx/metadata.go b/pkg/surveycore/internal/providers/wjx/metadata.go index eb2ed37..6b482bf 100644 --- a/pkg/surveycore/internal/providers/wjx/metadata.go +++ b/pkg/surveycore/internal/providers/wjx/metadata.go @@ -1,6 +1,9 @@ package wjx import ( + "fmt" + "math" + "regexp" "strconv" "strings" @@ -131,11 +134,14 @@ func sliderRange(div *nethtml.Node) model.SliderRange { if input == nil { return model.SliderRange{} } - return model.SliderRange{ - SliderMin: firstNonEmpty(attr(input, "min"), "0"), - SliderMax: firstNonEmpty(attr(input, "max"), "100"), - SliderStep: firstNonEmpty(attr(input, "step"), "1"), + number := func(attribute string) any { + value, err := strconv.ParseFloat(attr(input, attribute), 64) + if err != nil || math.IsNaN(value) || math.IsInf(value, 0) { + return nil + } + return value } + return model.SliderRange{SliderMin: number("min"), SliderMax: number("max"), SliderStep: number("step")} } func logicMetadata(div *nethtml.Node, questionNum int, optionTexts []string) (bool, []model.JumpRule, bool, []model.DisplayCondition, bool, []model.DisplayControl, string) { @@ -151,11 +157,31 @@ func logicMetadata(div *nethtml.Node, questionNum int, optionTexts []string) (bo jumpRules = append(jumpRules, model.JumpRule{OptionIndex: optionIndex, TargetQuestion: target, OptionText: optionTextAt(optionTexts, optionIndex)}) } relation := strings.TrimSpace(attr(div, "relation")) - hasDisplay := relation != "" && relation != "-1" displayConditions := make([]model.DisplayCondition, 0) - if hasDisplay { - displayConditions = append(displayConditions, model.DisplayCondition{QuestionNum: intValue(relation), Mode: "selected", OptionIndices: []int{}}) + seenConditions := map[string]bool{} + for _, chunk := range strings.Split(relation, "|") { + chunk = strings.TrimSpace(chunk) + if !displayRelationRE.MatchString(chunk) { + continue + } + parts := strings.Split(chunk, ",") + source := intValue(strings.TrimSpace(parts[0])) + var indices []int + seenIndices := map[int]bool{} + for _, part := range parts[1:] { + index := intValue(strings.TrimSpace(part)) - 1 + if index >= 0 && !seenIndices[index] { + indices = append(indices, index) + seenIndices[index] = true + } + } + key := strconv.Itoa(source) + ":" + fmt.Sprint(indices) + if source > 0 && len(indices) > 0 && !seenConditions[key] { + displayConditions = append(displayConditions, model.DisplayCondition{QuestionNum: source, Mode: "selected", OptionIndices: indices, RawRelation: chunk}) + seenConditions[key] = true + } } + hasDisplay := len(displayConditions) > 0 hasLogic := len(jumpRules) > 0 || hasDisplay status := "none" if hasLogic { @@ -165,6 +191,8 @@ func logicMetadata(div *nethtml.Node, questionNum int, optionTexts []string) (bo return len(jumpRules) > 0, jumpRules, hasDisplay, displayConditions, false, nil, status } +var displayRelationRE = regexp.MustCompile(`^\d+\s*,\s*\d+(?:\s*,\s*\d+)*$`) + func optionTextAt(values []string, index int) *string { if index < 0 || index >= len(values) { return nil diff --git a/pkg/surveycore/internal/providers/wjx/multilevel.go b/pkg/surveycore/internal/providers/wjx/multilevel.go new file mode 100644 index 0000000..016d44c --- /dev/null +++ b/pkg/surveycore/internal/providers/wjx/multilevel.go @@ -0,0 +1,204 @@ +package wjx + +import ( + "context" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/SurveyController/SurveyCore/pkg/surveycore/model" + nethtml "golang.org/x/net/html" +) + +const multilevelDropdown = "multilevel_dropdown" + +var ( + activityIDRE = regexp.MustCompile(`\bvar\s+activityId\s*=\s*(?:DecodeId\((\d+)\)|(\d+))`) + activityDecoderRE = regexp.MustCompile(`function\s+DecodeId\([^)]*\)\s*\{\s*return\s+\w+\s*\^\s*(\d+)`) + externalLevelDataRE = regexp.MustCompile(`levelDataExternalUrl\s*=\s*["'][^"']+`) + levelMarkerRE = regexp.MustCompile(`╰[0-9]+╯`) +) + +func (p Parser) parseDefinition(ctx context.Context, htmlText, surveyURL string) (model.SurveyDefinition, error) { + definition, err := ParseDefinitionFromHTML(htmlText) + if err != nil { + return definition, err + } + definition.SurveyURL = surveyURL + for index := range definition.Questions { + question := &definition.Questions[index] + if question.ProviderType != multilevelDropdown { + continue + } + activity := activityIDRE.FindStringSubmatch(htmlText) + if len(activity) == 0 { + return definition, ParseError{Message: "问卷星多级下拉缺少 activityId"} + } + id, _ := strconv.ParseInt(firstNonEmpty(activity[1], activity[2]), 10, 64) + if activity[1] != "" { + decoder := activityDecoderRE.FindStringSubmatch(htmlText) + if len(decoder) == 0 { + return definition, ParseError{Message: "问卷星多级下拉无法解析 activityId"} + } + mask, _ := strconv.ParseInt(decoder[1], 10, 64) + id ^= mask + } + page, err := url.Parse(surveyURL) + if err != nil { + return definition, err + } + endpoint := page.ResolveReference(&url.URL{Path: "/joinnew/setmenuselnew.aspx"}) + query := url.Values{"activityid": {strconv.FormatInt(id, 10)}, "ct": {"5"}, "pos": {strconv.Itoa(question.Num)}} + metadata := question.ProviderPageRaw.(map[string]any) + cid, _ := metadata["cid"].(string) + if cid != "" { + query.Set("cid", cid) + } + endpoint.RawQuery = query.Encode() + menuHTML, err := fetchHTMLWithReferer(ctx, httpClientOrDefault(p.Client), endpoint.String(), surveyURL, p.UserAgent) + if err != nil { + return definition, err + } + texts, values, labels, err := parseMultilevelOptions(menuHTML, cid != "") + if err != nil { + return definition, err + } + question.OptionTexts, question.Options = texts, len(texts) + metadata["submit_values"], metadata["level_labels"] = values, labels + } + return definition, nil +} + +func parseMultilevelOptions(htmlText string, encoded bool) ([]string, []string, []string, error) { + fail := func(message string) ([]string, []string, []string, error) { + return nil, nil, nil, ParseError{Message: "问卷星多级下拉" + message} + } + root, err := parseHTML(htmlText) + if err != nil { + return nil, nil, nil, err + } + input := findFirst(root, func(node *nethtml.Node) bool { return isElement(node, "input") && attr(node, "id") == "hfLevelData" }) + if input == nil { + return fail("未返回 hfLevelData") + } + for _, script := range findAll(root, func(node *nethtml.Node) bool { return isElement(node, "script") }) { + if externalLevelDataRE.MatchString(textContent(script)) { + return fail("暂不支持外部数据源") + } + } + data := attr(input, "value") + if encoded { + data, err = url.PathUnescape(data) + if err != nil { + return fail("层级数据编码无效") + } + } + levels, labelText, _ := strings.Cut(data, "〒") + columns := strings.Split(levels, "|") + if levels == "" || len(columns) > 6 { + return fail("层级数据为空或超过六级") + } + var paths, terminal [][]string + previous := map[string]bool{} + for level, column := range columns { + groups := map[string][]string{} + parent := "" + for _, raw := range strings.Split(strings.ReplaceAll(column, "\r\n", "\n"), "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + if level == 0 { + if strings.HasPrefix(line, "---") { + return fail("首级选项无效") + } + paths = append(paths, []string{line}) + previous[line] = true + } else if strings.HasPrefix(line, "---") { + parent = strings.TrimPrefix(line, "---") + if !previous[parent] { + return fail("父选项不存在:" + parent) + } + groups[parent] = nil + } else if parent == "" { + return fail("子选项缺少父选项") + } else { + groups[parent] = append(groups[parent], line) + } + } + if level == 0 { + if len(paths) == 0 { + return fail("首级选项无效") + } + continue + } + var next [][]string + for _, path := range paths { + children := groups[path[len(path)-1]] + if len(children) == 0 { + return fail("缺少子选项:" + path[len(path)-1]) + } + ends := false + for _, child := range children { + ends = ends || strings.Index(child, "々") > 0 + } + if ends { + terminal = append(terminal, path) + } else { + for _, child := range children { + next = append(next, append(append([]string(nil), path...), child)) + } + } + } + paths = next + previous = map[string]bool{} + for _, children := range groups { + for _, child := range children { + previous[child] = true + } + } + } + var texts, values []string + for _, path := range append(terminal, paths...) { + clean := make([]string, len(path)) + labels := make([]string, len(path)) + for index, value := range path { + clean[index] = levelMarkerRE.ReplaceAllString(value, "") + runes := []rune(clean[index]) + if len(runes) > 1 && strings.ContainsRune("┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇", runes[len(runes)-1]) { + runes = runes[:len(runes)-1] + } + labels[index] = string(runes) + } + texts = append(texts, strings.Join(labels, "‐")) + values = append(values, strings.Join(clean, "‐")) + } + labels := []string{} + if labelText != "" { + labels = strings.Split(labelText, "|") + } + return texts, values, labels, nil +} + +func multilevelAnswer(question model.QuestionMeta, indices []int) (string, error) { + metadata, _ := question.ProviderPageRaw.(map[string]any) + var values []string + switch raw := metadata["submit_values"].(type) { + case []string: + values = raw + case []any: + for _, item := range raw { + text, ok := item.(string) + if !ok { + return "", fmt.Errorf("第%d题多级下拉提交值无效", question.Num) + } + values = append(values, text) + } + } + if len(indices) != 1 || indices[0] < 0 || indices[0] >= len(values) { + return "", fmt.Errorf("第%d题多级下拉缺少有效提交值", question.Num) + } + return values[indices[0]], nil +} diff --git a/pkg/surveycore/internal/providers/wjx/multilevel_test.go b/pkg/surveycore/internal/providers/wjx/multilevel_test.go new file mode 100644 index 0000000..a3db9d8 --- /dev/null +++ b/pkg/surveycore/internal/providers/wjx/multilevel_test.go @@ -0,0 +1,44 @@ +package wjx + +import ( + "html" + "net/url" + "reflect" + "strings" + "testing" +) + +func TestMultilevelOptionsPreservePathsAndSubmissionMarkers(t *testing.T) { + for _, test := range []struct { + data string + encoded bool + texts, values, labels []string + }{ + {"总部╰1╯┌|---总部╰1╯┌\n研发部\n支持部〒公司|部门", true, []string{"总部‐研发部", "总部‐支持部"}, []string{"总部┌‐研发部", "总部┌‐支持部"}, []string{"公司", "部门"}}, + {"甲\n乙|---甲\n无下级々\n---乙\n分部|---无下级々\n占位々\n---分部\n研发部", false, []string{"甲", "乙‐分部‐研发部"}, []string{"甲", "乙‐分部‐研发部"}, []string{}}, + } { + data := test.data + if test.encoded { + data = url.PathEscape(data) + } + texts, values, labels, err := parseMultilevelOptions(``, test.encoded) + if err != nil || !reflect.DeepEqual(texts, test.texts) || !reflect.DeepEqual(values, test.values) || !reflect.DeepEqual(labels, test.labels) { + t.Fatalf("texts=%v values=%v labels=%v error=%v", texts, values, labels, err) + } + } +} + +func TestMultilevelRejectsIncompleteAndExternalData(t *testing.T) { + for _, test := range []struct{ html, message string }{ + {"", "hfLevelData"}, + {``, "层级数据为空"}, + {"", "父选项不存在"}, + {``, "缺少子选项"}, + {``, "外部数据源"}, + {``, "超过六级"}, + } { + if _, _, _, err := parseMultilevelOptions(test.html, false); err == nil || !strings.Contains(err.Error(), test.message) { + t.Fatalf("input=%s error=%v", test.html, err) + } + } +} diff --git a/pkg/surveycore/internal/providers/wjx/options.go b/pkg/surveycore/internal/providers/wjx/options.go index a2bd58f..89e0a55 100644 --- a/pkg/surveycore/internal/providers/wjx/options.go +++ b/pkg/surveycore/internal/providers/wjx/options.go @@ -24,7 +24,7 @@ func questionOptions(div *nethtml.Node, questionNum int, typeCode string) ([]str options, rows := matrixTexts(div, questionNum) return options, rows, nil case "8": - return []string{"50"}, nil, nil + return nil, nil, nil default: if looksLikeSliderMatrix(div) { options, rows := sliderMatrixTexts(div) diff --git a/pkg/surveycore/internal/providers/wjx/parser.go b/pkg/surveycore/internal/providers/wjx/parser.go index 55b66fb..a1e8b6e 100644 --- a/pkg/surveycore/internal/providers/wjx/parser.go +++ b/pkg/surveycore/internal/providers/wjx/parser.go @@ -15,7 +15,7 @@ func (p Parser) Parse(ctx context.Context, surveyURL string) (model.SurveyDefini if err != nil { return model.SurveyDefinition{}, err } - definition, err := ParseDefinitionFromHTML(htmlText) + definition, err := p.parseDefinition(ctx, htmlText, surveyURL) if err != nil { return model.SurveyDefinition{}, err } @@ -49,6 +49,9 @@ func ParseDefinitionFromHTML(htmlText string) (model.SurveyDefinition, error) { for pageIndex, fieldset := range fieldsets { visible := 0 for _, questionDiv := range directQuestionDivs(fieldset) { + if isEmptyGapfill(questionDiv) { + continue + } question := normalizeQuestion(questionDiv, pageIndex+1) if question.Num <= 0 { continue @@ -64,6 +67,24 @@ func ParseDefinitionFromHTML(htmlText string) (model.SurveyDefinition, error) { questions = append(questions, question) } } + byNum := make(map[int]int, len(questions)) + for index, question := range questions { + byNum[question.Num] = index + } + for _, target := range questions { + for _, condition := range target.DisplayConditions { + if index, ok := byNum[condition.QuestionNum]; ok { + source := &questions[index] + source.HasDependentDisplayLogic = true + source.LogicStatus = model.LogicParseStatusComplete + source.ControlsDisplayTargets = append(source.ControlsDisplayTargets, model.DisplayControl{TargetQuestionNum: target.Num, Mode: condition.Mode, OptionIndices: append([]int(nil), condition.OptionIndices...)}) + } + } + } + for index := range questions { + targets := questions[index].ControlsDisplayTargets + sort.SliceStable(targets, func(i, j int) bool { return targets[i].TargetQuestionNum < targets[j].TargetQuestionNum }) + } sort.SliceStable(questions, func(i int, j int) bool { if questions[i].Page == questions[j].Page { return questions[i].Num < questions[j].Num @@ -77,6 +98,24 @@ func ParseDefinitionFromHTML(htmlText string) (model.SurveyDefinition, error) { }, nil } +func isEmptyGapfill(div *nethtml.Node) bool { + return isHidden(div) && attr(div, "type") == "9" && attr(div, "gapfill") == "1" && (attr(div, "relation") == "" || attr(div, "relation") == "0") && countTextInputs(div) == 0 +} + +func emptyGapfillNumbers(htmlText string) []int { + root, err := parseHTML(htmlText) + if err != nil { + return nil + } + var numbers []int + for _, div := range directQuestionDivs(root) { + if isEmptyGapfill(div) && questionNumber(div) > 0 { + numbers = append(numbers, questionNumber(div)) + } + } + return numbers +} + func directQuestionDivs(root *nethtml.Node) []*nethtml.Node { candidates := findAll(root, func(node *nethtml.Node) bool { return isElement(node, "div") && hasAttr(node, "topic") @@ -106,8 +145,19 @@ func normalizeQuestion(div *nethtml.Node, page int) model.QuestionMeta { if typeCode != "11" && looksLikeOrder(div) { typeCode = "11" } + multilevel := findFirst(div, func(node *nethtml.Node) bool { + return isElement(node, "input") && attr(node, "verify") == "多级下拉" + }) + isMultilevel := typeCode == "1" && multilevel != nil + if isMultilevel { + typeCode = "7" + } title := questionTitle(div, num) optionTexts, rowTexts, fillable := questionOptions(div, num, typeCode) + optionCount := len(optionTexts) + if typeCode == "8" { + optionCount = maxInt(1, optionCount) + } textInputs := 0 switch typeCode { case "1", "2", "9": @@ -119,12 +169,6 @@ func normalizeQuestion(div *nethtml.Node, page int) model.QuestionMeta { } isDescription := looksLikeDescription(div, typeCode) isSliderMatrix := typeCode != "8" && looksLikeSliderMatrix(div) - if isSliderMatrix { - typeCode = "6" - } - if typeCode == "8" && len(optionTexts) == 0 { - optionTexts = []string{"50"} - } rating := typeCode == "5" && looksLikeRating(div) if typeCode == "5" && len(optionTexts) == 0 { optionTexts = ratingTexts(div) @@ -133,14 +177,12 @@ func normalizeQuestion(div *nethtml.Node, page int) model.QuestionMeta { optionTexts, _ = choiceTexts(div) } multiMin, multiMax := multiLimits(div, typeCode) - providerType := providerType(typeCode, textInputs, len(optionTexts), isSliderMatrix) + kind := providerType(typeCode, textInputs, len(optionTexts), isSliderMatrix) if rating { - // WJX has two type-5 controls: numeric scales and rating widgets. Keep - // the latter distinct so answer planning can apply score semantics. - providerType = "score" + kind = "score" } forcedIdx, forcedText := (*int)(nil), "" - if providerType == "single" || providerType == "score" || providerType == "dropdown" { + if kind == "single" || kind == "score" || kind == "dropdown" { forcedIdx, forcedText = forceSelectOption(title, optionTexts) } textLabels := textInputLabels(div) @@ -149,9 +191,14 @@ func normalizeQuestion(div *nethtml.Node, page int) model.QuestionMeta { hasJump, jumpRules, hasDisplay, displayConditions, hasDependentDisplay, controlsDisplayTargets, logicStatus := logicMetadata(div, num, optionTexts) if isDescription { typeCode = "0" - providerType = "description" } - unsupported := !isDescription && (!wjxSupportedTypeCode(typeCode) || ((typeCode == "1" || typeCode == "2" || typeCode == "9") && textInputs <= 0)) + var pageRaw any + providerType := typeCode + if isMultilevel { + providerType = multilevelDropdown + pageRaw = map[string]any{"cid": attr(multilevel, "cid")} + } + unsupported := !isDescription && (!wjxSupportedTypeCode(typeCode) || ((typeCode == "1" || typeCode == "2" || typeCode == "9") && textInputs <= 0 && !isSliderMatrix)) unsupportedReason := "" if unsupported { unsupportedReason = "问卷星题型缺少合法文本输入或暂不支持:" + firstNonEmpty(typeCode, "unknown") @@ -161,7 +208,7 @@ func normalizeQuestion(div *nethtml.Node, page int) model.QuestionMeta { Title: title, Description: "", TypeCode: typeCode, - Options: len(optionTexts), + Options: maxInt(optionCount, len(optionTexts)), Rows: maxInt(1, len(rowTexts)), RowTexts: rowTexts, Page: page, @@ -170,6 +217,7 @@ func normalizeQuestion(div *nethtml.Node, page int) model.QuestionMeta { ProviderID: strconv.Itoa(num), ProviderPageID: strconv.Itoa(page), ProviderType: providerType, + ProviderPageRaw: pageRaw, Required: required(div), IsDescription: isDescription, IsLocation: isLocation, @@ -177,8 +225,8 @@ func normalizeQuestion(div *nethtml.Node, page int) model.QuestionMeta { RatingMax: ratingMax(rating, len(optionTexts)), TextInputs: textInputs, TextInputLabels: textLabels, - IsTextLike: isTextLike(typeCode, textInputs, len(optionTexts), isLocation), - IsMultiText: textInputs > 1, + IsTextLike: !isSliderMatrix && isTextLike(typeCode, textInputs, len(optionTexts), isLocation), + IsMultiText: textInputs > 1 && !isSliderMatrix, IsSliderMatrix: isSliderMatrix, QuestionLogic: model.QuestionLogic{ LogicStatus: logicStatus, diff --git a/pkg/surveycore/internal/providers/wjx/parser_test.go b/pkg/surveycore/internal/providers/wjx/parser_test.go index 4e4eb12..a1a9ba4 100644 --- a/pkg/surveycore/internal/providers/wjx/parser_test.go +++ b/pkg/surveycore/internal/providers/wjx/parser_test.go @@ -17,19 +17,19 @@ func TestParseDefinitionFromHTML(t *testing.T) { t.Fatalf("question count = %d", len(definition.Questions)) } first := definition.Questions[0] - if first.ProviderType != "single" || first.TypeCode != "3" || first.Options != 2 || first.ForcedOptionIdx == nil || *first.ForcedOptionIdx != 1 { + if first.ProviderType != "3" || first.TypeCode != "3" || first.Options != 2 || first.ForcedOptionIdx == nil || *first.ForcedOptionIdx != 1 { t.Fatalf("first = %#v", first) } multiple := definition.Questions[1] - if multiple.ProviderType != "multiple" || multiple.MultiMinLimit == nil || *multiple.MultiMinLimit != 1 || multiple.MultiMaxLimit == nil || *multiple.MultiMaxLimit != 2 { + if multiple.ProviderType != "4" || multiple.MultiMinLimit == nil || *multiple.MultiMinLimit != 1 || multiple.MultiMaxLimit == nil || *multiple.MultiMaxLimit != 2 { t.Fatalf("multiple = %#v", multiple) } matrix := definition.Questions[4] - if matrix.ProviderType != "matrix" || matrix.Rows != 2 || matrix.Options != 2 || len(matrix.RowTexts) != 2 { + if matrix.ProviderType != "6" || matrix.Rows != 2 || matrix.Options != 2 || len(matrix.RowTexts) != 2 { t.Fatalf("matrix = %#v", matrix) } text := definition.Questions[5] - if text.ProviderType != "multi_text" || text.TextInputs != 2 { + if text.ProviderType != "1" || text.TextInputs != 2 { t.Fatalf("text = %#v", text) } } @@ -52,7 +52,7 @@ func TestParseDefinitionMetadataFields(t *testing.T) {
B
-