sino-bc-cli 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +256 -0
- package/dist/affordance/auth.md +31 -0
- package/dist/affordance/bill.md +48 -0
- package/dist/affordance/biz-line-ai-strategy.md +35 -0
- package/dist/affordance/biz-line-metrics.md +47 -0
- package/dist/affordance/biz-line.md +84 -0
- package/dist/affordance/common-file.md +25 -0
- package/dist/affordance/dashboard.md +75 -0
- package/dist/affordance/dict.md +28 -0
- package/dist/affordance/document.md +67 -0
- package/dist/affordance/im.md +91 -0
- package/dist/affordance/invoice.md +49 -0
- package/dist/affordance/media.md +17 -0
- package/dist/affordance/organization.md +47 -0
- package/dist/affordance/project-analysis.md +95 -0
- package/dist/affordance/project.md +321 -0
- package/dist/affordance/quota.md +19 -0
- package/dist/affordance/reconciliation.md +41 -0
- package/dist/affordance/report.md +75 -0
- package/dist/affordance/sso-team-group.md +30 -0
- package/dist/affordance/task.md +148 -0
- package/dist/affordance/tenant.md +17 -0
- package/dist/affordance/user.md +41 -0
- package/dist/affordance/wallet.md +100 -0
- package/dist/api-doc/README.md +67 -0
- package/dist/api-doc/custom/template.md +0 -0
- package/dist/api-doc/oapi/auth.md +61 -0
- package/dist/api-doc/oapi/bill.md +130 -0
- package/dist/api-doc/oapi/biz-line-ai-strategy.md +119 -0
- package/dist/api-doc/oapi/biz-line-metrics.md +419 -0
- package/dist/api-doc/oapi/biz-line.md +382 -0
- package/dist/api-doc/oapi/common-file.md +50 -0
- package/dist/api-doc/oapi/dashboard.md +218 -0
- package/dist/api-doc/oapi/dict.md +49 -0
- package/dist/api-doc/oapi/document.md +182 -0
- package/dist/api-doc/oapi/im.md +278 -0
- package/dist/api-doc/oapi/invoice.md +129 -0
- package/dist/api-doc/oapi/media.md +17 -0
- package/dist/api-doc/oapi/organization.md +94 -0
- package/dist/api-doc/oapi/project-analysis.md +364 -0
- package/dist/api-doc/oapi/project.md +1114 -0
- package/dist/api-doc/oapi/quota.md +21 -0
- package/dist/api-doc/oapi/reconciliation.md +98 -0
- package/dist/api-doc/oapi/report.md +254 -0
- package/dist/api-doc/oapi/sso-team-group.md +54 -0
- package/dist/api-doc/oapi/task.md +528 -0
- package/dist/api-doc/oapi/tenant.md +17 -0
- package/dist/api-doc/oapi/user.md +98 -0
- package/dist/api-doc/oapi/wallet.md +275 -0
- package/dist/generated/api-catalog.json +39681 -0
- package/dist/generated/api-directory-index.json +1509 -0
- package/dist/index.js +2 -0
- package/dist/skill-manifest.json +336 -0
- package/dist/skills/biz-line-strategy-generate/SKILL.md +122 -0
- package/dist/skills/biz-line-strategy-generate/references/ai-strategy-prompt.md +50 -0
- package/dist/skills/biz-line-strategy-generate/references/api-flow.md +155 -0
- package/dist/skills/biz-line-strategy-generate/references/four-strategies.md +58 -0
- package/dist/skills/biz-line-strategy-generate/references/metrics.md +119 -0
- package/dist/skills/biz-line-strategy-generate/references/output-excel.md +63 -0
- package/dist/skills/biz-line-strategy-generate/references/runtime.md +127 -0
- package/dist/skills/biz-line-strategy-generate/references/spec.md +500 -0
- package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +89 -0
- package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +629 -0
- package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +87 -0
- package/dist/skills/biz-line-strategy-generate/scripts/cli_json.py +705 -0
- package/dist/skills/biz-line-strategy-generate/scripts/fetch_data.py +449 -0
- package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +402 -0
- package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +556 -0
- package/dist/skills/biz-line-strategy-generate/scripts/submit_tags.py +32 -0
- package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +932 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_ai_strategy.py +235 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_cli_json.py +97 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_database_output.py +904 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +253 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_strategy_rules.py +224 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_tag_submission.py +238 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +1233 -0
- package/dist/skills/sino-bc-cli/SKILL.md +62 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-auth/SKILL.md +36 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-bill/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/SKILL.md +58 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/common-mistakes.md +7 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/enums.md +13 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-ai-strategy/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-metrics/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-common-file/SKILL.md +44 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-dashboard/SKILL.md +50 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-dict/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-document/SKILL.md +53 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-im/SKILL.md +62 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-invoice/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-media/SKILL.md +40 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-organization/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project/SKILL.md +112 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/SKILL.md +61 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/analysis-workflow.md +21 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/filters-and-metrics.md +30 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-quota/SKILL.md +44 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-reconciliation/SKILL.md +47 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-report/SKILL.md +53 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-sso-team-group/SKILL.md +45 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-task/SKILL.md +73 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-task/references/state-machine.md +18 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-tenant/SKILL.md +34 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-user/SKILL.md +57 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-wallet/SKILL.md +55 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8534789.md +53 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8551546.md +70 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8562638.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8566367.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8581524.md +17 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8601540.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8614891.md +33 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8621792.md +17 -0
- package/dist/skills/sino-bc-shared/SKILL.md +31 -0
- package/dist/skills/sino-bc-shared/references/auth-recovery.md +36 -0
- package/dist/skills/sino-bc-shared/references/config-and-env.md +60 -0
- package/dist/skills/sino-bc-shared/references/output-contract.md +86 -0
- package/dist/skills/sino-bc-shared/references/skill-lifecycle.md +17 -0
- package/dist/skills/sino-bc-shared/references/tenant-context.md +21 -0
- package/dist/skills/sino-bc-shared/references/write-approval.md +16 -0
- package/package.json +70 -0
- package/scripts/postinstall.cjs +36 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
把业务线策略生成结果写成 .xlsx(业务线策略生成 skill 专用,纯标准库实现,无第三方依赖)。
|
|
5
|
+
|
|
6
|
+
职责边界:本脚本只负责「结构化数据 -> Excel 文件」的机械落盘,
|
|
7
|
+
不做数据拉取和业务判断;统一入口先通过固定汇总脚本生成内容。
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
python3 build_report.py --input payload.json [--output /path/to/file.xlsx]
|
|
11
|
+
cat payload.json | python3 build_report.py --input -
|
|
12
|
+
|
|
13
|
+
payload.json 结构(统一工作流另将 Agent 回包传给导出函数):
|
|
14
|
+
{
|
|
15
|
+
"output_path": "可选,未传时默认写到桌面 业务线策略生成_YYYYMMDD_HHMMSS.xlsx",
|
|
16
|
+
"rows": [ { "...策略摘要行..." } ],
|
|
17
|
+
"details": [{"kind": "广告组", "action": "新增", "channel": "Meta",
|
|
18
|
+
"id": "广告组ID", "name": "名称", "spend_usd": "本期(上期:...)",
|
|
19
|
+
"...其余28项指标...": "...", "extra": ""}]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
列固定为:组别、业务线、本期/上期、项目名称、广告账号、
|
|
23
|
+
预算策略标签、预算策略内容、预算策略learning、广告产品策略标签、广告产品策略内容、广告产品策略learning、
|
|
24
|
+
受众策略标签、受众策略内容、受众策略learning、素材策略内容、素材策略learning。
|
|
25
|
+
|
|
26
|
+
一个业务线一行;本期/上期与多个项目名称都放在同一单元格内用换行分隔。
|
|
27
|
+
数据行统一套用 wrapText 样式,单元格内换行会正常展示。
|
|
28
|
+
固定策略与明细先组成双表草稿,另导出明细-only 工作簿供 AI 阅读;
|
|
29
|
+
取得 AI 文字后追加横向八列的 AI策略 表,三类标签直接复制主表值。
|
|
30
|
+
"""
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
import zipfile
|
|
36
|
+
import math
|
|
37
|
+
import unicodedata
|
|
38
|
+
import uuid
|
|
39
|
+
import re
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from xml.etree import ElementTree as ET
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
44
|
+
from cli_json import REPORT_METRICS
|
|
45
|
+
|
|
46
|
+
COLUMNS = [
|
|
47
|
+
("group", "组别"),
|
|
48
|
+
("bizLine", "业务线"),
|
|
49
|
+
("period", "本期/上期"),
|
|
50
|
+
("project", "项目名称"),
|
|
51
|
+
("adAccounts", "广告账号"),
|
|
52
|
+
("budgetTags", "预算策略标签"),
|
|
53
|
+
("budgetStrategy", "预算策略内容"),
|
|
54
|
+
("budgetLearning", "预算策略learning"),
|
|
55
|
+
("productTags", "广告产品策略标签"),
|
|
56
|
+
("productStrategy", "广告产品策略内容"),
|
|
57
|
+
("productLearning", "广告产品策略learning"),
|
|
58
|
+
("audienceTags", "受众策略标签"),
|
|
59
|
+
("audienceStrategy", "受众策略内容"),
|
|
60
|
+
("audienceLearning", "受众策略learning"),
|
|
61
|
+
("creativeStrategy", "素材策略内容"),
|
|
62
|
+
("creativeLearning", "素材策略learning"),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
# 策略内容列文字量最大,给最宽的列宽(前 5 列是基础信息列)
|
|
66
|
+
DETAIL_COLUMNS = [
|
|
67
|
+
("kind", "明细数据"),
|
|
68
|
+
("action", "动作"),
|
|
69
|
+
("channel", "渠道"),
|
|
70
|
+
("id", "id"),
|
|
71
|
+
("name", "名称"),
|
|
72
|
+
*((code, label) for code, label, _is_percent in REPORT_METRICS),
|
|
73
|
+
("extra", "补充"),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
CONTENT_KEYS = {key for key, _label in COLUMNS[5:]}
|
|
77
|
+
|
|
78
|
+
# 列宽:策略内容列最宽;项目名称列因单元格内多行罗列项目而适度加宽
|
|
79
|
+
COL_WIDTH_DEFAULT = 14
|
|
80
|
+
COL_WIDTH_CONTENT = 48
|
|
81
|
+
COL_WIDTH_MULTILINE = 22
|
|
82
|
+
MULTILINE_KEYS = {"period", "project", "adAccounts"}
|
|
83
|
+
|
|
84
|
+
# XML 1.0 与 Excel sharedStrings 不接受这些控制码及孤立代理项;保留制表、换行和回车。
|
|
85
|
+
_INVALID_XML_TEXT = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ud800-\udfff\ufffe\uffff]')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _sanitize_xml_text(value: str) -> str:
|
|
89
|
+
"""仅剔除无法写入 XML 的字符,不改变正常广告名称与换行。"""
|
|
90
|
+
return _INVALID_XML_TEXT.sub('', value)
|
|
91
|
+
|
|
92
|
+
CONTENT_TYPES_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
93
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
94
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
95
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
96
|
+
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
|
97
|
+
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
|
98
|
+
<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
|
99
|
+
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
|
100
|
+
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
|
101
|
+
</Types>"""
|
|
102
|
+
|
|
103
|
+
ROOT_RELS_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
104
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
105
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
|
106
|
+
</Relationships>"""
|
|
107
|
+
|
|
108
|
+
WORKBOOK_RELS_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
109
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
110
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
|
111
|
+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
112
|
+
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
|
|
113
|
+
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>
|
|
114
|
+
</Relationships>"""
|
|
115
|
+
|
|
116
|
+
WORKBOOK_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
117
|
+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
118
|
+
<sheets>
|
|
119
|
+
<sheet name="策略" sheetId="1" r:id="rId1"/>
|
|
120
|
+
<sheet name="明细" sheetId="2" r:id="rId4"/>
|
|
121
|
+
</sheets>
|
|
122
|
+
</workbook>"""
|
|
123
|
+
|
|
124
|
+
# cellXfs: 0=默认, 1=表头(粗体+浅底), 2=内容(自动换行+顶对齐)
|
|
125
|
+
STYLES_XML = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
126
|
+
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
127
|
+
<fonts count="2">
|
|
128
|
+
<font><sz val="10"/><name val="Microsoft YaHei"/></font>
|
|
129
|
+
<font><b/><sz val="10"/><color rgb="FFFFFFFF"/><name val="Microsoft YaHei"/></font>
|
|
130
|
+
</fonts>
|
|
131
|
+
<fills count="3">
|
|
132
|
+
<fill><patternFill patternType="none"/></fill>
|
|
133
|
+
<fill><patternFill patternType="none"/></fill>
|
|
134
|
+
<fill><patternFill patternType="solid"><fgColor rgb="FF4472C4"/><bgColor indexed="64"/></patternFill></fill>
|
|
135
|
+
</fills>
|
|
136
|
+
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
|
137
|
+
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
|
138
|
+
<cellXfs count="3">
|
|
139
|
+
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
|
|
140
|
+
<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1">
|
|
141
|
+
<alignment horizontal="center" vertical="center"/>
|
|
142
|
+
</xf>
|
|
143
|
+
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1">
|
|
144
|
+
<alignment vertical="top" wrapText="1"/>
|
|
145
|
+
</xf>
|
|
146
|
+
</cellXfs>
|
|
147
|
+
</styleSheet>"""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def col_letter(index: int) -> str:
|
|
151
|
+
"""0-based 列号转 Excel 列字母(A, B, ..., Z, AA, ...)"""
|
|
152
|
+
letters = ""
|
|
153
|
+
n = index + 1
|
|
154
|
+
while n > 0:
|
|
155
|
+
n, rem = divmod(n - 1, 26)
|
|
156
|
+
letters = chr(65 + rem) + letters
|
|
157
|
+
return letters
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _intern_factory():
|
|
161
|
+
seen = {}
|
|
162
|
+
order = []
|
|
163
|
+
|
|
164
|
+
def intern(value, rich=False):
|
|
165
|
+
# 同文字的策略富文本与普通单元格分别去重,避免格式串到明细或learning。
|
|
166
|
+
key = (value, rich)
|
|
167
|
+
if key not in seen:
|
|
168
|
+
seen[key] = len(order)
|
|
169
|
+
order.append(key)
|
|
170
|
+
return seen[key]
|
|
171
|
+
|
|
172
|
+
return intern, order
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_shared_strings(rows, details=None, *, include_strategy=True, ai_cells=None):
|
|
176
|
+
"""按输出范围收集字符串;AI 输入文件只包含明细文字。"""
|
|
177
|
+
intern, order = _intern_factory()
|
|
178
|
+
header_indices = [intern(label) for _key, label in COLUMNS] if include_strategy else []
|
|
179
|
+
row_indices = ([
|
|
180
|
+
[intern(str(row.get(key, "") or ""), key.endswith('Strategy') or key == 'adAccounts') for key, _label in COLUMNS] for row in rows
|
|
181
|
+
] if include_strategy else [])
|
|
182
|
+
detail_header = [intern(label) for _key, label in DETAIL_COLUMNS]
|
|
183
|
+
detail_rows = []
|
|
184
|
+
for row in details or []:
|
|
185
|
+
detail_rows.append(
|
|
186
|
+
[intern(str(row.get(key, "") or "")) for key, _label in DETAIL_COLUMNS]
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# AI 表复用共享字符串池并追加到末尾,前两张表的字符串索引保持不变。
|
|
190
|
+
ai_header = [intern(label) for label in AI_COLUMNS] if ai_cells is not None else []
|
|
191
|
+
ai_values = [intern(plain, tuple(parts) if any(bold for _text, bold in parts) else False)
|
|
192
|
+
for plain, parts in ai_cells] if ai_cells is not None else []
|
|
193
|
+
|
|
194
|
+
def string_xml(text, rich):
|
|
195
|
+
"""共享字符串统一保留换行与加粗,避免 AI 表使用预览器不兼容的 inlineStr。"""
|
|
196
|
+
escape_text = lambda value: xml_escape(value, {chr(13): ' '})
|
|
197
|
+
if not rich:
|
|
198
|
+
return f'<si><t xml:space="preserve">{escape_text(text)}</t></si>'
|
|
199
|
+
if isinstance(rich, tuple):
|
|
200
|
+
# AI 的 Markdown 已被拆为文字及样式,不重新解释投放内容。
|
|
201
|
+
parts = rich
|
|
202
|
+
else:
|
|
203
|
+
pattern = r'(【[^\n】]+】|\[[^\n\]]+\]|(?m:^)(?:Meta|Google|TikTok)(?=:))'
|
|
204
|
+
parts, start = [], 0
|
|
205
|
+
for match in re.finditer(pattern, text):
|
|
206
|
+
parts.extend((part, bold) for part, bold in
|
|
207
|
+
((text[start:match.start()], False), (match.group(), True)) if part)
|
|
208
|
+
start = match.end()
|
|
209
|
+
if text[start:] or not parts:
|
|
210
|
+
parts.append((text[start:], False))
|
|
211
|
+
runs = ''.join(f'<r>{"<rPr><b/></rPr>" if bold else ""}'
|
|
212
|
+
f'<t xml:space="preserve">{escape_text(part)}</t></r>' for part, bold in parts)
|
|
213
|
+
return '<si>' + runs + '</si>'
|
|
214
|
+
items = ''.join(string_xml(text, rich) for text, rich in order)
|
|
215
|
+
count = len(header_indices) + sum(map(len, row_indices)) + len(detail_header) + sum(map(len, detail_rows)) + len(ai_header) + len(ai_values)
|
|
216
|
+
sst_xml = (
|
|
217
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
218
|
+
f'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
|
219
|
+
f'count="{count}" uniqueCount="{len(order)}">{items}</sst>'
|
|
220
|
+
)
|
|
221
|
+
return sst_xml, header_indices, row_indices, detail_header, detail_rows, (ai_header, ai_values)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def build_sheet_xml(header_indices, row_indices, columns, content_keys=None, multiline_keys=None, values=None):
|
|
225
|
+
"""生成 worksheet xml:首行表头样式 s="1",其余行内容样式 s="2"(自动换行)"""
|
|
226
|
+
content_keys = content_keys or set()
|
|
227
|
+
multiline_keys = multiline_keys or set()
|
|
228
|
+
col_count = len(columns)
|
|
229
|
+
dimension_ref = f"A1:{col_letter(col_count - 1)}{len(row_indices) + 1}"
|
|
230
|
+
|
|
231
|
+
cols_xml_parts = []
|
|
232
|
+
widths = {}
|
|
233
|
+
for i, (key, _label) in enumerate(columns):
|
|
234
|
+
if key in content_keys:
|
|
235
|
+
width = COL_WIDTH_CONTENT
|
|
236
|
+
elif key in multiline_keys:
|
|
237
|
+
width = COL_WIDTH_MULTILINE
|
|
238
|
+
else:
|
|
239
|
+
width = COL_WIDTH_DEFAULT
|
|
240
|
+
# 尽量扩列减少换行;达到Excel行高上限仍保留单元格全文,不拆业务线行。
|
|
241
|
+
if key.endswith('Strategy'):
|
|
242
|
+
while width < 255 and any(visual_lines(str(record.get(key) or ''), width) > 26 for record in (values or [])):
|
|
243
|
+
width = min(255, width + 4)
|
|
244
|
+
widths[key] = width
|
|
245
|
+
cols_xml_parts.append(
|
|
246
|
+
f'<col min="{i + 1}" max="{i + 1}" width="{width}" customWidth="1"/>'
|
|
247
|
+
)
|
|
248
|
+
cols_xml = f"<cols>{''.join(cols_xml_parts)}</cols>"
|
|
249
|
+
|
|
250
|
+
def row_xml(row_num, indices, style_id):
|
|
251
|
+
cells = []
|
|
252
|
+
for i, sst_index in enumerate(indices):
|
|
253
|
+
ref = f"{col_letter(i)}{row_num}"
|
|
254
|
+
cells.append(f'<c r="{ref}" s="{style_id}" t="s"><v>{sst_index}</v></c>')
|
|
255
|
+
# Excel 不会可靠地对程序生成的行自动增高,按中文显示宽度确定行高。
|
|
256
|
+
if row_num == 1:
|
|
257
|
+
height = 32
|
|
258
|
+
else:
|
|
259
|
+
record = (values or [{}] * len(row_indices))[row_num - 2]
|
|
260
|
+
lines = max((visual_lines(str(record.get(key) or ""), widths[key]) for key, _ in columns), default=1)
|
|
261
|
+
height = min(409, max(30, lines * 15 + 10))
|
|
262
|
+
return f'<row r="{row_num}" ht="{height}" customHeight="1">{"".join(cells)}</row>'
|
|
263
|
+
|
|
264
|
+
rows_xml_parts = [row_xml(1, header_indices, 1)]
|
|
265
|
+
for offset, indices in enumerate(row_indices):
|
|
266
|
+
rows_xml_parts.append(row_xml(offset + 2, indices, 2))
|
|
267
|
+
|
|
268
|
+
sheet_data = f"<sheetData>{''.join(rows_xml_parts)}</sheetData>"
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
272
|
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
|
273
|
+
f'<dimension ref="{dimension_ref}"/>'
|
|
274
|
+
'<sheetViews><sheetView workbookViewId="0">'
|
|
275
|
+
'<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>'
|
|
276
|
+
'</sheetView></sheetViews>'
|
|
277
|
+
f'{cols_xml}{sheet_data}<autoFilter ref="{dimension_ref}"/>'
|
|
278
|
+
'</worksheet>'
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def default_output_path() -> str:
|
|
283
|
+
"""未指定输出路径时,默认写到用户桌面"""
|
|
284
|
+
home = os.path.expanduser("~")
|
|
285
|
+
desktop = os.path.join(home, "Desktop")
|
|
286
|
+
if not os.path.isdir(desktop):
|
|
287
|
+
# 部分中文 Windows / OneDrive 环境桌面路径不同,找不到就退回主目录
|
|
288
|
+
desktop = home
|
|
289
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
290
|
+
return os.path.join(desktop, f"业务线策略生成_{stamp}_{uuid.uuid4().hex[:8]}.xlsx")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def visual_lines(text, width):
|
|
294
|
+
"""中日韩字符占两格,保守估算换行后高度。"""
|
|
295
|
+
return sum(max(1, math.ceil(sum(2 if unicodedata.east_asian_width(c) in 'WF' else 1 for c in line) / max(1, width - 2))) for line in text.split('\n'))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def prepare_layout(rows, details):
|
|
299
|
+
"""每条业务线与维度明细均保持原行,动作列只记录业务动作。"""
|
|
300
|
+
# 写入与回读校验都走相同清洗,避免源数据中的非法控制字符使 sharedStrings.xml 无法解析。
|
|
301
|
+
def clean(record):
|
|
302
|
+
return {key: _sanitize_xml_text(value) if isinstance(value, str) else value
|
|
303
|
+
for key, value in record.items()}
|
|
304
|
+
|
|
305
|
+
rows = [clean(r) for r in rows]
|
|
306
|
+
details = [clean(r) for r in details or []]
|
|
307
|
+
# 单元格全文保留;超过 Excel 字符上限时在写入前明确报错,避免隐式截断。
|
|
308
|
+
return rows, details
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
AI_STRATEGY_HEADINGS = ('预算策略', '广告产品策略', '受众策略', '创意策略')
|
|
312
|
+
AI_COLUMNS = ('一页速览', '预算策略标签', '预算策略', '广告产品策略标签',
|
|
313
|
+
'广告产品策略', '受众策略标签', '受众策略', '创意策略')
|
|
314
|
+
AI_SECTION_PATTERN = re.compile(r'(?m)^[ \t]*【(预算策略|广告产品策略|受众策略|创意策略)】[ \t]*$')
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def prepare_ai_sections(text):
|
|
318
|
+
"""只按顶层标题机械分段与排版,不分析 AI 文案的投放结论。"""
|
|
319
|
+
if not isinstance(text, str) or not text.strip():
|
|
320
|
+
raise ValueError('AI 策略正文为空')
|
|
321
|
+
normalized = _sanitize_xml_text(text.replace('\r\n', '\n').replace('\r', '\n'))
|
|
322
|
+
matches = list(AI_SECTION_PATTERN.finditer(normalized))
|
|
323
|
+
if [match.group(1) for match in matches] != list(AI_STRATEGY_HEADINGS):
|
|
324
|
+
raise ValueError('AI 策略必须按预算、广告产品、受众、创意顺序各包含一个独立标题')
|
|
325
|
+
# 顶层标题用于确定 Excel 列;正文及其内部换行完整保留在同一格。
|
|
326
|
+
sections = [normalized[:matches[0].start()].strip()]
|
|
327
|
+
sections.extend(normalized[match.end():matches[index + 1].start() if index + 1 < len(matches)
|
|
328
|
+
else len(normalized)].strip()
|
|
329
|
+
for index, match in enumerate(matches))
|
|
330
|
+
if any(not section for section in sections):
|
|
331
|
+
raise ValueError('AI 策略速览或策略段为空')
|
|
332
|
+
prepared = []
|
|
333
|
+
for section in sections:
|
|
334
|
+
parts, cursor = [], 0
|
|
335
|
+
for match in re.finditer(r'\*\*(.+?)\*\*', section, re.DOTALL):
|
|
336
|
+
if match.start() > cursor:
|
|
337
|
+
parts.append((section[cursor:match.start()], False))
|
|
338
|
+
parts.append((match.group(1), True))
|
|
339
|
+
cursor = match.end()
|
|
340
|
+
if cursor < len(section) or not parts:
|
|
341
|
+
parts.append((section[cursor:], False))
|
|
342
|
+
plain = ''.join(value for value, _bold in parts)
|
|
343
|
+
if len(plain.encode('utf-16-le')) // 2 > 32767:
|
|
344
|
+
raise ValueError('AI 策略单列超过 Excel 单元格 32767 字符上限')
|
|
345
|
+
prepared.append((plain, parts))
|
|
346
|
+
return prepared
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def ai_cell_values(ai_sections, strategy_row):
|
|
350
|
+
"""把主表三类标签原值插入对应 AI 正文左侧,不由 AI 重新选标签。"""
|
|
351
|
+
def tag_cell(key):
|
|
352
|
+
value = str(strategy_row.get(key) or '')
|
|
353
|
+
return value, [(value, False)]
|
|
354
|
+
|
|
355
|
+
return [ai_sections[0], tag_cell('budgetTags'), ai_sections[1],
|
|
356
|
+
tag_cell('productTags'), ai_sections[2], tag_cell('audienceTags'),
|
|
357
|
+
ai_sections[3], ai_sections[4]]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def build_ai_sheet_xml(ai_sections, strategy_row, ai_indices):
|
|
361
|
+
"""一行业务线横向放速览、三类标签及四段正文,并保留原生加粗。"""
|
|
362
|
+
cells = ai_cell_values(ai_sections, strategy_row)
|
|
363
|
+
headers, values = [], []
|
|
364
|
+
for number, (header_index, value_index) in enumerate(zip(*ai_indices)):
|
|
365
|
+
letter = col_letter(number)
|
|
366
|
+
headers.append(f'<c r="{letter}1" s="1" t="s"><v>{header_index}</v></c>')
|
|
367
|
+
values.append(f'<c r="{letter}2" s="2" t="s"><v>{value_index}</v></c>')
|
|
368
|
+
height = min(409, max(36, *(visual_lines(plain, 75) * 15 + 8 for plain, _ in cells)))
|
|
369
|
+
return ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
370
|
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
|
371
|
+
'<dimension ref="A1:H2"/>'
|
|
372
|
+
'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" '
|
|
373
|
+
'activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
|
|
374
|
+
'<cols><col min="1" max="1" width="55" customWidth="1"/>'
|
|
375
|
+
'<col min="2" max="2" width="22" customWidth="1"/>'
|
|
376
|
+
'<col min="3" max="3" width="78" customWidth="1"/>'
|
|
377
|
+
'<col min="4" max="4" width="22" customWidth="1"/>'
|
|
378
|
+
'<col min="5" max="5" width="78" customWidth="1"/>'
|
|
379
|
+
'<col min="6" max="6" width="22" customWidth="1"/>'
|
|
380
|
+
'<col min="7" max="8" width="78" customWidth="1"/></cols>'
|
|
381
|
+
f'<sheetData><row r="1" ht="30" customHeight="1">{"".join(headers)}</row>'
|
|
382
|
+
f'<row r="2" ht="{height}" customHeight="1">{"".join(values)}</row></sheetData>'
|
|
383
|
+
'<autoFilter ref="A1:H2"/></worksheet>')
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def write_xlsx(rows, output_path: str, details=None, *, ai_strategy=None) -> str:
|
|
387
|
+
if not rows:
|
|
388
|
+
raise ValueError('策略数据为空')
|
|
389
|
+
rows, details = prepare_layout(rows, details)
|
|
390
|
+
ai_sections = prepare_ai_sections(ai_strategy) if ai_strategy is not None else None
|
|
391
|
+
if ai_sections is not None and len(rows) != 1:
|
|
392
|
+
raise ValueError('AI 策略 sheet 必须对应唯一业务线')
|
|
393
|
+
# 保持单元格全文;超过Excel实际字符限制时明确失败,不拆行或静默丢字。
|
|
394
|
+
for row, columns in [(r, COLUMNS) for r in rows] + [(r, DETAIL_COLUMNS) for r in details]:
|
|
395
|
+
for key, _ in columns:
|
|
396
|
+
if len(str(row.get(key) or '').encode('utf-16-le')) // 2 > 32767:
|
|
397
|
+
raise ValueError(f'{key} 超过Excel单元格32767字符上限,请精简内容;不会拆分原始记录行')
|
|
398
|
+
sst_xml, header_indices, row_indices, detail_header, detail_rows, ai_indices = build_shared_strings(
|
|
399
|
+
rows, details, ai_cells=ai_cell_values(ai_sections, rows[0]) if ai_sections is not None else None
|
|
400
|
+
)
|
|
401
|
+
sheet_xml = build_sheet_xml(
|
|
402
|
+
header_indices,
|
|
403
|
+
row_indices,
|
|
404
|
+
COLUMNS,
|
|
405
|
+
content_keys=CONTENT_KEYS,
|
|
406
|
+
multiline_keys=MULTILINE_KEYS,
|
|
407
|
+
values=rows,
|
|
408
|
+
)
|
|
409
|
+
detail_xml = build_sheet_xml(
|
|
410
|
+
detail_header,
|
|
411
|
+
detail_rows,
|
|
412
|
+
DETAIL_COLUMNS,
|
|
413
|
+
content_keys={"name", "extra"},
|
|
414
|
+
multiline_keys={'id', *(code for code, _label, _is_percent in REPORT_METRICS)},
|
|
415
|
+
values=details,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
out_dir = os.path.dirname(output_path)
|
|
419
|
+
if out_dir and not os.path.isdir(out_dir):
|
|
420
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
421
|
+
|
|
422
|
+
with zipfile.ZipFile(output_path, "x", zipfile.ZIP_DEFLATED) as zf:
|
|
423
|
+
# 第三张表只在最终工作簿追加;草稿仍维持原两张表结构。
|
|
424
|
+
content_types = CONTENT_TYPES_XML
|
|
425
|
+
workbook_rels = WORKBOOK_RELS_XML
|
|
426
|
+
workbook_xml = WORKBOOK_XML
|
|
427
|
+
if ai_sections is not None:
|
|
428
|
+
content_types = content_types.replace('</Types>',
|
|
429
|
+
'<Override PartName="/xl/worksheets/sheet3.xml" '
|
|
430
|
+
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>')
|
|
431
|
+
workbook_rels = workbook_rels.replace('</Relationships>',
|
|
432
|
+
'<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
|
|
433
|
+
'Target="worksheets/sheet3.xml"/></Relationships>')
|
|
434
|
+
workbook_xml = workbook_xml.replace('</sheets>',
|
|
435
|
+
'<sheet name="AI策略" sheetId="3" r:id="rId5"/></sheets>')
|
|
436
|
+
zf.writestr("[Content_Types].xml", content_types)
|
|
437
|
+
zf.writestr("_rels/.rels", ROOT_RELS_XML)
|
|
438
|
+
zf.writestr("xl/workbook.xml", workbook_xml)
|
|
439
|
+
zf.writestr("xl/_rels/workbook.xml.rels", workbook_rels)
|
|
440
|
+
zf.writestr("xl/styles.xml", STYLES_XML)
|
|
441
|
+
zf.writestr("xl/sharedStrings.xml", sst_xml)
|
|
442
|
+
zf.writestr("xl/worksheets/sheet1.xml", sheet_xml)
|
|
443
|
+
zf.writestr("xl/worksheets/sheet2.xml", detail_xml)
|
|
444
|
+
if ai_sections is not None:
|
|
445
|
+
zf.writestr("xl/worksheets/sheet3.xml", build_ai_sheet_xml(ai_sections, rows[0], ai_indices))
|
|
446
|
+
|
|
447
|
+
return output_path
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def write_detail_workbook(details, output_path):
|
|
451
|
+
"""给 AI 单独导出只有明细 sheet 的工作簿,不暴露固定策略正文。"""
|
|
452
|
+
_rows, clean_details = prepare_layout([], details)
|
|
453
|
+
for row in clean_details:
|
|
454
|
+
for key, _label in DETAIL_COLUMNS:
|
|
455
|
+
if len(str(row.get(key) or '').encode('utf-16-le')) // 2 > 32767:
|
|
456
|
+
raise ValueError(f'明细 {key} 超过 Excel 单元格上限')
|
|
457
|
+
strings, _head, _rows, detail_head, detail_rows, _ai = build_shared_strings(
|
|
458
|
+
[], clean_details, include_strategy=False)
|
|
459
|
+
sheet = build_sheet_xml(detail_head, detail_rows, DETAIL_COLUMNS,
|
|
460
|
+
content_keys={'name', 'extra'},
|
|
461
|
+
multiline_keys={'id', *(code for code, _label, _is_percent in REPORT_METRICS)},
|
|
462
|
+
values=clean_details)
|
|
463
|
+
# 从现有标准库工作簿结构裁剪成单 sheet 包,不保留策略表的共享字符串。
|
|
464
|
+
content_types = re.sub(r'<Override PartName="/xl/worksheets/sheet2.xml"[^>]*/>\n?', '', CONTENT_TYPES_XML)
|
|
465
|
+
relationships = re.sub(r'<Relationship Id="rId4"[^>]*/>\n?', '', WORKBOOK_RELS_XML)
|
|
466
|
+
workbook = (WORKBOOK_XML.replace('<sheet name="策略" sheetId="1" r:id="rId1"/>', '')
|
|
467
|
+
.replace('<sheet name="明细" sheetId="2" r:id="rId4"/>',
|
|
468
|
+
'<sheet name="明细" sheetId="1" r:id="rId1"/>'))
|
|
469
|
+
out_dir = os.path.dirname(output_path)
|
|
470
|
+
if out_dir:
|
|
471
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
472
|
+
with zipfile.ZipFile(output_path, 'x', zipfile.ZIP_DEFLATED) as archive:
|
|
473
|
+
for name, value in (('[Content_Types].xml', content_types), ('_rels/.rels', ROOT_RELS_XML),
|
|
474
|
+
('xl/workbook.xml', workbook), ('xl/_rels/workbook.xml.rels', relationships),
|
|
475
|
+
('xl/styles.xml', STYLES_XML), ('xl/sharedStrings.xml', strings),
|
|
476
|
+
('xl/worksheets/sheet1.xml', sheet)):
|
|
477
|
+
archive.writestr(name, value)
|
|
478
|
+
return output_path
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def validate_detail_workbook(path, details):
|
|
482
|
+
"""回读 AI 输入,确认唯一 sheet 与本条明细逐格一致。"""
|
|
483
|
+
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
484
|
+
_rows, clean_details = prepare_layout([], details)
|
|
485
|
+
with zipfile.ZipFile(path) as archive:
|
|
486
|
+
if archive.testzip():
|
|
487
|
+
raise ValueError('AI 明细工作簿 ZIP 校验失败')
|
|
488
|
+
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
|
|
489
|
+
if [item.attrib['name'] for item in workbook.findall('s:sheets/s:sheet', ns)] != ['明细']:
|
|
490
|
+
raise ValueError('AI 输入工作簿只能包含明细 sheet')
|
|
491
|
+
strings = [''.join(item.itertext()) for item in ET.fromstring(archive.read('xl/sharedStrings.xml'))]
|
|
492
|
+
sheet = ET.fromstring(archive.read('xl/worksheets/sheet1.xml'))
|
|
493
|
+
actual = [[strings[int(cell.find('s:v', ns).text)] for cell in row]
|
|
494
|
+
for row in sheet.findall('s:sheetData/s:row', ns)]
|
|
495
|
+
expected = [[label for _key, label in DETAIL_COLUMNS]] + [
|
|
496
|
+
[str(row.get(key) or '') for key, _label in DETAIL_COLUMNS] for row in clean_details]
|
|
497
|
+
if actual != expected:
|
|
498
|
+
raise ValueError('AI 输入明细 sheet 内容不完整')
|
|
499
|
+
return {'details': len(clean_details)}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _read_ai_sheet(archive):
|
|
503
|
+
"""回读本导出器的唯一 AI 数据行,核对表名、关联路径、坐标与八列表头。"""
|
|
504
|
+
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
505
|
+
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
|
|
506
|
+
sheets = workbook.findall('s:sheets/s:sheet', ns)
|
|
507
|
+
if [sheet.get('name') for sheet in sheets] != ['策略', '明细', 'AI策略']:
|
|
508
|
+
raise ValueError('AI 策略工作簿 sheet 名称或顺序不完整')
|
|
509
|
+
relation_id = sheets[-1].get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id')
|
|
510
|
+
relations = ET.fromstring(archive.read('xl/_rels/workbook.xml.rels'))
|
|
511
|
+
targets = [item.get('Target') for item in relations if item.get('Id') == relation_id
|
|
512
|
+
and item.get('Type', '').endswith('/worksheet') and item.get('TargetMode') != 'External']
|
|
513
|
+
if targets != ['worksheets/sheet3.xml']:
|
|
514
|
+
raise ValueError('AI 策略 sheet 关联路径无效')
|
|
515
|
+
sheet = ET.fromstring(archive.read('xl/' + targets[0]))
|
|
516
|
+
rows = sheet.findall('s:sheetData/s:row', ns)
|
|
517
|
+
if len(rows) != 2 or [row.get('r') for row in rows] != ['1', '2']:
|
|
518
|
+
raise ValueError('AI 策略 sheet 必须有唯一业务线数据行')
|
|
519
|
+
strings = [''.join(node.text or '' for node in item.findall('.//s:t', ns))
|
|
520
|
+
for item in ET.fromstring(archive.read('xl/sharedStrings.xml'))]
|
|
521
|
+
actual = []
|
|
522
|
+
for index, row in enumerate(rows, 1):
|
|
523
|
+
cells = row.findall('s:c', ns)
|
|
524
|
+
if [cell.get('r') for cell in cells] != [f'{col_letter(i)}{index}' for i in range(len(AI_COLUMNS))]:
|
|
525
|
+
raise ValueError('AI 策略 sheet 八列坐标不完整')
|
|
526
|
+
values = []
|
|
527
|
+
for cell in cells:
|
|
528
|
+
reference = cell.find('s:v', ns)
|
|
529
|
+
if cell.get('t') != 's' or cell.find('s:f', ns) is not None or reference is None \
|
|
530
|
+
or not re.fullmatch(r'[0-9]+', reference.text or ''):
|
|
531
|
+
raise ValueError('AI 策略 sheet 必须引用共享字符串')
|
|
532
|
+
ident = int(reference.text)
|
|
533
|
+
if ident >= len(strings):
|
|
534
|
+
raise ValueError('AI 策略 sheet 共享字符串索引越界')
|
|
535
|
+
values.append(strings[ident])
|
|
536
|
+
actual.append(values)
|
|
537
|
+
if actual[0] != list(AI_COLUMNS):
|
|
538
|
+
raise ValueError('AI 策略 sheet 八列表头不完整')
|
|
539
|
+
return dict(zip(AI_COLUMNS, actual[1]))
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def read_ai_sheet(path):
|
|
543
|
+
"""上传前读取最终 Excel 的可见文字,不从生成前 payload 替代入库来源。"""
|
|
544
|
+
with zipfile.ZipFile(path) as archive:
|
|
545
|
+
if archive.testzip():
|
|
546
|
+
raise ValueError('Excel ZIP 校验失败')
|
|
547
|
+
return _read_ai_sheet(archive)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def validate_xlsx(path, rows, details, *, ai_strategy=None):
|
|
551
|
+
"""逐单元格回读,与布局后的完整输入比较,避免只检验文件存在。"""
|
|
552
|
+
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
|
553
|
+
expected = prepare_layout(rows, details)
|
|
554
|
+
with zipfile.ZipFile(path) as archive:
|
|
555
|
+
if archive.testzip():
|
|
556
|
+
raise ValueError('Excel ZIP 校验失败')
|
|
557
|
+
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
|
|
558
|
+
names = ['策略', '明细'] + (['AI策略'] if ai_strategy is not None else [])
|
|
559
|
+
if [s.attrib['name'] for s in workbook.findall('s:sheets/s:sheet', ns)] != names:
|
|
560
|
+
raise ValueError('Excel sheet 名称或顺序不完整')
|
|
561
|
+
strings = [''.join(item.itertext()) for item in ET.fromstring(archive.read('xl/sharedStrings.xml'))]
|
|
562
|
+
for index, (records, columns) in enumerate(zip(expected, (COLUMNS, DETAIL_COLUMNS)), 1):
|
|
563
|
+
sheet = ET.fromstring(archive.read(f'xl/worksheets/sheet{index}.xml'))
|
|
564
|
+
actual = [[strings[int(cell.find('s:v', ns).text)] for cell in row] for row in sheet.findall('s:sheetData/s:row', ns)]
|
|
565
|
+
target = [[label for _, label in columns]] + [[str(r.get(key) or '') for key, _ in columns] for r in records]
|
|
566
|
+
if actual != target:
|
|
567
|
+
raise ValueError(f'Excel 第 {index} 张表内容或行列数不完整')
|
|
568
|
+
if ai_strategy is not None:
|
|
569
|
+
if len(expected[0]) != 1:
|
|
570
|
+
raise ValueError('AI 策略 sheet 必须对应唯一业务线')
|
|
571
|
+
ai_sections = prepare_ai_sections(ai_strategy)
|
|
572
|
+
actual = _read_ai_sheet(archive)
|
|
573
|
+
cells = ai_cell_values(ai_sections, expected[0][0])
|
|
574
|
+
if actual != dict(zip(AI_COLUMNS, [plain for plain, _parts in cells])):
|
|
575
|
+
raise ValueError('AI 策略 sheet 横向八列内容、标签或行数不完整')
|
|
576
|
+
result = {'rows': len(expected[0]), 'details': len(expected[1])}
|
|
577
|
+
if ai_strategy is not None:
|
|
578
|
+
result['ai_columns'] = len(AI_COLUMNS)
|
|
579
|
+
return result
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def main() -> int:
|
|
583
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
584
|
+
parser.add_argument(
|
|
585
|
+
"--input", required=True, help="payload JSON 文件路径,传 - 表示从 stdin 读取"
|
|
586
|
+
)
|
|
587
|
+
parser.add_argument(
|
|
588
|
+
"--output",
|
|
589
|
+
default=None,
|
|
590
|
+
help="输出 .xlsx 路径;未传时用 payload.output_path,仍未传则默认桌面",
|
|
591
|
+
)
|
|
592
|
+
args = parser.parse_args()
|
|
593
|
+
|
|
594
|
+
if args.input == "-":
|
|
595
|
+
raw = sys.stdin.read()
|
|
596
|
+
else:
|
|
597
|
+
with open(args.input, "r", encoding="utf-8") as f:
|
|
598
|
+
raw = f.read()
|
|
599
|
+
|
|
600
|
+
payload = json.loads(raw)
|
|
601
|
+
rows = payload.get("rows", [])
|
|
602
|
+
if not isinstance(rows, list) or len(rows) == 0:
|
|
603
|
+
print(json.dumps({"ok": False, "error": "payload.rows 为空或不是数组"}, ensure_ascii=False))
|
|
604
|
+
return 1
|
|
605
|
+
|
|
606
|
+
output_path = args.output or payload.get("output_path") or default_output_path()
|
|
607
|
+
output_path = os.path.abspath(output_path)
|
|
608
|
+
details = payload.get("details") or []
|
|
609
|
+
if not isinstance(details, list):
|
|
610
|
+
details = []
|
|
611
|
+
|
|
612
|
+
write_xlsx(rows, output_path, details=details)
|
|
613
|
+
|
|
614
|
+
print(
|
|
615
|
+
json.dumps(
|
|
616
|
+
{
|
|
617
|
+
"ok": True,
|
|
618
|
+
"output_path": output_path,
|
|
619
|
+
"row_count": len(rows),
|
|
620
|
+
"detail_count": len(details),
|
|
621
|
+
},
|
|
622
|
+
ensure_ascii=False,
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
return 0
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
if __name__ == "__main__":
|
|
629
|
+
sys.exit(main())
|