ofd-validator 0.5.1__tar.gz → 0.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/Cargo.lock +4 -3
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/PKG-INFO +1 -1
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/Cargo.toml +2 -1
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/orchestrator.rs +35 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/util.rs +7 -0
- ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/duplicate_link.rs +138 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/mod.rs +8 -0
- ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/store_host.rs +138 -0
- ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/url_tracker.rs +229 -0
- ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/variant_material.rs +120 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-python/Cargo.toml +1 -1
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/pyproject.toml +1 -1
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/Cargo.toml +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/README.md +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/lib.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/schema_cache.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/types.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/folder_name.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/gtin.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/json_validator.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/logo_validator.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/missing_files.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/store_id.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/lib.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/orchestrator.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/types.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/util.rs +0 -0
- {ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/validators.rs +0 -0
|
@@ -805,7 +805,7 @@ dependencies = [
|
|
|
805
805
|
|
|
806
806
|
[[package]]
|
|
807
807
|
name = "ofd-validator-core"
|
|
808
|
-
version = "0.5.
|
|
808
|
+
version = "0.5.2"
|
|
809
809
|
dependencies = [
|
|
810
810
|
"image",
|
|
811
811
|
"jsonschema",
|
|
@@ -813,12 +813,13 @@ dependencies = [
|
|
|
813
813
|
"regex",
|
|
814
814
|
"serde",
|
|
815
815
|
"serde_json",
|
|
816
|
+
"url",
|
|
816
817
|
"walkdir",
|
|
817
818
|
]
|
|
818
819
|
|
|
819
820
|
[[package]]
|
|
820
821
|
name = "ofd-validator-js"
|
|
821
|
-
version = "0.5.
|
|
822
|
+
version = "0.5.2"
|
|
822
823
|
dependencies = [
|
|
823
824
|
"napi",
|
|
824
825
|
"napi-build",
|
|
@@ -831,7 +832,7 @@ dependencies = [
|
|
|
831
832
|
|
|
832
833
|
[[package]]
|
|
833
834
|
name = "ofd-validator-python"
|
|
834
|
-
version = "0.5.
|
|
835
|
+
version = "0.5.2"
|
|
835
836
|
dependencies = [
|
|
836
837
|
"ofd-validator-core",
|
|
837
838
|
"pyo3",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "ofd-validator-core"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.2"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["Open Filament Collective"]
|
|
6
6
|
license = "MIT"
|
|
@@ -11,6 +11,7 @@ serde = { version = "1", features = ["derive"] }
|
|
|
11
11
|
serde_json = "1"
|
|
12
12
|
jsonschema = "0.28"
|
|
13
13
|
regex = "1"
|
|
14
|
+
url = "2"
|
|
14
15
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
|
15
16
|
rayon = "1"
|
|
16
17
|
walkdir = { version = "2", optional = true }
|
|
@@ -572,5 +572,40 @@ pub fn validate_dataset(dataset: &DataSet) -> ValidationResult {
|
|
|
572
572
|
// 6. GTIN/EAN validation
|
|
573
573
|
result.merge_from(&validators::validate_gtin_ean(&sizes_refs));
|
|
574
574
|
|
|
575
|
+
// 7. URL tracking parameters in purchase links (warnings)
|
|
576
|
+
result.merge_from(&validators::validate_url_tracking(&sizes_refs));
|
|
577
|
+
|
|
578
|
+
// 8. Variant colour-name / folder-id material-type redundancy (warnings)
|
|
579
|
+
let material_types: HashSet<String> = dataset
|
|
580
|
+
.schema_cache
|
|
581
|
+
.get("material_types")
|
|
582
|
+
.and_then(|s| s.get("enum"))
|
|
583
|
+
.and_then(|e| e.as_array())
|
|
584
|
+
.map(|arr| {
|
|
585
|
+
arr.iter()
|
|
586
|
+
.filter_map(|v| v.as_str().map(|s| s.to_ascii_uppercase()))
|
|
587
|
+
.collect()
|
|
588
|
+
})
|
|
589
|
+
.unwrap_or_else(|| {
|
|
590
|
+
validators::variant_material::MATERIAL_TYPES_FALLBACK
|
|
591
|
+
.iter()
|
|
592
|
+
.map(|s| s.to_string())
|
|
593
|
+
.collect()
|
|
594
|
+
});
|
|
595
|
+
let variant_refs: Vec<(&str, &Value)> = dataset
|
|
596
|
+
.json_entries
|
|
597
|
+
.iter()
|
|
598
|
+
.filter(|(_, schema_name, _)| schema_name == "variant")
|
|
599
|
+
.map(|(p, _, v)| (p.as_str(), v))
|
|
600
|
+
.collect();
|
|
601
|
+
result.merge_from(&validators::validate_variant_material(&variant_refs, &material_types));
|
|
602
|
+
|
|
603
|
+
// 9. Purchase-link host consistency vs the store's storefront (warnings)
|
|
604
|
+
let store_hosts = validators::store_host::build_store_hosts(&dataset.json_entries);
|
|
605
|
+
result.merge_from(&validators::validate_store_host(&store_hosts, &sizes_refs));
|
|
606
|
+
|
|
607
|
+
// 10. Generic purchase link reused across many variants of a filament (warnings)
|
|
608
|
+
result.merge_from(&validators::validate_duplicate_links(&sizes_refs));
|
|
609
|
+
|
|
575
610
|
result
|
|
576
611
|
}
|
|
@@ -16,6 +16,13 @@ pub fn cleanse_folder_name(name: &str) -> String {
|
|
|
16
16
|
name.replace('/', " ").trim().to_string()
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/// Extract the host (e.g. `shop.polymaker.com`) from an absolute URL, if parseable.
|
|
20
|
+
pub fn url_host(url: &str) -> Option<String> {
|
|
21
|
+
url::Url::parse(url)
|
|
22
|
+
.ok()
|
|
23
|
+
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
#[cfg(feature = "filesystem")]
|
|
20
27
|
pub fn load_json(path: &std::path::Path) -> Option<Value> {
|
|
21
28
|
let content = std::fs::read_to_string(path).ok()?;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
use serde_json::Value;
|
|
2
|
+
use std::collections::{BTreeSet, HashMap};
|
|
3
|
+
|
|
4
|
+
use crate::types::{ValidationError, ValidationResult};
|
|
5
|
+
|
|
6
|
+
/// Minimum number of distinct variants sharing one URL before we warn.
|
|
7
|
+
const MIN_VARIANTS: usize = 3;
|
|
8
|
+
|
|
9
|
+
/// From a sizes.json path label `.../<brand>/<MAT>/<filament>/<variant>/sizes.json`,
|
|
10
|
+
/// return `(filament_key, variant_folder)` = (`.../<brand>/<MAT>/<filament>`, `<variant>`).
|
|
11
|
+
fn filament_key(path: &str) -> Option<(String, String)> {
|
|
12
|
+
let norm = path.replace('\\', "/");
|
|
13
|
+
let parts: Vec<&str> = norm.split('/').collect();
|
|
14
|
+
if parts.len() < 3 {
|
|
15
|
+
return None;
|
|
16
|
+
}
|
|
17
|
+
let variant = parts[parts.len() - 2].to_string();
|
|
18
|
+
let key = parts[..parts.len() - 2].join("/");
|
|
19
|
+
Some((key, variant))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Warn when the same purchase URL is reused across many variants of one filament (a generic
|
|
23
|
+
/// link copied to every colour). Non-blocking; one warning per (filament, url).
|
|
24
|
+
pub fn validate_duplicate_links(sizes_entries: &[(&str, &Value)]) -> ValidationResult {
|
|
25
|
+
let mut result = ValidationResult::default();
|
|
26
|
+
|
|
27
|
+
// filament_key -> url -> set of variant folder names
|
|
28
|
+
let mut map: HashMap<String, HashMap<String, BTreeSet<String>>> = HashMap::new();
|
|
29
|
+
|
|
30
|
+
for (path_str, sizes_data) in sizes_entries {
|
|
31
|
+
let (fkey, variant) = match filament_key(path_str) {
|
|
32
|
+
Some(v) => v,
|
|
33
|
+
None => continue,
|
|
34
|
+
};
|
|
35
|
+
let sizes_arr = match sizes_data.as_array() {
|
|
36
|
+
Some(a) => a,
|
|
37
|
+
None => continue,
|
|
38
|
+
};
|
|
39
|
+
for size in sizes_arr {
|
|
40
|
+
let purchase_links = match size.get("purchase_links").and_then(|v| v.as_array()) {
|
|
41
|
+
Some(l) => l,
|
|
42
|
+
None => continue,
|
|
43
|
+
};
|
|
44
|
+
for link in purchase_links {
|
|
45
|
+
if let Some(url) = link.get("url").and_then(|v| v.as_str()) {
|
|
46
|
+
map.entry(fkey.clone())
|
|
47
|
+
.or_default()
|
|
48
|
+
.entry(url.to_string())
|
|
49
|
+
.or_default()
|
|
50
|
+
.insert(variant.clone());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Deterministic output ordering.
|
|
57
|
+
let mut filaments: Vec<&String> = map.keys().collect();
|
|
58
|
+
filaments.sort();
|
|
59
|
+
for fkey in filaments {
|
|
60
|
+
let urls = &map[fkey];
|
|
61
|
+
let mut url_keys: Vec<&String> = urls.keys().collect();
|
|
62
|
+
url_keys.sort();
|
|
63
|
+
for url in url_keys {
|
|
64
|
+
let variants = &urls[url];
|
|
65
|
+
if variants.len() >= MIN_VARIANTS {
|
|
66
|
+
result.add(ValidationError::warning(
|
|
67
|
+
"DuplicateLink",
|
|
68
|
+
format!(
|
|
69
|
+
"The same purchase url is reused across {} variants of this filament: {}",
|
|
70
|
+
variants.len(),
|
|
71
|
+
url
|
|
72
|
+
),
|
|
73
|
+
Some(format!("{}/*/sizes.json", fkey)),
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
result
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[cfg(test)]
|
|
83
|
+
mod tests {
|
|
84
|
+
use super::*;
|
|
85
|
+
use serde_json::json;
|
|
86
|
+
|
|
87
|
+
fn sized(url: &str) -> Value {
|
|
88
|
+
json!([{
|
|
89
|
+
"filament_weight": 1000, "diameter": 1.75,
|
|
90
|
+
"purchase_links": [{ "store_id": "s", "url": url }]
|
|
91
|
+
}])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[test]
|
|
95
|
+
fn flags_generic_link_reused_across_variants() {
|
|
96
|
+
let base = "data/b/PLA/snapspeed_pla";
|
|
97
|
+
let a = sized("https://a.com/generic");
|
|
98
|
+
let b = sized("https://a.com/generic");
|
|
99
|
+
let c = sized("https://a.com/generic");
|
|
100
|
+
let entries = vec![
|
|
101
|
+
(format!("{base}/red/sizes.json"), &a),
|
|
102
|
+
(format!("{base}/blue/sizes.json"), &b),
|
|
103
|
+
(format!("{base}/green/sizes.json"), &c),
|
|
104
|
+
];
|
|
105
|
+
let refs: Vec<(&str, &Value)> = entries.iter().map(|(p, v)| (p.as_str(), *v)).collect();
|
|
106
|
+
let res = validate_duplicate_links(&refs);
|
|
107
|
+
assert_eq!(res.warning_count(), 1);
|
|
108
|
+
assert!(res.is_valid());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[test]
|
|
112
|
+
fn distinct_links_pass() {
|
|
113
|
+
let base = "data/b/PLA/f";
|
|
114
|
+
let a = sized("https://a.com/1");
|
|
115
|
+
let b = sized("https://a.com/2");
|
|
116
|
+
let c = sized("https://a.com/3");
|
|
117
|
+
let entries = vec![
|
|
118
|
+
(format!("{base}/red/sizes.json"), &a),
|
|
119
|
+
(format!("{base}/blue/sizes.json"), &b),
|
|
120
|
+
(format!("{base}/green/sizes.json"), &c),
|
|
121
|
+
];
|
|
122
|
+
let refs: Vec<(&str, &Value)> = entries.iter().map(|(p, v)| (p.as_str(), *v)).collect();
|
|
123
|
+
assert_eq!(validate_duplicate_links(&refs).warning_count(), 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#[test]
|
|
127
|
+
fn same_url_across_two_variants_below_threshold() {
|
|
128
|
+
let base = "data/b/PLA/f";
|
|
129
|
+
let a = sized("https://a.com/x");
|
|
130
|
+
let b = sized("https://a.com/x");
|
|
131
|
+
let entries = vec![
|
|
132
|
+
(format!("{base}/red/sizes.json"), &a),
|
|
133
|
+
(format!("{base}/blue/sizes.json"), &b),
|
|
134
|
+
];
|
|
135
|
+
let refs: Vec<(&str, &Value)> = entries.iter().map(|(p, v)| (p.as_str(), *v)).collect();
|
|
136
|
+
assert_eq!(validate_duplicate_links(&refs).warning_count(), 0);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
+
mod duplicate_link;
|
|
1
2
|
mod folder_name;
|
|
2
3
|
mod gtin;
|
|
3
4
|
mod json_validator;
|
|
4
5
|
mod logo_validator;
|
|
5
6
|
pub mod missing_files;
|
|
7
|
+
pub mod store_host;
|
|
6
8
|
mod store_id;
|
|
9
|
+
mod url_tracker;
|
|
10
|
+
pub mod variant_material;
|
|
7
11
|
|
|
12
|
+
pub use duplicate_link::validate_duplicate_links;
|
|
8
13
|
pub use folder_name::validate_folder_name;
|
|
9
14
|
pub use gtin::validate_gtin_ean;
|
|
10
15
|
pub use json_validator::validate_json;
|
|
11
16
|
pub use logo_validator::validate_logo;
|
|
12
17
|
pub use missing_files::validate_required_files;
|
|
18
|
+
pub use store_host::validate_store_host;
|
|
13
19
|
pub use store_id::validate_store_ids;
|
|
20
|
+
pub use url_tracker::validate_url_tracking;
|
|
21
|
+
pub use variant_material::validate_variant_material;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
use serde_json::Value;
|
|
2
|
+
use std::collections::HashMap;
|
|
3
|
+
|
|
4
|
+
use crate::types::{ValidationError, ValidationResult};
|
|
5
|
+
use crate::util::url_host;
|
|
6
|
+
|
|
7
|
+
/// Build a map of `store_id -> canonical host` from store.json entries.
|
|
8
|
+
/// `json_entries` are (path_label, schema_name, Value).
|
|
9
|
+
pub fn build_store_hosts(json_entries: &[(String, String, Value)]) -> HashMap<String, String> {
|
|
10
|
+
let mut map = HashMap::new();
|
|
11
|
+
for (_, schema_name, data) in json_entries {
|
|
12
|
+
if schema_name != "store" {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
let id = data.get("id").and_then(|v| v.as_str());
|
|
16
|
+
let host = data
|
|
17
|
+
.get("storefront_url")
|
|
18
|
+
.and_then(|v| v.as_str())
|
|
19
|
+
.and_then(url_host);
|
|
20
|
+
if let (Some(id), Some(host)) = (id, host) {
|
|
21
|
+
map.insert(id.to_string(), host);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
map
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// The registrable-ish base domain: the last two dot-separated labels.
|
|
28
|
+
/// `shop.polymaker.com` and `us.polymaker.com` both -> `polymaker.com`.
|
|
29
|
+
fn base_domain(host: &str) -> String {
|
|
30
|
+
let labels: Vec<&str> = host.split('.').collect();
|
|
31
|
+
let n = labels.len();
|
|
32
|
+
if n >= 2 {
|
|
33
|
+
labels[n - 2..].join(".")
|
|
34
|
+
} else {
|
|
35
|
+
host.to_string()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Warn when a purchase-link URL's host differs from the store's storefront host but shares
|
|
40
|
+
/// the same base domain (an inconsistent subdomain, e.g. `shop.` vs `us.`). Cross-domain
|
|
41
|
+
/// links are left alone (they may be intentional resellers). Non-blocking warnings.
|
|
42
|
+
pub fn validate_store_host(
|
|
43
|
+
store_hosts: &HashMap<String, String>,
|
|
44
|
+
sizes_entries: &[(&str, &Value)],
|
|
45
|
+
) -> ValidationResult {
|
|
46
|
+
let mut result = ValidationResult::default();
|
|
47
|
+
|
|
48
|
+
for (path_str, sizes_data) in sizes_entries {
|
|
49
|
+
let sizes_arr = match sizes_data.as_array() {
|
|
50
|
+
Some(a) => a,
|
|
51
|
+
None => continue,
|
|
52
|
+
};
|
|
53
|
+
for (size_idx, size) in sizes_arr.iter().enumerate() {
|
|
54
|
+
let purchase_links = match size.get("purchase_links").and_then(|v| v.as_array()) {
|
|
55
|
+
Some(l) => l,
|
|
56
|
+
None => continue,
|
|
57
|
+
};
|
|
58
|
+
for (link_idx, link) in purchase_links.iter().enumerate() {
|
|
59
|
+
let store_id = match link.get("store_id").and_then(|v| v.as_str()) {
|
|
60
|
+
Some(s) => s,
|
|
61
|
+
None => continue,
|
|
62
|
+
};
|
|
63
|
+
let canonical = match store_hosts.get(store_id) {
|
|
64
|
+
Some(h) => h,
|
|
65
|
+
None => continue,
|
|
66
|
+
};
|
|
67
|
+
let link_host = match link.get("url").and_then(|v| v.as_str()).and_then(url_host) {
|
|
68
|
+
Some(h) => h,
|
|
69
|
+
None => continue,
|
|
70
|
+
};
|
|
71
|
+
if &link_host != canonical && base_domain(&link_host) == base_domain(canonical) {
|
|
72
|
+
result.add(ValidationError::warning(
|
|
73
|
+
"StoreHost",
|
|
74
|
+
format!(
|
|
75
|
+
"url host '{}' at $[{}].purchase_links[{}] differs from store '{}' storefront host '{}'",
|
|
76
|
+
link_host, size_idx, link_idx, store_id, canonical
|
|
77
|
+
),
|
|
78
|
+
Some(path_str.to_string()),
|
|
79
|
+
));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
result
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#[cfg(test)]
|
|
89
|
+
mod tests {
|
|
90
|
+
use super::*;
|
|
91
|
+
use serde_json::json;
|
|
92
|
+
|
|
93
|
+
fn hosts() -> HashMap<String, String> {
|
|
94
|
+
let mut m = HashMap::new();
|
|
95
|
+
m.insert("polymaker".to_string(), "us.polymaker.com".to_string());
|
|
96
|
+
m
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[test]
|
|
100
|
+
fn flags_subdomain_drift() {
|
|
101
|
+
let sizes = json!([{
|
|
102
|
+
"filament_weight": 1000, "diameter": 1.75,
|
|
103
|
+
"purchase_links": [{ "store_id": "polymaker", "url": "https://shop.polymaker.com/products/x" }]
|
|
104
|
+
}]);
|
|
105
|
+
let entries = vec![("data/b/PLA/f/red/sizes.json", &sizes)];
|
|
106
|
+
let res = validate_store_host(&hosts(), &entries);
|
|
107
|
+
assert_eq!(res.warning_count(), 1);
|
|
108
|
+
assert!(res.is_valid());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[test]
|
|
112
|
+
fn same_host_passes() {
|
|
113
|
+
let sizes = json!([{
|
|
114
|
+
"filament_weight": 1000, "diameter": 1.75,
|
|
115
|
+
"purchase_links": [{ "store_id": "polymaker", "url": "https://us.polymaker.com/products/x" }]
|
|
116
|
+
}]);
|
|
117
|
+
let entries = vec![("p", &sizes)];
|
|
118
|
+
assert_eq!(validate_store_host(&hosts(), &entries).warning_count(), 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#[test]
|
|
122
|
+
fn cross_domain_is_left_alone() {
|
|
123
|
+
let sizes = json!([{
|
|
124
|
+
"filament_weight": 1000, "diameter": 1.75,
|
|
125
|
+
"purchase_links": [{ "store_id": "polymaker", "url": "https://www.amazon.com/dp/x" }]
|
|
126
|
+
}]);
|
|
127
|
+
let entries = vec![("p", &sizes)];
|
|
128
|
+
assert_eq!(validate_store_host(&hosts(), &entries).warning_count(), 0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#[test]
|
|
132
|
+
fn builds_hosts_from_store_entries() {
|
|
133
|
+
let store = json!({ "id": "polymaker", "storefront_url": "https://us.polymaker.com/" });
|
|
134
|
+
let entries = vec![("stores/polymaker/store.json".to_string(), "store".to_string(), store)];
|
|
135
|
+
let map = build_store_hosts(&entries);
|
|
136
|
+
assert_eq!(map.get("polymaker").map(|s| s.as_str()), Some("us.polymaker.com"));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
use serde_json::Value;
|
|
2
|
+
use std::collections::HashSet;
|
|
3
|
+
use std::sync::LazyLock;
|
|
4
|
+
|
|
5
|
+
use crate::types::{ValidationError, ValidationResult};
|
|
6
|
+
|
|
7
|
+
/// Known tracking query-parameter keys (compared case-insensitively). Prefix families
|
|
8
|
+
/// (`utm_`, `mc_`, `pk_`) are handled separately in `is_tracking_key`.
|
|
9
|
+
///
|
|
10
|
+
/// Note: Shopify product selectors `variant` and `id` are intentionally NOT included —
|
|
11
|
+
/// they can point at a specific colour/product, so we treat them as meaningful.
|
|
12
|
+
static TRACKING_PARAMS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
|
13
|
+
[
|
|
14
|
+
"tag", "linkcode", "psc", "th", "ref", "ref_", "fbclid", "gclid", "gclsrc", "dclid",
|
|
15
|
+
"msclkid", "yclid", "twclid", "ttclid", "igshid", "si", "mc_cid", "mc_eid", "_pos", "_psq",
|
|
16
|
+
"_ss", "_v", "_sid", "spm", "scm", "aff", "affid", "srsltid", "gad_source", "epik",
|
|
17
|
+
"pk_campaign", "pk_kwd",
|
|
18
|
+
// Amazon / marketplace search-result context (identity lives in the /dp/<ASIN> path)
|
|
19
|
+
"dib", "dib_tag", "keywords", "qid", "sr", "sprefix", "crid", "ascsubtag", "smid",
|
|
20
|
+
"content-id", "qsid", "rnid", "refinements", "_encoding", "pldnsite",
|
|
21
|
+
]
|
|
22
|
+
.into_iter()
|
|
23
|
+
.collect()
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
fn is_tracking_key(key: &str) -> bool {
|
|
27
|
+
let k = key.trim().to_ascii_lowercase();
|
|
28
|
+
k.starts_with("utm_")
|
|
29
|
+
|| k.starts_with("mc_")
|
|
30
|
+
|| k.starts_with("pk_")
|
|
31
|
+
|| k.starts_with("pd_rd_")
|
|
32
|
+
|| k.starts_with("pf_rd_")
|
|
33
|
+
|| TRACKING_PARAMS.contains(k.as_str())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Hosts where the product identity is entirely in the path, so every query param is
|
|
37
|
+
/// search/tracking context and can be dropped wholesale (e.g. Amazon `/dp/<ASIN>`).
|
|
38
|
+
fn has_disposable_query(base: &str) -> bool {
|
|
39
|
+
let host = match crate::util::url_host(base) {
|
|
40
|
+
Some(h) => h.to_ascii_lowercase(),
|
|
41
|
+
None => return false,
|
|
42
|
+
};
|
|
43
|
+
if host == "a.co" || host == "amzn.to" || host == "amzn.eu" {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
["amazon", "ebay"]
|
|
47
|
+
.iter()
|
|
48
|
+
.any(|b| host.starts_with(&format!("{b}.")) || host.contains(&format!(".{b}.")))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Whether a URL fragment looks like tracking data (e.g. `#utm_source=x`) rather than a
|
|
52
|
+
/// content anchor (e.g. `#productinfo`, which we keep).
|
|
53
|
+
fn is_tracking_fragment(fragment: &str) -> bool {
|
|
54
|
+
if !fragment.contains('=') {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
let first_key = fragment.split(['&', '=']).next().unwrap_or("");
|
|
58
|
+
is_tracking_key(first_key)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Remove known tracking parameters (plus an empty `?` and tracking-only fragments) from a
|
|
62
|
+
/// URL, preserving the original ordering/encoding of everything kept. Idempotent.
|
|
63
|
+
pub fn strip_tracking(url: &str) -> String {
|
|
64
|
+
let (before_frag, fragment) = match url.split_once('#') {
|
|
65
|
+
Some((b, f)) => (b, Some(f)),
|
|
66
|
+
None => (url, None),
|
|
67
|
+
};
|
|
68
|
+
let (base, query) = match before_frag.split_once('?') {
|
|
69
|
+
Some((b, q)) => (b, Some(q)),
|
|
70
|
+
None => (before_frag, None),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let mut out = String::from(base);
|
|
74
|
+
|
|
75
|
+
if let Some(q) = query {
|
|
76
|
+
if !has_disposable_query(base) {
|
|
77
|
+
let kept: Vec<&str> = q
|
|
78
|
+
.split('&')
|
|
79
|
+
.filter(|pair| {
|
|
80
|
+
if pair.is_empty() {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
let key = pair.split('=').next().unwrap_or("");
|
|
84
|
+
!is_tracking_key(key)
|
|
85
|
+
})
|
|
86
|
+
.collect();
|
|
87
|
+
if !kept.is_empty() {
|
|
88
|
+
out.push('?');
|
|
89
|
+
out.push_str(&kept.join("&"));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if let Some(frag) = fragment {
|
|
95
|
+
if !frag.is_empty() && !is_tracking_fragment(frag) {
|
|
96
|
+
out.push('#');
|
|
97
|
+
out.push_str(frag);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// True if stripping tracking data would change the URL. Mirrors the webui `hasTracking`
|
|
105
|
+
/// helper; retained as a public API for consumers even though the rule uses `strip_tracking`
|
|
106
|
+
/// directly.
|
|
107
|
+
#[allow(dead_code)]
|
|
108
|
+
pub fn has_tracking(url: &str) -> bool {
|
|
109
|
+
strip_tracking(url) != url
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Warn about tracking parameters in `purchase_links[].url` inside sizes.json entries.
|
|
113
|
+
/// Each entry is (path_label, parsed sizes.json Value). Emits non-blocking warnings that
|
|
114
|
+
/// suggest the cleaned URL in the message.
|
|
115
|
+
pub fn validate_url_tracking(sizes_entries: &[(&str, &Value)]) -> ValidationResult {
|
|
116
|
+
let mut result = ValidationResult::default();
|
|
117
|
+
|
|
118
|
+
for (path_str, sizes_data) in sizes_entries {
|
|
119
|
+
let sizes_arr = match sizes_data.as_array() {
|
|
120
|
+
Some(a) => a,
|
|
121
|
+
None => continue,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
for (size_idx, size) in sizes_arr.iter().enumerate() {
|
|
125
|
+
let purchase_links = match size.get("purchase_links").and_then(|v| v.as_array()) {
|
|
126
|
+
Some(links) => links,
|
|
127
|
+
None => continue,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
for (link_idx, link) in purchase_links.iter().enumerate() {
|
|
131
|
+
if let Some(url) = link.get("url").and_then(|v| v.as_str()) {
|
|
132
|
+
let cleaned = strip_tracking(url);
|
|
133
|
+
if cleaned != url {
|
|
134
|
+
result.add(ValidationError::warning(
|
|
135
|
+
"URLTracking",
|
|
136
|
+
format!(
|
|
137
|
+
"Tracking parameters in url at $[{}].purchase_links[{}]; suggested: {}",
|
|
138
|
+
size_idx, link_idx, cleaned
|
|
139
|
+
),
|
|
140
|
+
Some(path_str.to_string()),
|
|
141
|
+
));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
result
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[cfg(test)]
|
|
152
|
+
mod tests {
|
|
153
|
+
use super::*;
|
|
154
|
+
use serde_json::json;
|
|
155
|
+
|
|
156
|
+
#[test]
|
|
157
|
+
fn strips_amazon_affiliate() {
|
|
158
|
+
assert_eq!(
|
|
159
|
+
strip_tracking("https://www.amazon.eg/dp/B0GVSM88NP/?tag=3dfil-20&linkCode=ogi&psc=1&th=1"),
|
|
160
|
+
"https://www.amazon.eg/dp/B0GVSM88NP/"
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#[test]
|
|
165
|
+
fn strips_amazon_search_query_wholesale() {
|
|
166
|
+
let dirty = "https://www.amazon.com/AmeLeis-Printer-Filament-Bundle-1-75mm/dp/B0GYNWLN2F?dib=eyJ2IjoiMSJ9.abc&dib_tag=se&keywords=AmeLeis&qid=1784042998&sr=8-1";
|
|
167
|
+
assert_eq!(
|
|
168
|
+
strip_tracking(dirty),
|
|
169
|
+
"https://www.amazon.com/AmeLeis-Printer-Filament-Bundle-1-75mm/dp/B0GYNWLN2F"
|
|
170
|
+
);
|
|
171
|
+
assert!(has_tracking(dirty));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#[test]
|
|
175
|
+
fn keeps_shopify_variant_selector() {
|
|
176
|
+
let u = "https://shop.polymaker.com/products/panchroma-galaxy?variant=44933539987513";
|
|
177
|
+
assert_eq!(strip_tracking(u), u);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#[test]
|
|
181
|
+
fn keeps_variant_and_content_fragment() {
|
|
182
|
+
let u = "https://shop.polymaker.com/products/panchroma-pla?variant=45411122413625#productinfo";
|
|
183
|
+
assert_eq!(strip_tracking(u), u);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#[test]
|
|
187
|
+
fn removes_trailing_empty_query() {
|
|
188
|
+
assert_eq!(
|
|
189
|
+
strip_tracking("https://us.polymaker.com/products/panchroma-glow?"),
|
|
190
|
+
"https://us.polymaker.com/products/panchroma-glow"
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#[test]
|
|
195
|
+
fn removes_trackers_but_keeps_meaningful() {
|
|
196
|
+
assert_eq!(
|
|
197
|
+
strip_tracking("https://x.com/p?utm_source=a&variant=5&gclid=z"),
|
|
198
|
+
"https://x.com/p?variant=5"
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#[test]
|
|
203
|
+
fn strips_tracking_fragment_keeps_anchor() {
|
|
204
|
+
assert_eq!(strip_tracking("https://x.com/p#utm_source=a"), "https://x.com/p");
|
|
205
|
+
assert_eq!(strip_tracking("https://x.com/p#section-2"), "https://x.com/p#section-2");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#[test]
|
|
209
|
+
fn is_idempotent() {
|
|
210
|
+
let dirty = "https://www.amazon.eg/dp/B0GVSM88NP/?tag=3dfil-20&th=1";
|
|
211
|
+
let once = strip_tracking(dirty);
|
|
212
|
+
assert_eq!(strip_tracking(&once), once);
|
|
213
|
+
assert!(has_tracking(dirty));
|
|
214
|
+
assert!(!has_tracking(&once));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#[test]
|
|
218
|
+
fn rule_emits_warning_not_error() {
|
|
219
|
+
let sizes = json!([{
|
|
220
|
+
"filament_weight": 1000, "diameter": 1.75,
|
|
221
|
+
"purchase_links": [{ "store_id": "amazon", "url": "https://a.com/x?tag=aff-20" }]
|
|
222
|
+
}]);
|
|
223
|
+
let entries = vec![("data/b/PLA/f/red/sizes.json", &sizes)];
|
|
224
|
+
let res = validate_url_tracking(&entries);
|
|
225
|
+
assert_eq!(res.warning_count(), 1);
|
|
226
|
+
assert_eq!(res.error_count(), 0);
|
|
227
|
+
assert!(res.is_valid()); // warnings never invalidate
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
use serde_json::Value;
|
|
2
|
+
use std::collections::HashSet;
|
|
3
|
+
|
|
4
|
+
use crate::types::{ValidationError, ValidationResult};
|
|
5
|
+
|
|
6
|
+
/// Canonical material-type tokens (mirrors `schemas/material_types_schema.json`).
|
|
7
|
+
/// Used as a fallback; `validate_dataset` prefers the live schema enum.
|
|
8
|
+
pub const MATERIAL_TYPES_FALLBACK: &[&str] = &[
|
|
9
|
+
"PLA", "PETG", "TPU", "ABS", "ASA", "PC", "PCTG", "PP", "PA6", "PA11", "PA12", "PA66", "CPE",
|
|
10
|
+
"TPE", "HIPS", "PHA", "PET", "PEI", "PBT", "PVB", "PVA", "PEKK", "PEEK", "BVOH", "TPC", "PPS",
|
|
11
|
+
"PPSU", "PVC", "PEBA", "PVDF", "PPA", "PCL", "PES", "PMMA", "POM", "PPE", "PS", "PSU", "TPI",
|
|
12
|
+
"SBS", "OBC", "EVA",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/// Split a display name / id into upper-cased alphanumeric tokens.
|
|
16
|
+
fn tokens(s: &str) -> Vec<String> {
|
|
17
|
+
s.split(|c: char| !c.is_ascii_alphanumeric())
|
|
18
|
+
.filter(|t| !t.is_empty())
|
|
19
|
+
.map(|t| t.to_ascii_uppercase())
|
|
20
|
+
.collect()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Return the first material token embedded in `name`, if any (whole-token match).
|
|
24
|
+
fn find_material_token(name: &str, material_types: &HashSet<String>) -> Option<String> {
|
|
25
|
+
tokens(name).into_iter().find(|t| material_types.contains(t))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Extract the variant folder name from a `.../<variant>/variant.json` path label.
|
|
29
|
+
fn variant_folder(path: &str) -> Option<String> {
|
|
30
|
+
let norm = path.replace('\\', "/");
|
|
31
|
+
let mut parts = norm.rsplit('/');
|
|
32
|
+
let _file = parts.next()?; // variant.json
|
|
33
|
+
parts.next().map(|s| s.to_string())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Warn when a variant's colour name (or its folder id) embeds a material-type token,
|
|
37
|
+
/// which is redundant with the parent MATERIAL directory. Non-blocking warnings;
|
|
38
|
+
/// at most one per variant. `variant_entries` are (path_label, parsed variant.json Value).
|
|
39
|
+
pub fn validate_variant_material(
|
|
40
|
+
variant_entries: &[(&str, &Value)],
|
|
41
|
+
material_types: &HashSet<String>,
|
|
42
|
+
) -> ValidationResult {
|
|
43
|
+
let mut result = ValidationResult::default();
|
|
44
|
+
|
|
45
|
+
for (path_str, data) in variant_entries {
|
|
46
|
+
if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
|
|
47
|
+
if let Some(tok) = find_material_token(name, material_types) {
|
|
48
|
+
result.add(ValidationError::warning(
|
|
49
|
+
"VariantMaterial",
|
|
50
|
+
format!(
|
|
51
|
+
"Colour name '{}' contains material type '{}', which is redundant with the material folder",
|
|
52
|
+
name, tok
|
|
53
|
+
),
|
|
54
|
+
Some(path_str.to_string()),
|
|
55
|
+
));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if let Some(folder) = variant_folder(path_str) {
|
|
61
|
+
if let Some(tok) = find_material_token(&folder, material_types) {
|
|
62
|
+
result.add(ValidationError::warning(
|
|
63
|
+
"VariantMaterial",
|
|
64
|
+
format!(
|
|
65
|
+
"Variant folder id '{}' contains material type '{}', which is redundant with the material folder",
|
|
66
|
+
folder, tok
|
|
67
|
+
),
|
|
68
|
+
Some(path_str.to_string()),
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#[cfg(test)]
|
|
78
|
+
mod tests {
|
|
79
|
+
use super::*;
|
|
80
|
+
use serde_json::json;
|
|
81
|
+
|
|
82
|
+
fn material_set() -> HashSet<String> {
|
|
83
|
+
MATERIAL_TYPES_FALLBACK.iter().map(|s| s.to_string()).collect()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#[test]
|
|
87
|
+
fn flags_material_in_colour_name() {
|
|
88
|
+
let v = json!({ "name": "Galaxy PETG", "color_hex": "#112233" });
|
|
89
|
+
let entries = vec![("data/b/PETG/f/galaxy_petg/variant.json", &v)];
|
|
90
|
+
let res = validate_variant_material(&entries, &material_set());
|
|
91
|
+
assert_eq!(res.warning_count(), 1);
|
|
92
|
+
assert_eq!(res.error_count(), 0);
|
|
93
|
+
assert!(res.is_valid());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#[test]
|
|
97
|
+
fn flags_material_in_folder_id_when_name_clean() {
|
|
98
|
+
let v = json!({ "name": "Sakura Pink", "color_hex": "#ffaabb" });
|
|
99
|
+
let entries = vec![("data/sunlu/PETG/petg/sakura_pink_petg/variant.json", &v)];
|
|
100
|
+
let res = validate_variant_material(&entries, &material_set());
|
|
101
|
+
assert_eq!(res.warning_count(), 1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[test]
|
|
105
|
+
fn clean_name_and_folder_pass() {
|
|
106
|
+
let v = json!({ "name": "Sakura Pink", "color_hex": "#ffaabb" });
|
|
107
|
+
let entries = vec![("data/sunlu/PETG/petg/sakura_pink/variant.json", &v)];
|
|
108
|
+
let res = validate_variant_material(&entries, &material_set());
|
|
109
|
+
assert_eq!(res.warning_count(), 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#[test]
|
|
113
|
+
fn whole_token_match_avoids_substrings() {
|
|
114
|
+
// "PETAL" must not match "PET"; "Space" must not match "PA"/"AS".
|
|
115
|
+
let v = json!({ "name": "Petal Space Grey", "color_hex": "#777777" });
|
|
116
|
+
let entries = vec![("data/b/PLA/f/petal/variant.json", &v)];
|
|
117
|
+
let res = validate_variant_material(&entries, &material_set());
|
|
118
|
+
assert_eq!(res.warning_count(), 0);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/folder_name.rs
RENAMED
|
File without changes
|
{ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/gtin.rs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ofd_validator-0.5.1 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/store_id.rs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|