
Data Quality Detection
- 1 installs
- 15 repo stars
- Updated June 17, 2026
- kaori-seasons/data-skill-hub
Diagnoses data-quality problems in tables and SQL pipelines and traces them to source pollution, mapping mistakes, wrong grain, or missing bridge keys.
About
A data-quality skill that diagnoses problems in tables and SQL pipelines and traces them to source pollution, mapping mistakes, wrong grain, or rule gaps. A data engineer uses it when data looks wrong between the warehouse and the frontend.
- Diagnoses null-heavy dimensions, enum/path field issues, and frontend-vs-warehouse discrepancies
- Traces issues to source pollution, SQL mapping mistakes, or missing bridge keys
Data Quality Detection by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,803 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kaori-seasons/data-skill-hub --skill data-quality-detectionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 15 |
| Last updated | June 17, 2026 |
| Repository | kaori-seasons/data-skill-hub ↗ |
What it does
Diagnoses data-quality problems in tables and SQL pipelines and traces them to source pollution, mapping mistakes, wrong grain, or missing bridge keys.
Files
Data Quality Detection
Use this skill when the task is not just "run null checks", but "explain why the data cannot be trusted and where the break actually happens."
What This Skill Solves
This skill is built for questions like:
- Which table is safe to use as the analysis base table?
- Is the bad value already present in ODS, or introduced by downstream SQL?
- Is the issue caused by source pollution, bridge-table failure, wrong field sourcing, or frontend formatting?
- Why do path fields, platform fields, or enum fields look wrong?
- Why do planned bridge keys exist in design docs but not in runtime tables?
First Principles
Treat data quality as a decision-support problem, not a formatting problem.
The skill assumes:
1. A field is "high quality" only if it can still support stable business decisions. 2. A finding is incomplete unless it is attached to a concrete break point. 3. Single-table evidence is weak; cross-layer evidence is preferred. 4. High fill rate does not imply correct semantics. 5. Every major conclusion should be backed by representative samples.
Workflow
1. Define the expected contract
Before querying, define:
- target grain
- key fields
- required dimensions
- required metrics
- derived metrics that should not be mistaken for physical columns
Read these when relevant:
references/four-table.pdfreferences/data-quality-report-20260408.md
2. Confirm the runtime object
Do not trust only the planned table name.
Check:
- whether the table exists
- whether the runtime columns match the design
- whether a result table is missing and requires fallback to source tables
- whether same-name fields are actually sourced from different places
Useful scripts:
scripts/run_table124_quality_report.pyscripts/run_total_hours_sql_field_probe.pyscripts/build_sql_data_map.py
3. Run six layers of checks
Structure
- schema completeness
- type mismatches
- comment/documentation gaps
- all-nullable design
Value quality
- null / blank rates
- dirty characters
- overlong values
- path suffix anomalies
- file-name vs full-path conflicts
Key and grain
- duplicate primary keys
- one-file-many-rows / one-order-many-rows / one-instance-many-rows
- distinct-count collapse
Enum and distribution
- mixed semantic/process/test values
- target/reference enum mismatches
- values that appear on only one side
Freshness and temporal sanity
- min/max timestamps
- future dates
- extreme stale dates
- fields that exist in schema but are empty in live runtime
Cross-layer and lineage
- source vs downstream consistency
- bridge-table breaks
- semantic miswrite such as
full_pathwritten into a folder-like field - frontend display-layer formatting differences
Root-Cause Buckets
Force each major issue into one of these buckets:
1. Source pollution 2. SQL mapping mistake 3. Missing bridge key or incomplete bridge table 4. Wrong grain or wrong key choice 5. Rule coverage gap 6. Frontend / interface formatting deviation
If a finding does not fit a bucket, the diagnosis is still incomplete.
Script Selection Guide
Use the smallest relevant artifact set first.
General row-level checks
scripts/run_quality_checks.pyscripts/run_7table_quality.py
Planned-vs-runtime bridge validation
scripts/run_table124_quality_report.py
Image/path/source diagnostics
scripts/run_current_version_image_source_dq.pyscripts/run_frontend_image_path_lineage_trace.pyscripts/run_dim_picture_enum_gap_report.pyscripts/run_platform_null_root_cause_probe.pyscripts/run_platform_path_layer_scan.pyscripts/run_sample_table01_platform_null_diagnosis.pyscripts/run_pic_backup_video_content_type_probe.py
SQL / lineage / field-source diagnostics
scripts/build_sql_data_map.pyscripts/run_total_hours_sql_field_probe.py
Bundled References
Read only the ones needed for the current question:
references/data-quality-report-20260408.mdreferences/table124-quality-report-20260416.jsonreferences/current-version-image-source-dq-report-20260416.jsonreferences/four-table.pdfreferences/total-hours-field-mapping-report.pdf
Output Standard
A good result should include:
1. execution summary 2. target grain and key definition 3. structural findings 4. value-level findings 5. lineage/bridge findings 6. root-cause classification 7. representative samples 8. remediation priority 9. explicit uncertainty or counterarguments
Self-Critique Checklist
Before finalizing, challenge your own diagnosis:
- Is the null actually invalid, or merely not applicable?
- Is the enum gap a bug, or a legitimate new business category?
- Is the issue in the source, or just in the target-field semantics?
- Is the sample biased toward recent data only?
- Does the lineage explain "where from" but not yet "why wrong"?
If those questions are unanswered, keep the diagnosis provisional.
{
"generated_at": "2026-04-16 10:57:37",
"sql_path": "/Users/windwheel/.copaw/workspaces/current-version.image-update.sql",
"source_table": "data_dws.dws_platform_file_resource_label_id",
"target_table": "data_dim.dim_picture_material_data_enriched",
"source_total_rows": 6402925,
"schema_columns": [
"file_type",
"file_id",
"platform",
"brand",
"create_time",
"full_path",
"concatenated_path",
"video_duration_type",
"video_wide_range",
"picture_size",
"picture_wear",
"spu",
"product_name",
"big_cate",
"mid_cate",
"sub_track",
"gender",
"scene",
"style",
"title",
"platform_source_id",
"good_ids",
"author",
"big_tag",
"middle_tag",
"publish_date",
"picture_type",
"resolution",
"file_name",
"width",
"height"
],
"projection_notes": [
"本报告不直接扫目标表,而是先还原 current-version.image-update.sql 的图片投影逻辑,再对投影结果做质检。",
"若 shared_folder_path 中出现后缀,可能来自两类原因:一是图片子集真的混入了视频或异常文件;二是 SQL 把完整 full_path 写进了 folder 语义字段。",
"抽样均按近期 image_update_time 优先,目的是先验证当前正在影响业务使用的数据,而不是只看历史脏样本。",
"本报告默认图片子集定义为源表 file_type = '图片',与 current-version.image-update.sql 保持一致。",
"实时源表 schema 缺少 data_update_time, folder_path, image_modify_time;相关口径在当前实库环境下会表现为全量回退或全量为空。"
],
"freshness": [
[
"create_time",
"2012-07-02 09:52:14",
"2026-04-14 19:04:18"
],
[
"data_update_time",
"",
""
],
[
"image_modify_time",
"",
""
],
[
"image_update_time",
"",
""
]
],
"health_metrics": [
{
"metric": "full_path_std 非空",
"row_count": 6402925,
"row_rate_pct": 100.0,
"meaning": "图片投影后是否能得到标准化完整路径"
},
{
"metric": "使用 folder_path + file_name 拼接",
"row_count": 0,
"row_rate_pct": 0.0,
"meaning": "结构化路径字段可用,能直接反映目录与文件名"
},
{
"metric": "回退到 full_path",
"row_count": 6402925,
"row_rate_pct": 100.0,
"meaning": "folder_path/file_name 不全,只能依赖原始 full_path"
},
{
"metric": "full_path 与 file_name 基名冲突",
"row_count": 6297456,
"row_rate_pct": 98.35,
"meaning": "源表同一条记录存在路径/文件名不一致"
},
{
"metric": "image_update_time 非空",
"row_count": 0,
"row_rate_pct": 0.0,
"meaning": "最终图片更新时间字段可稳定回填"
},
{
"metric": "目标 folder 字段将带后缀",
"row_count": 6402925,
"row_rate_pct": 100.0,
"meaning": "当前 SQL 会把完整路径写入 image_share_folder_path,语义上存在错位"
},
{
"metric": "file_id 去重后行数",
"row_count": 6350843,
"row_rate_pct": 99.19,
"meaning": "用于判断 file_id 是否存在重复污染"
},
{
"metric": "platform_source_id 去重后行数",
"row_count": 0,
"row_rate_pct": 0.0,
"meaning": "用于判断平台素材主键是否存在重复污染"
}
],
"extension_distribution": [
{
"path_ext_std": "jpg",
"path_suffix_family": "image",
"row_count": 6081651
},
{
"path_ext_std": "png",
"path_suffix_family": "image",
"row_count": 292164
},
{
"path_ext_std": "jpeg",
"path_suffix_family": "image",
"row_count": 20397
},
{
"path_ext_std": "mp4",
"path_suffix_family": "video",
"row_count": 8477
},
{
"path_ext_std": "gif",
"path_suffix_family": "image",
"row_count": 222
},
{
"path_ext_std": "jpe",
"path_suffix_family": "other",
"row_count": 12
},
{
"path_ext_std": "bmp",
"path_suffix_family": "image",
"row_count": 2
}
],
"issue_results": [
{
"code": "TARGET_FOLDER_FIELD_SUFFIX_RISK",
"title": "目标 shared_folder_path 字段语义错位",
"severity": "high",
"row_count": 6402925,
"row_rate_pct": 100.0,
"business_meaning": "当前 SQL 会把 full_path_std 填到 image_share_folder_path;只要路径带文件名,目标字段就会出现后缀,业务会误以为 folder_path 不纯。",
"sample_basis": "按 image_update_time 倒序抽样,观察目标字段将出现怎样的后缀表现。",
"diagnosis_hint": "这类问题不等同于源表污染,更像目标字段语义与 SQL 赋值口径不一致。",
"diagnosis": "命中 6402925 行,占图片子集 100.00%。这说明即便源表本身是图片,当前 SQL 也会把完整文件路径写进 folder 语义字段,业务看到后缀并不意外。",
"samples": [
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片O2O20260414AKLW103-1AKLW103-1_02.jpg",
"folder_path": "",
"file_name": "AKLW103-1_06",
"full_path_std": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片O2O20260414AKLW103-1AKLW103-1_02.jpg",
"file_name_std": "AKLW103-1_06",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AKLW103"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库李宁童装YTSW193灰底素材图YTSW193-1-MXK-WHITE-2.jpg",
"folder_path": "",
"file_name": "YTSW193-2-MXK-WHITE-11",
"full_path_std": "h3c.skomart.netmedia李宁素材库李宁童装YTSW193灰底素材图YTSW193-1-MXK-WHITE-2.jpg",
"file_name_std": "YTSW193-2-MXK-WHITE-11",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "YTSW193"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片20264月0414APLU256-3APLU256-3_08.jpg",
"folder_path": "",
"file_name": "APLU256-1_04",
"full_path_std": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片20264月0414APLU256-3APLU256-3_08.jpg",
"file_name_std": "APLU256-1_04",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "APLU256"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "唯品会",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OKAHSW119-5-MXK-DIAOPAI-Z(460).jpg",
"folder_path": "",
"file_name": "AHSW119-6-MXK-DIAOPAI-Z(460)",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OKAHSW119-5-MXK-DIAOPAI-Z(460).jpg",
"file_name_std": "AHSW119-6-MXK-DIAOPAI-Z(460)",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AHSW119"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OK唯品会YKFW004-7-MXK-DIAOPAI-Z(460).jpg",
"folder_path": "",
"file_name": "YKFW004-7-MXK-DIAOPAI-Z(460)",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OK唯品会YKFW004-7-MXK-DIAOPAI-Z(460).jpg",
"file_name_std": "YKFW004-7-MXK-DIAOPAI-Z(460)",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "YKFW004"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库22025ARXV004主图SKU搜索主图ARXV004-SKU搜索主图-assetsARXV004-3.jpg",
"folder_path": "",
"file_name": "ARXV004-3",
"full_path_std": "h3c.skomart.netmedia李宁素材库22025ARXV004主图SKU搜索主图ARXV004-SKU搜索主图-assetsARXV004-3.jpg",
"file_name_std": "ARXV004-3",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ARXV004"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "唯品会",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库李宁童装YPLW037主图YPLW037-主图-assets天猫主图竖YPLW037-2-MXK-SELL-4.jpg",
"folder_path": "",
"file_name": "YPLW037-2-MXK-SELL-3",
"full_path_std": "h3c.skomart.netmedia李宁素材库李宁童装YPLW037主图YPLW037-主图-assets天猫主图竖YPLW037-2-MXK-SELL-4.jpg",
"file_name_std": "YPLW037-2-MXK-SELL-3",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "YPLW037"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图(补上)20264月商品内页ATSW137-8ATSW137-8_07.jpg",
"folder_path": "",
"file_name": "ATSW137-8_07",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图(补上)20264月商品内页ATSW137-8ATSW137-8_07.jpg",
"file_name_std": "ATSW137-8_07",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ATSW137"
}
]
},
{
"code": "VIDEO_SUFFIX_IN_IMAGE_SET",
"title": "图片集混入视频后缀",
"severity": "high",
"row_count": 8477,
"row_rate_pct": 0.13,
"business_meaning": "这类记录会直接把 .mp4/.mov 等后缀带入目标图片表,是业务抱怨最强的一类硬证据。",
"sample_basis": "按 image_update_time 倒序抽样,优先看最近进入链路的数据。",
"diagnosis_hint": "若命中,说明源表 file_type='图片' 子集内已经混入视频资源或视频路径。",
"diagnosis": "命中 8477 行,占图片子集 0.13%。这可以直接佐证“图片链路混入视频路径”的业务直觉。",
"samples": [
{
"platform_source_id": "",
"file_id": "ffff17a6dd8dabbab6f298001fe5ec8c",
"platform": "",
"title": "",
"file_type": "图片",
"full_path": "192.168.2.153media历史资料2李宁素材库2021AGCR267视频AGCR267.mp4",
"folder_path": "",
"file_name": "AGCR267",
"full_path_std": "192.168.2.153media历史资料2李宁素材库2021AGCR267视频AGCR267.mp4",
"file_name_std": "AGCR267",
"path_ext_std": "mp4",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AGCR267"
},
{
"platform_source_id": "",
"file_id": "fffe16c0f4802be5761c2d35f74f6120",
"platform": "",
"title": "",
"file_type": "图片",
"full_path": "192.168.2.153media历史资料2李宁素材库2021AZGS043视频AZGS043 已改.mp4",
"folder_path": "",
"file_name": "AZGS043 已改",
"full_path_std": "192.168.2.153media历史资料2李宁素材库2021AZGS043视频AZGS043 已改.mp4",
"file_name_std": "AZGS043 已改",
"path_ext_std": "mp4",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AZGS043"
},
{
"platform_source_id": "",
"file_id": "fff1ea421128a8fc4724fac4c7bcd784",
"platform": "抖音",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia商品图片存储ZJB抖音主图完成-NK斯凯奇成人上新图-11.28177565177565-GYMT.mp4",
"folder_path": "",
"file_name": "177565-GYMT",
"full_path_std": "h3c.skomart.netmedia商品图片存储ZJB抖音主图完成-NK斯凯奇成人上新图-11.28177565177565-GYMT.mp4",
"file_name_std": "177565-GYMT",
"path_ext_std": "mp4",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "斯凯奇",
"spu": "177565"
},
{
"platform_source_id": "",
"file_id": "ffed3ac7ca671885b4274355c5d1d9d5",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "192.168.2.153media历史资料2李宁素材库2022ABAS053视频ABAS053.mp4",
"folder_path": "",
"file_name": "ABAS053",
"full_path_std": "192.168.2.153media历史资料2李宁素材库2022ABAS053视频ABAS053.mp4",
"file_name_std": "ABAS053",
"path_ext_std": "mp4",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ABAS053"
},
{
"platform_source_id": "",
"file_id": "ffe475fbc61b4cc2785ae41a4a8b06f8",
"platform": "抖音",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia商品图片存储ZJB抖音主图完成-NK斯凯奇成人&奥莱店上新图片7.14167338167338-BBK_2.mp4",
"folder_path": "",
"file_name": "167338-BBK_2",
"full_path_std": "h3c.skomart.netmedia商品图片存储ZJB抖音主图完成-NK斯凯奇成人&奥莱店上新图片7.14167338167338-BBK_2.mp4",
"file_name_std": "167338-BBK_2",
"path_ext_std": "mp4",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "斯凯奇",
"spu": "167338"
},
{
"platform_source_id": "",
"file_id": "ffe3bafe47bcaf539fa8281d3066d2c5",
"platform": "抖音",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia商品图片存储ZJB抖音主图完成-NK斯凯奇成人&奥莱店上新图片7.145551955519-NVBL_2.mp4",
"folder_path": "",
"file_name": "55519-NVBL_2",
"full_path_std": "h3c.skomart.netmedia商品图片存储ZJB抖音主图完成-NK斯凯奇成人&奥莱店上新图片7.145551955519-NVBL_2.mp4",
"file_name_std": "55519-NVBL_2",
"path_ext_std": "mp4",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "斯凯奇",
"spu": "55519"
},
{
"platform_source_id": "",
"file_id": "ffc7279d16d2b5319eb80be0bcd90354",
"platform": "",
"title": "",
"file_type": "图片",
"full_path": "192.168.2.153media历史资料2李宁素材库2021AAYR061视频AAYR061.mp4",
"folder_path": "",
"file_name": "AAYR061",
"full_path_std": "192.168.2.153media历史资料2李宁素材库2021AAYR061视频AAYR061.mp4",
"file_name_std": "AAYR061",
"path_ext_std": "mp4",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AAYR061"
},
{
"platform_source_id": "",
"file_id": "ffc48244391434eee127c85160b55542",
"platform": "",
"title": "",
"file_type": "图片",
"full_path": "192.168.2.153media历史资料2李宁素材库2021AFDRA07视频AFDRA07.mp4",
"folder_path": "",
"file_name": "AFDRA07",
"full_path_std": "192.168.2.153media历史资料2李宁素材库2021AFDRA07视频AFDRA07.mp4",
"file_name_std": "AFDRA07",
"path_ext_std": "mp4",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AFDRA07"
}
]
},
{
"code": "MISSING_STANDARD_PATH",
"title": "标准化完整路径缺失",
"severity": "high",
"row_count": 0,
"row_rate_pct": 0.0,
"business_meaning": "连标准化后的路径都缺失,意味着目标表核心定位字段会直接空掉,业务无法回溯素材文件。",
"sample_basis": "按 file_id 倒序抽样,快速锁定完全缺路径的数据来源。",
"diagnosis_hint": "这类问题比字段语义错位更严重,属于关键主链路信息缺失。",
"diagnosis": "本次实时检测未命中该问题。",
"samples": []
},
{
"code": "FALLBACK_TO_FULL_PATH",
"title": "结构化路径字段缺失,只能回退 full_path",
"severity": "medium",
"row_count": 6402925,
"row_rate_pct": 100.0,
"business_meaning": "folder_path/file_name 不完整时,SQL 无法严格区分目录和文件名,会削弱路径治理和 folder 级分析。",
"sample_basis": "按 image_update_time 倒序抽样,定位最近缺结构化路径的来源记录。",
"diagnosis_hint": "这类问题会放大 shared_folder_path 的字段语义歧义。",
"diagnosis": "命中 6402925 行,占图片子集 100.00%。实时源表 schema 本身就没有 folder_path,所以 current-version.image-update.sql 在当前实库上只能 100% 回退 full_path。",
"samples": [
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片O2O20260414AKLW103-1AKLW103-1_02.jpg",
"folder_path": "",
"file_name": "AKLW103-1_06",
"full_path_std": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片O2O20260414AKLW103-1AKLW103-1_02.jpg",
"file_name_std": "AKLW103-1_06",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AKLW103"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库李宁童装YTSW193灰底素材图YTSW193-1-MXK-WHITE-2.jpg",
"folder_path": "",
"file_name": "YTSW193-2-MXK-WHITE-11",
"full_path_std": "h3c.skomart.netmedia李宁素材库李宁童装YTSW193灰底素材图YTSW193-1-MXK-WHITE-2.jpg",
"file_name_std": "YTSW193-2-MXK-WHITE-11",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "YTSW193"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片20264月0414APLU256-3APLU256-3_08.jpg",
"folder_path": "",
"file_name": "APLU256-1_04",
"full_path_std": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片20264月0414APLU256-3APLU256-3_08.jpg",
"file_name_std": "APLU256-1_04",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "APLU256"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "唯品会",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OKAHSW119-5-MXK-DIAOPAI-Z(460).jpg",
"folder_path": "",
"file_name": "AHSW119-6-MXK-DIAOPAI-Z(460)",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OKAHSW119-5-MXK-DIAOPAI-Z(460).jpg",
"file_name_std": "AHSW119-6-MXK-DIAOPAI-Z(460)",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AHSW119"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OK唯品会YKFW004-7-MXK-DIAOPAI-Z(460).jpg",
"folder_path": "",
"file_name": "YKFW004-7-MXK-DIAOPAI-Z(460)",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OK唯品会YKFW004-7-MXK-DIAOPAI-Z(460).jpg",
"file_name_std": "YKFW004-7-MXK-DIAOPAI-Z(460)",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "YKFW004"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库22025ARXV004主图SKU搜索主图ARXV004-SKU搜索主图-assetsARXV004-3.jpg",
"folder_path": "",
"file_name": "ARXV004-3",
"full_path_std": "h3c.skomart.netmedia李宁素材库22025ARXV004主图SKU搜索主图ARXV004-SKU搜索主图-assetsARXV004-3.jpg",
"file_name_std": "ARXV004-3",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ARXV004"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "唯品会",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库李宁童装YPLW037主图YPLW037-主图-assets天猫主图竖YPLW037-2-MXK-SELL-4.jpg",
"folder_path": "",
"file_name": "YPLW037-2-MXK-SELL-3",
"full_path_std": "h3c.skomart.netmedia李宁素材库李宁童装YPLW037主图YPLW037-主图-assets天猫主图竖YPLW037-2-MXK-SELL-4.jpg",
"file_name_std": "YPLW037-2-MXK-SELL-3",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "YPLW037"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图(补上)20264月商品内页ATSW137-8ATSW137-8_07.jpg",
"folder_path": "",
"file_name": "ATSW137-8_07",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图(补上)20264月商品内页ATSW137-8ATSW137-8_07.jpg",
"file_name_std": "ATSW137-8_07",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ATSW137"
}
]
},
{
"code": "MISSING_IMAGE_UPDATE_TIME",
"title": "图片更新时间缺失",
"severity": "medium",
"row_count": 6402925,
"row_rate_pct": 100.0,
"business_meaning": "最终图片表的 image_modify_time 为空,会影响业务追踪近期变更和重跑判断。",
"sample_basis": "按 file_id 倒序抽样,快速复核更新时间缺失的来源分布。",
"diagnosis_hint": "若命中较高,说明 data_update_time / image_modify_time 两个来源字段都不稳定。",
"diagnosis": "命中 6402925 行,占图片子集 100.00%。实时源表 schema 同时缺少 data_update_time 和 image_modify_time,因此投影后的 image_update_time 必然全空。",
"samples": [
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片O2O20260414AKLW103-1AKLW103-1_02.jpg",
"folder_path": "",
"file_name": "AKLW103-1_06",
"full_path_std": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片O2O20260414AKLW103-1AKLW103-1_02.jpg",
"file_name_std": "AKLW103-1_06",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AKLW103"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库李宁童装YTSW193灰底素材图YTSW193-1-MXK-WHITE-2.jpg",
"folder_path": "",
"file_name": "YTSW193-2-MXK-WHITE-11",
"full_path_std": "h3c.skomart.netmedia李宁素材库李宁童装YTSW193灰底素材图YTSW193-1-MXK-WHITE-2.jpg",
"file_name_std": "YTSW193-2-MXK-WHITE-11",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "YTSW193"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片20264月0414APLU256-3APLU256-3_08.jpg",
"folder_path": "",
"file_name": "APLU256-1_04",
"full_path_std": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片20264月0414APLU256-3APLU256-3_08.jpg",
"file_name_std": "APLU256-1_04",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "APLU256"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "唯品会",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OKAHSW119-5-MXK-DIAOPAI-Z(460).jpg",
"folder_path": "",
"file_name": "AHSW119-6-MXK-DIAOPAI-Z(460)",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OKAHSW119-5-MXK-DIAOPAI-Z(460).jpg",
"file_name_std": "AHSW119-6-MXK-DIAOPAI-Z(460)",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AHSW119"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OK唯品会YKFW004-7-MXK-DIAOPAI-Z(460).jpg",
"folder_path": "",
"file_name": "YKFW004-7-MXK-DIAOPAI-Z(460)",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OK唯品会YKFW004-7-MXK-DIAOPAI-Z(460).jpg",
"file_name_std": "YKFW004-7-MXK-DIAOPAI-Z(460)",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "YKFW004"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库22025ARXV004主图SKU搜索主图ARXV004-SKU搜索主图-assetsARXV004-3.jpg",
"folder_path": "",
"file_name": "ARXV004-3",
"full_path_std": "h3c.skomart.netmedia李宁素材库22025ARXV004主图SKU搜索主图ARXV004-SKU搜索主图-assetsARXV004-3.jpg",
"file_name_std": "ARXV004-3",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ARXV004"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "唯品会",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库李宁童装YPLW037主图YPLW037-主图-assets天猫主图竖YPLW037-2-MXK-SELL-4.jpg",
"folder_path": "",
"file_name": "YPLW037-2-MXK-SELL-3",
"full_path_std": "h3c.skomart.netmedia李宁素材库李宁童装YPLW037主图YPLW037-主图-assets天猫主图竖YPLW037-2-MXK-SELL-4.jpg",
"file_name_std": "YPLW037-2-MXK-SELL-3",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "YPLW037"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图(补上)20264月商品内页ATSW137-8ATSW137-8_07.jpg",
"folder_path": "",
"file_name": "ATSW137-8_07",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图(补上)20264月商品内页ATSW137-8ATSW137-8_07.jpg",
"file_name_std": "ATSW137-8_07",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ATSW137"
}
]
},
{
"code": "BASENAME_CONFLICT",
"title": "full_path 与 file_name 基名不一致",
"severity": "medium",
"row_count": 6297456,
"row_rate_pct": 98.35,
"business_meaning": "同一条记录的 full_path 与 file_name 指向不同文件名时,路径标准化结果会不稳定,业务难以信任目录和文件映射。",
"sample_basis": "按 image_update_time 倒序抽样,查看路径与文件名冲突的真实样本。",
"diagnosis_hint": "若命中,优先排查上游同步是否发生覆盖、截断或二次加工。",
"diagnosis": "命中 6297456 行,占图片子集 98.35%。若命中,优先排查上游同步是否发生覆盖、截断或二次加工。",
"samples": [
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库22026AUSW027主图官网主图AUSW027-1AUSW027-1-MXK-SELL-1.jpg",
"folder_path": "",
"file_name": "AUSW027-1-MXK-SELL-1",
"full_path_std": "h3c.skomart.netmedia李宁素材库22026AUSW027主图官网主图AUSW027-1AUSW027-1-MXK-SELL-1.jpg",
"file_name_std": "AUSW027-1-MXK-SELL-1",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AUSW027"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片O2O20260414AFYW001-3AFYW001-3_04.jpg",
"folder_path": "",
"file_name": "AFYW001-6_04",
"full_path_std": "h3c.skomart.netmedia商品图片存储详情页内容包装详情页重点包装商品设计爆款切片O2O20260414AFYW001-3AFYW001-3_04.jpg",
"file_name_std": "AFYW001-6_04",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AFYW001"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "抖音",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库22025AYKV490主图SKU搜索主图AYKV490-1.jpg",
"folder_path": "",
"file_name": "AYKV490-2_07",
"full_path_std": "h3c.skomart.netmedia李宁素材库22025AYKV490主图SKU搜索主图AYKV490-1.jpg",
"file_name_std": "AYKV490-2_07",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AYKV490"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库22026ABPW009特殊角度图ABPW009-11030300595 拷贝.jpg",
"folder_path": "",
"file_name": "030300635 拷贝",
"full_path_std": "h3c.skomart.netmedia李宁素材库22026ABPW009特殊角度图ABPW009-11030300595 拷贝.jpg",
"file_name_std": "030300635 拷贝",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ABPW009"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库22025ABAV045ABAV045-57ABAV045-57-MXK-WHITE-22(800x1200).jpg",
"folder_path": "",
"file_name": "ABAV045-57-MXK-WHITE-Z(1500)",
"full_path_std": "h3c.skomart.netmedia李宁素材库22025ABAV045ABAV045-57ABAV045-57-MXK-WHITE-22(800x1200).jpg",
"file_name_std": "ABAV045-57-MXK-WHITE-Z(1500)",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ABAV045"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片尺寸图唯品会20260414-17款(我司拍摄)移交样配ABSW345-1-MXK-WHITE-16.jpg",
"folder_path": "",
"file_name": "ABSW345-1-MXK-WHITE-9",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片尺寸图唯品会20260414-17款(我司拍摄)移交样配ABSW345-1-MXK-WHITE-16.jpg",
"file_name_std": "ABSW345-1-MXK-WHITE-9",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ABSW345"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia李宁素材库22026APLW221主图SKU搜索主图APLW221-2-MXK-SELL-1.jpg",
"folder_path": "",
"file_name": "APLW221-2-MXK-SELL-1",
"full_path_std": "h3c.skomart.netmedia李宁素材库22026APLW221主图SKU搜索主图APLW221-2-MXK-SELL-1.jpg",
"file_name_std": "APLW221-2-MXK-SELL-1",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "APLW221"
},
{
"platform_source_id": "",
"file_id": "",
"platform": "唯品会",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OKARNW009-3-MXK-DIAOPAI-Z(460).jpg",
"folder_path": "",
"file_name": "ARNW009-3-MXK-DIAOPAI-Z(460)",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图2019吊牌新规则更改后20264月0410OKARNW009-3-MXK-DIAOPAI-Z(460).jpg",
"file_name_std": "ARNW009-3-MXK-DIAOPAI-Z(460)",
"path_ext_std": "jpg",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "ARNW009"
}
]
},
{
"code": "UNKNOWN_SUFFIX_IN_IMAGE_SET",
"title": "图片集出现非图片非视频后缀",
"severity": "medium",
"row_count": 12,
"row_rate_pct": 0.0,
"business_meaning": "这类记录不一定是视频,但已经偏离图片链路的主流文件格式,需要人工判断是否为压缩包、中间件文件或脏路径。",
"sample_basis": "按 image_update_time 倒序抽样,优先验证近期新增异常格式。",
"diagnosis_hint": "常见于脏数据、目录名误写、临时文件或外部系统落盘异常。",
"diagnosis": "命中 12 行,占图片子集 0.00%。常见于脏数据、目录名误写、临时文件或外部系统落盘异常。",
"samples": [
{
"platform_source_id": "",
"file_id": "f96ea0b98241405d2125d501213227ad",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "192.168.2.153media历史资料2李宁素材库2022AGCS025AGCS025-5AGCS025-5-F.jpE",
"folder_path": "",
"file_name": "AGCS025-5-F",
"full_path_std": "192.168.2.153media历史资料2李宁素材库2022AGCS025AGCS025-5AGCS025-5-F.jpE",
"file_name_std": "AGCS025-5-F",
"path_ext_std": "jpe",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AGCS025"
},
{
"platform_source_id": "",
"file_id": "ee7aa85ed1fc593dda9710049a52cf43",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "192.168.2.153media历史资料2李宁素材库2022AGCS025AGCS025-5AGCS025-5-G.jpE",
"folder_path": "",
"file_name": "AGCS025-5-G",
"full_path_std": "192.168.2.153media历史资料2李宁素材库2022AGCS025AGCS025-5AGCS025-5-G.jpE",
"file_name_std": "AGCS025-5-G",
"path_ext_std": "jpe",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AGCS025"
},
{
"platform_source_id": "",
"file_id": "e918cff60b1dc0c1739807fc5ea56567",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图20223月0312-6件(小君来图)AYZN009-16AYZN009-16-G.jpE",
"folder_path": "",
"file_name": "AYZN009-16-G",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图20223月0312-6件(小君来图)AYZN009-16AYZN009-16-G.jpE",
"file_name_std": "AYZN009-16-G",
"path_ext_std": "jpe",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AYZN009"
},
{
"platform_source_id": "",
"file_id": "d2ec3cce06ef0a1bc86a825b845a1e16",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图20223月0312-6件(小君来图)AGCR196-6AGCR196-6-E.jpE",
"folder_path": "",
"file_name": "AGCR196-6-E",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图20223月0312-6件(小君来图)AGCR196-6AGCR196-6-E.jpE",
"file_name_std": "AGCR196-6-E",
"path_ext_std": "jpe",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AGCR196"
},
{
"platform_source_id": "",
"file_id": "cafabf3e2f026f5cbcef0952fc3a1355",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "192.168.2.153media历史资料2李宁素材库2022AGCS026AGCS026-2AGCS026-2-F.jpE",
"folder_path": "",
"file_name": "AGCS026-2-F",
"full_path_std": "192.168.2.153media历史资料2李宁素材库2022AGCS026AGCS026-2AGCS026-2-F.jpE",
"file_name_std": "AGCS026-2-F",
"path_ext_std": "jpe",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AGCS026"
},
{
"platform_source_id": "",
"file_id": "bde84094653dc271bb2adc32fc3e8bed",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "192.168.2.153media历史资料2李宁素材库2022AGCS026AGCS026-2AGCS026-2-E.jpE",
"folder_path": "",
"file_name": "AGCS026-2-E",
"full_path_std": "192.168.2.153media历史资料2李宁素材库2022AGCS026AGCS026-2AGCS026-2-E.jpE",
"file_name_std": "AGCS026-2-E",
"path_ext_std": "jpe",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AGCS026"
},
{
"platform_source_id": "",
"file_id": "8097ba8efc4a8ab2c44a877143d35bc5",
"platform": "天猫",
"title": "",
"file_type": "图片",
"full_path": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图20223月0312-6件(小君来图)AGCR196-6AGCR196-6-G.jpE",
"folder_path": "",
"file_name": "AGCR196-6-G",
"full_path_std": "h3c.skomart.netmedia代运营中心李宁组运营共享李宁图片小图20223月0312-6件(小君来图)AGCR196-6AGCR196-6-G.jpE",
"file_name_std": "AGCR196-6-G",
"path_ext_std": "jpe",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AGCR196"
},
{
"platform_source_id": "",
"file_id": "629c38743897ddad053d7db75b218b43",
"platform": "",
"title": "",
"file_type": "图片",
"full_path": "192.168.2.153media历史资料2李宁素材库2021AGCS069AGCS069-2AGCS069-2-F.jpE",
"folder_path": "",
"file_name": "AGCS069-2-F",
"full_path_std": "192.168.2.153media历史资料2李宁素材库2021AGCS069AGCS069-2AGCS069-2-F.jpE",
"file_name_std": "AGCS069-2-F",
"path_ext_std": "jpe",
"image_update_time": "",
"image_modify_time": "",
"data_update_time": "",
"brand": "李宁",
"spu": "AGCS069"
}
]
}
],
"key_findings": [
"实时源表 schema 缺少 data_update_time, folder_path, image_modify_time。这意味着 current-version.image-update.sql 对这些字段的依赖,在当前实库上并未真正具备。",
"图片子集里直接命中 8477 行视频后缀路径,占比 0.13%,可以直接支撑“图片表混入视频格式”的判断。",
"有 6402925 行在当前 SQL 下会把完整文件路径写入 image_share_folder_path,占比 100.00%。 这说明 shared_folder_path 出现后缀不只是数据脏,更有字段语义映射问题。",
"有 6402925 行缺少 folder_path 或 file_name,只能回退 full_path,占比 100.00%。 这会削弱基于目录的业务分析和核查。",
"最终图片更新时间覆盖率只有 0.00%,仍有 100.00% 的记录无法稳定用于增量追踪。"
],
"recommendations": [
"若业务字段语义上要求目录路径,建议把目标表 image_share_folder_path 改为接 folder_path,而把完整路径单独保留在 full_path。",
"在 current-version.image-update.sql 上游增加图片后缀白名单校验,至少拦截 .mp4/.mov 等视频后缀进入图片链路。",
"对 folder_path、file_name、full_path 建立一致性校验,避免路径与文件名互相矛盾。",
"把本脚本的异常样本抽样结果交给业务复核,先确认是否为真实图片素材,再决定是清洗还是改口径。",
"先补齐实时源表 schema:data_update_time, folder_path, image_modify_time。否则 SQL 中对应逻辑只能停留在设计稿,无法在实库稳定生效。"
]
}Data Quality Assessment Report
Assessed: 2026-04-08 Platform: TencentCloud DLC SparkSQL Database Layer: data_ods / data_ads / data_dim Domain: Marketing Content (抖音电商内容营销) Assessment Method: Schema profiling via DLC SDK (Phase 1 Discovery)
---
Executive Summary
Assessment Scope: 5 tables across 3 database layers (ODS, ADS, DIM) in the marketing content domain. All tables store Douyin (抖音) video marketing data, e-commerce gravity tasks, shop normalization info, and image file metadata.
Overall Finding: These tables exhibit systemic data quality risks that require immediate attention. The most critical issues are:
1. Every column across all 5 tables is nullable — zero NOT NULL constraints 2. Severe type mismatch — numeric metrics stored as string type instead of numeric types 3. No column comments on 3 of 5 tables (orphan column semantics) 4. Questionable column classification — metrics mislabeled as dimensions, temporal fields mislabeled as identifiers 5. Tiny table size for DIM reference data (100 rows) with potential PK uniqueness risk
Note: This assessment is based on schema-level profiling (Phase 1 Discovery per the DDRMAP skill). Row-level statistical analysis (null rates, outliers, dirty values) requires direct query access to the DLC warehouse, which was not available during this session. The findings below are structural and architectural in nature.
---
Table-by-Table Analysis
Table 1: data_ods.ods_rpa_douyin_compass_video
| Attribute | Value |
|---|---|
| Rows | 203,568 |
| Columns | 50 |
| Database Layer | ODS (Operational Data Store) |
| Purpose | 抖音罗盘视频数据 (Douyin Compass video metrics) |
Schema Profile
| Category | Count | Columns |
|---|---|---|
| Identifier | 9 | id, douyin_unique_id, aweme_id, video_link, video_duration, video_create_time, product_id, local_video_url, implicit |
| Dimension | 8 | live_click_rate, finish_watch_rate, cart_type, account_type, time_type, avg_watch_duration, product_expose_click_rate, product_click_pay_rate, product_expose_pay_rate |
| Measure | 24 | pay_amt, watch_cnt, live_flow_cnt, other_user_pay_amt, like_cnt, comment_cnt, collect_cnt, share_cnt, follow_click_cnt, product_expose_cnt, product_click_cnt, user_pay_amt, order_cnt, pay_user_cnt, refund_amt, refund_order_cnt, product_thousand_expose_pay_amt, ad_cost, live_user_pay_amt, live_expose_cnt, live_order_cnt, live_thousand_expose_pay_amt, after_search_pay_amt, after_search_expose_cnt, after_search_click_cnt, after_search_order_cnt, shop_page_pay_amt, shop_page_order_cnt, other_order_cnt |
| Descriptive | 3 | shop_name, nick_name, title |
| Flag | 1 | is_ad |
| Temporal | 2 | update_time, create_time |
Critical Findings
[CRITICAL] C1.1 — Rate/percentage fields stored as STRING type
- Columns:
live_click_rate,finish_watch_rate,product_expose_click_rate,product_click_pay_rate,product_expose_pay_rate,avg_watch_duration - Impact: Cannot perform numeric aggregation, comparison, or filtering without CAST. Downstream BI tools will fail on SUM/AVG. RPA scraping dumps raw strings, but the ODS layer should normalize.
- Remediation: Add ETL step to CAST to
DECIMAL(10,4)orDOUBLE. Validate that values are parseable numbers.
[CRITICAL] C1.2 — `video_duration` classified as identifier, should be measure/dimension
- The column stores video duration (a numeric metric), but was classified as
identifierbased on name heuristics. - Impact: May be excluded from numeric quality checks. Duration analysis (avg watch time correlation) requires numeric treatment.
[CRITICAL] C1.3 — `is_ad` is STRING type for a boolean flag
- Stores "是否投放" (whether ad was placed) — inherently boolean.
- Impact: Risk of dirty values ("是"/"否"/"1"/"0"/"true"/"true"/null). No enum constraint.
- Remediation: Validate distinct values. Map to BOOLEAN in downstream layers.
[WARNING] W1.1 — All 50 columns are nullable
- Zero NOT NULL constraints on any column, including
id,aweme_id,create_time. - Impact: Primary key
idcould be null, making dedup impossible. Mandatory business fields have no enforcement.
[WARNING] W1.2 — `video_create_time` classified as identifier, not temporal
- This is a timestamp (视频发布时间). Misclassification means temporal consistency checks may skip it.
[WARNING] W1.3 — Duplicate semantics: `pay_amt` vs `user_pay_amt` vs `live_user_pay_amt`
- Three columns for "payment amount" with different scopes. Risk of misuse in downstream aggregations if consumers pick the wrong one.
[INFO] I1.1 — No column comments on all 50 columns
- All comments are empty strings. ODS tables should document the source system and field mapping.
---
Table 2: data_ods.ods_rpa_efficient_and_high_salary_douyin_video_df
| Attribute | Value |
|---|---|
| Rows | 5,114,783 |
| Columns | 21 |
| Database Layer | ODS |
| Purpose | 高效高薪抖音视频数据 (RPA-crawled Douyin video performance) |
Schema Profile
| Category | Count | Columns |
|---|---|---|
| Identifier | 1 | id |
| Descriptive | 3 | title, live_room_name, shop_name |
| Dimension | 16 | publish_time, genre, status, play_count, finish, finish5s, cover_click_ratio, skip2s, play_avg_time, like_count, share_count, comment_count, collect_count, visit_count, increase_fans_count, update_time, dt |
| Measure | 0 | — |
| Temporal | 0 | — |
Critical Findings
[CRITICAL] C2.1 — ALL metric columns stored as STRING type
- Columns:
play_count,finish,finish5s,cover_click_ratio,skip2s,play_avg_time,like_count,share_count,comment_count,collect_count,visit_count,increase_fans_count - These are all numeric metrics (播放量, 完播率, 点赞数, etc.) but stored as strings.
- Impact: 5.1M rows of metrics that cannot be directly aggregated, sorted numerically, or used in statistical analysis without CAST. This is the largest table in the assessment and the type mismatch affects the most rows.
[CRITICAL] C2.2 — `publish_time` is STRING type, not TIMESTAMP
- The video publish time is stored as string. Temporal queries (date range, recency) require parsing.
- Impact: Cannot use DATE functions directly. Risk of inconsistent date formats across 5.1M rows.
[CRITICAL] C2.3 — `dt` partition column is STRING type
- Standard partition column should be DATE type. STRING partitions may have format inconsistencies ("2026-04-08" vs "20260408" vs "2026/04/08").
[WARNING] W2.1 — No measure columns classified — everything is dimension
- The auto-classifier treated all STRING-typed numeric columns as dimensions. This means 0 columns receive numeric quality checks (outlier detection, range validation).
- Impact: Data quality tooling will skip the most important validation step for the largest table.
[WARNING] W2.2 — All 21 columns nullable, including `id`
- Same systemic issue as Table 1.
[WARNING] W2.3 — No column comments on any field
- All 21 columns have empty comments. No documentation of what "finish", "finish5s", "skip2s" mean.
[INFO] I2.1 — `status` column: unknown enum values
- Without data access, cannot verify if status values conform to expected set.
---
Table 3: data_ads.ads_dewu_gravity_task_df
| Attribute | Value |
|---|---|
| Rows | 3,903,537 |
| Columns | 35 |
| Database Layer | ADS (Application Data Store) |
| Purpose | 得物引力任务数据 (Dewu gravity/attraction task data) |
Schema Profile
| Category | Count | Columns |
|---|---|---|
| Identifier | 5 | parent_task_id, task_id, video_duration, video_avg_play_time, mid_category, guide_goods_fav_cnt |
| Dimension | 21 | data_date, right_sku, task_published_datetime, task_promote_type, task_mode, task_status, task_finished_datetime, task_amount_2, author, dynamic_published_datetime, play_ratio_3s, fullchainordcnt_2, fullchaingmv_2, time_type, sku, attribute_14d_gmv, attribute_14d_order, clothes_exposure_num, clothes_read_num, clothes_order_num |
| Measure | 5 | read_volume, visit_volume, page_viewer_volume, interact_volume, detail_access_amt, dynamic_click_rate, guide_detial_rate |
| Descriptive | 2 | shop_name, task_name, dynamic_link |
Critical Findings
[CRITICAL] C3.1 — Revenue/metric columns stored as STRING in an ADS layer
- Columns:
task_amount_2,fullchainordcnt_2,fullchaingmv_2,attribute_14d_gmv,attribute_14d_order,clothes_exposure_num,clothes_read_num,clothes_order_num - This is the ADS (application) layer, the final serving layer for BI dashboards. Storing GMV, order counts, and exposure metrics as strings is a fundamental design flaw.
- Impact: Dashboard queries must CAST at read time. Performance degradation. Risk of parse failures.
[CRITICAL] C3.2 — All datetime fields are STRING type
- Columns:
data_date,task_published_datetime,task_finished_datetime,dynamic_published_datetime - None are TIMESTAMP or DATE type.
- Impact: Cannot do temporal consistency checks (e.g., task_finished > task_published). Cannot partition efficiently.
[CRITICAL] C3.3 — `guide_detial_rate` — typo in column name
- "detial" should be "detail". If downstream code references the correct spelling, queries will fail silently.
[WARNING] W3.1 — Column classification errors
video_durationandvideo_avg_play_timeclassified asidentifier— these are measures.guide_goods_fav_cnt(引导商品收藏数) classified asidentifier— this is a measure.play_ratio_3sclassified asdimension— this is a ratio metric.- Impact: Quality checks will miss these columns.
[WARNING] W3.2 — `task_id` and `parent_task_id` — no PK constraint
- Task IDs should be unique primary keys. Without constraints, duplicate task IDs could corrupt aggregations.
[WARNING] W3.3 — `mid_category` classified as identifier
- This is a dimension (商品中类), not an identifier.
[INFO] I3.1 — `fullchaingmv_2` and `fullchainordcnt_2` — cryptic naming
- The
_2suffix suggests a versioned or derived metric. No comments explain the distinction.
---
Table 4: data_dim.dim_shop_normalized_info
| Attribute | Value |
|---|---|
| Rows | 100 |
| Columns | 5 |
| Database Layer | DIM (Dimension) |
| Purpose | 店铺规整维度表 (Normalized shop dimension table) |
Schema Profile
| Category | Count | Columns |
|---|---|---|
| Identifier | 1 | erp_shop_id |
| Dimension | 2 | platform, shop_nick |
| Descriptive | 2 | normalized_shop_name, brand_name |
Critical Findings
[CRITICAL] C4.1 — Only 100 rows for a dimension table
- A shop normalization dimension covering multiple platforms (抖音, 得物, etc.) with only 100 entries is extremely small.
- Risk: Either the coverage is incomplete, or this is a filtered/stale snapshot.
- Remediation: Verify against source system. Compare row count against expected shop count.
[WARNING] W4.1 — `erp_shop_id` nullable and not enforced as PK
- The shop ID is the natural primary key. If nullable, null shops will join incorrectly.
[WARNING] W4.2 — No unique constraint on `erp_shop_id`
- Duplicate shop IDs would cause fanout in downstream joins with fact tables.
[WARNING] W4.3 — `platform` column: potential enum consistency risk
- Values like "抖音"/"douyin"/"Douyin" or "得物"/"dewu" need to be consistent with how fact tables reference the platform.
[INFO] I4.1 — Small table, low statistical significance
- With only 100 rows, many statistical quality checks (outlier detection, distribution analysis) are not meaningful.
---
Table 5: data_ods.ods_t_image_file_information
| Attribute | Value |
|---|---|
| Rows | 278 |
| Columns | 22 |
| Database Layer | ODS |
| Purpose | 图片文件信息表 (Image file metadata) |
Schema Profile
| Category | Count | Columns |
|---|---|---|
| Identifier | 4 | id, file_id, width, height |
| Dimension | 11 | file_format, file_type, create_time, last_modify_time, config_staff, platform, store, live_room, spu, sku, data_fetch_time, data_update_time |
| Measure | 1 | file_size |
| Descriptive | 4 | folder_path, file_name, full_path, business_path |
| Flag | 1 | is_delete |
| Temporal | 0 | — |
Critical Findings
[CRITICAL] C5.1 — `width` and `height` classified as identifier, should be measure
- Image dimensions are numeric metrics. Classifying them as identifiers excludes them from range validation.
- Impact: Cannot detect corrupted images (width=0 or height=99999).
[CRITICAL] C5.2 — `create_time` and `last_modify_time` are STRING type, not TIMESTAMP
- Same temporal query issues as other tables.
[CRITICAL] C5.3 — `is_delete` is BOOLEAN type but nullable
- A soft-delete flag should default to
false. If nullable, queries filteringWHERE is_delete = falsewill miss null rows.
[WARNING] W5.1 — `width` and `height` are STRING type, not INT
- Image dimensions should be integer pixels. String storage prevents arithmetic (e.g., aspect ratio = width/height).
[WARNING] W5.2 — `file_size` is the only measure, and it's nullable
- File size should never be null for an existing file record.
[WARNING] W5.3 — Only 278 rows — very small ODS table
- May indicate a filtered snapshot, a test dataset, or an incomplete ETL load.
[INFO] I5.1 — `business_path` vs `folder_path` vs `full_path` — three path columns
- Three different path columns could cause confusion. Verify they serve distinct purposes.
---
Cross-Table Analysis
Systemic Issues
| Issue | Affected Tables | Severity |
|---|---|---|
| All columns nullable | All 5 | Critical |
| Numeric metrics stored as STRING | 4 of 5 (all except dim_shop) | Critical |
| No column comments | 3 of 5 | Warning |
| Temporal fields as STRING | 4 of 5 | Critical |
| Auto-classifier mislabels | All 5 | Warning |
| No PK/unique constraints | All 5 | Warning |
Data Flow Integrity
┌─────────────────────────┐
│ ods_rpa_douyin_compass │ (203K rows)
│ ods_rpa_efficient_... │ (5.1M rows)
│ ods_t_image_file_info │ (278 rows)
└───────────┬─────────────┘
│
┌───────────▼─────────────┐
│ ads_dewu_gravity_task │ (3.9M rows)
└───────────┬─────────────┘
│
┌───────────▼─────────────┐
│ dim_shop_normalized │ (100 rows)
└─────────────────────────┘Cross-table join risk: shop_name appears in ods_rpa_douyin_compass_video, ads_dewu_gravity_task_df, and ods_rpa_efficient_and_high_salary_douyin_video_df as a descriptive string. The DIM table uses shop_nick and normalized_shop_name. Without a shared erp_shop_id key in the fact tables, shop name matching will rely on fuzzy string matching, which is error-prone.
`sku` field appears in both `ads_dewu_gravity_task_df` and `ods_t_image_file_information` — potential join path, but no foreign key validation possible.
---
DDRMAP Dimension Scores (Schema-Level Assessment)
Since row-level data was not accessible, these scores reflect structural/architectural quality only. They represent "design quality" rather than "data quality."
| Dimension | Score | Rationale |
|---|---|---|
| Completeness | 40/100 | All columns nullable. No mandatory field enforcement. Missing column comments on 3 tables. |
| Accuracy | 50/100 | Type mismatches (string for numeric) make accuracy validation structurally impossible without CAST. Auto-classifier mislabels hide columns from quality checks. |
| Consistency | 45/100 | Shop name used as join key across tables but no normalization key. Column naming inconsistent (_2 suffixes, typos like "detial"). |
| Conformity | 35/100 | Temporal fields not using TIMESTAMP type. Numeric fields stored as STRING. Boolean flags as STRING. Partition columns as STRING. |
| Integrity | 50/100 | No PK constraints. No FK constraints. No referential integrity enforcement. Row counts seem reasonable but unverified. |
| Timeliness | 60/100 | ODS tables have update_time/create_time columns. ADS table has data_date. But all are STRING type, preventing freshness SLA calculations. |
Composite Score: 46/100 — Critical
This score reflects structural/architectural quality only. Actual row-level quality (null rates, outlier detection, dirty values) requires direct query access to the DLC warehouse and was not performed in this session.
---
Prioritized Recommendations
Immediate (Critical — fix this week)
1. Add NOT NULL constraints on primary key columns
ods_rpa_douyin_compass_video.idods_rpa_efficient_and_high_salary_douyin_video_df.idads_dewu_gravity_task_df.task_iddim_shop_normalized_info.erp_shop_idods_t_image_file_information.file_id
2. Convert STRING metric columns to numeric types in ETL
- All rate/percentage columns: CAST to
DECIMAL(10,4) - All count columns: CAST to
BIGINT - All amount columns: keep as
DECIMAL(10,2)(already correct in some tables)
3. Convert STRING temporal columns to TIMESTAMP/DATE
publish_time,dt,data_date,task_published_datetime,task_finished_datetime,dynamic_published_datetime,create_time,last_modify_time,data_fetch_time,data_update_time
4. Fix typo: `guide_detial_rate` → `guide_detail_rate`
This Sprint (Warning)
5. Add column comments to all tables, especially ODS tables with empty comments 6. Validate `is_ad` enum values — ensure only valid boolean representations 7. Add unique constraints on task_id, erp_shop_id, file_id (at ETL level if not at DDL level) 8. Reclassify columns — fix the auto-classifier so rate fields, dimensions, and measures are correctly categorized 9. Verify `dim_shop_normalized_info` coverage — 100 rows seems too small for a multi-platform dimension
Backlog (Info)
10. Standardize shop_name — add erp_shop_id to fact tables to enable proper dimension joins 11. Resolve triple-path columns in ods_t_image_file_information (folder_path, full_path, business_path) 12. Document `_2` suffix columns in ads_dewu_gravity_task_df 13. Consider adding `dt` partition to ods_rpa_douyin_compass_video and ods_t_image_file_information for partition pruning
---
Recommended Quality Monitoring Queries
Run these queries on the DLC warehouse to complete the assessment:
-- 1. Null rate on primary key columns
SELECT
'ods_rpa_douyin_compass_video.id' AS check,
SUM(CASE WHEN id IS NULL THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS null_rate
FROM data_ods.ods_rpa_douyin_compass_video;
-- 2. Duplicate PK check
SELECT task_id, COUNT(*) AS cnt
FROM data_ads.ads_dewu_gravity_task_df
GROUP BY task_id
HAVING COUNT(*) > 1
LIMIT 20;
-- 3. String-to-numeric parse validation
SELECT
play_count,
COUNT(*) AS cnt
FROM data_ods.ods_rpa_efficient_and_high_salary_douyin_video_df
WHERE play_count IS NOT NULL
AND play_count NOT RLIKE '^-?[0-9]+\\.?[0-9]*$'
GROUP BY play_count
ORDER BY cnt DESC
LIMIT 20;
-- 4. is_ad enum validation
SELECT is_ad, COUNT(*) AS cnt
FROM data_ods.ods_rpa_douyin_compass_video
GROUP BY is_ad
ORDER BY cnt DESC;
-- 5. Temporal consistency
SELECT COUNT(*) AS future_dates
FROM data_ods.ods_rpa_douyin_compass_video
WHERE video_create_time > CURRENT_TIMESTAMP();
-- 6. DIM coverage check
SELECT platform, COUNT(DISTINCT erp_shop_id) AS shop_count
FROM data_dim.dim_shop_normalized_info
GROUP BY platform;---
Assessment Limitations
1. No row-level data access — This assessment was performed via schema metadata only. Null rates, outlier detection, dirty value analysis, and statistical profiling were not computed. 2. Auto-classifier confidence — All column classifications are marked assumed (heuristic-based), not confirmed (human-verified). 3. No historical data — Cannot assess data freshness, partition gaps, or row count trends without time-series data. 4. Cross-table joins unverified — Referential integrity between tables could not be validated without query execution.
Recommended next step: Execute the monitoring queries above via DLC query console, then re-run the full DDRMAP assessment with row-level data.
---
Report generated by data-quality-test skill (DDRMAP framework) Assessment timestamp: 2026-04-08T11:00+08:00 Platform: TencentCloud DLC SparkSQL (ap-shanghai)
数据质量检测 Skill
1. Skill 定义
这个 skill 用来回答一个更底层的问题:
数据为什么“不可信”,到底是源头脏了、加工错了、桥接断了、口径歪了,还是前台把一个本来正确的值解释错了。
它不是单纯跑空值率,而是把“应然模型”和“实然数据”逐层对齐,然后给出可落地的根因拆解与治理优先级。
2. 扫描后提炼出的第一性原理
从已扫描的 Python 脚本与 PDF/Markdown 报告看,这个仓库里的高质量数据质检都遵循同一个底层逻辑:
1. 数据质量不是字段本身的属性,而是“数据是否还能稳定支撑业务决策”的属性。 2. 一条异常不能只被描述成“有问题”,必须被定位到具体断点:
- 源表污染
- SQL 映射错位
- 维度桥接缺失
- 粒度设计错误
- 枚举规则缺口
- 前台展示/接口格式化偏差
3. 任何单表结论都不够,必须做跨层核对:
- ODS 是否脏
- DWD/DWS 是否放大了脏值
- DIM/ADS 是否把脏值固化为“正式事实”
4. “高填充率”不等于“高质量”,很多字段恰恰是 100% 填充但语义混写。 5. 质量检测必须带样本,否则结论不可辩护。
3. 这个 Skill 的证据基础
这个 skill 主要抽象自以下资产:
- 结构与通用质检
data-warehouse/data-quality-report/run_quality_checks.pydata-warehouse/data-quality-report/run_7table_quality.pydata-warehouse/data-quality-report/data-quality-report-20260408.md- 规划对照与桥接表质检
data-warehouse/data-quality-report/run_table124_quality_report.pyfour-table.pdfdata-warehouse/data-quality-report/table124-quality-report-20260416.json- 图片链路质检
data-warehouse/data-quality-report/run_current_version_image_source_dq.pydata-warehouse/data-quality-report/run_dim_picture_enum_gap_report.pydata-warehouse/data-quality-report/run_frontend_image_path_lineage_trace.pydata-warehouse/data-quality-report/current-version-image-source-dq-report-20260416.json- 平台空值与路径根因
run_platform_null_root_cause_probe.pyrun_platform_path_layer_scan.pyrun_sample_table01_platform_null_diagnosis.pyrun_pic_backup_video_content_type_probe.py- 血缘与 SQL 结构补证
build_sql_data_map.pyrun_total_hours_sql_field_probe.pytotal-hours-field-mapping-report.pdf
4. 适用场景
- 想知道某张表能不能作为正式分析底表。
- 业务说“这个字段不对”,但还不知道错在源头还是加工。
- 同一个逻辑字段在不同表里看起来都有,想判定哪个才是可信口径。
- 图片/视频/订单/商品链路里出现空值、脏枚举、路径错位、桥接失败、血缘不清。
- 要输出能给业务、研发、数据仓库三方同时看的诊断报告。
5. 输入要求
- 至少有一个待检对象:
- 目标表
- 目标 SQL
- 目标报表
- 前台异常样例
- 最好同时具备三类参照:
- 规划文档或 PDF
- 运行中的表 schema / 行级数据
- 上下游 SQL / 血缘关系
6. 标准工作流
Step 1. 先定义“应然合同”
不要先跑 SQL,先定义这张表理论上应该满足什么。
合同通常来自四类来源:
- 规划稿/业务 PDF
- Excel 字段设计
- 建表 SQL
- 下游消费口径
先明确:
- 表的目标粒度是什么
- 核心主键是什么
- 哪些字段是必须可用的
- 哪些字段只是增强信息
- 哪些字段是派生指标,不应该被误当作物理字段
典型例子:
four-table.pdf把file_id + platform_source_id + sku定义成桥接核心。run_taobao_live_orders_core_dimension_report.py明确pay_cnt应视为派生口径,不是物理列。
Step 2. 再确认“实然对象”
不能只信规划表名,要确认真实运行表。
要检查:
- 表是否存在
- 列是否存在
- 真实列名是否和规划一致
- 同名字段是否被换源
- 结果表不存在时,是否要回退到源表核验
这一步在仓库里反复出现:
run_content_core_dimension_report.py会先探测结果表,再降级到源表。run_total_hours_sql_field_probe.py会跨库搜索候选表。
Step 3. 六层质检
3.1 结构层
- 列是否齐全
- 类型是否合理
- 注释是否缺失
- 是否全字段可空
- 关键字段是否被错误建成 string
3.2 值层
- 空值率
- 空串率
- 脏值
- 控制字符
- 过长值
- 路径后缀异常
- 文件名与路径基名冲突
3.3 主键与粒度层
- 主键是否唯一
- 是否一文件多行 / 一订单多行 / 一平台实例多行
- 去重后行数与总行数差多少
3.4 枚举与分布层
- 枚举全集是否合理
- 是否混入流程词、测试词、临时目录名
- target/ref 枚举是否一致
- 哪些枚举只在一侧存在
3.5 时序与新鲜度层
- 最早/最晚时间
- 是否有未来时间
- 是否有明显脏时间
- 是否存在“字段存在但实时库全空”
3.6 跨层与血缘层
- 上游能否对上
- 中间桥接是否断裂
- 下游是否把完整路径写进 folder 语义字段
- 前台异常是否只是展示层格式化造成
7. 根因推理框架
拿到异常后,按下面顺序判断,不要跳步。
A. 如果源表已经脏
特征:
- ODS 就出现视频后缀、错枚举、路径缺平台、时间脏值
结论:
- 这是源污染,DWD/DWS/DIM 只是继承或放大
B. 如果源表不脏,但目标语义错位
特征:
- 字段本身有值
- 但 SQL 把
full_path写进folder_path一类语义字段
结论:
- 这是加工映射错误,不是源污染
run_current_version_image_source_dq.py 就是这种典型。
C. 如果值缺失只发生在桥接层
特征:
- 上游两边都有值
- 中间
platform_source_id/sku/weight_factor这类桥接键丢失
结论:
- 是关联设计或桥接表建设不完整
D. 如果 target/ref 枚举全集不一致
特征:
- 一边有,一边没有
- 且来源层与结果层是否同步缺失可进一步拆根因
结论:
- 可能是:
- 规则未覆盖
- 来源周期没有样本
- 结果层聚合丢值
E. 如果前台值异常、数据库值正常
特征:
- 去分隔符、URL decode 后同源
- 只有前台多了
///、%20
结论:
- 更像接口/展示格式化问题
8. 输出物标准
一个合格的输出必须同时包含:
- 结论摘要
- 关键指标
- 根因分桶
- 代表样本
- 自我反证
- 治理建议
建议输出格式:
1. 执行摘要 2. 检查范围与粒度定义 3. 结构问题 4. 值问题 5. 关系/血缘问题 6. 根因拆解 7. 样本 8. 修复优先级 9. 反思与剩余不确定性
9. 高价值检查清单
优先检查以下问题,因为它们在已扫描资产里反复出现:
- string 类型承载金额、比率、数量
- 主键不唯一
- 平台字段空值但路径里其实有平台信息
- 图片集合混入视频后缀
folder_path/full_path/file_name语义错位- 结果字段取错来源列
- 枚举标准值和 reference 值集合不一致
- 规划文档要求的桥接字段在实表中缺失
10. 自我辩证与反思
这个 skill 也有边界,必须主动反驳自己:
1. 空值不一定是坏数据,也可能是真正的不适用。 2. 枚举差集不一定意味着错误,也可能代表新业务。 3. 单次抽样很容易被近期增量偏差误导。 4. 结果层异常不一定来自最近 SQL,也可能是历史存量没回刷。 5. 血缘图能说明“从哪来”,但不自动说明“为什么错”。 6. 如果只看统计不看样本,容易把语义问题误诊成分布问题。 7. 如果只看规划文档不看实库,容易把“未落地设计”误当成“线上回归”。
11. 一句话使用法
先定义应然合同,再核对运行对象,然后按“结构-值-粒度-枚举-时序-血缘”六层递进检查,最后把异常强制归入“源污染 / 映射错位 / 桥接缺失 / 规则缺口 / 展示偏差”五类根因之一。
#!/usr/bin/env python3
"""Comprehensive quality checks for 7 tables: missing rates, dirty values, outliers."""
import os, json, time, base64
os.environ['NO_PROXY'] = '*'
os.environ['no_proxy'] = '*'
for k in ['HTTP_PROXY','HTTPS_PROXY','http_proxy','https_proxy','ALL_PROXY','all_proxy']:
os.environ.pop(k, None)
import requests
_orig = requests.Session.request
def _patch(self, method, url, **kw):
kw['proxies'] = {'http':'','https':''}
return _orig(self, method, url, **kw)
requests.Session.request = _patch
from datetime import datetime
from tencentcloud.common import credential
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.dlc.v20210125 import dlc_client, models
cred = credential.Credential(os.environ.get('DLC_USER'), os.environ.get('DLC_PASSWORD'))
hp = HttpProfile()
hp.endpoint = 'dlc.tencentcloudapi.com'
cp = ClientProfile()
cp.httpProfile = hp
client = dlc_client.DlcClient(cred, 'ap-shanghai', cp)
output_dir = '/Users/windwheel/.copaw/workspaces/data-warehouse/data-quality-report'
def exec_sql(sql, db='data_ods', max_wait=120):
sql_b64 = base64.b64encode(sql.encode('utf-8')).decode('utf-8')
task = models.Task()
task.SparkSQLTask = {'SQL': sql_b64}
req = models.CreateTaskRequest()
req.DatabaseName = db
req.DataEngineName = 'SparkSQL'
req.Task = task
try:
resp = client.CreateTask(req)
data = json.loads(resp.to_json_string())
tid = data.get('TaskId')
if not tid: return None
except Exception as e:
print(f' CreateTask err: {e}')
return None
elapsed = 0
while elapsed < max_wait:
time.sleep(3)
elapsed += 3
try:
req2 = models.DescribeTaskResultRequest()
req2.TaskId = str(tid)
resp2 = client.DescribeTaskResult(req2)
result = json.loads(resp2.to_json_string())
ti = result.get('TaskInfo', result)
state = ti.get('State', '')
if state == 2:
rs = ti.get('ResultSet', '[]')
if isinstance(rs, str):
try: return json.loads(base64.b64decode(rs).decode('utf-8'))
except Exception:
try: return json.loads(rs)
except Exception: return []
return rs
elif state == 3:
print(f' SQL failed: {ti.get("OutputMessage","")}')
return None
except Exception:
pass
print(f' Timeout after {max_wait}s')
return None
def safe_val(res, row=0, col=0):
"""Extract a value from DLC result safely."""
try:
if res and isinstance(res, list) and len(res) > row:
r = res[row]
if isinstance(r, list) and len(r) > col:
return r[col]
return r
except: pass
return '?'
# Load schemas
with open(os.path.join(output_dir, 'new-table-schemas-20260408.json')) as f:
schemas = json.load(f)
with open(os.path.join(output_dir, 'all-profiles-20260408.json')) as f:
profiles = json.load(f)
all_cols = {}
for tbl, info in profiles.items():
db = info.get('database', 'data_ods')
all_cols[tbl] = {'db': db, 'cols': [{'name':c['name'],'type':c['type']} for c in info.get('columns',[])]}
for tbl, info in schemas.items():
if tbl not in all_cols:
all_cols[tbl] = {'db': info['db'], 'cols': info['columns']}
tables = [
('ods_t_file_information', 'data_ods', 'file_id'),
('ods_pic_for_up_new_backup', 'data_ods', 'id'),
('ods_t_image_file_information', 'data_ods', 'file_id'),
('ods_rpa_douyin_compass_video', 'data_ods', 'id'),
('tb16_dim_product_sale_dimension', 'data_dim', None),
('ods_dy_product_top_crowd_ays', 'data_ods', 'id'),
('ods_rpa_material_data', 'data_ods', None),
]
results = {}
for tbl, db, pk in tables:
full = f'{db}.{tbl}'
print(f"\n{'='*60}")
print(f' {full}')
print(f"{'='*60}")
ci = all_cols.get(tbl, {}).get('cols', [])
if not ci:
print(' NO SCHEMA'); continue
r = {'table': tbl, 'db': db, 'num_cols': len(ci)}
# 1. Row count
res = exec_sql(f'SELECT COUNT(*) FROM {full}', db)
total = safe_val(res)
r['total_rows'] = str(total)
print(f' Rows: {total}')
# 2. Null rates (all cols, chunk 8)
null_rates = {}
for i in range(0, len(ci), 8):
chunk = ci[i:i+8]
parts = []
for c in chunk:
ct = c['type'].lower()
if 'string' in ct:
parts.append(f"ROUND(SUM(CASE WHEN `{c['name']}` IS NULL OR TRIM(`{c['name']}`)='' THEN 1 ELSE 0 END)*100.0/COUNT(*),2) AS `{c['name']}`")
else:
parts.append(f"ROUND(SUM(CASE WHEN `{c['name']}` IS NULL THEN 1 ELSE 0 END)*100.0/COUNT(*),2) AS `{c['name']}`")
res = exec_sql(f"SELECT {', '.join(parts)} FROM {full}", db)
if res and isinstance(res, list) and len(res) > 0 and isinstance(res[0], list):
for j, c in enumerate(chunk):
null_rates[c['name']] = res[0][j] if j < len(res[0]) else '?'
r['null_rates'] = {k: str(v) for k, v in null_rates.items()}
high = [(k,v) for k,v in null_rates.items() if str(v) not in ('?','0','0.0','0.00') and float(str(v)) > 30]
high.sort(key=lambda x: -float(str(x[1])))
if high:
print(f' High null (>30%): {len(high)} cols')
for k,v in high[:8]: print(f' {k}: {v}%')
if len(high)>8: print(f' ... +{len(high)-8} more')
else:
print(f' High null (>30%): none')
# 3. Dirty values (string cols, first 8)
scols = [c['name'] for c in ci if 'string' in c['type'].lower()]
dirty = {}
for sc in scols[:8]:
res = exec_sql(
f"SELECT "
f"SUM(CASE WHEN `{sc}` RLIKE '[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]' THEN 1 ELSE 0 END), "
f"SUM(CASE WHEN `{sc}` != TRIM(`{sc}`) THEN 1 ELSE 0 END), "
f"SUM(CASE WHEN LENGTH(`{sc}`) > 500 THEN 1 ELSE 0 END) "
f"FROM {full} WHERE `{sc}` IS NOT NULL AND TRIM(`{sc}`)!=''", db)
if res and isinstance(res, list) and len(res)>0 and isinstance(res[0], list):
ctrl, tri, ol = str(res[0][0]), str(res[0][1]), str(res[0][2])
if ctrl!='0' or tri!='0' or ol!='0':
dirty[sc] = {'ctrl':ctrl,'trim':tri,'overlong':ol}
print(f' Dirty {sc}: ctrl={ctrl} trim={tri} overlong={ol}')
if not dirty: print(f' Dirty values: none (first 8 str cols)')
r['dirty_values'] = dirty
# 4. Numeric stats
ncols = [c['name'] for c in ci if any(t in c['type'].lower() for t in ['int','bigint','decimal','double','float'])]
nstats = {}
for nc in ncols[:5]:
res = exec_sql(f"SELECT MIN(`{nc}`),MAX(`{nc}`),ROUND(AVG(CAST(`{nc}` AS DOUBLE)),2),ROUND(STDDEV(CAST(`{nc}` AS DOUBLE)),2) FROM {full} WHERE `{nc}` IS NOT NULL", db)
if res and isinstance(res,list) and len(res)>0 and isinstance(res[0],list):
nstats[nc] = {'min':str(res[0][0]),'max':str(res[0][1]),'avg':str(res[0][2]),'stddev':str(res[0][3])}
print(f' Numeric {nc}: min={res[0][0]} max={res[0][1]} avg={res[0][2]} stddev={res[0][3]}')
r['numeric_stats'] = nstats
# 5. Time ranges
tcols = [c['name'] for c in ci if any(t in c['type'].lower() for t in ['timestamp','date'])]
tlike = [c['name'] for c in ci if 'string' in c['type'].lower() and any(k in c['name'].lower() for k in ['time','date','dt'])]
tranges = {}
for tc in list(set(tcols+tlike))[:5]:
res = exec_sql(f"SELECT MIN(`{tc}`),MAX(`{tc}`) FROM {full} WHERE `{tc}` IS NOT NULL AND TRIM(CAST(`{tc}` AS STRING))!=''", db)
if res and isinstance(res,list) and len(res)>0 and isinstance(res[0],list):
tranges[tc] = {'earliest':str(res[0][0]),'latest':str(res[0][1])}
print(f' Time {tc}: {res[0][0]} -> {res[0][1]}')
r['time_ranges'] = tranges
# 6. PK dup rate
if pk:
res = exec_sql(f"SELECT COUNT(DISTINCT `{pk}`),COUNT(*) FROM {full} WHERE `{pk}` IS NOT NULL", db)
if res and isinstance(res,list) and len(res)>0 and isinstance(res[0],list):
d,t = int(str(res[0][0])),int(str(res[0][1]))
dup = round((t-d)*100.0/t,2) if t>0 else 0
r['pk'] = {'distinct':d,'total':t,'dup_rate':dup}
print(f' PK({pk}): {d}/{t} = {dup}% dup')
# 7. Enum distribution for key dims
ecols = [c['name'] for c in ci if 'string' in c['type'].lower() and any(k in c['name'].lower() for k in ['type','status','platform','brand','delete','gender','category'])]
enums = {}
for ec in ecols[:3]:
res = exec_sql(f"SELECT `{ec}`,COUNT(*) FROM {full} WHERE `{ec}` IS NOT NULL AND TRIM(`{ec}`)!='' GROUP BY `{ec}` ORDER BY 2 DESC LIMIT 10", db)
if res and isinstance(res,list) and len(res)>0:
enums[ec] = res
top3 = json.dumps(res[:3], ensure_ascii=False)[:120]
print(f' Enum {ec}: {top3}')
r['enums'] = enums
results[tbl] = r
out = os.path.join(output_dir, f'quality-check-7tables-{datetime.now().strftime("%Y%m%d-%H%M")}.json')
with open(out,'w') as f:
json.dump(results, f, indent=2, ensure_ascii=False, default=str)
print(f'\nSaved: {out}')
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import json
import os
import time
from datetime import datetime
from pathlib import Path
from typing import Any
os.environ["NO_PROXY"] = "*"
os.environ["no_proxy"] = "*"
for _key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"]:
os.environ.pop(_key, None)
import requests
_orig_request = requests.Session.request
def _no_proxy(self, method, url, **kwargs):
kwargs["proxies"] = {"http": "", "https": ""}
return _orig_request(self, method, url, **kwargs)
requests.Session.request = _no_proxy
try:
import pandas as pd
except ImportError as exc:
raise SystemExit("缺少 pandas,请先安装:python -m pip install pandas openpyxl") from exc
try:
from openpyxl.styles import Alignment, Font, PatternFill
except ImportError as exc:
raise SystemExit("缺少 openpyxl,请先安装:python -m pip install openpyxl") from exc
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.platypus import LongTable, PageBreak, Paragraph, SimpleDocTemplate, Spacer, TableStyle
except ImportError as exc:
raise SystemExit("缺少 reportlab,请先安装:python -m pip install reportlab") from exc
try:
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.dlc.v20210125 import dlc_client, models
except ImportError as exc:
raise SystemExit(
"缺少 tencentcloud-sdk-python,请先安装:python -m pip install tencentcloud-sdk-python"
) from exc
REGION = "ap-shanghai"
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_OUTPUT_DIR = SCRIPT_DIR
TABLE_FQN = "data_dim.dim_picture_material_data_enriched"
def now_tag() -> str:
return datetime.now().strftime("%Y%m%d")
class DlcRunner:
def __init__(self, secret_id: str | None, secret_key: str | None, max_wait: int = 300):
if not secret_id or not secret_key:
raise SystemExit("缺少 DLC_USER / DLC_PASSWORD 环境变量。")
cred = credential.Credential(secret_id, secret_key)
http_profile = HttpProfile()
http_profile.endpoint = "dlc.tencentcloudapi.com"
client_profile = ClientProfile()
client_profile.httpProfile = http_profile
self.client = dlc_client.DlcClient(cred, REGION, client_profile)
self.max_wait = max_wait
def exec_sql(self, sql: str, db: str) -> list[list[Any]]:
preview = " ".join(sql.strip().split())[:160]
print(f"[SQL] {db}: {preview}...", flush=True)
task = models.Task()
task.SparkSQLTask = {"SQL": base64.b64encode(sql.encode("utf-8")).decode("utf-8")}
req = models.CreateTaskRequest()
req.DatabaseName = db
req.DataEngineName = "SparkSQL"
req.Task = task
resp = self.client.CreateTask(req)
task_id = json.loads(resp.to_json_string()).get("TaskId")
if not task_id:
raise RuntimeError("CreateTask 未返回 TaskId")
elapsed = 0
while elapsed < self.max_wait:
time.sleep(3)
elapsed += 3
req2 = models.DescribeTaskResultRequest()
req2.TaskId = str(task_id)
result = json.loads(self.client.DescribeTaskResult(req2).to_json_string())
task_info = result.get("TaskInfo", result)
state = task_info.get("State", "")
if state == 2:
result_set = task_info.get("ResultSet", "[]")
if isinstance(result_set, str):
try:
return json.loads(base64.b64decode(result_set).decode("utf-8"))
except Exception:
return json.loads(result_set)
return result_set or []
if state == 3:
raise RuntimeError(task_info.get("OutputMessage", "SQL failed"))
raise TimeoutError(f"SQL 执行超时,等待了 {self.max_wait}s")
def normalize_expr(column_name: str) -> str:
return f"TRIM(CAST(`{column_name}` AS STRING))"
def non_empty_expr(column_name: str) -> str:
return f"`{column_name}` IS NOT NULL AND TRIM(CAST(`{column_name}` AS STRING)) <> ''"
def compare_pair(runner: DlcRunner, target_col: str, ref_col: str, sample_limit: int) -> dict[str, Any]:
base_cte = f"""
WITH target_vals AS (
SELECT DISTINCT {normalize_expr(target_col)} AS enum_value
FROM {TABLE_FQN}
WHERE {non_empty_expr(target_col)}
),
ref_vals AS (
SELECT DISTINCT {normalize_expr(ref_col)} AS enum_value
FROM {TABLE_FQN}
WHERE {non_empty_expr(ref_col)}
)
"""
summary_sql = f"""
{base_cte}
SELECT
(SELECT COUNT(*) FROM target_vals) AS target_enum_count,
(SELECT COUNT(*) FROM ref_vals) AS ref_enum_count,
(SELECT COUNT(*) FROM ref_vals r LEFT JOIN target_vals t ON r.enum_value = t.enum_value WHERE t.enum_value IS NULL) AS missing_in_target_count,
(SELECT COUNT(*) FROM target_vals t LEFT JOIN ref_vals r ON t.enum_value = r.enum_value WHERE r.enum_value IS NULL) AS extra_in_target_count
"""
summary_rows = runner.exec_sql(summary_sql, "data_dim")
summary = {
"target_col": target_col,
"reference_col": ref_col,
"target_enum_count": int(summary_rows[0][0]) if summary_rows else 0,
"ref_enum_count": int(summary_rows[0][1]) if summary_rows else 0,
"missing_in_target_count": int(summary_rows[0][2]) if summary_rows else 0,
"extra_in_target_count": int(summary_rows[0][3]) if summary_rows else 0,
}
missing_sql = f"""
{base_cte}
SELECT
r.enum_value,
stats.row_count,
stats.spu_count
FROM ref_vals r
LEFT JOIN target_vals t ON r.enum_value = t.enum_value
LEFT JOIN (
SELECT
{normalize_expr(ref_col)} AS enum_value,
COUNT(*) AS row_count,
COUNT(DISTINCT CAST(spu AS STRING)) AS spu_count
FROM {TABLE_FQN}
WHERE {non_empty_expr(ref_col)}
GROUP BY {normalize_expr(ref_col)}
) stats ON r.enum_value = stats.enum_value
WHERE t.enum_value IS NULL
ORDER BY stats.row_count DESC, stats.spu_count DESC, r.enum_value ASC
"""
missing_df = pd.DataFrame(runner.exec_sql(missing_sql, "data_dim"), columns=["enum_value", "row_count", "spu_count"])
extra_sql = f"""
{base_cte}
SELECT
t.enum_value,
stats.row_count,
stats.spu_count
FROM target_vals t
LEFT JOIN ref_vals r ON t.enum_value = r.enum_value
LEFT JOIN (
SELECT
{normalize_expr(target_col)} AS enum_value,
COUNT(*) AS row_count,
COUNT(DISTINCT CAST(spu AS STRING)) AS spu_count
FROM {TABLE_FQN}
WHERE {non_empty_expr(target_col)}
GROUP BY {normalize_expr(target_col)}
) stats ON t.enum_value = stats.enum_value
WHERE r.enum_value IS NULL
ORDER BY stats.row_count DESC, stats.spu_count DESC, t.enum_value ASC
"""
extra_df = pd.DataFrame(runner.exec_sql(extra_sql, "data_dim"), columns=["enum_value", "row_count", "spu_count"])
missing_sample_sql = f"""
{base_cte}
SELECT
file_id,
platform,
spu,
{normalize_expr(ref_col)} AS reference_value,
{normalize_expr(target_col)} AS target_value,
full_path
FROM {TABLE_FQN}
WHERE {non_empty_expr(ref_col)}
AND {normalize_expr(ref_col)} IN (
SELECT r.enum_value
FROM ref_vals r
LEFT JOIN target_vals t ON r.enum_value = t.enum_value
WHERE t.enum_value IS NULL
)
LIMIT {sample_limit}
"""
missing_sample_df = pd.DataFrame(
runner.exec_sql(missing_sample_sql, "data_dim"),
columns=["file_id", "platform", "spu", "reference_value", "target_value", "full_path"],
)
extra_sample_sql = f"""
{base_cte}
SELECT
file_id,
platform,
spu,
{normalize_expr(target_col)} AS target_value,
{normalize_expr(ref_col)} AS reference_value,
full_path
FROM {TABLE_FQN}
WHERE {non_empty_expr(target_col)}
AND {normalize_expr(target_col)} IN (
SELECT t.enum_value
FROM target_vals t
LEFT JOIN ref_vals r ON t.enum_value = r.enum_value
WHERE r.enum_value IS NULL
)
LIMIT {sample_limit}
"""
extra_sample_df = pd.DataFrame(
runner.exec_sql(extra_sample_sql, "data_dim"),
columns=["file_id", "platform", "spu", "target_value", "reference_value", "full_path"],
)
return {
"summary": summary,
"missing_df": missing_df,
"extra_df": extra_df,
"missing_sample_df": missing_sample_df,
"extra_sample_df": extra_sample_df,
}
def write_excel(output_path: Path, frames: dict[str, pd.DataFrame]) -> None:
with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
for sheet_name, df in frames.items():
df.to_excel(writer, sheet_name=sheet_name[:31], index=False)
workbook = writer.book
header_fill = PatternFill("solid", fgColor="1F4E78")
header_font = Font(color="FFFFFF", bold=True)
for sheet in workbook.worksheets:
sheet.freeze_panes = "A2"
for cell in sheet[1]:
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
for column_cells in sheet.columns:
values = [str(cell.value) if cell.value is not None else "" for cell in column_cells[:120]]
width = min(max(len(value) for value in values) + 2, 90)
sheet.column_dimensions[column_cells[0].column_letter].width = width
def truncate_text(text: Any, limit: int = 72) -> str:
raw = str(text) if text is not None else ""
return raw if len(raw) <= limit else raw[: limit - 3] + "..."
def build_pdf_table(rows: list[list[Any]], col_widths: list[float], style) -> LongTable:
table = LongTable(rows, colWidths=col_widths, repeatRows=1)
table.setStyle(style)
return table
def write_pdf(output_path: Path, pair_results: list[dict[str, Any]]) -> None:
pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))
styles = getSampleStyleSheet()
title_style = ParagraphStyle("TitleCN", parent=styles["Title"], fontName="STSong-Light", fontSize=18, leading=24)
body_style = ParagraphStyle("BodyCN", parent=styles["BodyText"], fontName="STSong-Light", fontSize=10, leading=14)
heading_style = ParagraphStyle("HeadingCN", parent=styles["Heading2"], fontName="STSong-Light", fontSize=13, leading=18)
table_style = TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1F4E78")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, -1), "STSong-Light"),
("FONTSIZE", (0, 0), (-1, -1), 8.5),
("LEADING", (0, 0), (-1, -1), 10),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#9AA5B1")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.whitesmoke, colors.HexColor("#F4F7FB")]),
]
)
story: list[Any] = []
story.append(Paragraph("DIM 图片枚举值缺口报告", title_style))
story.append(Spacer(1, 4 * mm))
story.append(Paragraph(f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", body_style))
story.append(Paragraph("检测口径:对比同表 data_dim.dim_picture_material_data_enriched 中目标字段与 reference 字段的枚举值集合差异。", body_style))
story.append(Spacer(1, 4 * mm))
for idx, pair in enumerate(pair_results, start=1):
summary = pair["summary"]
story.append(Paragraph(f"{idx}. `{summary['target_col']}` 对比 `{summary['reference_col']}`", heading_style))
summary_rows = [["metric", "value"]]
for key, value in summary.items():
summary_rows.append([key, value])
story.append(build_pdf_table(summary_rows, [80 * mm, 40 * mm], table_style))
story.append(Spacer(1, 3 * mm))
missing_rows = [["enum_value", "row_count", "spu_count"]]
for row in pair["missing_df"].head(80).itertuples(index=False):
missing_rows.append([truncate_text(row.enum_value, 50), int(row.row_count), int(row.spu_count)])
story.append(Paragraph("reference 有、target 缺失的枚举值", body_style))
story.append(build_pdf_table(missing_rows, [90 * mm, 25 * mm, 25 * mm], table_style))
story.append(Spacer(1, 3 * mm))
extra_rows = [["enum_value", "row_count", "spu_count"]]
for row in pair["extra_df"].head(80).itertuples(index=False):
extra_rows.append([truncate_text(row.enum_value, 50), int(row.row_count), int(row.spu_count)])
story.append(Paragraph("target 有、reference 缺失的枚举值", body_style))
story.append(build_pdf_table(extra_rows, [90 * mm, 25 * mm, 25 * mm], table_style))
story.append(Spacer(1, 3 * mm))
sample_rows = [["file_id", "platform", "spu", "reference_value", "target_value", "full_path"]]
for row in pair["missing_sample_df"].head(40).itertuples(index=False):
sample_rows.append([
truncate_text(row.file_id, 18),
truncate_text(row.platform, 12),
truncate_text(row.spu, 16),
truncate_text(row.reference_value, 18),
truncate_text(row.target_value, 18),
truncate_text(row.full_path, 45),
])
story.append(Paragraph("缺失枚举值样本", body_style))
story.append(build_pdf_table(sample_rows, [28 * mm, 18 * mm, 18 * mm, 28 * mm, 28 * mm, 55 * mm], table_style))
if idx < len(pair_results):
story.append(PageBreak())
doc = SimpleDocTemplate(str(output_path), pagesize=A4, leftMargin=10 * mm, rightMargin=10 * mm, topMargin=10 * mm, bottomMargin=10 * mm)
doc.build(story)
def write_markdown(output_path: Path, pair_results: list[dict[str, Any]]) -> None:
lines: list[str] = []
lines.append("# DIM 图片枚举值缺口报告")
lines.append("")
lines.append(f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
lines.append("说明:`middle_cate` 在表中实际字段名为 `mid_cate`。")
lines.append("")
for pair in pair_results:
summary = pair["summary"]
lines.append(f"## `{summary['target_col']}` 对比 `{summary['reference_col']}`")
lines.append("")
lines.append("| metric | value |")
lines.append("|---|---:|")
for key, value in summary.items():
lines.append(f"| {key} | {value} |")
lines.append("")
lines.append("### reference 有、target 缺失的枚举值")
lines.append("")
lines.append("| enum_value | row_count | spu_count |")
lines.append("|---|---:|---:|")
for row in pair["missing_df"].itertuples(index=False):
lines.append(f"| {row.enum_value} | {int(row.row_count)} | {int(row.spu_count)} |")
lines.append("")
lines.append("### target 有、reference 缺失的枚举值")
lines.append("")
lines.append("| enum_value | row_count | spu_count |")
lines.append("|---|---:|---:|")
for row in pair["extra_df"].itertuples(index=False):
lines.append(f"| {row.enum_value} | {int(row.row_count)} | {int(row.spu_count)} |")
lines.append("")
lines.append("### 缺失枚举值样本")
lines.append("")
lines.append("| file_id | platform | spu | reference_value | target_value | full_path |")
lines.append("|---|---|---|---|---|---|")
for row in pair["missing_sample_df"].head(40).itertuples(index=False):
lines.append(f"| {row.file_id} | {row.platform} | {row.spu} | {row.reference_value} | {row.target_value} | {row.full_path} |")
lines.append("")
output_path.write_text("\n".join(lines), encoding="utf-8")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="DIM 图片枚举值缺口检测")
parser.add_argument("--sample-limit", type=int, default=50, help="每个字段对比的抽样数量")
parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR), help="输出目录")
parser.add_argument("--max-wait", type=int, default=300, help="单条 SQL 最长等待秒数")
return parser.parse_args()
def main() -> None:
args = parse_args()
output_dir = Path(args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
runner = DlcRunner(os.environ.get("DLC_USER"), os.environ.get("DLC_PASSWORD"), args.max_wait)
print("[1/2] 对比 mid_cate 与 reference_mid_cate...", flush=True)
mid_pair = compare_pair(runner, "mid_cate", "reference_mid_cate", args.sample_limit)
print("[2/2] 对比 track_first_li_ning_bi 与 reference_track...", flush=True)
track_pair = compare_pair(runner, "track_first_li_ning_bi", "reference_track", args.sample_limit)
pair_results = [mid_pair, track_pair]
date_tag = now_tag()
md_path = output_dir / f"dim-picture-enum-gap-report-{date_tag}.md"
json_path = output_dir / f"dim-picture-enum-gap-report-{date_tag}.json"
xlsx_path = output_dir / f"dim-picture-enum-gap-report-{date_tag}.xlsx"
pdf_path = output_dir / f"dim-picture-enum-gap-report-{date_tag}.pdf"
payload = {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"pairs": [
{
"summary": pair["summary"],
"missing_values": pair["missing_df"].to_dict(orient="records"),
"extra_values": pair["extra_df"].to_dict(orient="records"),
"missing_samples": pair["missing_sample_df"].to_dict(orient="records"),
"extra_samples": pair["extra_sample_df"].to_dict(orient="records"),
}
for pair in pair_results
],
}
write_markdown(md_path, pair_results)
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
write_excel(
xlsx_path,
{
"mid_summary": pd.DataFrame([mid_pair["summary"]]),
"mid_missing": mid_pair["missing_df"],
"mid_extra": mid_pair["extra_df"],
"mid_missing_sample": mid_pair["missing_sample_df"],
"track_summary": pd.DataFrame([track_pair["summary"]]),
"track_missing": track_pair["missing_df"],
"track_extra": track_pair["extra_df"],
"track_missing_sample": track_pair["missing_sample_df"],
},
)
write_pdf(pdf_path, pair_results)
print("[OK] MD:", md_path)
print("[OK] JSON:", json_path)
print("[OK] XLSX:", xlsx_path)
print("[OK] PDF:", pdf_path)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import json
import os
import time
from datetime import datetime
from pathlib import Path
from typing import Any
os.environ["NO_PROXY"] = "*"
os.environ["no_proxy"] = "*"
for _key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"]:
os.environ.pop(_key, None)
import requests
_orig_request = requests.Session.request
def _no_proxy(self, method, url, **kwargs):
kwargs["proxies"] = {"http": "", "https": ""}
return _orig_request(self, method, url, **kwargs)
requests.Session.request = _no_proxy
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.dlc.v20210125 import dlc_client, models
REGION = "ap-shanghai"
OUTPUT_DIR = Path("/Users/windwheel/.copaw/workspaces/platform_path_scan_output")
class DlcRunner:
def __init__(self, secret_id: str | None, secret_key: str | None, max_wait: int = 300):
if not secret_id or not secret_key:
raise SystemExit("缺少 DLC_USER / DLC_PASSWORD 环境变量。")
cred = credential.Credential(secret_id, secret_key)
http_profile = HttpProfile()
http_profile.endpoint = "dlc.tencentcloudapi.com"
client_profile = ClientProfile()
client_profile.httpProfile = http_profile
self.client = dlc_client.DlcClient(cred, REGION, client_profile)
self.max_wait = max_wait
def exec_sql(self, sql: str, db: str = "data_ods") -> list[list[Any]]:
task = models.Task()
task.SparkSQLTask = {"SQL": base64.b64encode(sql.encode("utf-8")).decode("utf-8")}
req = models.CreateTaskRequest()
req.DatabaseName = db
req.DataEngineName = "SparkSQL"
req.Task = task
resp = self.client.CreateTask(req)
payload = json.loads(resp.to_json_string())
task_id = payload.get("TaskId")
if not task_id:
raise RuntimeError(f"CreateTask 未返回 TaskId: {payload}")
elapsed = 0
while elapsed < self.max_wait:
time.sleep(3)
elapsed += 3
req2 = models.DescribeTaskResultRequest()
req2.TaskId = str(task_id)
result = json.loads(self.client.DescribeTaskResult(req2).to_json_string())
task_info = result.get("TaskInfo", result)
state = task_info.get("State", "")
if state == 2:
result_set = task_info.get("ResultSet", "[]")
if isinstance(result_set, str):
try:
return json.loads(base64.b64decode(result_set).decode("utf-8"))
except Exception:
return json.loads(result_set)
return result_set or []
if state == 3:
raise RuntimeError(task_info.get("OutputMessage", "SQL failed"))
raise TimeoutError(f"SQL 执行超时,等待了 {self.max_wait}s")
def enum_sql(topn: int) -> str:
return f"""
SELECT
CAST(content_type AS STRING) AS content_type,
COUNT(*) AS row_cnt
FROM data_ods.ods_pic_for_up_new_backup
WHERE COALESCE(CAST(is_delete AS STRING), 'false') = 'false'
AND content_type IS NOT NULL
AND TRIM(CAST(content_type AS STRING)) <> ''
AND CAST(content_type AS STRING) RLIKE '视频'
GROUP BY CAST(content_type AS STRING)
ORDER BY row_cnt DESC, content_type
LIMIT {topn}
""".strip()
def sample_sql(limit: int) -> str:
return f"""
SELECT
CAST(id AS STRING) AS file_id,
CAST(content_type AS STRING) AS content_type,
CAST(used_platform AS STRING) AS used_platform,
CAST(spu AS STRING) AS spu,
CAST(image_create_time AS STRING) AS image_create_time,
CAST(image_modify_time AS STRING) AS image_modify_time,
CAST(image_name AS STRING) AS image_name,
replace(COALESCE(image_full_path, ''), '\\\\', '/') AS image_full_path
FROM data_ods.ods_pic_for_up_new_backup
WHERE COALESCE(CAST(is_delete AS STRING), 'false') = 'false'
AND content_type IS NOT NULL
AND TRIM(CAST(content_type AS STRING)) <> ''
AND CAST(content_type AS STRING) RLIKE '视频'
ORDER BY image_modify_time DESC
LIMIT {limit}
""".strip()
def main() -> None:
parser = argparse.ArgumentParser(description="抽查 ods_pic_for_up_new_backup 中 content_type 含视频的枚举和值样本。")
parser.add_argument("--topn", type=int, default=20, help="枚举 TopN")
parser.add_argument("--sample-limit", type=int, default=20, help="样本行数")
parser.add_argument("--max-wait", type=int, default=180, help="DLC 最大等待秒数")
args = parser.parse_args()
runner = DlcRunner(os.getenv("DLC_USER"), os.getenv("DLC_PASSWORD"), max_wait=args.max_wait)
enum_rows = runner.exec_sql(enum_sql(args.topn))
sample_rows = runner.exec_sql(sample_sql(args.sample_limit))
payload = {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"content_type_distribution": [
{"content_type": row[0], "row_cnt": row[1]} for row in enum_rows
],
"samples": [
{
"file_id": row[0],
"content_type": row[1],
"used_platform": row[2],
"spu": row[3],
"image_create_time": row[4],
"image_modify_time": row[5],
"image_name": row[6],
"image_full_path": row[7],
}
for row in sample_rows
],
}
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
out_path = OUTPUT_DIR / f"pic-backup-video-content-type-probe-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Output written to: {out_path}")
print(json.dumps(payload, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import time
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any
os.environ["NO_PROXY"] = "*"
os.environ["no_proxy"] = "*"
for _key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"]:
os.environ.pop(_key, None)
import requests
_orig_request = requests.Session.request
def _no_proxy(self, method, url, **kwargs):
kwargs["proxies"] = {"http": "", "https": ""}
return _orig_request(self, method, url, **kwargs)
requests.Session.request = _no_proxy
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.dlc.v20210125 import dlc_client, models
REGION = "ap-shanghai"
TARGET_SQL = Path("/Users/windwheel/.copaw/workspaces/sample-table01.sql")
OUTPUT_DIR = Path("/Users/windwheel/.copaw/workspaces/platform_path_scan_output")
KNOWN_PLATFORM_PATTERNS = {
"天猫": [r"天猫"],
"淘宝": [r"淘宝"],
"唯品会": [r"唯品会", r"唯品"],
"快手": [r"快手"],
"拼多多": [r"拼多多", r"\bPDD\b"],
"抖音": [r"抖音"],
"京东": [r"京东", r"\bJD\b"],
"小红书": [r"小红书"],
"视频号": [r"视频号"],
"得物": [r"得物"],
}
NON_PLATFORM_TERMS = [
"商品内页",
"详情图",
"详情页",
"主图",
"白底图",
"灰底图",
"素材图",
"配色图",
"尺码图",
"面料图",
"内页图",
"温馨提示",
"选购图",
"海报图",
"场景图",
"平铺图",
"挂拍图",
"细节图",
"穿搭图",
"官网主图",
]
class DlcRunner:
def __init__(self, secret_id: str | None, secret_key: str | None, max_wait: int = 300):
if not secret_id or not secret_key:
raise SystemExit("缺少 DLC_USER / DLC_PASSWORD 环境变量。")
cred = credential.Credential(secret_id, secret_key)
http_profile = HttpProfile()
http_profile.endpoint = "dlc.tencentcloudapi.com"
client_profile = ClientProfile()
client_profile.httpProfile = http_profile
self.client = dlc_client.DlcClient(cred, REGION, client_profile)
self.max_wait = max_wait
def exec_sql(self, sql: str, db: str) -> list[list[Any]]:
task = models.Task()
task.SparkSQLTask = {"SQL": base64.b64encode(sql.encode("utf-8")).decode("utf-8")}
req = models.CreateTaskRequest()
req.DatabaseName = db
req.DataEngineName = "SparkSQL"
req.Task = task
resp = self.client.CreateTask(req)
payload = json.loads(resp.to_json_string())
task_id = payload.get("TaskId")
if not task_id:
raise RuntimeError(f"CreateTask 未返回 TaskId: {payload}")
elapsed = 0
while elapsed < self.max_wait:
time.sleep(3)
elapsed += 3
req2 = models.DescribeTaskResultRequest()
req2.TaskId = str(task_id)
result = json.loads(self.client.DescribeTaskResult(req2).to_json_string())
task_info = result.get("TaskInfo", result)
state = task_info.get("State", "")
if state == 2:
result_set = task_info.get("ResultSet", "[]")
if isinstance(result_set, str):
try:
return json.loads(base64.b64decode(result_set).decode("utf-8"))
except Exception:
return json.loads(result_set)
return result_set or []
if state == 3:
raise RuntimeError(task_info.get("OutputMessage", "SQL failed"))
raise TimeoutError(f"SQL 执行超时,等待了 {self.max_wait}s")
def get_cte_sql(sql_path: Path) -> str:
raw_sql = sql_path.read_text(encoding="utf-8")
marker = "insert overwrite table data_dwd.dwd_t_file_resource_id_test_001"
pos = raw_sql.lower().find(marker)
if pos < 0:
raise SystemExit(f"未找到目标 insert 语句: {marker}")
cte_sql = raw_sql[:pos].rstrip()
return re.sub(r";\s*$", "", cte_sql, flags=re.S)
def build_ranked_probe_cte(sql_path: Path) -> str:
cte_sql = get_cte_sql(sql_path)
return f"""{cte_sql},
ranked_probe as (
select
file_type,
file_id,
platform,
brand,
create_time,
full_path,
concatenated_path,
spu,
picture_type,
file_name,
source_priority,
order_time,
row_number() over (
partition by file_id
order by source_priority asc, order_time desc, create_time desc, full_path desc
) as rn
from resource_union
where file_id is not null
and trim(cast(file_id as string)) <> ''
)
""".strip()
def build_summary_sql(sql_path: Path) -> str:
probe_cte = build_ranked_probe_cte(sql_path)
return f"""{probe_cte}
select
cast(source_priority as string) as source_priority,
file_type,
count(*) as total_rows,
sum(case when platform is null or trim(cast(platform as string)) = '' then 1 else 0 end) as null_rows,
round(
100.0 * sum(case when platform is null or trim(cast(platform as string)) = '' then 1 else 0 end) / count(*),
4
) as null_rate_pct
from ranked_probe
where rn = 1
group by cast(source_priority as string), file_type
order by source_priority, file_type
""".strip()
def build_null_sample_sql(sql_path: Path, limit: int) -> str:
probe_cte = build_ranked_probe_cte(sql_path)
return f"""{probe_cte}
select
cast(source_priority as string) as source_priority,
file_type,
cast(file_id as string) as file_id,
cast(platform as string) as platform,
cast(brand as string) as brand,
cast(spu as string) as spu,
cast(picture_type as string) as picture_type,
cast(file_name as string) as file_name,
cast(create_time as string) as create_time,
cast(full_path as string) as full_path,
cast(concatenated_path as string) as concatenated_path
from ranked_probe
where rn = 1
and (platform is null or trim(cast(platform as string)) = '')
order by source_priority asc, create_time desc, file_id desc
limit {limit}
""".strip()
def normalize_text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def normalize_path(value: Any) -> str:
text = normalize_text(value)
if not text:
return ""
text = text.replace("\\", "/")
text = re.sub(r"/+", "/", text)
return text.strip("/")
def split_segments(path_text: str) -> list[str]:
return [seg.strip() for seg in path_text.split("/") if seg and seg.strip()]
def detect_known_platform(segment: str) -> str | None:
for platform, patterns in KNOWN_PLATFORM_PATTERNS.items():
for pattern in patterns:
if re.search(pattern, segment, flags=re.IGNORECASE):
return platform
return None
def classify_sample(row: dict[str, str]) -> tuple[str, str | None]:
path = normalize_path(row.get("full_path") or row.get("concatenated_path"))
if not path:
return "NO_PATH", None
segments = split_segments(path)
if row.get("file_type") == "视频":
if not segments:
return "VIDEO_NO_PLATFORM_SEGMENT", None
for seg in segments:
if detect_known_platform(seg):
return "VIDEO_PATH_HAS_PLATFORM_BUT_SQL_NOT_MINING", seg
return "VIDEO_PATH_NO_PLATFORM_SEGMENT", segments[-1] if segments else None
has_non_platform = False
for seg in segments:
if detect_known_platform(seg):
return "IMAGE_PATH_HAS_PLATFORM_BUT_SQL_MISSED", seg
if any(term in seg for term in NON_PLATFORM_TERMS):
has_non_platform = True
if has_non_platform:
return "IMAGE_ONLY_CONTENT_SEGMENTS", None
return "IMAGE_NO_PLATFORM_SEGMENT", segments[-1] if segments else None
def analyze_samples(rows: list[dict[str, str]]) -> dict[str, Any]:
reason_counter = Counter()
source_reason_counter: dict[str, Counter] = defaultdict(Counter)
segment_counter = Counter()
representative: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
reason, segment = classify_sample(row)
reason_counter[reason] += 1
source_reason_counter[row["source_priority"]][reason] += 1
if segment:
segment_counter[segment] += 1
if len(representative[reason]) < 5:
representative[reason].append(
{
"file_id": row["file_id"],
"source_priority": row["source_priority"],
"file_type": row["file_type"],
"brand": row["brand"],
"spu": row["spu"],
"picture_type": row["picture_type"],
"file_name": row["file_name"],
"create_time": row["create_time"],
"full_path": row["full_path"],
"concatenated_path": row["concatenated_path"],
}
)
return {
"reason_distribution": reason_counter.most_common(),
"reason_distribution_by_source_priority": {
source: counter.most_common() for source, counter in source_reason_counter.items()
},
"top_suspicious_terminal_segments": segment_counter.most_common(20),
"representative_samples": representative,
}
def rows_to_dicts(rows: list[list[Any]]) -> list[dict[str, str]]:
result: list[dict[str, str]] = []
for row in rows:
result.append(
{
"source_priority": normalize_text(row[0]),
"file_type": normalize_text(row[1]),
"file_id": normalize_text(row[2]),
"platform": normalize_text(row[3]),
"brand": normalize_text(row[4]),
"spu": normalize_text(row[5]),
"picture_type": normalize_text(row[6]),
"file_name": normalize_text(row[7]),
"create_time": normalize_text(row[8]),
"full_path": normalize_text(row[9]),
"concatenated_path": normalize_text(row[10]),
}
)
return result
def main() -> None:
parser = argparse.ArgumentParser(description="诊断 sample-table01.sql 当前 platform 空值的来源与样本。")
parser.add_argument("--db", default="data_dwd", help="DLC 执行库")
parser.add_argument("--sample-limit", type=int, default=2000, help="抽样空值样本数")
parser.add_argument("--max-wait", type=int, default=300, help="DLC 最大等待秒数")
args = parser.parse_args()
runner = DlcRunner(os.getenv("DLC_USER"), os.getenv("DLC_PASSWORD"), max_wait=args.max_wait)
summary_rows = runner.exec_sql(build_summary_sql(TARGET_SQL), args.db)
sample_rows = runner.exec_sql(build_null_sample_sql(TARGET_SQL, args.sample_limit), args.db)
sample_dicts = rows_to_dicts(sample_rows)
payload = {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"sql_file": str(TARGET_SQL),
"summary_by_source": [
{
"source_priority": row[0],
"file_type": row[1],
"total_rows": row[2],
"null_rows": row[3],
"null_rate_pct": row[4],
}
for row in summary_rows
],
"sample_limit": args.sample_limit,
"sample_analysis": analyze_samples(sample_dicts),
}
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
out_path = OUTPUT_DIR / f"sample-table01-platform-null-diagnosis-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Output written to: {out_path}")
print(json.dumps(payload, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()