@petercjl/taobao-ai-ops 0.1.0 → 0.1.2
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 +30 -22
- package/package.json +2 -7
- package/requirements.txt +1 -0
- package/skills/taobao-ai-operations/SKILL.md +8 -1
- package/src/cli.mjs +16 -6
- package/src/components.mjs +73 -0
- package/src/core.mjs +5 -1
- package/src/runtime.mjs +18 -16
- package/src/skill-manager.mjs +4 -4
- package/src/update.mjs +5 -5
- package/src/workflow.mjs +3 -3
- package/suite-manifest.json +186 -10
- package/skills/taobao-search-product-form/SKILL.md +0 -74
- package/skills/taobao-search-product-form/adapters/codex.json +0 -139
- package/skills/taobao-search-product-form/adapters/sealseek.json +0 -24
- package/skills/taobao-search-product-form/agents/openai.yaml +0 -3
- package/skills/taobao-search-product-form/capabilities.json +0 -148
- package/skills/taobao-search-product-form/references/SCHEMA.md +0 -3
- package/skills/taobao-search-product-form/references/index.md +0 -10
- package/skills/taobao-search-product-form/references/log.md +0 -35
- package/skills/taobao-search-product-form/references/queries/evaluate-white-label-forms.md +0 -3
- package/skills/taobao-search-product-form/references/queries/identify-advertisers.md +0 -5
- package/skills/taobao-search-product-form/references/queries/identify-geography.md +0 -5
- package/skills/taobao-search-product-form/references/queries/identify-price-bands.md +0 -3
- package/skills/taobao-search-product-form/references/queries/identify-product-forms.md +0 -3
- package/skills/taobao-search-product-form/references/queries/identify-title-roots.md +0 -5
- package/skills/taobao-search-product-form/references/report-output.md +0 -23
- package/skills/taobao-search-product-form/references/source-manifest.md +0 -37
- package/skills/taobao-search-product-form/references/title-lexicon.json +0 -55
- package/skills/taobao-search-product-form/references/topics/advertiser-analysis-method.md +0 -43
- package/skills/taobao-search-product-form/references/topics/geography-analysis-method.md +0 -23
- package/skills/taobao-search-product-form/references/topics/price-band-analysis-method.md +0 -27
- package/skills/taobao-search-product-form/references/topics/title-root-analysis-method.md +0 -37
- package/skills/taobao-search-product-form/references/topics/visual-form-method.md +0 -31
- package/skills/taobao-search-product-form/references/topics/white-label-evidence.md +0 -38
- package/skills/taobao-search-product-form/references/workflow-ledger.md +0 -28
- package/skills/taobao-search-product-form/report_runtime/VERSION +0 -1
- package/skills/taobao-search-product-form/report_runtime/renderer.py +0 -485
- package/skills/taobao-search-product-form/report_runtime/templates/workbench.html +0 -67
- package/skills/taobao-search-product-form/report_runtime/validator.py +0 -117
- package/skills/taobao-search-product-form/scripts/analyze.py +0 -119
- package/skills/taobao-search-product-form/scripts/analyze_advertisers.py +0 -352
- package/skills/taobao-search-product-form/scripts/analyze_geography.py +0 -316
- package/skills/taobao-search-product-form/scripts/analyze_price_bands.py +0 -257
- package/skills/taobao-search-product-form/scripts/analyze_title_roots.py +0 -327
- package/skills/taobao-search-product-form/scripts/assemble_opportunity_evidence.py +0 -322
- package/skills/taobao-search-product-form/scripts/build_report_viewmodel.py +0 -822
- package/skills/taobao-search-product-form/scripts/build_visual_review_viewmodel.py +0 -177
- package/skills/taobao-search-product-form/scripts/clean_search_export.mjs +0 -61
- package/skills/taobao-search-product-form/scripts/cluster_visual_forms.py +0 -193
- package/skills/taobao-search-product-form/scripts/compile_style_prototypes.py +0 -115
- package/skills/taobao-search-product-form/scripts/compile_visual_review.py +0 -60
- package/skills/taobao-search-product-form/scripts/compile_visual_selection.py +0 -148
- package/skills/taobao-search-product-form/scripts/prepare.py +0 -180
- package/skills/taobao-search-product-form/scripts/render_report.py +0 -31
- package/skills/taobao-search-product-form/scripts/validate_opportunity_cards.py +0 -84
- package/skills/taobao-search-product-form/scripts/validate_report_viewmodel.py +0 -152
- package/skills/taobao-search-product-form/scripts/visual_form_pipeline.py +0 -247
- package/skills/taobao-search-product-form/scripts/visual_observation_batches.py +0 -117
- package/skills/taobao-search-product-form/scripts/workflow_ledger.py +0 -177
- package/skills/yuce-category-opportunity-report/SKILL.md +0 -151
- package/skills/yuce-category-opportunity-report/adapters/codex.json +0 -113
- package/skills/yuce-category-opportunity-report/adapters/sealseek.json +0 -39
- package/skills/yuce-category-opportunity-report/agents/openai.yaml +0 -3
- package/skills/yuce-category-opportunity-report/assets/chart-views.js +0 -80
- package/skills/yuce-category-opportunity-report/assets/package.json +0 -1
- package/skills/yuce-category-opportunity-report/assets/report-template.html +0 -62
- package/skills/yuce-category-opportunity-report/capabilities.json +0 -48
- package/skills/yuce-category-opportunity-report/references/SCHEMA.md +0 -5
- package/skills/yuce-category-opportunity-report/references/data-contract.md +0 -40
- package/skills/yuce-category-opportunity-report/references/index.md +0 -6
- package/skills/yuce-category-opportunity-report/references/log.md +0 -29
- package/skills/yuce-category-opportunity-report/references/queries/assess-category-opportunity.md +0 -7
- package/skills/yuce-category-opportunity-report/references/source-manifest.md +0 -14
- package/skills/yuce-category-opportunity-report/references/topics/category-opportunity-method.md +0 -39
- package/skills/yuce-category-opportunity-report/scripts/build_report.py +0 -779
- package/skills/yuce-category-opportunity-report/scripts/market_routes.py +0 -139
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# Image-grounded form judgment
|
|
2
|
-
|
|
3
|
-
The upstream clean stage defines the product population. This module describes what is visible in its images; if a main object is ambiguous or appears outside the intended product boundary, flag it for that upstream decision rather than silently deleting its record. The image may disagree with a title or platform category.
|
|
4
|
-
|
|
5
|
-
## One semantic read, three selection dimensions
|
|
6
|
-
|
|
7
|
-
Before the first image call, create disjoint batches and record each readable product ID, image path/hash and batch ID. An original exceeding 10 MB is a `too_large` image-manifest item, retained in the natural-product evidence as `not_observed` and excluded from semantic batches and visual candidate groups; its title cannot substitute for an image observation. Record its ID and omission reason, then continue with the readable images. A visual worker receives the same questions and any provisional registry snapshot, but no closed list of allowable labels. For each ready product, capture one raw observation with `product_id`, `image_sha256`, `batch_id`, `status`, `visible_evidence`, and three independent dimensions:
|
|
8
|
-
|
|
9
|
-
1. `form`: one candidate structural class, evidence and visible traits such as outline, depth, divisions, size and handle/lid relationship. Assign `uncertain` if the structure cannot be resolved. Color alone never splits a structural class.
|
|
10
|
-
2. `style`: optional candidate product-design style, at least two concrete product-side cues, and a possible physical design delta. Keep main-image background, copy, lighting and graphic treatment in a separate `image_presentation` note. A style name derived only from one color is too weak and remains a cue rather than an accepted style class. Leave style unclassified when the product is visually generic or obscured.
|
|
11
|
-
3. `audience`: optional candidate group defined by a use task, visible task cues, an explicit evidence tier (`image_only`, `combined`, or `insufficient`), and a plausible product-side adaptation. A depicted egg is a task cue, not proof of the buyer's demographics or actual purchase behavior. A candidate audience requires a task that could change the product's size, structure, handling, set configuration or design. If there is no such evidence, leave it unclassified. Any later title/price corroboration is recorded as a separate join, not retroactively presented as image evidence.
|
|
12
|
-
|
|
13
|
-
The three dimensions are parallel selection paths. A style-led or audience-led physical product difference can constitute a new candidate merchandise proposition even when its base structural form matches another product. Distinguish candidate proposition from demonstrated opportunity: a single search snapshot cannot prove demand, profit or actual buyer identity.
|
|
14
|
-
|
|
15
|
-
Inspect each original cached image independently, working through small checkpoint batches. The contact sheet is an orientation and coverage-audit artifact, not the sole semantic input. Save each completed batch as an immutable, plan-bound checkpoint before moving on, and use the batch status command to resume after a context boundary. Every product remains independently keyed; the batch draft uses `{"schema_version":2,"observations":[{"product_id":"…","status":"tagged|uncertain","visible_evidence":"…","form":{"candidate":"…"},"style":{"candidate":null},"audience":{"candidate":null}}]}` with the evidence fields above when style or audience is proposed. The checkpoint command binds each row to its planned image hash and rejects missing or extra IDs. Reopening an unclear original is a QA exception to the single-pass aspiration; log it. Use bounded concurrent semantic workers or multi-image calls only when the host actually supports independent per-image understanding and their outputs can be reconciled; each worker owns disjoint product IDs. Unverified acceleration never blocks the serial path, and image-download concurrency does not count as semantic concurrency.
|
|
16
|
-
|
|
17
|
-
## Product-style prototype convergence after the image reads
|
|
18
|
-
|
|
19
|
-
The three raw observation dimensions are useful evidence, but the final classification answers a different question: **what distinguishable merchandise styles can the seller actually choose, compare, source or develop?** A prototype is a coherent real-product scheme described through several jointly visible cues: base object, construction and handling, key functional configuration, and (when material to selection) an integrated product-design language. A single feature count—body silhouette, lid, spout, color, surface texture or photographed food—is not itself a complete style. A prototype name should let a merchant picture the actual good, such as “锤纹雪平锅” or “蒸煮一体小锅”, without requiring the source title to complete its meaning.
|
|
20
|
-
|
|
21
|
-
1. Start with a varied image sample rather than a fixed category dictionary. Sketch candidate prototypes with a physical definition, sourceable selection difference, representative product IDs, inclusion and confusion boundaries. Use the original images and saved visible observations. The prototype registry is run-specific and can grow as new physical schemes appear.
|
|
22
|
-
2. Compare each later product with representative and boundary images. Assign it to an existing prototype when differences are ordinary size, color or selectable configuration variants. Create a new prototype when the whole structure, function set or distinctive finished-product design demands a different comparison or supplier-development task. Revisit earlier near neighbors after a new prototype is introduced. A real rare prototype remains visible with its small count.
|
|
23
|
-
3. A main image containing multiple recognizable goods can associate its listing with multiple prototypes; a hybrid good can likewise support more than one selection-relevant proposition. Preserve the product ID once in the population and record every justified membership. Keep ordinary variant details and single visual cues in separate fields so groups stay interpretable. Image presentation, graphic copy, inferred material, buyer demographic and unsupported use claims cannot create a prototype.
|
|
24
|
-
4. Keep out-of-object accessories, image-only promotional compositions, unresolved objects and unreadable images in explicit boundary/uncertain states. Do not silently delete their product IDs. Class membership is image-grounded; title, price, payment or shop data cannot decide the visual class.
|
|
25
|
-
5. Review group cohesion with representative, edge and low-confidence images, then compare near-neighbor groups for missed same-style merges. A group is useful when its members look like a coherent sellable product line and its distinction from another group would change the next selection investigation. Record merge/split reasons and remaining uncertainty; a smaller class count alone is not proof of quality.
|
|
26
|
-
|
|
27
|
-
Keep the raw observations immutable. A classification pass writes a versioned registry plus one decision for every planned ready product: `product_id`, `image_sha256`, `style_ids` (one or more), `variant_or_features`, `reason`, `confidence`, and optional `boundary_reason`. The registry records `style_id`, name, physical definition, selection difference, inclusion/boundary rule and representative IDs. The deterministic `compile_style_prototypes.py` validates this decision file against the original plan and observations, then outputs product-level labels, prototype summary and an audit. Re-aggregation may reuse the saved image-hash-bound observations; reopen an original when its description does not settle a consequential boundary. New product images still require their own semantic observation.
|
|
28
|
-
|
|
29
|
-
Use prototype groups in downstream joins and report navigation. Preserve structural form, design style and use-task observations as secondary evidence and filters. Reconcile `ready = observed = style-decided` and `too_large = not_observed` before full-run reporting; other unavailable-image reasons require diagnosis or retry. Count distinct product IDs for population and demand comparisons because prototype memberships can overlap. Join price, natural payment counts, shops and advertising only after the visual classes are finalized. This stage describes visible supply options, not buyer demand or entry feasibility. Return to Main Line Step 4 after this gate.
|
|
30
|
-
|
|
31
|
-
This phase describes visual supply, not buyer demand or product-entry feasibility. Join title roots, price, placement and other evidence in later modules before recommending a form.
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# From product observations to opportunity cards
|
|
2
|
-
|
|
3
|
-
## Question and candidate grain
|
|
4
|
-
|
|
5
|
-
The goal is to help a user decide what to research next, not to declare that a product can or cannot be sold. Begin with the image-grounded **whole-product style prototypes**: each is a coherent, sourceable merchandise option. Structural form, product-design cues and image-supported use tasks explain why a prototype is distinct and provide optional comparison cuts; a task becomes meaningful only when it implies a physical product difference, not merely new copy or a hero-image theme. An image-supported use task is not a verified buyer demographic.
|
|
6
|
-
|
|
7
|
-
Compare each prototype against its closest real-product alternatives and, where useful, the broader structural parent. Form–style and form–task intersections are secondary cuts when their member IDs and shop spread support interpretation. Candidate groups overlap, including listings that display more than one product style; their counts cannot be summed. Do not impose a permanent minimum count across categories. For sparse groups, report the observed products and roll the commercial comparison back to a better-supported comparable group. An unclassified task means insufficient image evidence, not absence of task demand. Preserve uncertain/non-target products outside candidate traction calculations until their boundary is reviewed.
|
|
8
|
-
|
|
9
|
-
## Five evidence questions
|
|
10
|
-
|
|
11
|
-
1. **Observed traction.** Examine distinct natural products and shops, the distribution of displayed `付款人数`, representative high-payment and ordinary products, and whether the group's apparent strength survives removal of its leading shop or product. A sum across product records is a descriptive index, not distinct buyers, monthly sales, units, conversion or GMV. A single search export cannot establish a growth trend or full-market share.
|
|
12
|
-
2. **Comparable price structure.** Use the run's fitted price bands to locate supply, then compare like products within the same structure and the closest observable configuration. Report price median and spread, not only an average. Search-page current price may select a different SKU or discount; a higher displayed price is not proven willingness to pay or margin.
|
|
13
|
-
3. **Merchant-line configuration.** For representative shops, contrast the candidate products with the same shop's other visible forms, versions and price tiers; identify natural-position strong products separately from products appearing in advertising positions. Describe the observed assortment. SKU roles, intentional product strategy and brand ownership remain hypotheses until separately verified. A multi-shop pattern has different implications from a group carried mainly by one shop.
|
|
14
|
-
4. **Competitive exposure.** Keep ad appearances, distinct ad products and distinct advertiser shops separate. Connect natural products to ads by product ID and exact shop name; preserve cross-shop name conflicts and ad-only products as unknown-form or unknown-payment cases. Advertising can indicate both seller attention and acquisition pressure; it does not measure spend, exposure or ROI.
|
|
15
|
-
5. **Product-side opening.** State which visible structural, material-appearance, color-system, handle, size or task-fit variation could make the proposed product different. Check whether existing merchants already cover it. Title roots are seller claims and discovery clues; image labels are visible observations; reviews, specifications, supplier quotes and tests are needed to establish user benefit, material truth, manufacturability and defects. Listing address may guide sourcing questions but does not identify a factory.
|
|
16
|
-
|
|
17
|
-
Compare competing explanations explicitly. For example, a candidate may show high displayed payment because of one established shop, a lower price, stronger natural ranking, a different underlying product configuration, or multiple shops independently selling it. Where the current export cannot distinguish these, present the alternatives and the discriminating next check. Do not smooth contradictions into an opaque score or infer that low ad presence means low competition.
|
|
18
|
-
|
|
19
|
-
## Opportunity-card contract
|
|
20
|
-
|
|
21
|
-
Create `opportunity_cards.json` with `schema_version: 2`, `source_evidence_sha256` (the current comparison JSON file's exact SHA-256), selected strategy/version and an array of cards. Each card's `candidate_id` resolves to a `product_style` candidate. Secondary form/style/task candidate IDs may appear as evidence references or comparison context. A readable Markdown rendering carries the same content.
|
|
22
|
-
|
|
23
|
-
| Field | Required meaning |
|
|
24
|
-
| --- | --- |
|
|
25
|
-
| `candidate_id`, `definition`, `parent_comparison` | Exact whole-product style member set and concrete product-side proposition, with the closest comparable prototype or broader structural form used for context. |
|
|
26
|
-
| `scope` | Source population, tagged/uncertain coverage, member product/shop counts, missing modules and search-snapshot limitations. |
|
|
27
|
-
| `observations` | Numeric traction, price, merchant-line, advertising, title and location facts, each linked to the compare artifact's field or product IDs. |
|
|
28
|
-
| `supporting_evidence`, `counterevidence` | Separate, traceable items; an empty side must say evidence is unavailable, not imply proof. |
|
|
29
|
-
| `interpretation` | A bounded explanation that considers shop, price and product-configuration alternatives. |
|
|
30
|
-
| `unknowns` | Missing facts that could reverse the current research priority. |
|
|
31
|
-
| `next_checks` | Specific review, SKU/price, cost, supplier or small-test questions; indicate what result would change the judgment. |
|
|
32
|
-
| `research_order` | Optional reasoned research sequence relative to other cards, never a binary market-entry decision or composite score. |
|
|
33
|
-
|
|
34
|
-
Every card should be readable without opening the raw JSON, while every named number and shop claim must trace back to the compare artifact. Keep observation, inference and proposed test visibly distinct. Include at least one representative product and one ordinary or counterexample product when available; show if the sample is too small for that comparison. The cards are complete when the user can inspect both the reason to investigate and the strongest reason that it may be misleading.
|
|
35
|
-
|
|
36
|
-
## Handoff boundary
|
|
37
|
-
|
|
38
|
-
The card may suggest a profit calculation or supplier investigation because those checks resolve uncertainty. It cannot conclude net margin, a verified unmet need, factory access, or an entry decision from a search snapshot. The broader product-development method separates demand, product differentiation, net profit, supplier standards and low-risk testing. Defer the last three to their own evidence-gathering stages; do not represent a request for research as authorization to buy, stock or publish.
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Stage ledger
|
|
2
|
-
|
|
3
|
-
The per-run `ledger.json` lives beside that run's data, not inside this Skill. It records two independent dimensions: `design_state` (`open`, `defined`, `validated`) describes how mature the stage method is; `status` describes what happened in this particular run. A stage can have a defined method without having been executed, or a provisional result awaiting review.
|
|
4
|
-
|
|
5
|
-
The initial stages are `input`, `clean`, `classify`, `compare`, `decide`, and `report`. Add a classification module with a stage ID such as `classify/placement` or `classify/visual-form` when that module is selected. These IDs are navigation handles, not a declaration of the module's eventual field schema or algorithm.
|
|
6
|
-
|
|
7
|
-
Statuses:
|
|
8
|
-
|
|
9
|
-
- `not_started`: no current result.
|
|
10
|
-
- `in_progress`: work begun, with intermediate artifacts if any.
|
|
11
|
-
- `awaiting_review`: development result exists and awaits the user's review.
|
|
12
|
-
- `approved`: the user confirmed the development result; record reviewer and what was approved.
|
|
13
|
-
- `verified`: run-mode result passed its automatic QA.
|
|
14
|
-
- `blocked`: cannot safely advance; record the reason.
|
|
15
|
-
- `skipped`: reserved for a documented development-stage experiment outside the complete run; record why when it could be mistaken for missing work.
|
|
16
|
-
|
|
17
|
-
Commands (resolve the script relative to the Skill directory):
|
|
18
|
-
|
|
19
|
-
```text
|
|
20
|
-
python3 scripts/workflow_ledger.py init --run-dir RUN_DIR --mode development --input WORKBOOK
|
|
21
|
-
python3 scripts/workflow_ledger.py show --ledger RUN_DIR/ledger.json
|
|
22
|
-
python3 scripts/workflow_ledger.py record --ledger RUN_DIR/ledger.json --stage clean --status awaiting_review --artifact OUTPUT_FILE --note "What was checked and what remains for review"
|
|
23
|
-
python3 scripts/workflow_ledger.py record --ledger RUN_DIR/ledger.json --stage clean --status approved --reviewer USER --note "What the user confirmed"
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
`record` also accepts `--design-state`, `--strategy` and repeated `--artifact`. Artifact paths are resolved to existing files and stored with size and SHA-256 so later work can detect changed inputs or intermediates. Each update adds an event and creates a recoverable backup of the previous ledger. `init` refuses to replace an existing ledger.
|
|
27
|
-
|
|
28
|
-
In development mode, a stage may be reviewed and approved independently while the complete workflow is being built. The next stage reads its actual approved artifacts and ledger scope. Record parent and downstream stages when their artifacts are produced; approval of a report does not automatically approve each source stage. If an input or strategy change makes a downstream result stale, mark that downstream stage accordingly with a reason before reusing it. In run mode, execute every required stage and module, update the ledger after each material transition, and continue after automated verification without routine user review. A development-stage review may stop after its current module and hand off the ledger plus artifacts.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
1.2.5
|
|
@@ -1,485 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Versioned Compact Commerce UI ViewModel renderer."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import base64
|
|
7
|
-
import hashlib
|
|
8
|
-
import html
|
|
9
|
-
import json
|
|
10
|
-
import pathlib
|
|
11
|
-
import re
|
|
12
|
-
from typing import Any
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
ROOT = pathlib.Path(__file__).resolve().parent
|
|
16
|
-
CONTRACT = "compact-workbench@1.0"
|
|
17
|
-
CLI_VERSION = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def esc(value: Any) -> str:
|
|
21
|
-
return html.escape(str(value if value is not None else ""), quote=True)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def slug(value: Any) -> str:
|
|
25
|
-
candidate = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(value or "")).strip("-")
|
|
26
|
-
if not candidate:
|
|
27
|
-
raise ValueError(f"Invalid empty identifier: {value!r}")
|
|
28
|
-
return candidate
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def mime_for(blob: bytes) -> str:
|
|
32
|
-
if blob.startswith(b"\xff\xd8\xff"):
|
|
33
|
-
return "image/jpeg"
|
|
34
|
-
if blob.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
35
|
-
return "image/png"
|
|
36
|
-
if blob.startswith((b"GIF87a", b"GIF89a")):
|
|
37
|
-
return "image/gif"
|
|
38
|
-
if blob.startswith(b"RIFF") and blob[8:12] == b"WEBP":
|
|
39
|
-
return "image/webp"
|
|
40
|
-
if blob.startswith(b"<svg") or b"<svg" in blob[:256]:
|
|
41
|
-
return "image/svg+xml"
|
|
42
|
-
raise ValueError("Unsupported image format; use JPEG, PNG, GIF, WebP, or SVG")
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def normalize_assets(raw: Any, input_dir: pathlib.Path) -> dict[str, dict[str, str]]:
|
|
46
|
-
if raw is None:
|
|
47
|
-
return {}
|
|
48
|
-
if isinstance(raw, dict):
|
|
49
|
-
items = [dict(value, id=key) for key, value in raw.items()]
|
|
50
|
-
elif isinstance(raw, list):
|
|
51
|
-
items = raw
|
|
52
|
-
else:
|
|
53
|
-
raise ValueError("assets must be an object or array")
|
|
54
|
-
result: dict[str, dict[str, str]] = {}
|
|
55
|
-
for item in items:
|
|
56
|
-
if not isinstance(item, dict) or not item.get("id") or not item.get("path"):
|
|
57
|
-
raise ValueError("each asset requires id and path")
|
|
58
|
-
asset_id = str(item["id"])
|
|
59
|
-
if asset_id in result:
|
|
60
|
-
raise ValueError(f"duplicate asset id: {asset_id}")
|
|
61
|
-
value = str(item["path"])
|
|
62
|
-
if re.match(r"https?://", value, re.I):
|
|
63
|
-
raise ValueError(f"remote rendering asset is forbidden: {asset_id}")
|
|
64
|
-
path = pathlib.Path(value).expanduser()
|
|
65
|
-
if not path.is_absolute():
|
|
66
|
-
path = input_dir / path
|
|
67
|
-
path = path.resolve()
|
|
68
|
-
if not path.is_file():
|
|
69
|
-
raise ValueError(f"asset not found: {asset_id} -> {path}")
|
|
70
|
-
blob = path.read_bytes()
|
|
71
|
-
mime = mime_for(blob)
|
|
72
|
-
result[asset_id] = {
|
|
73
|
-
"src": f"data:{mime};base64,{base64.b64encode(blob).decode('ascii')}",
|
|
74
|
-
"alt": str(item.get("alt", "")),
|
|
75
|
-
"role": str(item.get("role", "content")),
|
|
76
|
-
"sha256": hashlib.sha256(blob).hexdigest(),
|
|
77
|
-
}
|
|
78
|
-
return result
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
class Renderer:
|
|
82
|
-
def __init__(self, document: dict[str, Any], assets: dict[str, dict[str, str]]) -> None:
|
|
83
|
-
self.document = document
|
|
84
|
-
self.assets = assets
|
|
85
|
-
self.external_images = str((document.get("meta") or {}).get("external_images", "forbid"))
|
|
86
|
-
self.tab_counter = 0
|
|
87
|
-
|
|
88
|
-
def render_blocks(self, blocks: Any) -> str:
|
|
89
|
-
if blocks is None:
|
|
90
|
-
return ""
|
|
91
|
-
if not isinstance(blocks, list):
|
|
92
|
-
raise ValueError("blocks must be an array")
|
|
93
|
-
return "".join(self.render_block(block) for block in blocks)
|
|
94
|
-
|
|
95
|
-
def render_block(self, block: Any) -> str:
|
|
96
|
-
if not isinstance(block, dict):
|
|
97
|
-
raise ValueError("every block must be an object")
|
|
98
|
-
kind = block.get("type")
|
|
99
|
-
if kind == "conclusion":
|
|
100
|
-
return self.render_conclusion(block)
|
|
101
|
-
if kind == "alert":
|
|
102
|
-
tone = slug(block.get("tone", "warning"))
|
|
103
|
-
title = f"<strong>{esc(block.get('title'))}</strong> " if block.get("title") else ""
|
|
104
|
-
return f'<section class="alert tone-{tone} searchable">{title}<span>{esc(block.get("text", ""))}</span></section>'
|
|
105
|
-
if kind == "metrics":
|
|
106
|
-
columns = max(1, min(8, int(block.get("columns", 4))))
|
|
107
|
-
variant = slug(block.get("variant", "default"))
|
|
108
|
-
items = "".join(self.render_metric(item, variant) for item in block.get("items", []))
|
|
109
|
-
return f'<section class="metrics metrics-{columns} variant-{variant}">{items}</section>'
|
|
110
|
-
if kind == "grid":
|
|
111
|
-
columns = max(1, min(4, int(block.get("columns", 2))))
|
|
112
|
-
children = "".join(self.render_block(item) for item in block.get("items", []))
|
|
113
|
-
return f'<section class="module-grid grid-{columns}">{children}</section>'
|
|
114
|
-
if kind == "card":
|
|
115
|
-
title = esc(block.get("title", ""))
|
|
116
|
-
meta = esc(block.get("meta", ""))
|
|
117
|
-
head = f'<div class="card-head"><h2>{title}</h2><span>{meta}</span></div>' if title or meta else ""
|
|
118
|
-
body = self.render_blocks(block.get("blocks", []))
|
|
119
|
-
extra = slug(block.get("variant", "default"))
|
|
120
|
-
return f'<article class="card card-{extra} searchable">{head}<div class="card-body">{body}</div></article>'
|
|
121
|
-
if kind == "list":
|
|
122
|
-
ordered = bool(block.get("ordered"))
|
|
123
|
-
tag = "ol" if ordered else "ul"
|
|
124
|
-
items = "".join(f'<li class="searchable">{esc(item)}</li>' for item in block.get("items", []))
|
|
125
|
-
return f'<{tag} class="content-list">{items}</{tag}>'
|
|
126
|
-
if kind == "callout":
|
|
127
|
-
return f'<div class="callout searchable">{esc(block.get("text", ""))}</div>'
|
|
128
|
-
if kind == "text":
|
|
129
|
-
values = block.get("paragraphs")
|
|
130
|
-
if values is None:
|
|
131
|
-
values = [block.get("text", "")]
|
|
132
|
-
return '<div class="prose">' + "".join(f'<p class="searchable">{esc(value)}</p>' for value in values) + "</div>"
|
|
133
|
-
if kind == "table":
|
|
134
|
-
return self.render_table(block)
|
|
135
|
-
if kind == "tabs":
|
|
136
|
-
return self.render_tabs(block)
|
|
137
|
-
if kind == "products":
|
|
138
|
-
return self.render_products(block)
|
|
139
|
-
if kind == "stats":
|
|
140
|
-
items = "".join(f'<div class="mini-stat searchable"><span>{esc(x.get("label", ""))}</span><b>{esc(x.get("value", "—"))}</b></div>' for x in block.get("items", []))
|
|
141
|
-
return f'<div class="mini-stats">{items}</div>'
|
|
142
|
-
if kind == "badge":
|
|
143
|
-
tone = slug(block.get("tone", "info"))
|
|
144
|
-
return f'<span class="tag tone-{tone}">{esc(block.get("text", ""))}</span>'
|
|
145
|
-
if kind == "button":
|
|
146
|
-
label = esc(block.get("label", "打开"))
|
|
147
|
-
if block.get("target_view"):
|
|
148
|
-
return f'<button class="text-button" type="button" data-open-view="{esc(slug(block["target_view"]))}">{label}</button>'
|
|
149
|
-
if block.get("url"):
|
|
150
|
-
return f'<button class="text-button" type="button" data-url="{esc(block["url"])}">{label}</button>'
|
|
151
|
-
raise ValueError("button requires target_view or url")
|
|
152
|
-
if kind == "actions":
|
|
153
|
-
rows = []
|
|
154
|
-
for index, item in enumerate(block.get("items", []), 1):
|
|
155
|
-
rows.append(f'<div class="action-row searchable"><span class="action-rank">{index}</span><div><b>{esc(item.get("title", ""))}</b><p>{esc(item.get("text", ""))}</p></div><span class="tag tone-{esc(slug(item.get("tone", "info")))}">{esc(item.get("label", ""))}</span></div>')
|
|
156
|
-
return '<div class="action-list">' + "".join(rows) + "</div>"
|
|
157
|
-
if kind == "empty":
|
|
158
|
-
return f'<div class="empty">{esc(block.get("text", "暂无数据"))}</div>'
|
|
159
|
-
raise ValueError(f"unsupported block type: {kind!r}")
|
|
160
|
-
|
|
161
|
-
def render_conclusion(self, block: dict[str, Any]) -> str:
|
|
162
|
-
items = block.get("items")
|
|
163
|
-
if not isinstance(items, list) or len(items) < 2:
|
|
164
|
-
raise ValueError("conclusion requires at least two structured items")
|
|
165
|
-
valid_tones = {"info", "positive", "warning", "danger"}
|
|
166
|
-
valid_emphasis = {"normal", "strong", "italic", "strong-italic"}
|
|
167
|
-
cards = []
|
|
168
|
-
for item in items:
|
|
169
|
-
if not isinstance(item, dict) or not isinstance(item.get("label"), str) or not item["label"].strip():
|
|
170
|
-
raise ValueError("each conclusion item requires a nonempty label")
|
|
171
|
-
segments = item.get("segments")
|
|
172
|
-
if not isinstance(segments, list) or not segments:
|
|
173
|
-
raise ValueError("each conclusion item requires text segments")
|
|
174
|
-
tone = item.get("tone", "info")
|
|
175
|
-
if tone not in valid_tones:
|
|
176
|
-
raise ValueError(f"unsupported conclusion tone: {tone!r}")
|
|
177
|
-
rendered_segments = []
|
|
178
|
-
for segment in segments:
|
|
179
|
-
if not isinstance(segment, dict) or not isinstance(segment.get("text"), str) or not segment["text"]:
|
|
180
|
-
raise ValueError("each conclusion segment requires nonempty text")
|
|
181
|
-
emphasis = segment.get("emphasis", "normal")
|
|
182
|
-
segment_tone = segment.get("tone")
|
|
183
|
-
if emphasis not in valid_emphasis or (segment_tone is not None and segment_tone not in valid_tones):
|
|
184
|
-
raise ValueError("unsupported conclusion emphasis or tone")
|
|
185
|
-
classes = ["conclusion-segment", f"emphasis-{emphasis}"]
|
|
186
|
-
if segment_tone is not None:
|
|
187
|
-
classes.append(f"conclusion-color-{segment_tone}")
|
|
188
|
-
rendered_segments.append(f'<span class="{" ".join(classes)}">{esc(segment["text"])}</span>')
|
|
189
|
-
note = f'<div class="conclusion-note">{esc(item["note"])}</div>' if item.get("note") else ""
|
|
190
|
-
cards.append(
|
|
191
|
-
f'<article class="conclusion-item conclusion-tone-{tone} searchable">'
|
|
192
|
-
f'<h3>{esc(item["label"])}</h3>'
|
|
193
|
-
f'<p class="conclusion-copy">{"".join(rendered_segments)}</p>{note}</article>'
|
|
194
|
-
)
|
|
195
|
-
title = esc(block.get("title") or "核心结论")
|
|
196
|
-
return f'<section class="card conclusion-card"><div class="card-head"><h2>{title}</h2></div><div class="conclusion-grid">{"".join(cards)}</div></section>'
|
|
197
|
-
|
|
198
|
-
def render_metric(self, item: dict[str, Any], variant: str) -> str:
|
|
199
|
-
tone = slug(item.get("tone", "info"))
|
|
200
|
-
badge = f'<span class="metric-badge tone-{tone}">{esc(item.get("badge"))}</span>' if item.get("badge") else ""
|
|
201
|
-
return f'<article class="card metric metric-{variant} searchable"><div class="metric-label"><span>{esc(item.get("label", ""))}</span>{badge}</div><div class="metric-value">{esc(item.get("value", "—"))}</div><div class="metric-note">{esc(item.get("note", ""))}</div></article>'
|
|
202
|
-
|
|
203
|
-
def render_table(self, block: dict[str, Any]) -> str:
|
|
204
|
-
columns = block.get("columns", [])
|
|
205
|
-
if not columns:
|
|
206
|
-
raise ValueError("table requires columns")
|
|
207
|
-
labels = [column.get("label", column.get("key", "")) if isinstance(column, dict) else column for column in columns]
|
|
208
|
-
head = "".join(f"<th>{esc(label)}</th>" for label in labels)
|
|
209
|
-
rows = []
|
|
210
|
-
for row in block.get("rows", []):
|
|
211
|
-
if isinstance(row, dict):
|
|
212
|
-
values = [row.get(column.get("key")) if isinstance(column, dict) else row.get(str(column)) for column in columns]
|
|
213
|
-
else:
|
|
214
|
-
values = row
|
|
215
|
-
cells = "".join(f"<td>{self.render_cell(value)}</td>" for value in values)
|
|
216
|
-
rows.append(f'<tr class="searchable">{cells}</tr>')
|
|
217
|
-
title = esc(block.get("title", ""))
|
|
218
|
-
meta = esc(block.get("meta", ""))
|
|
219
|
-
headbar = f'<div class="card-head"><h2>{title}</h2><span>{meta}</span></div>' if title or meta else ""
|
|
220
|
-
return f'<article class="card table-card">{headbar}<div class="table-wrap"><table class="data-table"><thead><tr>{head}</tr></thead><tbody>{"".join(rows)}</tbody></table></div></article>'
|
|
221
|
-
|
|
222
|
-
def render_cell(self, value: Any) -> str:
|
|
223
|
-
if not isinstance(value, dict):
|
|
224
|
-
return esc(value if value is not None else "—")
|
|
225
|
-
text = esc(value.get("text", "—"))
|
|
226
|
-
image_url = str(value.get("image_url", "")).strip()
|
|
227
|
-
if image_url:
|
|
228
|
-
if self.external_images != "allow_https":
|
|
229
|
-
raise ValueError("table cell image_url requires meta.external_images=allow_https")
|
|
230
|
-
if not re.fullmatch(r'''https://[^\s"'<>]+''', image_url):
|
|
231
|
-
raise ValueError("table cell image_url must be an HTTPS URL")
|
|
232
|
-
text = (
|
|
233
|
-
'<div class="cell-product">'
|
|
234
|
-
f'<div class="cell-thumb"><img src="{esc(image_url)}" alt="{esc(value.get("alt") or value.get("text", ""))}" '
|
|
235
|
-
'loading="lazy" referrerpolicy="no-referrer" data-remote-image>'
|
|
236
|
-
'<div class="image-empty" hidden>加载失败</div></div>'
|
|
237
|
-
f'<span>{text}</span></div>'
|
|
238
|
-
)
|
|
239
|
-
if value.get("badge"):
|
|
240
|
-
text += f' <span class="tag tone-{esc(slug(value.get("tone", "info")))}">{esc(value["badge"])}</span>'
|
|
241
|
-
actions = []
|
|
242
|
-
if value.get("url"):
|
|
243
|
-
actions.append(f'<button class="text-button compact" type="button" data-url="{esc(value["url"])}">{esc(value.get("action_label", "打开"))}</button>')
|
|
244
|
-
if value.get("copy_value"):
|
|
245
|
-
actions.append(f'<button class="text-button compact copy-button" type="button" data-copy="{esc(value["copy_value"])}">{esc(value.get("copy_label", "复制"))}</button>')
|
|
246
|
-
if actions:
|
|
247
|
-
text += f'<div class="inline-actions">{"".join(actions)}</div>'
|
|
248
|
-
return text
|
|
249
|
-
|
|
250
|
-
def render_tabs(self, block: dict[str, Any]) -> str:
|
|
251
|
-
self.tab_counter += 1
|
|
252
|
-
group = f"tabs-{self.tab_counter}"
|
|
253
|
-
tabs = block.get("tabs", [])
|
|
254
|
-
if not tabs:
|
|
255
|
-
return self.render_block({"type": "empty", "text": block.get("empty_text", "暂无分类")})
|
|
256
|
-
buttons = []
|
|
257
|
-
panels = []
|
|
258
|
-
for index, tab in enumerate(tabs):
|
|
259
|
-
tab_id = slug(tab.get("id", f"tab-{index+1}"))
|
|
260
|
-
suffix = f" · {esc(tab.get('badge'))}" if tab.get("badge") not in (None, "") else ""
|
|
261
|
-
buttons.append(f'<button type="button" data-tab-target="{tab_id}" class="{"active" if index == 0 else ""}">{esc(tab.get("label", tab_id))}{suffix}</button>')
|
|
262
|
-
hidden = "" if index == 0 else " hidden"
|
|
263
|
-
panels.append(f'<div class="tab-panel" data-tab="{tab_id}"{hidden}>{self.render_blocks(tab.get("blocks", []))}</div>')
|
|
264
|
-
title = esc(block.get("title", ""))
|
|
265
|
-
meta = esc(block.get("meta", ""))
|
|
266
|
-
head = f'<div class="card-head"><h2>{title}</h2><span>{meta}</span></div>' if title or meta else ""
|
|
267
|
-
return f'<section class="card tabs-card" data-tabs="{group}">{head}<div class="tab-buttons">{"".join(buttons)}</div>{"".join(panels)}</section>'
|
|
268
|
-
|
|
269
|
-
def render_products(self, block: dict[str, Any]) -> str:
|
|
270
|
-
columns = max(1, min(4, int(block.get("columns", 3))))
|
|
271
|
-
items = block.get("items", [])
|
|
272
|
-
if not items:
|
|
273
|
-
return '<div class="empty">暂无候选商品</div>'
|
|
274
|
-
page_size = block.get("page_size")
|
|
275
|
-
if page_size is None:
|
|
276
|
-
products = "".join(self.render_product(item) for item in items)
|
|
277
|
-
return f'<div class="product-grid product-grid-{columns}">{products}</div>'
|
|
278
|
-
page_size = max(1, int(page_size))
|
|
279
|
-
options = block.get("sort_options", [])
|
|
280
|
-
option_html = "".join(
|
|
281
|
-
f'<option value="{esc(option["key"])}" data-direction="{esc(option.get("direction", "desc"))}">{esc(option["label"])}</option>'
|
|
282
|
-
for option in options
|
|
283
|
-
)
|
|
284
|
-
cards = "".join(
|
|
285
|
-
f'<div class="gallery-entry" data-source-order="{index}" '
|
|
286
|
-
f'data-sort-values="{esc(json.dumps(item.get("sort_values", {}), ensure_ascii=False, separators=(",", ":")))}">'
|
|
287
|
-
f'{self.render_product(item)}</div>'
|
|
288
|
-
for index, item in enumerate(items)
|
|
289
|
-
)
|
|
290
|
-
controls = (
|
|
291
|
-
'<div class="gallery-controls">'
|
|
292
|
-
f'<label>排序<select data-gallery-sort><option value="source">原始顺序</option>{option_html}</select></label>'
|
|
293
|
-
f'<span data-gallery-count>{len(items)} 件</span>'
|
|
294
|
-
'<div class="gallery-pager"><button type="button" data-page-prev>上一页</button>'
|
|
295
|
-
'<span data-page-status></span><button type="button" data-page-next>下一页</button></div>'
|
|
296
|
-
'</div>'
|
|
297
|
-
)
|
|
298
|
-
bottom = ('<div class="gallery-controls gallery-controls-bottom"><span data-gallery-count-bottom></span>'
|
|
299
|
-
'<div class="gallery-pager"><button type="button" data-page-prev-bottom>上一页</button>'
|
|
300
|
-
'<span data-page-status-bottom></span><button type="button" data-page-next-bottom>下一页</button>'
|
|
301
|
-
'</div></div>')
|
|
302
|
-
return (f'<section class="product-gallery" data-product-gallery data-page-size="{page_size}">'
|
|
303
|
-
f'{controls}<div class="product-grid product-grid-{columns}" data-gallery-grid>{cards}</div>'
|
|
304
|
-
f'{bottom}'
|
|
305
|
-
'</section>')
|
|
306
|
-
|
|
307
|
-
def render_product(self, item: dict[str, Any]) -> str:
|
|
308
|
-
asset_id = str(item.get("asset_id", ""))
|
|
309
|
-
asset = self.assets.get(asset_id)
|
|
310
|
-
image_url = str(item.get("image_url", "")).strip()
|
|
311
|
-
item_url = str(item.get("item_url", "")).strip()
|
|
312
|
-
if item_url and not re.fullmatch(r'''https://[^\s"'<>]+''', item_url):
|
|
313
|
-
raise ValueError("product item_url must be an HTTPS URL")
|
|
314
|
-
if asset:
|
|
315
|
-
image = (
|
|
316
|
-
f'<div class="product-media"><img src="{asset["src"]}" '
|
|
317
|
-
f'alt="{esc(item.get("alt") or asset.get("alt", ""))}"></div>'
|
|
318
|
-
)
|
|
319
|
-
elif image_url:
|
|
320
|
-
if self.external_images != "allow_https":
|
|
321
|
-
raise ValueError("product image_url requires meta.external_images=allow_https")
|
|
322
|
-
if not re.fullmatch(r'''https://[^\s"'<>]+''', image_url):
|
|
323
|
-
raise ValueError("product image_url must be an HTTPS URL")
|
|
324
|
-
image = (
|
|
325
|
-
f'<div class="product-media"><img src="{esc(image_url)}" alt="{esc(item.get("alt") or item.get("title", ""))}" '
|
|
326
|
-
'loading="lazy" referrerpolicy="no-referrer" data-remote-image>'
|
|
327
|
-
'<div class="image-empty" hidden>图片加载失败</div></div>'
|
|
328
|
-
)
|
|
329
|
-
else:
|
|
330
|
-
image = '<div class="product-media"><div class="image-empty">图片未提供</div></div>'
|
|
331
|
-
if item_url:
|
|
332
|
-
image = f'<a class="product-media-link" href="{esc(item_url)}" target="_blank" rel="noopener noreferrer" aria-label="打开商品:{esc(item.get("title", ""))}">{image}</a>'
|
|
333
|
-
title = esc(item.get("title", ""))
|
|
334
|
-
if item_url:
|
|
335
|
-
title = f'<a class="product-title-link" href="{esc(item_url)}" target="_blank" rel="noopener noreferrer">{title}</a>'
|
|
336
|
-
tone = slug(item.get("tone", "positive"))
|
|
337
|
-
facts = "".join(f'<div class="fact"><span>{esc(fact.get("label", ""))}</span><b>{esc(fact.get("value", "—"))}</b></div>' for fact in item.get("facts", []))
|
|
338
|
-
evidence = f'<div class="evidence">{esc(item.get("evidence"))}</div>' if item.get("evidence") else ""
|
|
339
|
-
actions = []
|
|
340
|
-
if item.get("url"):
|
|
341
|
-
actions.append(f'<button class="open-button" type="button" data-url="{esc(item["url"])}">{esc(item.get("action_label", "打开详情"))}</button>')
|
|
342
|
-
if item.get("copy_value"):
|
|
343
|
-
actions.append(f'<button class="open-button copy-button" type="button" data-copy="{esc(item["copy_value"])}">{esc(item.get("copy_label", "复制链接"))}</button>')
|
|
344
|
-
if item_url:
|
|
345
|
-
actions.append(f'<button class="open-button copy-button" type="button" data-copy="{esc(item_url)}">复制链接</button>')
|
|
346
|
-
action = f'<div class="product-actions">{"".join(actions)}</div>' if actions else ""
|
|
347
|
-
return f'<article class="card product-card searchable">{image}<div class="product-body"><div class="product-title">{title}</div><div class="product-meta"><span class="price">{esc(item.get("price", "—"))}</span><span class="tag tone-{tone}">{esc(item.get("badge", ""))}</span></div><div class="facts">{facts}</div>{evidence}{action}</div></article>'
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def validate_viewmodel(document: Any) -> dict[str, Any]:
|
|
351
|
-
if not isinstance(document, dict):
|
|
352
|
-
raise ValueError("ViewModel root must be an object")
|
|
353
|
-
if document.get("contract") != CONTRACT:
|
|
354
|
-
raise ValueError(f"contract must be {CONTRACT}")
|
|
355
|
-
meta = document.get("meta")
|
|
356
|
-
if not isinstance(meta, dict) or not meta.get("title"):
|
|
357
|
-
raise ValueError("meta.title is required")
|
|
358
|
-
views = document.get("views")
|
|
359
|
-
if not isinstance(views, list) or len(views) < 2:
|
|
360
|
-
raise ValueError("views must contain at least two top-level views")
|
|
361
|
-
ids = [slug(view.get("id")) for view in views if isinstance(view, dict)]
|
|
362
|
-
if len(ids) != len(views):
|
|
363
|
-
raise ValueError("every view must be an object with an id")
|
|
364
|
-
if len(ids) != len(set(ids)):
|
|
365
|
-
raise ValueError("view ids must be unique")
|
|
366
|
-
reading_path = (document.get("shell") or {}).get("reading_path", [])
|
|
367
|
-
for item in reading_path:
|
|
368
|
-
if slug(item.get("view_id")) not in ids:
|
|
369
|
-
raise ValueError(f"reading path references unknown view: {item.get('view_id')}")
|
|
370
|
-
for view in views:
|
|
371
|
-
if not view.get("label") or not view.get("title"):
|
|
372
|
-
raise ValueError(f"view {view.get('id')} requires label and title")
|
|
373
|
-
if not isinstance(view.get("blocks", []), list):
|
|
374
|
-
raise ValueError(f"view {view.get('id')} blocks must be an array")
|
|
375
|
-
return document
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
def render_file(input_value: pathlib.Path | str, output_value: pathlib.Path | str) -> dict[str, Any]:
|
|
379
|
-
input_path = pathlib.Path(input_value).expanduser().resolve()
|
|
380
|
-
output_path = pathlib.Path(output_value).expanduser().resolve()
|
|
381
|
-
if not input_path.is_file():
|
|
382
|
-
raise ValueError(f"input not found: {input_path}")
|
|
383
|
-
if output_path.exists():
|
|
384
|
-
raise ValueError(f"refusing to overwrite existing output: {output_path}")
|
|
385
|
-
document = validate_viewmodel(json.loads(input_path.read_text(encoding="utf-8")))
|
|
386
|
-
assets = normalize_assets(document.get("assets"), input_path.parent)
|
|
387
|
-
views = document["views"]
|
|
388
|
-
shell = document.get("shell", {})
|
|
389
|
-
sidebar_tabs = bool(shell.get("sidebar_tabs"))
|
|
390
|
-
|
|
391
|
-
def primary_tabs(view: dict[str, Any]) -> list[dict[str, Any]]:
|
|
392
|
-
for block in view.get("blocks", []):
|
|
393
|
-
if isinstance(block, dict) and block.get("type") == "tabs":
|
|
394
|
-
return block.get("tabs", [])
|
|
395
|
-
return []
|
|
396
|
-
|
|
397
|
-
nav_groups = []
|
|
398
|
-
for view in views:
|
|
399
|
-
view_id = slug(view["id"])
|
|
400
|
-
children = primary_tabs(view) if sidebar_tabs else []
|
|
401
|
-
child_html = ""
|
|
402
|
-
if children:
|
|
403
|
-
child_buttons = []
|
|
404
|
-
for index, tab in enumerate(children):
|
|
405
|
-
tab_id = slug(tab.get("id", f"tab-{index + 1}"))
|
|
406
|
-
badge = tab.get("badge")
|
|
407
|
-
badge_html = f'<span class="nav-child-badge">{esc(badge)}</span>' if badge not in (None, "") else ""
|
|
408
|
-
child_buttons.append(
|
|
409
|
-
f'<button type="button" class="nav-child" data-child-view="{esc(view_id)}" data-child-tab="{esc(tab_id)}">'
|
|
410
|
-
f'<span>{esc(tab.get("label", tab_id))}</span>{badge_html}</button>'
|
|
411
|
-
)
|
|
412
|
-
child_html = f'<div class="nav-children" data-nav-children="{esc(view_id)}">{"".join(child_buttons)}</div>'
|
|
413
|
-
chevron = '<span class="nav-chevron" aria-hidden="true">⌄</span>' if children else ""
|
|
414
|
-
nav_groups.append(
|
|
415
|
-
f'<div class="nav-group" data-nav-group="{esc(view_id)}">'
|
|
416
|
-
f'<button type="button" class="nav-parent" data-view-target="{esc(view_id)}"><span class="nav-dot"></span><span>{esc(view["label"])}</span>{chevron}</button>'
|
|
417
|
-
f'{child_html}</div>'
|
|
418
|
-
)
|
|
419
|
-
nav = "".join(nav_groups)
|
|
420
|
-
|
|
421
|
-
reading_items = []
|
|
422
|
-
for index, item in enumerate(shell.get("reading_path", []), 1):
|
|
423
|
-
tab_attr = f' data-reading-tab="{esc(slug(item["tab_id"]))}"' if item.get("tab_id") else ""
|
|
424
|
-
reading_items.append(
|
|
425
|
-
f'<button type="button" data-reading-view="{esc(slug(item["view_id"]))}"{tab_attr}>'
|
|
426
|
-
f'<span class="reading-step">{index}</span><span>{esc(item["label"])}</span></button>'
|
|
427
|
-
)
|
|
428
|
-
reading_path = (
|
|
429
|
-
'<nav class="reading-path" id="readingPath" aria-label="报告阅读路径">'
|
|
430
|
-
'<b>阅读路径</b><div class="reading-items">' + '<span class="reading-arrow">→</span>'.join(reading_items) + '</div></nav>'
|
|
431
|
-
if reading_items else ""
|
|
432
|
-
)
|
|
433
|
-
|
|
434
|
-
renderer = Renderer(document, assets)
|
|
435
|
-
rendered_views = []
|
|
436
|
-
for index, view in enumerate(views):
|
|
437
|
-
hidden = "" if index == 0 else " hidden"
|
|
438
|
-
badge = view.get("badge")
|
|
439
|
-
badge_html = ""
|
|
440
|
-
if badge:
|
|
441
|
-
badge_html = f'<span class="tag tone-{esc(slug(badge.get("tone", "info")))}">{esc(badge.get("text", ""))}</span>' if isinstance(badge, dict) else f'<span class="tag tone-info">{esc(badge)}</span>'
|
|
442
|
-
heading = f'<div class="page-heading"><div><div class="eyebrow">{esc(view.get("eyebrow", ""))}</div><h1>{esc(view["title"])}</h1><p>{esc(view.get("subtitle", ""))}</p></div>{badge_html}</div>'
|
|
443
|
-
content = renderer.render_blocks(view.get("blocks", []))
|
|
444
|
-
rendered_views.append(f'<section class="view" data-view="{esc(slug(view["id"]))}" id="view-{esc(slug(view["id"]))}" tabindex="-1"{hidden}>{heading}{content}</section>')
|
|
445
|
-
|
|
446
|
-
meta = document["meta"]
|
|
447
|
-
status = "".join(f"<div>{esc(line)}</div>" for line in shell.get("status", []))
|
|
448
|
-
search = '<input class="search" id="search" type="search" placeholder="{}">'.format(esc(shell.get("search_placeholder", "搜索当前视图"))) if shell.get("search", True) else ""
|
|
449
|
-
date = f'<div class="date-chip" id="dateRange">{esc(shell.get("date_range", ""))}</div>' if shell.get("date_range") else ""
|
|
450
|
-
replacements = {
|
|
451
|
-
"__TITLE__": esc(meta["title"]),
|
|
452
|
-
"__CLI_VERSION__": esc(CLI_VERSION),
|
|
453
|
-
"__CONTRACT__": esc(CONTRACT),
|
|
454
|
-
"__EXTERNAL_IMAGE_POLICY__": esc(str(meta.get("external_images", "forbid"))),
|
|
455
|
-
"__BRAND_MARK__": esc(shell.get("brand_mark", "CI")),
|
|
456
|
-
"__BRAND_TITLE__": esc(shell.get("brand_title", meta["title"])),
|
|
457
|
-
"__BRAND_SUBTITLE__": esc(shell.get("brand_subtitle", "Commerce Insight")),
|
|
458
|
-
"__TOP_TITLE__": esc(meta["title"]),
|
|
459
|
-
"__TOP_META__": esc(meta.get("subtitle", "")),
|
|
460
|
-
"__NAV_LABEL__": esc(shell.get("nav_label", "报告视图")),
|
|
461
|
-
"__NAV__": nav,
|
|
462
|
-
"__BODY_CLASS__": "sidebar-tabs" if sidebar_tabs else "",
|
|
463
|
-
"__READING_PATH__": reading_path,
|
|
464
|
-
"__STATUS__": status,
|
|
465
|
-
"__TOOLS__": search + date,
|
|
466
|
-
"__VIEWS__": "".join(rendered_views),
|
|
467
|
-
"__FOOTER__": esc(meta.get("footer", "")),
|
|
468
|
-
"__VIEW_IDS__": json.dumps([slug(view["id"]) for view in views], ensure_ascii=False),
|
|
469
|
-
}
|
|
470
|
-
template = (ROOT / "templates" / "workbench.html").read_text(encoding="utf-8")
|
|
471
|
-
for marker, value in replacements.items():
|
|
472
|
-
if marker not in template:
|
|
473
|
-
raise ValueError(f"template marker missing: {marker}")
|
|
474
|
-
template = template.replace(marker, value)
|
|
475
|
-
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
476
|
-
output_path.write_text(template, encoding="utf-8")
|
|
477
|
-
return {
|
|
478
|
-
"ok": True,
|
|
479
|
-
"output": str(output_path),
|
|
480
|
-
"cli_version": CLI_VERSION,
|
|
481
|
-
"contract": CONTRACT,
|
|
482
|
-
"views": len(views),
|
|
483
|
-
"assets_embedded": len(assets),
|
|
484
|
-
"bytes": output_path.stat().st_size,
|
|
485
|
-
}
|