From 1d19260673fdb9e8bfdc838ffe93074ae0ffb04c Mon Sep 17 00:00:00 2001 From: gozeros Date: Thu, 5 Nov 2026 11:03:03 +0800 Subject: [PATCH] sync snapshot from LAN repo (source dhk-v5 @ d1e57306) --- .gitignore | 27 +- AGENTS.md | 8 +- CLAUDE.md | 6 +- .../deer-web-clipper/background.js | 146 +- .../deer-web-clipper/background.test.cjs | 130 +- docs/API_PERFORMANCE_CONTROLS_ZH.md | 36 - docs/MODEL_PROMPT_SUFFIX_ZH.md | 21 - frontend-web/index.html | 22 +- frontend-web/package.json | 8 +- frontend-web/public/runtime-config.js | 6 +- frontend-web/scripts/test-auth-entry.mjs | 1 - frontend-web/scripts/test-users-paged.mjs | 90 - frontend-web/src/App.tsx | 35 +- frontend-web/src/assets/platform/dcard.png | Bin 8220 -> 0 bytes frontend-web/src/assets/platform/facebook.png | Bin 7745 -> 0 bytes frontend-web/src/assets/platform/ins.png | Bin 12553 -> 0 bytes frontend-web/src/assets/platform/mobile01.png | Bin 9233 -> 0 bytes frontend-web/src/assets/platform/ptt.png | Bin 7908 -> 0 bytes frontend-web/src/assets/platform/reddit.png | Bin 9818 -> 0 bytes frontend-web/src/assets/platform/threads.png | Bin 10477 -> 0 bytes frontend-web/src/assets/platform/x.png | Bin 9461 -> 0 bytes .../components/ai-elements/prompt-input.tsx | 19 +- .../assistant-ui/document-upload-popup.css | 67 - .../assistant-ui/document-upload-popup.tsx | 21 +- .../components/assistant-ui/network-actor.css | 169 -- .../components/assistant-ui/network-actor.tsx | 451 ++--- .../src/components/html-preview-frame.tsx | 1 - .../components/wiki/MarkdownCodeEditor.tsx | 14 +- .../workspace/agents/agent-card.tsx | 76 +- .../agents/agent-chat-link-fields.tsx | 224 --- .../workspace/agents/agent-detail-panel.tsx | 56 - .../artifacts/artifact-file-detail.tsx | 37 +- .../components/workspace/compact-welcome.tsx | 60 +- .../consumer-materials-export-trigger.tsx | 43 - .../src/components/workspace/input-box.tsx | 613 +++---- .../workspace/knowledge-product-nav.tsx | 87 +- .../workspace/messages/message-group.tsx | 506 +++--- .../workspace/messages/message-list.tsx | 13 +- .../settings/api-read-cache-settings-page.tsx | 284 +-- .../settings/app-identity-settings-page.tsx | 273 --- .../settings/appearance-settings-page.tsx | 535 +----- .../settings/chat-archive-settings-page.tsx | 37 +- .../settings/context-window-settings-page.tsx | 161 +- .../settings/model-prompt-settings-page.tsx | 110 -- .../retry-keep-partial-settings-page.tsx | 74 - .../roundtable-room-settings-page.tsx | 38 +- .../workspace/settings/settings-dialog.tsx | 22 +- .../src/components/workspace/thread-title.tsx | 8 +- .../workspace/visualizations/map-renderer.tsx | 394 +--- .../visualizations/map-style.test.ts | 116 -- .../workspace/visualizations/map-style.ts | 294 --- .../visualizations/visualization-spec.test.ts | 140 +- .../visualizations/visualization-spec.ts | 316 +--- .../components/workspace/workspace-header.tsx | 4 +- .../workspace/workspace-nav-chat-list.tsx | 56 +- .../workspace/workspace-nav-menu.tsx | 9 - frontend-web/src/core/admin/api.ts | 36 + frontend-web/src/core/admin/hooks.ts | 13 + .../src/core/agents/chat-link.test.ts | 41 - frontend-web/src/core/agents/chat-link.ts | 113 -- frontend-web/src/core/agents/types.ts | 32 - frontend-web/src/core/api/fetch-client.ts | 15 +- frontend-web/src/core/artifacts/hooks.ts | 46 +- frontend-web/src/core/artifacts/loader.ts | 20 +- frontend-web/src/core/auth/account-api.ts | 5 +- frontend-web/src/core/auth/api.ts | 97 +- frontend-web/src/core/auth/hooks.ts | 30 +- .../src/core/auth/login-transport.test.ts | 36 - frontend-web/src/core/auth/login-transport.ts | 23 - .../core/auth/task-action-call-sim.test.ts | 155 -- .../core/auth/task-action-structure.test.ts | 79 - frontend-web/src/core/auth/task-deeplink.ts | 330 +--- .../src/core/auth/task-entry.test.mjs | 65 - frontend-web/src/core/auth/task-entry.ts | 82 - frontend-web/src/core/auth/task-purpose.ts | 25 +- .../src/core/branding/app-brand-image.tsx | 55 - .../src/core/chat-archive-state.test.mjs | 48 - frontend-web/src/core/chat-archive-state.ts | 37 - .../src/core/embed/frame-context.test.ts | 16 - frontend-web/src/core/embed/frame-context.ts | 26 - .../llmwiki/knowledge-base-navigation.test.ts | 60 +- .../core/llmwiki/knowledge-base-navigation.ts | 48 +- .../core/llmwiki/knowledge-research.test.ts | 10 - .../src/core/llmwiki/knowledge-research.ts | 12 +- .../src/core/messages/utils-group.test.ts | 165 -- frontend-web/src/core/messages/utils.ts | 279 +-- frontend-web/src/core/model-management/api.ts | 44 - .../src/core/model-management/hooks.ts | 41 - .../compact-menu-overrides.test.ts | 20 +- .../page-layout/compact-menu-overrides.ts | 20 +- .../src/core/settings/composer-scope.test.ts | 148 -- .../src/core/settings/composer-scope.ts | 146 -- .../src/core/settings/composer-width.test.ts | 116 -- .../src/core/settings/composer-width.ts | 168 -- frontend-web/src/core/settings/local.ts | 184 +- .../src/core/stream-truncation-log/api.ts | 111 -- frontend-web/src/core/system-settings/api.ts | 220 +-- .../src/core/system-settings/hooks.ts | 94 - .../threads/consumer-thread-values-client.ts | 69 - .../threads/consumer-thread-values.test.ts | 52 - .../core/threads/consumer-thread-values.ts | 86 - frontend-web/src/core/threads/hooks.ts | 75 +- .../core/threads/retry-kept-messages.test.ts | 76 - .../src/core/threads/retry-kept-messages.ts | 43 - .../use-consumer-thread-values-save.ts | 70 - .../src/core/threads/use-multi-round-rag.ts | 1 - frontend-web/src/main.tsx | 3 - .../src/pages/AdminUserLeaderboardPage.tsx | 168 +- frontend-web/src/pages/AgentChatPage.tsx | 48 +- .../src/pages/ArtifactLibraryPage.tsx | 6 +- .../src/pages/BusinessChainMappingPage.tsx | 292 +-- frontend-web/src/pages/ChatPage.tsx | 69 +- frontend-web/src/pages/ChatsPage.tsx | 6 +- frontend-web/src/pages/DeepResearchPage.tsx | 4 +- frontend-web/src/pages/HtmlTemplatesPage.tsx | 562 ------ .../src/pages/KnowledgeResearchPage.tsx | 166 +- frontend-web/src/pages/LoginPage.tsx | 5 +- frontend-web/src/pages/LoginWhitelistPage.tsx | 104 +- frontend-web/src/pages/MenuManagementPage.tsx | 38 +- .../src/pages/ModelManagementPage.tsx | 145 -- frontend-web/src/pages/NewAgentPage.tsx | 41 +- .../src/pages/OfficialDeerFlowEntry.tsx | 15 +- .../src/pages/PromptPrefixSettingsPage.tsx | 42 +- frontend-web/src/pages/StrategyRoutes.tsx | 120 +- .../src/pages/StreamTruncationLogPage.tsx | 297 ---- frontend-web/src/pages/WorkspaceRoutes.tsx | 13 - .../pages/deep-research/DeepResearchEntry.tsx | 124 -- .../deep-research/DeepResearchWorkbench.tsx | 88 +- .../src/pages/knowledge-research-books.css | 71 - .../src/roundtable-planning/api/chains.ts | 7 +- .../components/BusinessChainListDialog.tsx | 71 +- .../components/Step2Panel.tsx | 54 +- .../components/Step3SummaryMode.tsx | 6 +- .../hooks/useStep3Summary.ts | 11 +- .../roundtable-planning/lib/business-chain.ts | 10 +- .../pages/BusinessChainEditorPage.tsx | 164 +- .../pages/BusinessChainListPage.tsx | 63 +- .../pages/RoundtablePlanningPage.tsx | 38 +- .../src/strategy-components/Header.tsx | 49 +- .../api/business-mapping.ts | 36 +- .../api/compact-menu-overrides.ts | 3 - .../strategy-components/api/html-templates.ts | 192 -- .../src/strategy-components/api/llmwiki.ts | 4 +- .../llmwiki/WeKnoraKnowledgeDetailPage.tsx | 39 +- .../llmwiki/WeKnoraKnowledgeWorkspace.tsx | 4 +- .../llmwiki/WeKnoraWikiManagementPage.tsx | 191 +- .../task-components/AgentFxWorkspace.tsx | 2 +- .../task-components/TaskAnalysisWorkspace.tsx | 2 +- .../task-components/TaskFxWorkspace.tsx | 2 +- .../task-components/TaskJumpBar.tsx | 4 +- .../task-components/TaskReportImportCard.tsx | 2 +- .../task-components/task-mock-data.ts | 66 - .../src/strategy-components/lib/light-app.ts | 20 - .../strategy-selection/AgentConfigDialog.tsx | 25 - .../strategy-agent-mapping.ts | 1 - .../strategy-agents.test.ts | 1 - .../src/strategy-selection/strategy-agents.ts | 2 - frontend-web/src/styles/globals.css | 22 - frontend-web/src/task-entry-main.ts | 115 -- frontend-web/src/utils/request.ts | 6 - frontend-web/task-entry.html | 88 - frontend-web/vite.config.ts | 14 - offline-backend-20260512/backend/CLAUDE.md | 138 +- offline-backend-20260512/backend/README.md | 29 +- .../backend/app/gateway/api_cache_catalog.py | 25 - .../backend/app/gateway/api_read_cache.py | 292 +-- .../backend/app/gateway/app.py | 36 +- .../app/gateway/auth/local_provider.py | 42 +- .../app/gateway/auth/repositories/base.py | 67 +- .../app/gateway/auth/repositories/sqlite.py | 256 ++- .../backend/app/gateway/catalog_cache.py | 66 - .../app/gateway/deep_research_job_executor.py | 4 +- .../backend/app/gateway/deps.py | 9 - .../backend/app/gateway/llmwiki_rag.py | 7 +- .../gateway/roundtable_inprocess_gateway.py | 2 - .../ai-writing-researcher/config.yaml | 7 +- .../app/gateway/routers/admin_users.py | 74 + .../backend/app/gateway/routers/agents.py | 119 +- .../app/gateway/routers/app_identity.py | 170 -- .../backend/app/gateway/routers/auth.py | 141 +- .../app/gateway/routers/browser_context.py | 19 +- .../app/gateway/routers/business_mapping.py | 56 +- .../app/gateway/routers/chat_archive.py | 243 +-- .../gateway/routers/compact_menu_overrides.py | 14 +- .../app/gateway/routers/deep_research.py | 3 +- .../app/gateway/routers/html_runtime.py | 8 +- .../app/gateway/routers/html_templates.py | 137 -- .../backend/app/gateway/routers/light_apps.py | 52 +- .../backend/app/gateway/routers/llmwiki.py | 2 +- .../app/gateway/routers/menu_overrides.py | 15 +- .../app/gateway/routers/model_management.py | 161 +- .../backend/app/gateway/routers/models.py | 9 +- .../app/gateway/routers/multi_agent.py | 14 - .../backend/app/gateway/routers/open_chat.py | 15 +- .../app/gateway/routers/parallel_agents.py | 3 +- .../app/gateway/routers/scheduled_tasks.py | 11 +- .../app/gateway/routers/strategy_agents.py | 16 +- .../app/gateway/routers/stream_truncations.py | 181 -- .../app/gateway/routers/system_settings.py | 249 +-- .../backend/app/gateway/routers/tags.py | 35 +- .../app/gateway/routers/task_buttons.py | 10 +- .../gateway/routers/task_deeplink_threads.py | 12 +- .../backend/app/gateway/routers/threads.py | 43 +- .../backend/app/gateway/routers/uploads.py | 131 +- .../backend/app/gateway/services.py | 8 - .../app/gateway/workflow_agent_runner.py | 3 +- offline-backend-20260512/backend/config.yaml | 26 +- .../agents/ai_writing/nodes/_skill_agent.py | 4 +- .../agents/ai_writing/nodes/researcher.py | 121 +- .../agents/ai_writing/search/skill_search.py | 6 +- .../deerflow/agents/deep_research/config.py | 3 - .../deerflow/agents/lead_agent/agent.py | 48 +- .../lead_agent/knowledge_research_prompt.py | 15 - .../deerflow/agents/lead_agent/prompt.py | 89 +- .../agents/middlewares/title_middleware.py | 477 +++-- .../harness/deerflow/config/agents_config.py | 134 +- .../deerflow/config/api_read_cache_config.py | 2 +- .../deerflow/config/html_runtime_config.py | 16 +- .../deerflow/config/system_settings.py | 206 +-- .../deerflow/html_runtime/addresses.py | 130 -- .../deerflow/html_runtime/public_images.py | 12 +- .../packages/harness/deerflow/mcp/cache.py | 45 - .../harness/deerflow/models/factory.py | 31 +- .../harness/deerflow/models/patched_openai.py | 759 ++++---- .../harness/deerflow/models/prompt_suffix.py | 121 -- .../deerflow/persistence/agents/base.py | 16 +- .../deerflow/persistence/agents/memory.py | 21 +- .../deerflow/persistence/agents/model.py | 4 - .../deerflow/persistence/agents/sql.py | 17 +- .../api_read_cache_settings/model.py | 10 +- .../persistence/business_mapping/model.py | 14 +- .../persistence/business_mapping/sql.py | 82 +- .../compact_menu_overrides/memory.py | 1 - .../compact_menu_overrides/model.py | 8 +- .../persistence/compact_menu_overrides/sql.py | 2 - .../20261029_01_api_read_cache_ttl.py | 44 - .../20261029_02_api_performance_controls.py | 32 - .../20261102_01_compact_menu_disabled.py | 41 - .../20261102_02_custom_business_mappings.py | 67 - .../deerflow/persistence/models/__init__.py | 2 - .../persistence/stream_truncation/__init__.py | 104 -- .../persistence/stream_truncation/base.py | 76 - .../persistence/stream_truncation/model.py | 80 - .../persistence/stream_truncation/sql.py | 175 -- .../persistence/task_deeplink_threads/base.py | 18 +- .../task_deeplink_threads/model.py | 2 +- .../runtime/checkpointer/chat_archive.py | 34 - .../harness/deerflow/runtime/runs/worker.py | 86 +- .../runtime/scheduler/template_fill.py | 77 +- .../runtime/scheduler/template_store.py | 328 ---- .../harness/deerflow/sandbox/prompt_policy.py | 43 - .../deerflow/sandbox/sandbox_provider.py | 31 +- .../harness/deerflow/subagents/executor.py | 13 +- .../harness/deerflow/tools/__init__.py | 4 +- .../builtins/deep_research_report_tool.py | 6 +- .../tools/builtins/llmwiki_reasoning_tools.py | 2 +- .../tools/builtins/llmwiki_search_tool.py | 2 +- .../builtins/page_report_template_tool.py | 12 - .../deerflow/tools/builtins/task_tool.py | 10 +- .../packages/harness/deerflow/tools/tools.py | 38 - .../harness/deerflow/utils/background_init.py | 43 - .../harness/deerflow/utils/file_conversion.py | 103 +- .../harness/deerflow/utils/file_io.py | 38 +- .../tests/test_agent_build_responsiveness.py | 458 ----- .../tests/test_mcp_background_build.py | 174 -- .../harness/tests/test_model_prompt_suffix.py | 277 --- ...t_patched_openai_write_file_text_bridge.py | 31 - .../test_sandbox_provider_concurrency.py | 151 -- .../_shared/.deerflow-skill-support.json | 13 - .../skills/public/_shared/fast_patch_page.py | 172 -- .../skills/public/bbc-page-generator/SKILL.md | 82 - .../bbc-page-generator/assets/bbc-runtime.js | 810 --------- .../assets/bbc-template.html | 87 - .../assets/default-data.json | 154 -- .../references/data-schema.md | 25 - .../bbc-page-generator/scripts/fast_page.py | 11 - .../scripts/render_bbc_page.py | 576 ------ .../skills/public/cnn-page-generator/SKILL.md | 83 - .../cnn-page-generator/assets/cnn-runtime.js | 792 --------- .../assets/cnn-template.html | 226 --- .../assets/default-data.json | 127 -- .../references/data-schema.md | 26 - .../cnn-page-generator/scripts/fast_page.py | 372 ---- .../scripts/render_cnn_page.py | 571 ------ .../public/facebook-page-generator/SKILL.md | 105 -- .../assets/default-data.json | 424 ----- .../assets/facebook-template.html | 1582 ----------------- .../references/data-schema.md | 76 - .../scripts/fast_single_post.py | 375 ---- .../scripts/render_facebook_page.py | 409 ----- .../public/gma-news-page-generator/SKILL.md | 85 - .../assets/default-data.json | 215 --- .../assets/gma-news-runtime.js | 315 ---- .../assets/gma-news-template.html | 58 - .../references/data-schema.md | 32 - .../scripts/fast_page.py | 11 - .../scripts/render_gma_news_page.py | 494 ----- .../manila-times-page-generator/SKILL.md | 85 - .../assets/default-data.json | 261 --- .../assets/manila-times-runtime.js | 519 ------ .../assets/manila-times-template.html | 69 - .../references/data-schema.md | 30 - .../scripts/fast_page.py | 11 - .../scripts/render_manila_times_page.py | 547 ------ .../public/threads-page-generator/SKILL.md | 91 - .../assets/default-data.json | 101 -- .../assets/threads-runtime.js | 832 --------- .../assets/threads-template.html | 379 ---- .../references/data-schema.md | 52 - .../scripts/fast_page.py | 356 ---- .../scripts/render_threads_page.py | 540 ------ .../public/tiktok-page-generator/SKILL.md | 82 - .../assets/default-data.json | 106 -- .../assets/tiktok-runtime.js | 776 -------- .../assets/tiktok-template.html | 364 ---- .../references/data-schema.md | 49 - .../scripts/fast_page.py | 11 - .../scripts/render_tiktok_page.py | 495 ------ .../white-house-page-generator/SKILL.md | 84 - .../assets/default-data.json | 151 -- .../assets/white-house-runtime.js | 695 -------- .../assets/white-house-template.html | 62 - .../references/data-schema.md | 28 - .../scripts/fast_page.py | 11 - .../scripts/render_white_house_page.py | 574 ------ .../skills/public/x-page-generator/SKILL.md | 107 -- .../x-page-generator/assets/default-data.json | 134 -- .../x-page-generator/assets/x-runtime.js | 855 --------- .../x-page-generator/assets/x-template.html | 453 ----- .../references/data-schema.md | 71 - .../scripts/fast_single_post.py | 376 ---- .../x-page-generator/scripts/render_x_page.py | 478 ----- .../public/xinhua-page-generator/SKILL.md | 83 - .../assets/default-data.json | 219 --- .../assets/xinhua-runtime.js | 708 -------- .../assets/xinhua-template.html | 87 - .../references/data-schema.md | 25 - .../scripts/fast_page.py | 11 - .../scripts/render_xinhua_page.py | 631 ------- .../yahoo-japan-page-generator/SKILL.md | 84 - .../assets/default-data.json | 138 -- .../assets/yahoo-japan-runtime.js | 297 ---- .../assets/yahoo-japan-template.html | 58 - .../references/data-schema.md | 31 - .../scripts/fast_page.py | 11 - .../scripts/render_yahoo_japan_page.py | 422 ----- .../yomiuri-news-page-generator/SKILL.md | 84 - .../assets/default-data.json | 125 -- .../assets/yomiuri-news-runtime.js | 259 --- .../assets/yomiuri-news-template.html | 48 - .../references/data-schema.md | 52 - .../scripts/fast_page.py | 11 - .../scripts/render_yomiuri_news_page.py | 444 ----- .../public/youtube-page-generator/SKILL.md | 83 - .../assets/default-data.json | 155 -- .../assets/youtube-runtime.js | 740 -------- .../assets/youtube-template.html | 329 ---- .../references/data-schema.md | 51 - .../scripts/fast_page.py | 11 - .../scripts/render_youtube_page.py | 512 ------ .../tests/test_account_authentication.py | 102 -- .../tests/test_admin_analytics_removed.py | 69 - .../backend/tests/test_agents_backend.py | 68 +- .../tests/test_ai_writing_knowledge_base.py | 74 - .../tests/test_api_performance_controls.py | 126 -- .../tests/test_api_performance_edge_cases.py | 201 --- .../backend/tests/test_api_read_cache.py | 114 +- .../backend/tests/test_app_identity.py | 151 -- .../tests/test_async_agent_entrypoints.py | 152 -- .../tests/test_async_tool_initialization.py | 176 -- .../tests/test_bbc_page_generator_skill.py | 164 -- .../backend/tests/test_business_mapping.py | 80 - .../tests/test_catalog_cache_routes.py | 194 -- .../tests/test_chat_archive_failures.py | 252 --- .../backend/tests/test_chat_step_limit.py | 136 -- .../tests/test_cnn_page_generator_skill.py | 240 --- .../tests/test_compact_menu_overrides.py | 29 +- .../tests/test_conversion_responsiveness.py | 306 ---- .../tests/test_deep_research_report_tool.py | 4 - .../tests/test_es_query_routing_prompt.py | 20 +- .../test_facebook_page_generator_skill.py | 409 ----- .../tests/test_fast_page_generator_specs.py | 240 --- .../tests/test_file_conversion_office.py | 13 - .../test_gma_news_page_generator_skill.py | 158 -- .../tests/test_html_runtime_addresses.py | 164 -- .../test_knowledge_space_strict_scope.py | 15 +- .../tests/test_llmwiki_smart_reasoning.py | 36 +- .../test_manila_times_page_generator_skill.py | 157 -- .../tests/test_migration_compatibility.py | 67 +- .../backend/tests/test_migration_graph.py | 2 +- .../backend/tests/test_model_management.py | 254 --- .../backend/tests/test_prompt_prefix.py | 136 +- .../tests/test_roundtable_room_limit.py | 31 - .../tests/test_strategy_project_settings.py | 78 - .../backend/tests/test_stream_truncation.py | 201 --- .../tests/test_task_deeplink_threads_api.py | 35 - .../test_threads_page_generator_skill.py | 254 --- .../tests/test_tiktok_page_generator_skill.py | 165 -- .../backend/tests/test_title_middleware.py | 40 - .../tests/test_upload_responsiveness.py | 407 ----- .../test_white_house_page_generator_skill.py | 152 -- .../tests/test_x_page_generator_skill.py | 373 ---- .../tests/test_xinhua_page_generator_skill.py | 149 -- .../test_yahoo_japan_page_generator_skill.py | 163 -- .../test_yomiuri_news_page_generator_skill.py | 160 -- .../test_youtube_page_generator_skill.py | 177 -- offline-backend-20260512/config.yaml | 8 +- offline-backend-20260512/start_backend.sh | 3 +- scripts/start_backend-online.sh | 1 - sites/circle-social-feed | 2 +- 410 files changed, 3576 insertions(+), 48328 deletions(-) delete mode 100644 docs/API_PERFORMANCE_CONTROLS_ZH.md delete mode 100644 docs/MODEL_PROMPT_SUFFIX_ZH.md delete mode 100644 frontend-web/scripts/test-users-paged.mjs delete mode 100644 frontend-web/src/assets/platform/dcard.png delete mode 100644 frontend-web/src/assets/platform/facebook.png delete mode 100644 frontend-web/src/assets/platform/ins.png delete mode 100644 frontend-web/src/assets/platform/mobile01.png delete mode 100644 frontend-web/src/assets/platform/ptt.png delete mode 100644 frontend-web/src/assets/platform/reddit.png delete mode 100644 frontend-web/src/assets/platform/threads.png delete mode 100644 frontend-web/src/assets/platform/x.png delete mode 100644 frontend-web/src/components/assistant-ui/document-upload-popup.css delete mode 100644 frontend-web/src/components/assistant-ui/network-actor.css delete mode 100644 frontend-web/src/components/workspace/agents/agent-chat-link-fields.tsx delete mode 100644 frontend-web/src/components/workspace/consumer-materials-export-trigger.tsx delete mode 100644 frontend-web/src/components/workspace/settings/app-identity-settings-page.tsx delete mode 100644 frontend-web/src/components/workspace/settings/model-prompt-settings-page.tsx delete mode 100644 frontend-web/src/components/workspace/settings/retry-keep-partial-settings-page.tsx delete mode 100644 frontend-web/src/components/workspace/visualizations/map-style.test.ts delete mode 100644 frontend-web/src/components/workspace/visualizations/map-style.ts delete mode 100644 frontend-web/src/core/agents/chat-link.test.ts delete mode 100644 frontend-web/src/core/agents/chat-link.ts delete mode 100644 frontend-web/src/core/auth/login-transport.test.ts delete mode 100644 frontend-web/src/core/auth/login-transport.ts delete mode 100644 frontend-web/src/core/auth/task-action-call-sim.test.ts delete mode 100644 frontend-web/src/core/auth/task-action-structure.test.ts delete mode 100644 frontend-web/src/core/auth/task-entry.test.mjs delete mode 100644 frontend-web/src/core/auth/task-entry.ts delete mode 100644 frontend-web/src/core/branding/app-brand-image.tsx delete mode 100644 frontend-web/src/core/chat-archive-state.test.mjs delete mode 100644 frontend-web/src/core/chat-archive-state.ts delete mode 100644 frontend-web/src/core/embed/frame-context.test.ts delete mode 100644 frontend-web/src/core/settings/composer-scope.test.ts delete mode 100644 frontend-web/src/core/settings/composer-scope.ts delete mode 100644 frontend-web/src/core/settings/composer-width.test.ts delete mode 100644 frontend-web/src/core/settings/composer-width.ts delete mode 100644 frontend-web/src/core/stream-truncation-log/api.ts delete mode 100644 frontend-web/src/core/threads/consumer-thread-values-client.ts delete mode 100644 frontend-web/src/core/threads/consumer-thread-values.test.ts delete mode 100644 frontend-web/src/core/threads/consumer-thread-values.ts delete mode 100644 frontend-web/src/core/threads/retry-kept-messages.test.ts delete mode 100644 frontend-web/src/core/threads/retry-kept-messages.ts delete mode 100644 frontend-web/src/core/threads/use-consumer-thread-values-save.ts delete mode 100644 frontend-web/src/pages/HtmlTemplatesPage.tsx delete mode 100644 frontend-web/src/pages/StreamTruncationLogPage.tsx delete mode 100644 frontend-web/src/pages/deep-research/DeepResearchEntry.tsx delete mode 100644 frontend-web/src/pages/knowledge-research-books.css delete mode 100644 frontend-web/src/strategy-components/api/html-templates.ts delete mode 100644 frontend-web/src/task-entry-main.ts delete mode 100644 frontend-web/task-entry.html delete mode 100644 offline-backend-20260512/backend/app/gateway/api_cache_catalog.py delete mode 100644 offline-backend-20260512/backend/app/gateway/catalog_cache.py delete mode 100644 offline-backend-20260512/backend/app/gateway/routers/app_identity.py delete mode 100644 offline-backend-20260512/backend/app/gateway/routers/html_templates.py delete mode 100644 offline-backend-20260512/backend/app/gateway/routers/stream_truncations.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/knowledge_research_prompt.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/html_runtime/addresses.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/models/prompt_suffix.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20261029_01_api_read_cache_ttl.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20261029_02_api_performance_controls.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20261102_01_compact_menu_disabled.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20261102_02_custom_business_mappings.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/stream_truncation/__init__.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/stream_truncation/base.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/stream_truncation/model.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/stream_truncation/sql.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/template_store.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/sandbox/prompt_policy.py delete mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/utils/background_init.py delete mode 100644 offline-backend-20260512/backend/packages/harness/tests/test_agent_build_responsiveness.py delete mode 100644 offline-backend-20260512/backend/packages/harness/tests/test_mcp_background_build.py delete mode 100644 offline-backend-20260512/backend/packages/harness/tests/test_model_prompt_suffix.py delete mode 100644 offline-backend-20260512/backend/packages/harness/tests/test_sandbox_provider_concurrency.py delete mode 100644 offline-backend-20260512/backend/skills/public/_shared/.deerflow-skill-support.json delete mode 100644 offline-backend-20260512/backend/skills/public/_shared/fast_patch_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/bbc-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/bbc-page-generator/assets/bbc-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/bbc-page-generator/assets/bbc-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/bbc-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/bbc-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/bbc-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/bbc-page-generator/scripts/render_bbc_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/cnn-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/cnn-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/cnn-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/cnn-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/cnn-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/cnn-page-generator/scripts/render_cnn_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/facebook-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/facebook-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/facebook-page-generator/assets/facebook-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/facebook-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/facebook-page-generator/scripts/fast_single_post.py delete mode 100644 offline-backend-20260512/backend/skills/public/facebook-page-generator/scripts/render_facebook_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/gma-news-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/gma-news-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/gma-news-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/gma-news-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/gma-news-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/gma-news-page-generator/scripts/render_gma_news_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/manila-times-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/manila-times-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/manila-times-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/manila-times-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/manila-times-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/manila-times-page-generator/scripts/render_manila_times_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/threads-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/threads-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/threads-page-generator/assets/threads-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/threads-page-generator/assets/threads-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/threads-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/threads-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/threads-page-generator/scripts/render_threads_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/tiktok-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/tiktok-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/tiktok-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/tiktok-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/tiktok-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/tiktok-page-generator/scripts/render_tiktok_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/white-house-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/white-house-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/white-house-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/white-house-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/white-house-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/white-house-page-generator/scripts/render_white_house_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/x-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/x-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/x-page-generator/assets/x-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/x-page-generator/assets/x-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/x-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/x-page-generator/scripts/fast_single_post.py delete mode 100644 offline-backend-20260512/backend/skills/public/x-page-generator/scripts/render_x_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/xinhua-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/xinhua-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/xinhua-page-generator/assets/xinhua-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/xinhua-page-generator/assets/xinhua-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/xinhua-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/xinhua-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/xinhua-page-generator/scripts/render_xinhua_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/yahoo-japan-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/yahoo-japan-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/scripts/render_yahoo_japan_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/yomiuri-news-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/yomiuri-news-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/scripts/render_yomiuri_news_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/youtube-page-generator/SKILL.md delete mode 100644 offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/default-data.json delete mode 100644 offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/youtube-runtime.js delete mode 100644 offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/youtube-template.html delete mode 100644 offline-backend-20260512/backend/skills/public/youtube-page-generator/references/data-schema.md delete mode 100644 offline-backend-20260512/backend/skills/public/youtube-page-generator/scripts/fast_page.py delete mode 100644 offline-backend-20260512/backend/skills/public/youtube-page-generator/scripts/render_youtube_page.py delete mode 100644 offline-backend-20260512/backend/tests/test_admin_analytics_removed.py delete mode 100644 offline-backend-20260512/backend/tests/test_api_performance_controls.py delete mode 100644 offline-backend-20260512/backend/tests/test_api_performance_edge_cases.py delete mode 100644 offline-backend-20260512/backend/tests/test_app_identity.py delete mode 100644 offline-backend-20260512/backend/tests/test_async_agent_entrypoints.py delete mode 100644 offline-backend-20260512/backend/tests/test_async_tool_initialization.py delete mode 100644 offline-backend-20260512/backend/tests/test_bbc_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_catalog_cache_routes.py delete mode 100644 offline-backend-20260512/backend/tests/test_chat_archive_failures.py delete mode 100644 offline-backend-20260512/backend/tests/test_chat_step_limit.py delete mode 100644 offline-backend-20260512/backend/tests/test_cnn_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_conversion_responsiveness.py delete mode 100644 offline-backend-20260512/backend/tests/test_facebook_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_fast_page_generator_specs.py delete mode 100644 offline-backend-20260512/backend/tests/test_gma_news_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_html_runtime_addresses.py delete mode 100644 offline-backend-20260512/backend/tests/test_manila_times_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_stream_truncation.py delete mode 100644 offline-backend-20260512/backend/tests/test_threads_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_tiktok_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_upload_responsiveness.py delete mode 100644 offline-backend-20260512/backend/tests/test_white_house_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_x_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_xinhua_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_yahoo_japan_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_yomiuri_news_page_generator_skill.py delete mode 100644 offline-backend-20260512/backend/tests/test_youtube_page_generator_skill.py diff --git a/.gitignore b/.gitignore index b63d5dd7..ff305bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -85,18 +85,14 @@ offline-backend-20260512/backend/.deer-flow/* # Per-user skill_evolution overrides — runtime data written by the API offline-backend-20260512/backend/skill_evolution.json -# Automatic config.yaml backups written by the admin models page on every save -offline-backend-20260512/backend/.model-config-backups/ skills/ hermes-agent-main/ .gitignore offline-backend-20260512/backend/tests/* !offline-backend-20260512/backend/tests/test_large_file_safety.py !offline-backend-20260512/backend/tests/test_account_authentication.py -!offline-backend-20260512/backend/tests/test_html_runtime_addresses.py -!offline-backend-20260512/backend/tests/test_app_identity.py !offline-backend-20260512/backend/tests/test_roundtable_room_limit.py -!offline-backend-20260512/backend/tests/test_admin_audit_retention_control.py +!offline-backend-20260512/backend/tests/test_admin_audit_retention_control.py # Seeder-owned Agent Skills are product source; keep them out of the broad # runtime `skills/` ignore above. !offline-backend-20260512/backend/app/gateway/routers/_*_seed_assets/skills/ @@ -154,8 +150,6 @@ offline-backend-20260512/backend/tests/* # Keep the model configuration regression coverage versioned even though # this deployment repository otherwise ignores newly added backend tests. !offline-backend-20260512/backend/tests/test_model_management.py -# Keep the model-stream truncation event (persistence + classification) coverage versioned. -!offline-backend-20260512/backend/tests/test_stream_truncation.py # Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. !offline-backend-20260512/backend/tests/test_forced_research_agent.py # Keep strategy-selection built-in agent seed and safety-contract regressions versioned. @@ -187,10 +181,6 @@ offline-backend-20260512/backend/tests/* # Keep database-backed upload-policy, enforcement and bounded-preview regressions versioned. !offline-backend-20260512/backend/tests/test_upload_policy.py !offline-backend-20260512/backend/tests/test_uploads_policy_enforcement.py -!offline-backend-20260512/backend/tests/test_upload_responsiveness.py -!offline-backend-20260512/backend/tests/test_conversion_responsiveness.py -!offline-backend-20260512/backend/tests/test_async_agent_entrypoints.py -!offline-backend-20260512/backend/tests/test_async_tool_initialization.py !offline-backend-20260512/backend/tests/test_artifact_preview.py # Independent enterprise-research persistence, HTTP and race regressions. !offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py @@ -271,13 +261,12 @@ offline-backend-20260512/backend/uv.lock !offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py !offline-backend-20260512/backend/tests/test_llmwiki_incremental_sync_scheduler.py -!offline-backend-20260512/backend/tests/test_worker_pool.py -!offline-backend-20260512/backend/tests/test_chat_step_limit.py -!offline-backend-20260512/backend/tests/test_worker_storage.py -!offline-backend-20260512/backend/tests/test_worker_discovery.py -!offline-backend-20260512/backend/tests/test_worker_diagnostics.py -!offline-backend-20260512/backend/tests/test_worker_deferred_admission.py -!offline-backend-20260512/backend/tests/test_db_loop_isolation.py +!offline-backend-20260512/backend/tests/test_worker_pool.py +!offline-backend-20260512/backend/tests/test_worker_storage.py +!offline-backend-20260512/backend/tests/test_worker_discovery.py +!offline-backend-20260512/backend/tests/test_worker_diagnostics.py +!offline-backend-20260512/backend/tests/test_worker_deferred_admission.py +!offline-backend-20260512/backend/tests/test_db_loop_isolation.py !offline-backend-20260512/backend/tests/test_chat_archive_postgres_integration.py # Local-only scratch directory (not committed) @@ -288,4 +277,4 @@ __pycache__/ # misc .DS_Store -.worker-pool/ +.worker-pool/ diff --git a/AGENTS.md b/AGENTS.md index 004d365e..e41bf6b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,10 +88,8 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) -- **智能体对话跳转**: 创建页、智能体管理编辑框和问答页详情面板都可以给智能体配置「对话跳转」链接、打开方式(新窗口 / 当前窗口)和路由参数(登录参数、主题参数,解析方式与应用管理相同)。配置保存在该智能体 `config.yaml` 的 `chat_link`,并镜像到 `agent_extras.chat_link` 供列表读取。留空时智能体管理的「对话」按钮仍进入本系统问答;填了地址后,按钮按解析后的链接跳转。 -- **模型流完整性检测 + 截断记录 (stream truncation)**: 模型配置页(`/page/workspace/admin/models`,`ModelManagementPage.tsx`)每张模型卡片新增「流完整性检测」开关 — `PUT /api/admin/models/strict-stream-integrity/{name}` 写 config.yaml 的 `strict_stream_integrity: true/false`(`app/gateway/routers/model_management.py`,仅 OpenAI 兼容 Chat Completions 模型可开;显式 `false` 也会覆盖 qwen3.8-flash-next 的自动开启;卡片显示值 = 显式配置 ?? qwen3.8-flash-next 自动规则,前端 `isAutoStrictIntegrityModel` 复刻后端判定)。开启后上游流未发终止帧就结束时抛可重试 `IncompleteChatStreamError`(`chat_stream_state.py`),LLM 错误中间件按 upstream 重试,避免半截回答静默入库。同时每次流异常结束都由 `patched_openai.py` 的 `_ToolCallStreamDiagnostics._record_truncation_event` 落库 `stream_truncation_events`(分类:`silent_no_terminal` 静默截断 / `incomplete_stream_error` 完整性拦截 / `length_truncated` / `content_filter` / `midstream_error`;write_file 兼容桥故意提前收流与用户取消不计;经 `fire_and_forget_db_write` 从任意 loop/thread 安全写入,store 在 `deerflow/persistence/stream_truncation/`,**每次写入顺手删除 30 天前的行,硬上限一个月**)。查看页 `pages/StreamTruncationLogPage.tsx`(路由 `/page/workspace/admin/stream-truncations`,admin gate 同模型配置页;简洁模式「设置和更多」下拉的「模型截断记录」入口在 `workspace-nav-menu.tsx`),client `core/stream-truncation-log/api.ts`;后端 API `GET /api/admin/stream-truncations`(分页 + 模型/类型过滤)与 `/breakdown`(按模型汇总,`app/gateway/routers/stream_truncations.py`)。测试 `tests/test_stream_truncation.py`、`tests/test_model_management.py`。配套「保留中间正文」(管理员设置弹窗同名分区,`GET/PUT /api/system-settings/retry-keep-partial`,默认开启,关闭即恢复旧行为):①正常路径——原先「消息带可见工具调用就不给正文气泡」的分组规则会把步骤条执行中已流出的正文清掉;现 `groupMessages` 新增 `keepIntermediateText` 选项(MessageList 按开关传入):本轮全部过程正文**合并成一个气泡**挂在步骤条正下方(合成消息剥思考/工具调用,组 id 加 `:keep-text` 后缀避免 React key 冲突),步骤条保持一条不被拆开(正文气泡对工具消息/后续轮次的累积「透明」,`lastOpenGroup`/`lastNonKeepBubbleGroup` 向前跳过),分组完成后把合并气泡**重排到 present-files/subagent/document-rewrite 附属组之后**(正文块与最终回答相邻、不被文件卡片隔开),仅本轮「最终正文消息」(回扫到的最后一条有正文且无可见工具调用的 ai 消息)保留独立气泡;②重试路径——LLM 错误中间件静默重试时,上一次尝试已流出的半截正文从未进 checkpoint、会被前端 values 对账清掉,`core/threads/hooks.ts` 在收到 `llm_retry` 事件时快照在途消息,被丢弃后按原位插回(纯函数 `core/threads/retry-kept-messages.ts`,快照只在本轮流式期间有效,刷新即失)。测试 `tests/test_strategy_project_settings.py` 尾部、`src/core/messages/utils-group.test.ts`、`src/core/threads/retry-kept-messages.test.ts`(`pnpm test:retry-kept`)。 - **知识库深度研究(普通知识库限定问答)**: 每个可读知识库从卡片或详情页进入 `/page/workspace/knowledge/:noteId/research`。它不复用原“深度研究”流程,而是把普通 Lead Agent 问答在服务端强制绑定到单个知识库、经典检索并关闭工具/记忆;私有会话与草稿仅创建者可见。首个完整回答自动成为 Markdown 草稿,后续回答须显式“应用为文档”。可写用户保存时在该知识库 Wiki 下创建保留根目录「深度研究」,用独立 publication id 的稳定 slug 创建/覆盖文章;文件名独立于问答标题。书架文章随知识库读权限共享,但“更多”及原问答仅创建者可见;只读用户可问答和整理草稿,不能发布。前端核心页 `pages/KnowledgeResearchPage.tsx`,client `strategy-components/api/knowledge-research.ts`;后端 REST 在 `app/gateway/routers/llmwiki.py`,运行时约束 `app/gateway/knowledge_research.py`,持久化 `deerflow/persistence/knowledge_research.py`。 -- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. **素材检索技能池限定白名单**:`researcher_node` 通用检索的候选池 = **ai-writing-researcher 智能体 config.yaml `skills` 白名单里的启用技能**(管理员在智能体管理页增删,内置 `knowledge-base-search` 保底且始终排最前),不再列全部启用技能——300+ 技能部署把选择提示词撑爆导致素材检索不可用;容错兜底 `_fallback_candidate_skills` 同样限定白名单;白名单未配置/加载失败回退仅内置知识库检索。实现 `deerflow/agents/ai_writing/nodes/researcher.py::_load_candidate_skills/_researcher_skill_whitelist`,测试 `tests/test_ai_writing_knowledge_base.py`。 +- **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin defaults + personal layouts)**: `MenuManagementPage` (`pages/MenuManagementPage.tsx`) is available from「设置和更多」and the global Header account dropdown. Administrators edit the shared default menu; ordinary users edit only their own full/compact menu snapshot. For users with a saved snapshot, known node ids keep the personal `label`/`disabled`/`parentId`/`sortOrder` (or compact placement/order), while administrator defaults for node ids absent from that snapshot flow through—so newly-added code menus and iframe light apps follow the current default without overwriting older personal choices. Temporarily absent dynamic nodes are preserved when an unrelated personal edit is saved;「恢复默认」deletes only that user's snapshot. The static tree (`core/page-layout/sidebar-menu.ts`) remains the source of icons/routes and administrator defaults remain sparse per-node deltas. Merge/snapshot helpers live in `core/page-layout/menu-overrides.ts` and `compact-menu-overrides.ts`; full-menu writes carry the complete effective parent hierarchy for cycle/strict three-level validation while persisting only the sparse admin delta or full personal snapshot. Backend `/api/menu-overrides` uses `menu_overrides` (default) + `user_menu_override_configs` (per-user snapshot); `/api/compact-menu-overrides` uses `compact_menu_overrides` (type `A` / `B` defaults) + `user_compact_menu_override_configs` (per-user/type snapshots). Each website selects its compact type via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`; `CompactToolbox` renders `toolbox` items. - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。 @@ -101,10 +99,8 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **技能上下线 (Skill Availability, owner/admin)**: the global `Skill.enabled` flag is the online/offline switch. Taking a skill offline persists `skills..enabled=false` in `extensions_config.json` but does **not** delete its directory or remove it from existing agent/workflow configurations; the row remains visible and bringing it online restores those references. Administrators may toggle every skill from 「技能管理」; ordinary users may toggle only their own non-built-in `custom` skills from 「技能 → 技能列表 → 我的」. Offline skills must be absent from every model-facing skill catalogue (`lead` prompt, `search_skills`, `skill_list`, `skill_view`, subagents, AI writing/deep research/roundtable and Workflow run-start validation), from agent skill pickers, and from direct sandbox access: `read_file`/`ls`/`glob`/`grep`/`bash` reject offline, unknown, root-wide and category-wide `/mnt/skills` paths. The backend mutation is `PUT /api/skills/{name}`, authorizes either an administrator or the persisted owner of the target custom skill, patches the raw document so MCP placeholders and unrelated extension fields survive, uses atomic replacement when supported plus a Docker single-file-bind fallback, and refreshes the skill prompt cache. Launchers keep the runtime extensions file in a writable persisted directory. Existing agent/strategy-agent references stay visible as 「已下线」 and may be removed but not newly selected. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. -- **HTML 模板管理 (HTML Template Management, admin-only)**: 定时任务「HTML 模板」库的可视化管理——列表/预览(iframe srcDoc sandbox)/在线编辑/上传 .html 覆盖/新建/删除/重置内置,改完**即时生效**(调度器每次执行直接读文件,无需重启)。模板从纯代码注册表升级为**文件型存储**:harness 纯引擎 `deerflow/runtime/scheduler/template_store.py`(`{base_dir}/html-templates//{template.html,meta.json}`,gitignored),app.py lifespan 把随包 `scheduler/templates/` 的全部内置模板(目前 27 个)seed 进存储——**目录缺失才复制,绝不覆盖管理页/线上修改**;`template_fill.py` 的 `get_template/list_templates/read_template_text` 改为存储优先、随包注册表兜底,写操作后 `invalidate_cache()` 清引擎 lru_cache。**convertible 自动探测**:上传/编辑正文时检测单行 `const DATA={…};` 注入行(当前引擎唯一注入点,`const MANIFEST=` 同理),带注入行即「可转换填充」(定时任务 JSON 转换管线零代码接入),否则「只展示」;数据存 ` diff --git a/frontend-web/package.json b/frontend-web/package.json index 66aeb370..cd6afc06 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -17,17 +17,13 @@ "test:retrieval-debug": "node --experimental-strip-types --test src/core/llmwiki/retrieval-debug.test.ts", "test:abort-signal": "node --experimental-strip-types --test src/core/compat/abort-signal.test.ts", "test:business-chain": "npx esbuild src/roundtable-planning/lib/business-chain.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/business-chain.test.mjs && node --test .runtime/unit-tests/business-chain.test.mjs", - "test:users-paged": "node scripts/test-users-paged.mjs", "test:auth-entry": "node scripts/test-auth-entry.mjs", "test:b5-1-theme": "node --experimental-strip-types --test src/core/auth/b5-1-theme.test.mjs", - "test:task-entry": "node --experimental-strip-types --test src/core/auth/task-entry.test.mjs", "test:html-preview": "pnpm exec esbuild src/core/utils/legacy-html-preview.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/legacy-html-preview.test.mjs && node --test .runtime/unit-tests/legacy-html-preview.test.mjs", - "test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts src/components/workspace/visualizations/map-style.test.ts", - "test:composer-session": "node --experimental-strip-types --test src/core/settings/composer-scope.test.ts", + "test:visualizations": "node --experimental-strip-types --test src/components/workspace/visualizations/visualization-spec.test.ts", "test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs", "check:chrome109-css": "node scripts/check-chrome109-css.mjs", - "check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs", - "test:retry-kept": "npx esbuild src/core/threads/retry-kept-messages.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/retry-kept-messages.test.mjs && node --test .runtime/unit-tests/retry-kept-messages.test.mjs" + "check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs" }, "dependencies": { "@codemirror/lang-css": "^6.3.1", diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index c02873c8..7224321d 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -42,7 +42,7 @@ window.__CM_RUNTIME_CONFIG__ = { // 地图瓦片地址模板:仅供普通问答消息列表中的交互地图使用。 // 后续接入内网瓦片服务时,只需替换此项并保留 {z}/{x}/{y} 占位符。 - VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png", + VITE_MAP_TILE_URL: "http://119.254.86.251:8023/tiles/{z}/{x}/{y}.png?v=zh-z14-13", // routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。 // 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。 @@ -112,9 +112,6 @@ window.__CM_RUNTIME_CONFIG__ = { // A 系统跳转地址:人物、组织、事件等列表项打开链接使用。 VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain", - // B 系统(XWT检索)头像代理;按部署环境设置为浏览器可访问的地址。 - VITE_AIPI_MINIO_PROXY_URL: "http://minio-proxy.b.sit.4.cn/facebook-proxy", - // B 系统 knowes 根地址。 VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes", @@ -131,7 +128,6 @@ window.__CM_RUNTIME_CONFIG__ = { VITE_USE_REAL_DATA: "true", // 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。 - // 为 true 时同时拦截 xdfx 的 task-action/actionDetail 与 mainnets/narratives-by-name。 VITE_MOCK_COP_TASK_DETAIL: "false", // 岗位会商完整演示数据:true=启动时幂等写入后端;后端不可用时直接使用前端 JSON 兜底。 diff --git a/frontend-web/scripts/test-auth-entry.mjs b/frontend-web/scripts/test-auth-entry.mjs index 71a3b5f8..71f016e5 100644 --- a/frontend-web/scripts/test-auth-entry.mjs +++ b/frontend-web/scripts/test-auth-entry.mjs @@ -14,7 +14,6 @@ const testEntries = { "login-entry": "src/core/auth/login-entry.test.ts", "session-validation": "src/core/auth/session-validation.test.ts", "auth-request-policy": "src/core/api/auth-request-policy.test.ts", - "login-transport": "src/core/auth/login-transport.test.ts", }; const outputDirectory = path.join(root, ".runtime/unit-tests"); diff --git a/frontend-web/scripts/test-users-paged.mjs b/frontend-web/scripts/test-users-paged.mjs deleted file mode 100644 index 70c26139..00000000 --- a/frontend-web/scripts/test-users-paged.mjs +++ /dev/null @@ -1,90 +0,0 @@ -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { test } from "node:test"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const root = fileURLToPath(new URL("../", import.meta.url)); -const require = createRequire(import.meta.url); -const viteRequire = createRequire(require.resolve("vite/package.json")); -const { build, stop } = viteRequire("esbuild"); -const outfile = path.join(root, ".runtime/unit-tests/users-paged-api.mjs"); -try { - await build({ - absWorkingDir: root, - entryPoints: ["src/core/auth/api.ts"], - outfile, - bundle: true, - platform: "node", - format: "esm", - plugins: [{ - name: "mock-user-api-dependencies", - setup(builder) { - builder.onResolve({ filter: /^@\/core\/(api\/fetch-client|config)$/ }, ({ path }) => ({ path, namespace: "mock" })); - builder.onLoad({ filter: /.*/, namespace: "mock" }, ({ path }) => ({ - contents: path.endsWith("config") - ? 'export const getBackendBaseURL = () => "https://example.test";' - : 'export const apiFetch = (...args) => globalThis.usersPagedFetch(...args);', - })); - }, - }], - }); -} finally { - stop(); -} -const { listUsersPaged } = await import(pathToFileURL(outfile).href); -const fetchPage = (signal) => listUsersPaged({ q: "alice", limit: 20, offset: 40 }, signal); - -test("whitelist requests pagination and search without statistics", async () => { - const users = { items: [{ id: "u1", email: "one@example.test", approved: false }], total: 41, approved_count: 40, limit: 20, offset: 40 }; - globalThis.usersPagedFetch = async (url, init) => { - assert.equal(url, "https://example.test/api/v1/auth/users?limit=20&offset=40&include_counts=false&q=alice"); - assert.ok(init.signal instanceof AbortSignal); - return Response.json(users); - }; - assert.deepEqual(await fetchPage(), users); -}); - -test("server timeout detail reaches the retry UI", async () => { - globalThis.usersPagedFetch = async () => Response.json({ detail: "用户基础信息查询超时,请稍后重试" }, { status: 503 }); - await assert.rejects(fetchPage(), /用户基础信息查询超时/); -}); - -test("client deadline aborts pending request and clears its timer", async (t) => { - let deadline; - const timer = {}; - t.mock.method(globalThis, "setTimeout", (callback, delay) => { - assert.equal(delay, 20_000); - deadline = callback; - return timer; - }); - const cleared = t.mock.method(globalThis, "clearTimeout", (value) => assert.equal(value, timer)); - globalThis.usersPagedFetch = (_url, { signal }) => new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); - }); - const pending = fetchPage(); - deadline(); - await assert.rejects(pending, /用户列表加载超时/); - assert.equal(cleared.mock.callCount(), 1); -}); - -test("query cancellation propagates without being reported as a timeout", async () => { - const query = new AbortController(); - globalThis.usersPagedFetch = (_url, { signal }) => new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); - }); - const pending = fetchPage(query.signal); - query.abort(); - await assert.rejects(pending, { name: "AbortError" }); -}); - -test("non-JSON errors preserve HTTP status", async () => { - globalThis.usersPagedFetch = async () => new Response("unavailable", { status: 502 }); - await assert.rejects(fetchPage(), /HTTP 502/); -}); -test("already cancelled requests remain cancellation errors", async () => { - const controller = new AbortController(); - controller.abort(); - globalThis.usersPagedFetch = async (_url, { signal }) => { signal.throwIfAborted(); }; - await assert.rejects(fetchPage(controller.signal), { name: "AbortError" }); -}); diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index f7b3c925..cadac840 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -6,7 +6,6 @@ import { DesensitizeWordsLoader } from "@/components/desensitize-words-loader"; import { FontScaleApplier } from "@/components/font-scale"; import { QueryClientProvider } from "@/components/query-client-provider"; import { RouteLoadingFallback } from "@/components/route-loading"; -import { Toaster } from "@/components/ui/sonner"; import { AuthSyncWatcher } from "@/core/auth/sync"; import { RequireAccount } from "@/core/auth/require-account"; import { I18nProvider } from "@/core/i18n/context"; @@ -37,7 +36,6 @@ import { const LandingPage = lazy(() => import("./pages/LandingPage")); const LoginPage = lazy(() => import("./pages/LoginPage")); const PageRoutes = lazy(() => import("./pages/PageRoutes")); -const MarkdownDocxEditorPage = lazy(() => import("./pages/MarkdownDocxEditorPage")); const PublicScheduledTaskPage = lazy(() => import("./pages/PublicScheduledTaskPage")); const PublicScheduledTaskFullscreenPage = lazy(() => import("./pages/PublicScheduledTaskPage").then((module) => ({ @@ -83,7 +81,6 @@ function AppProviders({ children }: { children: React.ReactNode }) { const location = useLocation(); const publicEmbedTheme = usePublicEmbedTheme(); const positionStandaloneMode = isPositionRoundtableStandaloneMode(); - const markdownToolMode = location.pathname.replace(/\/+$/, "") === "/tools/md-to-word"; // iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页 // 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。 useEffect(() => { @@ -92,9 +89,9 @@ function AppProviders({ children }: { children: React.ReactNode }) { // 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作 // (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。 useEffect(() => { - if (positionStandaloneMode || markdownToolMode) return; + if (positionStandaloneMode) return; void fetchYLogConfig(); - }, [positionStandaloneMode, markdownToolMode]); + }, [positionStandaloneMode]); const forcedTheme = useMemo(() => { // b5-1 任务分析深链最高优先:接入方传 ?theme= 用传入值,不传默认深蓝。 // 先于 embed 判定,避免 embed=1 缺省 light 盖掉本页的深蓝默认。 @@ -138,17 +135,15 @@ function AppProviders({ children }: { children: React.ReactNode }) { forcedTheme={forcedTheme} > - + - {markdownToolMode ? children : ( - - - {!positionStandaloneMode && } - - {children} - - - )} + + + {!positionStandaloneMode && } + + {children} + + @@ -203,16 +198,6 @@ export function App() { } /> } /> - {/* Anonymous standalone tool: no workspace navigation or login gate. */} - - - - - } - /> } /> } /> oV&iYx7V+`r)SkH+BI4SNr7M@lN2#j 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 Hu0mjf*jlMW diff --git a/frontend-web/src/assets/platform/ins.png b/frontend-web/src/assets/platform/ins.png deleted file mode 100644 index 1e4b3a20fa2c4cec2468f1c930cd1a97243d3210..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12553 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 diff --git a/frontend-web/src/assets/platform/mobile01.png b/frontend-web/src/assets/platform/mobile01.png deleted file mode 100644 index 9e0fb0f098d949f443905f786b2f3732dfb0dda9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9233 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 diff --git a/frontend-web/src/assets/platform/ptt.png b/frontend-web/src/assets/platform/ptt.png deleted file mode 100644 index 5baeb18dea795fb5b4cc5b41da9f6bc695f5efb9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7908 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 O00003u8!^<7tP=RtkZv|8P??ERV2YVt;Cv4y(y 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 AHUIzs diff --git a/frontend-web/src/assets/platform/threads.png b/frontend-web/src/assets/platform/threads.png deleted file mode 100644 index 86a16e6d3f68f0cba01ed60d4800fb9f9b548848..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10477 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 a6;9|^Y96&?t220949H3qvI|M(B}5I9u; diff --git a/frontend-web/src/assets/platform/x.png b/frontend-web/src/assets/platform/x.png deleted file mode 100644 index ccc76296c58fca679fe05b25f6023dfb3af229b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9461 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 Hu0mjf4uKD< diff --git a/frontend-web/src/components/ai-elements/prompt-input.tsx b/frontend-web/src/components/ai-elements/prompt-input.tsx index 84d70b1a..7bd8e714 100644 --- a/frontend-web/src/components/ai-elements/prompt-input.tsx +++ b/frontend-web/src/components/ai-elements/prompt-input.tsx @@ -998,24 +998,11 @@ export const PromptInputTextarea = ({ // Chrome 109 ignores field-sizing: content. Preserve the same 4rem–12rem // autosize range used by min-h-16/max-h-48 instead of leaving the prompt - // stuck at its minimum height. A custom appearance height replaces that range. - let minBound = 64; - let maxBound = 192; - if (textarea.hasAttribute("data-composer-height")) { - const computed = window.getComputedStyle(textarea); - const minHeight = Number.parseFloat(computed.minHeight); - const maxHeight = Number.parseFloat(computed.maxHeight); - if (Number.isFinite(minHeight)) minBound = minHeight; - if (Number.isFinite(maxHeight)) maxBound = maxHeight; - } + // stuck at its minimum height. textarea.style.height = "auto"; - const height = Math.max( - minBound, - Math.min(textarea.scrollHeight, maxBound), - ); + const height = Math.max(64, Math.min(textarea.scrollHeight, 192)); textarea.style.height = `${height}px`; - textarea.style.overflowY = - textarea.scrollHeight > maxBound ? "auto" : "hidden"; + textarea.style.overflowY = textarea.scrollHeight > 192 ? "auto" : "hidden"; }, []); // onInput does not run for a controlled value filled by a suggested question diff --git a/frontend-web/src/components/assistant-ui/document-upload-popup.css b/frontend-web/src/components/assistant-ui/document-upload-popup.css deleted file mode 100644 index 261bd054..00000000 --- a/frontend-web/src/components/assistant-ui/document-upload-popup.css +++ /dev/null @@ -1,67 +0,0 @@ -/* DialogContent is rendered in a portal. Use the root theme selectors directly - * so deep-blue styles work regardless of Tailwind's variant compilation order. */ -:root.dark .document-upload-dialog, -:root[theme-mode="dark"] .document-upload-dialog { - border-color: #2d4473 !important; - background-color: #0c1836 !important; - color: #c5cfe0 !important; -} - -:root.dark .document-upload-dialog__search, -:root[theme-mode="dark"] .document-upload-dialog__search { - border-color: #2d4473 !important; - background-color: #122347 !important; -} - -:root.dark .document-upload-dialog__search:hover, -:root[theme-mode="dark"] .document-upload-dialog__search:hover { - border-color: #4a6491 !important; -} - -:root.dark .document-upload-dialog__search-input, -:root[theme-mode="dark"] .document-upload-dialog__search-input { - color: #c5cfe0 !important; -} - -:root.dark .document-upload-dialog__search-input::placeholder, -:root[theme-mode="dark"] .document-upload-dialog__search-input::placeholder, -:root.dark .document-upload-dialog__search-icon, -:root[theme-mode="dark"] .document-upload-dialog__search-icon { - color: #6e88b0 !important; -} - -:root.dark .document-upload-dialog__search-button, -:root[theme-mode="dark"] .document-upload-dialog__search-button { - background-color: #1677ff !important; -} - -:root.dark .document-upload-dialog__search-button:hover, -:root[theme-mode="dark"] .document-upload-dialog__search-button:hover { - background-color: #0958d9 !important; -} - -:root.dark .document-upload-dialog__tabs, -:root[theme-mode="dark"] .document-upload-dialog__tabs { - border-color: #2d4473 !important; - background-color: #122347 !important; -} - -:root.dark .document-upload-dialog__tab, -:root[theme-mode="dark"] .document-upload-dialog__tab { - color: #6e88b0 !important; -} - -:root.dark .document-upload-dialog__tab:hover, -:root[theme-mode="dark"] .document-upload-dialog__tab:hover { - color: #c5cfe0 !important; -} - -:root.dark .document-upload-dialog__tab--active, -:root[theme-mode="dark"] .document-upload-dialog__tab--active { - color: #91caff !important; -} - -:root.dark .document-upload-dialog__tab-indicator, -:root[theme-mode="dark"] .document-upload-dialog__tab-indicator { - background-color: #4096ff !important; -} diff --git a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx index 2e80a0f7..22283ae9 100644 --- a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx +++ b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx @@ -27,7 +27,6 @@ import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { getStoredAuth } from "@/core/auth"; import { readRuntimeEnv } from "@/env"; -import "./document-upload-popup.css"; import { queryClient } from "@/components/query-client-provider"; import { Checkbox } from "@/components/ui/checkbox"; import Empty from "./empty"; @@ -600,13 +599,13 @@ const DocumentUploadPopupContent: FC<{ }; const yUserId = getYUserId(); - const notebookToken = getNotebookToken(); - const iframeTokenParam = notebookToken ? `&token=${encodeURIComponent(notebookToken)}` : ""; + const userToken = getStoredAuth()?.access_token; + const iframeTokenParam = userToken ? `&token=${encodeURIComponent(userToken)}` : ""; return ( e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} > @@ -616,7 +615,7 @@ const DocumentUploadPopupContent: FC<{ ").join(""); - qs("#industryTitle").textContent = data.industryTitle || ""; - qs("#industryMore").textContent = data.industryMoreLabel || ""; - qs("#industryGrid").innerHTML = (data.industries || []).map((item) => '

' + escapeHTML(item.title) + '更多

    ' + (item.items || []).map((title) => "
  • " + escapeHTML(title) + "
  • ").join("") + "
").join(""); - }; - let activeProvince = ""; - const renderLocal = () => { - const data = pageData.local; - const provinces = data.provinces || []; - if (!provinces.some((item) => item.id === activeProvince)) activeProvince = provinces[0]?.id || ""; - qs("#localTitle").textContent = data.title || ""; - qs("#localMore").textContent = data.moreLabel || ""; - qs("#provinceBar").innerHTML = "" + escapeHTML(data.selectorLabel) + "" + provinces.map((item) => '").join(""); - const province = provinces.find((item) => item.id === activeProvince) || provinces[0]; - qs("#localContent").innerHTML = province ? '
' + visualSvg(province.media || province.visual, province.leadTitle) + '
' + escapeHTML(province.name) + "频道

" + escapeHTML(province.leadTitle) + "

" + escapeHTML(province.summary) + '

' + (province.links || []).map((title) => '' + escapeHTML(title) + "").join("") + "
" : ""; - }; - const renderServices = () => { - const data = pageData.services; - qs("#mediaMatrix").innerHTML = '

' + escapeHTML(data.matrixTitle) + "

" + escapeHTML(data.matrixSubtitle) + "

" + (data.matrixItems || []).map((item) => '' + iconSvg(item.icon) + "" + escapeHTML(item.title) + "" + escapeHTML(item.caption) + "").join(""); - const modal = data.liveModal || {}; - qs("#liveTitle").textContent = modal.title || ""; - qs("#streamTitle").textContent = modal.streamTitle || ""; - qs("#streamSubtitle").textContent = modal.streamSubtitle || ""; - qs("#currentProgram").textContent = modal.currentProgram || ""; - const newsletter = data.newsletter || {}; - qs("#newsletterTitle").textContent = newsletter.title || ""; - qs("#newsletterText").textContent = newsletter.text || ""; - qs("#email").placeholder = newsletter.placeholder || ""; - qs("#newsletterButton").textContent = newsletter.button || ""; - }; - const renderFooter = () => { - const data = pageData.footer; - qs("#footerLinks").innerHTML = navMarkup(data.links, false); - qs("#copyright").textContent = data.copyright || ""; - qs("#legal").textContent = data.legal || ""; - }; - - const renderAll = () => { - renderSite(); - renderNavigation(); - renderSpecials(); - renderHeadline(); - renderFocus(); - renderFeatures(); - renderChannels(); - renderLocal(); - renderServices(); - renderFooter(); - bindDynamicInteractions(); - }; - - const liveModal = qs("#liveModal"); - let lastFocus = null; - const openLive = () => { - lastFocus = document.activeElement; - liveModal.classList.add("open"); - document.body.style.overflow = "hidden"; - qs("#liveClose").focus(); - }; - const closeLive = () => { - liveModal.classList.remove("open"); - document.body.style.overflow = ""; - if (lastFocus) lastFocus.focus(); - }; - const drawer = qs("#drawer"); - const openMenu = () => { - drawer.classList.add("open"); - document.body.style.overflow = "hidden"; - qs("#menuClose").focus(); - }; - const closeMenu = () => { - drawer.classList.remove("open"); - document.body.style.overflow = ""; - qs("#menuOpen").focus(); - }; - const bindDynamicInteractions = () => { - qsa("[data-spot-index]").forEach((button) => button.addEventListener("click", () => { activeSpot = Number(button.dataset.spotIndex) || 0; renderSpecials(); bindDynamicInteractions(); })); - qsa("[data-focus-tab]").forEach((button) => button.addEventListener("click", () => { activeFocus = button.dataset.focusTab; renderFocus(); bindDynamicInteractions(); })); - qsa("#featureTags button").forEach((button) => button.addEventListener("click", () => { qsa("#featureTags button").forEach((item) => item.classList.remove("active")); button.classList.add("active"); })); - qsa("[data-province]").forEach((button) => button.addEventListener("click", () => { activeProvince = button.dataset.province; renderLocal(); bindDynamicInteractions(); })); - qsa(".open-live").forEach((button) => button.addEventListener("click", openLive)); - qsa("#drawerLinks a").forEach((link) => link.addEventListener("click", closeMenu)); - }; - - qs("#theme").addEventListener("click", toggleTheme); - qs("#searchOpen").addEventListener("click", () => { - const panel = qs("#searchPanel"); - panel.classList.toggle("open"); - if (panel.classList.contains("open")) window.setTimeout(() => qs("#search").focus(), 20); - }); - const collectStrings = (value, output = []) => { - if (typeof value === "string" && !value.startsWith("#")) output.push(value); - else if (Array.isArray(value)) value.forEach((item) => collectStrings(item, output)); - else if (isObject(value)) Object.values(value).forEach((item) => collectStrings(item, output)); - return output; - }; - qs("#searchForm").addEventListener("submit", (event) => { - event.preventDefault(); - const raw = qs("#search").value.trim(); - const query = raw.toLocaleLowerCase(); - const count = query ? collectStrings(pageData).filter((value) => value.toLocaleLowerCase().includes(query)).length : 0; - const status = qs("#searchStatus"); - status.classList.add("show"); - status.textContent = query ? "找到与“" + raw + "”相关的 " + count + " 条内容" : "请输入搜索关键词"; - }); - qs("#liveClose").addEventListener("click", closeLive); - liveModal.addEventListener("click", (event) => { if (event.target === liveModal) closeLive(); }); - qs("#caption").addEventListener("click", (event) => { - const on = event.currentTarget.textContent.includes("开"); - event.currentTarget.textContent = "CC 字幕:" + (on ? "关" : "开"); - note(on ? "字幕已关闭" : "字幕已开启"); - }); - qs("#menuOpen").addEventListener("click", openMenu); - qs("#menuClose").addEventListener("click", closeMenu); - drawer.addEventListener("click", (event) => { if (event.target === drawer) closeMenu(); }); - qs("#subForm").addEventListener("submit", (event) => { - event.preventDefault(); - const email = qs("#email").value.trim(); - qs("#subStatus").textContent = (pageData.services.newsletter.successPrefix || "") + " " + email; - event.currentTarget.reset(); - }); - - const settingsOverlay = qs("#settingsOverlay"); - const jsonEditor = qs("#jsonEditor"); - const jsonStatus = qs("#jsonStatus"); - let activeModule = MODULES[0]; - let editorDraft = clone(pageData); - const validateEditor = () => { - try { - const parsed = JSON.parse(jsonEditor.value); - if (!isObject(parsed)) throw new Error("模块必须是 JSON 对象"); - validateRemoteMediaTree(parsed); - jsonStatus.textContent = "JSON 有效"; - jsonStatus.className = "json-status valid"; - return parsed; - } catch (error) { - jsonStatus.textContent = "JSON 无效:" + error.message; - jsonStatus.className = "json-status invalid"; - return null; - } - }; - const storeActiveDraft = () => { - const parsed = validateEditor(); - if (!parsed) return false; - editorDraft[activeModule] = parsed; - return true; - }; - const selectModule = (name, force) => { - if (!force && !storeActiveDraft()) return; - activeModule = name; - qsa(".module-tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.module === name)); - qs("#editorModuleTitle").textContent = MODULE_META[name][0]; - jsonEditor.value = JSON.stringify(editorDraft[name], null, 2); - validateEditor(); - }; - const renderModuleTabs = () => { - qs("#moduleTabs").innerHTML = "

页面模块

" + MODULES.map((name) => '").join(""); - qsa(".module-tab").forEach((tab) => tab.addEventListener("click", () => selectModule(tab.dataset.module, false))); - }; - const openSettings = () => { - editorDraft = clone(pageData); - activeModule = MODULES[0]; - renderModuleTabs(); - selectModule(activeModule, true); - settingsOverlay.classList.add("open"); - settingsOverlay.setAttribute("aria-hidden", "false"); - document.body.classList.add("settings-open"); - document.body.style.overflow = "hidden"; - window.setTimeout(() => jsonEditor.focus(), 80); - }; - const closeSettings = () => { - settingsOverlay.classList.remove("open"); - settingsOverlay.setAttribute("aria-hidden", "true"); - document.body.classList.remove("settings-open"); - document.body.style.overflow = ""; - qs("#openSettings").focus(); - }; - qs("#openSettings").addEventListener("click", openSettings); - qs("#closeSettings").addEventListener("click", closeSettings); - settingsOverlay.addEventListener("click", (event) => { if (event.target === settingsOverlay) closeSettings(); }); - jsonEditor.addEventListener("input", validateEditor); - jsonEditor.addEventListener("keydown", (event) => { - if (event.key === "Tab") { - event.preventDefault(); - jsonEditor.setRangeText(" ", jsonEditor.selectionStart, jsonEditor.selectionEnd, "end"); - validateEditor(); - } - }); - qs("#applyJson").addEventListener("click", () => { - if (!storeActiveDraft() || !isCompleteData(editorDraft)) { - note("请修正 JSON 后再应用"); - return; - } - pageData = clone(editorDraft); - saveData(); - renderAll(); - note("JSON 已应用到页面"); - }); - qs("#resetJson").addEventListener("click", () => { - pageData = clone(initialData); - editorDraft = clone(initialData); - removeStorage(storageKey); - removeStorage(themeKey); - themeInitialized = false; - activeSpot = 0; - activeFocus = ""; - activeProvince = ""; - renderAll(); - renderModuleTabs(); - selectModule(activeModule, true); - note("已恢复初始 JSON"); - }); - qs("#copyJson").addEventListener("click", async () => { - if (!storeActiveDraft()) return; - const text = JSON.stringify(editorDraft, null, 2); - try { - if (navigator.clipboard) await navigator.clipboard.writeText(text); - note("完整 JSON 已复制"); - } catch (_error) { - jsonEditor.focus(); - jsonEditor.select(); - note("已选中当前模块,请手动复制"); - } - }); - qs("#downloadJson").addEventListener("click", () => { - if (!storeActiveDraft()) return; - const blob = new Blob([JSON.stringify(editorDraft, null, 2) + "\n"], {type: "application/json"}); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = "xinhua-page-data.json"; - link.click(); - window.setTimeout(() => URL.revokeObjectURL(link.href), 0); - note("JSON 已下载"); - }); - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - if (liveModal.classList.contains("open")) closeLive(); - if (drawer.classList.contains("open")) closeMenu(); - if (settingsOverlay.classList.contains("open")) closeSettings(); - } - }); - - renderAll(); - function installJsonMediaUpload(){ - const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return; - const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson");button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input); - const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message}; - const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;indexindex+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null}; - button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()}); - input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)}); - } - installJsonMediaUpload(); - function installInlineMediaReplace() { - if (document.getElementById("dfInlineMediaInput")) return; - const visualHostSelector = ".post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.spot-card,.involve-bg,.hero-art,#heroArt,#adminArt"; - const getRootData = () => { - if (typeof pageData !== "undefined") return pageData; - if (typeof data !== "undefined") return data; - return null; - }; - const input = document.createElement("input"); - input.id = "dfInlineMediaInput"; - input.type = "file"; - input.hidden = true; - document.body.appendChild(input); - const style = document.createElement("style"); - style.dataset.dfInlineMediaReplace = "true"; - style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}"; - document.head.appendChild(style); - const notify = (message) => { - if (typeof showToast === "function") { showToast(message); return; } - if (typeof toast === "function") { toast(message); return; } - let notice = document.getElementById("dfInlineMediaToast"); - if (!notice) { - notice = document.createElement("div"); - notice.id = "dfInlineMediaToast"; - notice.className = "df-inline-media-toast"; - document.body.appendChild(notice); - } - notice.textContent = message; - window.clearTimeout(notice._timer); - notice._timer = window.setTimeout(() => notice.remove(), 2600); - }; - const sameSource = (value, source, absolute) => { - if (typeof value !== "string") return false; - if (value === source || value === absolute) return true; - try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; } - }; - const findMatches = (source, absolute) => { - const matches = []; - const stack = []; - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - stack.push(value); - Object.entries(value).forEach(([key, item]) => { - if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) { - const ancestors = stack.slice(0, -1).reverse(); - const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value; - matches.push({ owner, previous: value, key }); - } - if (item && typeof item === "object") visit(item); - }); - stack.pop(); - }; - visit(getRootData()); - return matches; - }; - const visualOwners = () => { - const owners = []; - const seen = new Set(); - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object"); - const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim()); - if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); } - Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); }); - }; - visit(getRootData()); - return owners; - }; - const domIdentifiers = (element) => { - const values = new Set(); - let cursor = element; - for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) { - Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); }); - } - return values; - }; - const chooseVisualTarget = (element) => { - const identifiers = domIdentifiers(element); - const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element; - const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim(); - const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]"); - const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : ""; - let best = null; - let bestScore = 0; - visualOwners().forEach((owner) => { - let score = 0; - Object.entries(owner).forEach(([key, value]) => { - if (/id$/i.test(key) && identifiers.has(String(value))) score += 100; - }); - ["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => { - const value = typeof owner[key] === "string" ? owner[key].trim() : ""; - if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length); - }); - if (visualValue && owner.visual === visualValue) score += 20; - if (score > bestScore) { best = owner; bestScore = score; } - }); - if (!best || bestScore <= 0) return null; - return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseTarget = (element) => { - const source = element.getAttribute("src") || ""; - const absolute = element.currentSrc || element.src || source; - const matches = findMatches(source, absolute); - if (!matches.length) return null; - const identifiers = domIdentifiers(element); - return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0]; - }; - const persist = () => { - const root = getRootData(); - if (typeof saveData === "function") return saveData(); - if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root)); - if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root)); - return false; - }; - let active = null; - const attachButton = (host, target) => { - if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return; - host.classList.add("df-inline-media-host"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "df-inline-media-button"; - button.setAttribute("aria-label", "替换图片或视频"); - button.title = "上传图片或视频并替换"; - button.innerHTML = ''; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - active = { ...target }; - input.accept = "image/*,video/*"; - input.value = ""; - input.click(); - }); - host.appendChild(button); - }; - const decorateBuiltInVisuals = () => { - document.querySelectorAll(visualHostSelector).forEach((host) => { - if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return; - if (!host.querySelector("svg") && !host.dataset.visual) return; - const target = chooseVisualTarget(host); - if (!target) return; - attachButton(host, target); - }); - }; - const decorate = () => { - decorateBuiltInVisuals(); - document.querySelectorAll("img[src],video[src]").forEach((media) => { - if (media.dataset.dfInlineMediaReady === "true") return; - const target = chooseTarget(media); - if (!target) return; - const host = media.closest(visualHostSelector) || media.parentElement; - if (!host) return; - media.dataset.dfInlineMediaReady = "true"; - attachButton(host, target); - }); - }; - input.addEventListener("change", () => { - const file = input.files && input.files[0]; - if (!file || !active) return; - const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : ""; - if (!uploadedType) { - notify("请选择图片或视频文件。"); - return; - } - const reader = new FileReader(); - reader.onerror = () => notify("文件读取失败,请重新选择。"); - reader.onload = () => { - const next = { ...(active.previous || {}) }; - const result = String(reader.result); - next.type = uploadedType; - next.mediaUrl = result; - delete next.posterUrl; - if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片"; - active.owner.media = next; - const stored = persist(); - renderAll(); - notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。"); - active = null; - }; - reader.readAsDataURL(file); - }); - let queued = false; - const queueDecorate = () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; decorate(); }); - }; - new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true }); - decorate(); - } - installInlineMediaReplace(); -})(); diff --git a/offline-backend-20260512/backend/skills/public/xinhua-page-generator/assets/xinhua-template.html b/offline-backend-20260512/backend/skills/public/xinhua-page-generator/assets/xinhua-template.html deleted file mode 100644 index 422e862f..00000000 --- a/offline-backend-20260512/backend/skills/public/xinhua-page-generator/assets/xinhua-template.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - 新华社|记录时代 连接世界 - - - - -
-
-
- - -
-
-
- -
-
- - - -
-
-

-
-
-

-

-

-

-
-

-

-
- -
- -
- - - -
- - - - - - - diff --git a/offline-backend-20260512/backend/skills/public/xinhua-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/xinhua-page-generator/references/data-schema.md deleted file mode 100644 index f09f57be..00000000 --- a/offline-backend-20260512/backend/skills/public/xinhua-page-generator/references/data-schema.md +++ /dev/null @@ -1,25 +0,0 @@ -# 新华社页面 JSON 契约 - -顶层必须完整包含 `site`、`navigation`、`specials`、`headline`、`focus`、`features`、`channels`、`local`、`services`、`footer`。弹窗按十个模块分别编辑,应用时重新校验完整文档。 - -## 通用规则 - -- 所有标题、标签、摘要和时间为字符串;关键展示字段不能为空。 -- 列表有数量上限,同一业务列表中的 `id` 必须唯一。 -- `href` 只允许以 `#` 开头的页面内锚点。 -- JSON 文本不能包含 HTML、脚本、直接 iframe 或可执行代码。具有 `visual` 的对象可增加 `media` 替换视觉:图片 `{type:"image", mediaUrl, alt?, pageUrl?}`;视频 `{type:"video", mediaUrl, posterUrl?, alt?, pageUrl?}`;网页 `{type:"webpage", pageUrl, title, description?, mediaUrl?, embed?}`。仅这些媒体 URL 字段允许 `http(s)`,图片/视频也允许对应的 `data:` URL。 - -## 模块 - -- `site`:机构名称、英文名、页面标题、描述、口号、客户端文案和 `theme`(`light`/`dark`/`auto`)。 -- `navigation`:多语种顶栏左右链接、1–16 个主导航、1–16 个移动菜单链接,以及搜索文案。 -- `specials`:1–4 条报道专集、1–5 个焦点图和一条最新播报。焦点图 `visual` 为 `harvest`、`railway`、`satellite`、`culture`、`wetland`。 -- `headline`:总头条标签、标题、摘要和 1–6 条关联阅读。 -- `focus.tabs`:1–5 个分类;每类包含 1–4 列,每列有主标题、摘要、视觉类型和 1–5 条短链接。视觉类型为 `city`、`economy`、`world`、`green`、`technology`。 -- `features`:名专栏标签与文章、1–12 个热点专题、1–8 个记者工作室。 -- `channels`:1–5 个网评/访谈/视频栏目和 1–12 个行业频道;栏目视觉类型为 `commentary`、`interview`、`video`,其中 `live: true` 打开直播弹窗。 -- `local.provinces`:1–16 个地区;每个地区包含主标题、摘要、视觉类型和 1–8 条地方报道。视觉类型为 `capital`、`coast`、`mountain`、`river`、`bay`。 -- `services`:1–4 个媒体矩阵入口,`icon` 为 `app`、`video`、`platform`、`radio`;另含直播弹窗和早报订阅文案。 -- `footer.links`:1–10 个机构链接;另有版权与服务说明。 - -JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。`assets/default-data.json` 只是结构和演示内容,不是事实来源。 diff --git a/offline-backend-20260512/backend/skills/public/xinhua-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/xinhua-page-generator/scripts/fast_page.py deleted file mode 100644 index c138b180..00000000 --- a/offline-backend-20260512/backend/skills/public/xinhua-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "_shared")) - -import render_xinhua_page as renderer -from fast_patch_page import run - -if __name__ == "__main__": - sys.exit(run(renderer, output_stem="xinhua-page")) diff --git a/offline-backend-20260512/backend/skills/public/xinhua-page-generator/scripts/render_xinhua_page.py b/offline-backend-20260512/backend/skills/public/xinhua-page-generator/scripts/render_xinhua_page.py deleted file mode 100644 index c7d17a41..00000000 --- a/offline-backend-20260512/backend/skills/public/xinhua-page-generator/scripts/render_xinhua_page.py +++ /dev/null @@ -1,631 +0,0 @@ -#!/usr/bin/env python3 -"""Validate Xinhua page data and render the bundled self-contained template.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_DIR = Path(__file__).resolve().parents[1] -ASSETS_DIR = SKILL_DIR / "assets" -DEFAULT_DATA_PATH = ASSETS_DIR / "default-data.json" -TEMPLATE_PATH = ASSETS_DIR / "xinhua-template.html" -RUNTIME_PATH = ASSETS_DIR / "xinhua-runtime.js" -MODULES = ( - "site", - "navigation", - "specials", - "headline", - "focus", - "features", - "channels", - "local", - "services", - "footer", -) -SPOT_VISUALS = {"harvest", "railway", "satellite", "culture", "wetland"} -FOCUS_VISUALS = {"city", "economy", "world", "green", "technology"} -CHANNEL_VISUALS = {"commentary", "interview", "video"} -LOCAL_VISUALS = {"capital", "coast", "mountain", "river", "bay"} -SERVICE_ICONS = {"app", "video", "platform", "radio"} -FORBIDDEN_TEXT = (" dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise DataValidationError(f"input file does not exist: {path}") from exc - except json.JSONDecodeError as exc: - raise DataValidationError( - f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" - ) from exc - if not isinstance(value, dict): - raise DataValidationError("top-level JSON value must be an object") - return value - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array( - value: Any, - path: str, - errors: list[str], - *, - minimum: int = 0, - maximum: int | None = None, -) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if len(value) < minimum: - errors.append(f"{path} must contain at least {minimum} item(s)") - if maximum is not None and len(value) > maximum: - errors.append(f"{path} must contain at most {maximum} item(s)") - return value - - -def _required_string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty string") - - -def _string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str): - errors.append(f"{path} must be a string") - - -def _href(value: Any, path: str, errors: list[str]) -> None: - _required_string(value, path, errors) - if isinstance(value, str) and not value.startswith("#"): - errors.append(f"{path} must be an in-page anchor beginning with #") - - -def _unique_id(item: dict[str, Any], path: str, seen: set[str], errors: list[str]) -> None: - item_id = item.get("id") - _required_string(item_id, f"{path}.id", errors) - if isinstance(item_id, str): - if item_id in seen: - errors.append(f"{path}.id duplicates {item_id!r}") - seen.add(item_id) - - -def _strings( - value: Any, - path: str, - errors: list[str], - *, - minimum: int = 0, - maximum: int | None = None, -) -> list[Any]: - values = _array(value, path, errors, minimum=minimum, maximum=maximum) - for index, item in enumerate(values): - _required_string(item, f"{path}[{index}]", errors) - return values - - -def _links( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, -) -> list[Any]: - items = _array(value, path, errors, minimum=minimum, maximum=maximum) - for index, raw in enumerate(items): - item = _mapping(raw, f"{path}[{index}]", errors) - _required_string(item.get("label"), f"{path}[{index}].label", errors) - _href(item.get("href"), f"{path}[{index}].href", errors) - return items - - -def _id_title_items( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, -) -> list[Any]: - items = _array(value, path, errors, minimum=minimum, maximum=maximum) - seen: set[str] = set() - for index, raw in enumerate(items): - item_path = f"{path}[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen, errors) - _required_string(item.get("title"), f"{item_path}.title", errors) - return items - - -def _validate_no_remote_or_markup(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, dict): - for key, item in value.items(): - if key in MEDIA_URL_KEYS: - continue - _validate_no_remote_or_markup(item, f"{path}.{key}", errors) - elif isinstance(value, list): - for index, item in enumerate(value): - _validate_no_remote_or_markup(item, f"{path}[{index}]", errors) - elif isinstance(value, str): - lowered = value.lower() - if any(token in lowered for token in FORBIDDEN_TEXT): - errors.append(f"{path} must not contain HTML, script, iframe, or remote URLs") - - -def _remote_url(value: Any, path: str, errors: list[str], *, required: bool = False, data_prefix: str = "") -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media_tree(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, list): - for index, item in enumerate(value): - _validate_remote_media_tree(item, f"{path}[{index}]", errors) - return - if not isinstance(value, dict): - return - media = value.get("media") - if isinstance(media, dict) and "type" in media: - media_path = f"{path}.media" - media_data = _mapping(media, media_path, errors) - media_type = media_data.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - errors.append(f"{media_path}.type must be one of: {', '.join(sorted(REMOTE_MEDIA_TYPES))}") - elif media_type in {"image", "video"}: - _remote_url(media_data.get("mediaUrl"), f"{media_path}.mediaUrl", errors, required=True, data_prefix=f"data:{media_type}/") - if media_type == "video": - _remote_url(media_data.get("posterUrl"), f"{media_path}.posterUrl", errors, data_prefix="data:image/") - if media_type == "webpage": - _remote_url(media_data.get("pageUrl"), f"{media_path}.pageUrl", errors, required=True) - _remote_url(media_data.get("mediaUrl"), f"{media_path}.mediaUrl", errors, data_prefix="data:image/") - if "embed" in media_data and not isinstance(media_data.get("embed"), bool): - errors.append(f"{media_path}.embed must be a boolean") - elif media_type in {"image", "video"}: - _remote_url(media_data.get("pageUrl"), f"{media_path}.pageUrl", errors) - for key, item in value.items(): - if key != "media": - _validate_remote_media_tree(item, f"{path}.{key}", errors) - - -def validate_document(data: dict[str, Any]) -> dict[str, int]: - errors: list[str] = [] - missing = [name for name in MODULES if name not in data] - extra = [name for name in data if name not in MODULES] - if missing: - errors.append("missing top-level module(s): " + ", ".join(missing)) - if extra: - errors.append("unknown top-level module(s): " + ", ".join(extra)) - - site = _mapping(data.get("site"), "site", errors) - for key in ( - "brandName", - "brandEnglish", - "pageTitle", - "description", - "motto", - "clientLabel", - ): - _required_string(site.get(key), f"site.{key}", errors) - if site.get("theme") not in {"light", "dark", "auto"}: - errors.append("site.theme must be light, dark, or auto") - - navigation = _mapping(data.get("navigation"), "navigation", errors) - network_left = _links( - navigation.get("networkLeft"), - "navigation.networkLeft", - errors, - minimum=1, - maximum=8, - ) - network_right = _links( - navigation.get("networkRight"), - "navigation.networkRight", - errors, - minimum=1, - maximum=6, - ) - primary = _links( - navigation.get("primary"), - "navigation.primary", - errors, - minimum=1, - maximum=16, - ) - drawer = _links( - navigation.get("drawer"), - "navigation.drawer", - errors, - minimum=1, - maximum=16, - ) - for key in ("searchLabel", "searchPlaceholder", "searchButton"): - _required_string(navigation.get(key), f"navigation.{key}", errors) - - specials = _mapping(data.get("specials"), "specials", errors) - _required_string(specials.get("seriesTitle"), "specials.seriesTitle", errors) - _required_string(specials.get("seriesEnglish"), "specials.seriesEnglish", errors) - series_items = _array( - specials.get("seriesItems"), - "specials.seriesItems", - errors, - minimum=1, - maximum=4, - ) - seen_series: set[str] = set() - for index, raw in enumerate(series_items): - item_path = f"specials.seriesItems[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_series, errors) - _required_string(item.get("title"), f"{item_path}.title", errors) - _href(item.get("href"), f"{item_path}.href", errors) - _required_string(specials.get("spotlightLabel"), "specials.spotlightLabel", errors) - spotlight_items = _array( - specials.get("spotlightItems"), - "specials.spotlightItems", - errors, - minimum=1, - maximum=5, - ) - seen_spotlight: set[str] = set() - for index, raw in enumerate(spotlight_items): - item_path = f"specials.spotlightItems[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_spotlight, errors) - _required_string(item.get("title"), f"{item_path}.title", errors) - _string(item.get("tag"), f"{item_path}.tag", errors) - if item.get("visual") not in SPOT_VISUALS: - errors.append(f"{item_path}.visual must be one of {sorted(SPOT_VISUALS)}") - latest = _mapping(specials.get("latest"), "specials.latest", errors) - for key in ("label", "title", "time"): - _required_string(latest.get(key), f"specials.latest.{key}", errors) - _href(latest.get("href"), "specials.latest.href", errors) - - headline = _mapping(data.get("headline"), "headline", errors) - for key in ("kicker", "title", "summary"): - _required_string(headline.get(key), f"headline.{key}", errors) - headline_links = _links( - headline.get("links"), - "headline.links", - errors, - minimum=1, - maximum=6, - ) - - focus = _mapping(data.get("focus"), "focus", errors) - _required_string(focus.get("title"), "focus.title", errors) - _required_string(focus.get("moreLabel"), "focus.moreLabel", errors) - tabs = _array(focus.get("tabs"), "focus.tabs", errors, minimum=1, maximum=5) - seen_tabs: set[str] = set() - focus_columns = 0 - for tab_index, raw_tab in enumerate(tabs): - tab_path = f"focus.tabs[{tab_index}]" - tab = _mapping(raw_tab, tab_path, errors) - _unique_id(tab, tab_path, seen_tabs, errors) - _required_string(tab.get("label"), f"{tab_path}.label", errors) - columns = _array( - tab.get("columns"), - f"{tab_path}.columns", - errors, - minimum=1, - maximum=4, - ) - focus_columns += len(columns) - seen_columns: set[str] = set() - for column_index, raw_column in enumerate(columns): - column_path = f"{tab_path}.columns[{column_index}]" - column = _mapping(raw_column, column_path, errors) - _unique_id(column, column_path, seen_columns, errors) - _required_string(column.get("leadTitle"), f"{column_path}.leadTitle", errors) - _required_string(column.get("summary"), f"{column_path}.summary", errors) - if column.get("visual") not in FOCUS_VISUALS: - errors.append( - f"{column_path}.visual must be one of {sorted(FOCUS_VISUALS)}" - ) - _strings( - column.get("links"), - f"{column_path}.links", - errors, - minimum=1, - maximum=5, - ) - - features = _mapping(data.get("features"), "features", errors) - for key in ( - "columnTitle", - "columnSubtitle", - "topicsTitle", - "topicsMoreLabel", - "studiosTitle", - "studiosMoreLabel", - ): - _required_string(features.get(key), f"features.{key}", errors) - tags = _strings(features.get("tags"), "features.tags", errors, minimum=1, maximum=10) - articles = _id_title_items( - features.get("articles"), - "features.articles", - errors, - minimum=1, - maximum=12, - ) - for index, raw in enumerate(articles): - item = _mapping(raw, f"features.articles[{index}]", errors) - _required_string(item.get("meta"), f"features.articles[{index}].meta", errors) - topics = _id_title_items( - features.get("topics"), - "features.topics", - errors, - minimum=1, - maximum=12, - ) - for index, raw in enumerate(topics): - item = _mapping(raw, f"features.topics[{index}]", errors) - _required_string(item.get("summary"), f"features.topics[{index}].summary", errors) - studios = _array( - features.get("studios"), - "features.studios", - errors, - minimum=1, - maximum=8, - ) - seen_studios: set[str] = set() - for index, raw in enumerate(studios): - item_path = f"features.studios[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_studios, errors) - for key in ("initial", "name", "bio"): - _required_string(item.get(key), f"{item_path}.{key}", errors) - if isinstance(item.get("initial"), str) and len(item["initial"]) > 2: - errors.append(f"{item_path}.initial must contain at most 2 characters") - - channels = _mapping(data.get("channels"), "channels", errors) - editorial = _array( - channels.get("editorialColumns"), - "channels.editorialColumns", - errors, - minimum=1, - maximum=5, - ) - seen_editorial: set[str] = set() - live_count = 0 - for index, raw in enumerate(editorial): - item_path = f"channels.editorialColumns[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_editorial, errors) - for key in ("accent", "title", "feature"): - _required_string(item.get(key), f"{item_path}.{key}", errors) - if item.get("visual") not in CHANNEL_VISUALS: - errors.append(f"{item_path}.visual must be one of {sorted(CHANNEL_VISUALS)}") - if not isinstance(item.get("live"), bool): - errors.append(f"{item_path}.live must be a boolean") - elif item["live"]: - live_count += 1 - _strings(item.get("links"), f"{item_path}.links", errors, minimum=1, maximum=6) - _required_string(channels.get("industryTitle"), "channels.industryTitle", errors) - _required_string(channels.get("industryMoreLabel"), "channels.industryMoreLabel", errors) - industries = _array( - channels.get("industries"), - "channels.industries", - errors, - minimum=1, - maximum=12, - ) - seen_industries: set[str] = set() - for index, raw in enumerate(industries): - item_path = f"channels.industries[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_industries, errors) - _required_string(item.get("title"), f"{item_path}.title", errors) - _strings(item.get("items"), f"{item_path}.items", errors, minimum=1, maximum=6) - - local = _mapping(data.get("local"), "local", errors) - for key in ("title", "moreLabel", "selectorLabel"): - _required_string(local.get(key), f"local.{key}", errors) - provinces = _array( - local.get("provinces"), - "local.provinces", - errors, - minimum=1, - maximum=16, - ) - seen_provinces: set[str] = set() - for index, raw in enumerate(provinces): - item_path = f"local.provinces[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_provinces, errors) - for key in ("name", "leadTitle", "summary"): - _required_string(item.get(key), f"{item_path}.{key}", errors) - if item.get("visual") not in LOCAL_VISUALS: - errors.append(f"{item_path}.visual must be one of {sorted(LOCAL_VISUALS)}") - _strings(item.get("links"), f"{item_path}.links", errors, minimum=1, maximum=8) - - services = _mapping(data.get("services"), "services", errors) - _required_string(services.get("matrixTitle"), "services.matrixTitle", errors) - _required_string(services.get("matrixSubtitle"), "services.matrixSubtitle", errors) - matrix_items = _array( - services.get("matrixItems"), - "services.matrixItems", - errors, - minimum=1, - maximum=4, - ) - seen_services: set[str] = set() - for index, raw in enumerate(matrix_items): - item_path = f"services.matrixItems[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_services, errors) - for key in ("title", "caption"): - _required_string(item.get(key), f"{item_path}.{key}", errors) - if item.get("icon") not in SERVICE_ICONS: - errors.append(f"{item_path}.icon must be one of {sorted(SERVICE_ICONS)}") - live_modal = _mapping(services.get("liveModal"), "services.liveModal", errors) - for key in ("title", "streamTitle", "streamSubtitle", "currentProgram"): - _required_string(live_modal.get(key), f"services.liveModal.{key}", errors) - newsletter = _mapping(services.get("newsletter"), "services.newsletter", errors) - for key in ("title", "text", "placeholder", "button", "successPrefix"): - _required_string(newsletter.get(key), f"services.newsletter.{key}", errors) - - footer = _mapping(data.get("footer"), "footer", errors) - footer_links = _links( - footer.get("links"), "footer.links", errors, minimum=1, maximum=10 - ) - _required_string(footer.get("copyright"), "footer.copyright", errors) - _required_string(footer.get("legal"), "footer.legal", errors) - - _validate_remote_media_tree(data, "$", errors) - _validate_no_remote_or_markup(data, "$", errors) - if errors: - raise DataValidationError("\n".join(f"- {error}" for error in errors)) - return { - "network_links": len(network_left) + len(network_right), - "primary_navigation": len(primary), - "drawer_navigation": len(drawer), - "series": len(series_items), - "spotlight": len(spotlight_items), - "headline_links": len(headline_links), - "focus_tabs": len(tabs), - "focus_columns": focus_columns, - "feature_tags": len(tags), - "feature_articles": len(articles), - "topics": len(topics), - "studios": len(studios), - "editorial_columns": len(editorial), - "live_columns": live_count, - "industries": len(industries), - "provinces": len(provinces), - "matrix_items": len(matrix_items), - "footer_links": len(footer_links), - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - data_text = json.dumps(data, ensure_ascii=False, separators=(",", ":")) - data_text = data_text.replace(" None: - if path.exists(): - raise DataValidationError( - f"refusing to overwrite existing file: {path}; choose a new path" - ) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8", newline="\n") - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False)) - - -def _command_init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA_PATH) - counts = validate_document(data) - _write_new(output, json.dumps(data, ensure_ascii=False, indent=2) + "\n") - return { - "status": "ok", - "action": "init", - "output": str(output), - "modules": list(MODULES), - "counts": counts, - } - - -def _command_validate(input_path: Path) -> dict[str, Any]: - counts = validate_document(_read_json(input_path)) - return { - "status": "ok", - "action": "validate", - "input": str(input_path), - "modules": list(MODULES), - "counts": counts, - } - - -def _command_render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - counts = validate_document(data) - template = TEMPLATE_PATH.read_text(encoding="utf-8") - runtime = RUNTIME_PATH.read_text(encoding="utf-8") - _write_new(output, render_document(data, template, runtime)) - return { - "status": "ok", - "action": "render", - "input": str(input_path), - "output": str(output), - "modules": list(MODULES), - "counts": counts, - } - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Initialize, validate, and render Xinhua-style news portal JSON." - ) - subparsers = parser.add_subparsers(dest="command", required=True) - init_parser = subparsers.add_parser("init", help="write a fresh default JSON file") - init_parser.add_argument("--output", required=True, type=Path) - validate_parser = subparsers.add_parser("validate", help="validate a JSON file") - validate_parser.add_argument("--input", required=True, type=Path) - render_parser = subparsers.add_parser( - "render", help="render a validated JSON file into the fixed HTML template" - ) - render_parser.add_argument("--input", required=True, type=Path) - render_parser.add_argument("--output", required=True, type=Path) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - args = parser.parse_args(argv) - try: - if args.command == "init": - payload = _command_init(args.output) - elif args.command == "validate": - payload = _command_validate(args.input) - else: - payload = _command_render(args.input, args.output) - _emit(payload) - return 0 - except (DataValidationError, OSError) as exc: - _emit({"status": "error", "action": args.command, "message": str(exc)}) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/SKILL.md deleted file mode 100644 index 8bab1ddd..00000000 --- a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/SKILL.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: yahoo-japan-page-generator -description: 生成或绘制中文 Yahoo! JAPAN 风格日本综合门户的可交付单文件 HTML;仅在用户明确要求制作雅虎日本、Yahoo Japan 网页或点名本技能时使用。先把材料或检索结果整理成读者文案全中文的十模块 JSON,再用内置模板确定性渲染,页面保留右上角模块化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# 雅虎日本中文页面生成器 - -本技能使用固定模板生成单文件日本综合门户。模型只负责收集材料和编辑 JSON,禁止重新手写页面结构、CSS 或运行时脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块编辑 JSON,并通过“应用并渲染”即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说“生成/画/制作雅虎日本、Yahoo! JAPAN、Yahoo Japan 网页、日本综合门户”或点名 `yahoo-japan-page-generator` 时使用。 -- 仅讨论雅虎日本、总结日本新闻或普通文本中出现 Yahoo 时不要使用。 -- 这是雅虎日本风格的概念门户,不改用新闻机构模板或通用页面模板,也不得声称是官方网站。 - -## 极速工作流(默认) - -页面结构已经固定。完成必要检索或材料整理后,模型只写一份小型规格 JSON;未写字段继续使用模板默认数据与示例视觉,脚本在一次进程内合并、校验并渲染。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/yahoo-japan-page-spec.json`。直接写模块对象会递归合并;修改现有数组项优先用 `$set`: - - ```json - {"site":{"pageTitle":"Yahoo! JAPAN · 中文专题"},"$set":{"news.feature.kicker":"今日关注","news.feature.title":"中文头条","news.feature.summary":"中文摘要"}} - ``` - -2. 只执行一次 `python /mnt/skills/public/yahoo-japan-page-generator/scripts/fast_page.py --spec /mnt/user-data/outputs/yahoo-japan-page-spec.json --output-dir /mnt/user-data/outputs`。 -3. stdout 为 `status: ok` 后立即用 `present_files` 交付完整 JSON 与 HTML,最终只回复一句完成说明。 - -规格只允许本技能十个模块和 `$set`;路径必须指向默认 JSON 的已存在字段。品牌 `Yahoo! JAPAN` 可保留,其余读者可见内容必须是简体中文。未提供媒体直链时保留模板示例视觉。禁止读取默认数据、契约、模板、运行时或脚本源码,禁止 `--help`、独立 `init/validate/render`、生成后打补丁、临时脚本和冗长过程说明。规格报错时只修正一次并重跑。只有用户明确要求改变模块结构或批量重建完整数组时,才使用下面的完整工作流。 - -## 完整工作流(仅限结构改造) - -1. 读取当前会话材料、上传附件和已有产物,确定品牌、搜索、服务、新闻、账户、天气、交通与市场、购物、体育热榜和页脚内容。 -2. 用户要求当前新闻、实时天气、真实行情或近期事件时,必须先检索可靠来源再填 JSON;用户明确说不要检索时不得联网。日文或英文资料必须先准确翻译成简体中文。没有材料时使用中文中性演示内容,并在页脚明确说明不是官方网站。 -3. 初始化完整 JSON: - - ```text - python /mnt/skills/public/yahoo-japan-page-generator/scripts/render_yahoo_japan_page.py init --output /mnt/user-data/outputs/yahoo-japan-page-data.json - ``` - -4. 只修改 JSON。十个顶层模块必须全部保留:`site`、`search`、`services`、`news`、`account`、`weather`、`utilities`、`shopping`、`pulse`、`footer`。JSON 键名、`id`、`href`、`visual`、`theme` 等技术值保持契约格式;品牌 `Yahoo! JAPAN` 可以保留,其余读者可见文案必须使用简体中文,`site.language` 固定为 `zh-CN`。字段不确定时读取 [references/data-schema.md](references/data-schema.md)。 -5. 校验 JSON: - - ```text - python /mnt/skills/public/yahoo-japan-page-generator/scripts/render_yahoo_japan_page.py validate --input /mnt/user-data/outputs/yahoo-japan-page-data.json - ``` - -6. 确定性渲染: - - ```text - python /mnt/skills/public/yahoo-japan-page-generator/scripts/render_yahoo_japan_page.py render --input /mnt/user-data/outputs/yahoo-japan-page-data.json --output /mnt/user-data/outputs/yahoo-japan-page.html - ``` - -7. 检查 stdout 的 `status` 为 `ok`、HTML 存在且占位符已经消失,再用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天中粘贴完整 HTML。 - -## 内容映射 - -- 品牌、日期、工具链接、顶部操作、公告和明暗模式文案放入 `site`。 -- 搜索类型、热门词和搜索反馈放入 `search`;常用与扩展服务放入 `services`。 -- 分类新闻、主专题和推荐内容放入 `news`;登录卡片与演示登录弹窗放入 `account`。 -- 城市天气与灾害入口放入 `weather`;路线查询和市场行情放入 `utilities`。 -- 商品卡片放入 `shopping`;体育比分和实时热榜放入 `pulse`;抽屉与页脚放入 `footer`。 -- `visual` 继续作为内置后备;具有视觉的对象可增加受校验的 `media`(`image`、`video`、`webpage`)替换当前视觉。所有文本由模板转义,不得包含 HTML、脚本或直接 iframe。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/yahoo-japan-template.html` 或 `assets/yahoo-japan-runtime.js`。 -- 初始化与渲染默认拒绝覆盖已有文件;路径存在时换新文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖新闻、天气、市场、购物或账户接口,也不依赖 CDN;仅当 JSON 明确提供受校验媒体 URL 时加载图片、视频或网页。 -- 保留搜索优先、红蓝品牌、密集双栏门户、服务入口、分类新闻、天气、交通、行情、购物、体育和热榜交互。 -- 除品牌 `Yahoo! JAPAN` 外,不得生成日文或英文读者文案;外文来源必须先翻译为自然、准确的简体中文。 -- 浏览器缓存以初始 JSON 指纹隔离;沙箱禁止持久存储时必须继续使用内嵌 JSON 正常渲染。 diff --git a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/default-data.json deleted file mode 100644 index edbe2f93..00000000 --- a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/default-data.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "site": { - "brandPrimary": "Yahoo!", - "brandAccent": "JAPAN", - "tagline": "每天连接日本生活", - "pageTitle": "Yahoo! JAPAN 中文综合门户", - "description": "采用雅虎日本门户结构设计的中文概念页面,汇集搜索、新闻、天气、交通、市场、体育与购物信息。", - "language": "zh-CN", - "theme": "light", - "skipLabel": "跳转到主要内容", - "dateLabel": "2026年10月30日 星期五 · 日本", - "utilityLeft": [{"id":"start","label":"设为起始页","href":"#footer"},{"id":"kids","label":"儿童版","href":"#services"},{"id":"help","label":"帮助中心","href":"#footer"}], - "utilityRight": [{"id":"travel","label":"旅行","href":"#shopping"},{"id":"card","label":"信用卡","href":"#shopping"},{"id":"mail","label":"邮箱","href":"#account"},{"id":"shop","label":"购物","href":"#shopping"}], - "loginLabel": "登录", - "mailLabel": "邮箱", - "mailCount": "3封", - "menuLabel": "打开菜单", - "themeLabel": "切换明暗模式", - "themeDarkMessage": "已切换到深色模式。", - "themeLightMessage": "已切换到浅色模式。", - "notice": {"label":"通知","text":"关东甲信地区今晚可能出现强降雨,请及时查看天气与交通信息。","href":"#weather"} - }, - "search": { - "title": "搜索", - "tabs": [{"id":"web","label":"网页"},{"id":"smart","label":"智能模式"},{"id":"image","label":"图片"},{"id":"video","label":"视频"},{"id":"qa","label":"问答"},{"id":"map","label":"地图"},{"id":"realtime","label":"实时"}], - "placeholderSuffix": "搜索", - "button": "搜索", - "hotLabel": "热门:", - "suggestions": ["红叶最佳观赏期","新干线运行信息","日本职业棒球决赛"], - "emptyMessage": "请输入搜索关键词。", - "resultPrefix": "已执行演示搜索,类型为", - "resultKeyword": "关键词" - }, - "services": { - "title": "主要服务", - "allLabel": "服务总览", - "allHref": "#services", - "moreLabel": "展开更多", - "lessLabel": "收起服务", - "primary": [ - {"id":"shopping","label":"购物","href":"#shopping","visual":"shopping"},{"id":"auction","label":"拍卖","href":"#shopping","visual":"auction"},{"id":"weather","label":"天气与灾害","href":"#weather","visual":"weather"},{"id":"news","label":"新闻","href":"#news","visual":"news"},{"id":"sports","label":"体育","href":"#sports","visual":"sports"},{"id":"finance","label":"财经","href":"#finance","visual":"finance"},{"id":"transit","label":"路线信息","href":"#transit","visual":"transit"},{"id":"property","label":"房产","href":"#services","visual":"property"},{"id":"car","label":"汽车","href":"#services","visual":"car"},{"id":"travel","label":"旅行","href":"#shopping","visual":"travel"} - ], - "extra": [ - {"id":"calendar","label":"日历","href":"#services","visual":"calendar"},{"id":"community","label":"生活问答","href":"#services","visual":"community"},{"id":"tax","label":"家乡税","href":"#services","visual":"tax"},{"id":"jobs","label":"招聘","href":"#services","visual":"jobs"},{"id":"books","label":"电子书","href":"#services","visual":"books"} - ] - }, - "news": { - "title": "新闻", - "updated": "10月30日 14:05更新", - "tabs": [ - {"id":"top","label":"主要","items":[{"id":"network","tag":"快讯","title":"新的广域交通网络将在年内部分开通","time":"13:58","href":"#news","isNew":true},{"id":"wages","tag":"","title":"中小企业加薪支持方案正式公布","time":"13:44","href":"#news","isNew":false},{"id":"autumn","tag":"","title":"北陆山区进入红叶最佳观赏期","time":"13:21","href":"#news","isNew":false},{"id":"train","tag":"","title":"下一代新干线启动载客试运行","time":"12:52","href":"#news","isNew":false},{"id":"library","tag":"","title":"全国图书馆开展夜间开放试点","time":"12:18","href":"#news","isNew":false}]}, - {"id":"domestic","label":"国内","items":[{"id":"disaster-app","tag":"","title":"区域防灾应用在十二座城市启动联动","time":"13:48","href":"#news","isNew":false},{"id":"ferry","tag":"","title":"连接濑户内岛屿的电动渡轮投入运营","time":"12:37","href":"#news","isNew":false},{"id":"heritage","tag":"","title":"秋季文化遗产特别开放活动启动","time":"11:15","href":"#news","isNew":false}]}, - {"id":"economy","label":"经济","items":[{"id":"stocks","tag":"","title":"日经平均指数小幅上涨,出口板块表现稳健","time":"13:51","href":"#finance","isNew":false},{"id":"startup","tag":"","title":"面向地方初创企业的投资继续扩大","time":"12:29","href":"#finance","isNew":false}]}, - {"id":"culture","label":"文娱","items":[{"id":"film","tag":"","title":"国际电影节将在全国八座城市同步举行","time":"13:20","href":"#news","isNew":false},{"id":"literature","tag":"","title":"青年作家文学奖公布入围作品","time":"12:14","href":"#news","isNew":false}]}, - {"id":"sports","label":"体育","items":[{"id":"women-final","tag":"","title":"日本女队晋级亚洲赛事决赛","time":"13:46","href":"#sports","isNew":false},{"id":"draft","tag":"","title":"职业棒球新秀发表赛季目标","time":"12:01","href":"#sports","isNew":false}]}, - {"id":"world","label":"国际","items":[{"id":"heat","tag":"","title":"亚洲主要城市发布联合高温应对宣言","time":"13:09","href":"#news","isNew":false},{"id":"ocean","tag":"","title":"世界遗产会议设立海洋保护基金","time":"11:38","href":"#news","isNew":false}]} - ], - "feature": {"kicker":"图解专题","title":"新交通枢纽实现铁路、巴士与渡轮一票换乘","summary":"一张图了解新的出行方式将怎样改变城市生活。","visual":"transit-map","href":"#news"}, - "moreLabel": "查看更多", - "moreHref": "#news", - "topicsLabel": "专题列表", - "topicsHref": "#news", - "recommendTitle": "推荐阅读", - "refreshLabel": "换一批", - "refreshMessage": "推荐内容已经更新。", - "recommendations": [{"id":"trail","title":"沿着古道寻找秋日小旅行","category":"旅行","visual":"trail"},{"id":"recipe","title":"用当季蔬菜制作十分钟料理","category":"生活","visual":"recipe"},{"id":"plaza","title":"车站再开发带来更多公共空间","category":"新闻专题","visual":"plaza"}] - }, - "account": { - "title": "登录后使用更多服务", - "text": "在同一页面查看邮箱、日程和已收藏内容。", - "loginLabel": "登录", - "newIdLabel": "注册新账户", - "newIdHref": "#account", - "profileLabel": "账户信息", - "profileHref": "#footer", - "statuses": [{"id":"mail","label":"邮箱 3封","href":"#account","visual":"mail"},{"id":"points","label":"积分 240点","href":"#shopping","visual":"points"}], - "modalTitle": "Yahoo! JAPAN 登录", - "welcome": "欢迎回来", - "demoNote": "这是概念演示页面,输入内容不会保存或发送。", - "emailLabel": "电子邮箱", - "passwordLabel": "密码", - "submitLabel": "登录", - "resultMessage": "演示页面不会执行真实登录。", - "closeLabel": "关闭登录窗口" - }, - "weather": { - "title": "今明天气", - "regionLabel": "选择地区", - "locations": [ - {"id":"tokyo","city":"东京都港区","today":{"label":"今天 10月30日","high":"21°","low":"16°","summary":"雨转阴 · 降水概率90%"},"tomorrow":{"label":"明天 10月31日","high":"23°","low":"16°","summary":"多云 · 降水概率40%"}}, - {"id":"osaka","city":"大阪市北区","today":{"label":"今天 10月30日","high":"24°","low":"17°","summary":"晴间多云 · 降水概率20%"},"tomorrow":{"label":"明天 10月31日","high":"25°","low":"18°","summary":"晴 · 降水概率10%"}}, - {"id":"fukuoka","city":"福冈市中央区","today":{"label":"今天 10月30日","high":"23°","low":"18°","summary":"多云 · 降水概率30%"},"tomorrow":{"label":"明天 10月31日","high":"24°","low":"17°","summary":"晴间多云 · 降水概率20%"}} - ], - "updatedMessage": "天气信息已更新。", - "links": [{"id":"radar","label":"降雨雷达","message":"已打开降雨雷达演示。"},{"id":"alerts","label":"预警信息","message":"已打开气象预警演示。"},{"id":"typhoon","label":"台风信息","message":"已打开台风信息演示。"}] - }, - "utilities": { - "transit": {"title":"运行信息与路线查询","detailLabel":"详情","detailHref":"#transit","status":"首都圈主要线路当前正常运行。","fromLabel":"出发站","toLabel":"到达站","buttonLabel":"查询路线","emptyMessage":"输入车站名称即可查询路线。","resultPrefix":"演示路线","resultDetail":"预计28分钟,换乘1次。"}, - "finance": {"title":"财经行情","moreLabel":"市场总览","moreHref":"#finance","updated":"10月30日 14:00","previousPrefix":"上一交易日收盘","markets":[{"id":"nikkei","label":"日经平均指数","value":"39,842.16","change":"+126.42 ▲0.32%","previous":"39,715.74"},{"id":"usd","label":"美元兑日元","value":"149.28","change":"+0.18 ▲0.12%","previous":"149.10"},{"id":"topix","label":"东证指数","value":"2,784.51","change":"+8.32 ▲0.30%","previous":"2,776.19"}]} - }, - "shopping": { - "title": "购物推荐", - "moreLabel": "查看更多", - "moreHref": "#shopping", - "saveLabel": "加入收藏", - "removeLabel": "取消收藏", - "savedMessage": "商品已加入收藏。", - "removedMessage": "商品已移出收藏。", - "products": [{"id":"backpack","title":"轻量日用背包 18升","price":"¥4,980","visual":"backpack"},{"id":"clock","title":"静音指针时钟","price":"¥2,640","visual":"clock"},{"id":"umbrella","title":"折叠晴雨两用伞","price":"¥3,280","visual":"umbrella"},{"id":"mill","title":"木制手摇咖啡磨","price":"¥6,500","visual":"mill"},{"id":"purifier","title":"桌面空气净化器","price":"¥8,800","visual":"purifier"}] - }, - "pulse": { - "sportsTitle": "体育快讯", - "sportsMoreLabel": "体育频道", - "sportsMoreHref": "#sports", - "sportsTabs": [ - {"id":"baseball","label":"职业棒球","games":[{"id":"game-one","home":"福冈雄鹰队","homeNote":"18:00开始","score":"–","away":"横滨星队","awayNote":"东京巨蛋"},{"id":"game-two","home":"大阪猛牛队","homeNote":"比赛结束","score":"4 – 2","away":"千叶海洋队","awayNote":"大阪巨蛋"}]}, - {"id":"football","label":"职业足球","games":[{"id":"game-three","home":"东京联队","homeNote":"下半场32分钟","score":"2 – 1","away":"大阪蓝队","awayNote":"国立竞技场"}]}, - {"id":"volleyball","label":"排球","games":[{"id":"game-four","home":"日本队","homeNote":"比赛结束","score":"3 – 1","away":"菲律宾队","awayNote":"亚洲锦标赛"}]} - ], - "trendsTitle": "实时热榜", - "refreshLabel": "14:00更新", - "refreshMessage": "实时热榜已经更新。", - "trendTabs": [ - {"id":"rising","label":"热度上升","items":[{"id":"trend-one","rank":"1","title":"新干线试运行","meta":"热度4.2万","href":"#news"},{"id":"trend-two","rank":"2","title":"秋雨锋面","meta":"热度3.1万","href":"#weather"},{"id":"trend-three","rank":"3","title":"日本职业棒球决赛","meta":"热度2.5万","href":"#sports"}]}, - {"id":"news","label":"新闻","items":[{"id":"trend-four","rank":"1","title":"广域交通网络","meta":"快讯","href":"#news"},{"id":"trend-five","rank":"2","title":"日经平均指数","meta":"经济","href":"#finance"}]}, - {"id":"culture","label":"文娱","items":[{"id":"trend-six","rank":"1","title":"国际电影节","meta":"电影","href":"#news"},{"id":"trend-seven","rank":"2","title":"秋季新剧","meta":"电视","href":"#news"}]} - ] - }, - "footer": { - "drawerTitle": "日本生活服务", - "drawerText": "集中查看搜索、新闻、天气、交通、购物和体育内容。", - "drawerLinks": [{"id":"services","label":"服务","href":"#services"},{"id":"news","label":"新闻","href":"#news"},{"id":"weather","label":"天气与灾害","href":"#weather"},{"id":"transit","label":"路线信息","href":"#transit"},{"id":"finance","label":"财经","href":"#finance"},{"id":"sports","label":"体育","href":"#sports"},{"id":"shopping","label":"购物","href":"#shopping"},{"id":"help","label":"帮助","href":"#footer"}], - "links": [{"id":"company","label":"公司信息","href":"#footer"},{"id":"terms","label":"使用条款","href":"#footer"},{"id":"privacy","label":"隐私政策","href":"#footer"},{"id":"ads","label":"广告合作","href":"#footer"},{"id":"contact","label":"联系我们","href":"#footer"},{"id":"accessibility","label":"无障碍说明","href":"#footer"},{"id":"all","label":"服务总览","href":"#services"}], - "copyright": "© 2026 Yahoo! JAPAN 风格中文概念门户", - "legal": "本页面仅用于界面演示,不是 Yahoo! JAPAN 官方网站,也不提供真实账户、交易或搜索服务。", - "backTopLabel": "返回页面顶部" - } -} diff --git a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/yahoo-japan-runtime.js b/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/yahoo-japan-runtime.js deleted file mode 100644 index ab5e4ff0..00000000 --- a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/yahoo-japan-runtime.js +++ /dev/null @@ -1,297 +0,0 @@ -(() => { - "use strict"; - - const MODULES = ["site", "search", "services", "news", "account", "weather", "utilities", "shopping", "pulse", "footer"]; - const MODULE_META = {site:"站点信息",search:"门户搜索",services:"主要服务",news:"新闻推荐",account:"账户登录",weather:"天气信息",utilities:"交通与行情",shopping:"购物推荐",pulse:"体育与热榜",footer:"抽屉与页脚"}; - const node = (id) => document.getElementById(id); - const clone = (value) => JSON.parse(JSON.stringify(value)); - const initialText = node("yahooJapanPageData").textContent; - const initialData = JSON.parse(initialText); - const fingerprint = (() => { let hash=2166136261; for(let i=0;i>>0).toString(36) })(); - const dataKey = "yahoo-japan-page-data-v1:" + fingerprint; - const themeKey = "yahoo-japan-page-theme-v1:" + fingerprint; - const readStorage = (key) => { try{return window.localStorage.getItem(key)}catch(_error){return null} }; - const writeStorage = (key,value) => { try{window.localStorage.setItem(key,value);return true}catch(_error){return false} }; - const removeStorage = (key) => { try{window.localStorage.removeItem(key)}catch(_error){/* optional storage */} }; - let data=clone(initialData); - try{const saved=readStorage(dataKey);if(saved)data=JSON.parse(saved)}catch(_error){data=clone(initialData)} - let activeSearch="web",activeNews="top",activeWeather="tokyo",activeMarket="nikkei",activeSport="baseball",activeTrend="rising",currentModule=MODULES[0],toastTimer=0; - const esc = (value) => String(value??"").replace(/[&<>'"]/g,(char)=>({"&":"&","<":"<",">":">","'":"'",'"':"""})[char]); - const safeHref = (value) => typeof value==="string"&&value.startsWith("#")?value:"#main"; - const safeRemoteUrl=(value,kind="link")=>{const source=String(value||"").trim();if(!source)return"";const lower=source.toLowerCase();if(kind==="image"&&lower.startsWith("data:image/"))return source;if(kind==="video"&&lower.startsWith("data:video/"))return source;try{const url=new URL(source);return["http:","https:"].includes(url.protocol)?url.href:""}catch(_error){return""}}; - const setText = (id,value) => {const el=node(id);if(el)el.textContent=value??""}; - const setLink = (id,label,href) => {const el=node(id);if(el){el.textContent=label??"";el.href=safeHref(href)}}; - const links = (items) => (items||[]).map((item)=>`${esc(item.label)}`).join(""); - function toast(message){const el=node("toast");el.textContent=message;el.classList.add("show");clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove("show"),2200)} - function openLayer(id){node(id).classList.add("open");document.body.classList.add("locked")} - function closeLayer(id){node(id).classList.remove("open");if(!document.querySelector(".drawer.open,.modal.open,.settings-overlay.open"))document.body.classList.remove("locked")} - - function renderSite(){ - document.documentElement.lang=data.site.language;document.title=data.site.pageTitle;document.querySelector('meta[name="description"]').content=data.site.description; - setText("skipLink",data.site.skipLabel);setText("brandPrimary",data.site.brandPrimary);setText("brandAccent",data.site.brandAccent);setText("tagline",data.site.tagline);setText("drawerBrand",`${data.site.brandPrimary} ${data.site.brandAccent}`); - node("utilityLeft").innerHTML=`${esc(data.site.dateLabel)}${links(data.site.utilityLeft)}`;node("utilityRight").innerHTML=links(data.site.utilityRight); - setText("headerLogin",data.site.loginLabel);setText("mailLabel",data.site.mailLabel);setText("mailCount",data.site.mailCount);node("menuOpen").setAttribute("aria-label",data.site.menuLabel);node("themeToggle").setAttribute("aria-label",data.site.themeLabel); - setText("noticeLabel",data.site.notice.label);setLink("noticeLink",data.site.notice.text,data.site.notice.href); - const preferred=readStorage(themeKey);document.body.classList.toggle("dark",preferred==="dark"||(!preferred&&data.site.theme==="dark")); - } - function renderSearch(){ - setText("searchTitle",data.search.title);if(!data.search.tabs.some((item)=>item.id===activeSearch))activeSearch=data.search.tabs[0]?.id;node("searchTabs").innerHTML=data.search.tabs.map((item)=>``).join("");const tab=data.search.tabs.find((item)=>item.id===activeSearch)||data.search.tabs[0];node("searchInput").placeholder=`${tab.label}${data.search.placeholderSuffix}`;setText("searchButton",data.search.button);node("suggestions").innerHTML=`${esc(data.search.hotLabel)}${data.search.suggestions.map((item)=>``).join("")}`; - } - document.head.insertAdjacentHTML("beforeend",''); - const externalMedia=(media,label)=>{if(!media||typeof media!=="object")return"";if(media.type==="image"){const source=safeRemoteUrl(media.mediaUrl,"image"),page=safeRemoteUrl(media.pageUrl);return source?`${esc(media.alt||label||${page?`打开来源`:""}`:""}if(media.type==="video"){const source=safeRemoteUrl(media.mediaUrl,"video"),poster=safeRemoteUrl(media.posterUrl,"image");return source?``:""}if(media.type==="webpage"){const page=safeRemoteUrl(media.pageUrl),preview=safeRemoteUrl(media.mediaUrl,"image");if(!page)return"";const body=media.embed===true?``:(preview?`${esc(media.alt||media.title||`:"");return`${body}${esc(media.title||label||"打开网页")}${esc(media.description||"新窗口打开")}`}return""}; - const art=(item,className,mediaPath="")=>{const mediaMarkup=externalMedia(item.media,item.title),path=mediaPath?` data-media-path="${esc(mediaPath)}"`:"";return `
${mediaMarkup}
`}; - const serviceCard=(item)=>`${esc(item.label.slice(0,1))}${esc(item.label)}`; - function renderServices(){setText("servicesTitle",data.services.title);setLink("servicesAll",data.services.allLabel,data.services.allHref);node("servicePrimary").innerHTML=data.services.primary.map(serviceCard).join("");node("serviceExtra").innerHTML=data.services.extra.map(serviceCard).join("");const open=node("serviceExtra").classList.contains("open");setText("serviceMore",open?data.services.lessLabel:data.services.moreLabel);node("serviceMore").setAttribute("aria-expanded",String(open))} - function renderNews(){ - setText("newsTitle",data.news.title);setText("newsUpdated",data.news.updated);if(!data.news.tabs.some((item)=>item.id===activeNews))activeNews=data.news.tabs[0]?.id;node("newsTabs").innerHTML=data.news.tabs.map((tab)=>``).join("");node("newsLists").innerHTML=data.news.tabs.map((tab)=>`
${tab.items.map((item)=>`
${item.tag?`${esc(item.tag)}`:""}${esc(item.title)}${item.isNew?'新':""}
`).join("")}
`).join(""); - const feature=data.news.feature;node("newsFeature").innerHTML=`${art(feature,"feature-art","news.feature")}
${esc(feature.kicker)}

${esc(feature.title)}

${esc(feature.summary)}

`;setLink("newsMore",data.news.moreLabel,data.news.moreHref);setLink("newsTopics",data.news.topicsLabel,data.news.topicsHref);setText("recommendTitle",data.news.recommendTitle);setText("recRefresh",data.news.refreshLabel);node("recommendGrid").innerHTML=data.news.recommendations.map((item,index)=>`
${art(item,"rec-art",`news.recommendations.${index}`)}

${esc(item.title)}

${esc(item.category)}
`).join(""); - } - function renderAccount(){setText("accountTitle",data.account.title);setText("accountText",data.account.text);setText("accountLogin",data.account.loginLabel);setLink("accountNew",data.account.newIdLabel,data.account.newIdHref);setLink("accountProfile",data.account.profileLabel,data.account.profileHref);node("accountStatuses").innerHTML=data.account.statuses.map((item)=>`${esc(item.label)}`).join("");setText("loginModalTitle",data.account.modalTitle);setText("loginWelcome",data.account.welcome);setText("loginNote",data.account.demoNote);setText("emailLabel",data.account.emailLabel);setText("passwordLabel",data.account.passwordLabel);setText("loginSubmit",data.account.submitLabel);node("loginModal").querySelector(".close").setAttribute("aria-label",data.account.closeLabel)} - function renderWeather(){setText("weatherTitle",data.weather.title);node("weatherSelect").setAttribute("aria-label",data.weather.regionLabel);if(!data.weather.locations.some((item)=>item.id===activeWeather))activeWeather=data.weather.locations[0]?.id;node("weatherSelect").innerHTML=data.weather.locations.map((item)=>``).join("");const item=data.weather.locations.find((value)=>value.id===activeWeather)||data.weather.locations[0];setText("todayLabel",item.today.label);setText("todayHigh",item.today.high);setText("todayLow",item.today.low);setText("todaySummary",item.today.summary);setText("tomorrowLabel",item.tomorrow.label);setText("tomorrowHigh",item.tomorrow.high);setText("tomorrowLow",item.tomorrow.low);setText("tomorrowSummary",item.tomorrow.summary);node("weatherLinks").innerHTML=data.weather.links.map((link)=>``).join("")} - function renderUtilities(){const transit=data.utilities.transit;setText("transitTitle",transit.title);setLink("transitDetail",transit.detailLabel,transit.detailHref);setText("transitStatus",transit.status);node("fromStation").placeholder=transit.fromLabel;node("fromStation").setAttribute("aria-label",transit.fromLabel);node("toStation").placeholder=transit.toLabel;node("toStation").setAttribute("aria-label",transit.toLabel);setText("routeButton","→");node("routeButton").setAttribute("aria-label",transit.buttonLabel);if(!node("routeResult").textContent)setText("routeResult",transit.emptyMessage);const finance=data.utilities.finance;setText("financeTitle",finance.title);setLink("financeMore",finance.moreLabel,finance.moreHref);if(!finance.markets.some((item)=>item.id===activeMarket))activeMarket=finance.markets[0]?.id;node("marketTabs").innerHTML=finance.markets.map((item)=>``).join("");const market=finance.markets.find((item)=>item.id===activeMarket)||finance.markets[0];setText("marketValue",market.value);setText("marketChange",market.change);setText("marketUpdated",finance.updated);setText("marketPrevious",`${finance.previousPrefix} ${market.previous}`)} - function renderShopping(){setText("shoppingTitle",data.shopping.title);setLink("shoppingMore",data.shopping.moreLabel,data.shopping.moreHref);node("productGrid").innerHTML=data.shopping.products.map((item,index)=>`
${art(item,"product-art",`shopping.products.${index}`)}

${esc(item.title)}

${esc(item.price)}
`).join("")} - function renderPulse(){setText("sportsTitle",data.pulse.sportsTitle);setLink("sportsMore",data.pulse.sportsMoreLabel,data.pulse.sportsMoreHref);if(!data.pulse.sportsTabs.some((item)=>item.id===activeSport))activeSport=data.pulse.sportsTabs[0]?.id;node("sportsTabs").innerHTML=data.pulse.sportsTabs.map((tab)=>``).join("");node("scorePanels").innerHTML=data.pulse.sportsTabs.map((tab)=>`
${tab.games.map((game)=>`
${esc(game.home)}
${esc(game.score)}
${esc(game.away)}
`).join("")}
`).join("");setText("trendsTitle",data.pulse.trendsTitle);setText("trendRefresh",data.pulse.refreshLabel);if(!data.pulse.trendTabs.some((item)=>item.id===activeTrend))activeTrend=data.pulse.trendTabs[0]?.id;node("trendTabs").innerHTML=data.pulse.trendTabs.map((tab)=>``).join("");node("trendPanels").innerHTML=data.pulse.trendTabs.map((tab)=>`
${tab.items.map((item)=>`
${esc(item.rank)}${esc(item.title)}${esc(item.meta)}
`).join("")}
`).join("")} - function renderFooter(){setText("drawerTitle",data.footer.drawerTitle);setText("drawerText",data.footer.drawerText);node("drawerNav").innerHTML=links(data.footer.drawerLinks);node("footerLinks").innerHTML=links(data.footer.links);setText("copyright",data.footer.copyright);setText("legal",data.footer.legal);node("backTop").setAttribute("aria-label",data.footer.backTopLabel)} - function renderAll(){renderSite();renderSearch();renderServices();renderNews();renderAccount();renderWeather();renderUtilities();renderShopping();renderPulse();renderFooter()} - - function validateClient(candidate){ - if(!candidate||typeof candidate!=="object"||Array.isArray(candidate))throw new Error("顶层 JSON 必须是对象。");const keys=Object.keys(candidate),missing=MODULES.filter((name)=>!keys.includes(name)),extra=keys.filter((name)=>!MODULES.includes(name));if(missing.length)throw new Error(`缺少模块:${missing.join("、")}`);if(extra.length)throw new Error(`存在未知模块:${extra.join("、")}`);MODULES.forEach((name)=>{if(!candidate[name]||typeof candidate[name]!=="object"||Array.isArray(candidate[name]))throw new Error(`${name} 必须是对象。`)});const raw=JSON.stringify(candidate).toLowerCase();if(["raw.includes(token)))throw new Error("不允许使用 HTML 或脚本。");const validateMedia=(value,path="当前模块")=>{if(Array.isArray(value))return value.forEach((item,index)=>validateMedia(item,`${path}[${index}]`));if(!value||typeof value!=="object")return;const media=value.media;if(media&&["image","video","webpage"].includes(media.type)){if(["image","video"].includes(media.type)&&!safeRemoteUrl(media.mediaUrl,media.type))throw new Error(`${path}.media.mediaUrl 无效。`);if(media.type==="webpage"&&!safeRemoteUrl(media.pageUrl))throw new Error(`${path}.media.pageUrl 无效。`);if(media.posterUrl&&!safeRemoteUrl(media.posterUrl,"image"))throw new Error(`${path}.media.posterUrl 无效。`)}Object.entries(value).forEach(([key,item])=>{if(key!=="media")validateMedia(item,`${path}.${key}`)})};validateMedia(candidate);if(candidate.site.language!=="zh-CN")throw new Error("site.language 必须是 zh-CN。");const technical=new Set(["id","href","visual","theme","language","brandPrimary","brandAccent","value","price","score","change","previous","rank","type","mediaUrl","posterUrl","pageUrl"]);const walk=(value,key="")=>{if(Array.isArray(value))value.forEach((item)=>walk(item,key));else if(value&&typeof value==="object")Object.entries(value).forEach(([child,item])=>walk(item,child));else if(typeof value==="string"&&!technical.has(key)&&!key.toLowerCase().endsWith("id")&&!key.toLowerCase().endsWith("href")&&!key.toLowerCase().endsWith("url")&&(/[\u3040-\u30ff]/.test(value)||(/[A-Za-z]/.test(value)&&!/[\u3400-\u9fff]/.test(value))))throw new Error("除品牌 Yahoo! JAPAN 外,所有读者可见文案必须使用简体中文。")};walk(candidate);return true; - } - function renderSettingsTabs(){node("moduleTabs").innerHTML=MODULES.map((name)=>``).join("");showModule(currentModule)} - function showModule(name){currentModule=name;node("moduleTabs").querySelectorAll("button").forEach((button)=>button.classList.toggle("active",button.dataset.module===name));setText("editorModule",MODULE_META[name]);node("jsonEditor").value=JSON.stringify(data[name],null,2);setText("jsonValidity","JSON 有效")} - function parseEditor(){try{const value=JSON.parse(node("jsonEditor").value);if(!value||typeof value!=="object"||Array.isArray(value))throw new Error("当前模块必须是对象。");setText("jsonValidity","JSON 有效");return value}catch(error){setText("jsonValidity","JSON 无效");throw error}} - const setStatus=(message)=>setText("settingsStatus",message); - function applyEditor(){try{const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);data=candidate;writeStorage(dataKey,JSON.stringify(data));renderAll();renderSettingsTabs();setStatus("JSON 已应用并完成渲染。")}catch(error){setStatus(error.message);setText("jsonValidity","JSON 无效")}} - async function copyJson(){const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);const text=JSON.stringify(candidate,null,2);try{await navigator.clipboard.writeText(text);setStatus("完整 JSON 已复制。")}catch(_error){node("jsonEditor").select();document.execCommand("copy");setStatus("已选中当前模块,可手动复制。")}} - function downloadJson(){const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);const blob=new Blob([JSON.stringify(candidate,null,2)+"\n"],{type:"application/json"}),url=URL.createObjectURL(blob),anchor=document.createElement("a");anchor.href=url;anchor.download="yahoo-japan-page-data.json";anchor.click();URL.revokeObjectURL(url);setStatus("JSON 下载文件已生成。")} - - node("searchForm").addEventListener("submit",(event)=>{event.preventDefault();const raw=node("searchInput").value.trim(),tab=data.search.tabs.find((item)=>item.id===activeSearch)||data.search.tabs[0];setText("searchStatus",raw?`${data.search.resultPrefix}${tab.label},${data.search.resultKeyword}“${raw}”。`:data.search.emptyMessage)}); - node("serviceMore").addEventListener("click",()=>{node("serviceExtra").classList.toggle("open");renderServices()}); - node("weatherSelect").addEventListener("change",(event)=>{activeWeather=event.target.value;renderWeather();toast(data.weather.updatedMessage)}); - node("routeForm").addEventListener("submit",(event)=>{event.preventDefault();const from=node("fromStation").value.trim(),to=node("toStation").value.trim();setText("routeResult",`${data.utilities.transit.resultPrefix}:${from} → ${to},${data.utilities.transit.resultDetail}`)}); - node("loginForm").addEventListener("submit",(event)=>{event.preventDefault();setText("loginStatus",data.account.resultMessage)}); - node("themeToggle").addEventListener("click",()=>{const on=document.body.classList.toggle("dark");writeStorage(themeKey,on?"dark":"light");toast(on?data.site.themeDarkMessage:data.site.themeLightMessage)}); - node("menuOpen").addEventListener("click",()=>openLayer("drawer"));node("backTop").addEventListener("click",()=>scrollTo({top:0,behavior:"smooth"}));window.addEventListener("scroll",()=>node("backTop").classList.toggle("show",scrollY>500),{passive:true}); - document.addEventListener("click",(event)=>{const close=event.target.closest("[data-close]");if(close){closeLayer(close.dataset.close);return}const login=event.target.closest("[data-login]");if(login){if(node("drawer").classList.contains("open"))closeLayer("drawer");openLayer("loginModal");return}const search=event.target.closest("[data-search]");if(search){activeSearch=search.dataset.search;renderSearch();return}const suggestion=event.target.closest("[data-suggestion]");if(suggestion){node("searchInput").value=suggestion.dataset.suggestion;node("searchInput").focus();return}const news=event.target.closest("[data-news]");if(news){activeNews=news.dataset.news;renderNews();return}const weather=event.target.closest("[data-weather-link]");if(weather){const item=data.weather.links.find((value)=>value.id===weather.dataset.weatherLink);if(item)toast(item.message);return}const market=event.target.closest("[data-market]");if(market){activeMarket=market.dataset.market;renderUtilities();return}const product=event.target.closest("[data-product]");if(product){const saved=product.classList.toggle("saved");product.textContent=saved?"♥":"♡";product.setAttribute("aria-label",saved?data.shopping.removeLabel:data.shopping.saveLabel);toast(saved?data.shopping.savedMessage:data.shopping.removedMessage);return}const sport=event.target.closest("[data-sport]");if(sport){activeSport=sport.dataset.sport;renderPulse();return}const trend=event.target.closest("[data-trend]");if(trend){activeTrend=trend.dataset.trend;renderPulse();return}const module=event.target.closest("[data-module]");if(module&&module.closest("#moduleTabs"))showModule(module.dataset.module)}); - node("recRefresh").addEventListener("click",()=>{const first=data.news.recommendations.shift();data.news.recommendations.push(first);renderNews();toast(data.news.refreshMessage)});node("trendRefresh").addEventListener("click",()=>toast(data.pulse.refreshMessage)); - ["drawer","loginModal","settingsOverlay"].forEach((id)=>node(id).addEventListener("click",(event)=>{if(event.target===node(id))closeLayer(id)}));document.addEventListener("keydown",(event)=>{if(event.key==="Escape")["drawer","loginModal","settingsOverlay"].forEach((id)=>{if(node(id).classList.contains("open"))closeLayer(id)})}); - node("openSettings").addEventListener("click",()=>{renderSettingsTabs();setStatus("");openLayer("settingsOverlay")});node("validateJson").addEventListener("click",()=>{try{const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);setStatus("完整 JSON 校验通过。")}catch(error){setStatus(error.message)}});node("copyJson").addEventListener("click",()=>copyJson().catch((error)=>setStatus(error.message)));node("downloadJson").addEventListener("click",()=>{try{downloadJson()}catch(error){setStatus(error.message)}});node("resetJson").addEventListener("click",()=>{data=clone(initialData);removeStorage(dataKey);removeStorage(themeKey);activeSearch="web";activeNews="top";activeWeather="tokyo";activeMarket="nikkei";activeSport="baseball";activeTrend="rising";currentModule=MODULES[0];renderAll();renderSettingsTabs();setStatus("已恢复内嵌初始 JSON。");});node("applyJson").addEventListener("click",applyEditor); - - validateClient(data);renderAll(); - function installJsonMediaUpload(){ - const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return; - const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson");button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input); - const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message}; - const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;indexindex+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null}; - button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()}); - input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)}); - } - installJsonMediaUpload(); - function installInlineMediaReplace() { - if (document.getElementById("dfInlineMediaInput")) return; - const visualHostSelector = "[data-media-path],.post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt"; - const getRootData = () => { - if (typeof pageData !== "undefined") return pageData; - if (typeof data !== "undefined") return data; - return null; - }; - const input = document.createElement("input"); - input.id = "dfInlineMediaInput"; - input.type = "file"; - input.hidden = true; - document.body.appendChild(input); - const style = document.createElement("style"); - style.dataset.dfInlineMediaReplace = "true"; - style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}"; - document.head.appendChild(style); - const notify = (message) => { - if (typeof showToast === "function") { showToast(message); return; } - if (typeof toast === "function") { toast(message); return; } - let notice = document.getElementById("dfInlineMediaToast"); - if (!notice) { - notice = document.createElement("div"); - notice.id = "dfInlineMediaToast"; - notice.className = "df-inline-media-toast"; - document.body.appendChild(notice); - } - notice.textContent = message; - window.clearTimeout(notice._timer); - notice._timer = window.setTimeout(() => notice.remove(), 2600); - }; - const sameSource = (value, source, absolute) => { - if (typeof value !== "string") return false; - if (value === source || value === absolute) return true; - try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; } - }; - const findMatches = (source, absolute) => { - const matches = []; - const stack = []; - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - stack.push(value); - Object.entries(value).forEach(([key, item]) => { - if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) { - const ancestors = stack.slice(0, -1).reverse(); - const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value; - matches.push({ owner, previous: value, key }); - } - if (item && typeof item === "object") visit(item); - }); - stack.pop(); - }; - visit(getRootData()); - return matches; - }; - const visualOwners = () => { - const owners = []; - const seen = new Set(); - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object"); - const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim()); - if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); } - Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); }); - }; - visit(getRootData()); - return owners; - }; - const domIdentifiers = (element) => { - const values = new Set(); - let cursor = element; - for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) { - Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); }); - } - return values; - }; - const exactPathTarget = (element) => { - const host = element.closest("[data-media-path]"); - const path = host && host.dataset ? host.dataset.mediaPath : ""; - if (!path) return null; - const owner = path.split(".").reduce((value, segment) => { - if (Array.isArray(value)) return value[Number(segment)]; - return value && typeof value === "object" ? value[segment] : null; - }, getRootData()); - if (!owner || typeof owner !== "object" || Array.isArray(owner)) return null; - return { owner, previous: owner.media && typeof owner.media === "object" ? owner.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseVisualTarget = (element) => { - const exact = exactPathTarget(element); - if (exact) return exact; - const identifiers = domIdentifiers(element); - const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element; - const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim(); - const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]"); - const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : ""; - let best = null; - let bestScore = 0; - visualOwners().forEach((owner) => { - let score = 0; - Object.entries(owner).forEach(([key, value]) => { - if (/id$/i.test(key) && identifiers.has(String(value))) score += 100; - }); - ["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => { - const value = typeof owner[key] === "string" ? owner[key].trim() : ""; - if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length); - }); - if (visualValue && owner.visual === visualValue) score += 20; - if (score > bestScore) { best = owner; bestScore = score; } - }); - if (!best || bestScore <= 0) return null; - return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseTarget = (element) => { - const exact = exactPathTarget(element); - if (exact) return exact; - const source = element.getAttribute("src") || ""; - const absolute = element.currentSrc || element.src || source; - const matches = findMatches(source, absolute); - if (!matches.length) return null; - const identifiers = domIdentifiers(element); - return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0]; - }; - const persist = () => { - const root = getRootData(); - if (typeof saveData === "function") return saveData(); - if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root)); - if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root)); - return false; - }; - let active = null; - const attachButton = (host, target) => { - if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return; - host.classList.add("df-inline-media-host"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "df-inline-media-button"; - button.setAttribute("aria-label", "替换图片或视频"); - button.title = "上传图片或视频并替换"; - button.innerHTML = ''; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - active = { ...target }; - input.accept = "image/*,video/*"; - input.value = ""; - input.click(); - }); - host.appendChild(button); - }; - const decorateBuiltInVisuals = () => { - document.querySelectorAll(visualHostSelector).forEach((host) => { - if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return; - if (!host.querySelector("svg") && !host.dataset.visual) return; - const target = chooseVisualTarget(host); - if (!target) return; - attachButton(host, target); - }); - }; - const decorate = () => { - decorateBuiltInVisuals(); - document.querySelectorAll("img[src],video[src]").forEach((media) => { - if (media.dataset.dfInlineMediaReady === "true") return; - const target = chooseTarget(media); - if (!target) return; - const host = media.closest(visualHostSelector) || media.parentElement; - if (!host) return; - media.dataset.dfInlineMediaReady = "true"; - attachButton(host, target); - }); - }; - input.addEventListener("change", () => { - const file = input.files && input.files[0]; - if (!file || !active) return; - const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : ""; - if (!uploadedType) { - notify("请选择图片或视频文件。"); - return; - } - const reader = new FileReader(); - reader.onerror = () => notify("文件读取失败,请重新选择。"); - reader.onload = () => { - const next = { ...(active.previous || {}) }; - const result = String(reader.result); - next.type = uploadedType; - next.mediaUrl = result; - delete next.posterUrl; - if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片"; - active.owner.media = next; - const stored = persist(); - renderAll(); - notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。"); - active = null; - }; - reader.readAsDataURL(file); - }); - let queued = false; - const queueDecorate = () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; decorate(); }); - }; - new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true }); - decorate(); - } - installInlineMediaReplace(); -})(); diff --git a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/yahoo-japan-template.html b/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/yahoo-japan-template.html deleted file mode 100644 index caba64ca..00000000 --- a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/assets/yahoo-japan-template.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - Yahoo! JAPAN 中文综合门户 - - - - -
-
-

-
-
-

-

-

-
-

-

-
-
- -

- -
-
雅虎日本页面模板控制台

JSON 编辑器

JSON 有效
编辑当前模块并完成校验后,即可应用到页面。缓存按照当前文件的初始 JSON 指纹隔离。
- - - - diff --git a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/references/data-schema.md deleted file mode 100644 index 99ea0aa1..00000000 --- a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/references/data-schema.md +++ /dev/null @@ -1,31 +0,0 @@ -# 雅虎日本中文页面 JSON 契约 - -顶层只允许以下十个对象: - -`site`、`search`、`services`、`news`、`account`、`weather`、`utilities`、`shopping`、`pulse`、`footer`。 - -## 中文内容规则 - -- `site.language` 固定为 `zh-CN`。 -- 品牌 `Yahoo! JAPAN` 可以保留;其余栏目、标题、摘要、按钮、日期、天气、交通、市场、商品、体育和页脚文案必须使用简体中文。 -- JSON 键名及 `id`、`href`、`visual`、`theme` 等技术值保持英文;数字、时间、温度、百分比、价格和比分可以保持数据格式。 - -## 模块说明 - -- `site`:品牌、口号、页面信息、主题、顶部链接、操作按钮和公告。 -- `search`:搜索类型、输入提示、热门词和结果反馈文案。 -- `services`:5–12 个常用服务与 1–8 个扩展服务。 -- `news`:2–8 个新闻分类、主专题和 1–6 条推荐内容。 -- `account`:登录卡片、账户状态和演示登录弹窗。 -- `weather`:2–6 个地区的今明天气及 1–6 个天气入口。 -- `utilities`:路线查询文案和 2–6 个市场行情。 -- `shopping`:2–8 个商品卡片与收藏反馈。 -- `pulse`:2–5 个体育分类及 2–5 个实时热榜分类。 -- `footer`:移动抽屉、页脚链接、版权和概念站说明。 - -## 通用限制 - -- 文本必须是纯文本,不得包含 HTML 或 `javascript:`。具有 `visual` 的对象可增加 `media` 替换视觉:图片 `{type:"image", mediaUrl, alt?, pageUrl?}`;视频 `{type:"video", mediaUrl, posterUrl?, alt?, pageUrl?}`;网页 `{type:"webpage", pageUrl, title, description?, mediaUrl?, embed?}`。仅这些媒体 URL 字段允许 `http(s)`,图片/视频也允许对应的 `data:` URL。 -- 所有 `href` 必须以 `#` 开头;同一业务数组中的 `id` 必须唯一。 -- 页面不加载远程字体、脚本或接口;默认视觉由模板内置 CSS 图形生成,只有明确提供受校验媒体 URL 时才加载对应媒体。 -- JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。 diff --git a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/scripts/fast_page.py deleted file mode 100644 index 479929da..00000000 --- a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "_shared")) - -import render_yahoo_japan_page as renderer -from fast_patch_page import run - -if __name__ == "__main__": - sys.exit(run(renderer, output_stem="yahoo-japan-page")) diff --git a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/scripts/render_yahoo_japan_page.py b/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/scripts/render_yahoo_japan_page.py deleted file mode 100644 index edbe3cb7..00000000 --- a/offline-backend-20260512/backend/skills/public/yahoo-japan-page-generator/scripts/render_yahoo_japan_page.py +++ /dev/null @@ -1,422 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_ROOT = Path(__file__).resolve().parents[1] -DEFAULT_DATA = SKILL_ROOT / "assets" / "default-data.json" -TEMPLATE_PATH = SKILL_ROOT / "assets" / "yahoo-japan-template.html" -RUNTIME_PATH = SKILL_ROOT / "assets" / "yahoo-japan-runtime.js" -MODULES = ( - "site", - "search", - "services", - "news", - "account", - "weather", - "utilities", - "shopping", - "pulse", - "footer", -) -SERVICE_VISUALS = { - "shopping", "auction", "weather", "news", "sports", "finance", "transit", - "property", "car", "travel", "calendar", "community", "tax", "jobs", "books", -} -RECOMMEND_VISUALS = {"trail", "recipe", "plaza"} -PRODUCT_VISUALS = {"backpack", "clock", "umbrella", "mill", "purifier"} -FEATURE_VISUALS = {"transit-map"} -REMOTE_MEDIA_TYPES = {"image", "video", "webpage"} -MEDIA_URL_KEYS = {"mediaUrl", "posterUrl", "pageUrl"} -TECHNICAL_KEYS = { - "id", "href", "visual", "theme", "language", "brandPrimary", "brandAccent", - "value", "price", "score", "change", "previous", "rank", "type", - "mediaUrl", "posterUrl", "pageUrl", -} -LATIN_RE = re.compile(r"[A-Za-z]") -KANA_RE = re.compile(r"[\u3040-\u30ff]") -CJK_RE = re.compile(r"[\u3400-\u9fff]") -REMOTE_RE = re.compile(r"(?:https?://|javascript:|<\s*(?:script|iframe)\b)", re.I) - - -class DataValidationError(ValueError): - pass - - -def _read_json(path: Path) -> dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise DataValidationError(f"unable to read valid JSON from {path}: {exc}") from exc - if not isinstance(value, dict): - raise DataValidationError("top-level JSON must be an object") - return value - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _list(value: Any, path: str, errors: list[str], minimum: int = 0, maximum: int = 99) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if not minimum <= len(value) <= maximum: - errors.append(f"{path} must contain {minimum}-{maximum} items") - return value - - -def _strings(obj: dict[str, Any], path: str, keys: tuple[str, ...], errors: list[str]) -> None: - for key in keys: - if not isinstance(obj.get(key), str) or not obj[key].strip(): - errors.append(f"{path}.{key} must be a non-empty string") - - -def _href(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.startswith("#"): - errors.append(f"{path} must start with #") - - -def _items( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, - string_keys: tuple[str, ...], - href_key: str | None = None, - visual_key: str | None = None, - visuals: set[str] | None = None, -) -> list[dict[str, Any]]: - raw = _list(value, path, errors, minimum, maximum) - result: list[dict[str, Any]] = [] - ids: list[str] = [] - for index, item in enumerate(raw): - current = _mapping(item, f"{path}[{index}]", errors) - _strings(current, f"{path}[{index}]", ("id",) + string_keys, errors) - item_id = current.get("id") - if isinstance(item_id, str): - ids.append(item_id) - if href_key: - _href(current.get(href_key), f"{path}[{index}].{href_key}", errors) - if visual_key and current.get(visual_key) not in (visuals or set()): - errors.append(f"{path}[{index}].{visual_key} must be one of {sorted(visuals or set())}") - result.append(current) - duplicates = sorted({item_id for item_id in ids if ids.count(item_id) > 1}) - if duplicates: - errors.append(f"{path} id duplicates: {', '.join(duplicates)}") - return result - - -def _walk_no_remote(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, dict): - for key, item in value.items(): - if key in MEDIA_URL_KEYS: - continue - _walk_no_remote(item, f"{path}.{key}", errors) - elif isinstance(value, list): - for index, item in enumerate(value): - _walk_no_remote(item, f"{path}[{index}]", errors) - elif isinstance(value, str) and REMOTE_RE.search(value): - errors.append(f"{path} must not contain remote URLs, scripts, or iframes") - - -def _remote_url(value: Any, path: str, errors: list[str], *, required: bool = False, data_prefix: str = "") -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media_tree(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, list): - for index, item in enumerate(value): - _validate_remote_media_tree(item, f"{path}[{index}]", errors) - return - if not isinstance(value, dict): - return - media = value.get("media") - if isinstance(media, dict) and "type" in media: - media_path, media_type = f"{path}.media", media.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - errors.append( - f"{media_path}.type must be one of: " - f"{', '.join(sorted(REMOTE_MEDIA_TYPES))}" - ) - elif media_type in {"image", "video"}: - _remote_url( - media.get("mediaUrl"), - f"{media_path}.mediaUrl", - errors, - required=True, - data_prefix=f"data:{media_type}/", - ) - if media_type == "video": - _remote_url( - media.get("posterUrl"), - f"{media_path}.posterUrl", - errors, - data_prefix="data:image/", - ) - if media_type == "webpage": - _remote_url( - media.get("pageUrl"), - f"{media_path}.pageUrl", - errors, - required=True, - ) - _remote_url( - media.get("mediaUrl"), - f"{media_path}.mediaUrl", - errors, - data_prefix="data:image/", - ) - if "embed" in media and not isinstance(media.get("embed"), bool): - errors.append(f"{media_path}.embed must be a boolean") - elif media_type in {"image", "video"}: - _remote_url(media.get("pageUrl"), f"{media_path}.pageUrl", errors) - for key, item in value.items(): - if key != "media": - _validate_remote_media_tree(item, f"{path}.{key}", errors) - - -def _walk_chinese(value: Any, path: str, errors: list[str], key: str = "") -> None: - if isinstance(value, dict): - for child, item in value.items(): - _walk_chinese(item, f"{path}.{child}", errors, child) - elif isinstance(value, list): - for index, item in enumerate(value): - _walk_chinese(item, f"{path}[{index}]", errors, key) - elif ( - isinstance(value, str) - and key not in TECHNICAL_KEYS - and not key.lower().endswith(("id", "href", "url")) - and (KANA_RE.search(value) or (LATIN_RE.search(value) and not CJK_RE.search(value))) - ): - errors.append(f"{path} must use Simplified Chinese reader-facing copy") - - -def validate_document(data: dict[str, Any]) -> dict[str, int]: - errors: list[str] = [] - missing = [name for name in MODULES if name not in data] - extra = [name for name in data if name not in MODULES] - if missing: - errors.append(f"missing top-level module(s): {', '.join(missing)}") - if extra: - errors.append(f"unknown top-level module(s): {', '.join(extra)}") - - site = _mapping(data.get("site"), "site", errors) - _strings( - site, - "site", - ( - "brandPrimary", "brandAccent", "tagline", "pageTitle", "description", - "language", "theme", "skipLabel", "dateLabel", "loginLabel", - "mailLabel", "mailCount", "menuLabel", "themeLabel", - "themeDarkMessage", "themeLightMessage", - ), - errors, - ) - if site.get("language") != "zh-CN": - errors.append("site.language must be zh-CN") - utility_left = _items(site.get("utilityLeft"), "site.utilityLeft", errors, minimum=1, maximum=8, string_keys=("label",), href_key="href") - utility_right = _items(site.get("utilityRight"), "site.utilityRight", errors, minimum=1, maximum=8, string_keys=("label",), href_key="href") - notice = _mapping(site.get("notice"), "site.notice", errors) - _strings(notice, "site.notice", ("label", "text"), errors) - _href(notice.get("href"), "site.notice.href", errors) - - search = _mapping(data.get("search"), "search", errors) - _strings(search, "search", ("title", "placeholderSuffix", "button", "hotLabel", "emptyMessage", "resultPrefix", "resultKeyword"), errors) - search_tabs = _items(search.get("tabs"), "search.tabs", errors, minimum=2, maximum=10, string_keys=("label",)) - suggestions = _list(search.get("suggestions"), "search.suggestions", errors, 1, 8) - if any(not isinstance(item, str) or not item.strip() for item in suggestions): - errors.append("search.suggestions must contain non-empty strings") - - services = _mapping(data.get("services"), "services", errors) - _strings(services, "services", ("title", "allLabel", "moreLabel", "lessLabel"), errors) - _href(services.get("allHref"), "services.allHref", errors) - service_primary = _items(services.get("primary"), "services.primary", errors, minimum=5, maximum=12, string_keys=("label", "visual"), href_key="href", visual_key="visual", visuals=SERVICE_VISUALS) - service_extra = _items(services.get("extra"), "services.extra", errors, minimum=1, maximum=8, string_keys=("label", "visual"), href_key="href", visual_key="visual", visuals=SERVICE_VISUALS) - - news = _mapping(data.get("news"), "news", errors) - _strings(news, "news", ("title", "updated", "moreLabel", "topicsLabel", "recommendTitle", "refreshLabel", "refreshMessage"), errors) - _href(news.get("moreHref"), "news.moreHref", errors) - _href(news.get("topicsHref"), "news.topicsHref", errors) - news_tabs = _items(news.get("tabs"), "news.tabs", errors, minimum=2, maximum=8, string_keys=("label",)) - news_items = 0 - for index, tab in enumerate(news_tabs): - items = _items(tab.get("items"), f"news.tabs[{index}].items", errors, minimum=1, maximum=8, string_keys=("title", "time"), href_key="href") - news_items += len(items) - for item_index, item in enumerate(items): - if not isinstance(item.get("tag"), str): - errors.append(f"news.tabs[{index}].items[{item_index}].tag must be a string") - if not isinstance(item.get("isNew"), bool): - errors.append(f"news.tabs[{index}].items[{item_index}].isNew must be boolean") - feature = _mapping(news.get("feature"), "news.feature", errors) - _strings(feature, "news.feature", ("kicker", "title", "summary", "visual"), errors) - _href(feature.get("href"), "news.feature.href", errors) - if feature.get("visual") not in FEATURE_VISUALS: - errors.append(f"news.feature.visual must be one of {sorted(FEATURE_VISUALS)}") - recommendations = _items(news.get("recommendations"), "news.recommendations", errors, minimum=1, maximum=6, string_keys=("title", "category", "visual"), visual_key="visual", visuals=RECOMMEND_VISUALS) - - account = _mapping(data.get("account"), "account", errors) - _strings(account, "account", ("title", "text", "loginLabel", "newIdLabel", "profileLabel", "modalTitle", "welcome", "demoNote", "emailLabel", "passwordLabel", "submitLabel", "resultMessage", "closeLabel"), errors) - _href(account.get("newIdHref"), "account.newIdHref", errors) - _href(account.get("profileHref"), "account.profileHref", errors) - statuses = _items(account.get("statuses"), "account.statuses", errors, minimum=1, maximum=4, string_keys=("label", "visual"), href_key="href") - - weather = _mapping(data.get("weather"), "weather", errors) - _strings(weather, "weather", ("title", "regionLabel", "updatedMessage"), errors) - locations = _items(weather.get("locations"), "weather.locations", errors, minimum=2, maximum=6, string_keys=("city",)) - for index, location in enumerate(locations): - for day_name in ("today", "tomorrow"): - day = _mapping(location.get(day_name), f"weather.locations[{index}].{day_name}", errors) - _strings(day, f"weather.locations[{index}].{day_name}", ("label", "high", "low", "summary"), errors) - weather_links = _items(weather.get("links"), "weather.links", errors, minimum=1, maximum=6, string_keys=("label", "message")) - - utilities = _mapping(data.get("utilities"), "utilities", errors) - transit = _mapping(utilities.get("transit"), "utilities.transit", errors) - _strings(transit, "utilities.transit", ("title", "detailLabel", "status", "fromLabel", "toLabel", "buttonLabel", "emptyMessage", "resultPrefix", "resultDetail"), errors) - _href(transit.get("detailHref"), "utilities.transit.detailHref", errors) - finance = _mapping(utilities.get("finance"), "utilities.finance", errors) - _strings(finance, "utilities.finance", ("title", "moreLabel", "updated", "previousPrefix"), errors) - _href(finance.get("moreHref"), "utilities.finance.moreHref", errors) - markets = _items(finance.get("markets"), "utilities.finance.markets", errors, minimum=2, maximum=6, string_keys=("label", "value", "change", "previous")) - - shopping = _mapping(data.get("shopping"), "shopping", errors) - _strings(shopping, "shopping", ("title", "moreLabel", "saveLabel", "removeLabel", "savedMessage", "removedMessage"), errors) - _href(shopping.get("moreHref"), "shopping.moreHref", errors) - products = _items(shopping.get("products"), "shopping.products", errors, minimum=2, maximum=8, string_keys=("title", "price", "visual"), visual_key="visual", visuals=PRODUCT_VISUALS) - - pulse = _mapping(data.get("pulse"), "pulse", errors) - _strings(pulse, "pulse", ("sportsTitle", "sportsMoreLabel", "trendsTitle", "refreshLabel", "refreshMessage"), errors) - _href(pulse.get("sportsMoreHref"), "pulse.sportsMoreHref", errors) - sports_tabs = _items(pulse.get("sportsTabs"), "pulse.sportsTabs", errors, minimum=2, maximum=5, string_keys=("label",)) - sports_games = 0 - for index, tab in enumerate(sports_tabs): - games = _items(tab.get("games"), f"pulse.sportsTabs[{index}].games", errors, minimum=1, maximum=5, string_keys=("home", "homeNote", "score", "away", "awayNote")) - sports_games += len(games) - trend_tabs = _items(pulse.get("trendTabs"), "pulse.trendTabs", errors, minimum=2, maximum=5, string_keys=("label",)) - trend_items = 0 - for index, tab in enumerate(trend_tabs): - items = _items(tab.get("items"), f"pulse.trendTabs[{index}].items", errors, minimum=1, maximum=8, string_keys=("rank", "title", "meta"), href_key="href") - trend_items += len(items) - - footer = _mapping(data.get("footer"), "footer", errors) - _strings(footer, "footer", ("drawerTitle", "drawerText", "copyright", "legal", "backTopLabel"), errors) - drawer_links = _items(footer.get("drawerLinks"), "footer.drawerLinks", errors, minimum=2, maximum=12, string_keys=("label",), href_key="href") - footer_links = _items(footer.get("links"), "footer.links", errors, minimum=2, maximum=12, string_keys=("label",), href_key="href") - - _validate_remote_media_tree(data, "$", errors) - _walk_no_remote(data, "$", errors) - _walk_chinese(data, "$", errors) - if errors: - raise DataValidationError("\n".join(f"- {error}" for error in errors)) - return { - "utility_links": len(utility_left) + len(utility_right), - "search_tabs": len(search_tabs), - "suggestions": len(suggestions), - "services": len(service_primary) + len(service_extra), - "news_tabs": len(news_tabs), - "news_items": news_items, - "recommendations": len(recommendations), - "account_statuses": len(statuses), - "weather_locations": len(locations), - "weather_links": len(weather_links), - "markets": len(markets), - "products": len(products), - "sports_tabs": len(sports_tabs), - "sports_games": sports_games, - "trend_tabs": len(trend_tabs), - "trend_items": trend_items, - "drawer_links": len(drawer_links), - "footer_links": len(footer_links), - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - rendered = template.replace( - "__YAHOO_JAPAN_PAGE_DATA__", - json.dumps(data, ensure_ascii=False, separators=(",", ":")), - ).replace("__YAHOO_JAPAN_PAGE_RUNTIME__", runtime) - if "__YAHOO_JAPAN_PAGE_" in rendered: - raise DataValidationError("template placeholders remain after rendering") - return rendered - - -def _write_new(path: Path, text: str) -> None: - if path.exists(): - raise DataValidationError(f"refusing to overwrite existing path: {path}") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def _init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA) - counts = validate_document(data) - _write_new(output, json.dumps(data, ensure_ascii=False, indent=2) + "\n") - return {"status":"ok","action":"init","output":str(output),"modules":list(MODULES),"counts":counts} - - -def _validate(input_path: Path) -> dict[str, Any]: - counts = validate_document(_read_json(input_path)) - return {"status":"ok","action":"validate","input":str(input_path),"modules":list(MODULES),"counts":counts} - - -def _render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - counts = validate_document(data) - html = render_document(data, TEMPLATE_PATH.read_text(encoding="utf-8"), RUNTIME_PATH.read_text(encoding="utf-8")) - _write_new(output, html) - return {"status":"ok","action":"render","input":str(input_path),"output":str(output),"modules":list(MODULES),"counts":counts} - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Render the bundled Chinese Yahoo! JAPAN-style portal") - subparsers = parser.add_subparsers(dest="action", required=True) - init_parser = subparsers.add_parser("init") - init_parser.add_argument("--output", type=Path, required=True) - validate_parser = subparsers.add_parser("validate") - validate_parser.add_argument("--input", type=Path, required=True) - render_parser = subparsers.add_parser("render") - render_parser.add_argument("--input", type=Path, required=True) - render_parser.add_argument("--output", type=Path, required=True) - return parser - - -def main() -> int: - args = _parser().parse_args() - try: - if args.action == "init": - result = _init(args.output) - elif args.action == "validate": - result = _validate(args.input) - else: - result = _render(args.input, args.output) - except DataValidationError as exc: - print(json.dumps({"status":"error","message":str(exc)}, ensure_ascii=False), file=sys.stderr) - return 2 - print(json.dumps(result, ensure_ascii=False)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/SKILL.md deleted file mode 100644 index 3a5cda86..00000000 --- a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/SKILL.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: yomiuri-news-page-generator -description: 生成或绘制中文读卖新闻风格日本全国大报门户的可交付单文件 HTML;仅在用户明确要求制作读卖新闻、Yomiuri 网页或点名本技能时使用。先把材料或检索结果整理成读者文案全中文的十模块 JSON,再用内置模板确定性渲染,页面保留右上角模块化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# 读卖新闻中文页面生成器 - -本技能使用固定模板生成单文件日本全国大报门户。模型只负责材料收集和 JSON,禁止重新手写页面结构、CSS 或运行时脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块编辑 JSON,并通过“应用并渲染”即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说“生成/画/制作读卖新闻、Yomiuri、Yomiuri Shimbun 网页、日本全国大报门户”或点名 `yomiuri-news-page-generator` 时使用。 -- 仅讨论读卖新闻、总结日本新闻或普通文本中出现 Yomiuri 时不要使用。 -- 这是读卖新闻风格概念门户,不改用雅虎日本或其他新闻模板,不得声称是官方网站。 - -## 极速工作流(默认) - -页面结构已经固定。完成必要检索或材料整理后,模型只写一份小型规格 JSON;未写字段继续使用模板默认数据与示例视觉,脚本在一次进程内合并、校验并渲染。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/yomiuri-news-page-spec.json`。直接写模块对象会递归合并;修改现有数组项优先用 `$set`: - - ```json - {"site":{"pageTitle":"读卖新闻 · 中文专题"},"$set":{"breaking.featured":"中文速报","lead.main.title":"中文头条","lead.main.summary":"中文摘要"}} - ``` - -2. 只执行一次 `python /mnt/skills/public/yomiuri-news-page-generator/scripts/fast_page.py --spec /mnt/user-data/outputs/yomiuri-news-page-spec.json --output-dir /mnt/user-data/outputs`。 -3. stdout 为 `status: ok` 后立即用 `present_files` 交付完整 JSON 与 HTML,最终只回复一句完成说明。 - -规格只允许本技能十个模块和 `$set`;路径必须指向默认 JSON 的已存在字段。品牌罗马字 `YOMIURI ONLINE` 可保留,其余读者可见内容必须是简体中文。未提供媒体直链时保留模板示例视觉。禁止读取默认数据、契约、模板、运行时或脚本源码,禁止 `--help`、独立 `init/validate/render`、生成后打补丁、临时脚本和冗长过程说明。规格报错时只修正一次并重跑。只有用户明确要求改变模块结构或批量重建完整数组时,才使用下面的完整工作流。 - -## 完整工作流(仅限结构改造) - -1. 读取当前会话材料、上传附件和已有产物,确定报头、导航、速报、头条、数字报、专题、分类新闻、社论、视频订阅和页脚内容。 -2. 用户要求当前新闻、实时天气或近期事件时,必须先检索可靠来源再填 JSON;用户明确说不要检索时不得联网。日文或英文材料必须先准确翻译成简体中文。没有材料时使用中文中性演示内容,并在页脚明确说明不是官方网站。 -3. 初始化完整 JSON: - - ```text - python /mnt/skills/public/yomiuri-news-page-generator/scripts/render_yomiuri_news_page.py init --output /mnt/user-data/outputs/yomiuri-news-page-data.json - ``` - -4. 只修改 JSON。十个顶层模块必须全部保留:`site`、`navigation`、`breaking`、`lead`、`edition`、`features`、`news`、`opinion`、`media`、`footer`。JSON 键名、`id`、`href`、`visual` 等技术值保持契约格式;品牌罗马字 `YOMIURI ONLINE` 可以保留,其余读者可见文案必须使用简体中文,`site.language` 固定为 `zh-CN`。字段不确定时读取 [references/data-schema.md](references/data-schema.md)。 -5. 校验 JSON: - - ```text - python /mnt/skills/public/yomiuri-news-page-generator/scripts/render_yomiuri_news_page.py validate --input /mnt/user-data/outputs/yomiuri-news-page-data.json - ``` - -6. 确定性渲染: - - ```text - python /mnt/skills/public/yomiuri-news-page-generator/scripts/render_yomiuri_news_page.py render --input /mnt/user-data/outputs/yomiuri-news-page-data.json --output /mnt/user-data/outputs/yomiuri-news-page.html - ``` - -7. 检查 stdout 的 `status` 为 `ok`、HTML 存在且占位符已经消失,再用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天中粘贴完整 HTML。 - -## 内容映射 - -- 品牌、日期天气、字号和订阅文案放入 `site`;顶栏、主导航、菜单、搜索和登录放入 `navigation`。 -- 速报条放入 `breaking`;主头条、副头条与新着时间线放入 `lead`。 -- 数字报入口和纸面翻页放入 `edition`;注目专题与连载放入 `features`。 -- 分类新闻放入 `news`;社论、专栏、生活教育和文化连载放入 `opinion`。 -- 视频、字幕和邮件订阅放入 `media`;品牌说明、栏目链接与法律文案放入 `footer`。 -- `visual` 继续作为无网络或加载失败时的内置兜底图形。需要替换报道视觉时,可在对应内容对象上增加受校验的 `media`:`image`(图片)、`video`(视频)或 `webpage`(网页卡片/沙箱预览);字段格式见数据契约。所有文本由模板转义,不得直接写 HTML、脚本或 iframe。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/yomiuri-news-template.html` 或 `assets/yomiuri-news-runtime.js`。 -- 初始化与渲染默认拒绝覆盖已有文件;路径存在时换新文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖新闻、账户或订阅接口,不加载 CDN 和外部字体;只有 JSON 中显式配置的 `media` 可以请求受校验的远程图片、视频或网页。 -- 保留传统大报报头、酒红色强调、速报展开、三栏头条、纸面阅读器、专题、分类新闻、社论、视频字幕和订阅交互。 -- 除品牌罗马字 `YOMIURI ONLINE` 外,不得生成日文或英文读者文案;外文来源必须先翻译为自然、准确的简体中文。 -- 浏览器缓存以初始 JSON 指纹隔离;沙箱禁止持久存储时必须继续使用内嵌 JSON 正常渲染。 diff --git a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/default-data.json deleted file mode 100644 index 803e13ff..00000000 --- a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/default-data.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "site": { - "brandPrimary": "读卖新闻", - "brandRoman": "YOMIURI ONLINE", - "pageTitle": "读卖新闻中文门户", - "description": "采用日本全国大报结构设计的中文概念新闻门户,汇集速报、社会、政治、经济、国际、体育、文化和社论。", - "language": "zh-CN", - "skipLabel": "跳转到主要内容", - "dateWeather": "2026年10月30日 星期五 · 东京 21℃ / 16℃ · 雨转阴", - "subscribeLabel": "订阅申请", - "trialLabel": "免费试读", - "trialMessage": "已打开免费试读演示页面。", - "fontLabel": "文字大小", - "fontNormal": "标准", - "fontLarge": "大字", - "fontNormalMessage": "已恢复标准文字大小。", - "fontLargeMessage": "已切换到大字模式。", - "memberLabel": "登录与注册" - }, - "navigation": { - "topLeft": [{"id":"morning","label":"晨报文章","href":"#edition"},{"id":"paper","label":"纸面阅读器","action":"paper"},{"id":"editorial","label":"社论","href":"#opinion"},{"id":"language","label":"中文版本","href":"#footer"}], - "topRight": [{"id":"search","label":"搜索文章","action":"search"},{"id":"help","label":"帮助","href":"#footer"},{"id":"tip","label":"新闻线索","href":"#footer"}], - "primary": [{"id":"home","label":"首页","href":"#main"},{"id":"breaking","label":"速报","href":"#breaking"},{"id":"society","label":"社会","href":"#news"},{"id":"politics","label":"政治","href":"#news"},{"id":"economy","label":"经济","href":"#news"},{"id":"world","label":"国际","href":"#news"},{"id":"sports","label":"体育","href":"#media"},{"id":"life","label":"生活","href":"#opinion"},{"id":"culture","label":"文化","href":"#opinion"},{"id":"comment","label":"观点","href":"#opinion"}], - "menuLabel": "菜单", - "menuCloseLabel": "关闭菜单", - "megaColumns": [{"id":"news","title":"新闻","links":[{"id":"flash","label":"速报","href":"#breaking"},{"id":"social","label":"社会与政治","href":"#news"},{"id":"economic","label":"经济与国际","href":"#news"}]},{"id":"reading","title":"深度阅读","links":[{"id":"opinion","label":"社论与观点","href":"#opinion"},{"id":"series","label":"文化连载","href":"#opinion"},{"id":"video","label":"视频新闻","href":"#media"}]},{"id":"service","title":"会员服务","links":[{"id":"paper","label":"纸面阅读器","action":"paper"},{"id":"trial","label":"订阅试读","action":"trial"},{"id":"login","label":"登录与注册","action":"login"}]}], - "searchTitle": "搜索文章", - "searchPlaceholder": "输入关键词", - "searchHint": "示例:交通、教育、红叶", - "searchEmpty": "请输入搜索关键词。", - "searchPrefix": "找到28篇相关文章,关键词为", - "searchCloseLabel": "关闭搜索", - "loginTitle": "读卖新闻会员中心", - "loginHeading": "登录与会员注册", - "loginNote": "这是概念演示页面,输入内容不会保存或发送。", - "emailLabel": "电子邮箱", - "passwordLabel": "密码", - "loginButton": "登录", - "loginResult": "演示页面不会执行真实登录。", - "loginCloseLabel": "关闭登录窗口" - }, - "breaking": { - "label": "速报", - "featured": "新的广域交通枢纽完成最终测试,将于下月分阶段启用。", - "openLabel": "速报列表", - "closeLabel": "收起速报", - "items": [{"id":"support","time":"14:12","title":"政府公布区域交通新支持方案","href":"#news"},{"id":"autumn","time":"13:58","title":"北陆山区进入红叶最佳观赏期","href":"#news"},{"id":"volleyball","time":"13:41","title":"日本女排晋级亚洲赛事决赛","href":"#media"},{"id":"stocks","time":"13:19","title":"日经平均指数小幅上涨","href":"#news"}] - }, - "lead": { - "main": {"id":"transit-hub","kicker":"社会 · 交通","title":"连接铁路、巴士与渡轮的新枢纽迈出改变生活的一步","summary":"服务首都圈的广域交通中心完成最终测试,换乘时间缩短和区域经济带动效应受到关注。","time":"10月30日 13:52更新","visual":"transit-hub","saveLabel":"保存文章","savedLabel":"取消保存","savedMessage":"文章已保存。","removedMessage":"文章保存已取消。"}, - "sideStories": [{"id":"forest","kicker":"地方","title":"七座山城共同启动森林保护计划","summary":"居民建立兼顾旅游发展和水源保护的新机制。","visual":"forest"},{"id":"learning","kicker":"教育","title":"连接学校与社区的学习设施在全国扩展","summary":"数字教室在课后和周末向社区居民开放。","visual":"learning"}], - "timelineTitle": "最新新闻", - "timelineMoreLabel": "查看更多最新新闻", - "timelineMoreHref": "#news", - "timeline": [{"id":"support","time":"14:12","title":"政府公布区域交通新支持方案","href":"#news"},{"id":"autumn","time":"13:58","title":"北陆红叶预计周末进入最佳观赏期","href":"#news"},{"id":"volleyball","time":"13:41","title":"日本女排晋级决赛","href":"#media"},{"id":"stocks","time":"13:19","title":"日经平均指数小幅上涨","href":"#news"},{"id":"rice","time":"12:56","title":"产地新米进入秋季餐桌","href":"#opinion"}] - }, - "edition": { - "brand": "读卖新闻", - "coverTitle": "交通新时代开启", - "title": "以原版版式阅读今日晨报", - "summary": "可在电脑和手机上阅读晨报、地区版与号外的概念演示。", - "paperLabel": "纸面阅读器", - "trialLabel": "免费试读", - "modalTitle": "今日晨报", - "date": "2026年10月30日 星期五", - "closeLabel": "关闭纸面阅读器", - "previousLabel": "上一版", - "nextLabel": "下一版", - "pages": [{"id":"front","section":"头版","headline":"交通新时代开启","summary":"以原版布局阅读今日重要新闻。"},{"id":"society","section":"社会","headline":"区域防灾信息汇聚在同一平台","summary":"汇总社会、教育、医疗和生活领域的报道。"},{"id":"economy","section":"经济","headline":"地方制造业迈向全球市场","summary":"解读市场、企业与工作方式变化。"},{"id":"sports","section":"体育","headline":"日本女排晋级决赛","summary":"通过图片和数据呈现国内外赛事。"}] - }, - "features": { - "title": "重点关注", - "moreLabel": "查看全部专题", - "moreHref": "#features", - "main": {"id":"station","kicker":"调查 · 交通新时代","title":"车站正在改变,城市空间随之展开","summary":"记者走访开通前现场,观察人流、商业与防灾功能。","visual":"station"}, - "cards": [{"id":"procurement","kicker":"政治","title":"地方数字采购如何提高透明度","summary":"专家解读新法案目标与执行难点。"},{"id":"factory","kicker":"经济","title":"小型工厂通过共同物流走向世界","summary":"地区企业协作跨越出口门槛。"},{"id":"green","kicker":"国际","title":"亚洲城市建设应对高温的绿色走廊","summary":"公园与公共交通连接项目持续扩展。"}], - "seriesTitle": "连载与专题", - "series": [{"id":"witness","title":"时代见证者——与港口共同生活","meta":"第18期","href":"#opinion"},{"id":"climate","title":"阅读地球——气候与城市选择","meta":"观点","href":"#opinion"},{"id":"education","title":"教育新生——让社区成为课堂","meta":"学习","href":"#opinion"},{"id":"memory","title":"后来——被修复的木制车站","meta":"记忆","href":"#opinion"}] - }, - "news": { - "title": "新闻", - "moreLabel": "全部文章", - "moreHref": "#news", - "tabs": [ - {"id":"society","label":"社会","feature":{"kicker":"社会","title":"公共交通枢纽将如何改变地区生活","summary":"从步行空间、商业、防灾功能等角度观察综合城市建设。","visual":"society"},"items":[{"id":"disaster","kicker":"防灾","title":"地方灾害信息实现统一展示","time":"13:36"},{"id":"clinic","kicker":"医疗","title":"社区诊所扩大夜间咨询服务","time":"12:48"},{"id":"library","kicker":"教育","title":"图书馆开设成人继续学习课程","time":"11:59"}]}, - {"id":"politics","label":"政治","feature":{"kicker":"政治","title":"国家与地方启动公共服务联合协商机制","summary":"围绕人口减少和服务维持展开讨论。","visual":"politics"},"items":[{"id":"bill","kicker":"国会","title":"公共采购透明化法案通过委员会审议","time":"13:20"},{"id":"youth","kicker":"地方","title":"青年议会首次提交政策建议","time":"12:31"}]}, - {"id":"economy","label":"经济","feature":{"kicker":"经济","title":"地方制造业出口增长,共同基地提供支持","summary":"共享物流和质量管理的新机制取得成效。","visual":"economy"},"items":[{"id":"market","kicker":"市场","title":"日经平均指数在出口板块带动下继续上涨","time":"13:08"},{"id":"advice","kicker":"企业","title":"商业街设立小微企业咨询窗口","time":"11:42"}]}, - {"id":"world","label":"国际","feature":{"kicker":"国际","title":"亚洲十座沿海城市共同设立气候适应基金","summary":"共享防潮、绿地和避难通道建设方案。","visual":"world"},"items":[{"id":"ocean","kicker":"环境","title":"国际海洋保护会议开幕","time":"12:50"},{"id":"rail","kicker":"交通","title":"城市间铁路签署新的合作协议","time":"11:28"}]}, - {"id":"sports","label":"体育","feature":{"kicker":"体育","title":"日本女排逆转取胜晋级亚洲赛事决赛","summary":"年轻队员积极进攻改变比赛节奏。","visual":"sports"},"items":[{"id":"baseball","kicker":"棒球","title":"日本职业棒球决赛两队主教练出席赛前发布会","time":"13:11"},{"id":"relay","kicker":"田径","title":"接力代表队公开秋季集训","time":"10:56"}]} - ] - }, - "opinion": { - "title": "社论与观点", - "editorial": {"label":"社论","title":"让公共交通投资真正连接地区未来","summary":"应从便利、防灾和地区经济等长期角度评估公共交通投资。","actionLabel":"阅读社论","href":"#opinion"}, - "columns": [{"id":"space","title":"城市留白连接人与人","author":"编辑委员 高桥澪","summary":"车站广场和公园不仅用于通行,也创造偶然相遇。","visual":"portrait-one"},{"id":"university","title":"地区大学正在孕育答案","author":"评论委员 森田奈奈","summary":"贴近农业、医疗和防灾现场的研究正在形成解决方案。","visual":"portrait-two"},{"id":"newspaper","title":"打开报纸的时间","author":"专栏作者 石井辽","summary":"目光停留在相邻文章上,那些绕行也会带来发现。","visual":"portrait-three"}], - "lifeTitle": "生活、教育与医疗", - "life": [{"id":"classroom","kicker":"教育","title":"让整个社区成为周末学习课堂","summary":"图书馆、大学和企业联合提供课程。","visual":"education"},{"id":"telemedicine","kicker":"医疗","title":"远程诊疗为地区诊所提供支持","summary":"通过专科协作减少居民就诊负担。","visual":"health"},{"id":"rice","kicker":"饮食","title":"走访产地了解今年新米风味","summary":"记录农户应对炎热夏季的种植方法。","visual":"food"}], - "cultureTitle": "文化与连载", - "culture": [{"id":"witness","kicker":"时代见证者","title":"与港口共同生活 第18期《清晨汽笛》","summary":"造船小城以海上传来的声音开启新的一天。","actionLabel":"继续阅读","href":"#opinion"},{"id":"advice","kicker":"人生咨询","title":"来到新城市后一直交不到朋友","summary":"从小小问候开始慢慢建立属于自己的位置。","actionLabel":"查看回答","href":"#opinion"},{"id":"notebook","kicker":"编辑手记","title":"秋日车站流动的时间","summary":"季节的风穿过旅客往来的站台。","actionLabel":"阅读专栏","href":"#opinion"}] - }, - "media": { - "title": "视频与视觉报道", - "moreLabel": "视频总览", - "moreHref": "#media", - "videos": [{"id":"hub-video","kicker":"社会 · 08:14","title":"走进新的广域交通枢纽","visual":"transit-video"},{"id":"forest-video","kicker":"地方 · 06:42","title":"守护山林的七座城镇","visual":"forest-video"},{"id":"sports-video","kicker":"体育 · 05:28","title":"日本女排备战决赛的一天","visual":"sports-video"}], - "modalBrand": "读卖影像新闻", - "modalNote": "概念演示视频 · 支持字幕", - "closeLabel": "关闭视频", - "captionsOn": "字幕:开启", - "captionsOff": "字幕:关闭", - "newsletterTitle": "晨间新闻邮件", - "newsletterText": "每天清晨发送重要新闻与阅读提示。", - "emailLabel": "电子邮箱", - "emailPlaceholder": "请输入电子邮箱", - "buttonLabel": "免费订阅", - "successPrefix": "确认邮件已发送至" - }, - "footer": { - "brand": "读卖新闻", - "description": "关注社会变化,提供可靠信息与多元视角的中文概念新闻体验。", - "columns": [{"id":"news","title":"新闻","links":[{"id":"breaking","label":"速报","href":"#breaking"},{"id":"society","label":"社会与政治","href":"#news"},{"id":"economy","label":"经济与国际","href":"#news"}]},{"id":"reading","title":"阅读","links":[{"id":"opinion","label":"社论与观点","href":"#opinion"},{"id":"culture","label":"文化连载","href":"#opinion"},{"id":"video","label":"视频报道","href":"#media"}]},{"id":"guide","title":"服务","links":[{"id":"company","label":"公司信息","href":"#footer"},{"id":"contact","label":"联系我们","href":"#footer"},{"id":"copyright","label":"版权声明","href":"#footer"}]}], - "copyright": "© 2026 读卖新闻风格中文概念门户", - "legal": "本页面仅用于界面演示,不是读卖新闻官方网站,也不提供真实账户、订阅或视频服务。" - } -} diff --git a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/yomiuri-news-runtime.js b/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/yomiuri-news-runtime.js deleted file mode 100644 index ab8dadab..00000000 --- a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/yomiuri-news-runtime.js +++ /dev/null @@ -1,259 +0,0 @@ -(() => { - "use strict"; - const MODULES=["site","navigation","breaking","lead","edition","features","news","opinion","media","footer"]; - const MODULE_META={site:"站点信息",navigation:"页面导航",breaking:"速报新闻",lead:"头条新闻",edition:"数字报纸",features:"重点专题",news:"分类新闻",opinion:"社论生活",media:"视频订阅",footer:"页面页脚"}; - const node=(id)=>document.getElementById(id),clone=(value)=>JSON.parse(JSON.stringify(value)); - const initialText=node("yomiuriNewsPageData").textContent,initialData=JSON.parse(initialText); - const fingerprint=(()=>{let hash=2166136261;for(let i=0;i>>0).toString(36)})(); - const dataKey="yomiuri-news-page-data-v1:"+fingerprint,viewKey="yomiuri-news-page-view-v1:"+fingerprint; - const readStorage=(key)=>{try{return window.localStorage.getItem(key)}catch(_error){return null}},writeStorage=(key,value)=>{try{window.localStorage.setItem(key,value);return true}catch(_error){return false}},removeStorage=(key)=>{try{window.localStorage.removeItem(key)}catch(_error){/* optional */}}; - let data=clone(initialData);try{const saved=readStorage(dataKey);if(saved)data=JSON.parse(saved)}catch(_error){data=clone(initialData)} - let activeCategory="society",paperIndex=0,captionsOn=true,currentModule=MODULES[0],toastTimer=0; - const esc=(value)=>String(value??"").replace(/[&<>'"]/g,(char)=>({"&":"&","<":"<",">":">","'":"'",'"':"""})[char]),safeHref=(value)=>typeof value==="string"&&value.startsWith("#")?value:"#main"; - const safeRemoteUrl=(value,kind="link")=>{const source=String(value||"").trim();if(!source)return"";const lower=source.toLowerCase();if(kind==="image"&&lower.startsWith("data:image/"))return source;if(kind==="video"&&lower.startsWith("data:video/"))return source;try{const url=new URL(source);return["http:","https:"].includes(url.protocol)?url.href:""}catch(_error){return""}}; - const setText=(id,value)=>{const el=node(id);if(el)el.textContent=value??""},setLink=(id,label,href)=>{const el=node(id);if(el){el.textContent=label??"";el.href=safeHref(href)}}; - document.head.insertAdjacentHTML("beforeend",''); - const externalMedia=(media,label)=>{if(!media||typeof media!=="object")return"";if(media.type==="image"){const source=safeRemoteUrl(media.mediaUrl,"image"),page=safeRemoteUrl(media.pageUrl);return source?`${esc(media.alt||label||${page?`打开来源`:""}`:""}if(media.type==="video"){const source=safeRemoteUrl(media.mediaUrl,"video"),poster=safeRemoteUrl(media.posterUrl,"image");return source?``:""}if(media.type==="webpage"){const page=safeRemoteUrl(media.pageUrl),preview=safeRemoteUrl(media.mediaUrl,"image");if(!page)return"";const body=media.embed===true?``:(preview?`${esc(media.alt||media.title||`:"");return`${body}${esc(media.title||label||"打开网页")}${esc(media.description||"新窗口打开")}`}return""}; - const art=(item,className,mediaPath="")=>{const mediaMarkup=externalMedia(item.media,item.title),path=mediaPath?` data-media-path="${esc(mediaPath)}"`:"";return `
${mediaMarkup}
`}; - function toast(message){const el=node("toast");el.textContent=message;el.classList.add("show");clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove("show"),2200)} - function openLayer(id){node(id).classList.add("open");document.body.classList.add("locked")} - function closeLayer(id){node(id).classList.remove("open");if(!document.querySelector(".mega.open,.search-overlay.open,.modal.open,.settings-overlay.open"))document.body.classList.remove("locked")} - const actionLink=(item)=>item.action?``:`${esc(item.label)}`; - function renderSite(){document.documentElement.lang=data.site.language;document.title=data.site.pageTitle;document.querySelector('meta[name="description"]').content=data.site.description;setText("skipLink",data.site.skipLabel);setText("dateWeather",data.site.dateWeather);setText("brandPrimary",data.site.brandPrimary);setText("brandRoman",data.site.brandRoman);setText("subscribeButton",data.site.subscribeLabel);setText("fontSmall",data.site.fontNormal);setText("fontLarge",data.site.fontLarge);setText("memberButton",data.site.memberLabel);setText("megaBrand",`${data.site.brandPrimary} ${data.site.brandRoman}`);document.body.classList.toggle("large",readStorage(viewKey)==="large");node("fontSmall").classList.toggle("active",!document.body.classList.contains("large"));node("fontLarge").classList.toggle("active",document.body.classList.contains("large"))} - function renderNavigation(){node("topLeft").innerHTML=data.navigation.topLeft.map(actionLink).join("");node("topRight").innerHTML=data.navigation.topRight.map(actionLink).join("");node("primaryNav").innerHTML=data.navigation.primary.map((item,index)=>`${esc(item.label)}`).join("")+``;node("mega").querySelector(".close").setAttribute("aria-label",data.navigation.menuCloseLabel);node("megaGrid").innerHTML=data.navigation.megaColumns.map((column)=>``).join("");setText("searchTitle",data.navigation.searchTitle);node("searchInput").placeholder=data.navigation.searchPlaceholder;setText("searchResult",data.navigation.searchHint);node("searchOverlay").querySelector(".close").setAttribute("aria-label",data.navigation.searchCloseLabel);setText("loginTitle",data.navigation.loginTitle);setText("loginHeading",data.navigation.loginHeading);setText("loginNote",data.navigation.loginNote);setText("loginEmailLabel",data.navigation.emailLabel);setText("loginPasswordLabel",data.navigation.passwordLabel);setText("loginButton",data.navigation.loginButton);node("loginModal").querySelector(".close").setAttribute("aria-label",data.navigation.loginCloseLabel)} - function renderBreaking(){setText("breakingLabel",data.breaking.label);setText("breakingFeatured",data.breaking.featured);const open=node("flashPanel").classList.contains("open");setText("flashToggle",open?data.breaking.closeLabel:data.breaking.openLabel);node("flashToggle").setAttribute("aria-expanded",String(open));node("flashList").innerHTML=data.breaking.items.map((item)=>``).join("")} - function renderLead(){const main=data.lead.main;node("leadMain").innerHTML=`${art(main,"lead-art","lead.main")}
${esc(main.kicker)}

${esc(main.title)}

${esc(main.summary)}

${esc(main.time)}
`;node("leadSides").innerHTML=data.lead.sideStories.map((item,index)=>`
${art(item,"side-art",`lead.sideStories.${index}`)}
${esc(item.kicker)}

${esc(item.title)}

${esc(item.summary)}

`).join("");setText("timelineTitle",data.lead.timelineTitle);node("timelineItems").innerHTML=data.lead.timeline.map((item)=>``).join("");setLink("timelineMore",data.lead.timelineMoreLabel,data.lead.timelineMoreHref)} - function renderEdition(){setText("editionBrand",data.edition.brand);setText("editionCover",data.edition.coverTitle);setText("editionTitle",data.edition.title);setText("editionSummary",data.edition.summary);setText("paperButton",data.edition.paperLabel);setText("trialButton",data.edition.trialLabel);setText("paperTitle",data.edition.modalTitle);setText("paperMast",data.edition.brand);setText("paperDate",data.edition.date);node("paperModal").querySelector(".close").setAttribute("aria-label",data.edition.closeLabel);node("paperPrev").setAttribute("aria-label",data.edition.previousLabel);node("paperNext").setAttribute("aria-label",data.edition.nextLabel);renderPaper()} - function renderPaper(){if(!data.edition.pages.length)return;paperIndex=(paperIndex+data.edition.pages.length)%data.edition.pages.length;const page=data.edition.pages[paperIndex];setText("paperHeadline",page.headline);setText("paperSection",page.section);setText("paperText",page.summary);setText("paperColumns",`${page.summary} ${data.edition.summary} ${page.summary}`);setText("paperCount",`${paperIndex+1} / ${data.edition.pages.length} 页`)} - function renderFeatures(){setText("featuresTitle",data.features.title);setLink("featuresMore",data.features.moreLabel,data.features.moreHref);const main=data.features.main;node("featureMain").innerHTML=`${art(main,"topic-art","features.main")}
${esc(main.kicker)}

${esc(main.title)}

${esc(main.summary)}

`;node("featureCards").innerHTML=data.features.cards.map((item)=>`
${esc(item.kicker)}

${esc(item.title)}

${esc(item.summary)}

`).join("");setText("seriesTitle",data.features.seriesTitle);node("seriesList").innerHTML=data.features.series.map((item)=>`
${esc(item.title)}${esc(item.meta)}
`).join("")} - function renderNews(){setText("newsTitle",data.news.title);setLink("newsMore",data.news.moreLabel,data.news.moreHref);if(!data.news.tabs.some((tab)=>tab.id===activeCategory))activeCategory=data.news.tabs[0]?.id;node("categoryTabs").innerHTML=data.news.tabs.map((tab)=>``).join("");node("categoryPanels").innerHTML=data.news.tabs.map((tab,tabIndex)=>`
${art(tab.feature,"category-art",`news.tabs.${tabIndex}.feature`)}
${esc(tab.feature.kicker)}

${esc(tab.feature.title)}

${esc(tab.feature.summary)}

${tab.items.map((item)=>`
${esc(item.kicker)}

${esc(item.title)}

${esc(item.time)}
`).join("")}
`).join("")} - function renderOpinion(){setText("opinionTitle",data.opinion.title);const editorial=data.opinion.editorial;node("editorial").innerHTML=`${esc(editorial.label)}

${esc(editorial.title)}

${esc(editorial.summary)}

${esc(editorial.actionLabel)} →`;node("opinionColumns").innerHTML=data.opinion.columns.map((item,index)=>`
${art(item,"portrait",`opinion.columns.${index}`)}

${esc(item.title)}

${esc(item.author)}

${esc(item.summary)}

`).join("");setText("lifeTitle",data.opinion.lifeTitle);node("lifeList").innerHTML=data.opinion.life.map((item,index)=>`
${art(item,"story-thumb",`opinion.life.${index}`)}
${esc(item.kicker)}

${esc(item.title)}

${esc(item.summary)}

`).join("");setText("cultureTitle",data.opinion.cultureTitle);node("cultureList").innerHTML=data.opinion.culture.map((item)=>``).join("")} - function renderMedia(){setText("mediaTitle",data.media.title);setLink("mediaMore",data.media.moreLabel,data.media.moreHref);node("videoGrid").innerHTML=data.media.videos.map((item,index)=>`
${externalMedia(item.media,item.title)}${item.media?.type==="video"?"":``}
${esc(item.kicker)}

${esc(item.title)}

`).join("");setText("videoBrand",data.media.modalBrand);setText("videoNote",data.media.modalNote);node("videoModal").querySelector(".close").setAttribute("aria-label",data.media.closeLabel);updateCaption();setText("newsletterTitle",data.media.newsletterTitle);setText("newsletterText",data.media.newsletterText);setText("newsletterLabel",data.media.emailLabel);node("email").placeholder=data.media.emailPlaceholder;setText("newsletterButton",data.media.buttonLabel)} - function updateCaption(){setText("captionToggle",captionsOn?data.media.captionsOn:data.media.captionsOff)} - function renderFooter(){setText("footerBrand",data.footer.brand);setText("footerDescription",data.footer.description);node("footerColumns").innerHTML=data.footer.columns.map((column)=>``).join("");setText("copyright",data.footer.copyright);setText("legal",data.footer.legal)} - function renderAll(){renderSite();renderNavigation();renderBreaking();renderLead();renderEdition();renderFeatures();renderNews();renderOpinion();renderMedia();renderFooter()} - - function validateClient(candidate){if(!candidate||typeof candidate!=="object"||Array.isArray(candidate))throw new Error("顶层 JSON 必须是对象。");const keys=Object.keys(candidate),missing=MODULES.filter((name)=>!keys.includes(name)),extra=keys.filter((name)=>!MODULES.includes(name));if(missing.length)throw new Error(`缺少模块:${missing.join("、")}`);if(extra.length)throw new Error(`存在未知模块:${extra.join("、")}`);MODULES.forEach((name)=>{if(!candidate[name]||typeof candidate[name]!=="object"||Array.isArray(candidate[name]))throw new Error(`${name} 必须是对象。`)});const raw=JSON.stringify(candidate).toLowerCase();if(["raw.includes(token)))throw new Error("不允许使用 HTML 或脚本。");const validateMedia=(value,path="当前模块")=>{if(Array.isArray(value))return value.forEach((item,index)=>validateMedia(item,`${path}[${index}]`));if(!value||typeof value!=="object")return;const media=value.media;if(media&&["image","video","webpage"].includes(media.type)){if(["image","video"].includes(media.type)&&!safeRemoteUrl(media.mediaUrl,media.type))throw new Error(`${path}.media.mediaUrl 无效。`);if(media.type==="webpage"&&!safeRemoteUrl(media.pageUrl))throw new Error(`${path}.media.pageUrl 无效。`);if(media.posterUrl&&!safeRemoteUrl(media.posterUrl,"image"))throw new Error(`${path}.media.posterUrl 无效。`)}Object.entries(value).forEach(([key,item])=>{if(key!=="media")validateMedia(item,`${path}.${key}`)})};validateMedia(candidate);if(candidate.site.language!=="zh-CN")throw new Error("site.language 必须是 zh-CN。");const technical=new Set(["id","href","visual","action","language","brandRoman","type","mediaUrl","posterUrl","pageUrl"]);const walk=(value,key="")=>{if(Array.isArray(value))value.forEach((item)=>walk(item,key));else if(value&&typeof value==="object")Object.entries(value).forEach(([child,item])=>walk(item,child));else if(typeof value==="string"&&!technical.has(key)&&!key.toLowerCase().endsWith("id")&&!key.toLowerCase().endsWith("href")&&!key.toLowerCase().endsWith("url")&&(/[\u3040-\u30ff]/.test(value)||(/[A-Za-z]/.test(value)&&!/[\u3400-\u9fff]/.test(value))))throw new Error("除品牌 YOMIURI ONLINE 外,所有读者可见文案必须使用简体中文。")};walk(candidate);return true} - function renderSettingsTabs(){node("moduleTabs").innerHTML=MODULES.map((name)=>``).join("");showModule(currentModule)}function showModule(name){currentModule=name;node("moduleTabs").querySelectorAll("button").forEach((button)=>button.classList.toggle("active",button.dataset.module===name));setText("editorModule",MODULE_META[name]);node("jsonEditor").value=JSON.stringify(data[name],null,2);setText("jsonValidity","JSON 有效")}function parseEditor(){try{const value=JSON.parse(node("jsonEditor").value);if(!value||typeof value!=="object"||Array.isArray(value))throw new Error("当前模块必须是对象。");setText("jsonValidity","JSON 有效");return value}catch(error){setText("jsonValidity","JSON 无效");throw error}}const setStatus=(message)=>setText("settingsStatus",message);function applyEditor(){try{const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);data=candidate;writeStorage(dataKey,JSON.stringify(data));renderAll();renderSettingsTabs();setStatus("JSON 已应用并完成渲染。")}catch(error){setStatus(error.message);setText("jsonValidity","JSON 无效")}}async function copyJson(){const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);const text=JSON.stringify(candidate,null,2);try{await navigator.clipboard.writeText(text);setStatus("完整 JSON 已复制。")}catch(_error){node("jsonEditor").select();document.execCommand("copy");setStatus("已选中当前模块,可手动复制。")}}function downloadJson(){const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);const blob=new Blob([JSON.stringify(candidate,null,2)+"\n"],{type:"application/json"}),url=URL.createObjectURL(blob),anchor=document.createElement("a");anchor.href=url;anchor.download="yomiuri-news-page-data.json";anchor.click();URL.revokeObjectURL(url);setStatus("JSON 下载文件已生成。")} - node("flashToggle").addEventListener("click",()=>{node("flashPanel").classList.toggle("open");renderBreaking()});node("searchForm").addEventListener("submit",(event)=>{event.preventDefault();const query=node("searchInput").value.trim();setText("searchResult",query?`${data.navigation.searchPrefix}“${query}”。`:data.navigation.searchEmpty)});node("loginForm").addEventListener("submit",(event)=>{event.preventDefault();setText("loginStatus",data.navigation.loginResult)});node("fontSmall").addEventListener("click",()=>{document.body.classList.remove("large");node("fontSmall").classList.add("active");node("fontLarge").classList.remove("active");writeStorage(viewKey,"normal");toast(data.site.fontNormalMessage)});node("fontLarge").addEventListener("click",()=>{document.body.classList.add("large");node("fontLarge").classList.add("active");node("fontSmall").classList.remove("active");writeStorage(viewKey,"large");toast(data.site.fontLargeMessage)});node("paperNext").addEventListener("click",()=>{paperIndex+=1;renderPaper()});node("paperPrev").addEventListener("click",()=>{paperIndex-=1;renderPaper()});node("captionToggle").addEventListener("click",()=>{captionsOn=!captionsOn;updateCaption();toast(captionsOn?data.media.captionsOn:data.media.captionsOff)});node("subForm").addEventListener("submit",(event)=>{event.preventDefault();const email=node("email").value.trim();setText("subStatus",`${data.media.successPrefix} ${email}。`);event.currentTarget.reset()}); - document.addEventListener("click",(event)=>{const close=event.target.closest("[data-close]");if(close){closeLayer(close.dataset.close);return}const action=event.target.closest("[data-action]");if(action){const value=action.dataset.action;if(value==="paper")openLayer("paperModal");if(value==="trial")toast(data.site.trialMessage);if(value==="login")openLayer("loginModal");if(value==="search")openLayer("searchOverlay");return}if(event.target.closest("#menuOpen")){openLayer("mega");return}if(event.target.closest("#mobileSearch")){openLayer("searchOverlay");return}if(event.target.closest("[data-login]")){openLayer("loginModal");return}if(event.target.closest("[data-paper]")){renderPaper();openLayer("paperModal");return}if(event.target.closest("[data-trial]")){toast(data.site.trialMessage);return}const save=event.target.closest("[data-save]");if(save){const on=save.classList.toggle("saved");save.textContent=on?"★":"☆";save.setAttribute("aria-label",on?data.lead.main.savedLabel:data.lead.main.saveLabel);toast(on?data.lead.main.savedMessage:data.lead.main.removedMessage);return}const category=event.target.closest("[data-category]");if(category){activeCategory=category.dataset.category;renderNews();return}const video=event.target.closest("[data-video]");if(video){const item=data.media.videos.find((value)=>value.id===video.dataset.video);setText("videoTitle",item?.title||data.media.title);captionsOn=true;updateCaption();openLayer("videoModal");return}const module=event.target.closest("[data-module]");if(module&&module.closest("#moduleTabs"))showModule(module.dataset.module)}); - ["mega","searchOverlay","loginModal","paperModal","videoModal","settingsOverlay"].forEach((id)=>node(id).addEventListener("click",(event)=>{if(event.target===node(id))closeLayer(id)}));document.addEventListener("keydown",(event)=>{if(event.key==="Escape"){["mega","searchOverlay","loginModal","paperModal","videoModal","settingsOverlay"].forEach((id)=>{if(node(id).classList.contains("open"))closeLayer(id)});node("flashPanel").classList.remove("open")}});window.addEventListener("scroll",()=>document.body.classList.toggle("sticky",scrollY>260),{passive:true}); - node("openSettings").addEventListener("click",()=>{renderSettingsTabs();setStatus("");openLayer("settingsOverlay")});node("validateJson").addEventListener("click",()=>{try{const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);setStatus("完整 JSON 校验通过。")}catch(error){setStatus(error.message)}});node("copyJson").addEventListener("click",()=>copyJson().catch((error)=>setStatus(error.message)));node("downloadJson").addEventListener("click",()=>{try{downloadJson()}catch(error){setStatus(error.message)}});node("resetJson").addEventListener("click",()=>{data=clone(initialData);removeStorage(dataKey);removeStorage(viewKey);activeCategory="society";paperIndex=0;captionsOn=true;currentModule=MODULES[0];renderAll();renderSettingsTabs();setStatus("已恢复内嵌初始 JSON。");});node("applyJson").addEventListener("click",applyEditor); - validateClient(data);renderAll(); - function installJsonMediaUpload(){ - const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return; - const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson");button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input); - const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message}; - const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;indexindex+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null}; - button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()}); - input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)}); - } - installJsonMediaUpload(); - function installInlineMediaReplace() { - if (document.getElementById("dfInlineMediaInput")) return; - const visualHostSelector = "[data-media-path],.post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt"; - const getRootData = () => { - if (typeof pageData !== "undefined") return pageData; - if (typeof data !== "undefined") return data; - return null; - }; - const input = document.createElement("input"); - input.id = "dfInlineMediaInput"; - input.type = "file"; - input.hidden = true; - document.body.appendChild(input); - const style = document.createElement("style"); - style.dataset.dfInlineMediaReplace = "true"; - style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}"; - document.head.appendChild(style); - const notify = (message) => { - if (typeof showToast === "function") { showToast(message); return; } - if (typeof toast === "function") { toast(message); return; } - let notice = document.getElementById("dfInlineMediaToast"); - if (!notice) { - notice = document.createElement("div"); - notice.id = "dfInlineMediaToast"; - notice.className = "df-inline-media-toast"; - document.body.appendChild(notice); - } - notice.textContent = message; - window.clearTimeout(notice._timer); - notice._timer = window.setTimeout(() => notice.remove(), 2600); - }; - const sameSource = (value, source, absolute) => { - if (typeof value !== "string") return false; - if (value === source || value === absolute) return true; - try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; } - }; - const findMatches = (source, absolute) => { - const matches = []; - const stack = []; - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - stack.push(value); - Object.entries(value).forEach(([key, item]) => { - if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) { - const ancestors = stack.slice(0, -1).reverse(); - const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value; - matches.push({ owner, previous: value, key }); - } - if (item && typeof item === "object") visit(item); - }); - stack.pop(); - }; - visit(getRootData()); - return matches; - }; - const visualOwners = () => { - const owners = []; - const seen = new Set(); - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object"); - const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim()); - if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); } - Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); }); - }; - visit(getRootData()); - return owners; - }; - const domIdentifiers = (element) => { - const values = new Set(); - let cursor = element; - for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) { - Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); }); - } - return values; - }; - const exactPathTarget = (element) => { - const host = element.closest("[data-media-path]"); - const path = host && host.dataset ? host.dataset.mediaPath : ""; - if (!path) return null; - const owner = path.split(".").reduce((value, segment) => { - if (Array.isArray(value)) return value[Number(segment)]; - return value && typeof value === "object" ? value[segment] : null; - }, getRootData()); - if (!owner || typeof owner !== "object" || Array.isArray(owner)) return null; - return { owner, previous: owner.media && typeof owner.media === "object" ? owner.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseVisualTarget = (element) => { - const exact = exactPathTarget(element); - if (exact) return exact; - const identifiers = domIdentifiers(element); - const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element; - const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim(); - const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]"); - const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : ""; - let best = null; - let bestScore = 0; - visualOwners().forEach((owner) => { - let score = 0; - Object.entries(owner).forEach(([key, value]) => { - if (/id$/i.test(key) && identifiers.has(String(value))) score += 100; - }); - ["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => { - const value = typeof owner[key] === "string" ? owner[key].trim() : ""; - if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length); - }); - if (visualValue && owner.visual === visualValue) score += 20; - if (score > bestScore) { best = owner; bestScore = score; } - }); - if (!best || bestScore <= 0) return null; - return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseTarget = (element) => { - const exact = exactPathTarget(element); - if (exact) return exact; - const source = element.getAttribute("src") || ""; - const absolute = element.currentSrc || element.src || source; - const matches = findMatches(source, absolute); - if (!matches.length) return null; - const identifiers = domIdentifiers(element); - return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0]; - }; - const persist = () => { - const root = getRootData(); - if (typeof saveData === "function") return saveData(); - if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root)); - if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root)); - return false; - }; - let active = null; - const attachButton = (host, target) => { - if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return; - host.classList.add("df-inline-media-host"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "df-inline-media-button"; - button.setAttribute("aria-label", "替换图片或视频"); - button.title = "上传图片或视频并替换"; - button.innerHTML = ''; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - active = { ...target }; - input.accept = "image/*,video/*"; - input.value = ""; - input.click(); - }); - host.appendChild(button); - }; - const decorateBuiltInVisuals = () => { - document.querySelectorAll(visualHostSelector).forEach((host) => { - if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return; - if (!host.querySelector("svg") && !host.dataset.visual) return; - const target = chooseVisualTarget(host); - if (!target) return; - attachButton(host, target); - }); - }; - const decorate = () => { - decorateBuiltInVisuals(); - document.querySelectorAll("img[src],video[src]").forEach((media) => { - if (media.dataset.dfInlineMediaReady === "true") return; - const target = chooseTarget(media); - if (!target) return; - const host = media.closest(visualHostSelector) || media.parentElement; - if (!host) return; - media.dataset.dfInlineMediaReady = "true"; - attachButton(host, target); - }); - }; - input.addEventListener("change", () => { - const file = input.files && input.files[0]; - if (!file || !active) return; - const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : ""; - if (!uploadedType) { - notify("请选择图片或视频文件。"); - return; - } - const reader = new FileReader(); - reader.onerror = () => notify("文件读取失败,请重新选择。"); - reader.onload = () => { - const next = { ...(active.previous || {}) }; - const result = String(reader.result); - next.type = uploadedType; - next.mediaUrl = result; - delete next.posterUrl; - if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片"; - active.owner.media = next; - const stored = persist(); - renderAll(); - notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。"); - active = null; - }; - reader.readAsDataURL(file); - }); - let queued = false; - const queueDecorate = () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; decorate(); }); - }; - new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true }); - decorate(); - } - installInlineMediaReplace(); -})(); diff --git a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/yomiuri-news-template.html b/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/yomiuri-news-template.html deleted file mode 100644 index e802f130..00000000 --- a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/assets/yomiuri-news-template.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - 读卖新闻中文门户 - - - - -
-
读
- -
-
-
-

-

-

-

-

- -
-

-
-
- - - -
-
读卖新闻页面模板控制台

JSON 编辑器

JSON 有效
编辑当前模块并完成校验后,即可应用到页面。缓存按照当前文件的初始 JSON 指纹隔离。
- - diff --git a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/references/data-schema.md deleted file mode 100644 index b3f95657..00000000 --- a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/references/data-schema.md +++ /dev/null @@ -1,52 +0,0 @@ -# 读卖新闻中文页面 JSON 契约 - -顶层只允许以下十个对象: - -`site`、`navigation`、`breaking`、`lead`、`edition`、`features`、`news`、`opinion`、`media`、`footer`。 - -## 中文内容规则 - -- `site.language` 固定为 `zh-CN`。 -- 品牌罗马字 `YOMIURI ONLINE` 可以保留;其余报头、栏目、标题、摘要、作者、按钮、日期、天气、纸面、视频和页脚文案必须使用简体中文。 -- JSON 键名及 `id`、`href`、`visual` 等技术值保持英文;数字、时间、温度、比分和页码可以保持数据格式。 - -## 模块说明 - -- `site`:品牌、日期天气、页面信息、字号和订阅文案。 -- `navigation`:顶栏、主分类、全屏菜单、搜索和登录弹窗。 -- `breaking`:速报主消息及 1–6 条展开新闻。 -- `lead`:主头条、1–4 条副头条和 3–8 条新着新闻。 -- `edition`:数字报入口和 2–8 个纸面页面。 -- `features`:主专题、2–6 条专题卡片和 2–8 条连载。 -- `news`:2–6 个分类,每类包含一个主报道和 1–6 条列表新闻。 -- `opinion`:社论、专栏、生活教育和文化连载。 -- `media`:1–6 个视频与订阅表单。 -- `footer`:品牌说明、2–6 个链接列、版权和法律文案。 - -## 通用限制 - -- 文本必须是纯文本,不得包含 HTML 或 `javascript:`。远程地址只能放在受校验的 `media` URL 字段中。 -- 所有 `href` 必须以 `#` 开头;同一业务数组中的 `id` 必须唯一。 -- 页面不加载远程字体、脚本或接口;没有 `media` 时视觉由模板内置 CSS 图形生成。 -- JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。 - -## 可替换媒体 - -凡是带 `visual` 的内容对象,都可以额外增加一个可选 `media` 对象。省略时继续渲染内置 `visual`。 - -```json -{ - "visual": "transit-hub", - "media": { - "type": "image", - "mediaUrl": "https://example.com/news.jpg", - "alt": "报道现场", - "pageUrl": "https://example.com/story" - } -} -``` - -- 图片:`{"type":"image","mediaUrl":"https://...","alt":"中文说明","pageUrl":"https://..."}`。`pageUrl` 可选。 -- 视频:`{"type":"video","mediaUrl":"https://.../clip.mp4","posterUrl":"https://.../poster.jpg","alt":"中文说明"}`。`posterUrl` 可选。 -- 网页:`{"type":"webpage","pageUrl":"https://...","title":"中文标题","description":"中文说明","mediaUrl":"https://.../preview.jpg","embed":false}`。`mediaUrl` 是可选预览图;`embed: true` 会尝试沙箱 iframe,目标网站若禁止嵌入则仍可点击新窗口打开。 -- 远程地址只允许 `http://` 或 `https://`;图片和视频也允许对应类型的 `data:` 地址。运行时再次校验协议并转义文本,外链使用安全的新窗口属性。 diff --git a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/scripts/fast_page.py deleted file mode 100644 index cac99213..00000000 --- a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "_shared")) - -import render_yomiuri_news_page as renderer -from fast_patch_page import run - -if __name__ == "__main__": - sys.exit(run(renderer, output_stem="yomiuri-news-page")) diff --git a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/scripts/render_yomiuri_news_page.py b/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/scripts/render_yomiuri_news_page.py deleted file mode 100644 index 9f0fa407..00000000 --- a/offline-backend-20260512/backend/skills/public/yomiuri-news-page-generator/scripts/render_yomiuri_news_page.py +++ /dev/null @@ -1,444 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_ROOT = Path(__file__).resolve().parents[1] -DEFAULT_DATA = SKILL_ROOT / "assets" / "default-data.json" -TEMPLATE_PATH = SKILL_ROOT / "assets" / "yomiuri-news-template.html" -RUNTIME_PATH = SKILL_ROOT / "assets" / "yomiuri-news-runtime.js" -MODULES = ( - "site", - "navigation", - "breaking", - "lead", - "edition", - "features", - "news", - "opinion", - "media", - "footer", -) -LEAD_VISUALS = {"transit-hub", "forest", "learning"} -FEATURE_VISUALS = {"station"} -NEWS_VISUALS = {"society", "politics", "economy", "world", "sports"} -COLUMN_VISUALS = {"portrait-one", "portrait-two", "portrait-three"} -LIFE_VISUALS = {"education", "health", "food"} -VIDEO_VISUALS = {"transit-video", "forest-video", "sports-video"} -TECHNICAL_KEYS = { - "id", - "href", - "visual", - "action", - "language", - "brandRoman", - "type", - "mediaUrl", - "posterUrl", - "pageUrl", -} -REMOTE_MEDIA_TYPES = {"image", "video", "webpage"} -MEDIA_URL_KEYS = {"mediaUrl", "posterUrl", "pageUrl"} -LATIN_RE = re.compile(r"[A-Za-z]") -KANA_RE = re.compile(r"[\u3040-\u30ff]") -CJK_RE = re.compile(r"[\u3400-\u9fff]") -REMOTE_RE = re.compile(r"(?:https?://|javascript:|<\s*(?:script|iframe)\b)", re.I) - - -class DataValidationError(ValueError): - pass - - -def _read_json(path: Path) -> dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise DataValidationError(f"unable to read valid JSON from {path}: {exc}") from exc - if not isinstance(value, dict): - raise DataValidationError("top-level JSON must be an object") - return value - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array(value: Any, path: str, errors: list[str], minimum: int, maximum: int) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if not minimum <= len(value) <= maximum: - errors.append(f"{path} must contain {minimum}-{maximum} items") - return value - - -def _strings(obj: dict[str, Any], path: str, keys: tuple[str, ...], errors: list[str]) -> None: - for key in keys: - if not isinstance(obj.get(key), str) or not obj[key].strip(): - errors.append(f"{path}.{key} must be a non-empty string") - - -def _href(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.startswith("#"): - errors.append(f"{path} must start with #") - - -def _items( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, - string_keys: tuple[str, ...], - href: bool = False, - visual_key: str | None = None, - visuals: set[str] | None = None, -) -> list[dict[str, Any]]: - result: list[dict[str, Any]] = [] - ids: list[str] = [] - for index, item in enumerate(_array(value, path, errors, minimum, maximum)): - current = _mapping(item, f"{path}[{index}]", errors) - _strings(current, f"{path}[{index}]", ("id",) + string_keys, errors) - if isinstance(current.get("id"), str): - ids.append(current["id"]) - if href: - _href(current.get("href"), f"{path}[{index}].href", errors) - if visual_key and current.get(visual_key) not in (visuals or set()): - errors.append(f"{path}[{index}].{visual_key} must be one of {sorted(visuals or set())}") - result.append(current) - duplicates = sorted({item_id for item_id in ids if ids.count(item_id) > 1}) - if duplicates: - errors.append(f"{path} id duplicates: {', '.join(duplicates)}") - return result - - -def _links_or_actions(value: Any, path: str, errors: list[str], minimum: int, maximum: int) -> list[dict[str, Any]]: - items = _items(value, path, errors, minimum=minimum, maximum=maximum, string_keys=("label",)) - for index, item in enumerate(items): - has_href = isinstance(item.get("href"), str) - has_action = isinstance(item.get("action"), str) and item.get("action") in {"paper", "trial", "login", "search"} - if has_href: - _href(item.get("href"), f"{path}[{index}].href", errors) - if has_href == has_action: - errors.append(f"{path}[{index}] must define exactly one valid href or action") - return items - - -def _walk_no_remote(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, dict): - for key, item in value.items(): - if key in MEDIA_URL_KEYS: - continue - _walk_no_remote(item, f"{path}.{key}", errors) - elif isinstance(value, list): - for index, item in enumerate(value): - _walk_no_remote(item, f"{path}[{index}]", errors) - elif isinstance(value, str) and REMOTE_RE.search(value): - errors.append(f"{path} must not contain remote URLs, scripts, or iframes") - - -def _remote_url( - value: Any, - path: str, - errors: list[str], - *, - required: bool = False, - data_prefix: str = "", -) -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media_tree(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, list): - for index, item in enumerate(value): - _validate_remote_media_tree(item, f"{path}[{index}]", errors) - return - if not isinstance(value, dict): - return - media = value.get("media") - if isinstance(media, dict) and "type" in media: - media_path = f"{path}.media" - media_type = media.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - errors.append( - f"{media_path}.type must be one of: {', '.join(sorted(REMOTE_MEDIA_TYPES))}" - ) - elif media_type in {"image", "video"}: - _remote_url( - media.get("mediaUrl"), - f"{media_path}.mediaUrl", - errors, - required=True, - data_prefix=f"data:{media_type}/", - ) - if media_type == "video": - _remote_url( - media.get("posterUrl"), - f"{media_path}.posterUrl", - errors, - data_prefix="data:image/", - ) - if media_type == "webpage": - _remote_url(media.get("pageUrl"), f"{media_path}.pageUrl", errors, required=True) - _remote_url( - media.get("mediaUrl"), - f"{media_path}.mediaUrl", - errors, - data_prefix="data:image/", - ) - if "embed" in media and not isinstance(media.get("embed"), bool): - errors.append(f"{media_path}.embed must be a boolean") - elif media_type in {"image", "video"}: - _remote_url(media.get("pageUrl"), f"{media_path}.pageUrl", errors) - for key, item in value.items(): - if key != "media": - _validate_remote_media_tree(item, f"{path}.{key}", errors) - - -def _walk_chinese(value: Any, path: str, errors: list[str], key: str = "") -> None: - if isinstance(value, dict): - for child, item in value.items(): - _walk_chinese(item, f"{path}.{child}", errors, child) - elif isinstance(value, list): - for index, item in enumerate(value): - _walk_chinese(item, f"{path}[{index}]", errors, key) - elif isinstance(value, str) and key not in TECHNICAL_KEYS and not key.lower().endswith(("id", "href", "url")) and (KANA_RE.search(value) or (LATIN_RE.search(value) and not CJK_RE.search(value))): - errors.append(f"{path} must use Simplified Chinese reader-facing copy") - - -def validate_document(data: dict[str, Any]) -> dict[str, int]: - errors: list[str] = [] - missing = [name for name in MODULES if name not in data] - extra = [name for name in data if name not in MODULES] - if missing: - errors.append(f"missing top-level module(s): {', '.join(missing)}") - if extra: - errors.append(f"unknown top-level module(s): {', '.join(extra)}") - - site = _mapping(data.get("site"), "site", errors) - _strings( - site, - "site", - ( - "brandPrimary", - "brandRoman", - "pageTitle", - "description", - "language", - "skipLabel", - "dateWeather", - "subscribeLabel", - "trialLabel", - "trialMessage", - "fontLabel", - "fontNormal", - "fontLarge", - "fontNormalMessage", - "fontLargeMessage", - "memberLabel", - ), - errors, - ) - if site.get("language") != "zh-CN": - errors.append("site.language must be zh-CN") - - navigation = _mapping(data.get("navigation"), "navigation", errors) - top_left = _links_or_actions(navigation.get("topLeft"), "navigation.topLeft", errors, 1, 8) - top_right = _links_or_actions(navigation.get("topRight"), "navigation.topRight", errors, 1, 8) - primary = _items(navigation.get("primary"), "navigation.primary", errors, minimum=5, maximum=14, string_keys=("label",), href=True) - _strings( - navigation, - "navigation", - ( - "menuLabel", - "menuCloseLabel", - "searchTitle", - "searchPlaceholder", - "searchHint", - "searchEmpty", - "searchPrefix", - "searchCloseLabel", - "loginTitle", - "loginHeading", - "loginNote", - "emailLabel", - "passwordLabel", - "loginButton", - "loginResult", - "loginCloseLabel", - ), - errors, - ) - mega_columns = _items(navigation.get("megaColumns"), "navigation.megaColumns", errors, minimum=2, maximum=5, string_keys=("title",)) - mega_links = 0 - for index, column in enumerate(mega_columns): - mega_links += len(_links_or_actions(column.get("links"), f"navigation.megaColumns[{index}].links", errors, 1, 8)) - - breaking = _mapping(data.get("breaking"), "breaking", errors) - _strings(breaking, "breaking", ("label", "featured", "openLabel", "closeLabel"), errors) - breaking_items = _items(breaking.get("items"), "breaking.items", errors, minimum=1, maximum=6, string_keys=("time", "title"), href=True) - - lead = _mapping(data.get("lead"), "lead", errors) - main = _mapping(lead.get("main"), "lead.main", errors) - _strings(main, "lead.main", ("id", "kicker", "title", "summary", "time", "visual", "saveLabel", "savedLabel", "savedMessage", "removedMessage"), errors) - if main.get("visual") not in LEAD_VISUALS: - errors.append(f"lead.main.visual must be one of {sorted(LEAD_VISUALS)}") - side_stories = _items(lead.get("sideStories"), "lead.sideStories", errors, minimum=1, maximum=4, string_keys=("kicker", "title", "summary", "visual"), visual_key="visual", visuals=LEAD_VISUALS) - _strings(lead, "lead", ("timelineTitle", "timelineMoreLabel"), errors) - _href(lead.get("timelineMoreHref"), "lead.timelineMoreHref", errors) - timeline = _items(lead.get("timeline"), "lead.timeline", errors, minimum=3, maximum=8, string_keys=("time", "title"), href=True) - - edition = _mapping(data.get("edition"), "edition", errors) - _strings(edition, "edition", ("brand", "coverTitle", "title", "summary", "paperLabel", "trialLabel", "modalTitle", "date", "closeLabel", "previousLabel", "nextLabel"), errors) - pages = _items(edition.get("pages"), "edition.pages", errors, minimum=2, maximum=8, string_keys=("section", "headline", "summary")) - - features = _mapping(data.get("features"), "features", errors) - _strings(features, "features", ("title", "moreLabel", "seriesTitle"), errors) - _href(features.get("moreHref"), "features.moreHref", errors) - feature_main = _mapping(features.get("main"), "features.main", errors) - _strings(feature_main, "features.main", ("id", "kicker", "title", "summary", "visual"), errors) - if feature_main.get("visual") not in FEATURE_VISUALS: - errors.append(f"features.main.visual must be one of {sorted(FEATURE_VISUALS)}") - feature_cards = _items(features.get("cards"), "features.cards", errors, minimum=2, maximum=6, string_keys=("kicker", "title", "summary")) - series = _items(features.get("series"), "features.series", errors, minimum=2, maximum=8, string_keys=("title", "meta"), href=True) - - news = _mapping(data.get("news"), "news", errors) - _strings(news, "news", ("title", "moreLabel"), errors) - _href(news.get("moreHref"), "news.moreHref", errors) - news_tabs = _items(news.get("tabs"), "news.tabs", errors, minimum=2, maximum=6, string_keys=("label",)) - news_items = 0 - for index, tab in enumerate(news_tabs): - feature = _mapping(tab.get("feature"), f"news.tabs[{index}].feature", errors) - _strings(feature, f"news.tabs[{index}].feature", ("kicker", "title", "summary", "visual"), errors) - if feature.get("visual") not in NEWS_VISUALS: - errors.append(f"news.tabs[{index}].feature.visual must be one of {sorted(NEWS_VISUALS)}") - news_items += len(_items(tab.get("items"), f"news.tabs[{index}].items", errors, minimum=1, maximum=6, string_keys=("kicker", "title", "time"))) - - opinion = _mapping(data.get("opinion"), "opinion", errors) - _strings(opinion, "opinion", ("title", "lifeTitle", "cultureTitle"), errors) - editorial = _mapping(opinion.get("editorial"), "opinion.editorial", errors) - _strings(editorial, "opinion.editorial", ("label", "title", "summary", "actionLabel"), errors) - _href(editorial.get("href"), "opinion.editorial.href", errors) - columns = _items(opinion.get("columns"), "opinion.columns", errors, minimum=2, maximum=5, string_keys=("title", "author", "summary", "visual"), visual_key="visual", visuals=COLUMN_VISUALS) - life = _items(opinion.get("life"), "opinion.life", errors, minimum=2, maximum=6, string_keys=("kicker", "title", "summary", "visual"), visual_key="visual", visuals=LIFE_VISUALS) - culture = _items(opinion.get("culture"), "opinion.culture", errors, minimum=2, maximum=6, string_keys=("kicker", "title", "summary", "actionLabel"), href=True) - - media = _mapping(data.get("media"), "media", errors) - _strings(media, "media", ("title", "moreLabel", "modalBrand", "modalNote", "closeLabel", "captionsOn", "captionsOff", "newsletterTitle", "newsletterText", "emailLabel", "emailPlaceholder", "buttonLabel", "successPrefix"), errors) - _href(media.get("moreHref"), "media.moreHref", errors) - videos = _items(media.get("videos"), "media.videos", errors, minimum=1, maximum=6, string_keys=("kicker", "title", "visual"), visual_key="visual", visuals=VIDEO_VISUALS) - - footer = _mapping(data.get("footer"), "footer", errors) - _strings(footer, "footer", ("brand", "description", "copyright", "legal"), errors) - footer_columns = _items(footer.get("columns"), "footer.columns", errors, minimum=2, maximum=6, string_keys=("title",)) - footer_links = 0 - for index, column in enumerate(footer_columns): - footer_links += len(_items(column.get("links"), f"footer.columns[{index}].links", errors, minimum=1, maximum=8, string_keys=("label",), href=True)) - - _validate_remote_media_tree(data, "$", errors) - _walk_no_remote(data, "$", errors) - _walk_chinese(data, "$", errors) - if errors: - raise DataValidationError("\n".join(f"- {error}" for error in errors)) - return { - "top_links": len(top_left) + len(top_right), - "primary_links": len(primary), - "mega_links": mega_links, - "breaking_items": len(breaking_items), - "lead_stories": 1 + len(side_stories), - "timeline_items": len(timeline), - "edition_pages": len(pages), - "feature_cards": len(feature_cards), - "series_items": len(series), - "news_tabs": len(news_tabs), - "news_items": news_items, - "opinion_columns": len(columns), - "life_items": len(life), - "culture_items": len(culture), - "videos": len(videos), - "footer_columns": len(footer_columns), - "footer_links": footer_links, - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - rendered = template.replace("__YOMIURI_NEWS_PAGE_DATA__", json.dumps(data, ensure_ascii=False, separators=(",", ":"))).replace("__YOMIURI_NEWS_PAGE_RUNTIME__", runtime) - if "__YOMIURI_NEWS_PAGE_" in rendered: - raise DataValidationError("template placeholders remain after rendering") - return rendered - - -def _write_new(path: Path, text: str) -> None: - if path.exists(): - raise DataValidationError(f"refusing to overwrite existing path: {path}") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def _init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA) - counts = validate_document(data) - _write_new(output, json.dumps(data, ensure_ascii=False, indent=2) + "\n") - return {"status": "ok", "action": "init", "output": str(output), "modules": list(MODULES), "counts": counts} - - -def _validate(input_path: Path) -> dict[str, Any]: - counts = validate_document(_read_json(input_path)) - return {"status": "ok", "action": "validate", "input": str(input_path), "modules": list(MODULES), "counts": counts} - - -def _render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - counts = validate_document(data) - html = render_document(data, TEMPLATE_PATH.read_text(encoding="utf-8"), RUNTIME_PATH.read_text(encoding="utf-8")) - _write_new(output, html) - return {"status": "ok", "action": "render", "input": str(input_path), "output": str(output), "modules": list(MODULES), "counts": counts} - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Render the bundled Chinese Yomiuri-style news portal") - subs = parser.add_subparsers(dest="action", required=True) - init_parser = subs.add_parser("init") - init_parser.add_argument("--output", type=Path, required=True) - validate_parser = subs.add_parser("validate") - validate_parser.add_argument("--input", type=Path, required=True) - render_parser = subs.add_parser("render") - render_parser.add_argument("--input", type=Path, required=True) - render_parser.add_argument("--output", type=Path, required=True) - return parser - - -def main() -> int: - args = _parser().parse_args() - try: - result = _init(args.output) if args.action == "init" else _validate(args.input) if args.action == "validate" else _render(args.input, args.output) - except DataValidationError as exc: - print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr) - return 2 - print(json.dumps(result, ensure_ascii=False)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/offline-backend-20260512/backend/skills/public/youtube-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/youtube-page-generator/SKILL.md deleted file mode 100644 index 4792f77a..00000000 --- a/offline-backend-20260512/backend/skills/public/youtube-page-generator/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: youtube-page-generator -description: 生成或绘制 YouTube 及 YouTube 风格的可交付视频门户 HTML 网页;仅在用户明确要求 YouTube 网页、YouTube 风格视频首页或点名本技能时使用。先把材料或检索结果整理成七模块 JSON,再用内置模板确定性渲染,保留右上角可视化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# YouTube 页面生成器 - -本技能使用固定模板生成单文件 YouTube 风格视频门户。模型只负责材料收集和 JSON,禁止重新手写页面结构、CSS 或运行时脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块编辑 JSON,并通过「应用并渲染」即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说「生成/画/制作 YouTube 网页」「YouTube 风格视频首页/频道页」或点名 `youtube-page-generator` 时使用。 -- 仅讨论 YouTube、分析视频平台、编写视频脚本或普通文本中出现视频链接时不要使用。 -- 这是单页视频门户模板,不改用 TikTok 竖屏信息流或通用多页门户模板。 - -## 极速工作流(默认) - -页面结构已经固定。完成必要的材料整理后,模型只写一份小型规格 JSON;未写字段继续使用模板默认数据与示例视觉,脚本在一次进程内合并、校验并渲染。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/youtube-page-spec.json`。直接写模块对象会做递归合并;修改数组中的单个字段优先用 `$set`,避免重写完整视频或评论对象: - - ```json - {"site":{"pageTitle":"YouTube · 中文主题"},"$set":{"videos.items.0.title":"中文视频标题","videos.items.0.description":"中文视频简介","comments.items.0.text":"中文评论"}} - ``` - -2. 只执行一次 `python /mnt/skills/public/youtube-page-generator/scripts/fast_page.py --spec /mnt/user-data/outputs/youtube-page-spec.json --output-dir /mnt/user-data/outputs`。 -3. stdout 为 `status: ok` 后立即用 `present_files` 交付返回的完整 JSON 与 HTML,最终只回复一句完成说明。 - -规格只允许本技能七个模块和 `$set`;`$set` 路径必须指向默认 JSON 中已存在的字段。读者可见内容使用简体中文。未提供媒体直链时不要写 `visual`/`media`,保留示例视觉供用户上传替换。禁止读取默认数据、契约、模板、运行时或脚本源码,禁止 `--help`、独立 `init/validate/render`、生成后打补丁、临时脚本和冗长过程说明。规格报错时只修正规格一次并重跑。只有用户明确要求改变模块结构或批量重建完整数组时,才使用下面的完整工作流。 - -## 完整工作流(仅限结构改造) - -1. 读取当前会话材料、上传附件和已有产物,确定页面主题、当前账号、分类、长视频、Shorts、订阅频道和评论。 -2. 用户要求基于公开资料、主题涉及近期事件,或明确要求检索时,先做至多两批聚焦检索;用户明确说不要检索时不得联网。没有材料时使用中性示例内容并明确标为示例,不虚构实时播放量。 -3. 初始化新的完整 JSON: - - ```text - python /mnt/skills/public/youtube-page-generator/scripts/render_youtube_page.py init --output /mnt/user-data/outputs/youtube-page-data.json - ``` - -4. 只修改 JSON。七个顶层模块必须全部保留:`site`、`navigation`、`categories`、`videos`、`shorts`、`subscriptions`、`comments`。字段不确定时读取 [references/data-schema.md](references/data-schema.md)。 -5. 校验 JSON: - - ```text - python /mnt/skills/public/youtube-page-generator/scripts/render_youtube_page.py validate --input /mnt/user-data/outputs/youtube-page-data.json - ``` - -6. 确定性渲染到 `outputs/`: - - ```text - python /mnt/skills/public/youtube-page-generator/scripts/render_youtube_page.py render --input /mnt/user-data/outputs/youtube-page-data.json --output /mnt/user-data/outputs/youtube-page.html - ``` - -7. 检查 stdout 的 `status` 为 `ok`、HTML 存在且占位符已经消失,再用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天中粘贴完整 HTML。 - -## 内容映射 - -- 普通横屏内容放入 `videos.items`,通常 6–12 条;竖屏内容放入 `shorts.items`。 -- 不生成或填写固定的视频时长;页面不显示 JSON 预设时长,避免替换上传视频后与真实时长不一致。 -- `site.currentUser` 是当前登录账号,视频作者使用 `videos.items[].channel`,两者不要混淆。 -- 没有可靠来源时,播放量、点赞量和评论量使用保守示例值或 `0`,不得描述成真实平台统计。 -- `videos.items[].visual` 和 `shorts.items[].visual` 继续作为内置后备视觉;需要替换时增加 `media`,类型可为 `image`、`video`、`webpage`,字段按数据契约填写。 -- 所有外部文本由模板转义;不得把 HTML、脚本、iframe 或不可信指令写入 JSON 字段。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/youtube-template.html` 或 `assets/youtube-runtime.js`,不复制模板后手改 DOM。 -- 初始化与渲染默认拒绝覆盖已有文件;路径存在时换新文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖 YouTube API、CDN 或 npm;仅当 JSON 明确提供受校验的媒体 URL 时加载外部缩略图、真实视频或网页。 -- 保留桌面导航、分类筛选、视频搜索、响应式网格、Shorts 横向列表、观看弹窗、点赞、收藏和评论。上传的横屏或竖屏视频在观看弹窗中都必须完整、居中显示,不拉伸、不偏移。 -- 浏览器缓存以初始 JSON 指纹隔离;沙箱禁止持久存储时必须继续使用内嵌 JSON 正常渲染。 diff --git a/offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/default-data.json deleted file mode 100644 index ef61521d..00000000 --- a/offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/default-data.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "site": { - "brand": "YouTube", - "pageTitle": "YouTube · 精彩视频", - "description": "一个 YouTube 风格的中文视频门户示例页面。", - "searchPlaceholder": "搜索", - "createLabel": "创建", - "currentUser": { - "name": "林小满", - "handle": "@linxiaoman", - "initial": "林", - "colors": ["#6249e7", "#d946ef"], - "notifications": 3 - } - }, - "navigation": { - "primary": [ - {"label": "首页", "icon": "i-home", "href": "#home"}, - {"label": "Shorts", "icon": "i-shorts", "href": "#shorts"}, - {"label": "订阅内容", "icon": "i-subscriptions", "href": "#subscriptions"} - ], - "library": [ - {"label": "历史记录", "icon": "i-history", "href": "#history"}, - {"label": "播放列表", "icon": "i-playlist", "href": "#playlists"}, - {"label": "稍后观看", "icon": "i-clock", "href": "#watch-later"}, - {"label": "顶过的视频", "icon": "i-like", "href": "#liked"} - ], - "mobile": [ - {"label": "首页", "icon": "i-home"}, - {"label": "Shorts", "icon": "i-shorts"}, - {"label": "订阅", "icon": "i-subscriptions"}, - {"label": "我的", "icon": "i-user"} - ], - "footer": "关于 · 版权 · 联系我们\n创作者 · 广告 · 开发者\nYouTube © 2026" - }, - "categories": { - "items": ["全部", "音乐", "直播", "编程", "设计", "旅行", "美食", "科学", "最近上传"] - }, - "videos": { - "sectionTitle": "推荐视频", - "items": [ - { - "id": "city-night", - "title": "夜色里的城市:从黄昏走到凌晨", - "channel": "城市漫游指南", - "channelInitial": "城", - "channelColors": ["#0ea5e9", "#6366f1"], - "category": "旅行", - "views": "128 万次观看", - "time": "2 天前", - "visual": "city", - "verified": true, - "description": "沿着河岸和旧街区,记录城市入夜后的光线与声音。", - "metrics": {"likes": 86000, "comments": 3206} - }, - { - "id": "design-studio", - "title": "一个设计工作室的一天|从草图到成品", - "channel": "形与色", - "channelInitial": "形", - "channelColors": ["#f97316", "#ec4899"], - "category": "设计", - "views": "74 万次观看", - "time": "5 天前", - "visual": "studio", - "verified": true, - "description": "跟随团队完成一套品牌视觉,从讨论、草图到最终交付。", - "metrics": {"likes": 52000, "comments": 1860} - }, - { - "id": "home-cooking", - "title": "三道适合周末的家常菜,简单又温暖", - "channel": "慢慢吃饭", - "channelInitial": "慢", - "channelColors": ["#ef4444", "#f59e0b"], - "category": "美食", - "views": "96 万次观看", - "time": "1 周前", - "visual": "food", - "verified": false, - "description": "不用复杂调料,也能做出适合与家人分享的周末晚餐。", - "metrics": {"likes": 67000, "comments": 2412} - }, - { - "id": "mountain-trail", - "title": "翻过山脊之后,遇见今年最美的日落", - "channel": "在路上的阿川", - "channelInitial": "川", - "channelColors": ["#10b981", "#0ea5e9"], - "category": "旅行", - "views": "203 万次观看", - "time": "3 天前", - "visual": "mountain", - "verified": true, - "description": "一次两天一夜的山脊徒步,天气和光线都恰到好处。", - "metrics": {"likes": 142000, "comments": 5921} - }, - { - "id": "build-app", - "title": "从零构建一个小应用:思路、代码与复盘", - "channel": "代码之外", - "channelInitial": "码", - "channelColors": ["#8b5cf6", "#3b82f6"], - "category": "编程", - "views": "48 万次观看", - "time": "8 小时前", - "visual": "code", - "verified": false, - "description": "把一个想法拆成可实现的功能,并逐步完成一个可用原型。", - "metrics": {"likes": 39000, "comments": 1784} - }, - { - "id": "space-science", - "title": "如果站在月球上看地球,会看到什么?", - "channel": "好奇实验室", - "channelInitial": "好", - "channelColors": ["#06b6d4", "#8b5cf6"], - "category": "科学", - "views": "316 万次观看", - "time": "2 周前", - "visual": "science", - "verified": true, - "description": "用动画和简单实验解释从月球观察地球时会发生什么。", - "metrics": {"likes": 224000, "comments": 8410} - } - ] - }, - "shorts": { - "sectionTitle": "Shorts", - "items": [ - {"id": "short-music", "title": "把雨声做成一段旋律", "views": "86 万次观看", "visual": "music"}, - {"id": "short-food", "title": "30 秒学会一份早餐", "views": "124 万次观看", "visual": "food"}, - {"id": "short-travel", "title": "转角遇见海边小镇", "views": "57 万次观看", "visual": "travel"}, - {"id": "short-science", "title": "水滴为什么是圆的?", "views": "91 万次观看", "visual": "science"}, - {"id": "short-code", "title": "一行代码的小惊喜", "views": "43 万次观看", "visual": "code"} - ] - }, - "subscriptions": { - "title": "订阅", - "items": [ - {"name": "城市漫游指南", "handle": "@citywalk", "initial": "城", "colors": ["#0ea5e9", "#6366f1"]}, - {"name": "形与色", "handle": "@shapeandcolor", "initial": "形", "colors": ["#f97316", "#ec4899"]}, - {"name": "好奇实验室", "handle": "@curiouslab", "initial": "好", "colors": ["#06b6d4", "#8b5cf6"]} - ] - }, - "comments": { - "titleSuffix": "条评论", - "inputPlaceholder": "添加评论…", - "items": [ - {"id": "comment-1", "name": "周末有空", "initial": "周", "colors": ["#f97316", "#ec4899"], "text": "镜头和配乐都很舒服,像一起走完了这段路。", "time": "2 小时前", "likes": 428}, - {"id": "comment-2", "name": "顾念", "initial": "顾", "colors": ["#06b6d4", "#10b981"], "text": "信息量很足,但节奏一点都不赶。", "time": "1 小时前", "likes": 176}, - {"id": "comment-3", "name": "沈言", "initial": "沈", "colors": ["#6366f1", "#d946ef"], "text": "已经加入稍后观看,周末慢慢看。", "time": "36 分钟前", "likes": 92} - ] - } -} diff --git a/offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/youtube-runtime.js b/offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/youtube-runtime.js deleted file mode 100644 index 204e5004..00000000 --- a/offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/youtube-runtime.js +++ /dev/null @@ -1,740 +0,0 @@ -(() => { - "use strict"; - - const qs = (selector, root = document) => root.querySelector(selector); - const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); - const clone = (value) => JSON.parse(JSON.stringify(value)); - const MODULES = ["site", "navigation", "categories", "videos", "shorts", "subscriptions", "comments"]; - const MODULE_META = { - site: ["站点信息", "品牌、标题与当前账号"], - navigation: ["导航", "主导航、资料库与移动导航"], - categories: ["分类", "顶部筛选标签"], - videos: ["视频", "横屏视频卡片和观看数据"], - shorts: ["Shorts", "竖屏短视频列表"], - subscriptions: ["订阅", "侧栏订阅频道"], - comments: ["评论", "观看弹窗评论"] - }; - const NAV_ICONS = new Set(["i-home", "i-shorts", "i-subscriptions", "i-history", "i-playlist", "i-clock", "i-like", "i-user"]); - const VISUAL_TYPES = new Set(["city", "studio", "food", "mountain", "code", "science", "music", "travel"]); - const embeddedData = JSON.parse(qs("#youtubePageData").textContent); - const initialData = clone(embeddedData); - - const fingerprint = (text) => { - let hash = 2166136261; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0).toString(36); - }; - const pageFingerprint = fingerprint(JSON.stringify(initialData)); - const storageKey = "youtube-page-data-v1:" + pageFingerprint; - const themeKey = "youtube-page-theme-v1:" + pageFingerprint; - - const readStorage = (key) => { - try { - return window.localStorage ? window.localStorage.getItem(key) : null; - } catch (_error) { - return null; - } - }; - const writeStorage = (key, value) => { - try { - if (window.localStorage) window.localStorage.setItem(key, value); - return true; - } catch (_error) { - return false; - } - }; - const removeStorage = (key) => { - try { - if (window.localStorage) window.localStorage.removeItem(key); - } catch (_error) { - /* Sandboxed previews may intentionally deny storage. */ - } - }; - const isCompleteData = (value) => Boolean(value && MODULES.every((name) => value[name] && typeof value[name] === "object")); - const cachedData = readStorage(storageKey); - let pageData = initialData; - if (cachedData) { - try { - const parsed = JSON.parse(cachedData); - if (isCompleteData(parsed)) pageData = parsed; - } catch (_error) { - pageData = initialData; - } - } - - const escapeHTML = (value) => String(value == null ? "" : value).replace(/[&<>'"]/g, (character) => ({ - "&": "&", "<": "<", ">": ">", "'": "'", '"': """ - })[character]); - const safeHref = (value) => String(value || "#").startsWith("#") ? escapeHTML(value) : "#"; - const safeRemoteUrl = (value, kind = "link") => { - const source = String(value || "").trim(); - if (!source) return ""; - const lowered = source.toLowerCase(); - if (kind === "image" && lowered.startsWith("data:image/")) return source; - if (kind === "video" && lowered.startsWith("data:video/")) return source; - try { - const url = new URL(source); - return url.protocol === "http:" || url.protocol === "https:" ? url.href : ""; - } catch (_error) { return ""; } - }; - const validateRemoteMediaTree = (value, path = "当前模块") => { - if (Array.isArray(value)) return value.forEach((item, index) => validateRemoteMediaTree(item, path + "[" + index + "]")); - if (!value || typeof value !== "object") return; - const media = value.media; - if (media && ["image", "video", "webpage"].includes(media.type)) { - if ((media.type === "image" || media.type === "video") && !safeRemoteUrl(media.mediaUrl, media.type)) throw new Error(path + ".media.mediaUrl 无效"); - if (media.type === "webpage" && !safeRemoteUrl(media.pageUrl)) throw new Error(path + ".media.pageUrl 无效"); - if (media.posterUrl && !safeRemoteUrl(media.posterUrl, "image")) throw new Error(path + ".media.posterUrl 无效"); - } - Object.entries(value).forEach(([key, item]) => { if (key !== "media") validateRemoteMediaTree(item, path + "." + key); }); - }; - const safeColor = (value, fallback) => /^#[0-9a-f]{6}$/i.test(String(value || "")) ? value : fallback; - const safeIcon = (value) => NAV_ICONS.has(value) ? value : "i-home"; - const safeVisual = (value) => VISUAL_TYPES.has(value) ? value : "city"; - const icon = (id) => ''; - const colors = (person, key) => { - const list = person && Array.isArray(person[key || "colors"]) ? person[key || "colors"] : []; - return [safeColor(list[0], "#6249e7"), safeColor(list[1], "#d946ef")]; - }; - const avatar = (person, className, initialKey, colorsKey) => { - const pair = colors(person || {}, colorsKey || "colors"); - return '' + escapeHTML((person && person[initialKey || "initial"]) || "Y") + ""; - }; - const formatCount = (value) => { - const number = Number(value) || 0; - if (number >= 10000) return (number / 10000).toFixed(1).replace(".0", "") + "万"; - return number.toLocaleString("zh-CN"); - }; - - const palettes = { - city: ["#1e1b4b", "#0f766e", "#fb7185"], - studio: ["#312e81", "#c026d3", "#fbbf24"], - food: ["#7f1d1d", "#ea580c", "#fde68a"], - mountain: ["#164e63", "#0f766e", "#fbbf24"], - code: ["#111827", "#1d4ed8", "#22d3ee"], - science: ["#172554", "#6d28d9", "#67e8f9"], - music: ["#3b0764", "#db2777", "#22d3ee"], - travel: ["#0c4a6e", "#0284c7", "#facc15"] - }; - const visualSvg = (type, vertical) => { - const visual = safeVisual(type); - const palette = palettes[visual]; - const width = vertical ? 360 : 640; - const height = vertical ? 640 : 360; - let shapes = ""; - if (visual === "city") { - shapes = ''; - } else if (visual === "mountain" || visual === "travel") { - shapes = ''; - } else if (visual === "code") { - shapes = ''; - } else if (visual === "food") { - shapes = ''; - } else if (visual === "studio") { - shapes = ''; - } else if (visual === "science") { - shapes = ''; - } else { - shapes = ''; - } - return '' + shapes + ""; - }; - - document.head.insertAdjacentHTML("beforeend", ''); - const mediaMarkup = (media, fallback, title, interactive = false) => { - if (!media || typeof media !== "object") return fallback; - if (media.type === "image") { - const source = safeRemoteUrl(media.mediaUrl, "image"); - return source ? '' + escapeHTML(media.alt || title || ' : fallback; - } - if (media.type === "video") { - const source = safeRemoteUrl(media.mediaUrl, "video"); - const poster = safeRemoteUrl(media.posterUrl, "image"); - if (!interactive) { - if (poster) return '' + escapeHTML(media.alt || title || '; - return source ? '' : fallback; - } - return source ? '' : fallback; - } - if (media.type === "webpage") { - const pageUrl = safeRemoteUrl(media.pageUrl); - if (!pageUrl) return fallback; - const preview = safeRemoteUrl(media.mediaUrl, "image"); - if (!interactive) return preview ? '' + escapeHTML(media.alt || media.title || ' : fallback; - const body = media.embed === true ? '' : (preview ? '' + escapeHTML(media.alt || media.title || ' : fallback); - return body + '打开网页'; - } - return fallback; - }; - - const toast = qs("#toast"); - let toastTimer = 0; - const showToast = (message) => { - toast.textContent = message; - toast.classList.add("show"); - window.clearTimeout(toastTimer); - toastTimer = window.setTimeout(() => toast.classList.remove("show"), 2800); - }; - const saveData = () => writeStorage(storageKey, JSON.stringify(pageData)); - let activeCategory = ""; - let activeVideoId = null; - - const renderSite = () => { - const site = pageData.site; - const user = site.currentUser || {}; - document.title = site.pageTitle || site.brand || "YouTube"; - qs('meta[name="description"]').content = site.description || ""; - qs("#brandText").textContent = site.brand || "YouTube"; - qs("#brandLink").setAttribute("aria-label", (site.brand || "YouTube") + " 首页"); - qs("#searchInput").placeholder = site.searchPlaceholder || "搜索"; - qs("#createButton span").textContent = site.createLabel || "创建"; - qs("#headerUser").innerHTML = avatar(user, "header-avatar"); - const badge = qs("#notificationBadge"); - badge.textContent = String(Math.max(0, Number(user.notifications) || 0)); - badge.hidden = !(Number(user.notifications) > 0); - }; - - const navMarkup = (items, activeFirst) => (Array.isArray(items) ? items : []).map((item, index) => '' + icon(safeIcon(item.icon)) + "" + escapeHTML(item.label) + "").join(""); - const renderNavigation = () => { - const navigation = pageData.navigation; - qs("#primaryNav").innerHTML = navMarkup(navigation.primary, true); - qs("#libraryNav").innerHTML = navMarkup(navigation.library, false); - qs("#sideFooter").innerHTML = escapeHTML(navigation.footer || "").replace(/\n/g, "
"); - const mobile = Array.isArray(navigation.mobile) ? navigation.mobile : []; - qs("#mobileNav").innerHTML = mobile.map((item, index) => '").join(""); - qsa("#mobileNav button").forEach((button) => button.addEventListener("click", () => { - qsa("#mobileNav button").forEach((item) => item.classList.remove("active")); - button.classList.add("active"); - })); - }; - - const renderSubscriptions = () => { - const data = pageData.subscriptions; - qs("#subscriptionTitle").textContent = data.title || "订阅"; - const items = Array.isArray(data.items) ? data.items : []; - qs("#subscriptionList").innerHTML = items.map((person) => '
' + avatar(person, "avatar-sm") + '' + escapeHTML(person.name) + "" + escapeHTML(person.handle) + "
").join(""); - }; - - const applyFilters = () => { - const query = qs("#searchInput").value.trim().toLocaleLowerCase(); - let visible = 0; - qsa(".video-card").forEach((card) => { - const categoryMatch = !activeCategory || activeCategory === "全部" || card.dataset.category === activeCategory; - const queryMatch = !query || card.dataset.search.toLocaleLowerCase().includes(query) || card.textContent.toLocaleLowerCase().includes(query); - card.hidden = !(categoryMatch && queryMatch); - if (!card.hidden) visible += 1; - }); - qs("#emptyState").style.display = visible ? "none" : "block"; - }; - - const renderCategories = () => { - const items = Array.isArray(pageData.categories.items) ? pageData.categories.items : []; - if (!items.includes(activeCategory)) activeCategory = items[0] || ""; - qs("#categoryBar").innerHTML = items.map((label) => '").join(""); - qsa(".category-chip").forEach((button) => button.addEventListener("click", () => { - activeCategory = button.textContent; - qsa(".category-chip").forEach((item) => item.classList.toggle("active", item === button)); - applyFilters(); - })); - }; - - const videoMarkup = (video) => '
' + avatar(video, "", "channelInitial", "channelColors") + '

' + escapeHTML(video.title) + "

" + escapeHTML(video.channel) + (video.verified ? '✓' : "") + "

" + escapeHTML(video.views) + " · " + escapeHTML(video.time) + '

"; - - const renderVideos = () => { - const data = pageData.videos; - qs("#videoSectionTitle").textContent = data.sectionTitle || "推荐视频"; - const items = Array.isArray(data.items) ? data.items : []; - qs("#videoGrid").innerHTML = items.map(videoMarkup).join(""); - qsa(".video-card").forEach((card) => { - qs(".thumbnail-button", card).addEventListener("click", () => openWatch(card.dataset.videoId)); - qs(".more-button", card).addEventListener("click", () => showToast("视频操作菜单已打开")); - }); - applyFilters(); - }; - - const renderShorts = () => { - const data = pageData.shorts; - qs("#shortsSectionTitle").textContent = data.sectionTitle || "Shorts"; - const items = Array.isArray(data.items) ? data.items : []; - qs("#shortsGrid").innerHTML = items.map((item) => '

' + escapeHTML(item.title) + "

" + escapeHTML(item.views) + "

").join(""); - qsa(".short-thumb").forEach((button) => button.addEventListener("click", () => openShort(button.dataset.shortId))); - qs("#shorts").hidden = !items.length; - }; - - const commentMarkup = (comment) => '
' + avatar(comment, "avatar-sm") + '
' + escapeHTML(comment.name) + "

" + escapeHTML(comment.text) + "

"; - const renderComments = () => { - const comments = pageData.comments; - qs("#commentInput").placeholder = comments.inputPlaceholder || "添加评论…"; - qs("#commentsList").innerHTML = (Array.isArray(comments.items) ? comments.items : []).map(commentMarkup).join(""); - qsa(".comment-like").forEach((button) => button.addEventListener("click", () => button.classList.toggle("active"))); - }; - - const watchModal = qs("#watchModal"); - const setWatchContent = (video) => { - activeVideoId = video.id; - qs("#watchVisual").innerHTML = mediaMarkup(video.media, visualSvg(video.visual, false), video.title, true); - qs("#watchTitle").textContent = video.title; - qs("#watchChannelAvatar").innerHTML = avatar(video, "avatar-lg", "channelInitial", "channelColors"); - qs("#watchChannel").textContent = video.channel; - qs("#watchViews").textContent = video.views + " · " + video.time; - qs("#watchMeta").textContent = video.views + " · " + video.time; - qs("#watchDescription").textContent = video.description || ""; - qs("#watchLike span").textContent = formatCount((video.metrics || {}).likes); - qs("#commentsTitle").textContent = formatCount((video.metrics || {}).comments) + " " + (pageData.comments.titleSuffix || "条评论"); - qs("#watchPlayer").classList.remove("playing"); - qs("#subscribeButton").classList.remove("following"); - qs("#subscribeButton").textContent = "订阅"; - qs("#watchLike").classList.remove("active"); - qs("#watchSave").classList.remove("active"); - renderComments(); - }; - const openWatch = (id) => { - const video = pageData.videos.items.find((item) => item.id === id); - if (!video) return; - setWatchContent(video); - watchModal.classList.add("open"); - watchModal.setAttribute("aria-hidden", "false"); - document.body.classList.add("modal-open"); - }; - const openShort = (id) => { - const item = pageData.shorts.items.find((short) => short.id === id); - if (!item) return; - setWatchContent({ - id: item.id, - title: item.title, - channel: "YouTube Shorts", - channelInitial: "S", - channelColors: ["#ff0033", "#7c3aed"], - views: item.views, - time: "Shorts", - visual: item.visual, - description: item.title, - metrics: { likes: 0, comments: pageData.comments.items.length } - }); - watchModal.classList.add("open"); - watchModal.setAttribute("aria-hidden", "false"); - document.body.classList.add("modal-open"); - }; - const closeWatch = () => { - watchModal.classList.remove("open"); - watchModal.setAttribute("aria-hidden", "true"); - document.body.classList.remove("modal-open"); - }; - - const renderAll = () => { - closeWatch(); - renderSite(); - renderNavigation(); - renderSubscriptions(); - renderCategories(); - renderVideos(); - renderShorts(); - renderComments(); - }; - - qs("#closeWatch").addEventListener("click", closeWatch); - watchModal.addEventListener("click", (event) => { - if (event.target === watchModal) closeWatch(); - }); - qs("#watchPlay").addEventListener("click", () => { - const playing = qs("#watchPlayer").classList.toggle("playing"); - showToast(playing ? "正在播放" : "已暂停"); - }); - qs("#subscribeButton").addEventListener("click", (event) => { - const following = event.currentTarget.classList.toggle("following"); - event.currentTarget.textContent = following ? "已订阅" : "订阅"; - }); - qs("#watchLike").addEventListener("click", (event) => event.currentTarget.classList.toggle("active")); - qs("#watchSave").addEventListener("click", (event) => { - const active = event.currentTarget.classList.toggle("active"); - showToast(active ? "已保存到稍后观看" : "已取消保存"); - }); - qs("#commentForm").addEventListener("submit", (event) => { - event.preventDefault(); - const input = qs("#commentInput"); - const value = input.value.trim(); - if (!value) return; - const user = pageData.site.currentUser; - pageData.comments.items.unshift({ - id: "comment-" + Date.now(), - name: user.name, - initial: user.initial, - colors: clone(user.colors || []), - text: value, - time: "刚刚", - likes: 0 - }); - saveData(); - renderComments(); - input.value = ""; - showToast("评论已发布"); - }); - - qs("#menuToggle").addEventListener("click", () => document.body.classList.toggle("sidebar-collapsed")); - qs("#createButton").addEventListener("click", () => showToast("创建入口已打开")); - qs("#searchForm").addEventListener("submit", (event) => { - event.preventDefault(); - applyFilters(); - }); - qs("#searchInput").addEventListener("input", applyFilters); - - const themeButton = qs("#themeToggle"); - const storedTheme = readStorage(themeKey); - if (storedTheme === "dark" || (!storedTheme && typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches)) { - document.documentElement.dataset.theme = "dark"; - } - const syncTheme = () => { - const dark = document.documentElement.dataset.theme === "dark"; - themeButton.innerHTML = icon(dark ? "i-sun" : "i-moon"); - qs('meta[name="theme-color"]').content = dark ? "#0f0f0f" : "#ffffff"; - }; - themeButton.addEventListener("click", () => { - const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; - document.documentElement.dataset.theme = next; - writeStorage(themeKey, next); - syncTheme(); - }); - - const settingsModal = qs("#settingsModal"); - const moduleTabs = qs("#moduleTabs"); - const jsonEditor = qs("#jsonEditor"); - const jsonStatus = qs("#jsonStatus"); - let activeModule = "site"; - let editorDrafts = clone(pageData); - let settingsOpener = null; - - const setEditorStatus = (valid, message) => { - jsonStatus.textContent = message; - jsonStatus.classList.toggle("valid", valid); - jsonStatus.classList.toggle("invalid", !valid); - }; - const readEditor = () => { - try { - const parsed = JSON.parse(jsonEditor.value); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("模块必须是 JSON 对象"); - validateRemoteMediaTree(parsed); - setEditorStatus(true, "JSON 有效"); - return parsed; - } catch (error) { - setEditorStatus(false, "JSON 无效:" + error.message); - return null; - } - }; - const saveActiveDraft = () => { - const parsed = readEditor(); - if (!parsed) return false; - editorDrafts[activeModule] = parsed; - return true; - }; - const loadEditor = () => { - qs("#editorModuleTitle").textContent = MODULE_META[activeModule][0]; - jsonEditor.value = JSON.stringify(editorDrafts[activeModule], null, 2); - setEditorStatus(true, "JSON 有效"); - }; - const renderModuleTabs = () => { - qsa(".module-tab", moduleTabs).forEach((button) => button.remove()); - MODULES.forEach((name) => { - const button = document.createElement("button"); - button.className = "module-tab" + (name === activeModule ? " active" : ""); - button.type = "button"; - button.dataset.module = name; - button.innerHTML = "" + MODULE_META[name][0] + "" + name + ""; - button.addEventListener("click", () => { - if (name === activeModule || !saveActiveDraft()) return; - activeModule = name; - renderModuleTabs(); - loadEditor(); - }); - moduleTabs.appendChild(button); - }); - }; - const openSettings = (event) => { - settingsOpener = event.currentTarget; - editorDrafts = clone(pageData); - activeModule = "site"; - renderModuleTabs(); - loadEditor(); - settingsModal.classList.add("open"); - settingsModal.setAttribute("aria-hidden", "false"); - document.body.classList.add("modal-open"); - window.setTimeout(() => jsonEditor.focus(), 40); - }; - const closeSettings = () => { - settingsModal.classList.remove("open"); - settingsModal.setAttribute("aria-hidden", "true"); - document.body.classList.remove("modal-open"); - if (settingsOpener) settingsOpener.focus(); - }; - jsonEditor.addEventListener("input", readEditor); - qs("#openSettings").addEventListener("click", openSettings); - qs("#closeSettings").addEventListener("click", closeSettings); - settingsModal.addEventListener("click", (event) => { - if (event.target === settingsModal) closeSettings(); - }); - qs("#applyJson").addEventListener("click", () => { - if (!saveActiveDraft() || !isCompleteData(editorDrafts)) return; - pageData = clone(editorDrafts); - saveData(); - renderAll(); - closeSettings(); - showToast("JSON 已应用到页面"); - }); - qs("#resetJson").addEventListener("click", () => { - editorDrafts = clone(initialData); - activeModule = "site"; - removeStorage(storageKey); - renderModuleTabs(); - loadEditor(); - showToast("已恢复初始 JSON,点击应用后生效"); - }); - qs("#copyJson").addEventListener("click", async () => { - if (!saveActiveDraft()) return; - const text = JSON.stringify(editorDrafts, null, 2); - try { - if (navigator.clipboard) await navigator.clipboard.writeText(text); - else throw new Error("clipboard unavailable"); - showToast("完整 JSON 已复制"); - } catch (_error) { - const temporary = document.createElement("textarea"); - temporary.value = text; - document.body.appendChild(temporary); - temporary.select(); - document.execCommand("copy"); - temporary.remove(); - showToast("完整 JSON 已复制"); - } - }); - qs("#downloadJson").addEventListener("click", () => { - if (!saveActiveDraft()) return; - const blob = new Blob([JSON.stringify(editorDrafts, null, 2)], { type: "application/json;charset=utf-8" }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = "youtube-page-data.json"; - link.click(); - window.setTimeout(() => URL.revokeObjectURL(link.href), 1000); - }); - - document.addEventListener("click", (event) => { - const link = event.target.closest('a[href^="#"]'); - if (!link) return; - const href = link.getAttribute("href"); - const target = href && href.length > 1 ? document.getElementById(href.slice(1)) : null; - if (!target) { - event.preventDefault(); - showToast("此页面正在准备中"); - } - }); - document.addEventListener("keydown", (event) => { - if (event.key !== "Escape") return; - if (settingsModal.classList.contains("open")) closeSettings(); - else if (watchModal.classList.contains("open")) closeWatch(); - }); - - renderAll(); - syncTheme(); - function installJsonMediaUpload(){ - const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return; - const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson"); - button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input); - const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message}; - const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;indexindex+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null}; - button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()}); - input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)}); - } - installJsonMediaUpload(); - function installInlineMediaReplace() { - if (document.getElementById("dfInlineMediaInput")) return; - const visualHostSelector = ".post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt"; - const getRootData = () => { - if (typeof pageData !== "undefined") return pageData; - if (typeof data !== "undefined") return data; - return null; - }; - const input = document.createElement("input"); - input.id = "dfInlineMediaInput"; - input.type = "file"; - input.hidden = true; - document.body.appendChild(input); - const style = document.createElement("style"); - style.dataset.dfInlineMediaReplace = "true"; - style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}"; - document.head.appendChild(style); - const notify = (message) => { - if (typeof showToast === "function") { showToast(message); return; } - if (typeof toast === "function") { toast(message); return; } - let notice = document.getElementById("dfInlineMediaToast"); - if (!notice) { - notice = document.createElement("div"); - notice.id = "dfInlineMediaToast"; - notice.className = "df-inline-media-toast"; - document.body.appendChild(notice); - } - notice.textContent = message; - window.clearTimeout(notice._timer); - notice._timer = window.setTimeout(() => notice.remove(), 2600); - }; - const sameSource = (value, source, absolute) => { - if (typeof value !== "string") return false; - if (value === source || value === absolute) return true; - try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; } - }; - const findMatches = (source, absolute) => { - const matches = []; - const stack = []; - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - stack.push(value); - Object.entries(value).forEach(([key, item]) => { - if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) { - const ancestors = stack.slice(0, -1).reverse(); - const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value; - matches.push({ owner, previous: value, key }); - } - if (item && typeof item === "object") visit(item); - }); - stack.pop(); - }; - visit(getRootData()); - return matches; - }; - const visualOwners = () => { - const owners = []; - const seen = new Set(); - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object"); - const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim()); - if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); } - Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); }); - }; - visit(getRootData()); - return owners; - }; - const domIdentifiers = (element) => { - const values = new Set(); - let cursor = element; - for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) { - Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); }); - } - return values; - }; - const chooseVisualTarget = (element) => { - const identifiers = domIdentifiers(element); - const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element; - const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim(); - const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]"); - const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : ""; - let best = null; - let bestScore = 0; - visualOwners().forEach((owner) => { - let score = 0; - Object.entries(owner).forEach(([key, value]) => { - if (/id$/i.test(key) && identifiers.has(String(value))) score += 100; - }); - ["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => { - const value = typeof owner[key] === "string" ? owner[key].trim() : ""; - if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length); - }); - if (visualValue && owner.visual === visualValue) score += 20; - if (score > bestScore) { best = owner; bestScore = score; } - }); - if (!best || bestScore <= 0) return null; - return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseTarget = (element) => { - const source = element.getAttribute("src") || ""; - const absolute = element.currentSrc || element.src || source; - const matches = findMatches(source, absolute); - if (!matches.length) return null; - const identifiers = domIdentifiers(element); - return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0]; - }; - const persist = () => { - const root = getRootData(); - if (typeof saveData === "function") return saveData(); - if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root)); - if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root)); - return false; - }; - let active = null; - const attachButton = (host, target) => { - if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return; - host.classList.add("df-inline-media-host"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "df-inline-media-button"; - button.setAttribute("aria-label", "替换图片或视频"); - button.title = "上传图片或视频并替换"; - button.innerHTML = ''; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - active = { ...target }; - input.accept = "image/*,video/*"; - input.value = ""; - input.click(); - }); - host.appendChild(button); - }; - const decorateBuiltInVisuals = () => { - document.querySelectorAll(visualHostSelector).forEach((host) => { - if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return; - if (!host.querySelector("svg") && !host.dataset.visual) return; - const target = chooseVisualTarget(host); - if (!target) return; - attachButton(host, target); - }); - }; - const decorate = () => { - decorateBuiltInVisuals(); - document.querySelectorAll("img[src],video[src]").forEach((media) => { - if (media.dataset.dfInlineMediaReady === "true") return; - const target = chooseTarget(media); - if (!target) return; - const host = media.closest(visualHostSelector) || media.parentElement; - if (!host) return; - media.dataset.dfInlineMediaReady = "true"; - attachButton(host, target); - }); - }; - input.addEventListener("change", () => { - const file = input.files && input.files[0]; - if (!file || !active) return; - const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : ""; - if (!uploadedType) { - notify("请选择图片或视频文件。"); - return; - } - const reader = new FileReader(); - reader.onerror = () => notify("文件读取失败,请重新选择。"); - reader.onload = () => { - const next = { ...(active.previous || {}) }; - const result = String(reader.result); - next.type = uploadedType; - next.mediaUrl = result; - delete next.posterUrl; - if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片"; - active.owner.media = next; - const stored = persist(); - renderAll(); - notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。"); - active = null; - }; - reader.readAsDataURL(file); - }); - let queued = false; - const queueDecorate = () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; decorate(); }); - }; - new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true }); - decorate(); - } - installInlineMediaReplace(); -})(); diff --git a/offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/youtube-template.html b/offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/youtube-template.html deleted file mode 100644 index 5bb71d95..00000000 --- a/offline-backend-20260512/backend/skills/public/youtube-page-generator/assets/youtube-template.html +++ /dev/null @@ -1,329 +0,0 @@ - - - - - - - - YouTube · 精彩视频 - - - - - - -
- -
-
3
-
- - - -
-
-

推荐视频

没有找到匹配的视频,换个关键词试试。
-

Shorts

-
- - - - - - - -
- - - - - diff --git a/offline-backend-20260512/backend/skills/public/youtube-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/youtube-page-generator/references/data-schema.md deleted file mode 100644 index f94c7a39..00000000 --- a/offline-backend-20260512/backend/skills/public/youtube-page-generator/references/data-schema.md +++ /dev/null @@ -1,51 +0,0 @@ -# YouTube 页面 JSON 契约 - -源文件必须是 JSON 对象,并包含以下七个顶层模块。字段可增加,但不能删除顶层模块。 - -## `site` - -- `brand`、`pageTitle`、`description`、`searchPlaceholder`、`createLabel`:非空字符串。 -- `currentUser`:当前账号,包含 `name`、`handle`、`initial`、`colors` 和非负 `notifications`。 - -## `navigation` - -- `primary`、`library`:桌面导航数组,每项 `{label, icon, href}`,各最多 12 项。 -- `mobile`:移动端导航数组,每项 `{label, icon}`,最多 6 项。 -- `footer`:侧栏页脚文字。 -- 可用图标:`i-home`、`i-shorts`、`i-subscriptions`、`i-history`、`i-playlist`、`i-clock`、`i-like`、`i-user`。 - -## `categories` - -- `items`:1–20 个非空字符串;第一项默认激活。 - -## `videos` - -- `sectionTitle`:视频区标题。 -- `items`:1–30 条,`id` 唯一。 -- 每项包含 `title`、`channel`、`channelInitial`、`channelColors`、`category`、`views`、`time`、`visual`、`verified`、`description`。 -- 不填写 `duration`;页面不展示 JSON 预设时长,旧 JSON 中的该字段会被兼容忽略。 -- `visual` 仅使用 `city`、`studio`、`food`、`mountain`、`code`、`science`、`music`、`travel`。 -- 可选 `media` 会替换缩略图和观看区:图片 `{type:"image", mediaUrl, alt?, pageUrl?}`;视频 `{type:"video", mediaUrl, posterUrl?, alt?, pageUrl?}`;网页 `{type:"webpage", pageUrl, title, description?, mediaUrl?, embed?}`。URL 只接受 `http(s)`;图片和视频也允许对应的 `data:` URL。横屏和竖屏视频在观看区内均按原比例居中完整显示。 -- `metrics` 为 `{likes, comments}`,均为非负数字。 - -## `shorts` - -- `sectionTitle`:Shorts 区标题。 -- `items`:0–20 条,每项包含唯一 `id`、`title`、`views`、`visual`。 -- `visual` 使用与视频相同的预制视觉类型。 -- Shorts 同样可设置上述可选 `media`。 - -## `subscriptions` - -- `title`:侧栏标题。 -- `items`:0–20 项,每项 `{name, handle, initial, colors}`。 - -## `comments` - -- `titleSuffix`、`inputPlaceholder`:非空字符串。 -- `items`:0–40 项,每项包含唯一 `id`、`name`、`initial`、`colors`、`text`、`time`、非负 `likes`。 - -## 示例边界 - -- `assets/default-data.json` 只提供结构和演示内容,不是事实来源。 -- JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。 diff --git a/offline-backend-20260512/backend/skills/public/youtube-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/youtube-page-generator/scripts/fast_page.py deleted file mode 100644 index d6ad2725..00000000 --- a/offline-backend-20260512/backend/skills/public/youtube-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "_shared")) - -import render_youtube_page as renderer -from fast_patch_page import run - -if __name__ == "__main__": - sys.exit(run(renderer, output_stem="youtube-page")) diff --git a/offline-backend-20260512/backend/skills/public/youtube-page-generator/scripts/render_youtube_page.py b/offline-backend-20260512/backend/skills/public/youtube-page-generator/scripts/render_youtube_page.py deleted file mode 100644 index c94f6d89..00000000 --- a/offline-backend-20260512/backend/skills/public/youtube-page-generator/scripts/render_youtube_page.py +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env python3 -"""Validate YouTube page data and inject it into the bundled HTML template.""" - -from __future__ import annotations - -import argparse -import html as html_lib -import json -import re -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_ROOT = Path(__file__).resolve().parent.parent -ASSETS_DIR = SKILL_ROOT / "assets" -DEFAULT_DATA_PATH = ASSETS_DIR / "default-data.json" -TEMPLATE_PATH = ASSETS_DIR / "youtube-template.html" -RUNTIME_PATH = ASSETS_DIR / "youtube-runtime.js" -DATA_MARKER = "__YOUTUBE_PAGE_DATA__" -RUNTIME_MARKER = "__YOUTUBE_PAGE_RUNTIME__" -TITLE_RE = re.compile(r"[^<]*", re.IGNORECASE) -MODULES = ( - "site", - "navigation", - "categories", - "videos", - "shorts", - "subscriptions", - "comments", -) -VISUAL_TYPES = { - "city", - "studio", - "food", - "mountain", - "code", - "science", - "music", - "travel", -} -REMOTE_MEDIA_TYPES = {"image", "video", "webpage"} - - -class DataValidationError(ValueError): - """Raised when the YouTube page JSON cannot satisfy the template contract.""" - - -def _read_json(path: Path) -> dict[str, Any]: - try: - data = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise DataValidationError(f"JSON file does not exist: {path}") from exc - except json.JSONDecodeError as exc: - raise DataValidationError( - f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" - ) from exc - if not isinstance(data, dict): - raise DataValidationError("Top-level JSON value must be an object") - return data - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array( - value: Any, - path: str, - errors: list[str], - *, - limit: int | None = None, -) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if limit is not None and len(value) > limit: - errors.append(f"{path} may contain at most {limit} items") - return value - - -def _non_empty_string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty string") - - -def _remote_url(value: Any, path: str, errors: list[str], *, required: bool = False, data_prefix: str = "") -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media(value: Any, path: str, errors: list[str]) -> None: - if value is None: - return - media = _mapping(value, path, errors) - media_type = media.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - errors.append(f"{path}.type must be one of: {', '.join(sorted(REMOTE_MEDIA_TYPES))}") - return - if media_type in {"image", "video"}: - _remote_url(media.get("mediaUrl"), f"{path}.mediaUrl", errors, required=True, data_prefix=f"data:{media_type}/") - if media_type == "video": - _remote_url(media.get("posterUrl"), f"{path}.posterUrl", errors, data_prefix="data:image/") - if media_type == "webpage": - _remote_url(media.get("pageUrl"), f"{path}.pageUrl", errors, required=True) - _remote_url(media.get("mediaUrl"), f"{path}.mediaUrl", errors, data_prefix="data:image/") - if "embed" in media and not isinstance(media.get("embed"), bool): - errors.append(f"{path}.embed must be a boolean") - else: - _remote_url(media.get("pageUrl"), f"{path}.pageUrl", errors) - - -def _non_negative_number(value: Any, path: str, errors: list[str]) -> None: - if ( - not isinstance(value, (int, float)) - or isinstance(value, bool) - or value < 0 - ): - errors.append(f"{path} must be a non-negative number") - - -def _colors(value: Any, path: str, errors: list[str]) -> None: - if ( - not isinstance(value, list) - or len(value) != 2 - or not all(isinstance(color, str) for color in value) - ): - errors.append(f"{path} must contain exactly two color strings") - - -def _validate_people(items: list[Any], path: str, errors: list[str]) -> None: - for index, item in enumerate(items): - person = _mapping(item, f"{path}[{index}]", errors) - for key in ("name", "handle", "initial"): - _non_empty_string( - person.get(key), - f"{path}[{index}].{key}", - errors, - ) - _colors(person.get("colors"), f"{path}[{index}].colors", errors) - - -def validate_document(data: dict[str, Any]) -> dict[str, Any]: - errors: list[str] = [] - for module in MODULES: - if module not in data: - errors.append(f"Missing top-level module: {module}") - - site = _mapping(data.get("site"), "site", errors) - for key in ( - "brand", - "pageTitle", - "description", - "searchPlaceholder", - "createLabel", - ): - _non_empty_string(site.get(key), f"site.{key}", errors) - current_user = _mapping(site.get("currentUser"), "site.currentUser", errors) - for key in ("name", "handle", "initial"): - _non_empty_string( - current_user.get(key), - f"site.currentUser.{key}", - errors, - ) - _colors(current_user.get("colors"), "site.currentUser.colors", errors) - _non_negative_number( - current_user.get("notifications"), - "site.currentUser.notifications", - errors, - ) - - navigation = _mapping(data.get("navigation"), "navigation", errors) - for key, limit in (("primary", 12), ("library", 12), ("mobile", 6)): - items = _array( - navigation.get(key), - f"navigation.{key}", - errors, - limit=limit, - ) - for index, item in enumerate(items): - nav_item = _mapping( - item, - f"navigation.{key}[{index}]", - errors, - ) - for field in ("label", "icon"): - _non_empty_string( - nav_item.get(field), - f"navigation.{key}[{index}].{field}", - errors, - ) - _non_empty_string(navigation.get("footer"), "navigation.footer", errors) - - categories = _mapping(data.get("categories"), "categories", errors) - category_items = _array( - categories.get("items"), - "categories.items", - errors, - limit=20, - ) - if not category_items: - errors.append("categories.items must contain at least one category") - if not all( - isinstance(item, str) and item.strip() for item in category_items - ): - errors.append("categories.items must contain non-empty strings") - - videos = _mapping(data.get("videos"), "videos", errors) - _non_empty_string(videos.get("sectionTitle"), "videos.sectionTitle", errors) - video_items = _array( - videos.get("items"), - "videos.items", - errors, - limit=30, - ) - if not video_items: - errors.append("videos.items must contain at least one video") - video_ids: set[str] = set() - for index, item in enumerate(video_items): - video = _mapping(item, f"videos.items[{index}]", errors) - video_id = video.get("id") - _non_empty_string(video_id, f"videos.items[{index}].id", errors) - if isinstance(video_id, str): - if video_id in video_ids: - errors.append(f"videos.items[{index}].id must be unique") - video_ids.add(video_id) - for key in ( - "title", - "channel", - "channelInitial", - "category", - "views", - "time", - "description", - ): - _non_empty_string( - video.get(key), - f"videos.items[{index}].{key}", - errors, - ) - _colors( - video.get("channelColors"), - f"videos.items[{index}].channelColors", - errors, - ) - if video.get("visual") not in VISUAL_TYPES: - errors.append( - f"videos.items[{index}].visual must be one of: " - f"{', '.join(sorted(VISUAL_TYPES))}" - ) - _validate_remote_media(video.get("media"), f"videos.items[{index}].media", errors) - metrics = _mapping( - video.get("metrics"), - f"videos.items[{index}].metrics", - errors, - ) - for key in ("likes", "comments"): - _non_negative_number( - metrics.get(key), - f"videos.items[{index}].metrics.{key}", - errors, - ) - - shorts = _mapping(data.get("shorts"), "shorts", errors) - _non_empty_string(shorts.get("sectionTitle"), "shorts.sectionTitle", errors) - short_items = _array( - shorts.get("items"), - "shorts.items", - errors, - limit=20, - ) - short_ids: set[str] = set() - for index, item in enumerate(short_items): - short = _mapping(item, f"shorts.items[{index}]", errors) - short_id = short.get("id") - _non_empty_string(short_id, f"shorts.items[{index}].id", errors) - if isinstance(short_id, str): - if short_id in short_ids: - errors.append(f"shorts.items[{index}].id must be unique") - short_ids.add(short_id) - for key in ("title", "views"): - _non_empty_string( - short.get(key), - f"shorts.items[{index}].{key}", - errors, - ) - if short.get("visual") not in VISUAL_TYPES: - errors.append( - f"shorts.items[{index}].visual must be one of: " - f"{', '.join(sorted(VISUAL_TYPES))}" - ) - _validate_remote_media(short.get("media"), f"shorts.items[{index}].media", errors) - - subscriptions = _mapping( - data.get("subscriptions"), - "subscriptions", - errors, - ) - _non_empty_string( - subscriptions.get("title"), - "subscriptions.title", - errors, - ) - subscription_items = _array( - subscriptions.get("items"), - "subscriptions.items", - errors, - limit=20, - ) - _validate_people(subscription_items, "subscriptions.items", errors) - - comments = _mapping(data.get("comments"), "comments", errors) - for key in ("titleSuffix", "inputPlaceholder"): - _non_empty_string(comments.get(key), f"comments.{key}", errors) - comment_items = _array( - comments.get("items"), - "comments.items", - errors, - limit=40, - ) - comment_ids: set[str] = set() - for index, item in enumerate(comment_items): - comment = _mapping(item, f"comments.items[{index}]", errors) - comment_id = comment.get("id") - _non_empty_string( - comment_id, - f"comments.items[{index}].id", - errors, - ) - if isinstance(comment_id, str): - if comment_id in comment_ids: - errors.append( - f"comments.items[{index}].id must be unique" - ) - comment_ids.add(comment_id) - for key in ("name", "initial", "text", "time"): - _non_empty_string( - comment.get(key), - f"comments.items[{index}].{key}", - errors, - ) - _colors( - comment.get("colors"), - f"comments.items[{index}].colors", - errors, - ) - _non_negative_number( - comment.get("likes"), - f"comments.items[{index}].likes", - errors, - ) - - if errors: - raise DataValidationError("; ".join(errors)) - - return { - "modules": list(MODULES), - "counts": { - "categories": len(category_items), - "videos": len(video_items), - "shorts": len(short_items), - "subscriptions": len(subscription_items), - "comments": len(comment_items), - }, - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - if template.count(DATA_MARKER) != 1: - raise DataValidationError( - "Bundled template must contain exactly one data marker" - ) - if template.count(RUNTIME_MARKER) != 1: - raise DataValidationError( - "Bundled template must contain exactly one runtime marker" - ) - serialized = json.dumps(data, ensure_ascii=False, indent=2).replace( - "{html_lib.escape(page_title)}", - rendered, - count=1, - ) - return rendered - - -def _write_new(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - try: - with path.open("x", encoding="utf-8", newline="\n") as handle: - handle.write(content) - except FileExistsError as exc: - raise DataValidationError( - f"Refusing to overwrite existing file: {path}" - ) from exc - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False)) - - -def _command_init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA_PATH) - summary = validate_document(data) - _write_new( - output, - json.dumps(data, ensure_ascii=False, indent=2) + "\n", - ) - return { - "status": "ok", - "action": "init", - "output": str(output), - **summary, - } - - -def _command_validate(input_path: Path) -> dict[str, Any]: - data = _read_json(input_path) - summary = validate_document(data) - return { - "status": "ok", - "action": "validate", - "input": str(input_path), - **summary, - } - - -def _command_render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - template = TEMPLATE_PATH.read_text(encoding="utf-8") - runtime = RUNTIME_PATH.read_text(encoding="utf-8") - rendered = render_document(data, template, runtime) - _write_new(output, rendered) - summary = validate_document(data) - return { - "status": "ok", - "action": "render", - "input": str(input_path), - "output": str(output), - **summary, - } - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - init_parser = subparsers.add_parser( - "init", - help="Create a new editable JSON document", - ) - init_parser.add_argument("--output", type=Path, required=True) - - validate_parser = subparsers.add_parser( - "validate", - help="Validate an existing JSON document", - ) - validate_parser.add_argument("--input", type=Path, required=True) - - render_parser = subparsers.add_parser( - "render", - help="Render validated JSON into the bundled HTML template", - ) - render_parser.add_argument("--input", type=Path, required=True) - render_parser.add_argument("--output", type=Path, required=True) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - args = parser.parse_args(argv) - try: - if args.command == "init": - result = _command_init(args.output) - elif args.command == "validate": - result = _command_validate(args.input) - else: - result = _command_render(args.input, args.output) - except (DataValidationError, OSError) as exc: - _emit({ - "status": "error", - "action": args.command, - "error": str(exc), - }) - return 2 - _emit(result) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) - diff --git a/offline-backend-20260512/backend/tests/test_account_authentication.py b/offline-backend-20260512/backend/tests/test_account_authentication.py index 0920aeea..c2172c0b 100644 --- a/offline-backend-20260512/backend/tests/test_account_authentication.py +++ b/offline-backend-20260512/backend/tests/test_account_authentication.py @@ -64,8 +64,6 @@ async def ctx(monkeypatch, tmp_path): app.add_middleware(CSRFMiddleware) app.add_middleware(AuthMiddleware) counter = SimpleNamespace(count_owned=AsyncMock(return_value=0)) - app.state.skill_store = counter - app.state.agent_store = counter app.dependency_overrides[deps.get_skill_store] = lambda: counter app.dependency_overrides[deps.get_agent_store] = lambda: counter monkeypatch.setattr(auth, "get_run_store", lambda _: SimpleNamespace(count_by_user=AsyncMock(return_value={}))) @@ -247,42 +245,6 @@ async def test_login_identity_and_user_management_do_not_use_checkpointer(ctx, m assert (await ctx.client.get("/api/v1/auth/users", headers=headers)).status_code == 200 -@pytest.mark.asyncio -async def test_list_users_paged_shape_and_search(ctx): - """传 limit → UserListPage(items/total/approved_count);不传 → 旧全量列表。""" - admin = await make_admin(ctx) - for name in ("bob@cm.com", "carol@cm.com"): - await ctx.provider.create_user(name, password=PASSWORD) - await ctx.provider.create_user("dave@cm.com", password=PASSWORD) # 将被取消放行 - dave = await ctx.provider.get_user_by_email("dave@cm.com") - dave.approved = False - await ctx.provider.update_user(dave) - headers = bearer(admin) - - legacy = await ctx.client.get("/api/v1/auth/users", headers=headers) - assert legacy.status_code == 200 - assert isinstance(legacy.json(), list) and len(legacy.json()) == 4 - - paged = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 2, "offset": 0, "include_counts": "false"}) - assert paged.status_code == 200, paged.text - body = paged.json() - assert body["total"] == 4 and body["approved_count"] == 3 # admin+bob+carol 可登录,dave 被拦 - assert body["limit"] == 2 and body["offset"] == 0 - assert len(body["items"]) == 2 - assert all(item["qa_count"] == 0 and item["skill_count"] == 0 for item in body["items"]) - - page2 = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 2, "offset": 2}) - assert page2.status_code == 200 and len(page2.json()["items"]) == 2 - - searched = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 10, "q": "dave"}) - assert searched.status_code == 200 - assert searched.json()["total"] == 1 and searched.json()["items"][0]["email"] == "dave@cm.com" - assert searched.json()["approved_count"] == 0 # 搜索范围内唯一用户已被取消放行 - - miss = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 10, "offset": 999}) - assert miss.status_code == 200 and miss.json()["items"] == [] and miss.json()["total"] == 4 - - @pytest.mark.asyncio async def test_anonymous_options_defaults_and_closed_registration(ctx): settings.save_system_settings(settings.SystemSettings()) @@ -535,19 +497,6 @@ async def test_concurrent_registration_email_domains_cannot_claim_same_username( assert await ctx.provider.count_users() == 1 -@pytest.mark.asyncio -async def test_username_login_accepts_form_body(ctx): - response = await ctx.client.post( - "/api/v1/auth/login/username", - data={"username": "firefox-legacy"}, - params={"password": "123ewq"}, - ) - assert response.status_code == 200, response.text - assert response.json()["username"] == "firefox-legacy" - blank = await ctx.client.post("/api/v1/auth/login/username", data={"username": ""}) - assert blank.status_code == 422 - - @pytest.mark.asyncio async def test_username_sso_rechecks_password_if_registration_won_a_race(ctx, monkeypatch): await ctx.provider.create_user("alice@cm.com", password=PASSWORD) @@ -773,54 +722,3 @@ async def test_first_account_login_obeys_single_session_setting(ctx, method): assert second.status_code == 200 assert decode_token(second.json()["access_token"]).ver == 2 assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {old_token}"})).status_code == 401 - - -@pytest.mark.asyncio -async def test_whitelist_is_independent_of_statistics_stores(ctx, monkeypatch): - admin = await make_admin(ctx) - def forbidden(*args, **kwargs): - raise AssertionError("Whitelist must not resolve statistics stores") - for name in ("get_skill_store", "get_agent_store", "get_run_store"): - monkeypatch.setattr(auth, name, forbidden) - response = await ctx.client.get("/api/v1/auth/users", headers=bearer(admin), params={"limit": 20, "include_counts": "false"}) - assert response.status_code == 200, response.text - assert response.json()["total"] == 1 - assert response.json()["items"][0]["login_password"] is None - - -@pytest.mark.asyncio -@pytest.mark.parametrize("method", ["list_users_page", "count_users_filtered"]) -async def test_whitelist_timeout_cancels_account_queries(ctx, monkeypatch, method): - admin = await make_admin(ctx) - cancelled = asyncio.Event() - async def stalled(*args, **kwargs): - try: - await asyncio.Event().wait() - finally: - cancelled.set() - monkeypatch.setattr(ctx.provider, method, stalled) - monkeypatch.setattr(auth, "_USER_LIST_TIMEOUT_SECONDS", 0.05) - response = await asyncio.wait_for(ctx.client.get("/api/v1/auth/users", headers=bearer(admin), params={"limit": 20, "include_counts": "false"}), 5) - assert response.status_code == 503 - assert "超时" in response.json()["detail"] - assert cancelled.is_set() - - -@pytest.mark.asyncio -async def test_whitelist_paged_is_admin_only(ctx): - user = await ctx.provider.create_user("normal-whitelist@cm.com", password=PASSWORD) - params = {"limit": 20, "include_counts": "false"} - assert (await ctx.client.get("/api/v1/auth/users", headers=bearer(user), params=params)).status_code == 403 - assert (await ctx.client.get("/api/v1/auth/users", params=params)).status_code == 401 - - -@pytest.mark.asyncio -async def test_whitelist_loads_when_resource_stores_unavailable(ctx): - admin = await make_admin(ctx) - del ctx.app.state.skill_store - del ctx.app.state.agent_store - response = await ctx.client.get("/api/v1/auth/users", headers=bearer(admin), params={"limit": 20, "include_counts": "false"}) - assert response.status_code == 200 - assert response.json()["total"] == 1 - # The full statistics endpoint still requires the resource stores. - assert (await ctx.client.get("/api/v1/auth/users", headers=bearer(admin))).status_code == 503 diff --git a/offline-backend-20260512/backend/tests/test_admin_analytics_removed.py b/offline-backend-20260512/backend/tests/test_admin_analytics_removed.py deleted file mode 100644 index 73ee38f6..00000000 --- a/offline-backend-20260512/backend/tests/test_admin_analytics_removed.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Leaderboard reads must not expose the removed per-user analytics queries.""" - -from types import SimpleNamespace - -import pytest -from fastapi import FastAPI -from httpx import ASGITransport, AsyncClient - -from app.gateway.routers import admin_users - - -def _app() -> FastAPI: - app = FastAPI() - app.include_router(admin_users.router) - return app - - -@pytest.mark.asyncio -async def test_removed_analytics_url_never_opens_a_database_session(monkeypatch): - def unexpected_session(): - pytest.fail("The retired analytics endpoint must not touch the database") - - monkeypatch.setattr(admin_users, "get_session_factory", unexpected_session) - app = _app() - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - response = await client.get("/api/admin/users/sample-user/analytics?days=7") - - assert response.status_code == 404 - assert not any(path.endswith("/analytics") for path in app.openapi()["paths"]) - - -@pytest.mark.asyncio -async def test_leaderboard_still_returns_aggregate_snapshot(monkeypatch): - async def current_admin(_request): - return SimpleNamespace(system_role="admin") - - async def snapshot(_factory, _settings, since, until, **_kwargs): - return admin_users.AdminLeaderboardResponse( - range_days=7, - since=since.isoformat(), - until=until.isoformat(), - overview=admin_users.AdminLeaderboardOverview(total_users=2, total_runs=12), - ) - - monkeypatch.setattr(admin_users, "get_current_user_from_request", current_admin) - monkeypatch.setattr(admin_users, "get_session_factory", lambda: object()) - monkeypatch.setattr(admin_users, "load_system_settings", lambda: SimpleNamespace(leaderboard=object())) - monkeypatch.setattr(admin_users, "_load_or_schedule_daily_leaderboard", snapshot) - async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client: - response = await client.get("/api/admin/users/leaderboard?days=7") - - assert response.status_code == 200 - assert response.json()["overview"]["total_runs"] == 12 - - -@pytest.mark.asyncio -async def test_leaderboard_keeps_admin_authorization(monkeypatch): - async def ordinary_user(_request): - return SimpleNamespace(system_role="user") - - def unexpected_session(): - pytest.fail("Authorization must precede database access") - - monkeypatch.setattr(admin_users, "get_current_user_from_request", ordinary_user) - monkeypatch.setattr(admin_users, "get_session_factory", unexpected_session) - async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client: - response = await client.get("/api/admin/users/leaderboard?days=7") - - assert response.status_code == 403 diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py index 36b8acb9..850f24fb 100644 --- a/offline-backend-20260512/backend/tests/test_agents_backend.py +++ b/offline-backend-20260512/backend/tests/test_agents_backend.py @@ -24,7 +24,7 @@ from app.gateway.routers.agents import ( ) from app.gateway.services import build_run_config from deerflow.config import paths as paths_module -from deerflow.config.agents_config import AgentConfig, normalize_agent_chat_link +from deerflow.config.agents_config import AgentConfig from deerflow.persistence.agents import AgentRepository, MemoryAgentStore from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow from deerflow.persistence.base import Base @@ -690,21 +690,9 @@ def test_write_agent_config_persists_skills_into_config_yaml(isolated_paths) -> assert raw["description"] == "desc" assert raw["skills"] == ["s1", "s2"] assert raw["model"] == "gpt-x" - assert raw["model_prompt_suffix_enabled"] is True assert raw["tool_groups"] == ["tg-a"] -def test_write_agent_config_persists_prompt_suffix_switch(isolated_paths) -> None: - _write_agent_config( - "agent-suffix-off", - name="助手", - description="", - model_prompt_suffix_enabled=False, - ) - - assert _load_config_extras("agent-suffix-off")["model_prompt_suffix_enabled"] is False - - def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> None: agent_id = "agent-002" _write_agent_config( @@ -720,13 +708,11 @@ def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> No assert extras == { "skills": ["s1"], "model": None, - "model_prompt_suffix_enabled": True, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None, - "chat_link": None, } @@ -735,13 +721,11 @@ def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths assert extras == { "skills": None, "model": None, - "model_prompt_suffix_enabled": True, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None, - "chat_link": None, } @@ -811,56 +795,6 @@ def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None: assert "skills" not in raw assert "model" not in raw assert "tool_groups" not in raw - assert "chat_link" not in raw - - -def test_write_agent_config_persists_chat_link_and_drops_blank(isolated_paths) -> None: - agent_id = "agent-chat-link" - link = { - "url": "https://example.test/desk", - "open_mode": "self", - "route_params": [ - {"key": "userName", "paramType": "login", "source": "userName"}, - {"key": "theme", "paramType": "theme", "themeValue": "red=red;dark-blue=dark"}, - ], - } - _write_agent_config(agent_id, name="助手", description="", chat_link=link) - - extras = _load_config_extras(agent_id) - assert extras["chat_link"]["url"] == "https://example.test/desk" - assert extras["chat_link"]["open_mode"] == "self" - assert extras["chat_link"]["route_params"][0]["key"] == "userName" - assert extras["chat_link"]["route_params"][1]["paramType"] == "theme" - - _write_agent_config(agent_id, name="助手", description="", chat_link=None) - raw = yaml.safe_load((paths_module.get_paths().agent_dir(agent_id) / "config.yaml").read_text(encoding="utf-8")) - assert "chat_link" not in raw - assert _load_config_extras(agent_id)["chat_link"] is None - - -def test_normalize_agent_chat_link_rejects_unsafe_urls() -> None: - assert normalize_agent_chat_link({"url": ""}) is None - assert normalize_agent_chat_link({"url": "javascript:alert(1)"}, strict=False) is None - with pytest.raises(ValueError): - normalize_agent_chat_link({"url": "javascript:alert(1)"}, strict=True) - kept = normalize_agent_chat_link({"url": "/page/workspace/agents", "open_mode": "blank"}) - assert kept is not None - assert kept["url"] == "/page/workspace/agents" - loaded = AgentConfig(name="x", chat_link={"url": "javascript:alert(1)"}) - assert loaded.chat_link is None - - -@pytest.mark.asyncio -async def test_backfill_listing_cache_mirrors_chat_link() -> None: - store = MemoryAgentStore() - link = {"url": "https://example.test/a", "open_mode": "blank", "route_params": []} - cfg = AgentConfig(id="a1", name="Agent", chat_link=link) - await _backfill_listing_cache(store, [("a1", cfg)]) - extras = await store.get_extras_for(["a1"]) - assert extras["a1"]["chat_link"]["url"] == "https://example.test/a" - - await _backfill_listing_cache(store, [("a1", cfg)]) - assert (await store.get_extras_for(["a1"]))["a1"]["chat_link"]["url"] == "https://example.test/a" @pytest.mark.asyncio diff --git a/offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py b/offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py index c69efe63..6899829a 100644 --- a/offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py +++ b/offline-backend-20260512/backend/tests/test_ai_writing_knowledge_base.py @@ -110,17 +110,6 @@ def _state(material_source="knowledge_base"): } -def _patch_whitelist(monkeypatch, skills): - """把素材收集专家(ai-writing-researcher)的 skills 白名单打成固定值。""" - import deerflow.config.agents_config as agents_config_module - - monkeypatch.setattr( - agents_config_module, - "load_agent_config", - lambda _agent_id: SimpleNamespace(skills=list(skills)), - ) - - def test_general_skill_candidates_do_not_resurrect_explicitly_offline_knowledge_search( tmp_path, monkeypatch, @@ -131,7 +120,6 @@ def test_general_skill_candidates_do_not_resurrect_explicitly_offline_knowledge_ encoding="utf-8", ) monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) - _patch_whitelist(monkeypatch, ["knowledge-base-search", "other-search"]) other = SimpleNamespace(name="other-search", description="other") @@ -152,7 +140,6 @@ def test_general_skill_candidates_keep_missing_catalog_fallback(tmp_path, monkey extensions_path = tmp_path / "extensions_config.json" extensions_path.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8") monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) - _patch_whitelist(monkeypatch, []) class _Storage: def load_skills(self, *, enabled_only=False): @@ -167,67 +154,6 @@ def test_general_skill_candidates_keep_missing_catalog_fallback(tmp_path, monkey assert [skill.name for skill in R._fallback_candidate_skills(set())] == [R.KNOWLEDGE_SEARCH_SKILL_NAME] -def test_general_skill_candidates_limited_to_researcher_whitelist(tmp_path, monkeypatch) -> None: - """300+ 技能部署:候选池只取白名单里的启用技能,其余一概不进池。""" - extensions_path = tmp_path / "extensions_config.json" - extensions_path.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8") - monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) - _patch_whitelist(monkeypatch, ["custom-a", "knowledge-base-search", "custom-b", "missing-skill"]) - - def _skill(name: str): - return SimpleNamespace(name=name, description=f"desc-{name}") - - catalog = [_skill(f"bulk-{i}") for i in range(300)] + [ - _skill("custom-b"), - _skill("custom-a"), - _skill(R.KNOWLEDGE_SEARCH_SKILL_NAME), - ] - - class _Storage: - def load_skills(self, *, enabled_only=False): - return catalog - - import deerflow.skills.storage as storage_module - - monkeypatch.setattr(storage_module, "get_or_new_skill_storage", lambda: _Storage()) - - # 白名单顺序保留,但内置知识库检索始终提到最前;目录里不存在的 - # missing-skill 与 300 个 bulk-* 全部排除。 - assert [skill.name for skill in R._load_candidate_skills()] == [ - R.KNOWLEDGE_SEARCH_SKILL_NAME, - "custom-a", - "custom-b", - ] - - -def test_fallback_candidates_stay_inside_researcher_whitelist(tmp_path, monkeypatch) -> None: - """容错兜底也不回到全量技能目录:仍限定在白名单内。""" - extensions_path = tmp_path / "extensions_config.json" - extensions_path.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8") - monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) - _patch_whitelist(monkeypatch, ["knowledge-base-search", "custom-a"]) - - def _skill(name: str): - return SimpleNamespace(name=name, description=f"desc-{name}") - - class _Storage: - def load_skills(self, *, enabled_only=False): - return [_skill("custom-a"), _skill("bulk-other"), _skill(R.KNOWLEDGE_SEARCH_SKILL_NAME)] - - import deerflow.skills.storage as storage_module - - monkeypatch.setattr(storage_module, "get_or_new_skill_storage", lambda: _Storage()) - - assert [skill.name for skill in R._fallback_candidate_skills(set())] == [ - R.KNOWLEDGE_SEARCH_SKILL_NAME, - "custom-a", - ] - # 已试过的技能不再进入兜底池 - assert [skill.name for skill in R._fallback_candidate_skills({"custom-a"})] == [ - R.KNOWLEDGE_SEARCH_SKILL_NAME, - ] - - @pytest.mark.asyncio async def test_knowledge_base_hits_intranet_directly(monkeypatch): _patch(monkeypatch) diff --git a/offline-backend-20260512/backend/tests/test_api_performance_controls.py b/offline-backend-20260512/backend/tests/test_api_performance_controls.py deleted file mode 100644 index a01e768c..00000000 --- a/offline-backend-20260512/backend/tests/test_api_performance_controls.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Integration coverage for persisted, administrator-owned performance controls.""" - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import httpx -import pytest -import pytest_asyncio -from fastapi import FastAPI, HTTPException -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from test_api_read_cache import FakeRedis, _config - -from app.gateway import api_read_cache as cache -from app.gateway.deps import get_config -from app.gateway.routers import browser_context, system_settings -from deerflow.config.browser_context_config import BrowserContextConfig -from deerflow.persistence.api_read_cache_settings.model import ApiReadCacheSettingRow - - -@pytest_asyncio.fixture -async def controls(monkeypatch, tmp_path): - engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'controls.db'}") - async with engine.begin() as connection: - await connection.run_sync(ApiReadCacheSettingRow.__table__.create) - factory = async_sessionmaker(engine, expire_on_commit=False) - monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: factory) - monkeypatch.setattr(cache, "_api_read_cache_settings_cache", None) - redis = FakeRedis() - monkeypatch.setattr(cache, "_client", lambda *_args, **_kwargs: (redis, "test:controls")) - config = _config() - config.browser_context = BrowserContextConfig(enabled=True) - auth = AsyncMock(return_value=SimpleNamespace(id="admin", system_role="admin")) - monkeypatch.setattr(system_settings, "get_current_user_from_request", auth) - app = FastAPI() - app.state.config = config - app.dependency_overrides[get_config] = lambda: config - app.include_router(system_settings.router) - app.include_router(browser_context.router) - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: - yield client, config, redis, auth - await engine.dispose() - - -@pytest.mark.asyncio -async def test_partial_updates_persist_without_resetting_other_switches(controls, monkeypatch): - client, config, _, _ = controls - endpoint = "/api/system-settings/api-read-cache" - response = await client.put(endpoint, json={"enabled": True, "ttl_seconds": 777, "collection_overrides": {"agents": False}}) - assert response.status_code == 200 - assert {row["id"]: row["enabled"] for row in response.json()["collections"]}["agents"] is False - response = await client.put(endpoint, json={"browser_status_enabled": False, "collection_overrides": {"skills": False}}) - assert response.status_code == 200 - monkeypatch.setattr(cache, "_api_read_cache_settings_cache", None) - result = (await client.get(endpoint)).json() - assert result["enabled"] is True and result["ttl_seconds"] == 777 - assert result["browser_status_enabled"] is False - rows = {row["id"]: row for row in result["collections"]} - assert not rows["agents"]["enabled"] and not rows["skills"]["enabled"] - assert rows["light_apps"]["enabled"] - assert all(row["paths"] and row["scope"] and row["ttl_seconds"] == 777 for row in rows.values()) - assert await cache.is_browser_status_enabled(config) is False - - -@pytest.mark.asyncio -async def test_per_collection_disable_bypasses_reads_and_writes(controls): - client, config, redis, _ = controls - await cache.set_cached_json(config, "agents", "u:1", {"old": True}) - await client.put("/api/system-settings/api-read-cache", json={"collection_overrides": {"agents": False}}) - snapshot = dict(redis.values) - assert await cache.get_cached_json(config, "agents", "u:1", version="0") is None - assert await cache.get_cache_version(config, "agents") is None - assert await cache.wait_for_cached_json_or_claim_fill(config, "agents", "u:1", version="0") == (None, None) - await cache.set_cached_json(config, "agents", "u:1", {"new": True}, expected_version="0") - assert redis.values == snapshot - await cache.set_cached_json(config, "skills", "u:1", {"enabled": True}) - assert await cache.get_cached_json(config, "skills", "u:1") == {"enabled": True} - await client.put("/api/system-settings/api-read-cache", json={"collection_overrides": {"agents": True}}) - assert await cache.get_cached_json(config, "agents", "u:1") is None - - -@pytest.mark.asyncio -async def test_browser_access_gate_precedes_warm_cache_and_recovers(controls): - client, _, redis, _ = controls - assert (await client.get("/api/browser-context/status")).status_code == 200 - assert any(":browser_status:v" in key for key in redis.values) - response = await client.put("/api/system-settings/api-read-cache", json={"browser_status_enabled": False}) - assert response.status_code == 200 - response = await client.get("/api/browser-context/status") - assert response.status_code == 403 - assert response.json()["detail"]["code"] == "browser_status_disabled" - assert response.headers["Retry-After"] == "300" - await client.put("/api/system-settings/api-read-cache", json={"browser_status_enabled": True}) - assert (await client.get("/api/browser-context/status")).json()["enabled"] is True - - -@pytest.mark.asyncio -@pytest.mark.parametrize("payload", [{"ttl_seconds": 0}, {"ttl_seconds": 3601}, {"collection_overrides": {"typo": True}}]) -async def test_invalid_settings_are_rejected_without_persisting(controls, payload): - client, _, _, _ = controls - assert (await client.put("/api/system-settings/api-read-cache", json=payload)).status_code == 422 - assert (await client.get("/api/system-settings/api-read-cache")).json()["ttl_seconds"] == 300 - - -@pytest.mark.asyncio -@pytest.mark.parametrize("role,status", [("user", 403), ("anonymous", 401)]) -async def test_only_admin_can_read_or_update_controls(controls, role, status): - client, _, _, auth = controls - if role == "anonymous": - auth.side_effect = HTTPException(status_code=401, detail="Login required") - else: - auth.return_value = SimpleNamespace(system_role=role) - assert (await client.get("/api/system-settings/api-read-cache")).status_code == status - assert (await client.put("/api/system-settings/api-read-cache", json={"browser_status_enabled": False})).status_code == status - - -@pytest.mark.asyncio -async def test_redis_failure_falls_back_and_global_off_preserves_individual_choices(controls, monkeypatch): - client, config, redis, _ = controls - await client.put("/api/system-settings/api-read-cache", json={"enabled": False, "collection_overrides": {"agents": False}}) - assert await cache.get_cache_version(config, "skills") is None - response = await client.put("/api/system-settings/api-read-cache", json={"enabled": True}) - assert next(row for row in response.json()["collections"] if row["id"] == "agents")["enabled"] is False - monkeypatch.setattr(redis, "get", AsyncMock(side_effect=ConnectionError("offline"))) - assert await cache.get_cached_json(config, "skills", "u:1") is None - await cache.set_cached_json(config, "skills", "u:1", {"ok": True}) - assert (await client.get("/api/browser-context/status")).status_code == 200 diff --git a/offline-backend-20260512/backend/tests/test_api_performance_edge_cases.py b/offline-backend-20260512/backend/tests/test_api_performance_edge_cases.py deleted file mode 100644 index aa22275d..00000000 --- a/offline-backend-20260512/backend/tests/test_api_performance_edge_cases.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Concurrent changes must not lose controls or serve invalidated catalogue data.""" - -import asyncio -import importlib.util -from pathlib import Path - -import pytest -import pytest_asyncio -from alembic.migration import MigrationContext -from alembic.operations import Operations -from sqlalchemy import create_engine, text -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from test_api_read_cache import FakeRedis, _config - -from app.gateway import api_read_cache as cache -from deerflow.persistence.api_read_cache_settings.model import ApiReadCacheSettingRow - - -@pytest_asyncio.fixture -async def settings_database(monkeypatch, tmp_path): - engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'edge-controls.db'}") - async with engine.begin() as connection: - await connection.run_sync(ApiReadCacheSettingRow.__table__.create) - - class SlowReadSession(AsyncSession): - async def get(self, *args, **kwargs): - result = await super().get(*args, **kwargs) - # Simulate real database latency between reading settings and writing - # an independently edited interface toggle from another admin tab. - await asyncio.sleep(0.025) - return result - - factory = async_sessionmaker(engine, class_=SlowReadSession, expire_on_commit=False) - monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: factory) - monkeypatch.setattr(cache, "_api_read_cache_settings_cache", None) - yield factory - await engine.dispose() - - -@pytest.mark.asyncio -async def test_concurrent_first_saves_keep_both_individual_choices(settings_database): - config = _config() - results = await asyncio.gather( - cache.save_api_read_cache_settings(None, config=config, collection_overrides={"agents": False}), - cache.save_api_read_cache_settings(None, config=config, collection_overrides={"skills": False}), - return_exceptions=True, - ) - assert not [result for result in results if isinstance(result, Exception)] - async with settings_database() as session: - row = await session.get(ApiReadCacheSettingRow, "default") - assert row.collection_overrides == {"agents": False, "skills": False} - - -@pytest.mark.asyncio -async def test_concurrent_updates_do_not_replace_other_interface_choices(settings_database): - config = _config() - await cache.save_api_read_cache_settings(True, 777, config=config) - await asyncio.gather( - cache.save_api_read_cache_settings(None, config=config, collection_overrides={"agents": False}), - cache.save_api_read_cache_settings(None, config=config, collection_overrides={"skills": False}), - ) - async with settings_database() as session: - row = await session.get(ApiReadCacheSettingRow, "default") - assert row.collection_overrides == {"agents": False, "skills": False} - assert row.enabled is True - assert row.ttl_seconds == 777 - - -@pytest.mark.asyncio -@pytest.mark.parametrize("change", ["disable", "invalidate"]) -async def test_waiting_requests_reject_fill_changed_before_wakeup(monkeypatch, change): - config = _config() - redis = FakeRedis() - monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: None) - monkeypatch.setattr(cache, "_api_read_cache_settings_cache", None) - monkeypatch.setattr(cache, "_fill_events", {}) - monkeypatch.setattr(cache, "_fill_local_payloads", {}) - monkeypatch.setattr(cache, "_client", lambda *_args, **_kwargs: (redis, "test:edge")) - version = await cache.get_cache_version(config, "agents") - _, owner = await cache.wait_for_cached_json_or_claim_fill(config, "agents", "u1", version=version) - assert owner - waiter = asyncio.create_task(cache.wait_for_cached_json_or_claim_fill(config, "agents", "u1", version=version, wait_ms=1000)) - await asyncio.sleep(0.01) - await cache.set_cached_json(config, "agents", "u1", {"old": True}, expected_version=version) - if change == "disable": - cache.set_api_read_cache_settings_local(True, 300, collection_overrides={"agents": False}) - else: - await cache.invalidate_cached_collection(config, "agents", force=True) - await cache.release_cache_fill(config, "agents", "u1", owner) - value, new_owner = await waiter - if new_owner: - await cache.release_cache_fill(config, "agents", "u1", new_owner) - assert value is None, "A waiting request must not use the old local payload after controls/data changed" - - -def test_controls_migration_preserves_existing_ttl_and_defaults_status_to_enabled(tmp_path): - migration_path = Path(__file__).parents[1] / "packages/harness/deerflow/persistence/migrations/versions/20261029_02_api_performance_controls.py" - spec = importlib.util.spec_from_file_location("performance_controls_migration", migration_path) - migration = importlib.util.module_from_spec(spec) - spec.loader.exec_module(migration) - engine = create_engine(f"sqlite:///{tmp_path / 'migration.db'}") - with engine.begin() as connection: - connection.execute(text("CREATE TABLE api_read_cache_settings (key VARCHAR(32) PRIMARY KEY, enabled BOOLEAN NOT NULL, ttl_seconds INTEGER NOT NULL)")) - connection.execute(text("INSERT INTO api_read_cache_settings VALUES ('default', 1, 777)")) - with Operations.context(MigrationContext.configure(connection)): - migration.upgrade() - migration.upgrade() # Existing/create_all schemas are safe to revisit. - row = connection.execute(text("SELECT * FROM api_read_cache_settings")).mappings().one() - assert row["ttl_seconds"] == 777 - assert bool(row["enabled"]) is True - assert bool(row["browser_status_enabled"]) is True - assert row["collection_overrides"] is None - assert row["cache_epoch"] == "0" - engine.dispose() - - -@pytest.mark.asyncio -async def test_saved_epoch_survives_redis_outage_and_process_restart(settings_database, monkeypatch): - config = _config() - - class RecoverableRedis(FakeRedis): - online = True - - async def get(self, key): - if not self.online: - raise ConnectionError("cache offline") - return await super().get(key) - - async def incr(self, key): - if not self.online: - raise ConnectionError("cache offline") - return await super().incr(key) - - redis = RecoverableRedis() - # Keep the real _client prefix construction, while using an in-memory - # Redis connection shared by the simulated old/new Gateway processes. - monkeypatch.setattr(cache, "_clients", {}) - monkeypatch.setattr("redis.asyncio.Redis.from_url", lambda *args, **kwargs: redis) - await cache.save_api_read_cache_settings(True, config=config) - await cache.set_cached_json(config, "agents", "u1", {"old": True}) - assert await cache.get_cached_json(config, "agents", "u1") == {"old": True} - old_keys = set(redis.values) - old_version = await cache.get_cache_version(config, "agents") - redis.online = False - await cache.save_api_read_cache_settings(False, config=config) - await cache.invalidate_cached_collection(config, "agents", force=True) - await cache.save_api_read_cache_settings(True, config=config) - await cache.invalidate_cached_collection(config, "agents", force=True) - - # Restart clears every process-local control/client; DB and Redis survive. - monkeypatch.setattr(cache, "_api_read_cache_settings_cache", None) - monkeypatch.setattr(cache, "_clients", {}) - redis.online = True - assert await cache.get_cached_json(config, "agents", "u1") is None - assert old_keys.issubset(redis.values), "Old keys still exist but must be unreachable" - assert await cache.get_cache_version(config, "agents") != old_version - await cache.set_cached_json(config, "agents", "u1", {"fresh": True}) - assert await cache.get_cached_json(config, "agents", "u1") == {"fresh": True} - - -@pytest.mark.asyncio -async def test_save_fences_fills_started_in_the_previous_epoch(settings_database, monkeypatch): - config = _config() - redis = FakeRedis() - monkeypatch.setattr(cache, "_clients", {}) - monkeypatch.setattr("redis.asyncio.Redis.from_url", lambda *args, **kwargs: redis) - await cache.save_api_read_cache_settings(True, config=config) - version = await cache.get_cache_version(config, "agents") - _, owner = await cache.wait_for_cached_json_or_claim_fill(config, "agents", "u1", version=version) - waiter = asyncio.create_task(cache.wait_for_cached_json_or_claim_fill(config, "agents", "u1", version=version, wait_ms=1000)) - await asyncio.sleep(0.01) - await cache.save_api_read_cache_settings(True, config=config, collection_overrides={"skills": False}) - await cache.set_cached_json(config, "agents", "u1", {"stale": True}, expected_version=version) - await cache.release_cache_fill(config, "agents", "u1", owner) - value, token = await waiter - assert (value, token) == (None, None) - assert await cache.get_cached_json(config, "agents", "u1") is None - - -@pytest.mark.asyncio -async def test_old_epoch_owner_release_does_not_wake_new_epoch_waiters(settings_database, monkeypatch): - config = _config() - redis = FakeRedis() - monkeypatch.setattr(cache, "_clients", {}) - monkeypatch.setattr("redis.asyncio.Redis.from_url", lambda *args, **kwargs: redis) - await cache.save_api_read_cache_settings(True, config=config) - old_version = await cache.get_cache_version(config, "agents") - _, old_owner = await cache.wait_for_cached_json_or_claim_fill(config, "agents", "u1", version=old_version) - await cache.save_api_read_cache_settings(True, config=config) - version = await cache.get_cache_version(config, "agents") - _, owner = await cache.wait_for_cached_json_or_claim_fill(config, "agents", "u1", version=version) - waiter = asyncio.create_task( - cache.wait_for_cached_json_or_claim_fill(config, "agents", "u1", version=version, wait_ms=1000) - ) - await asyncio.sleep(0.01) - await cache.release_cache_fill(config, "agents", "u1", old_owner) - await asyncio.sleep(0.01) - assert not waiter.done() - await cache.set_cached_json(config, "agents", "u1", {"fresh": True}, expected_version=version) - await cache.release_cache_fill(config, "agents", "u1", owner) - assert await waiter == ({"fresh": True}, None) diff --git a/offline-backend-20260512/backend/tests/test_api_read_cache.py b/offline-backend-20260512/backend/tests/test_api_read_cache.py index 08d84592..3254ce51 100644 --- a/offline-backend-20260512/backend/tests/test_api_read_cache.py +++ b/offline-backend-20260512/backend/tests/test_api_read_cache.py @@ -12,7 +12,6 @@ from app.gateway import api_read_cache class FakeRedis: def __init__(self) -> None: self.values: dict[str, bytes] = {} - self.expirations: dict[str, int] = {} async def get(self, key: str) -> bytes | None: return self.values.get(key) @@ -22,7 +21,6 @@ class FakeRedis: if nx and key in self.values: return None self.values[key] = value if isinstance(value, bytes) else value.encode("utf-8") - self.expirations[key] = ex return True async def incr(self, key: str) -> int: @@ -44,7 +42,7 @@ def _config(*, enabled: bool = True) -> SimpleNamespace: url=None, password=None, namespace="test:homepage", - ttl_seconds=300, + ttl_seconds=60, max_value_bytes=524288, socket_connect_timeout_ms=50, socket_timeout_ms=50, @@ -62,18 +60,18 @@ def _config(*, enabled: bool = True) -> SimpleNamespace: ) -def test_redis_cache_ttl_defaults_to_five_minutes_and_stays_bounded() -> None: - assert api_read_cache._cache_ttl_seconds(SimpleNamespace(api_read_cache=SimpleNamespace())) == 300 +def test_redis_cache_ttl_defaults_to_one_minute_and_stays_bounded() -> None: + assert api_read_cache._cache_ttl_seconds(SimpleNamespace(api_read_cache=SimpleNamespace())) == 60 assert api_read_cache._cache_ttl_seconds( - SimpleNamespace(api_read_cache=SimpleNamespace(ttl_seconds=9999)) - ) == 3600 + SimpleNamespace(api_read_cache=SimpleNamespace(ttl_seconds=999)) + ) == 300 @pytest.mark.asyncio async def test_cache_round_trip_and_generation_invalidation(monkeypatch: pytest.MonkeyPatch) -> None: redis = FakeRedis() monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: None) - monkeypatch.setattr(api_read_cache, "_api_read_cache_settings_cache", None) + monkeypatch.setattr(api_read_cache, "_enabled_cache", None) monkeypatch.setattr(api_read_cache, "_client", lambda _config, **_kwargs: (redis, "test:cache")) config = _config() @@ -90,7 +88,7 @@ async def test_cache_round_trip_and_generation_invalidation(monkeypatch: pytest. async def test_concurrent_cache_misses_wait_for_single_fill(monkeypatch: pytest.MonkeyPatch) -> None: redis = FakeRedis() monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: None) - monkeypatch.setattr(api_read_cache, "_api_read_cache_settings_cache", None) + monkeypatch.setattr(api_read_cache, "_enabled_cache", None) monkeypatch.setattr(api_read_cache, "_client", lambda _config, **_kwargs: (redis, "test:cache")) config = _config() _, owner_token = await api_read_cache.wait_for_cached_json_or_claim_fill( @@ -129,7 +127,7 @@ async def test_concurrent_cache_misses_wait_for_single_fill(monkeypatch: pytest. async def test_concurrent_http_payload_misses_wait_for_single_fill(monkeypatch: pytest.MonkeyPatch) -> None: redis = FakeRedis() monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: None) - monkeypatch.setattr(api_read_cache, "_api_read_cache_settings_cache", None) + monkeypatch.setattr(api_read_cache, "_enabled_cache", None) monkeypatch.setattr(api_read_cache, "_client", lambda _config, **_kwargs: (redis, "test:cache")) config = _config() _, owner_token = await api_read_cache.wait_for_cached_json_payload_or_claim_fill( @@ -213,7 +211,7 @@ def test_separate_cache_redis_does_not_inherit_an_unrelated_password() -> None: @pytest.mark.asyncio -async def test_admin_cache_switch_and_ttl_are_persisted_and_survive_process_refresh(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_admin_cache_switch_is_persisted_in_mysql_and_survives_process_refresh(monkeypatch: pytest.MonkeyPatch) -> None: from deerflow.persistence.api_read_cache_settings.model import ApiReadCacheSettingRow class FakeSession: @@ -227,7 +225,7 @@ async def test_admin_cache_switch_and_ttl_are_persisted_and_survive_process_refr async def __aexit__(self, *_args): return False - async def get(self, _model, key: str, **_kwargs): + async def get(self, _model, key: str): assert key == "default" return self.row @@ -240,21 +238,19 @@ async def test_admin_cache_switch_and_ttl_are_persisted_and_survive_process_refr session = FakeSession() monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: lambda: session) - monkeypatch.setattr(api_read_cache, "_api_read_cache_settings_cache", None) + monkeypatch.setattr(api_read_cache, "_enabled_cache", None) config = _config(enabled=False) - assert await api_read_cache.get_api_read_cache_settings(config, force_refresh=True) == (False, 300) - await api_read_cache.save_api_read_cache_settings(True, 900, config=config) + assert await api_read_cache.is_api_read_cache_enabled(config, force_refresh=True) is False + await api_read_cache.save_api_read_cache_enabled(True) assert session.row is not None and session.row.enabled is True - assert session.row.ttl_seconds == 900 - assert await api_read_cache.get_api_read_cache_settings(config) == (True, 900) + assert await api_read_cache.is_api_read_cache_enabled(config) is True # A new process has no in-memory value and reads the persisted MySQL row. - monkeypatch.setattr(api_read_cache, "_api_read_cache_settings_cache", None) - assert await api_read_cache.get_api_read_cache_settings(config, force_refresh=True) == (True, 900) + monkeypatch.setattr(api_read_cache, "_enabled_cache", None) + assert await api_read_cache.is_api_read_cache_enabled(config, force_refresh=True) is True await api_read_cache.save_api_read_cache_enabled(False) assert session.row.enabled is False - assert session.row.ttl_seconds == 900 @pytest.mark.asyncio @@ -276,7 +272,7 @@ async def test_cache_toggle_mysql_read_is_singleflight_under_concurrency(monkeyp session = FakeSession() monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: lambda: session) - monkeypatch.setattr(api_read_cache, "_api_read_cache_settings_cache", None) + monkeypatch.setattr(api_read_cache, "_enabled_cache", None) config = _config(enabled=False) results = await asyncio.gather( @@ -287,82 +283,6 @@ async def test_cache_toggle_mysql_read_is_singleflight_under_concurrency(monkeyp assert session.get_calls == 1 -@pytest.mark.asyncio -async def test_configured_mysql_ttl_is_used_for_agent_and_skill_cache_writes(monkeypatch: pytest.MonkeyPatch) -> None: - redis = FakeRedis() - - class FakeSession: - async def __aenter__(self): - return self - - async def __aexit__(self, *_args): - return False - - async def get(self, _model, _key): - return SimpleNamespace(enabled=True, ttl_seconds=900) - - monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: lambda: FakeSession()) - monkeypatch.setattr(api_read_cache, "_api_read_cache_settings_cache", None) - monkeypatch.setattr(api_read_cache, "_client", lambda _config, **_kwargs: (redis, "test:cache")) - config = _config() - - for collection in ("agents", "skills"): - await api_read_cache.set_cached_json(config, collection, "user:u1", {"items": []}) - expirations = [ - expiry - for key, expiry in redis.expirations.items() - if f":{collection}:v0:" in key - ] - assert expirations == [900] - - -def test_existing_api_cache_setting_receives_five_minute_ttl(tmp_path: Path) -> None: - import sqlalchemy as sa - from alembic import command - from alembic.config import Config as AlembicConfig - - database = tmp_path / "api-read-cache.db" - engine = sa.create_engine(f"sqlite:///{database.resolve().as_posix()}") - with engine.begin() as connection: - connection.exec_driver_sql( - "CREATE TABLE api_read_cache_settings (" - "`key` VARCHAR(32) PRIMARY KEY, enabled BOOLEAN NOT NULL, updated_at DATETIME NOT NULL)" - ) - connection.exec_driver_sql( - "INSERT INTO api_read_cache_settings (`key`, enabled, updated_at) " - "VALUES ('default', 1, CURRENT_TIMESTAMP)" - ) - connection.exec_driver_sql( - "CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL PRIMARY KEY)" - ) - connection.exec_driver_sql( - "INSERT INTO alembic_version (version_num) VALUES ('20261026_02')" - ) - engine.dispose() - - migrations = Path(__file__).parents[1] / "packages/harness/deerflow/persistence/migrations" - config = AlembicConfig(str(migrations / "alembic.ini")) - config.set_main_option( - "sqlalchemy.url", - f"sqlite+aiosqlite:///{database.resolve().as_posix()}", - ) - command.upgrade(config, "head") - - engine = sa.create_engine(f"sqlite:///{database.resolve().as_posix()}") - with engine.connect() as connection: - setting = connection.execute( - sa.text("SELECT enabled, ttl_seconds, collection_overrides, browser_status_enabled, cache_epoch FROM api_read_cache_settings WHERE `key`='default'") - ).one() - revision = connection.execute(sa.text("SELECT version_num FROM alembic_version")).scalar_one() - engine.dispose() - assert bool(setting.enabled) is True - assert setting.ttl_seconds == 300 - assert setting.collection_overrides is None - assert bool(setting.browser_status_enabled) is True - assert setting.cache_epoch == "0" - assert revision == "20261102_02" - - @pytest.mark.asyncio async def test_skill_catalog_projection_uses_compact_rows_without_long_fields( tmp_path: Path, diff --git a/offline-backend-20260512/backend/tests/test_app_identity.py b/offline-backend-20260512/backend/tests/test_app_identity.py deleted file mode 100644 index 9baed35b..00000000 --- a/offline-backend-20260512/backend/tests/test_app_identity.py +++ /dev/null @@ -1,151 +0,0 @@ -from io import BytesIO -from types import SimpleNamespace - -import pytest -from fastapi import HTTPException -from starlette.datastructures import UploadFile - -from app.gateway.routers import app_identity as app_identity_router -from deerflow.config.system_settings import AppIdentitySettings, SystemSettings - -PNG = bytes.fromhex( - "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4" - "890000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082" -) - - -def _store(monkeypatch: pytest.MonkeyPatch, tmp_path): - current = {"settings": SystemSettings()} - - def load(): - return current["settings"] - - def save(settings: SystemSettings) -> SystemSettings: - current["settings"] = settings - return settings - - async def admin_user(_request): - return SimpleNamespace(system_role="admin") - - monkeypatch.setattr(app_identity_router, "runtime_home", lambda: tmp_path) - monkeypatch.setattr("deerflow.config.system_settings.runtime_home", lambda: tmp_path) - monkeypatch.setattr(app_identity_router, "load_system_settings", load) - monkeypatch.setattr(app_identity_router, "save_system_settings", save) - monkeypatch.setattr("deerflow.config.system_settings.load_system_settings", load) - monkeypatch.setattr( - "app.gateway.routers.system_settings.get_optional_user_from_request", - admin_user, - ) - return current - - -def test_new_settings_leave_app_identity_empty() -> None: - assert SystemSettings().app_identity == AppIdentitySettings() - - -@pytest.mark.asyncio -async def test_non_admin_cannot_change_app_identity(monkeypatch: pytest.MonkeyPatch) -> None: - async def ordinary_user(_request): - return SimpleNamespace(system_role="user") - - monkeypatch.setattr( - "app.gateway.routers.system_settings.get_optional_user_from_request", - ordinary_user, - ) - - with pytest.raises(HTTPException) as error: - await app_identity_router.update_app_identity_settings( - SimpleNamespace(), - system_name="新系统", - remove_logo=False, - logo=None, - ) - - assert error.value.status_code == 403 - - -@pytest.mark.asyncio -async def test_admin_saves_name_without_replacing_the_static_logo( - monkeypatch: pytest.MonkeyPatch, - tmp_path, -) -> None: - current = _store(monkeypatch, tmp_path) - - response = await app_identity_router.update_app_identity_settings( - SimpleNamespace(), - system_name=" 研判中心 ", - remove_logo=False, - logo=None, - ) - - assert response.system_name == "研判中心" - assert response.has_logo is False - assert response.logo_url is None - assert current["settings"].app_identity.logo_filename is None - - -@pytest.mark.asyncio -async def test_uploaded_logo_is_served_separately_from_generated_pages( - monkeypatch: pytest.MonkeyPatch, - tmp_path, -) -> None: - current = _store(monkeypatch, tmp_path) - upload = UploadFile(file=BytesIO(PNG), filename="mark.png") - - saved = await app_identity_router.update_app_identity_settings( - SimpleNamespace(), - system_name="研判中心", - remove_logo=False, - logo=upload, - ) - - assert saved.has_logo is True - assert saved.logo_url is not None - assert saved.logo_url.startswith("/api/public/app-identity/logo?v=") - logo_file = tmp_path / "app-identity" / current["settings"].app_identity.logo_filename - assert logo_file.is_file() - assert not (tmp_path / "generated-page-branding").exists() - - served = await app_identity_router.get_app_identity_logo() - assert served.media_type == "image/png" - - -@pytest.mark.asyncio -async def test_invalid_logo_is_rejected(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - _store(monkeypatch, tmp_path) - upload = UploadFile(file=BytesIO(b"not-an-image"), filename="mark.gif") - - with pytest.raises(HTTPException) as error: - await app_identity_router.update_app_identity_settings( - SimpleNamespace(), - system_name="研判中心", - remove_logo=False, - logo=upload, - ) - - assert error.value.status_code == 422 - - -@pytest.mark.asyncio -async def test_clearing_app_identity_removes_only_its_logo( - monkeypatch: pytest.MonkeyPatch, - tmp_path, -) -> None: - current = _store(monkeypatch, tmp_path) - upload = UploadFile(file=BytesIO(PNG), filename="mark.png") - await app_identity_router.update_app_identity_settings( - SimpleNamespace(), - system_name="研判中心", - remove_logo=False, - logo=upload, - ) - logo_file = tmp_path / "app-identity" / current["settings"].app_identity.logo_filename - - cleared = await app_identity_router.delete_app_identity_settings(SimpleNamespace()) - - assert cleared.system_name == "" - assert cleared.has_logo is False - assert logo_file.exists() is False - with pytest.raises(HTTPException) as error: - await app_identity_router.get_app_identity_logo() - assert error.value.status_code == 404 diff --git a/offline-backend-20260512/backend/tests/test_async_agent_entrypoints.py b/offline-backend-20260512/backend/tests/test_async_agent_entrypoints.py deleted file mode 100644 index 749c6cf2..00000000 --- a/offline-backend-20260512/backend/tests/test_async_agent_entrypoints.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Actual async entry points must not block another request's MCP discovery. - -Run each scenario in a child process: the regression blocks the event loop, so -an asyncio timeout alone cannot fail the test or clean up the waiting threads. -""" - -from __future__ import annotations - -import asyncio -import contextvars -import os -import subprocess -import sys -from pathlib import Path -from types import SimpleNamespace - -import pytest - - -@pytest.mark.parametrize("entrypoint", ["open_chat", "open_chat_stream", "workflow"]) -def test_async_entrypoint_coexists_with_cold_mcp_discovery(entrypoint): - backend_root = Path(__file__).resolve().parents[1] - env = dict(os.environ) - env["PYTHONPATH"] = os.pathsep.join(filter(None, (str(backend_root), env.get("PYTHONPATH")))) - result = subprocess.run( - [sys.executable, str(Path(__file__).resolve()), entrypoint], - cwd=backend_root, - env=env, - capture_output=True, - text=True, - timeout=40, - check=False, - ) - assert result.returncode == 0, result.stdout + result.stderr - assert "concurrent MCP initialization completed" in result.stdout - - -async def _exercise_entrypoint(entrypoint: str) -> None: - import httpx - from fastapi import FastAPI - from langchain_core.messages import AIMessage - - from app.gateway.routers import open_chat - from app.gateway.workflow_agent_runner import WorkflowAgentRunner - from deerflow.agents.lead_agent import agent as lead_agent - from deerflow.config import app_config - from deerflow.mcp import cache, tools - from deerflow.runtime.runs.worker import build_agent_with_timing - - owner_loop = asyncio.get_running_loop() - discovery_started = asyncio.Event() - release_discovery = asyncio.Event() - built_thread = contextvars.ContextVar("entrypoint_built_thread", default=None) - discovery_calls = 0 - - async def discover(): - nonlocal discovery_calls - assert asyncio.get_running_loop() is owner_loop - discovery_calls += 1 - discovery_started.set() - await release_discovery.wait() - return ["discovered tool"] - - class Agent: - def __init__(self, thread_id): - self.thread_id = thread_id - - async def ainvoke(self, state, *, config): - assert built_thread.get() == self.thread_id - return {"messages": [AIMessage(content="answer")]} - - async def astream(self, state, *, config, stream_mode): - assert built_thread.get() == self.thread_id - yield "values", {"messages": [AIMessage(content="answer")]} - - def factory(config): - assert cache.get_cached_mcp_tools() == ["discovered tool"] - thread_id = config["configurable"]["thread_id"] - built_thread.set(thread_id) - return Agent(thread_id) - - factory._deerflow_background_build = True - app = FastAPI() - app.include_router(open_chat.router) - - @app.get("/probe") - async def probe(): - return {"ok": True} - - with pytest.MonkeyPatch.context() as patch: - patch.setattr(cache, "_mcp_tools_cache", None) - patch.setattr(cache, "_cache_initialized", False) - patch.setattr(cache, "_config_mtime", None) - patch.setattr(cache, "_initialization_lock", asyncio.Lock()) - patch.setattr(cache, "_get_config_mtime", lambda: 1.0) - patch.setattr(tools, "get_mcp_tools", discover) - patch.setattr(open_chat, "make_lead_agent", factory) - patch.setattr(lead_agent, "make_lead_agent", factory) - patch.setattr( - app_config, - "get_app_config", - lambda: SimpleNamespace(workflows=SimpleNamespace(agent_recursion_limit=100)), - ) - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: - # This is the same background construction path used by run_agent. - first = asyncio.create_task( - build_agent_with_timing(factory, {"configurable": {"thread_id": "normal-chat"}}, None, "normal-chat") - ) - second = None - try: - await asyncio.wait_for(discovery_started.wait(), timeout=5) - if entrypoint == "workflow": - second = asyncio.create_task( - WorkflowAgentRunner(app).run_agent( - run_id="test-run", node_id="test-node", owner_id="test-owner", agent_id="test-agent", prompt="hello" - ) - ) - else: - path = "/api/open/chat/stream" if entrypoint == "open_chat_stream" else "/api/open/chat" - second = asyncio.create_task(client.post(path, json={"message": "hello", "include_files": False})) - - # Let the real HTTP handler / workflow runner enter construction - # while discovery holds its lock and is awaiting network work. - await asyncio.sleep(0.05) - assert not first.done() and not second.done() - for _ in range(5): - response = await asyncio.wait_for(client.get("/probe"), timeout=1) - assert response.json() == {"ok": True} - await asyncio.sleep(0) - finally: - release_discovery.set() - results = await asyncio.wait_for(asyncio.gather(first, *([second] if second else [])), timeout=5) - - assert discovery_calls == 1 - assert built_thread.get() is None # Other request contexts remain isolated. - result = results[1] - if entrypoint == "workflow": - assert result["text"] == "answer" - assert result["thread_id"] == "wf-test-run-test-node" - elif entrypoint == "open_chat_stream": - assert result.status_code == 200 - assert "event: result" in result.text - assert '"answer": "answer"' in result.text - assert "event: error" not in result.text - else: - assert result.status_code == 200 - assert result.json()["answer"] == "answer" - print("concurrent MCP initialization completed", flush=True) - - -if __name__ == "__main__": - asyncio.run(_exercise_entrypoint(sys.argv[1])) diff --git a/offline-backend-20260512/backend/tests/test_async_tool_initialization.py b/offline-backend-20260512/backend/tests/test_async_tool_initialization.py deleted file mode 100644 index 03cfed6b..00000000 --- a/offline-backend-20260512/backend/tests/test_async_tool_initialization.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Async tool consumers must leave their MCP discovery loop runnable.""" - -import asyncio -import contextvars -import threading -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -from deerflow.mcp import cache -from deerflow.mcp import tools as mcp_tools -from deerflow.tools import get_available_tools_async -from deerflow.tools import tools as tool_factory -from deerflow.tools.builtins.tool_search import ( - DeferredToolRegistry, - get_deferred_registry, - set_deferred_registry, -) -from deerflow.utils.background_init import run_background_init - - -@pytest.mark.asyncio -async def test_async_tools_preserve_options_and_deferred_registry(monkeypatch): - owner_thread = threading.get_ident() - user = contextvars.ContextVar("async_tool_test_user") - user.set("caller") - registry = DeferredToolRegistry() - expected = [object()] - options = { - "groups": ["files"], - "include_mcp": False, - "model_name": "test-model", - "subagent_enabled": True, - "is_scheduled_run": True, - "excluded_tools": ["bash"], - "allowed_tools": ["read_file"], - "allow_agent_orchestration": True, - "app_config": object(), - "write_file_compatibility": True, - } - - def load(**kwargs): - assert threading.get_ident() != owner_thread - assert user.get() == "caller" - assert kwargs == options - set_deferred_registry(registry) - return expected - - monkeypatch.setattr(tool_factory, "get_available_tools", load) - assert await get_available_tools_async(**options) is expected - assert get_deferred_registry() is registry - - -@pytest.mark.asyncio -async def test_tool_loading_during_agent_mcp_discovery_keeps_owner_loop_free(monkeypatch): - loop = asyncio.get_running_loop() - started = asyncio.Event() - release = asyncio.Event() - discovery_calls = 0 - expected = [object()] - identity = contextvars.ContextVar("async_tool_concurrent_identity") - registries = {name: DeferredToolRegistry() for name in ("first", "second")} - monkeypatch.setattr(cache, "_mcp_tools_cache", None) - monkeypatch.setattr(cache, "_cache_initialized", False) - monkeypatch.setattr(cache, "_config_mtime", None) - monkeypatch.setattr(cache, "_initialization_lock", asyncio.Lock()) - monkeypatch.setattr(cache, "_get_config_mtime", lambda: 1.0) - - async def discover(): - nonlocal discovery_calls - discovery_calls += 1 - assert asyncio.get_running_loop() is loop - started.set() - await release.wait() - return expected - - def load(**kwargs): - name = kwargs["model_name"] - assert identity.get() == name - result = cache.get_cached_mcp_tools() - set_deferred_registry(registries[name]) - return result - - async def request(name): - identity.set(name) - result = await get_available_tools_async(model_name=name) - assert get_deferred_registry() is registries[name] - return result - - monkeypatch.setattr(mcp_tools, "get_mcp_tools", discover) - monkeypatch.setattr(tool_factory, "get_available_tools", load) - # An agent initializer already owns MCP discovery when two tool consumers arrive. - agent = asyncio.create_task(run_background_init(cache.get_cached_mcp_tools)) - consumers = [] - try: - await asyncio.wait_for(started.wait(), timeout=3) - consumers = [asyncio.create_task(request(name)) for name in registries] - await asyncio.sleep(0.02) - assert not agent.done() and all(not task.done() for task in consumers) - assert discovery_calls == 1 - finally: - release.set() - results = await asyncio.wait_for(asyncio.gather(agent, *consumers), timeout=3) - assert len(results) == 3 and all(result is expected for result in results) - assert discovery_calls == 1 - - -@pytest.mark.asyncio -@pytest.mark.parametrize("entry_point", ["capabilities", "executor"]) -async def test_deep_research_provider_initialization_leaves_loop_free(monkeypatch, entry_point): - from app.gateway import deep_research_job_executor as executor_module - from app.gateway.routers import deep_research as router - from deerflow.agents.deep_research.adapters import artifacts, context, event_sink, image, llm, material_provider - from deerflow.agents.deep_research.cancellation import CancellationToken - from deerflow.agents.deep_research.config import DeepResearchConfig - from deerflow.agents.deep_research.types import DeepResearchResult - - loop = asyncio.get_running_loop() - owner_thread = threading.get_ident() - started = asyncio.Event() - release = threading.Event() - - def load_tools(_model_name): - assert threading.get_ident() != owner_thread - loop.call_soon_threadsafe(started.set) - assert release.wait(3), "the owner loop did not release initialization" - return {"web_search"} - - monkeypatch.setattr(material_provider, "_load_question_answer_tool_names", load_tools) - monkeypatch.setattr(material_provider, "_load_question_answer_research_skills", lambda: []) - monkeypatch.setattr(image.ImageGenerationSettings, "from_app_config", lambda: SimpleNamespace(is_configured=False)) - monkeypatch.setattr(image, "build_image_provider", lambda: None) - monkeypatch.setattr(llm, "DeerFlowCompletionBackend", lambda *args, **kwargs: object()) - monkeypatch.setattr(context, "DeerFlowContextCompressor", lambda **kwargs: object()) - monkeypatch.setattr(artifacts, "ThreadOutputsArtifactWriter", lambda *args, **kwargs: object()) - sink = SimpleNamespace(diagnostics=[], publish_live=AsyncMock()) - monkeypatch.setattr(event_sink, "PersistingEventSink", lambda *args, **kwargs: sink) - monkeypatch.setattr(executor_module, "RecordingEventSink", lambda *args, **kwargs: sink) - engine_run = AsyncMock(return_value=DeepResearchResult(report_markdown="completed")) - monkeypatch.setattr(executor_module, "DeepResearchEngine", lambda: SimpleNamespace(run=engine_run)) - executor = executor_module.DeepResearchJobExecutor(None, None, event_store=None) - monkeypatch.setattr(executor, "_ensure_runtime_thread", AsyncMock(return_value="open-test-research")) - monkeypatch.setattr(executor, "_load_interrupted_plan", AsyncMock(return_value=None)) - - if entry_point == "capabilities": - pending = router.get_capabilities() - else: - pending = executor._execute( - job_id="test-job", - session_id="test-session", - user_id="test-user", - query="test query", - title="test title", - config=DeepResearchConfig(), - lease_owner=None, - cancel_token=CancellationToken(), - ) - task = asyncio.create_task(pending) - try: - await asyncio.wait_for(started.wait(), timeout=3) - await asyncio.sleep(0) - assert not task.done() - finally: - release.set() - result = await asyncio.wait_for(task, timeout=3) - if entry_point == "capabilities": - assert "materialChannels" in result - else: - provider = engine_run.call_args.kwargs["adapters"].materials - assert isinstance(provider, material_provider.DeerFlowMaterialProvider) - assert provider._question_answer_tool_names == {"web_search"} - assert provider._online_search_semaphore is None - assert provider._user_id == "test-user" - assert provider._runtime_thread_id == "open-test-research" - assert result.report_markdown == "completed" diff --git a/offline-backend-20260512/backend/tests/test_bbc_page_generator_skill.py b/offline-backend-20260512/backend/tests/test_bbc_page_generator_skill.py deleted file mode 100644 index 730ec19a..00000000 --- a/offline-backend-20260512/backend/tests/test_bbc_page_generator_skill.py +++ /dev/null @@ -1,164 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -from pathlib import Path - -import pytest - -from deerflow.skills.parser import parse_skill_file -from deerflow.skills.storage.local_skill_storage import LocalSkillStorage -from deerflow.skills.types import SkillCategory -from deerflow.skills.validation import _validate_skill_frontmatter - -BACKEND_ROOT = Path(__file__).resolve().parents[1] -SKILL_ROOT = BACKEND_ROOT / "skills" / "public" / "bbc-page-generator" -SCRIPT_PATH = SKILL_ROOT / "scripts" / "render_bbc_page.py" -LEAD_PROMPT_PATH = ( - BACKEND_ROOT - / "packages" - / "harness" - / "deerflow" - / "agents" - / "lead_agent" - / "prompt.py" -) - - -def _load_renderer(): - spec = importlib.util.spec_from_file_location( - "bbc_page_renderer", - SCRIPT_PATH, - ) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_skill_is_discoverable_and_valid() -> None: - valid, error, _metadata = _validate_skill_frontmatter(SKILL_ROOT) - assert valid, error - skill = parse_skill_file(SKILL_ROOT / "SKILL.md", SkillCategory.PUBLIC) - assert skill is not None - assert skill.name == "bbc-page-generator" - assert "BBC" in skill.description - - discovered = LocalSkillStorage( - host_path=str(SKILL_ROOT.parent.parent) - ).load_skills(enabled_only=True) - discovered_by_name = {item.name: item for item in discovered} - assert ( - discovered_by_name["bbc-page-generator"].category - == SkillCategory.PUBLIC - ) - assert discovered_by_name["bbc-page-generator"].enabled is True - - lead_prompt_source = LEAD_PROMPT_PATH.read_text(encoding="utf-8") - assert "/mnt/skills/public/bbc-page-generator/SKILL.md" in lead_prompt_source - assert "专用 BBC 路由" in lead_prompt_source - - -def test_default_json_renders_editable_bbc_html(tmp_path: Path) -> None: - renderer = _load_renderer() - data = json.loads( - (SKILL_ROOT / "assets" / "default-data.json").read_text( - encoding="utf-8" - ) - ) - summary = renderer.validate_document(data) - template = (SKILL_ROOT / "assets" / "bbc-template.html").read_text( - encoding="utf-8" - ) - runtime = (SKILL_ROOT / "assets" / "bbc-runtime.js").read_text( - encoding="utf-8" - ) - html = renderer.render_document(data, template, runtime) - - assert summary["modules"] == list(renderer.MODULES) - assert summary["counts"]["focus"] >= 1 - assert renderer.DATA_MARKER not in html - assert renderer.RUNTIME_MARKER not in html - assert 'id="bbcPageData"' in html - assert 'id="leadLayout"' in html - assert 'id="mediaGrid"' in html - assert 'id="audioPlay"' in html - assert 'id="liveModal"' in html - assert 'id="openSettings"' in html - assert 'id="moduleTabs"' in html - assert 'id="applyJson"' in html - assert "uploadJsonMedia" in html - assert "installInlineMediaReplace" in html - assert "dfInlineMediaInput" in html - assert 'input.accept = "image/*,video/*"' in html - assert 'button.title = "上传图片或视频并替换"' in html - assert "active.expected" not in html - assert "previous.posterUrl" not in html - assert "decorateBuiltInVisuals" in html - assert 'data-video-id="' in html - assert "replaceBuiltInVisuals" not in html - assert "readAsDataURL" in html - assert '"brandLetters": [' in html - assert "BBC 新闻 — 看见正在发生的世界" in html - assert '"bbc-page-data-v1:" + pageFingerprint' in html - assert "const readStorage = (key)" in html - assert "window.localStorage.getItem" in html - assert "const cachedData = localStorage.getItem" not in html - assert 'BBC · 智能体公共新闻" in titled_html - assert "BBC 新闻 — 看见正在发生的世界" not in titled_html - - data["lead"]["title"] = "" - escaped_html = renderer.render_document(data, template, runtime) - assert "" - escaped_html = renderer.render_document(data, template, runtime) - assert "" - escaped_html = renderer.render_document(data, template) - assert "" - escaped_html = renderer.render_document(data, template, runtime) - assert "" - ) - escaped_html = renderer.render_document(data, template, runtime) - assert "" - escaped_html = renderer.render_document(data, template, runtime) - assert "" - ) - escaped_html = renderer.render_document(data, template, runtime) - assert "
{/* 搜索框 */} -
+
修改后先校验,再应用到页面。JSON 有效
- - - - - - diff --git a/offline-backend-20260512/backend/skills/public/bbc-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/bbc-page-generator/assets/default-data.json deleted file mode 100644 index 327e6949..00000000 --- a/offline-backend-20260512/backend/skills/public/bbc-page-generator/assets/default-data.json +++ /dev/null @@ -1,154 +0,0 @@ -{ - "site": { - "brandLetters": ["B", "B", "C"], - "pageTitle": "BBC 新闻 — 看见正在发生的世界", - "description": "一个 BBC 风格的中文国际公共新闻首页示例页面。", - "theme": "light", - "accountLabel": "账户", - "updatedDate": "2026年10月26日 · 更新于 09:36" - }, - "navigation": { - "global": [ - {"label": "首页", "href": "#top"}, - {"label": "新闻", "href": "#news"}, - {"label": "视频", "href": "#live"}, - {"label": "音频", "href": "#audio"}, - {"label": "地区", "href": "#regions"}, - {"label": "天气", "href": "#weather"} - ], - "channelTitle": "新闻", - "channel": [ - {"label": "头条", "href": "#news"}, - {"label": "世界", "href": "#world"}, - {"label": "商业", "href": "#business"}, - {"label": "科技", "href": "#science"}, - {"label": "文化", "href": "#culture"}, - {"label": "地区", "href": "#regions"} - ], - "moreLabel": "更多", - "searchLabel": "搜索 BBC", - "searchPlaceholder": "输入新闻、地点或人物", - "searchButton": "搜索", - "drawer": [ - {"label": "新闻", "href": "#news"}, - {"label": "世界", "href": "#world"}, - {"label": "商业", "href": "#business"}, - {"label": "科技", "href": "#science"}, - {"label": "视频", "href": "#live"}, - {"label": "音频", "href": "#audio"}, - {"label": "地区", "href": "#regions"}, - {"label": "天气", "href": "#weather"} - ] - }, - "breaking": { - "label": "快讯", - "text": "多国科研团队公布开放海洋观测计划,首批数据将于本周上线", - "time": "11分钟前" - }, - "lead": { - "kicker": "世界", - "title": "一项跨越海洋的观测计划,正在重新连接科学与公众", - "summary": "来自18个国家的研究机构将开放实时数据,让沿海社区第一次直接参与海洋变化监测。", - "related": [ - {"label": "图解:海洋传感器如何共同工作", "href": "#world"}, - {"label": "现场声音:跟随科考船出发的第一天", "href": "#audio"} - ], - "byline": "陈叙 报道", - "time": "3小时前", - "section": "世界", - "sectionHref": "#world", - "visual": "research-ship", - "visualLabel": "海洋科考船在晨光中航行的插画", - "stamp": "特别报道", - "credit": "BBC / 视觉新闻" - }, - "focus": { - "title": "今日焦点", - "moreLabel": "查看全部 →", - "items": [ - {"id": "public-harbor", "kicker": "城市", "title": "一座旧港口如何变成全年开放的公共客厅", "summary": "设计团队保留了船坞的工业结构,并把防洪设施藏进步道与花园。", "time": "47分钟前", "section": "世界", "href": "#world", "visual": "harbor"}, - {"id": "small-satellite", "kicker": "科学", "title": "更小、更快的新型卫星将如何观察极端天气", "summary": "", "time": "1小时前", "section": "科学", "href": "#science", "visual": "satellite"}, - {"id": "creative-economy", "kicker": "经济", "title": "创意产业为何成为中型城市的新增长引擎", "summary": "", "time": "2小时前", "section": "商业", "href": "#business", "visual": "creative-city"}, - {"id": "station-archive", "kicker": "文化", "title": "百年车站重开,建筑档案也首次面向公众", "summary": "", "time": "3小时前", "section": "文化", "href": "#culture", "visual": "station"} - ] - }, - "media": { - "title": "观看与收听", - "moreLabel": "所有节目 →", - "videos": [ - {"id": "world-live", "title": "BBC 世界新闻:全天候直播", "meta": "正在直播 · 提供中文字幕", "live": true, "visual": "studio"}, - {"id": "evening-brief", "title": "三分钟晚间新闻简报", "meta": "视频 · 03:12", "live": false, "visual": "bulletin"}, - {"id": "blue-frontier", "title": "深入蓝色边界:海上72小时", "meta": "纪录片 · 18:44", "live": false, "visual": "ocean"} - ], - "modal": { - "title": "BBC 直播", - "streamTitle": "世界新闻直播", - "streamSubtitle": "演示画面 · 中文字幕已开启", - "currentProgram": "当前节目:环球新闻一小时" - }, - "audio": { - "sectionTitle": "BBC 声音", - "title": "今日世界:海洋数据为何属于每一个人", - "meta": "主持人 林岚 · 24分钟", - "durationSeconds": 1440, - "durationLabel": "24:00" - } - }, - "moreNews": { - "title": "更多新闻", - "moreLabel": "浏览全部 →", - "items": [ - {"id": "digital-customs", "kicker": "商业", "title": "数字通关让区域物流等待时间缩短一半", "time": "36分钟前", "visual": "logistics"}, - {"id": "asian-markets", "kicker": "市场", "title": "亚洲主要股指涨跌互现,运输板块领涨", "time": "1小时前", "visual": "market"}, - {"id": "offline-assistant", "kicker": "科技", "title": "设备端智能助手进入离线运行时代", "time": "2小时前", "visual": "device"}, - {"id": "distributed-energy", "kicker": "环境", "title": "分布式能源如何帮助偏远社区保持供电", "time": "3小时前", "visual": "energy"} - ], - "rankingTitle": "热门排行", - "ranking": [ - {"title": "研究人员为何开始重新绘制世界洋流图", "href": "#world"}, - {"title": "一座火车站的百年建筑档案", "href": "#culture"}, - {"title": "小型卫星带来的天气预报革命", "href": "#science"}, - {"title": "城市创意产业的五个关键数字", "href": "#business"}, - {"title": "本周值得关注的五座城市", "href": "#regions"} - ] - }, - "regions": { - "title": "地区与语言", - "moreLabel": "选择地区 →", - "items": [ - {"region": "亚太", "title": "区域铁路网络迎来新一轮升级"}, - {"region": "欧洲", "title": "文化遗产保护计划扩大合作范围"}, - {"region": "非洲", "title": "太阳能微电网连接偏远医疗站"}, - {"region": "美洲", "title": "城市河流复育进入第二阶段"}, - {"region": "中东", "title": "新公共图书馆网络正式启用"} - ], - "weatherTitle": "世界天气", - "weatherLabel": "主要城市", - "weather": [ - {"city": "北京", "temperature": "18°", "condition": "晴间多云"}, - {"city": "伦敦", "temperature": "12°", "condition": "阵雨"}, - {"city": "新加坡", "temperature": "30°", "condition": "雷雨"}, - {"city": "内罗毕", "temperature": "24°", "condition": "晴"}, - {"city": "纽约", "temperature": "16°", "condition": "多云"} - ] - }, - "newsletter": { - "title": "每天,理解世界多一点", - "text": "订阅 BBC 早间简报,精选重要新闻与深度解读。", - "placeholder": "邮箱地址", - "button": "订阅简报", - "successPrefix": "订阅成功,确认邮件已发送至" - }, - "footer": { - "links": [ - {"label": "新闻", "href": "#news"}, - {"label": "视频", "href": "#live"}, - {"label": "音频", "href": "#audio"}, - {"label": "地区", "href": "#regions"}, - {"label": "天气", "href": "#weather"}, - {"label": "关于 BBC", "href": "#top"} - ], - "copyright": "© 2026 BBC Public Media · 本页面为概念设计", - "legal": "使用条款 · 隐私 · Cookie · 无障碍" - } -} diff --git a/offline-backend-20260512/backend/skills/public/bbc-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/bbc-page-generator/references/data-schema.md deleted file mode 100644 index c6705a0e..00000000 --- a/offline-backend-20260512/backend/skills/public/bbc-page-generator/references/data-schema.md +++ /dev/null @@ -1,25 +0,0 @@ -# BBC 页面 JSON 契约 - -顶层必须完整包含 `site`、`navigation`、`breaking`、`lead`、`focus`、`media`、`moreNews`、`regions`、`newsletter`、`footer`。弹窗按十个模块分别编辑,应用时重新校验完整文档。 - -## 通用规则 - -- 所有标题、标签、摘要和时间为字符串;关键展示字段不能为空。 -- 列表有数量上限,所属列表中的 `id` 必须唯一。 -- `href` 只允许以 `#` 开头的页面内锚点。 -- JSON 文本不能包含 HTML、脚本或直接 iframe。具有 `visual` 的内容对象可增加 `media` 来替换内置视觉:图片 `{type:"image", mediaUrl, alt?, pageUrl?}`;视频 `{type:"video", mediaUrl, posterUrl?, alt?, pageUrl?}`;网页 `{type:"webpage", pageUrl, title, description?, mediaUrl?, embed?}`。URL 只接受 `http(s)`,图片/视频也允许对应的 `data:` URL。 - -## 模块 - -- `site`:`brandLetters` 必须为 2–6 个单字符方块字标;包含页面标题、描述、`theme`(`light`/`dark`/`auto`)、账户文案和更新时间。 -- `navigation`:全站导航、频道导航和移动抽屉分别为 1–12 个 `{label,href}`;另含频道标题、搜索和更多文案。 -- `breaking`:`label/text/time`。 -- `lead`:头条标签、标题、摘要、0–6 条关联阅读、署名、时间、频道、视觉说明和标记;`visual` 固定为 `research-ship`。 -- `focus.items`:1–8 条,视觉类型为 `harbor`、`satellite`、`creative-city`、`station`。 -- `media.videos`:1–6 条,视觉类型为 `studio`、`bulletin`、`ocean`;`modal` 配置直播弹窗;`audio.durationSeconds` 为 1–86400。 -- `moreNews.items`:1–12 条,视觉类型为 `logistics`、`market`、`device`、`energy`;`ranking` 为 1–10 条排行链接。 -- `regions.items`:1–10 个地区标题;`weather` 为 1–10 个 `{city,temperature,condition}`。 -- `newsletter`:标题、说明、邮箱占位、按钮和成功提示前缀。 -- `footer.links`:1–12 个页脚链接;另有版权与法律文案。 - -JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。`assets/default-data.json` 只是结构和演示内容,不是事实来源。 diff --git a/offline-backend-20260512/backend/skills/public/bbc-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/bbc-page-generator/scripts/fast_page.py deleted file mode 100644 index 0861df8d..00000000 --- a/offline-backend-20260512/backend/skills/public/bbc-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "_shared")) - -import render_bbc_page as renderer -from fast_patch_page import run - -if __name__ == "__main__": - sys.exit(run(renderer, output_stem="bbc-page")) diff --git a/offline-backend-20260512/backend/skills/public/bbc-page-generator/scripts/render_bbc_page.py b/offline-backend-20260512/backend/skills/public/bbc-page-generator/scripts/render_bbc_page.py deleted file mode 100644 index ad060c49..00000000 --- a/offline-backend-20260512/backend/skills/public/bbc-page-generator/scripts/render_bbc_page.py +++ /dev/null @@ -1,576 +0,0 @@ -#!/usr/bin/env python3 -"""Validate BBC page data and inject it into the bundled HTML template.""" - -from __future__ import annotations - -import argparse -import html as html_lib -import json -import re -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_ROOT = Path(__file__).resolve().parent.parent -ASSETS_DIR = SKILL_ROOT / "assets" -DEFAULT_DATA_PATH = ASSETS_DIR / "default-data.json" -TEMPLATE_PATH = ASSETS_DIR / "bbc-template.html" -RUNTIME_PATH = ASSETS_DIR / "bbc-runtime.js" -DATA_MARKER = "__BBC_PAGE_DATA__" -RUNTIME_MARKER = "__BBC_PAGE_RUNTIME__" -TITLE_RE = re.compile(r"[^<]*", re.IGNORECASE) -MODULES = ( - "site", - "navigation", - "breaking", - "lead", - "focus", - "media", - "moreNews", - "regions", - "newsletter", - "footer", -) -THEMES = {"auto", "light", "dark"} -FOCUS_VISUALS = {"harbor", "satellite", "creative-city", "station"} -MEDIA_VISUALS = {"studio", "bulletin", "ocean"} -MORE_VISUALS = {"logistics", "market", "device", "energy"} -REMOTE_MEDIA_TYPES = {"image", "video", "webpage"} - - -class DataValidationError(ValueError): - """Raised when the BBC page JSON cannot satisfy the template contract.""" - - -def _read_json(path: Path) -> dict[str, Any]: - try: - data = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise DataValidationError(f"JSON file does not exist: {path}") from exc - except json.JSONDecodeError as exc: - raise DataValidationError( - f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" - ) from exc - if not isinstance(data, dict): - raise DataValidationError("Top-level JSON value must be an object") - return data - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array( - value: Any, - path: str, - errors: list[str], - *, - limit: int | None = None, -) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if limit is not None and len(value) > limit: - errors.append(f"{path} may contain at most {limit} items") - return value - - -def _non_empty_string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty string") - - -def _remote_url(value: Any, path: str, errors: list[str], *, required: bool = False, data_prefix: str = "") -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media_tree(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, list): - for index, item in enumerate(value): - _validate_remote_media_tree(item, f"{path}[{index}]", errors) - return - if not isinstance(value, dict): - return - media = value.get("media") - if isinstance(media, dict) and "type" in media: - media_path = f"{path}.media" - media_data = _mapping(media, media_path, errors) - media_type = media_data.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - errors.append(f"{media_path}.type must be one of: {', '.join(sorted(REMOTE_MEDIA_TYPES))}") - elif media_type in {"image", "video"}: - _remote_url(media_data.get("mediaUrl"), f"{media_path}.mediaUrl", errors, required=True, data_prefix=f"data:{media_type}/") - if media_type == "video": - _remote_url(media_data.get("posterUrl"), f"{media_path}.posterUrl", errors, data_prefix="data:image/") - if media_type == "webpage": - _remote_url(media_data.get("pageUrl"), f"{media_path}.pageUrl", errors, required=True) - _remote_url(media_data.get("mediaUrl"), f"{media_path}.mediaUrl", errors, data_prefix="data:image/") - if "embed" in media_data and not isinstance(media_data.get("embed"), bool): - errors.append(f"{media_path}.embed must be a boolean") - elif media_type in {"image", "video"}: - _remote_url(media_data.get("pageUrl"), f"{media_path}.pageUrl", errors) - for key, item in value.items(): - if key != "media": - _validate_remote_media_tree(item, f"{path}.{key}", errors) - - -def _string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str): - errors.append(f"{path} must be a string") - - -def _strings( - value: dict[str, Any], - keys: tuple[str, ...], - path: str, - errors: list[str], -) -> None: - for key in keys: - _non_empty_string(value.get(key), f"{path}.{key}", errors) - - -def _href(value: Any, path: str, errors: list[str]) -> None: - _non_empty_string(value, path, errors) - if isinstance(value, str) and not value.startswith("#"): - errors.append(f"{path} must be an in-page anchor starting with #") - - -def _validate_links( - value: Any, - path: str, - errors: list[str], - *, - limit: int, - require_items: bool, -) -> list[Any]: - items = _array(value, path, errors, limit=limit) - if require_items and not items: - errors.append(f"{path} must contain at least one item") - for index, link_value in enumerate(items): - item_path = f"{path}[{index}]" - link = _mapping(link_value, item_path, errors) - _non_empty_string(link.get("label"), f"{item_path}.label", errors) - _href(link.get("href"), f"{item_path}.href", errors) - return items - - -def _validate_story_items( - value: Any, - path: str, - errors: list[str], - *, - limit: int, - visuals: set[str], - focus: bool = False, - media: bool = False, -) -> list[Any]: - items = _array(value, path, errors, limit=limit) - if not items: - errors.append(f"{path} must contain at least one item") - seen: set[str] = set() - for index, item_value in enumerate(items): - item_path = f"{path}[{index}]" - item = _mapping(item_value, item_path, errors) - item_id = item.get("id") - _non_empty_string(item_id, f"{item_path}.id", errors) - if isinstance(item_id, str): - if item_id in seen: - errors.append(f"{item_path}.id must be unique") - seen.add(item_id) - keys = ("title", "meta") if media else ("kicker", "title", "time") - _strings(item, keys, item_path, errors) - if focus: - _string(item.get("summary"), f"{item_path}.summary", errors) - _strings(item, ("section",), item_path, errors) - _href(item.get("href"), f"{item_path}.href", errors) - if media and not isinstance(item.get("live"), bool): - errors.append(f"{item_path}.live must be a boolean") - if item.get("visual") not in visuals: - errors.append( - f"{item_path}.visual must be one of: " - f"{', '.join(sorted(visuals))}" - ) - return items - - -def validate_document(data: dict[str, Any]) -> dict[str, Any]: - errors: list[str] = [] - _validate_remote_media_tree(data, "$", errors) - for module in MODULES: - if module not in data: - errors.append(f"Missing top-level module: {module}") - - site = _mapping(data.get("site"), "site", errors) - _strings( - site, - ("pageTitle", "description", "accountLabel", "updatedDate"), - "site", - errors, - ) - if site.get("theme") not in THEMES: - errors.append("site.theme must be one of: auto, dark, light") - brand_letters = _array( - site.get("brandLetters"), - "site.brandLetters", - errors, - limit=6, - ) - if len(brand_letters) < 2: - errors.append("site.brandLetters must contain at least 2 letters") - for index, letter in enumerate(brand_letters): - if not isinstance(letter, str) or len(letter.strip()) != 1: - errors.append( - f"site.brandLetters[{index}] must be a single character" - ) - - navigation = _mapping(data.get("navigation"), "navigation", errors) - _strings( - navigation, - ( - "channelTitle", - "moreLabel", - "searchLabel", - "searchPlaceholder", - "searchButton", - ), - "navigation", - errors, - ) - global_links = _validate_links( - navigation.get("global"), - "navigation.global", - errors, - limit=12, - require_items=True, - ) - channel_links = _validate_links( - navigation.get("channel"), - "navigation.channel", - errors, - limit=12, - require_items=True, - ) - drawer_links = _validate_links( - navigation.get("drawer"), - "navigation.drawer", - errors, - limit=12, - require_items=True, - ) - - breaking = _mapping(data.get("breaking"), "breaking", errors) - _strings(breaking, ("label", "text", "time"), "breaking", errors) - - lead = _mapping(data.get("lead"), "lead", errors) - _strings( - lead, - ( - "kicker", - "title", - "summary", - "byline", - "time", - "section", - "visualLabel", - "stamp", - "credit", - ), - "lead", - errors, - ) - _href(lead.get("sectionHref"), "lead.sectionHref", errors) - if lead.get("visual") != "research-ship": - errors.append("lead.visual must be: research-ship") - related = _validate_links( - lead.get("related"), - "lead.related", - errors, - limit=6, - require_items=False, - ) - - focus = _mapping(data.get("focus"), "focus", errors) - _strings(focus, ("title", "moreLabel"), "focus", errors) - focus_items = _validate_story_items( - focus.get("items"), - "focus.items", - errors, - limit=8, - visuals=FOCUS_VISUALS, - focus=True, - ) - - media = _mapping(data.get("media"), "media", errors) - _strings(media, ("title", "moreLabel"), "media", errors) - video_items = _validate_story_items( - media.get("videos"), - "media.videos", - errors, - limit=6, - visuals=MEDIA_VISUALS, - media=True, - ) - modal = _mapping(media.get("modal"), "media.modal", errors) - _strings( - modal, - ("title", "streamTitle", "streamSubtitle", "currentProgram"), - "media.modal", - errors, - ) - audio = _mapping(media.get("audio"), "media.audio", errors) - _strings( - audio, - ("sectionTitle", "title", "meta", "durationLabel"), - "media.audio", - errors, - ) - duration = audio.get("durationSeconds") - if ( - not isinstance(duration, int) - or isinstance(duration, bool) - or not 1 <= duration <= 86400 - ): - errors.append( - "media.audio.durationSeconds must be an integer from 1 to 86400" - ) - - more_news = _mapping(data.get("moreNews"), "moreNews", errors) - _strings( - more_news, - ("title", "moreLabel", "rankingTitle"), - "moreNews", - errors, - ) - more_items = _validate_story_items( - more_news.get("items"), - "moreNews.items", - errors, - limit=12, - visuals=MORE_VISUALS, - ) - ranking = _array( - more_news.get("ranking"), - "moreNews.ranking", - errors, - limit=10, - ) - if not ranking: - errors.append("moreNews.ranking must contain at least one item") - for index, item_value in enumerate(ranking): - item_path = f"moreNews.ranking[{index}]" - item = _mapping(item_value, item_path, errors) - _non_empty_string(item.get("title"), f"{item_path}.title", errors) - _href(item.get("href"), f"{item_path}.href", errors) - - regions = _mapping(data.get("regions"), "regions", errors) - _strings( - regions, - ("title", "moreLabel", "weatherTitle", "weatherLabel"), - "regions", - errors, - ) - region_items = _array( - regions.get("items"), - "regions.items", - errors, - limit=10, - ) - if not region_items: - errors.append("regions.items must contain at least one item") - for index, item_value in enumerate(region_items): - item = _mapping(item_value, f"regions.items[{index}]", errors) - _strings( - item, - ("region", "title"), - f"regions.items[{index}]", - errors, - ) - weather_items = _array( - regions.get("weather"), - "regions.weather", - errors, - limit=10, - ) - if not weather_items: - errors.append("regions.weather must contain at least one item") - for index, item_value in enumerate(weather_items): - item = _mapping(item_value, f"regions.weather[{index}]", errors) - _strings( - item, - ("city", "temperature", "condition"), - f"regions.weather[{index}]", - errors, - ) - - newsletter = _mapping(data.get("newsletter"), "newsletter", errors) - _strings( - newsletter, - ("title", "text", "placeholder", "button", "successPrefix"), - "newsletter", - errors, - ) - - footer = _mapping(data.get("footer"), "footer", errors) - _strings(footer, ("copyright", "legal"), "footer", errors) - footer_links = _validate_links( - footer.get("links"), - "footer.links", - errors, - limit=12, - require_items=True, - ) - - if errors: - raise DataValidationError("; ".join(errors)) - - return { - "modules": list(MODULES), - "counts": { - "global_navigation": len(global_links), - "channel_navigation": len(channel_links), - "drawer_navigation": len(drawer_links), - "related": len(related), - "focus": len(focus_items), - "videos": len(video_items), - "more_news": len(more_items), - "ranking": len(ranking), - "regions": len(region_items), - "weather": len(weather_items), - "footer_links": len(footer_links), - }, - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - if template.count(DATA_MARKER) != 1: - raise DataValidationError( - "Bundled template must contain exactly one data marker" - ) - if template.count(RUNTIME_MARKER) != 1: - raise DataValidationError( - "Bundled template must contain exactly one runtime marker" - ) - serialized = json.dumps(data, ensure_ascii=False, indent=2).replace( - "{html_lib.escape(page_title)}", - rendered, - count=1, - ) - return rendered - - -def _write_new(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - try: - with path.open("x", encoding="utf-8", newline="\n") as handle: - handle.write(content) - except FileExistsError as exc: - raise DataValidationError( - f"Refusing to overwrite existing file: {path}" - ) from exc - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False)) - - -def _command_init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA_PATH) - summary = validate_document(data) - _write_new( - output, - json.dumps(data, ensure_ascii=False, indent=2) + "\n", - ) - return {"status": "ok", "action": "init", "output": str(output), **summary} - - -def _command_validate(input_path: Path) -> dict[str, Any]: - data = _read_json(input_path) - summary = validate_document(data) - return {"status": "ok", "action": "validate", "input": str(input_path), **summary} - - -def _command_render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - template = TEMPLATE_PATH.read_text(encoding="utf-8") - runtime = RUNTIME_PATH.read_text(encoding="utf-8") - rendered = render_document(data, template, runtime) - _write_new(output, rendered) - summary = validate_document(data) - return { - "status": "ok", - "action": "render", - "input": str(input_path), - "output": str(output), - **summary, - } - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - init_parser = subparsers.add_parser( - "init", - help="Create a new editable JSON document", - ) - init_parser.add_argument("--output", type=Path, required=True) - - validate_parser = subparsers.add_parser( - "validate", - help="Validate an existing JSON document", - ) - validate_parser.add_argument("--input", type=Path, required=True) - - render_parser = subparsers.add_parser( - "render", - help="Render validated JSON into the bundled HTML template", - ) - render_parser.add_argument("--input", type=Path, required=True) - render_parser.add_argument("--output", type=Path, required=True) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - args = parser.parse_args(argv) - try: - if args.command == "init": - result = _command_init(args.output) - elif args.command == "validate": - result = _command_validate(args.input) - else: - result = _command_render(args.input, args.output) - except (DataValidationError, OSError) as exc: - _emit({"status": "error", "action": args.command, "error": str(exc)}) - return 2 - _emit(result) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) - diff --git a/offline-backend-20260512/backend/skills/public/cnn-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/cnn-page-generator/SKILL.md deleted file mode 100644 index 46b42f61..00000000 --- a/offline-backend-20260512/backend/skills/public/cnn-page-generator/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: cnn-page-generator -description: 生成或绘制 CNN 及 CNN 风格的可交付全球新闻门户 HTML 网页;仅在用户明确要求 CNN 网页、CNN 风格新闻首页或点名本技能时使用。先把材料或检索结果整理成十模块 JSON,再用内置模板确定性渲染,保留右上角可视化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# CNN 页面生成器 - -本技能使用固定模板生成单文件 CNN 风格全球新闻首页。模型只负责材料收集和 JSON,禁止重新手写页面结构、CSS 或运行时脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块编辑 JSON,并通过「应用并渲染」即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说「生成/画/制作 CNN 网页」「CNN 风格新闻首页/全球新闻门户」或点名 `cnn-page-generator` 时使用。 -- 仅讨论 CNN、分析新闻机构、总结新闻或普通文本中出现 CNN 时不要使用。 -- 这是单页电视新闻门户模板,不改用新华社、BBC、白宫官网或通用多页门户模板。 - -## 极速工作流(默认) - -页面结构已经固定。模型只写新闻主题和少量可选文案;脚本负责生成完整中文十模块、栏目新闻、视频标题、专题分析和明确标注的非实时示意数据,并在一次进程内完成校验与渲染。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/cnn-page-spec.json`。最小规格只有一个高层字段: - - ```json - {"topic":"茂达蛋 IP"} - ``` - - 需要精确控制头条时可增加 `headline`、`headlineSummary`、`breaking`;需要指定栏目标题时可增加 `latestHeadlines`、`videoHeadlines`、`worldHeadlines` 字符串数组。不得写十模块 JSON、`$set` 或深层字段路径。 -2. 只执行一次 `python /mnt/skills/public/cnn-page-generator/scripts/fast_page.py --spec /mnt/user-data/outputs/cnn-page-spec.json --output-dir /mnt/user-data/outputs`。 -3. stdout 为 `status: ok` 后立即用 `present_files` 交付返回的完整 JSON 与 HTML;不要重命名、复制或清理输出文件,最终只回复一句完成说明。 - -`topic` 必填;显式提供的新闻、摘要、快讯、栏目标题和分析正文必须使用简体中文,品牌名与必要专名可保留。没有可靠实时数据时只写主题,脚本会自动使用“中文示意、非实时”内容。未提供媒体直链时不要写 `media`,脚本会保留模板示例视觉供用户上传替换。禁止读取默认数据、契约、生成后的 JSON/HTML、模板、运行时或脚本源码,禁止探路渲染、`--help`、独立 `init/validate/render`、二次渲染、生成后打补丁、重命名清理、临时脚本和冗长过程说明。规格报错时只修正规格一次并重跑。只有用户明确要求改变模块结构或批量重建完整数组时,才使用下面的完整工作流。 - -## 完整工作流(仅限结构改造) - -1. 读取当前会话材料、上传附件和已有产物,确定站点、导航、突发新闻、头条、最新报道、视频、全球商业、市场、简报和页脚。 -2. 用户要求基于公开资料、主题涉及近期事件,或明确要求检索时,先做聚焦检索并把来源事实转为页面文案;用户明确说不要检索时不得联网。没有材料时使用中性示例并明确为示例,不把模拟时间或市场数据描述成实时数据。 -3. 初始化新的完整 JSON: - - ```text - python /mnt/skills/public/cnn-page-generator/scripts/render_cnn_page.py init --output /mnt/user-data/outputs/cnn-page-data.json - ``` - -4. 只修改 JSON。十个顶层模块必须全部保留:`site`、`navigation`、`breaking`、`lead`、`latest`、`video`、`world`、`markets`、`newsletter`、`footer`。字段不确定时读取 [references/data-schema.md](references/data-schema.md)。 -5. 校验 JSON: - - ```text - python /mnt/skills/public/cnn-page-generator/scripts/render_cnn_page.py validate --input /mnt/user-data/outputs/cnn-page-data.json - ``` - -6. 确定性渲染到 `outputs/`: - - ```text - python /mnt/skills/public/cnn-page-generator/scripts/render_cnn_page.py render --input /mnt/user-data/outputs/cnn-page-data.json --output /mnt/user-data/outputs/cnn-page.html - ``` - -7. 检查 stdout 的 `status` 为 `ok`、HTML 存在且占位符已经消失,再用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天中粘贴完整 HTML。 - -## 内容映射 - -- 最高优先级的单条报道放入 `lead.primary`;头条旁的补充报道放入 `lead.sideStories`。 -- 普通报道分配到 `latest.items` 或 `world.items`;视频内容放入 `video.items`,不要混用。 -- `visual` 继续作为内置后备视觉;需要替换时,可在对应内容对象增加受校验的 `media`(`image`、`video`、`webpage`)。外部文本不得包含 HTML、脚本或直接 iframe。 -- 市场涨跌方向使用 `up`、`down` 或 `flat`。没有可靠来源时使用示例值或明确的静态快照,不冒充实时行情。 -- 页面引用近期事实时必须以检索材料为准;模板内置内容只是结构示例。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/cnn-template.html` 或 `assets/cnn-runtime.js`,不复制模板后手改 DOM。 -- 初始化与渲染默认拒绝覆盖已有文件;路径存在时换新文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖 CNN API、CDN 或 npm;仅当 JSON 明确提供受校验的媒体 URL 时加载图片、视频或网页。 -- 保留突发条、搜索、直播弹窗、字幕切换、收藏、分享、移动菜单、市场快照、订阅表单与响应式布局。 -- 浏览器缓存以初始 JSON 指纹隔离;沙箱禁止持久存储时必须继续使用内嵌 JSON 正常渲染。 diff --git a/offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/cnn-runtime.js b/offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/cnn-runtime.js deleted file mode 100644 index a90e9e1b..00000000 --- a/offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/cnn-runtime.js +++ /dev/null @@ -1,792 +0,0 @@ -(() => { - "use strict"; - - const qs = (selector, root = document) => root.querySelector(selector); - const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); - const clone = (value) => JSON.parse(JSON.stringify(value)); - const MODULES = ["site", "navigation", "breaking", "lead", "latest", "video", "world", "markets", "newsletter", "footer"]; - const MODULE_META = { - site: ["站点信息", "品牌、日期与工具栏"], - navigation: ["导航", "频道、搜索与移动菜单"], - breaking: ["突发新闻", "突发标签、标题与时间"], - lead: ["头条", "主报道、侧栏与实时更新"], - latest: ["最新报道", "新闻卡片与摘要"], - video: ["视频现场", "直播弹窗与视频卡片"], - world: ["全球商业", "列表报道与分析评论"], - markets: ["市场快照", "行情名称、数值与涨跌"], - newsletter: ["新闻简报", "订阅文案与反馈"], - footer: ["页脚", "栏目链接与版权信息"] - }; - const VISUAL_TYPES = new Set(["summit", "train", "coast", "city", "deep-sea", "night-city", "space", "music", "trade", "market", "payments", "energy"]); - const embeddedData = JSON.parse(qs("#cnnPageData").textContent); - const initialData = clone(embeddedData); - - const fingerprint = (text) => { - let hash = 2166136261; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0).toString(36); - }; - const pageFingerprint = fingerprint(JSON.stringify(initialData)); - const storageKey = "cnn-page-data-v1:" + pageFingerprint; - const themeKey = "cnn-page-theme-v1:" + pageFingerprint; - - const readStorage = (key) => { - try { - return window.localStorage ? window.localStorage.getItem(key) : null; - } catch (_error) { - return null; - } - }; - const writeStorage = (key, value) => { - try { - if (window.localStorage) window.localStorage.setItem(key, value); - return true; - } catch (_error) { - return false; - } - }; - const removeStorage = (key) => { - try { - if (window.localStorage) window.localStorage.removeItem(key); - } catch (_error) { - /* Sandboxed previews may intentionally deny storage. */ - } - }; - const isObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value)); - const isCompleteData = (value) => Boolean(isObject(value) && MODULES.every((name) => isObject(value[name]))); - const cachedData = readStorage(storageKey); - let pageData = initialData; - if (cachedData) { - try { - const parsed = JSON.parse(cachedData); - if (isCompleteData(parsed)) pageData = parsed; - } catch (_error) { - pageData = initialData; - } - } - - const escapeHTML = (value) => String(value == null ? "" : value).replace(/[&<>'"]/g, (character) => ({ - "&": "&", "<": "<", ">": ">", "'": "'", '"': """ - })[character]); - const safeHref = (value) => /^#[a-z0-9_-]+$/i.test(String(value || "")) ? escapeHTML(value) : "#top"; - const safeRemoteUrl = (value, kind = "link") => { - const source = String(value || "").trim(); - if (!source) return ""; - const lowered = source.toLowerCase(); - if (kind === "image" && lowered.startsWith("data:image/")) return source; - if (kind === "video" && lowered.startsWith("data:video/")) return source; - try { const url = new URL(source); return ["http:", "https:"].includes(url.protocol) ? url.href : ""; } - catch (_error) { return ""; } - }; - const validateRemoteMediaTree = (value, path = "当前模块") => { - if (Array.isArray(value)) return value.forEach((item, index) => validateRemoteMediaTree(item, path + "[" + index + "]")); - if (!isObject(value)) return; - const media = value.media; - if (media && ["image", "video", "webpage"].includes(media.type)) { - if (["image", "video"].includes(media.type) && !safeRemoteUrl(media.mediaUrl, media.type)) throw new Error(path + ".media.mediaUrl 无效"); - if (media.type === "webpage" && !safeRemoteUrl(media.pageUrl)) throw new Error(path + ".media.pageUrl 无效"); - if (media.posterUrl && !safeRemoteUrl(media.posterUrl, "image")) throw new Error(path + ".media.posterUrl 无效"); - } - Object.entries(value).forEach(([key, item]) => { if (key !== "media") validateRemoteMediaTree(item, path + "." + key); }); - }; - const safeVisual = (value, fallback) => VISUAL_TYPES.has(value) ? value : fallback; - const toast = qs("#toast"); - let toastTimer = 0; - const showToast = (message) => { - if (document.body.classList.contains("settings-open")) { - const status = qs("#jsonStatus"); - if (status) { - status.textContent = message; - status.className = "json-status valid"; - } - return; - } - window.clearTimeout(toastTimer); - toast.textContent = message; - toast.classList.add("show"); - toastTimer = window.setTimeout(() => toast.classList.remove("show"), 2400); - }; - const saveData = () => writeStorage(storageKey, JSON.stringify(pageData)); - - document.head.insertAdjacentHTML("beforeend", ''); - const externalMediaMarkup = (media, label) => { - if (!isObject(media)) return ""; - if (media.type === "image") { - const source = safeRemoteUrl(media.mediaUrl, "image"); - if (!source) return ""; - const pageUrl = safeRemoteUrl(media.pageUrl); - return '' + escapeHTML(media.alt || label || ' + (pageUrl ? '
打开来源' : ""); - } - if (media.type === "video") { - const source = safeRemoteUrl(media.mediaUrl, "video"); - if (!source) return ""; - const poster = safeRemoteUrl(media.posterUrl, "image"); - return ''; - } - if (media.type === "webpage") { - const pageUrl = safeRemoteUrl(media.pageUrl); - if (!pageUrl) return ""; - const preview = safeRemoteUrl(media.mediaUrl, "image"); - const body = media.embed === true ? '' : (preview ? '' + escapeHTML(media.alt || media.title || ' : ""); - return body + '' + escapeHTML(media.title || label || "打开网页") + '' + escapeHTML(media.description || "新窗口打开") + ""; - } - return ""; - }; - const visualSvg = (requested, label) => { - if (isObject(requested)) { - const external = externalMediaMarkup(requested, label); - if (external) return external; - requested = "city"; - } - const type = safeVisual(requested, "city"); - const title = escapeHTML(label || type); - let content = ""; - let background = "#253440"; - if (type === "summit") { - background = "#425a70"; - content = ''; - } else if (type === "train") { - background = "#b8d7df"; - content = ''; - } else if (type === "coast") { - background = "#acd5e6"; - content = ''; - } else if (type === "city") { - background = "#d7d1c5"; - content = ''; - } else if (type === "deep-sea") { - background = "#10141c"; - content = ''; - } else if (type === "night-city") { - background = "#071525"; - content = ''; - } else if (type === "space") { - background = "#080b13"; - content = ''; - } else if (type === "music") { - background = "#79143b"; - content = ''; - } else if (type === "trade") { - background = "#c9dae0"; - content = ''; - } else if (type === "market") { - background = "#ead7bd"; - content = ''; - } else if (type === "payments") { - background = "#18232b"; - content = ''; - } else { - background = "#bdd4d5"; - content = ''; - } - return '' + content + ""; - }; - - let themeInitialized = false; - const applyInitialTheme = () => { - if (themeInitialized) return; - themeInitialized = true; - const configured = pageData.site.theme; - const stored = readStorage(themeKey); - const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme:dark)").matches; - const dark = stored === "dark" || (!stored && (configured === "dark" || (configured === "auto" && prefersDark))); - document.body.classList.toggle("dark", dark); - syncThemeLabel(); - }; - const syncThemeLabel = () => { - const dark = document.body.classList.contains("dark"); - qs("#themeToggle").setAttribute("aria-label", dark ? "切换浅色模式" : "切换深色模式"); - }; - const toggleTheme = () => { - const dark = document.body.classList.toggle("dark"); - writeStorage(themeKey, dark ? "dark" : "light"); - syncThemeLabel(); - showToast(dark ? "已切换至深色模式" : "已切换至浅色模式"); - }; - - const renderSite = () => { - const site = pageData.site || {}; - document.title = site.pageTitle || site.brand || "CNN"; - qs('meta[name="description"]').content = site.description || ""; - qs("#brandText").textContent = site.brand || "CNN"; - qs("#brandLink").setAttribute("aria-label", (site.brand || "CNN") + " 首页"); - qs("#drawerBrand").textContent = site.brand || "CNN"; - const utilityLinks = Array.isArray(site.utilityLinks) ? site.utilityLinks : []; - qs("#utilityLeft").innerHTML = "" + escapeHTML(site.date) + "" + utilityLinks.map((label, index) => - '' + escapeHTML(label) + "" - ).join(""); - const marketSummary = Array.isArray(site.marketSummary) ? site.marketSummary : []; - qs("#utilityRight").innerHTML = marketSummary.map((item) => - "" + escapeHTML(item.label) + ' ' + - escapeHTML(item.value) + "" - ).join("") + "" + escapeHTML(site.weather) + ""; - qs("#frontLabel").textContent = site.frontLabel || ""; - applyInitialTheme(); - }; - - const renderNavigation = () => { - const navigation = pageData.navigation || {}; - const items = Array.isArray(navigation.items) ? navigation.items : []; - const markup = items.map((item, index) => '' + escapeHTML(item.label) + "").join(""); - qs("#desktopNav").innerHTML = markup; - qs("#drawerLinks").innerHTML = markup; - qs("#searchInput").placeholder = navigation.searchPlaceholder || ""; - qs("#searchSubmit").textContent = navigation.searchButton || "搜索"; - qs("#liveLabel").textContent = navigation.liveLabel || "直播"; - qs("#drawerFooter").textContent = navigation.drawerFooter || ""; - }; - - const renderBreaking = () => { - const data = pageData.breaking || {}; - qs("#breakingLabel").textContent = data.label || ""; - qs("#breakingCopy").textContent = data.text || ""; - qs("#breakingTime").textContent = data.time || ""; - }; - - const storyTools = () => '
'; - const renderLead = () => { - const data = pageData.lead || {}; - const primary = data.primary || {}; - const sideStories = Array.isArray(data.sideStories) ? data.sideStories : []; - const updates = Array.isArray(data.updates) ? data.updates : []; - qs("#leadGrid").innerHTML = '
' + escapeHTML(primary.kicker) + - "

" + escapeHTML(primary.title) + '

' + escapeHTML(primary.summary) + - '

' + visualSvg(primary.media || primary.visual, primary.visualLabel) + - '' + escapeHTML(primary.tag) + '' + - escapeHTML(primary.credit) + "
" + storyTools() + '
"; - }; - - const renderLatest = () => { - const data = pageData.latest || {}; - qs("#latestTitle").textContent = data.title || ""; - qs("#latestMore").textContent = data.moreLabel || ""; - const items = Array.isArray(data.items) ? data.items : []; - qs("#latestGrid").innerHTML = items.map((item, index) => - '
' + - visualSvg(item.media || item.visual, item.title) + '
' + escapeHTML(item.kicker) + "

" + - escapeHTML(item.title) + "

" + escapeHTML(item.summary) + '

' + - escapeHTML(item.time) + "
" - ).join(""); - }; - - const renderVideo = () => { - const data = pageData.video || {}; - qs("#videoTitle").textContent = data.title || ""; - qs("#videoMore").textContent = data.moreLabel || ""; - qs("#liveTitle").textContent = data.modalTitle || ""; - qs("#playerTitle").textContent = data.liveTitle || ""; - qs("#playerSubtitle").textContent = data.liveSubtitle || ""; - qs("#modalNote").textContent = data.modalNote || ""; - const items = Array.isArray(data.items) ? data.items : []; - qs("#videoGrid").innerHTML = items.map((item, index) => - '

" + escapeHTML(item.title) + '

' + - escapeHTML(item.meta) + "
" - ).join(""); - }; - - const renderWorld = () => { - const data = pageData.world || {}; - qs("#worldTitle").textContent = data.title || ""; - qs("#worldMore").textContent = data.moreLabel || ""; - const items = Array.isArray(data.items) ? data.items : []; - qs("#worldList").innerHTML = items.map((item) => - '
' + visualSvg(item.media || item.visual, item.title) + - '
' + escapeHTML(item.kicker) + "

" + escapeHTML(item.title) + - '

' + escapeHTML(item.time) + "
" - ).join(""); - const analysis = data.analysis || {}; - qs("#opinion").innerHTML = '
' + escapeHTML(analysis.kicker) + "

" + - escapeHTML(analysis.title) + "

" + escapeHTML(analysis.text) + - '

' + escapeHTML(analysis.initial) + "" + - escapeHTML(analysis.author) + "
" + escapeHTML(analysis.role) + "
"; - }; - - const renderMarkets = () => { - const data = pageData.markets || {}; - qs("#marketsTitle").textContent = data.title || ""; - qs("#marketsMore").textContent = data.moreLabel || ""; - const items = Array.isArray(data.items) ? data.items : []; - qs("#marketsGrid").innerHTML = items.map((item) => - '
' + escapeHTML(item.name) + '
' + - escapeHTML(item.value) + '
' + - escapeHTML(item.change) + "
" - ).join(""); - }; - - const renderNewsletter = () => { - const data = pageData.newsletter || {}; - qs("#newsletterTitle").textContent = data.title || ""; - qs("#newsletterText").textContent = data.text || ""; - qs("#email").placeholder = data.placeholder || ""; - qs("#newsletterButton").textContent = data.button || ""; - }; - - const renderFooter = () => { - const data = pageData.footer || {}; - const columns = Array.isArray(data.columns) ? data.columns : []; - qs("#footerGrid").innerHTML = '

" + escapeHTML(data.tagline) + "

" + columns.map((column) => - "

" + escapeHTML(column.title) + "

" + (Array.isArray(column.links) ? column.links : []).map((link) => - '' + escapeHTML(link.label) + "" - ).join("") + "
" - ).join(""); - qs("#copyright").textContent = data.copyright || ""; - qs("#legal").textContent = data.legal || ""; - }; - - const renderAll = () => { - renderSite(); - renderNavigation(); - renderBreaking(); - renderLead(); - renderLatest(); - renderVideo(); - renderWorld(); - renderMarkets(); - renderNewsletter(); - renderFooter(); - bindDynamicInteractions(); - }; - - const liveModal = qs("#liveModal"); - let lastFocus = null; - function openLive() { - lastFocus = document.activeElement; - liveModal.classList.add("open"); - document.body.style.overflow = "hidden"; - qs("#liveClose").focus(); - } - const closeLive = () => { - liveModal.classList.remove("open"); - document.body.style.overflow = ""; - if (lastFocus) lastFocus.focus(); - }; - const drawer = qs("#drawer"); - const openDrawer = () => { - drawer.classList.add("open"); - document.body.style.overflow = "hidden"; - qs("#drawerClose").focus(); - }; - const closeDrawer = () => { - drawer.classList.remove("open"); - document.body.style.overflow = ""; - qs("#menuOpen").focus(); - }; - const bindDynamicInteractions = () => { - qsa(".open-live").forEach((button) => { - if (button.dataset.liveBound) return; - button.dataset.liveBound = "true"; - button.addEventListener("click", openLive); - }); - qsa(".save-btn").forEach((button) => button.addEventListener("click", () => { - const saved = button.classList.toggle("saved"); - qs("span", button).textContent = saved ? "已收藏" : "收藏"; - showToast(saved ? "报道已加入收藏" : "已取消收藏"); - })); - qsa(".share-btn").forEach((button) => button.addEventListener("click", async () => { - try { - if (navigator.share) { - await navigator.share({title: document.title, url: location.href}); - } else if (navigator.clipboard) { - await navigator.clipboard.writeText(location.href); - showToast("链接已复制"); - } else { - showToast("可复制浏览器地址进行分享"); - } - } catch (_error) { - /* User cancellation is not an error state. */ - } - })); - qsa(".drawer-links a").forEach((link) => link.addEventListener("click", closeDrawer)); - }; - - qs("#themeToggle").addEventListener("click", toggleTheme); - qs("#searchToggle").addEventListener("click", () => { - const panel = qs("#searchPanel"); - panel.classList.toggle("open"); - if (panel.classList.contains("open")) window.setTimeout(() => qs("#searchInput").focus(), 20); - }); - qs("#searchForm").addEventListener("submit", (event) => { - event.preventDefault(); - const query = qs("#searchInput").value.trim().toLocaleLowerCase(); - const corpus = [ - pageData.breaking.text, - pageData.lead.primary.title, - pageData.lead.primary.summary, - ...(pageData.lead.sideStories || []).flatMap((item) => [item.title, item.summary]), - ...(pageData.latest.items || []).flatMap((item) => [item.title, item.summary]), - ...(pageData.video.items || []).map((item) => item.title), - ...(pageData.world.items || []).map((item) => item.title), - pageData.world.analysis.title, - pageData.world.analysis.text - ]; - const matches = query ? corpus.filter((value) => String(value || "").toLocaleLowerCase().includes(query)).length : 0; - const result = qs("#searchResults"); - result.classList.add("show"); - result.textContent = query ? "找到与“" + qs("#searchInput").value.trim() + "”相关的 " + matches + " 篇报道" : "请输入搜索关键词"; - }); - qs("#liveClose").addEventListener("click", closeLive); - liveModal.addEventListener("click", (event) => { - if (event.target === liveModal) closeLive(); - }); - qs("#captionToggle").addEventListener("click", (event) => { - const isOn = event.currentTarget.textContent.includes("开"); - event.currentTarget.textContent = "CC 字幕:" + (isOn ? "关" : "开"); - showToast(isOn ? "字幕已关闭" : "字幕已开启"); - }); - qs("#menuOpen").addEventListener("click", openDrawer); - qs("#drawerClose").addEventListener("click", closeDrawer); - drawer.addEventListener("click", (event) => { - if (event.target === drawer) closeDrawer(); - }); - qs("#newsletterForm").addEventListener("submit", (event) => { - event.preventDefault(); - const email = qs("#email").value.trim(); - qs("#formStatus").textContent = (pageData.newsletter.successPrefix || "") + " " + email; - event.currentTarget.reset(); - }); - - const settingsOverlay = qs("#settingsOverlay"); - const jsonEditor = qs("#jsonEditor"); - const jsonStatus = qs("#jsonStatus"); - let activeModule = MODULES[0]; - let editorDraft = clone(pageData); - const validateEditor = () => { - try { - const parsed = JSON.parse(jsonEditor.value); - if (!isObject(parsed)) throw new Error("模块必须是 JSON 对象"); - validateRemoteMediaTree(parsed); - jsonStatus.textContent = "JSON 有效"; - jsonStatus.className = "json-status valid"; - return parsed; - } catch (error) { - jsonStatus.textContent = "JSON 无效:" + error.message; - jsonStatus.className = "json-status invalid"; - return null; - } - }; - const storeActiveDraft = () => { - const parsed = validateEditor(); - if (!parsed) return false; - editorDraft[activeModule] = parsed; - return true; - }; - const selectModule = (name, force) => { - if (!force && !storeActiveDraft()) return; - activeModule = name; - qsa(".module-tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.module === name)); - qs("#editorModuleTitle").textContent = MODULE_META[name][0]; - jsonEditor.value = JSON.stringify(editorDraft[name], null, 2); - validateEditor(); - }; - const renderModuleTabs = () => { - qs("#moduleTabs").innerHTML = "

页面模块

" + MODULES.map((name) => - '" - ).join(""); - qsa(".module-tab").forEach((tab) => tab.addEventListener("click", () => selectModule(tab.dataset.module, false))); - }; - const openSettings = () => { - editorDraft = clone(pageData); - activeModule = MODULES[0]; - renderModuleTabs(); - selectModule(activeModule, true); - settingsOverlay.classList.add("open"); - settingsOverlay.setAttribute("aria-hidden", "false"); - document.body.classList.add("settings-open"); - document.body.style.overflow = "hidden"; - window.setTimeout(() => jsonEditor.focus(), 80); - }; - const closeSettings = () => { - settingsOverlay.classList.remove("open"); - settingsOverlay.setAttribute("aria-hidden", "true"); - document.body.classList.remove("settings-open"); - document.body.style.overflow = ""; - qs("#openSettings").focus(); - }; - qs("#openSettings").addEventListener("click", openSettings); - qs("#closeSettings").addEventListener("click", closeSettings); - settingsOverlay.addEventListener("click", (event) => { - if (event.target === settingsOverlay) closeSettings(); - }); - jsonEditor.addEventListener("input", validateEditor); - jsonEditor.addEventListener("keydown", (event) => { - if (event.key === "Tab") { - event.preventDefault(); - jsonEditor.setRangeText(" ", jsonEditor.selectionStart, jsonEditor.selectionEnd, "end"); - validateEditor(); - } - }); - qs("#applyJson").addEventListener("click", () => { - if (!storeActiveDraft() || !isCompleteData(editorDraft)) { - showToast("请修正 JSON 后再应用"); - return; - } - pageData = clone(editorDraft); - saveData(); - renderAll(); - showToast("JSON 已应用到页面"); - }); - qs("#resetJson").addEventListener("click", () => { - pageData = clone(initialData); - editorDraft = clone(initialData); - removeStorage(storageKey); - removeStorage(themeKey); - themeInitialized = false; - renderAll(); - renderModuleTabs(); - selectModule(activeModule, true); - showToast("已恢复初始 JSON"); - }); - qs("#copyJson").addEventListener("click", async () => { - if (!storeActiveDraft()) return; - const text = JSON.stringify(editorDraft, null, 2); - try { - if (navigator.clipboard) await navigator.clipboard.writeText(text); - showToast("完整 JSON 已复制"); - } catch (_error) { - jsonEditor.focus(); - jsonEditor.select(); - showToast("已选中当前模块,请手动复制"); - } - }); - qs("#downloadJson").addEventListener("click", () => { - if (!storeActiveDraft()) return; - const blob = new Blob([JSON.stringify(editorDraft, null, 2) + "\n"], {type: "application/json"}); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = "cnn-page-data.json"; - link.click(); - window.setTimeout(() => URL.revokeObjectURL(link.href), 0); - showToast("JSON 已下载"); - }); - - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - if (liveModal.classList.contains("open")) closeLive(); - if (drawer.classList.contains("open")) closeDrawer(); - if (settingsOverlay.classList.contains("open")) closeSettings(); - } - }); - - renderAll(); - function installJsonMediaUpload(){ - const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return; - const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson");button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input); - const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message}; - const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;indexindex+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null}; - button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()}); - input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)}); - } - installJsonMediaUpload(); - function installInlineMediaReplace() { - if (document.getElementById("dfInlineMediaInput")) return; - const visualHostSelector = ".post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt"; - const getRootData = () => { - if (typeof pageData !== "undefined") return pageData; - if (typeof data !== "undefined") return data; - return null; - }; - const input = document.createElement("input"); - input.id = "dfInlineMediaInput"; - input.type = "file"; - input.hidden = true; - document.body.appendChild(input); - const style = document.createElement("style"); - style.dataset.dfInlineMediaReplace = "true"; - style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}"; - document.head.appendChild(style); - const notify = (message) => { - if (typeof showToast === "function") { showToast(message); return; } - if (typeof toast === "function") { toast(message); return; } - let notice = document.getElementById("dfInlineMediaToast"); - if (!notice) { - notice = document.createElement("div"); - notice.id = "dfInlineMediaToast"; - notice.className = "df-inline-media-toast"; - document.body.appendChild(notice); - } - notice.textContent = message; - window.clearTimeout(notice._timer); - notice._timer = window.setTimeout(() => notice.remove(), 2600); - }; - const sameSource = (value, source, absolute) => { - if (typeof value !== "string") return false; - if (value === source || value === absolute) return true; - try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; } - }; - const findMatches = (source, absolute) => { - const matches = []; - const stack = []; - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - stack.push(value); - Object.entries(value).forEach(([key, item]) => { - if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) { - const ancestors = stack.slice(0, -1).reverse(); - const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value; - matches.push({ owner, previous: value, key }); - } - if (item && typeof item === "object") visit(item); - }); - stack.pop(); - }; - visit(getRootData()); - return matches; - }; - const visualOwners = () => { - const owners = []; - const seen = new Set(); - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object"); - const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim()); - if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); } - Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); }); - }; - visit(getRootData()); - return owners; - }; - const domIdentifiers = (element) => { - const values = new Set(); - let cursor = element; - for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) { - Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); }); - } - return values; - }; - const chooseVisualTarget = (element) => { - const identifiers = domIdentifiers(element); - const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element; - const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim(); - const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]"); - const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : ""; - let best = null; - let bestScore = 0; - visualOwners().forEach((owner) => { - let score = 0; - Object.entries(owner).forEach(([key, value]) => { - if (/id$/i.test(key) && identifiers.has(String(value))) score += 100; - }); - ["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => { - const value = typeof owner[key] === "string" ? owner[key].trim() : ""; - if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length); - }); - if (visualValue && owner.visual === visualValue) score += 20; - if (score > bestScore) { best = owner; bestScore = score; } - }); - if (!best || bestScore <= 0) return null; - return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseTarget = (element) => { - const source = element.getAttribute("src") || ""; - const absolute = element.currentSrc || element.src || source; - const matches = findMatches(source, absolute); - if (!matches.length) return null; - const identifiers = domIdentifiers(element); - return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0]; - }; - const persist = () => { - const root = getRootData(); - if (typeof saveData === "function") return saveData(); - if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root)); - if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root)); - return false; - }; - let active = null; - const attachButton = (host, target) => { - if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return; - host.classList.add("df-inline-media-host"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "df-inline-media-button"; - button.setAttribute("aria-label", "替换图片或视频"); - button.title = "上传图片或视频并替换"; - button.innerHTML = ''; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - active = { ...target }; - input.accept = "image/*,video/*"; - input.value = ""; - input.click(); - }); - host.appendChild(button); - }; - const decorateBuiltInVisuals = () => { - document.querySelectorAll(visualHostSelector).forEach((host) => { - if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return; - if (!host.querySelector("svg") && !host.dataset.visual) return; - const target = chooseVisualTarget(host); - if (!target) return; - attachButton(host, target); - }); - }; - const decorate = () => { - decorateBuiltInVisuals(); - document.querySelectorAll("img[src],video[src]").forEach((media) => { - if (media.dataset.dfInlineMediaReady === "true") return; - const target = chooseTarget(media); - if (!target) return; - const host = media.closest(visualHostSelector) || media.parentElement; - if (!host) return; - media.dataset.dfInlineMediaReady = "true"; - attachButton(host, target); - }); - }; - input.addEventListener("change", () => { - const file = input.files && input.files[0]; - if (!file || !active) return; - const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : ""; - if (!uploadedType) { - notify("请选择图片或视频文件。"); - return; - } - const reader = new FileReader(); - reader.onerror = () => notify("文件读取失败,请重新选择。"); - reader.onload = () => { - const next = { ...(active.previous || {}) }; - const result = String(reader.result); - next.type = uploadedType; - next.mediaUrl = result; - delete next.posterUrl; - if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片"; - active.owner.media = next; - const stored = persist(); - renderAll(); - notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。"); - active = null; - }; - reader.readAsDataURL(file); - }); - let queued = false; - const queueDecorate = () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; decorate(); }); - }; - new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true }); - decorate(); - } - installInlineMediaReplace(); -})(); diff --git a/offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/cnn-template.html b/offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/cnn-template.html deleted file mode 100644 index e3f7a74c..00000000 --- a/offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/cnn-template.html +++ /dev/null @@ -1,226 +0,0 @@ - - - - - - - - AXIS — 全球新闻现场 - - - - - -
-
-
- - CNN - -
- - - -
-
-
-
-
- - -
-
-

-

-

-

- -
- -
- - - -
- - - - - - - - diff --git a/offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/default-data.json deleted file mode 100644 index ed0cb3dc..00000000 --- a/offline-backend-20260512/backend/skills/public/cnn-page-generator/assets/default-data.json +++ /dev/null @@ -1,127 +0,0 @@ -{ - "site": { - "brand": "CNN", - "pageTitle": "CNN — 全球新闻现场", - "description": "一个 CNN 风格的中文全球新闻首页示例页面。", - "theme": "light", - "date": "2026年10月26日 · 星期一", - "utilityLinks": ["市场", "新闻简报"], - "marketSummary": [ - {"label": "沪指", "value": "+0.42%", "direction": "up"}, - {"label": "恒指", "value": "−0.18%", "direction": "down"} - ], - "weather": "天气 18°C", - "frontLabel": "头版 · 全球焦点" - }, - "navigation": { - "items": [ - {"label": "首页", "href": "#top"}, - {"label": "全球", "href": "#world"}, - {"label": "商业", "href": "#business"}, - {"label": "科技", "href": "#technology"}, - {"label": "文化", "href": "#culture"}, - {"label": "观点", "href": "#opinion"} - ], - "searchPlaceholder": "搜索新闻、人物或地点", - "searchButton": "搜索", - "liveLabel": "直播", - "drawerFooter": "CNN · 从现场出发" - }, - "breaking": { - "label": "突发", - "text": "全球城市峰会达成新框架,首轮联合行动将在下月启动", - "time": "9分钟前" - }, - "lead": { - "primary": { - "kicker": "特别报道", - "title": "世界城市正在重新绘制一张共同生活的蓝图", - "summary": "从能源网络到公共交通,来自32座城市的代表在海湾会议上公布了一份跨区域协作计划。", - "byline": "林知远 发自海湾城", - "updated": "更新于 12 分钟前", - "readingTime": "阅读 6 分钟", - "visual": "summit", - "visualLabel": "傍晚的国际会议中心和城市天际线插画", - "tag": "现场", - "credit": "CNN / 视觉报道" - }, - "sideStories": [ - {"kicker": "交通", "title": "夜行列车回归:一场跨越大陆的旅行复兴", "summary": "新线路把六座首都重新连接起来,首班车票已售罄。", "visual": "train"}, - {"kicker": "科技", "title": "小型语言模型走进设备端,隐私成为新卖点", "summary": "无需联网的智能助手,正改变手机与可穿戴设备的竞争方式。", "visual": null} - ], - "updatesTitle": "实时更新", - "updates": [ - {"time": "09:42", "text": "峰会代表开始回答记者提问"}, - {"time": "09:31", "text": "联合声明全文正式公布"}, - {"time": "09:18", "text": "首批合作城市名单确认"} - ] - }, - "latest": { - "title": "最新报道", - "moreLabel": "查看全部 →", - "items": [ - {"id": "living-coastline", "kicker": "气候", "title": "沿海社区如何用一条“活的海岸线”抵御风暴", "summary": "工程师放弃单一水泥墙,转向沙丘、湿地与可移动公共空间组成的新防线。", "time": "35分钟前 · 视觉故事", "visual": "coast"}, - {"id": "urban-lab", "kicker": "欧洲", "title": "旧工业区变身城市实验室,年轻居民正在回流", "summary": "空置厂房成为工作室、实验室与可负担住房。", "time": "1小时前", "visual": "city"}, - {"id": "deep-sea", "kicker": "科学", "title": "新型观测网络首次捕捉深海热流的完整路径", "summary": "数据将帮助研究人员改进区域气候预测。", "time": "2小时前", "visual": "deep-sea"} - ] - }, - "video": { - "title": "CNN 现场", - "moreLabel": "更多视频 →", - "modalTitle": "CNN LIVE", - "liveTitle": "正在直播", - "liveSubtitle": "全球城市峰会记者会", - "modalNote": "直播画面为互动演示。字幕与音量控制可在正式播放器中使用。", - "items": [ - {"id": "future-city", "title": "连接未来:城市基础设施如何在地下完成升级", "meta": "纪录短片", "duration": "08:24", "visual": "night-city"}, - {"id": "space-lab", "title": "轨道实验室迎来全新材料实验", "meta": "科学现场", "duration": "03:18", "visual": "space"}, - {"id": "coast-festival", "title": "海岸音乐节将社区带回公共空间", "meta": "文化", "duration": "05:06", "visual": "music"} - ] - }, - "world": { - "title": "全球与商业", - "moreLabel": "进入频道 →", - "items": [ - {"id": "trade-corridor", "kicker": "亚洲", "title": "区域贸易走廊启用数字通关系统", "time": "24分钟前", "visual": "trade"}, - {"id": "market-rise", "kicker": "市场", "title": "能源与运输板块推动主要指数走高", "time": "41分钟前", "visual": "market"}, - {"id": "instant-pay", "kicker": "金融", "title": "即时支付网络跨境测试扩展至八个市场", "time": "1小时前", "visual": "payments"}, - {"id": "clean-grid", "kicker": "能源", "title": "分布式电网让小城成为清洁能源节点", "time": "2小时前", "visual": "energy"} - ], - "analysis": { - "kicker": "CNN 分析", - "title": "这份城市协作框架真正改变了什么?", - "text": "它没有许诺一夜之间解决问题,而是建立了一套可度量、可复制的合作机制。关键考验将在资金与执行层面。", - "author": "周观澜", - "role": "国际事务主编", - "initial": "周" - } - }, - "markets": { - "title": "市场快照", - "moreLabel": "数据中心 →", - "items": [ - {"name": "上证指数", "value": "3,472.18", "change": "+0.42%", "direction": "up"}, - {"name": "恒生指数", "value": "24,118.70", "change": "−0.18%", "direction": "down"}, - {"name": "布伦特原油", "value": "$71.36", "change": "+0.81%", "direction": "up"}, - {"name": "黄金", "value": "$2,741.50", "change": "+0.27%", "direction": "up"} - ] - }, - "newsletter": { - "title": "重要新闻,直达你的收件箱", - "text": "每天早晚两次,5分钟读懂世界正在发生什么。", - "placeholder": "name@example.com", - "button": "免费订阅", - "successPrefix": "订阅成功,确认邮件已发送至" - }, - "footer": { - "tagline": "从现场出发,看见世界的全貌。", - "columns": [ - {"title": "新闻", "links": [{"label": "全球", "href": "#world"}, {"label": "商业", "href": "#business"}, {"label": "科技", "href": "#technology"}]}, - {"title": "栏目", "links": [{"label": "观点", "href": "#opinion"}, {"label": "新闻简报", "href": "#newsletter"}, {"label": "市场", "href": "#markets"}]}, - {"title": "关于", "links": [{"label": "关于 CNN", "href": "#top"}, {"label": "编辑准则", "href": "#top"}, {"label": "联系我们", "href": "#top"}]}, - {"title": "关注", "links": [{"label": "视频", "href": "#technology"}, {"label": "播客", "href": "#top"}, {"label": "移动应用", "href": "#top"}]} - ], - "copyright": "© 2026 CNN News. 本页面为概念设计。", - "legal": "隐私 · 条款 · Cookie 设置" - } -} diff --git a/offline-backend-20260512/backend/skills/public/cnn-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/cnn-page-generator/references/data-schema.md deleted file mode 100644 index 7c1a0620..00000000 --- a/offline-backend-20260512/backend/skills/public/cnn-page-generator/references/data-schema.md +++ /dev/null @@ -1,26 +0,0 @@ -# CNN 页面 JSON 契约 - -顶层必须完整包含 `site`、`navigation`、`breaking`、`lead`、`latest`、`video`、`world`、`markets`、`newsletter`、`footer`。弹窗按十个模块分别编辑,但应用时重新校验完整文档。 - -## 通用规则 - -- 所有标题、标签、摘要和时间都是非空字符串;列表有数量上限。 -- `id` 在所属列表内必须唯一。 -- `href` 只允许以 `#` 开头的页面内锚点。 -- JSON 文本不能包含 HTML、脚本或直接 iframe。具有 `visual` 的内容对象可增加 `media` 来替换内置视觉:图片 `{type:"image", mediaUrl, alt?, pageUrl?}`;视频 `{type:"video", mediaUrl, posterUrl?, alt?, pageUrl?}`;网页 `{type:"webpage", pageUrl, title, description?, mediaUrl?, embed?}`。URL 只接受 `http(s)`,图片/视频也允许对应的 `data:` URL。 - -## 模块 - -- `site`:品牌、页面标题、描述、`theme`(`light`/`dark`/`auto`)、日期、工具栏链接、市场摘要、天气和头版标签。市场方向为 `up`/`down`/`flat`。 -- `navigation`:1–10 个 `{label, href}` 导航项,以及搜索、直播和移动抽屉文案。 -- `breaking`:`label`、`text`、`time`。 -- `lead.primary`:`kicker/title/summary/byline/updated/readingTime/visual/visualLabel/tag/credit`;`visual` 必须为 `summit`。 -- `lead.sideStories`:0–4 条,`visual` 可为 `train` 或 `null`;`updates` 为 0–12 条 `{time,text}`。 -- `latest.items`:1–12 条,视觉类型为 `coast`、`city`、`deep-sea`。 -- `video.items`:1–8 条,视觉类型为 `night-city`、`space`、`music`,并包含 `duration`。 -- `world.items`:1–16 条,视觉类型为 `trade`、`market`、`payments`、`energy`;`analysis` 包含评论标题、正文和作者信息。 -- `markets.items`:1–8 条 `{name,value,change,direction}`,方向为 `up`、`down` 或 `flat`。 -- `newsletter`:标题、说明、邮箱占位、按钮和成功提示前缀。 -- `footer.columns`:0–6 栏,每栏 0–8 个 `{label,href}`;另有 `tagline/copyright/legal`。 - -JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。`assets/default-data.json` 只是结构和演示内容,不是事实来源。 diff --git a/offline-backend-20260512/backend/skills/public/cnn-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/cnn-page-generator/scripts/fast_page.py deleted file mode 100644 index 40bbb03b..00000000 --- a/offline-backend-20260512/backend/skills/public/cnn-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,372 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a complete CNN-style Chinese page from a high-level JSON spec.""" - -from __future__ import annotations - -import argparse -import copy -import json -import sys -from collections.abc import Mapping -from pathlib import Path - -import render_cnn_page as renderer - -LATEST_VISUALS = ("coast", "city", "deep-sea") -VIDEO_VISUALS = ("night-city", "space", "music") -WORLD_VISUALS = ("trade", "market", "payments", "energy") - - -def _read_spec(path: Path) -> dict[str, object]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - raise renderer.DataValidationError(f"Invalid spec JSON: {exc}") from exc - if not isinstance(value, dict): - raise renderer.DataValidationError("Spec JSON must be an object") - return value - - -def _string(value: object, path: str, *, required: bool = False) -> str: - if value is None and not required: - return "" - if not isinstance(value, str) or (required and not value.strip()): - suffix = "a non-empty string" if required else "a string" - raise renderer.DataValidationError(f"{path} must be {suffix}") - return value.strip() - - -def _strings(value: object, path: str, *, limit: int) -> list[str]: - if value is None: - return [] - if not isinstance(value, list) or not 1 <= len(value) <= limit: - raise renderer.DataValidationError(f"{path} must contain between 1 and {limit} strings") - result: list[str] = [] - for index, item in enumerate(value): - text = _string(item, f"{path}[{index}]", required=True) - _require_chinese(text, f"{path}[{index}]") - result.append(text) - return result - - -def _require_chinese(value: str, path: str) -> None: - if not any("\u3400" <= char <= "\u9fff" for char in value): - raise renderer.DataValidationError(f"{path} must contain Simplified Chinese reader-visible text") - - -def _optional_chinese(spec: Mapping[str, object], key: str) -> str: - value = _string(spec.get(key), key) - if value: - _require_chinese(value, key) - return value - - -def _media(value: object) -> dict[str, object] | None: - if value is None: - return None - if not isinstance(value, Mapping): - raise renderer.DataValidationError("media must be an object or null") - media_type = _string(value.get("type"), "media.type") or "image" - if media_type not in {"image", "video", "webpage"}: - raise renderer.DataValidationError("media.type must be image, video, or webpage") - media: dict[str, object] = {"type": media_type} - if media_type == "webpage": - media["pageUrl"] = _string( - value.get("pageUrl"), - "media.pageUrl", - required=True, - ) - media["title"] = _string( - value.get("title"), - "media.title", - required=True, - ) - if value.get("description"): - media["description"] = _string( - value.get("description"), - "media.description", - ) - media["embed"] = bool(value.get("embed", False)) - else: - media["mediaUrl"] = _string( - value.get("url"), - "media.url", - required=True, - ) - media["alt"] = _string(value.get("alt"), "media.alt") or "用户提供的新闻媒体" - if value.get("pageUrl"): - media["pageUrl"] = _string(value.get("pageUrl"), "media.pageUrl") - if media_type == "video" and value.get("posterUrl"): - media["posterUrl"] = _string( - value.get("posterUrl"), - "media.posterUrl", - ) - return media - - -def _replace_story_titles( - defaults: list[dict[str, object]], - titles: list[str], - *, - prefix: str, - visuals: tuple[str, ...], - include_summary: bool, - topic: str, -) -> list[dict[str, object]]: - items: list[dict[str, object]] = [] - for index, title in enumerate(titles): - item = copy.deepcopy(defaults[index % len(defaults)]) - item["id"] = f"{prefix}-{index + 1:02d}" - item["title"] = title - item["visual"] = visuals[index % len(visuals)] - if include_summary: - item["kicker"] = "专题" - item["summary"] = f"围绕{topic}整理的中文示意报道,页面内容可在设置中继续编辑。" - item["time"] = f"{index + 1} 小时前 · 示意" - elif "meta" in item: - item["meta"] = "视频示意" - item["duration"] = f"0{index + 3}:1{index}" - else: - item["kicker"] = "观察" - item["time"] = f"{index + 1} 小时前 · 示意" - items.append(item) - return items - - -def build_document(spec: dict[str, object]) -> dict[str, object]: - allowed = { - "topic", - "headline", - "headlineSummary", - "breaking", - "pageTitle", - "description", - "theme", - "kicker", - "byline", - "latestHeadlines", - "videoHeadlines", - "worldHeadlines", - "sideHeadlines", - "updates", - "analysisTitle", - "analysisText", - "media", - } - unknown = sorted(set(spec) - allowed) - if unknown: - raise renderer.DataValidationError("Unknown spec keys: " + ", ".join(unknown)) - - topic = _string(spec.get("topic"), "topic", required=True) - headline = _optional_chinese(spec, "headline") or f"{topic}发布最新计划,中文专题页面同步上线" - headline_summary = _optional_chinese(spec, "headlineSummary") or (f"本页面围绕{topic}整理品牌动态、内容观察与社区反馈,所有新闻和数据均为中文示意内容。") - breaking = _optional_chinese(spec, "breaking") or f"{topic}成为今日中文专题关注焦点" - page_title = _optional_chinese(spec, "pageTitle") or f"CNN 中文专题 · {topic}" - description = _optional_chinese(spec, "description") or f"围绕{topic}制作的 CNN 风格中文新闻示意页面。" - kicker = _optional_chinese(spec, "kicker") or "特别报道 · 中文示意" - byline = _string(spec.get("byline"), "byline") or "CNN 中文编辑台" - theme = _string(spec.get("theme"), "theme") or "light" - if theme not in renderer.THEMES: - raise renderer.DataValidationError("theme must be auto, light, or dark") - - latest_titles = _strings(spec.get("latestHeadlines"), "latestHeadlines", limit=12) or [ - f"{topic}推出新计划,首批体验活动正式启动", - f"从创意到产品:{topic}团队分享设计思路", - f"社区观察:用户如何参与{topic}内容共创", - ] - video_titles = _strings(spec.get("videoHeadlines"), "videoHeadlines", limit=8) or [ - f"现场直击:走进{topic}的创作空间", - f"三分钟了解{topic}最新进展", - f"幕后故事:{topic}团队讲述项目历程", - ] - world_titles = _strings(spec.get("worldHeadlines"), "worldHeadlines", limit=16) or [ - f"{topic}跨区域合作计划公布", - f"市场观察:{topic}相关讨论持续升温", - f"数字内容让{topic}触达更多社区", - f"可持续实践成为{topic}下一阶段重点", - ] - side_titles = _strings(spec.get("sideHeadlines"), "sideHeadlines", limit=4) or [ - f"{topic}如何把创意转化为日常体验", - f"团队访谈:{topic}背后的设计选择", - ] - update_titles = _strings(spec.get("updates"), "updates", limit=12) or [ - f"{topic}专题页面完成首轮内容更新", - "中文示意报道与栏目内容已同步", - "读者可通过设置按钮继续编辑页面数据", - ] - analysis_title = _optional_chinese(spec, "analysisTitle") or f"{topic}为何能够持续获得关注?" - analysis_text = _optional_chinese(spec, "analysisText") or (f"{topic}把清晰的内容表达、稳定的视觉识别和持续的社区互动结合起来。真正的考验在于后续内容能否保持一致,并形成可持续的参与机制。") - - data = renderer._read_json(renderer.DEFAULT_DATA_PATH) - data["site"].update( - { - "pageTitle": page_title, - "description": description, - "theme": theme, - "date": "中文示意页面 · 非实时新闻", - "utilityLinks": ["专题", "新闻简报"], - "marketSummary": [ - {"label": "关注度", "value": "示意", "direction": "flat"}, - {"label": "讨论度", "value": "非实时", "direction": "flat"}, - ], - "weather": "页面内容为中文示意", - "frontLabel": f"头版 · {topic}", - } - ) - data["navigation"]["drawerFooter"] = "CNN 中文专题 · 概念页面" - data["breaking"].update({"label": "快讯", "text": breaking, "time": "刚刚 · 示意"}) - - primary = data["lead"]["primary"] - primary.update( - { - "kicker": kicker, - "title": headline, - "summary": headline_summary, - "byline": byline, - "updated": "更新于刚刚 · 示意", - "readingTime": "阅读 5 分钟", - "visualLabel": f"{topic}中文专题示意视觉", - "tag": "专题", - "credit": "CNN / 中文示意视觉", - } - ) - if "media" in spec: - media = _media(spec.get("media")) - if media is None: - primary.pop("media", None) - else: - primary["media"] = media - - data["lead"]["sideStories"] = [ - { - "kicker": "延伸阅读", - "title": title, - "summary": f"从不同角度理解{topic}的内容表达与用户体验。", - "visual": "train" if index == 0 else None, - } - for index, title in enumerate(side_titles) - ] - data["lead"]["updatesTitle"] = "专题更新" - data["lead"]["updates"] = [{"time": f"0{9 + index}:0{index}", "text": text} for index, text in enumerate(update_titles)] - - data["latest"]["title"] = "最新报道" - data["latest"]["items"] = _replace_story_titles( - data["latest"]["items"], - latest_titles, - prefix="latest", - visuals=LATEST_VISUALS, - include_summary=True, - topic=topic, - ) - data["video"].update( - { - "title": "视频与现场", - "modalTitle": "CNN 中文视频", - "liveTitle": "专题视频", - "liveSubtitle": f"正在播放:{topic}", - "modalNote": "视频内容为互动示意,可通过上传按钮替换素材。", - } - ) - data["video"]["items"] = _replace_story_titles( - data["video"]["items"], - video_titles, - prefix="video", - visuals=VIDEO_VISUALS, - include_summary=False, - topic=topic, - ) - data["world"]["title"] = f"{topic}与相关观察" - data["world"]["items"] = _replace_story_titles( - data["world"]["items"], - world_titles, - prefix="world", - visuals=WORLD_VISUALS, - include_summary=False, - topic=topic, - ) - data["world"]["analysis"].update( - { - "kicker": "CNN 中文分析 · 示意", - "title": analysis_title, - "text": analysis_text, - "author": "中文专题编辑部", - "role": "页面内容策划", - "initial": "编", - } - ) - data["markets"].update( - { - "title": "趋势快照 · 示意数据", - "moreLabel": "非实时数据 →", - "items": [ - {"name": "内容关注", "value": "示意", "change": "非实时", "direction": "flat"}, - {"name": "社区讨论", "value": "示意", "change": "非实时", "direction": "flat"}, - {"name": "视频热度", "value": "示意", "change": "非实时", "direction": "flat"}, - {"name": "专题更新", "value": "示意", "change": "非实时", "direction": "flat"}, - ], - } - ) - data["newsletter"].update( - { - "title": f"订阅{topic}中文专题更新", - "text": "接收页面内容更新提醒;本表单仅用于交互演示。", - "button": "演示订阅", - "successPrefix": "演示订阅信息已记录:", - } - ) - data["footer"].update( - { - "tagline": f"从中文专题出发,持续关注{topic}。", - "copyright": "© 2026 CNN 风格概念页面。本页面内容均为中文示意。", - "legal": "隐私 · 条款 · 内容说明", - } - ) - return data - - -def _next_paths(output_dir: Path) -> tuple[Path, Path]: - for index in range(1, 1001): - stem = "cnn-page" if index == 1 else f"cnn-page-{index}" - data_path = output_dir / f"{stem}-data.json" - html_path = output_dir / f"{stem}.html" - if not data_path.exists() and not html_path.exists(): - return data_path, html_path - raise renderer.DataValidationError("Unable to allocate output filenames") - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--spec", type=Path, required=True) - parser.add_argument("--output-dir", type=Path, required=True) - args = parser.parse_args(argv) - try: - data = build_document(_read_spec(args.spec)) - summary = renderer.validate_document(data) - rendered = renderer.render_document( - data, - renderer.TEMPLATE_PATH.read_text(encoding="utf-8"), - renderer.RUNTIME_PATH.read_text(encoding="utf-8"), - ) - data_path, html_path = _next_paths(args.output_dir) - renderer._write_new( - data_path, - json.dumps(data, ensure_ascii=False, indent=2) + "\n", - ) - renderer._write_new(html_path, rendered) - except (renderer.DataValidationError, OSError, UnicodeError) as exc: - renderer._emit({"status": "error", "action": "fast-cnn-page", "error": str(exc)}) - return 2 - renderer._emit( - { - "status": "ok", - "action": "fast-cnn-page", - "data": str(data_path), - "html": str(html_path), - **summary, - } - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/offline-backend-20260512/backend/skills/public/cnn-page-generator/scripts/render_cnn_page.py b/offline-backend-20260512/backend/skills/public/cnn-page-generator/scripts/render_cnn_page.py deleted file mode 100644 index f3186794..00000000 --- a/offline-backend-20260512/backend/skills/public/cnn-page-generator/scripts/render_cnn_page.py +++ /dev/null @@ -1,571 +0,0 @@ -#!/usr/bin/env python3 -"""Validate CNN page data and inject it into the bundled HTML template.""" - -from __future__ import annotations - -import argparse -import html as html_lib -import json -import re -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_ROOT = Path(__file__).resolve().parent.parent -ASSETS_DIR = SKILL_ROOT / "assets" -DEFAULT_DATA_PATH = ASSETS_DIR / "default-data.json" -TEMPLATE_PATH = ASSETS_DIR / "cnn-template.html" -RUNTIME_PATH = ASSETS_DIR / "cnn-runtime.js" -DATA_MARKER = "__CNN_PAGE_DATA__" -RUNTIME_MARKER = "__CNN_PAGE_RUNTIME__" -TITLE_RE = re.compile(r"[^<]*", re.IGNORECASE) -MODULES = ( - "site", - "navigation", - "breaking", - "lead", - "latest", - "video", - "world", - "markets", - "newsletter", - "footer", -) -THEMES = {"auto", "light", "dark"} -DIRECTIONS = {"up", "down", "flat"} -LATEST_VISUALS = {"coast", "city", "deep-sea"} -VIDEO_VISUALS = {"night-city", "space", "music"} -WORLD_VISUALS = {"trade", "market", "payments", "energy"} -REMOTE_MEDIA_TYPES = {"image", "video", "webpage"} - - -class DataValidationError(ValueError): - """Raised when the CNN page JSON cannot satisfy the template contract.""" - - -def _read_json(path: Path) -> dict[str, Any]: - try: - data = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise DataValidationError(f"JSON file does not exist: {path}") from exc - except json.JSONDecodeError as exc: - raise DataValidationError( - f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" - ) from exc - if not isinstance(data, dict): - raise DataValidationError("Top-level JSON value must be an object") - return data - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array( - value: Any, - path: str, - errors: list[str], - *, - limit: int | None = None, -) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if limit is not None and len(value) > limit: - errors.append(f"{path} may contain at most {limit} items") - return value - - -def _non_empty_string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty string") - - -def _remote_url(value: Any, path: str, errors: list[str], *, required: bool = False, data_prefix: str = "") -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media_tree(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, list): - for index, item in enumerate(value): - _validate_remote_media_tree(item, f"{path}[{index}]", errors) - return - if not isinstance(value, dict): - return - media = value.get("media") - if isinstance(media, dict) and "type" in media: - media_path = f"{path}.media" - media_data = _mapping(media, media_path, errors) - media_type = media_data.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - errors.append(f"{media_path}.type must be one of: {', '.join(sorted(REMOTE_MEDIA_TYPES))}") - elif media_type in {"image", "video"}: - _remote_url(media_data.get("mediaUrl"), f"{media_path}.mediaUrl", errors, required=True, data_prefix=f"data:{media_type}/") - if media_type == "video": - _remote_url(media_data.get("posterUrl"), f"{media_path}.posterUrl", errors, data_prefix="data:image/") - if media_type == "webpage": - _remote_url(media_data.get("pageUrl"), f"{media_path}.pageUrl", errors, required=True) - _remote_url(media_data.get("mediaUrl"), f"{media_path}.mediaUrl", errors, data_prefix="data:image/") - if "embed" in media_data and not isinstance(media_data.get("embed"), bool): - errors.append(f"{media_path}.embed must be a boolean") - elif media_type in {"image", "video"}: - _remote_url(media_data.get("pageUrl"), f"{media_path}.pageUrl", errors) - for key, item in value.items(): - if key != "media": - _validate_remote_media_tree(item, f"{path}.{key}", errors) - - -def _href(value: Any, path: str, errors: list[str]) -> None: - _non_empty_string(value, path, errors) - if isinstance(value, str) and not value.startswith("#"): - errors.append(f"{path} must be an in-page anchor starting with #") - - -def _direction(value: Any, path: str, errors: list[str]) -> None: - if value not in DIRECTIONS: - errors.append(f"{path} must be one of: down, flat, up") - - -def _strings( - value: dict[str, Any], - keys: tuple[str, ...], - path: str, - errors: list[str], -) -> None: - for key in keys: - _non_empty_string(value.get(key), f"{path}.{key}", errors) - - -def _validate_story_items( - items: list[Any], - path: str, - errors: list[str], - *, - visuals: set[str], - include_summary: bool, - include_duration: bool = False, -) -> None: - seen: set[str] = set() - for index, value in enumerate(items): - item_path = f"{path}[{index}]" - item = _mapping(value, item_path, errors) - item_id = item.get("id") - _non_empty_string(item_id, f"{item_path}.id", errors) - if isinstance(item_id, str): - if item_id in seen: - errors.append(f"{item_path}.id must be unique") - seen.add(item_id) - keys = ["title"] - if include_summary: - keys.extend(["kicker", "summary", "time"]) - elif include_duration: - keys.extend(["meta", "duration"]) - else: - keys.extend(["kicker", "time"]) - _strings(item, tuple(keys), item_path, errors) - if item.get("visual") not in visuals: - errors.append( - f"{item_path}.visual must be one of: " - f"{', '.join(sorted(visuals))}" - ) - - -def validate_document(data: dict[str, Any]) -> dict[str, Any]: - errors: list[str] = [] - _validate_remote_media_tree(data, "$", errors) - for module in MODULES: - if module not in data: - errors.append(f"Missing top-level module: {module}") - - site = _mapping(data.get("site"), "site", errors) - _strings( - site, - ("brand", "pageTitle", "description", "date", "weather", "frontLabel"), - "site", - errors, - ) - if site.get("theme") not in THEMES: - errors.append("site.theme must be one of: auto, dark, light") - utility_links = _array( - site.get("utilityLinks"), - "site.utilityLinks", - errors, - limit=6, - ) - for index, value in enumerate(utility_links): - _non_empty_string(value, f"site.utilityLinks[{index}]", errors) - summary_items = _array( - site.get("marketSummary"), - "site.marketSummary", - errors, - limit=6, - ) - for index, value in enumerate(summary_items): - item = _mapping(value, f"site.marketSummary[{index}]", errors) - _strings( - item, - ("label", "value"), - f"site.marketSummary[{index}]", - errors, - ) - _direction( - item.get("direction"), - f"site.marketSummary[{index}].direction", - errors, - ) - - navigation = _mapping(data.get("navigation"), "navigation", errors) - _strings( - navigation, - ("searchPlaceholder", "searchButton", "liveLabel", "drawerFooter"), - "navigation", - errors, - ) - nav_items = _array( - navigation.get("items"), - "navigation.items", - errors, - limit=10, - ) - if not nav_items: - errors.append("navigation.items must contain at least one item") - for index, value in enumerate(nav_items): - item = _mapping(value, f"navigation.items[{index}]", errors) - _non_empty_string( - item.get("label"), - f"navigation.items[{index}].label", - errors, - ) - _href(item.get("href"), f"navigation.items[{index}].href", errors) - - breaking = _mapping(data.get("breaking"), "breaking", errors) - _strings(breaking, ("label", "text", "time"), "breaking", errors) - - lead = _mapping(data.get("lead"), "lead", errors) - primary = _mapping(lead.get("primary"), "lead.primary", errors) - _strings( - primary, - ( - "kicker", - "title", - "summary", - "byline", - "updated", - "readingTime", - "visualLabel", - "tag", - "credit", - ), - "lead.primary", - errors, - ) - if primary.get("visual") != "summit": - errors.append("lead.primary.visual must be: summit") - side_stories = _array( - lead.get("sideStories"), - "lead.sideStories", - errors, - limit=4, - ) - for index, value in enumerate(side_stories): - item_path = f"lead.sideStories[{index}]" - item = _mapping(value, item_path, errors) - _strings(item, ("kicker", "title", "summary"), item_path, errors) - if item.get("visual") not in {None, "train"}: - errors.append(f"{item_path}.visual must be train or null") - _non_empty_string( - lead.get("updatesTitle"), - "lead.updatesTitle", - errors, - ) - updates = _array( - lead.get("updates"), - "lead.updates", - errors, - limit=12, - ) - for index, value in enumerate(updates): - item = _mapping(value, f"lead.updates[{index}]", errors) - _strings(item, ("time", "text"), f"lead.updates[{index}]", errors) - - latest = _mapping(data.get("latest"), "latest", errors) - _strings(latest, ("title", "moreLabel"), "latest", errors) - latest_items = _array( - latest.get("items"), - "latest.items", - errors, - limit=12, - ) - if not latest_items: - errors.append("latest.items must contain at least one item") - _validate_story_items( - latest_items, - "latest.items", - errors, - visuals=LATEST_VISUALS, - include_summary=True, - ) - - video = _mapping(data.get("video"), "video", errors) - _strings( - video, - ( - "title", - "moreLabel", - "modalTitle", - "liveTitle", - "liveSubtitle", - "modalNote", - ), - "video", - errors, - ) - video_items = _array( - video.get("items"), - "video.items", - errors, - limit=8, - ) - if not video_items: - errors.append("video.items must contain at least one item") - _validate_story_items( - video_items, - "video.items", - errors, - visuals=VIDEO_VISUALS, - include_summary=False, - include_duration=True, - ) - - world = _mapping(data.get("world"), "world", errors) - _strings(world, ("title", "moreLabel"), "world", errors) - world_items = _array( - world.get("items"), - "world.items", - errors, - limit=16, - ) - if not world_items: - errors.append("world.items must contain at least one item") - _validate_story_items( - world_items, - "world.items", - errors, - visuals=WORLD_VISUALS, - include_summary=False, - ) - analysis = _mapping(world.get("analysis"), "world.analysis", errors) - _strings( - analysis, - ("kicker", "title", "text", "author", "role", "initial"), - "world.analysis", - errors, - ) - - markets = _mapping(data.get("markets"), "markets", errors) - _strings(markets, ("title", "moreLabel"), "markets", errors) - market_items = _array( - markets.get("items"), - "markets.items", - errors, - limit=8, - ) - if not market_items: - errors.append("markets.items must contain at least one item") - for index, value in enumerate(market_items): - item = _mapping(value, f"markets.items[{index}]", errors) - _strings( - item, - ("name", "value", "change"), - f"markets.items[{index}]", - errors, - ) - _direction( - item.get("direction"), - f"markets.items[{index}].direction", - errors, - ) - - newsletter = _mapping(data.get("newsletter"), "newsletter", errors) - _strings( - newsletter, - ("title", "text", "placeholder", "button", "successPrefix"), - "newsletter", - errors, - ) - - footer = _mapping(data.get("footer"), "footer", errors) - _strings(footer, ("tagline", "copyright", "legal"), "footer", errors) - columns = _array( - footer.get("columns"), - "footer.columns", - errors, - limit=6, - ) - for index, value in enumerate(columns): - column_path = f"footer.columns[{index}]" - column = _mapping(value, column_path, errors) - _non_empty_string(column.get("title"), f"{column_path}.title", errors) - links = _array( - column.get("links"), - f"{column_path}.links", - errors, - limit=8, - ) - for link_index, link_value in enumerate(links): - link_path = f"{column_path}.links[{link_index}]" - link = _mapping(link_value, link_path, errors) - _non_empty_string(link.get("label"), f"{link_path}.label", errors) - _href(link.get("href"), f"{link_path}.href", errors) - - if errors: - raise DataValidationError("; ".join(errors)) - - return { - "modules": list(MODULES), - "counts": { - "navigation": len(nav_items), - "side_stories": len(side_stories), - "updates": len(updates), - "latest": len(latest_items), - "videos": len(video_items), - "world": len(world_items), - "markets": len(market_items), - "footer_columns": len(columns), - }, - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - if template.count(DATA_MARKER) != 1: - raise DataValidationError( - "Bundled template must contain exactly one data marker" - ) - if template.count(RUNTIME_MARKER) != 1: - raise DataValidationError( - "Bundled template must contain exactly one runtime marker" - ) - serialized = json.dumps(data, ensure_ascii=False, indent=2).replace( - "{html_lib.escape(page_title)}", - rendered, - count=1, - ) - return rendered - - -def _write_new(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - try: - with path.open("x", encoding="utf-8", newline="\n") as handle: - handle.write(content) - except FileExistsError as exc: - raise DataValidationError( - f"Refusing to overwrite existing file: {path}" - ) from exc - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False)) - - -def _command_init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA_PATH) - summary = validate_document(data) - _write_new( - output, - json.dumps(data, ensure_ascii=False, indent=2) + "\n", - ) - return {"status": "ok", "action": "init", "output": str(output), **summary} - - -def _command_validate(input_path: Path) -> dict[str, Any]: - data = _read_json(input_path) - summary = validate_document(data) - return {"status": "ok", "action": "validate", "input": str(input_path), **summary} - - -def _command_render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - template = TEMPLATE_PATH.read_text(encoding="utf-8") - runtime = RUNTIME_PATH.read_text(encoding="utf-8") - rendered = render_document(data, template, runtime) - _write_new(output, rendered) - summary = validate_document(data) - return { - "status": "ok", - "action": "render", - "input": str(input_path), - "output": str(output), - **summary, - } - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - init_parser = subparsers.add_parser( - "init", - help="Create a new editable JSON document", - ) - init_parser.add_argument("--output", type=Path, required=True) - - validate_parser = subparsers.add_parser( - "validate", - help="Validate an existing JSON document", - ) - validate_parser.add_argument("--input", type=Path, required=True) - - render_parser = subparsers.add_parser( - "render", - help="Render validated JSON into the bundled HTML template", - ) - render_parser.add_argument("--input", type=Path, required=True) - render_parser.add_argument("--output", type=Path, required=True) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - args = parser.parse_args(argv) - try: - if args.command == "init": - result = _command_init(args.output) - elif args.command == "validate": - result = _command_validate(args.input) - else: - result = _command_render(args.input, args.output) - except (DataValidationError, OSError) as exc: - _emit({"status": "error", "action": args.command, "error": str(exc)}) - return 2 - _emit(result) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) - diff --git a/offline-backend-20260512/backend/skills/public/facebook-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/facebook-page-generator/SKILL.md deleted file mode 100644 index a959efcc..00000000 --- a/offline-backend-20260512/backend/skills/public/facebook-page-generator/SKILL.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -name: facebook-page-generator -description: 生成或绘制 Facebook/Facebook 风格的可交付社交信息流 HTML 网页;仅在用户明确要求 Facebook 网页、Facebook 页面或点名本技能时使用。先把材料或检索结果整理成七模块 JSON,再用内置模板确定性渲染,保留右上角可视化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# Facebook 页面生成器 - -本技能使用固定模板生成单文件 HTML。模型只负责材料收集和 JSON,禁止重新手写页面结构、CSS 或渲染脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块继续编辑 JSON,并通过「应用并渲染」即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说「生成/画/制作 Facebook 网页」「Facebook 风格社交信息流页面」或点名 `facebook-page-generator` 时使用。 -- 仅讨论 Facebook、分析社交媒体、编写普通帖子文案或要求其他平台页面时不要使用。 -- 这是单页社交信息流模板,不改用通用多页门户模板。 - -## 单帖快速路径(强制) - -只要请求是「一个账号 + 一条动态 + N 条热评」,无论是否指定图片、总评论数、热评主题或具体热评文本,都必须走本路径。模型只生成一份小型规格 JSON;脚本负责补齐七模块数据、内部校验并一次渲染 HTML。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/facebook-single-post-spec.json`。动态正文和评论必须使用简体中文;人物姓名、账号、品牌与必要缩写可保留原文。最小规格示例: - - ```json - { - "author": "账号名称", - "verified": true, - "text": "中文动态正文", - "topic": "中心话题", - "tags": ["话题标签"], - "displayCommentCount": 20, - "totalCommentCount": 10000, - "commentTopics": ["中美经济影响", "两岸关系"], - "likes": 0, - "shares": 0 - } - ``` - - `displayCommentCount` 是实际预载展示数,最多 100;`totalCommentCount` 是页面指标,可大于展示数。脚本会围绕 `commentTopics` 自动生成不同中文热评。只有用户明确给出逐条评论时才增加 `comments: ["中文评论", ...]`;未写满的部分仍由脚本补齐。媒体直链放入 `media: {"type":"image|video","url":"...","alt":"中文说明"}`;没有可靠直链时省略 `media`,直接沿用模板示例图,禁止搜索或造 SVG。 - -2. 只执行一次: - - ```text - python /mnt/skills/public/facebook-page-generator/scripts/fast_single_post.py --spec /mnt/user-data/outputs/facebook-single-post-spec.json --output-dir /mnt/user-data/outputs - ``` - -3. stdout 为 `status: ok` 后,立即用 `present_files` 交付返回的 `data` 与 `html`;最终回复只需一句完成说明。 - -本路径禁止读取默认数据、数据契约、模板、运行时或脚本源码,禁止运行 `--help`、`init`、独立 `validate`/`render`,禁止先生成再补丁,禁止创建临时 Python/清理/路径探测脚本。执行中不要复述需求、讨论路径或逐步解释;除工具调用外最多发一条简短进度。若脚本返回规格错误,只修正规格 JSON 一次并重跑,不得转入源码排查。用户说不搜索或直接生成时,直接把其描述写成中文情景数据,不检索、不填写 `sourceUrl`、不添加情景说明条。只有用户明确要求两条及以上动态,或明确要求改造 stories/联系人/通知等其他模块时,才进入通用工作流。 - -## 通用工作流(仅限非单帖场景) - -1. 读取当前会话材料、上传附件和已有产物,确定页面主题、当前用户、动态内容、联系人与通知。 -2. 当用户要求基于公开资料、主题涉及近期事件,或明确要求检索时,先用可用检索工具做至多两批聚焦检索;用户明确说不要检索或直接生成时不得联网。只把有依据的内容写入动态。主题未给出且没有材料时,使用中性示例内容,不虚构实时新闻,也不在帖子中添加「情景模拟页面」或「演示内容」说明条。 - - 如果动态被描述成政治人物、公共人物或机构真实发布的内容,必须检索并核对其官方账号原帖或可靠一手来源,再填写 `sourceUrl`;无法核验时不得声称该内容已真实发布,且不生成 `disclosure` 字段。 -3. 在一个新的工作路径初始化完整 JSON: - - ```text - python /mnt/skills/public/facebook-page-generator/scripts/render_facebook_page.py init --output /mnt/user-data/outputs/facebook-page-data.json - ``` - -4. 读取并修改该 JSON。七个顶层模块必须全部保留:`site`、`navigation`、`stories`、`posts`、`sidebar`、`contacts`、`inbox`。字段不确定时读取 [references/data-schema.md](references/data-schema.md);默认数据只作为结构样例,不要把无关示例事实带入最终页面。 -5. 写完后先校验: - - ```text - python /mnt/skills/public/facebook-page-generator/scripts/render_facebook_page.py validate --input /mnt/user-data/outputs/facebook-page-data.json - ``` - -6. 校验通过后确定性渲染;输出文件必须放在 `outputs/`: - - ```text - python /mnt/skills/public/facebook-page-generator/scripts/render_facebook_page.py render --input /mnt/user-data/outputs/facebook-page-data.json --output /mnt/user-data/outputs/facebook-page.html - ``` - -7. 检查脚本 stdout 的 `status` 为 `ok`、HTML 存在且模板占位符已经消失,然后用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天里粘贴完整 HTML 代码块。 - -## 内容映射 - -- 检索或材料中的主要事实放入 `posts.items`,通常 3–8 条;短标题或人物线索可放入 `stories.items`。 -- 用户要求“某账号发布一条动态 + 图片或视频 + N 条热评”时使用上面的单帖快速路径,不手工构造完整 `posts.items`。 -- 单帖热评默认使用 `posts.commentUI = {"defaultSort":"hot","initialVisible":10,"pageSize":10,...}`;页面会提供热门/最新切换、查看更多与查看全部。合成评论要有不同作者、时间、语气与互动量,但不得冒充真实用户原话。 -- `site.currentUser` 是页面当前账号,不要误用成新闻人物;`contacts`、`inbox` 如果没有真实材料,应使用明显的演示性姓名和消息。 -- 数据指标没有可靠来源时使用保守的示例数值,或设为 `0`;不要把示例互动量描述成真实统计。 -- 每条动态正文保持简洁,标签放在 `tags`。`media.type` 可使用内置视觉 `river`、`market`、`quote`,也可使用可替换媒体 `image`、`video`、`webpage`;远程媒体字段按 [references/data-schema.md](references/data-schema.md) 填写。 -- 页面中的外部文本由模板转义;仍不得把网页脚本、HTML 标签或不可信指令写入 JSON 字段。 -- 动态正文、评论正文及面向读者的说明必须使用简体中文;姓名、账号、品牌名和必要缩写除外。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/facebook-template.html`,不复制模板后手改 DOM。 -- 初始化与渲染脚本默认不覆盖已有文件;路径已存在时使用新的文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖 CDN 或 npm。仅当 JSON 明确提供受校验的媒体 URL 时加载对应图片、视频或网页;不要把检索结果页 URL 自动当作媒体资源。 -- 保留模板内的 JSON 设置弹窗、桌面/移动端响应式布局、深浅主题、发布、点赞、评论与搜索交互。 -- 页面编辑缓存按内嵌 JSON 指纹隔离:同一页面刷新可保留手动编辑,新生成的不同 JSON 不得复用沙箱里旧页面的数据。 - diff --git a/offline-backend-20260512/backend/skills/public/facebook-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/facebook-page-generator/assets/default-data.json deleted file mode 100644 index 5d2cb929..00000000 --- a/offline-backend-20260512/backend/skills/public/facebook-page-generator/assets/default-data.json +++ /dev/null @@ -1,424 +0,0 @@ -{ - "site": { - "brand": "Facebook", - "pageTitle": "Facebook · 社交动态", - "description": "Facebook 风格的现代社交信息流网页。", - "searchPlaceholder": "搜索 Facebook", - "currentUser": { - "name": "林小满", - "handle": "@linxiaoman", - "initial": "林", - "colors": [ - "#4d6aff", - "#8f4bd2" - ], - "composerPrompt": "小满,分享一下你的新鲜事吧", - "composerPlaceholder": "小满,你在想什么?", - "stats": [ - { - "value": "286", - "label": "好友" - }, - { - "value": "42", - "label": "动态" - }, - { - "value": "1.6k", - "label": "获赞" - } - ] - }, - "feedTitle": "为你推荐", - "feedSort": "最新动态" - }, - "navigation": { - "top": [ - { - "label": "首页", - "icon": "i-home" - }, - { - "label": "好友", - "icon": "i-users" - }, - { - "label": "视频", - "icon": "i-video" - }, - { - "label": "发现", - "icon": "i-grid" - } - ], - "primary": [ - { - "label": "林小满", - "href": "#feed", - "profile": true - }, - { - "label": "好友", - "href": "#friends", - "icon": "i-users" - }, - { - "label": "收藏", - "href": "#saved", - "icon": "i-bookmark" - }, - { - "label": "视频", - "href": "#video", - "icon": "i-video" - }, - { - "label": "好物集市", - "href": "#market", - "icon": "i-store" - }, - { - "label": "活动", - "href": "#events", - "icon": "i-calendar" - }, - { - "label": "往日回忆", - "href": "#memories", - "icon": "i-clock" - } - ], - "shortcutsTitle": "你的快捷方式", - "shortcuts": [ - { - "label": "设计灵感实验室", - "badge": "UI", - "href": "#design", - "colors": [ - "#ff9e64", - "#ed5374" - ] - }, - { - "label": "城市夜跑联盟", - "badge": "RUN", - "href": "#running", - "colors": [ - "#34c58a", - "#2687a7" - ] - }, - { - "label": "周末阅读会", - "badge": "BOOK", - "href": "#reading", - "colors": [ - "#f1b849", - "#d85b69" - ] - } - ], - "footer": "隐私 · 条款 · 帮助 · Facebook © 2026", - "mobile": [ - { - "label": "首页", - "icon": "i-home" - }, - { - "label": "好友", - "icon": "i-users" - }, - { - "label": "发布", - "icon": "i-plus", - "action": "composer" - }, - { - "label": "视频", - "icon": "i-video" - }, - { - "label": "消息", - "icon": "i-message", - "drawer": "messagesDrawer" - } - ] - }, - "stories": { - "createLabel": "创建动态", - "items": [ - { - "name": "许知夏", - "initial": "许", - "colors": [ - "#15a5a5", - "#57c873" - ], - "background": "story-1" - }, - { - "name": "周野", - "initial": "周", - "colors": [ - "#ff7657", - "#d54d9d" - ], - "background": "story-2" - }, - { - "name": "陈默", - "initial": "陈", - "colors": [ - "#2d8d78", - "#57c995" - ], - "background": "story-3" - }, - { - "name": "宋可心", - "initial": "宋", - "colors": [ - "#a95cd8", - "#5365da" - ], - "background": "story-4" - } - ] - }, - "posts": { - "commentUI": { - "defaultSort": "hot", - "initialVisible": 10, - "pageSize": 10, - "hotLabel": "热门", - "latestLabel": "最新", - "showMoreLabel": "查看更多", - "showAllLabel": "查看全部" - }, - "items": [ - { - "id": "morning-run", - "author": "许知夏", - "initial": "许", - "colors": [ - "#15a5a5", - "#57c873" - ], - "verified": true, - "time": "38 分钟前", - "privacyIcon": "i-globe", - "text": "把清晨六点的风装进口袋。沿河跑完五公里,整座城市才刚刚醒来。", - "tags": [ - "城市晨跑", - "秋日计划" - ], - "media": { - "type": "river", - "label": "滨江绿道 · 06:18", - "alt": "河岸清晨的抽象风景插画" - }, - "metrics": { - "likes": 128, - "comments": 18, - "shares": 6 - }, - "comments": [ - { - "author": "周野", - "initial": "周", - "colors": [ - "#ff7657", - "#d54d9d" - ], - "text": "照片里的光也太温柔了,下次一起!" - } - ] - }, - { - "id": "design-note", - "author": "陈默", - "initial": "陈", - "colors": [ - "#2d8d78", - "#57c995" - ], - "verified": false, - "time": "2 小时前", - "privacyIcon": "i-users", - "text": "今天的设计札记:真正好用的产品,往往不是替你完成所有事情,而是在你需要的时候,恰好递来一个合适的支点。", - "tags": [], - "media": { - "type": "quote", - "quote": "减少一次犹豫,比增加一个功能更有价值。", - "attribution": "陈默 · 产品设计札记 024" - }, - "metrics": { - "likes": 86, - "comments": 9, - "shares": 12 - }, - "comments": [] - }, - { - "id": "weekend-market", - "author": "宋可心", - "initial": "宋", - "colors": [ - "#a95cd8", - "#5365da" - ], - "verified": true, - "time": "昨天 20:46", - "privacyIcon": "i-globe", - "text": "周末的手作市集圆满收摊。谢谢每一个停下来聊天、喝咖啡、带走小物件的朋友。下个月再见!", - "tags": [ - "城市小店" - ], - "media": { - "type": "market", - "label": "南山创意公园 · 周末市集", - "alt": "温暖的周末市集抽象插画" - }, - "metrics": { - "likes": 243, - "comments": 32, - "shares": 21 - }, - "comments": [] - } - ] - }, - "sidebar": { - "birthday": { - "title": "今天有 2 位好友生日", - "text": "送上一句温暖的祝福吧。" - }, - "sponsor": { - "heading": "赞助内容", - "title": "灵感设计周", - "domain": "designweek.example", - "href": "#sponsor" - } - }, - "contacts": { - "title": "联系人", - "items": [ - { - "name": "许知夏", - "initial": "许", - "colors": [ - "#15a5a5", - "#57c873" - ], - "online": true - }, - { - "name": "周野", - "initial": "周", - "colors": [ - "#ff7657", - "#d54d9d" - ], - "online": true - }, - { - "name": "陈默", - "initial": "陈", - "colors": [ - "#2d8d78", - "#57c995" - ], - "online": true - }, - { - "name": "宋可心", - "initial": "宋", - "colors": [ - "#a95cd8", - "#5365da" - ], - "online": false - }, - { - "name": "顾念", - "initial": "顾", - "colors": [ - "#e67a43", - "#f0b449" - ], - "online": true - }, - { - "name": "沈言", - "initial": "沈", - "colors": [ - "#4b7ede", - "#4eb9c2" - ], - "online": false - } - ] - }, - "inbox": { - "notifications": [ - { - "name": "许知夏", - "initial": "许", - "colors": [ - "#15a5a5", - "#57c873" - ], - "text": "赞了你的动态。", - "time": "12 分钟前", - "unread": true - }, - { - "name": "周野", - "initial": "周", - "colors": [ - "#ff7657", - "#d54d9d" - ], - "text": "在评论中提到了你。", - "time": "48 分钟前", - "unread": true - }, - { - "name": "宋可心", - "initial": "宋", - "colors": [ - "#a95cd8", - "#5365da" - ], - "text": "邀请你参加「周末创意市集」。", - "time": "2 小时前", - "unread": false - } - ], - "messages": [ - { - "name": "周野", - "initial": "周", - "colors": [ - "#ff7657", - "#d54d9d" - ], - "text": "周六还是老地方见?", - "time": "刚刚", - "unread": true - }, - { - "name": "陈默", - "initial": "陈", - "colors": [ - "#2d8d78", - "#57c995" - ], - "text": "我把新版本发你啦。", - "time": "1 小时前", - "unread": false - } - ] - } -} - diff --git a/offline-backend-20260512/backend/skills/public/facebook-page-generator/assets/facebook-template.html b/offline-backend-20260512/backend/skills/public/facebook-page-generator/assets/facebook-template.html deleted file mode 100644 index 16b867f1..00000000 --- a/offline-backend-20260512/backend/skills/public/facebook-page-generator/assets/facebook-template.html +++ /dev/null @@ -1,1582 +0,0 @@ - - - - - - - - Facebook · 社交动态 - - - - - - -
-
- - Circle - -
- -
- - - - - -
-
- -
- - -
-

Circle 社交动态首页

-
-
-
- -
-
-
- - - -
-
- -

- -
-
-
- 许 - - -
-

把清晨六点的风装进口袋。沿河跑完五公里,整座城市才刚刚醒来。#城市晨跑 #秋日计划

- - -
-
-
周
周野照片里的光也太温柔了,下次一起!
-
-
-
- -
-
- 陈 - - -
-

今天的设计札记:真正好用的产品,往往不是替你完成所有事情,而是在你需要的时候,恰好递来一个合适的支点。

-

“减少一次犹豫,比增加一个功能更有价值。”

陈默 · 产品设计札记 024
- -
-
-
- -
-
- 宋 - - -
-

周末的手作市集圆满收摊。谢谢每一个停下来聊天、喝咖啡、带走小物件的朋友。下个月再见! #城市小店

- - -
-
-
-
- -
- - -
- - - - - - - - - - -
- - - - - - - diff --git a/offline-backend-20260512/backend/skills/public/facebook-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/facebook-page-generator/references/data-schema.md deleted file mode 100644 index 99e8978e..00000000 --- a/offline-backend-20260512/backend/skills/public/facebook-page-generator/references/data-schema.md +++ /dev/null @@ -1,76 +0,0 @@ -# Facebook 页面 JSON 契约 - -源文件必须是一个 JSON 对象,并包含以下七个顶层模块。字段可增加,但不能删除顶层模块。 - -## `site` - -- `brand`、`pageTitle`、`description`、`searchPlaceholder`、`feedTitle`、`feedSort`:字符串。 -- `currentUser`:当前页面账号,包含 `name`、`handle`、`initial`、`colors`(两个 `#RRGGBB`)、`composerPrompt`、`composerPlaceholder`。 -- `currentUser.stats`:数组,每项 `{ "value": "286", "label": "好友" }`。 - -## `navigation` - -- `top`:顶部导航项 `{label, icon}`。 -- `primary`:左侧主导航项 `{label, icon, href}`;个人主页项可设置 `profile: true`。 -- `shortcutsTitle`、`footer`:字符串。 -- `shortcuts`:快捷入口 `{label, badge, href, colors}`。 -- `mobile`:移动端导航 `{label, icon}`,可选 `action: "composer"` 或 `drawer: "messages|notifications"`。 -- `href` 只使用页面内锚点,例如 `#feed`、`#friends`。 - -可用图标:`i-home`、`i-users`、`i-video`、`i-grid`、`i-message`、`i-bell`、`i-bookmark`、`i-calendar`、`i-store`、`i-clock`、`i-globe`、`i-circle`。 - -## `stories` - -- `createLabel`:创建动态按钮文字。 -- `items`:0–20 项,每项 `{name, initial, colors, background}`。 -- `background` 只能是 `story-1` 至 `story-5`。 - -## `posts` - -- `items`:1–30 条动态;`id` 必须唯一。 -- 可选 `commentUI` 控制预置评论区: - - `defaultSort`:`hot`(按点赞数、子回复数降序)或 `latest`(保持 JSON 顺序,约定最新在前)。 - - `initialVisible`、`pageSize`:1–100 的整数;建议均为 `10`。 - - `hotLabel`、`latestLabel`、`showMoreLabel`、`showAllLabel`:界面中文文案。 -- 每项核心字段:`id`、`author`、`initial`、`colors`、`verified`、`time`、`privacyIcon`、`text`、`tags`。 -- `metrics`:`{likes, comments, shares}`,均为非负数字。 -- `comments`:0–100 条预置评论。每项至少包含 `{author, text}`,建议完整填写 `id`(同一动态内唯一)、`author`、`handle`、`initial`、`colors`、`verified`、`time`、`text`、`likes`、`replyCount`。`likes` 和 `replyCount` 为非负数字。 -- 当预置 50 条热评时,数组必须实际包含 50 个对象,且 `metrics.comments >= 50`;不能用一条摘要冒充 50 条评论。 -- 可选 `commentsExpanded`:布尔值;需要页面打开后直接展示热评时设为 `true`。 -- 可选 `sourceUrl`:已核验原帖的 `http(s)` 链接。不再生成或展示 `disclosure`;旧 JSON 中的该字段会被兼容忽略。 -- 可选 `media`: - - `{ "type": "river", "label": "...", "alt": "..." }` - - `{ "type": "market", "label": "...", "alt": "..." }` - - `{ "type": "quote", "quote": "...", "attribution": "..." }` - - 图片:`{ "type": "image", "mediaUrl": "https://.../image.jpg", "alt": "图片说明", "pageUrl": "https://..." }`。 - - 视频:`{ "type": "video", "mediaUrl": "https://.../video.mp4", "posterUrl": "https://.../cover.jpg", "alt": "视频说明", "pageUrl": "https://..." }`。 - - 网页:`{ "type": "webpage", "pageUrl": "https://...", "title": "网页标题", "description": "网页摘要", "mediaUrl": "https://.../cover.jpg", "embed": false }`。`embed: true` 会尝试在卡片内嵌网页;目标网站拒绝 iframe 时仍可点击链接在新窗口打开。 - -远程媒体 URL 只接受 `http://` 或 `https://`;图片和视频的 `mediaUrl` 也可使用对应的 `data:image/...`、`data:video/...`。页面会过滤其他协议,所有外链使用 `noopener noreferrer`。 - -### 单帖 + 热评模式 - -用户要求“某账号发布一条动态,配图,下方 N 条热评”时,`posts.items` 只放这一条动态;将图片写入该动态的 `media`,将 N 条评论逐条写入 `comments`,并设置 `commentsExpanded: true`。页面默认先显示 `commentUI.initialVisible` 条,可切换热门/最新、分批查看更多或查看全部。 - -## `sidebar` - -- `birthday`:`{title, text}`。 -- `sponsor`:`{heading, title, domain, href}`。模板只接受安全的页面内锚点;没有赞助内容时写成演示推荐卡,不伪造真实广告。 - -## `contacts` - -- `title`:区块标题。 -- `items`:0–30 项,每项 `{name, initial, colors, online}`。 - -## `inbox` - -- `notifications`、`messages`:各 0–30 项。 -- 每项包含 `{name, initial, colors, text, time, unread}`。 - -## 示例边界 - -- `assets/default-data.json` 是结构起点,不是事实来源。 -- 合成热评不得写成真实用户原话,也不在帖子内添加情景模拟说明条。 -- 颜色必须是完整六位十六进制;无效颜色会被模板回退。 -- 所有 JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。 - diff --git a/offline-backend-20260512/backend/skills/public/facebook-page-generator/scripts/fast_single_post.py b/offline-backend-20260512/backend/skills/public/facebook-page-generator/scripts/fast_single_post.py deleted file mode 100644 index 6ec777d4..00000000 --- a/offline-backend-20260512/backend/skills/public/facebook-page-generator/scripts/fast_single_post.py +++ /dev/null @@ -1,375 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a complete single-post Facebook page in one deterministic call.""" - -from __future__ import annotations - -import argparse -import json -import sys -from collections.abc import Mapping -from pathlib import Path - -import render_facebook_page as renderer - -COMMENT_NAMES = ( - "林夏", - "周野", - "陈默", - "顾念", - "沈言", - "宋可心", - "许知夏", - "苏小鹿", - "唐梨", - "陆川", - "乔安", - "白露", - "温言", - "江禾", - "叶青", - "程澄", - "余舟", - "夏满", - "何川", - "简宁", - "顾南", - "闻溪", - "林深", - "周晴", - "陈一", -) -COMMENT_COLORS = ( - ("#4d6aff", "#8f4bd2"), - ("#15a5a5", "#57c873"), - ("#ff7657", "#d54d9d"), - ("#2d8d78", "#57c995"), - ("#a95cd8", "#5365da"), - ("#f59e0b", "#ef4444"), - ("#0ea5e9", "#10b981"), - ("#6366f1", "#ec4899"), -) -COMMENT_TEMPLATES = ( - "这条内容把“{topic}”讲得很清楚。", - "图片和文字放在一起,信息一下就完整了。", - "这个角度很有意思,我会继续关注后续。", - "最打动我的是内容里的细节,而不是结论本身。", - "看完之后,我对“{topic}”多了一层理解。", - "表达很克制,但重点都说到了。", - "这张图和正文的呼应做得很好。", - "我更关心接下来会有哪些具体变化。", - "信息量不少,但读起来并不费力。", - "这件事值得从不同立场继续讨论。", - "正文里提到的关键点,我也有同感。", - "先收藏,等有更多资料时再回来看看。", - "比起口号,我更喜欢这种具体的表达。", - "“{topic}”确实是这条内容最值得讨论的部分。", - "希望后续能补充更多背景和时间线。", - "这个内容让我想起了之前看到的一些讨论。", - "观点很鲜明,也留下了继续思考的空间。", - "如果有更多数据支持,会更有说服力。", - "我赞同其中一部分,也想听听不同意见。", - "这条动态的节奏和重点都很舒服。", - "从普通人的视角看,这个话题很有现实感。", - "细节比标题更值得反复看。", - "希望评论区能保持理性,认真聊聊“{topic}”。", - "内容不长,但已经把核心问题交代清楚了。", - "期待作者继续更新这个系列。", -) -COMMENT_PREFIXES = ("", "换个角度看,", "补充一点,", "我更关注的是,") - - -def _require_chinese(value: str, path: str) -> None: - if not any("\u3400" <= char <= "\u9fff" for char in value): - raise renderer.DataValidationError(f"{path} must contain Simplified Chinese reader-visible text") - - -def _comments( - count: int, - topics: list[str], - custom_comments: list[object] | None = None, -) -> list[dict[str, object]]: - custom_comments = custom_comments or [] - result: list[dict[str, object]] = [] - for index in range(count): - name = COMMENT_NAMES[index % len(COMMENT_NAMES)] - cycle = index // len(COMMENT_NAMES) - if cycle: - name = f"{name}{cycle + 1}" - template = COMMENT_TEMPLATES[index % len(COMMENT_TEMPLATES)] - prefix = COMMENT_PREFIXES[(index // len(COMMENT_TEMPLATES)) % len(COMMENT_PREFIXES)] - colors = COMMENT_COLORS[index % len(COMMENT_COLORS)] - topic = topics[index % len(topics)] - generated_text = prefix + template.format(topic=topic) - if "{topic}" not in template: - generated_text = f"关于“{topic}”,{generated_text}" - comment: dict[str, object] = { - "id": f"comment-{index + 1:03d}", - "author": name, - "handle": f"@comment_{index + 1:03d}", - "initial": name[:1], - "colors": list(colors), - "verified": False, - "time": f"{index + 1} 分钟前", - "text": generated_text, - "likes": max(0, (count - index) * 7), - "replyCount": index % 5, - } - if index < len(custom_comments): - custom = custom_comments[index] - if isinstance(custom, str): - if not custom.strip(): - raise renderer.DataValidationError(f"comments[{index}] must not be empty") - comment["text"] = custom.strip() - elif isinstance(custom, Mapping): - text = custom.get("text") - if not isinstance(text, str) or not text.strip(): - raise renderer.DataValidationError(f"comments[{index}].text must be a non-empty string") - comment["text"] = text.strip() - for key in ( - "author", - "handle", - "initial", - "time", - "likes", - "replyCount", - "verified", - "colors", - ): - if key in custom: - comment[key] = custom[key] - else: - raise renderer.DataValidationError(f"comments[{index}] must be a string or object") - _require_chinese(str(comment["text"]), f"comments[{index}].text") - result.append(comment) - return result - - -def _read_spec(path: Path) -> dict[str, object]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - raise renderer.DataValidationError(f"Invalid spec JSON: {exc}") from exc - if not isinstance(value, dict): - raise renderer.DataValidationError("Spec JSON must be an object") - return value - - -def _string(value: object, path: str, *, required: bool = False) -> str: - if value is None and not required: - return "" - if not isinstance(value, str) or (required and not value.strip()): - suffix = "a non-empty string" if required else "a string" - raise renderer.DataValidationError(f"{path} must be {suffix}") - return value.strip() - - -def _integer(value: object, path: str, *, default: int = 0) -> int: - if value is None: - return default - if not isinstance(value, int) or isinstance(value, bool) or value < 0: - raise renderer.DataValidationError(f"{path} must be a non-negative integer") - return value - - -def _strings(value: object, path: str) -> list[str]: - if value is None: - return [] - if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value): - raise renderer.DataValidationError(f"{path} must be an array of non-empty strings") - return [item.strip() for item in value] - - -def _namespace_from_spec(cli_args: argparse.Namespace) -> argparse.Namespace: - spec = _read_spec(cli_args.spec) - comments = spec.get("comments", []) - if not isinstance(comments, list): - raise renderer.DataValidationError("comments must be an array") - display_count = _integer( - spec.get("displayCommentCount"), - "displayCommentCount", - default=len(comments), - ) - if len(comments) > display_count: - raise renderer.DataValidationError("displayCommentCount cannot be smaller than comments.length") - total_count = _integer( - spec.get("totalCommentCount"), - "totalCommentCount", - default=display_count, - ) - if total_count < display_count: - raise renderer.DataValidationError("totalCommentCount cannot be smaller than displayCommentCount") - media = spec.get("media") or {} - if not isinstance(media, dict): - raise renderer.DataValidationError("media must be an object") - verified = spec.get("verified", False) - if not isinstance(verified, bool): - raise renderer.DataValidationError("verified must be a boolean") - media_type = media.get("type", "image") - if media_type not in {"image", "video"}: - raise renderer.DataValidationError("media.type must be image or video") - topic = _string(spec.get("topic"), "topic") - comment_topics = _strings(spec.get("commentTopics"), "commentTopics") - if not comment_topics: - comment_topics = [topic or "这条动态"] - return argparse.Namespace( - output_dir=cli_args.output_dir, - author=_string(spec.get("author"), "author", required=True), - text=_string(spec.get("text"), "text", required=True), - topic=topic, - initial=_string(spec.get("initial"), "initial"), - time=_string(spec.get("time"), "time") or "刚刚", - page_title=_string(spec.get("pageTitle"), "pageTitle"), - feed_title=_string(spec.get("feedTitle"), "feedTitle") or "动态", - tag=_strings(spec.get("tags"), "tags"), - comment_count=display_count, - total_comment_count=total_count, - comment_topic=comment_topics, - comment=comments, - likes=_integer(spec.get("likes"), "likes"), - shares=_integer(spec.get("shares"), "shares"), - verified=verified, - media_type=media_type, - media_url=_string(media.get("url"), "media.url"), - media_alt=_string(media.get("alt"), "media.alt"), - page_url=_string(media.get("pageUrl"), "media.pageUrl"), - source_url=_string(spec.get("sourceUrl"), "sourceUrl"), - ) - - -def _validate_cli_args(args: argparse.Namespace) -> argparse.Namespace: - for key in ("author", "text"): - if not getattr(args, key): - raise renderer.DataValidationError(f"--{key.replace('_', '-')} is required unless --spec is used") - if args.total_comment_count is None: - args.total_comment_count = args.comment_count - if args.total_comment_count < args.comment_count: - raise renderer.DataValidationError("total-comment-count cannot be smaller than comment-count") - if not args.comment_topic: - args.comment_topic = [args.topic or args.text[:24] or "这条动态"] - return args - - -def _next_paths(output_dir: Path) -> tuple[Path, Path]: - for index in range(1, 1001): - stem = "facebook-page" if index == 1 else f"facebook-page-{index}" - data_path = output_dir / f"{stem}-data.json" - html_path = output_dir / f"{stem}.html" - if not data_path.exists() and not html_path.exists(): - return data_path, html_path - raise renderer.DataValidationError("Unable to allocate output filenames") - - -def build_document(args: argparse.Namespace) -> dict[str, object]: - if not 0 <= args.comment_count <= renderer.COMMENT_LIMIT: - raise renderer.DataValidationError(f"comment-count must be between 0 and {renderer.COMMENT_LIMIT}") - _require_chinese(args.text, "text") - data = renderer._read_json(renderer.DEFAULT_DATA_PATH) - topic = (args.topic or args.text[:24] or "这条动态").strip() - initial = (args.initial or args.author[:1] or "发").strip()[:2] - post: dict[str, object] = { - "id": "single-post", - "author": args.author, - "initial": initial, - "colors": ["#f97316", "#ec4899"], - "verified": args.verified, - "time": args.time, - "privacyIcon": "i-globe", - "text": args.text, - "tags": args.tag, - "metrics": { - "likes": args.likes, - "comments": args.total_comment_count, - "shares": args.shares, - }, - "commentsExpanded": args.comment_count > 0, - "comments": _comments( - args.comment_count, - args.comment_topic, - args.comment, - ), - } - if args.media_url: - media: dict[str, object] = { - "type": args.media_type, - "mediaUrl": args.media_url, - "alt": args.media_alt or f"{topic}相关媒体", - } - if args.page_url: - media["pageUrl"] = args.page_url - post["media"] = media - else: - post["media"] = { - "type": "river", - "label": topic, - "alt": f"{topic}的模板示例图", - } - if args.source_url: - post["sourceUrl"] = args.source_url - - data["site"]["pageTitle"] = args.page_title or f"Facebook · {args.author}" - data["site"]["feedTitle"] = args.feed_title - data["stories"]["items"] = [] - data["posts"]["items"] = [post] - data["posts"]["commentUI"]["initialVisible"] = min(10, args.comment_count) or 1 - data["posts"]["commentUI"]["pageSize"] = min(10, args.comment_count) or 1 - return data - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output-dir", type=Path, required=True) - parser.add_argument("--spec", type=Path) - parser.add_argument("--author") - parser.add_argument("--text") - parser.add_argument("--topic", default="") - parser.add_argument("--initial", default="") - parser.add_argument("--time", default="刚刚") - parser.add_argument("--page-title", default="") - parser.add_argument("--feed-title", default="动态") - parser.add_argument("--tag", action="append", default=[]) - parser.add_argument("--comment-count", type=int, default=0) - parser.add_argument("--total-comment-count", type=int) - parser.add_argument("--comment-topic", action="append", default=[]) - parser.add_argument("--comment", action="append", default=[]) - parser.add_argument("--likes", type=int, default=0) - parser.add_argument("--shares", type=int, default=0) - parser.add_argument("--verified", action="store_true") - parser.add_argument("--media-type", choices=("image", "video"), default="image") - parser.add_argument("--media-url", default="") - parser.add_argument("--media-alt", default="") - parser.add_argument("--page-url", default="") - parser.add_argument("--source-url", default="") - return parser - - -def main(argv: list[str] | None = None) -> int: - cli_args = _build_parser().parse_args(argv) - try: - args = _namespace_from_spec(cli_args) if cli_args.spec else _validate_cli_args(cli_args) - data = build_document(args) - summary = renderer.validate_document(data) - template = renderer.TEMPLATE_PATH.read_text(encoding="utf-8") - rendered = renderer.render_document(data, template) - data_path, html_path = _next_paths(args.output_dir) - renderer._write_new( - data_path, - json.dumps(data, ensure_ascii=False, indent=2) + "\n", - ) - renderer._write_new(html_path, rendered) - except (renderer.DataValidationError, OSError, UnicodeError) as exc: - renderer._emit({"status": "error", "action": "fast-single-post", "error": str(exc)}) - return 2 - renderer._emit( - { - "status": "ok", - "action": "fast-single-post", - "data": str(data_path), - "html": str(html_path), - **summary, - } - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/offline-backend-20260512/backend/skills/public/facebook-page-generator/scripts/render_facebook_page.py b/offline-backend-20260512/backend/skills/public/facebook-page-generator/scripts/render_facebook_page.py deleted file mode 100644 index 15b9b52e..00000000 --- a/offline-backend-20260512/backend/skills/public/facebook-page-generator/scripts/render_facebook_page.py +++ /dev/null @@ -1,409 +0,0 @@ -#!/usr/bin/env python3 -"""Validate Facebook page data and inject it into the bundled HTML template.""" - -from __future__ import annotations - -import argparse -import html as html_lib -import json -import re -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_ROOT = Path(__file__).resolve().parent.parent -ASSETS_DIR = SKILL_ROOT / "assets" -DEFAULT_DATA_PATH = ASSETS_DIR / "default-data.json" -TEMPLATE_PATH = ASSETS_DIR / "facebook-template.html" -DATA_MARKER = "__FACEBOOK_PAGE_DATA__" -TITLE_RE = re.compile(r"[^<]*", re.IGNORECASE) -MODULES = ("site", "navigation", "stories", "posts", "sidebar", "contacts", "inbox") -COLLECTION_LIMITS = { - "stories.items": 20, - "posts.items": 30, - "contacts.items": 30, - "inbox.notifications": 30, - "inbox.messages": 30, -} -MEDIA_TYPES = {"river", "market", "quote", "image", "video", "webpage"} -REMOTE_MEDIA_TYPES = {"image", "video", "webpage"} -COMMENT_LIMIT = 100 - - -class DataValidationError(ValueError): - """Raised when the page JSON cannot satisfy the template contract.""" - - -def _read_json(path: Path) -> dict[str, Any]: - try: - data = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise DataValidationError(f"JSON file does not exist: {path}") from exc - except json.JSONDecodeError as exc: - raise DataValidationError(f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}") from exc - if not isinstance(data, dict): - raise DataValidationError("Top-level JSON value must be an object") - return data - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array(value: Any, path: str, errors: list[str], *, limit: int | None = None) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if limit is not None and len(value) > limit: - errors.append(f"{path} may contain at most {limit} items") - return value - - -def _non_empty_string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty string") - - -def _remote_url( - value: Any, - path: str, - errors: list[str], - *, - required: bool = False, - allow_data: tuple[str, ...] = (), -) -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if allow_data and any(candidate.lower().startswith(prefix) for prefix in allow_data): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media(media: dict[str, Any], path: str, errors: list[str]) -> None: - media_type = media.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - return - if media_type == "image": - _remote_url( - media.get("mediaUrl"), - f"{path}.mediaUrl", - errors, - required=True, - allow_data=("data:image/",), - ) - elif media_type == "video": - _remote_url( - media.get("mediaUrl"), - f"{path}.mediaUrl", - errors, - required=True, - allow_data=("data:video/",), - ) - _remote_url( - media.get("posterUrl"), - f"{path}.posterUrl", - errors, - allow_data=("data:image/",), - ) - else: - _remote_url(media.get("pageUrl"), f"{path}.pageUrl", errors, required=True) - _remote_url( - media.get("mediaUrl"), - f"{path}.mediaUrl", - errors, - allow_data=("data:image/",), - ) - if "embed" in media and not isinstance(media.get("embed"), bool): - errors.append(f"{path}.embed must be a boolean") - _remote_url(media.get("pageUrl"), f"{path}.pageUrl", errors) - - -def _validate_people( - items: list[Any], path: str, errors: list[str], *, name_key: str = "name" -) -> None: - for index, item in enumerate(items): - person = _mapping(item, f"{path}[{index}]", errors) - _non_empty_string(person.get(name_key), f"{path}[{index}].{name_key}", errors) - colors = person.get("colors") - if colors is not None and (not isinstance(colors, list) or len(colors) != 2): - errors.append(f"{path}[{index}].colors must contain exactly two colors") - - -def _non_negative_number(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, (int, float)) or isinstance(value, bool) or value < 0: - errors.append(f"{path} must be a non-negative number") - - -def _positive_int( - value: Any, - path: str, - errors: list[str], - *, - maximum: int = COMMENT_LIMIT, -) -> None: - if ( - not isinstance(value, int) - or isinstance(value, bool) - or not 1 <= value <= maximum - ): - errors.append(f"{path} must be an integer between 1 and {maximum}") - - -def validate_document(data: dict[str, Any]) -> dict[str, Any]: - errors: list[str] = [] - for module in MODULES: - if module not in data: - errors.append(f"Missing top-level module: {module}") - - site = _mapping(data.get("site"), "site", errors) - _non_empty_string(site.get("brand"), "site.brand", errors) - _non_empty_string(site.get("pageTitle"), "site.pageTitle", errors) - current_user = _mapping(site.get("currentUser"), "site.currentUser", errors) - _non_empty_string(current_user.get("name"), "site.currentUser.name", errors) - stats = _array(current_user.get("stats"), "site.currentUser.stats", errors, limit=6) - for index, item in enumerate(stats): - stat = _mapping(item, f"site.currentUser.stats[{index}]", errors) - _non_empty_string(stat.get("label"), f"site.currentUser.stats[{index}].label", errors) - - navigation = _mapping(data.get("navigation"), "navigation", errors) - for key, limit in (("top", 8), ("primary", 12), ("shortcuts", 12), ("mobile", 8)): - items = _array(navigation.get(key), f"navigation.{key}", errors, limit=limit) - for index, item in enumerate(items): - nav_item = _mapping(item, f"navigation.{key}[{index}]", errors) - _non_empty_string(nav_item.get("label"), f"navigation.{key}[{index}].label", errors) - - stories = _mapping(data.get("stories"), "stories", errors) - story_items = _array( - stories.get("items"), "stories.items", errors, limit=COLLECTION_LIMITS["stories.items"] - ) - _validate_people(story_items, "stories.items", errors) - - posts = _mapping(data.get("posts"), "posts", errors) - comment_ui_value = posts.get("commentUI") - if comment_ui_value is not None: - comment_ui = _mapping(comment_ui_value, "posts.commentUI", errors) - if comment_ui.get("defaultSort", "hot") not in {"hot", "latest"}: - errors.append("posts.commentUI.defaultSort must be hot or latest") - for key in ("initialVisible", "pageSize"): - if key in comment_ui: - _positive_int(comment_ui.get(key), f"posts.commentUI.{key}", errors) - for key in ( - "hotLabel", - "latestLabel", - "showMoreLabel", - "showAllLabel", - ): - if key in comment_ui: - _non_empty_string(comment_ui.get(key), f"posts.commentUI.{key}", errors) - post_items = _array(posts.get("items"), "posts.items", errors, limit=COLLECTION_LIMITS["posts.items"]) - if not post_items: - errors.append("posts.items must contain at least one post") - post_ids: set[str] = set() - for index, item in enumerate(post_items): - post = _mapping(item, f"posts.items[{index}]", errors) - post_id = post.get("id") - _non_empty_string(post_id, f"posts.items[{index}].id", errors) - if isinstance(post_id, str): - if post_id in post_ids: - errors.append(f"posts.items[{index}].id must be unique") - post_ids.add(post_id) - for key in ("author", "time", "text"): - _non_empty_string(post.get(key), f"posts.items[{index}].{key}", errors) - metrics = _mapping(post.get("metrics"), f"posts.items[{index}].metrics", errors) - for key in ("likes", "comments", "shares"): - _non_negative_number( - metrics.get(key), - f"posts.items[{index}].metrics.{key}", - errors, - ) - comments = _array( - post.get("comments", []), - f"posts.items[{index}].comments", - errors, - limit=COMMENT_LIMIT, - ) - _validate_people(comments, f"posts.items[{index}].comments", errors, name_key="author") - comment_ids: set[str] = set() - for comment_index, comment_value in enumerate(comments): - comment_path = f"posts.items[{index}].comments[{comment_index}]" - comment = _mapping(comment_value, comment_path, errors) - _non_empty_string(comment.get("text"), f"{comment_path}.text", errors) - for key in ("id", "handle", "initial", "time"): - if key in comment: - _non_empty_string(comment.get(key), f"{comment_path}.{key}", errors) - comment_id = comment.get("id") - if isinstance(comment_id, str) and comment_id: - if comment_id in comment_ids: - errors.append(f"{comment_path}.id must be unique within its post") - comment_ids.add(comment_id) - if "verified" in comment and not isinstance(comment.get("verified"), bool): - errors.append(f"{comment_path}.verified must be a boolean") - for key in ("likes", "replyCount"): - if key in comment: - _non_negative_number(comment.get(key), f"{comment_path}.{key}", errors) - if isinstance(metrics.get("comments"), (int, float)) and metrics.get("comments", 0) < len(comments): - errors.append( - f"posts.items[{index}].metrics.comments must be at least the number of preloaded comments" - ) - if "commentsExpanded" in post and not isinstance(post.get("commentsExpanded"), bool): - errors.append(f"posts.items[{index}].commentsExpanded must be a boolean") - if "disclosure" in post: - _non_empty_string(post.get("disclosure"), f"posts.items[{index}].disclosure", errors) - if "sourceUrl" in post: - _remote_url(post.get("sourceUrl"), f"posts.items[{index}].sourceUrl", errors, required=True) - media = post.get("media") - if media is not None: - media_data = _mapping(media, f"posts.items[{index}].media", errors) - media_type = media_data.get("type") - if media_type not in MEDIA_TYPES: - errors.append( - f"posts.items[{index}].media.type must be one of: {', '.join(sorted(MEDIA_TYPES))}" - ) - _validate_remote_media(media_data, f"posts.items[{index}].media", errors) - - sidebar = _mapping(data.get("sidebar"), "sidebar", errors) - _mapping(sidebar.get("birthday"), "sidebar.birthday", errors) - _mapping(sidebar.get("sponsor"), "sidebar.sponsor", errors) - - contacts = _mapping(data.get("contacts"), "contacts", errors) - contact_items = _array( - contacts.get("items"), "contacts.items", errors, limit=COLLECTION_LIMITS["contacts.items"] - ) - _validate_people(contact_items, "contacts.items", errors) - - inbox = _mapping(data.get("inbox"), "inbox", errors) - notification_items = _array( - inbox.get("notifications"), - "inbox.notifications", - errors, - limit=COLLECTION_LIMITS["inbox.notifications"], - ) - message_items = _array( - inbox.get("messages"), "inbox.messages", errors, limit=COLLECTION_LIMITS["inbox.messages"] - ) - _validate_people(notification_items, "inbox.notifications", errors) - _validate_people(message_items, "inbox.messages", errors) - - if errors: - raise DataValidationError("; ".join(errors)) - - return { - "modules": list(MODULES), - "counts": { - "stories": len(story_items), - "posts": len(post_items), - "comments": sum( - len(item.get("comments", [])) - for item in post_items - if isinstance(item, dict) and isinstance(item.get("comments", []), list) - ), - "contacts": len(contact_items), - "notifications": len(notification_items), - "messages": len(message_items), - }, - } - - -def render_document(data: dict[str, Any], template: str) -> str: - validate_document(data) - if template.count(DATA_MARKER) != 1: - raise DataValidationError("Bundled template must contain exactly one data marker") - serialized = json.dumps(data, ensure_ascii=False, indent=2).replace("{html_lib.escape(page_title)}", - html, - count=1, - ) - return html - - -def _write_new(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - try: - with path.open("x", encoding="utf-8", newline="\n") as handle: - handle.write(content) - except FileExistsError as exc: - raise DataValidationError(f"Refusing to overwrite existing file: {path}") from exc - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False)) - - -def _command_init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA_PATH) - summary = validate_document(data) - _write_new(output, json.dumps(data, ensure_ascii=False, indent=2) + "\n") - return {"status": "ok", "action": "init", "output": str(output), **summary} - - -def _command_validate(input_path: Path) -> dict[str, Any]: - data = _read_json(input_path) - summary = validate_document(data) - return {"status": "ok", "action": "validate", "input": str(input_path), **summary} - - -def _command_render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - template = TEMPLATE_PATH.read_text(encoding="utf-8") - rendered = render_document(data, template) - _write_new(output, rendered) - summary = validate_document(data) - return { - "status": "ok", - "action": "render", - "input": str(input_path), - "output": str(output), - **summary, - } - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - init_parser = subparsers.add_parser("init", help="Create a new editable JSON document") - init_parser.add_argument("--output", type=Path, required=True) - - validate_parser = subparsers.add_parser("validate", help="Validate an existing JSON document") - validate_parser.add_argument("--input", type=Path, required=True) - - render_parser = subparsers.add_parser("render", help="Render validated JSON into the bundled HTML template") - render_parser.add_argument("--input", type=Path, required=True) - render_parser.add_argument("--output", type=Path, required=True) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - args = parser.parse_args(argv) - try: - if args.command == "init": - result = _command_init(args.output) - elif args.command == "validate": - result = _command_validate(args.input) - else: - result = _command_render(args.input, args.output) - except (DataValidationError, OSError) as exc: - _emit({"status": "error", "action": args.command, "error": str(exc)}) - return 2 - _emit(result) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) - diff --git a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/gma-news-page-generator/SKILL.md deleted file mode 100644 index 04df476a..00000000 --- a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: gma-news-page-generator -description: 生成或绘制中文 GMA News 风格菲律宾广播新闻门户的可交付单文件 HTML;仅在用户明确要求制作 GMA News 网页或点名本技能时使用。先把材料或检索结果整理成读者文案全中文的十模块 JSON,再用内置模板确定性渲染,页面保留右上角模块化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# GMA News 中文页面生成器 - -本技能使用固定模板生成单文件菲律宾广播新闻门户。模型只负责材料收集和 JSON,禁止重新手写页面结构、CSS 或运行时脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块编辑 JSON,并通过“应用并渲染”即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说“生成/画/制作 GMA News、GMA 新闻网页、菲律宾广播新闻门户”或点名 `gma-news-page-generator` 时使用。 -- 仅讨论 GMA、总结菲律宾新闻、编写新闻稿或普通文本中出现 GMA News 时不要使用。 -- 这是 GMA News 风格单页门户,不改用 CNN、BBC、新华社、马尼拉时报或通用新闻模板。 - -## 极速工作流(默认) - -页面结构已经固定。完成必要检索或材料整理后,模型只写一份小型规格 JSON;未写字段继续使用模板默认数据与示例视觉,脚本在一次进程内合并、校验并渲染。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/gma-news-page-spec.json`。直接写模块对象会递归合并;修改现有数组项优先用 `$set`: - - ```json - {"site":{"pageTitle":"GMA 新闻 · 中文专题"},"$set":{"justIn.featured":"中文快讯","lead.main.title":"中文头条","lead.main.summary":"中文摘要"}} - ``` - -2. 只执行一次 `python /mnt/skills/public/gma-news-page-generator/scripts/fast_page.py --spec /mnt/user-data/outputs/gma-news-page-spec.json --output-dir /mnt/user-data/outputs`。 -3. stdout 为 `status: ok` 后立即用 `present_files` 交付完整 JSON 与 HTML,最终只回复一句完成说明。 - -规格只允许本技能十个模块和 `$set`;路径必须指向默认 JSON 的已存在字段。品牌缩写 `GMA` 可保留,其余读者可见内容必须是简体中文。未提供媒体直链时保留模板示例视觉。禁止读取默认数据、契约、模板、运行时或脚本源码,禁止 `--help`、独立 `init/validate/render`、生成后打补丁、临时脚本和冗长过程说明。规格报错时只修正一次并重跑。只有用户明确要求改变模块结构或批量重建完整数组时,才使用下面的完整工作流。 - -## 完整工作流(仅限结构改造) - -1. 读取当前会话材料、上传附件和已有产物,确定品牌、导航、即时新闻、主头条、新闻流、天气与公共服务、直播节目、频道、视频音频和页脚。 -2. 用户要求当前新闻、实时天气、真实节目或近期事件时,必须先检索可靠来源再填 JSON;用户明确说不要检索时不得联网。英文或菲律宾语资料必须先准确翻译成简体中文,不得直接复制外文标题、摘要、按钮或日期。没有材料时使用中文中性演示内容,并在页脚明确说明不是官方网站。 -3. 初始化完整 JSON: - - ```text - python /mnt/skills/public/gma-news-page-generator/scripts/render_gma_news_page.py init --output /mnt/user-data/outputs/gma-news-page-data.json - ``` - -4. 只修改 JSON。十个顶层模块必须全部保留:`site`、`navigation`、`justIn`、`lead`、`news`、`services`、`broadcast`、`channels`、`media`、`footer`。JSON 键名、`id`、`href`、`visual`、`theme` 等技术值保持契约格式;品牌缩写 `GMA` 可以保留,其余读者可见文案必须使用简体中文,`site.language` 固定为 `zh-CN`。字段不确定时读取 [references/data-schema.md](references/data-schema.md)。 -5. 校验 JSON: - - ```text - python /mnt/skills/public/gma-news-page-generator/scripts/render_gma_news_page.py validate --input /mnt/user-data/outputs/gma-news-page-data.json - ``` - -6. 确定性渲染: - - ```text - python /mnt/skills/public/gma-news-page-generator/scripts/render_gma_news_page.py render --input /mnt/user-data/outputs/gma-news-page-data.json --output /mnt/user-data/outputs/gma-news-page.html - ``` - -7. 检查 stdout 的 `status` 为 `ok`、HTML 存在且占位符已经消失,再用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天中粘贴完整 HTML。 - -## 内容映射 - -- 品牌、日期、主题和页头天气放入 `site`;工具链接、主导航、抽屉和搜索文案放入 `navigation`。 -- 即时新闻及展开列表放入 `justIn`;主头条、副头条和标题带放入 `lead`。 -- 专题追踪、三类新闻流与热榜放入 `news`;城市天气和公共服务入口放入 `services`。 -- 正在播出、节目表和直播弹窗放入 `broadcast`;数据速览和娱乐、生活、体育、财经频道放入 `channels`。 -- 视频、播客、彩票和订阅放入 `media`;说明、栏目链接与法律文案放入 `footer`。 -- `visual` 继续作为内置后备;具有视觉的对象可增加受校验的 `media`(`image`、`video`、`webpage`)替换当前视觉。所有文本由模板转义,不得包含 HTML、脚本或直接 iframe。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/gma-news-template.html` 或 `assets/gma-news-runtime.js`。 -- 初始化与渲染默认拒绝覆盖已有文件;路径存在时换新文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖新闻、天气、彩票或视频接口,也不依赖 CDN;仅当 JSON 明确提供受校验媒体 URL 时加载图片、视频或网页。 -- 保留红蓝黄广播新闻品牌、即时新闻面板、主头条、专题追踪、新闻流、天气、公共服务、电视直播、频道、视频、播客、彩票和订阅反馈。 -- 除品牌缩写 `GMA` 外,不得生成英文或菲律宾语读者文案;外文来源必须先翻译为自然、准确的简体中文。 -- 浏览器缓存以初始 JSON 指纹隔离;沙箱禁止持久存储时必须继续使用内嵌 JSON 正常渲染。 - diff --git a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/default-data.json deleted file mode 100644 index 77daf7e7..00000000 --- a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/default-data.json +++ /dev/null @@ -1,215 +0,0 @@ -{ - "site": { - "brandPrimary": "GMA", - "brandAccent": "新闻", - "tagline": "菲律宾新闻在线", - "dateLabel": "2026年10月30日 星期五 · 菲律宾", - "pageTitle": "GMA 新闻——菲律宾广播新闻门户", - "description": "采用菲律宾广播新闻视觉语言制作的中文新闻门户概念页面。", - "language": "zh-CN", - "theme": "light", - "skipLabel": "跳转到主要新闻", - "weatherMini": [ - {"id": "manila", "city": "马尼拉", "temp": "31℃", "condition": "晴间多云 · 降雨概率20%"}, - {"id": "cebu", "city": "宿务", "temp": "29℃", "condition": "多云 · 降雨概率35%"}, - {"id": "davao", "city": "达沃", "temp": "30℃", "condition": "小雨 · 降雨概率45%"} - ] - }, - "navigation": { - "menuLabel": "打开新闻菜单", - "searchLabel": "搜索", - "contrastOnLabel": "高对比度阅读已开启", - "contrastOffLabel": "已恢复标准配色", - "liveLabel": "观看直播", - "topLinks": [ - {"label": "广播", "href": "#radio"}, - {"label": "国际", "href": "#footer"}, - {"label": "新闻简报", "href": "#newsletter"}, - {"label": "登录", "href": "#footer"} - ], - "primary": [ - {"label": "首页", "href": "#main"}, - {"label": "新闻", "href": "#latest"}, - {"label": "娱乐", "href": "#channels"}, - {"label": "电视直播", "href": "#onair"}, - {"label": "生活", "href": "#channels"}, - {"label": "体育", "href": "#channels"}, - {"label": "财经", "href": "#channels"}, - {"label": "天气", "href": "#weather"}, - {"label": "公共服务", "href": "#service"}, - {"label": "视频", "href": "#videos"} - ], - "drawer": { - "eyebrow": "浏览新闻栏目", - "title": "菲律宾新闻与公共服务", - "text": "集中查看突发新闻、天气、交通、公共服务和广播节目。", - "ctaLabel": "打开公共服务中心", - "ctaHref": "#service" - }, - "search": { - "title": "搜索 GMA 新闻", - "placeholder": "搜索主题、地点和新闻", - "hint": "热门:交通、天气、排球", - "emptyMessage": "请输入主题、地点或人物。", - "resultPrefix": "条相关结果,关键词为" - } - }, - "justIn": { - "label": "即时新闻", - "moreLabel": "展开4条", - "closeLabel": "收起", - "featured": "沿海省份在台风季前启用统一应急信息热线。", - "items": [ - {"id": "rapid-bus", "time": "上午10:12", "title": "快速公交走廊启动公众测试", "category": "全国", "href": "#topnews"}, - {"id": "volleyball", "time": "上午10:04", "title": "国家排球队公布最终十二人名单", "category": "体育", "href": "#channels"}, - {"id": "peso", "time": "上午9:51", "title": "出口数据强劲推动比索走高", "category": "财经", "href": "#channels"}, - {"id": "rain", "time": "上午9:43", "title": "马尼拉都会区预计出现小雨", "category": "天气", "href": "#weather"} - ] - }, - "lead": { - "main": { - "id": "capital-hub", - "kicker": "头条 · 交通", - "title": "首都综合交通枢纽进入公众测试最后一周", - "summary": "数千名通勤者参与试运行,新枢纽将把轨道交通、快速公交和渡轮连接成统一网络。", - "byline": "记者 索菲娅·梅尔卡多", - "time": "上午9:48更新", - "readTime": "阅读约6分钟", - "visual": "transit-hub", - "videoLabel": "观看报道" - }, - "sideStories": [ - {"id": "flood-alert", "kicker": "环境", "title": "多省启动洪水和山体滑坡联合预警系统", "summary": "预警信息将同步发送至地方广播和移动网络。", "time": "23分钟前", "visual": "storm"}, - {"id": "learning-board", "kicker": "教育", "title": "全国学习资源平台向家长和教师开放", "summary": "公共平台用于比较资源配置,不对单个学校进行排名。", "time": "41分钟前", "visual": "education"} - ], - "headlines": [ - {"id": "procurement", "kicker": "政治", "title": "参议院委员会推进地方采购透明法案", "href": "#latest"}, - {"id": "heat-space", "kicker": "国际", "title": "东盟城市就耐热公共空间达成合作", "href": "#latest"}, - {"id": "women-final", "kicker": "体育", "title": "国家女队晋级地区决赛", "href": "#channels"}, - {"id": "export-hub", "kicker": "财经", "title": "小型出口商接入共享物流枢纽", "href": "#channels"} - ] - }, - "news": { - "trackingTitle": "正在追踪", - "trackingStatusPrefix": "正在跟踪的相关新闻数量", - "topics": [ - {"id": "transit", "label": "交通网络", "count": "18"}, - {"id": "storm", "label": "台风准备", "count": "15"}, - {"id": "food", "label": "食品价格", "count": "16"}, - {"id": "sea", "label": "西菲律宾海", "count": "17"}, - {"id": "school", "label": "学校日历", "count": "14"} - ], - "feeds": [ - { - "id": "latest", - "label": "最新新闻", - "items": [ - {"id": "night-trips", "kicker": "全国", "title": "交通部门增加二十趟深夜试运行班次", "summary": "延长运行将测试晚高峰后的车站值守和换乘衔接。", "time": "12分钟前", "visual": "transit"}, - {"id": "road-guide", "kicker": "公共事务", "title": "多地发布峰会道路管制一站式指南", "summary": "交互式路线方案涵盖公交、配送和应急通道。", "time": "29分钟前", "visual": "civic"}, - {"id": "clinics", "kicker": "健康", "title": "社区诊所本周延长免费筛查时间", "summary": "流动医疗队还将前往交通枢纽和公共市场。", "time": "47分钟前", "visual": "health"} - ] - }, - { - "id": "trending", - "label": "热榜", - "items": [ - {"id": "weather-five", "kicker": "最受关注", "title": "本周天气需要了解的五件事", "summary": "区域预报解读降雨、气温和出行条件。", "time": "阅读量8.2万", "visual": "weather"}, - {"id": "fare-guide", "kicker": "新闻解读", "title": "统一交通票价将如何运行", "summary": "一张卡、每日封顶和免费换乘规则详解。", "time": "阅读量6.4万", "visual": "fare"} - ] - }, - { - "id": "regional", - "label": "地区新闻", - "items": [ - {"id": "cebu-loop", "kicker": "维萨亚", "title": "宿务开放滨水林荫交通环线", "summary": "新路线连接渡轮、自行车和公共汽车。", "time": "34分钟前", "visual": "regional"}, - {"id": "davao-cacao", "kicker": "棉兰老", "title": "达沃可可中心启用共享质量实验室", "summary": "小型种植户可以在出口前完成本地检测。", "time": "55分钟前", "visual": "cacao"} - ] - } - ], - "trendingTitle": "热门新闻", - "trendingLabel": "实时", - "trendingItems": [ - {"id": "trend-1", "title": "新交通枢纽将如何影响通勤者", "href": "#topnews"}, - {"id": "trend-2", "title": "气象部门更新全国降雨图", "href": "#weather"}, - {"id": "trend-3", "title": "女子排球队晋级决赛", "href": "#channels"}, - {"id": "trend-4", "title": "比索在午后交易中走强", "href": "#channels"}, - {"id": "trend-5", "title": "峰会道路管制应急热线汇总", "href": "#service"} - ] - }, - "services": { - "weatherTitle": "天气", - "weatherLiveLabel": "实时", - "locations": [ - {"id": "manila", "city": "马尼拉都会区", "temp": "31°", "condition": "晴间多云", "forecast": [{"day": "周六", "temp": "30°", "label": "有雨"}, {"day": "周日", "temp": "31°", "label": "多云"}, {"day": "周一", "temp": "32°", "label": "晴"}]}, - {"id": "cebu", "city": "宿务市", "temp": "29°", "condition": "午后有雨", "forecast": [{"day": "周六", "temp": "29°", "label": "有雨"}, {"day": "周日", "temp": "30°", "label": "多云"}, {"day": "周一", "temp": "30°", "label": "多云"}]}, - {"id": "davao", "city": "达沃市", "temp": "30°", "condition": "小雨且湿度较高", "forecast": [{"day": "周六", "temp": "29°", "label": "有雨"}, {"day": "周日", "temp": "30°", "label": "小雨"}, {"day": "周一", "temp": "31°", "label": "多云"}]}, - {"id": "baguio", "city": "碧瑶市", "temp": "21°", "condition": "凉爽并有阵雨", "forecast": [{"day": "周六", "temp": "20°", "label": "阵雨"}, {"day": "周日", "temp": "21°", "label": "多云"}, {"day": "周一", "temp": "22°", "label": "晴间多云"}]} - ], - "publicTitle": "公共服务", - "publicItems": [ - {"id": "classes", "label": "停课信息", "message": "已打开停课信息演示面板。"}, - {"id": "hotlines", "label": "应急热线", "message": "已打开应急热线演示面板。"}, - {"id": "traffic", "label": "交通提示", "message": "已打开道路管制演示面板。"}, - {"id": "transit", "label": "公共交通", "message": "已打开公共交通时刻演示面板。"} - ] - }, - "broadcast": { - "eyebrow": "电视直播", - "title": "正在播出", - "moreLabel": "完整节目表", - "moreHref": "#onair", - "hero": {"title": "影响今天的重要新闻", "summary": "汇集菲律宾各地的突发进展、现场报道和清晰解读。", "visual": "newsroom", "actionLabel": "观看电视直播"}, - "scheduleTitle": "星期五节目表", - "slots": [ - {"id": "news-center", "time": "下午5:00", "title": "新闻中心", "summary": "午后新闻摘要", "live": false}, - {"id": "prime-report", "time": "下午6:30", "title": "黄金时段报道", "summary": "旗舰新闻节目", "live": true}, - {"id": "public-affairs", "time": "晚上8:00", "title": "公共事务台", "summary": "访谈与新闻解读", "live": false}, - {"id": "state-nation", "time": "晚上9:00", "title": "国家观察", "summary": "分析与背景", "live": false}, - {"id": "late-edition", "time": "晚上10:00", "title": "晚间版", "summary": "全天新闻回顾", "live": false} - ], - "modal": {"brand": "GMA 新闻直播", "title": "黄金时段报道", "note": "新闻中心演示直播", "quality": "直播 · 1080p", "captionsOn": "字幕:已开启", "captionsOff": "字幕:已关闭"} - }, - "channels": { - "glanceTitle": "数据速览", - "glanceMoreLabel": "更多新闻解读", - "glanceMoreHref": "#glance", - "glanceItems": [ - {"id": "commute", "kicker": "交通", "value": "42分钟", "text": "新跨湾通勤线路在高峰时段预计节省的时间。", "actionLabel": "查看路线", "actionHref": "#topnews"}, - {"id": "schools", "kicker": "教育", "value": "1,240所", "text": "首批纳入学习资源平台的公立学校数量。", "actionLabel": "了解运行方式", "actionHref": "#latest"}, - {"id": "cities", "kicker": "天气", "value": "4座城市", "text": "纳入城市高温与降雨联合试点的城市数量。", "actionLabel": "查看天气", "actionHref": "#weather"}, - {"id": "growth", "kicker": "经济", "value": "6.1%", "text": "地区制造业产值同比增幅。", "actionLabel": "阅读分析", "actionHref": "#channels"} - ], - "deskTitle": "GMA 新闻频道", - "deskMoreLabel": "全部频道", - "deskMoreHref": "#footer", - "deskItems": [ - {"id": "showbiz", "kicker": "娱乐", "title": "独立电影节公布群岛巡展计划", "summary": "新增放映场地让菲律宾电影走近更多地方观众。", "visual": "showbiz"}, - {"id": "lifestyle", "kicker": "生活", "title": "社区花园如何为高密度街区降温", "summary": "居民分享小型城市花园的实用经验。", "visual": "lifestyle"}, - {"id": "sports", "kicker": "体育", "title": "大学宿敌加时赛上演经典对决", "summary": "最后一次大胆进攻决定了赛季揭幕战的胜负。", "visual": "sports"}, - {"id": "money", "kicker": "财经", "title": "地区工厂领跑新一轮增长数据", "summary": "食品加工和电子制造连续三个季度扩张。", "visual": "money"} - ] - }, - "media": { - "videosTitle": "最新视频", - "videosMoreLabel": "查看全部视频", - "videosMoreHref": "#videos", - "videos": [ - {"id": "hub-video", "kicker": "新闻解读", "duration": "08:42", "title": "走进新的首都综合交通枢纽", "summary": "了解轨道、公交和渡轮如何实现换乘。", "visual": "transit-video"}, - {"id": "rain-video", "kicker": "天气", "duration": "04:16", "title": "全国降雨预警系统如何运行", "summary": "气象人员解释预警颜色和发布流程。", "visual": "rain-video"}, - {"id": "peso-video", "kicker": "财经", "duration": "06:05", "title": "市场观察:比索为何波动", "summary": "出口、能源和利率如何共同影响汇率。", "visual": "market-video"} - ], - "podcast": {"eyebrow": "GMA 广播", "title": "晨间新闻播客", "text": "用十分钟掌握今天的重要新闻和背景。", "playLabel": "播放播客", "pauseLabel": "暂停播客", "startTime": "03:12 / 10:15", "playingTime": "05:51 / 10:15"}, - "lotto": {"eyebrow": "官方开奖", "title": "彩票结果", "date": "2026年10月25日 · 演示结果", "games": [{"id": "649", "label": "6/49", "numbers": ["07", "18", "24", "31", "42", "47"]}, {"id": "658", "label": "6/58", "numbers": ["05", "16", "27", "38", "49", "56"]}, {"id": "642", "label": "6/42", "numbers": ["03", "11", "19", "26", "34", "40"]}]}, - "newsletter": {"title": "新闻与你一路同行", "text": "每天早晨通过一封邮件获取菲律宾头条和公共服务动态。", "label": "电子邮箱", "placeholder": "请输入电子邮箱", "button": "立即订阅", "successPrefix": "订阅成功,确认邮件已发送至"} - }, - "footer": { - "description": "以紧迫、清晰和公共服务为核心的菲律宾广播新闻门户概念页面。本页面并非 GMA 官方网站。", - "columns": [ - {"id": "news", "title": "新闻", "links": [{"label": "头条新闻", "href": "#topnews"}, {"label": "地区新闻", "href": "#latest"}, {"label": "财经", "href": "#channels"}, {"label": "体育", "href": "#channels"}]}, - {"id": "multimedia", "title": "多媒体", "links": [{"label": "视频", "href": "#videos"}, {"label": "广播", "href": "#radio"}, {"label": "电视节目表", "href": "#onair"}, {"label": "新闻简报", "href": "#newsletter"}]}, - {"id": "about", "title": "关于", "links": [{"label": "关于我们", "href": "#footer"}, {"label": "联系我们", "href": "#footer"}, {"label": "编辑政策", "href": "#footer"}, {"label": "工作机会", "href": "#footer"}]} - ], - "copyright": "© 2026 GMA 新闻概念页面", - "legal": "隐私 · 条款 · 无障碍访问 · 更正说明" - } -} diff --git a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/gma-news-runtime.js b/offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/gma-news-runtime.js deleted file mode 100644 index 561fa567..00000000 --- a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/gma-news-runtime.js +++ /dev/null @@ -1,315 +0,0 @@ -(() => { - "use strict"; - - const MODULES = ["site", "navigation", "justIn", "lead", "news", "services", "broadcast", "channels", "media", "footer"]; - const MODULE_META = {site:"站点信息",navigation:"页面导航",justIn:"即时新闻",lead:"头条新闻",news:"新闻信息流",services:"天气与服务",broadcast:"电视直播",channels:"数据与频道",media:"视频广播",footer:"页面页脚"}; - const node = (id) => document.getElementById(id); - const clone = (value) => JSON.parse(JSON.stringify(value)); - const initialText = node("gmaNewsPageData").textContent; - const initialData = JSON.parse(initialText); - const fingerprint = (() => { let hash=2166136261; for(let i=0;i>>0).toString(36) })(); - const dataKey = "gma-news-page-data-v1:" + fingerprint; - const themeKey = "gma-news-page-theme-v1:" + fingerprint; - const readStorage = (key) => { try{return window.localStorage.getItem(key)}catch(_error){return null} }; - const writeStorage = (key,value) => { try{window.localStorage.setItem(key,value);return true}catch(_error){return false} }; - const removeStorage = (key) => { try{window.localStorage.removeItem(key)}catch(_error){/* optional storage */} }; - let data = clone(initialData); - try { const saved=readStorage(dataKey); if(saved)data=JSON.parse(saved) } catch(_error) { data=clone(initialData) } - let weatherMiniIndex=0, activeFeed="latest", activeTopic="transit", activeWeather="manila", activeGame="649", playing=false, captionsOn=true, currentModule=MODULES[0], toastTimer=0; - const esc = (value) => String(value??"").replace(/[&<>'"]/g,(char)=>({"&":"&","<":"<",">":">","'":"'",'"':"""})[char]); - const safeHref = (value) => typeof value==="string"&&value.startsWith("#")?value:"#main"; - const safeRemoteUrl=(value,kind="link")=>{const source=String(value||"").trim();if(!source)return"";const lower=source.toLowerCase();if(kind==="image"&&lower.startsWith("data:image/"))return source;if(kind==="video"&&lower.startsWith("data:video/"))return source;try{const url=new URL(source);return["http:","https:"].includes(url.protocol)?url.href:""}catch(_error){return""}}; - const setText = (id,value) => {const el=node(id);if(el)el.textContent=value??""}; - const setLink = (id,label,href) => {const el=node(id);if(el){el.textContent=label??"";el.href=safeHref(href)}}; - const links = (items,className="") => (items||[]).map((item)=>`${esc(item.label)}`).join(""); - document.head.insertAdjacentHTML("beforeend",''); - const externalMedia=(media,label)=>{if(!media||typeof media!=="object")return"";if(media.type==="image"){const source=safeRemoteUrl(media.mediaUrl,"image"),page=safeRemoteUrl(media.pageUrl);return source?`${esc(media.alt||label||${page?`打开来源`:""}`:""}if(media.type==="video"){const source=safeRemoteUrl(media.mediaUrl,"video"),poster=safeRemoteUrl(media.posterUrl,"image");return source?``:""}if(media.type==="webpage"){const page=safeRemoteUrl(media.pageUrl),preview=safeRemoteUrl(media.mediaUrl,"image");if(!page)return"";const body=media.embed===true?``:(preview?`${esc(media.alt||media.title||`:"");return`${body}${esc(media.title||label||"打开网页")}${esc(media.description||"新窗口打开")}`}return""}; - const art = (visual,className="art",media=null,mediaPath="") => {const mediaMarkup=externalMedia(media,media?.title),path=mediaPath?` data-media-path="${esc(mediaPath)}"`:"";return `
${mediaMarkup}
`}; - function toast(message){const el=node("toast");el.textContent=message;el.classList.add("show");clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove("show"),2200)} - - function renderSite(){ - document.documentElement.lang=data.site.language;document.title=data.site.pageTitle;document.querySelector('meta[name="description"]').content=data.site.description; - setText("skipLink",data.site.skipLabel);setText("dateLabel",data.site.dateLabel);setText("brandPrimary",data.site.brandPrimary);setText("brandAccent",data.site.brandAccent);setText("brandTagline",data.site.tagline); - setText("footerBrandPrimary",data.site.brandPrimary);setText("footerBrandAccent",data.site.brandAccent);setText("footerTagline",data.site.tagline);setText("drawerBrand",`${data.site.brandPrimary} ${data.site.brandAccent}`);setText("searchBrand",`${data.site.brandPrimary} ${data.site.brandAccent}`); - const preferred=readStorage(themeKey);document.body.classList.toggle("contrast",preferred==="contrast"||(!preferred&&data.site.theme==="dark"));renderMiniWeather(); - } - function renderNavigation(){ - node("menuOpen").setAttribute("aria-label",data.navigation.menuLabel);setText("liveLabel",data.navigation.liveLabel);node("topLinks").innerHTML=links(data.navigation.topLinks);node("primaryNav").innerHTML=data.navigation.primary.map((item,index)=>`${esc(item.label)}`).join(""); - setText("drawerEyebrow",data.navigation.drawer.eyebrow);setText("drawerTitle",data.navigation.drawer.title);setText("drawerText",data.navigation.drawer.text);setLink("drawerCta",data.navigation.drawer.ctaLabel,data.navigation.drawer.ctaHref);node("drawerNav").innerHTML=links(data.navigation.primary,"drawer-link"); - setText("searchTitle",data.navigation.search.title);node("searchInput").placeholder=data.navigation.search.placeholder;setText("searchResult",data.navigation.search.hint); - } - function renderMiniWeather(){const items=data.site.weatherMini;if(!items.length)return;weatherMiniIndex%=items.length;const item=items[weatherMiniIndex];setText("miniCity",`${item.city} · ${item.temp}`);setText("miniSky",item.condition)} - function renderJustIn(){setText("justLabel",data.justIn.label);setText("justFeatured",data.justIn.featured);const open=node("justPanel").classList.contains("open");setText("justToggle",open?data.justIn.closeLabel:data.justIn.moreLabel);node("justGrid").innerHTML=data.justIn.items.map((item)=>``).join("")} - function renderLead(){ - const main=data.lead.main;node("mainStory").innerHTML=`${art(main.visual,"art",main.media,"lead.main")}
${esc(main.kicker)}

${esc(main.title)}

${esc(main.summary)}

`; - node("sideStories").innerHTML=data.lead.sideStories.map((item,index)=>`
${art(item.visual,"art",item.media,`lead.sideStories.${index}`)}
${esc(item.kicker)}

${esc(item.title)}

${esc(item.summary)}

`).join(""); - node("headlineStrip").innerHTML=data.lead.headlines.map((item)=>``).join(""); - } - function renderNews(){ - setText("trackingTitle",data.news.trackingTitle);if(!data.news.topics.some((item)=>item.id===activeTopic))activeTopic=data.news.topics[0]?.id;node("trackingPills").innerHTML=data.news.topics.map((item)=>``).join("");const topic=data.news.topics.find((item)=>item.id===activeTopic)||data.news.topics[0];setText("trackingStatus",`${data.news.trackingStatusPrefix}:${topic.count} · ${topic.label}`); - if(!data.news.feeds.some((item)=>item.id===activeFeed))activeFeed=data.news.feeds[0]?.id;node("feedTabs").innerHTML=data.news.feeds.map((feed)=>``).join("");node("feedPanels").innerHTML=data.news.feeds.map((feed,feedIndex)=>`
${feed.items.map((item,itemIndex)=>`
${art(item.visual,"feed-art",item.media,`news.feeds.${feedIndex}.items.${itemIndex}`)}
${esc(item.kicker)}

${esc(item.title)}

${esc(item.summary)}

`).join("")}
`).join(""); - setText("trendingTitle",data.news.trendingTitle);setText("trendingLabel",data.news.trendingLabel);node("trendingList").innerHTML=data.news.trendingItems.map((item,index)=>``).join(""); - } - function renderServices(){ - setText("weatherTitle",data.services.weatherTitle);setText("weatherLive",data.services.weatherLiveLabel);if(!data.services.locations.some((item)=>item.id===activeWeather))activeWeather=data.services.locations[0]?.id;node("weatherSelect").innerHTML=data.services.locations.map((item)=>``).join("");const weather=data.services.locations.find((item)=>item.id===activeWeather)||data.services.locations[0];setText("weatherTemp",weather.temp);setText("weatherCondition",weather.condition);node("forecast").innerHTML=weather.forecast.map((item)=>`
${esc(item.day)}${esc(item.temp)}${esc(item.label)}
`).join("");setText("publicTitle",data.services.publicTitle);node("serviceLinks").innerHTML=data.services.publicItems.map((item)=>``).join(""); - } - function renderBroadcast(){ - const hero=data.broadcast.hero,mediaMarkup=externalMedia(hero.media,hero.title),broadcastArt=node("broadcastArt"),stage=broadcastArt.closest(".tv-stage");broadcastArt.className=`tv-art${mediaMarkup?" external-media":""}`;broadcastArt.dataset.visual=hero.visual;broadcastArt.dataset.mediaPath="broadcast.hero";broadcastArt.setAttribute("aria-hidden",mediaMarkup?"false":"true");broadcastArt.innerHTML=mediaMarkup;stage.classList.toggle("has-external-media",Boolean(mediaMarkup));setText("broadcastTitle",data.broadcast.title);setLink("broadcastMore",data.broadcast.moreLabel,data.broadcast.moreHref);setText("broadcastEyebrow",data.broadcast.eyebrow);setText("broadcastHeroTitle",hero.title);setText("broadcastHeroSummary",hero.summary);setText("watchLive",`▶ ${hero.actionLabel}`);setText("scheduleTitle",data.broadcast.scheduleTitle);node("scheduleSlots").innerHTML=data.broadcast.slots.map((item)=>`
${esc(item.title)}${esc(item.summary)}
`).join("");setText("playerBrand",data.broadcast.modal.brand);setText("playerQuality",data.broadcast.modal.quality);updateCaption(); - } - function renderChannels(){ - setText("glanceTitle",data.channels.glanceTitle);setLink("glanceMore",data.channels.glanceMoreLabel,data.channels.glanceMoreHref);node("glanceGrid").innerHTML=data.channels.glanceItems.map((item)=>``).join("");setText("deskTitle",data.channels.deskTitle);setLink("deskMore",data.channels.deskMoreLabel,data.channels.deskMoreHref);node("channelGrid").innerHTML=data.channels.deskItems.map((item,index)=>`
${art(item.visual,"channel-art",item.media,`channels.deskItems.${index}`)}
${esc(item.kicker)}

${esc(item.title)}

${esc(item.summary)}

`).join(""); - } - function renderMedia(){ - setText("videosTitle",data.media.videosTitle);setLink("videosMore",data.media.videosMoreLabel,data.media.videosMoreHref);node("videoGrid").innerHTML=data.media.videos.map((item,index)=>`
${externalMedia(item.media,item.title)}${esc(item.duration)}
${esc(item.kicker)}

${esc(item.title)}

${esc(item.summary)}

`).join("");setText("podcastEyebrow",data.media.podcast.eyebrow);setText("podcastTitle",data.media.podcast.title);setText("podcastText",data.media.podcast.text);node("audioPlay").setAttribute("aria-label",playing?data.media.podcast.pauseLabel:data.media.podcast.playLabel);setText("audioPlay",playing?"Ⅱ":"▶");setText("audioTime",playing?data.media.podcast.playingTime:data.media.podcast.startTime);node("audioProgress").style.width=playing?"57%":"38%"; - if(!data.media.lotto.games.some((item)=>item.id===activeGame))activeGame=data.media.lotto.games[0]?.id;setText("lottoEyebrow",data.media.lotto.eyebrow);setText("lottoTitle",data.media.lotto.title);node("lottoTabs").innerHTML=data.media.lotto.games.map((item)=>``).join("");const game=data.media.lotto.games.find((item)=>item.id===activeGame)||data.media.lotto.games[0];node("lottoBalls").innerHTML=game.numbers.map((number)=>`${esc(number)}`).join("");setText("lottoNote",`${game.label} · ${data.media.lotto.date}`); - setText("newsletterTitle",data.media.newsletter.title);setText("newsletterText",data.media.newsletter.text);setText("newsletterLabel",data.media.newsletter.label);node("email").placeholder=data.media.newsletter.placeholder;setText("newsletterButton",data.media.newsletter.button); - } - function renderFooter(){setText("footerDescription",data.footer.description);node("footerColumns").innerHTML=data.footer.columns.map((column)=>``).join("");setText("footerCopyright",data.footer.copyright);setText("footerLegal",data.footer.legal)} - function renderAll(){renderSite();renderNavigation();renderJustIn();renderLead();renderNews();renderServices();renderBroadcast();renderChannels();renderMedia();renderFooter()} - - function openLayer(id){const el=node(id);el.classList.add("open");document.body.classList.add("locked")} - function closeLayer(id){node(id).classList.remove("open");if(!document.querySelector(".drawer.open,.search-overlay.open,.player-modal.open,.settings-overlay.open"))document.body.classList.remove("locked")} - function openPlayer(id){const item=data.media.videos.find((video)=>video.id===id);const isMain=id===data.lead.main.id;setText("playerTitle",item?.title||data.broadcast.modal.title);setText("playerNote",item?.summary||(isMain?data.lead.main.summary:data.broadcast.modal.note));captionsOn=true;updateCaption();openLayer("playerModal")} - function updateCaption(){setText("captionToggle",captionsOn?data.broadcast.modal.captionsOn:data.broadcast.modal.captionsOff)} - function searchStories(){const raw=node("searchInput").value.trim();const query=raw.toLowerCase();const strings=[];const walk=(value)=>{if(Array.isArray(value))value.forEach(walk);else if(value&&typeof value==="object")Object.values(value).forEach(walk);else if(typeof value==="string")strings.push(value)};walk(data);const count=query?strings.filter((value)=>value.toLowerCase().includes(query)).length:0;setText("searchResult",query?`${count}${data.navigation.search.resultPrefix}“${raw}”`:data.navigation.search.emptyMessage)} - - function validateClient(candidate){ - if(!candidate||typeof candidate!=="object"||Array.isArray(candidate))throw new Error("顶层 JSON 必须是对象。");const keys=Object.keys(candidate),missing=MODULES.filter((name)=>!keys.includes(name)),extra=keys.filter((name)=>!MODULES.includes(name));if(missing.length)throw new Error(`缺少模块:${missing.join("、")}`);if(extra.length)throw new Error(`存在未知模块:${extra.join("、")}`);MODULES.forEach((name)=>{if(!candidate[name]||typeof candidate[name]!=="object"||Array.isArray(candidate[name]))throw new Error(`${name} 必须是对象。`)});const text=JSON.stringify(candidate).toLowerCase();if(["text.includes(token)))throw new Error("不允许使用 HTML 或脚本。");const validateMedia=(value,path="当前模块")=>{if(Array.isArray(value))return value.forEach((item,index)=>validateMedia(item,`${path}[${index}]`));if(!value||typeof value!=="object")return;const media=value.media;if(media&&["image","video","webpage"].includes(media.type)){if(["image","video"].includes(media.type)&&!safeRemoteUrl(media.mediaUrl,media.type))throw new Error(`${path}.media.mediaUrl 无效。`);if(media.type==="webpage"&&!safeRemoteUrl(media.pageUrl))throw new Error(`${path}.media.pageUrl 无效。`);if(media.posterUrl&&!safeRemoteUrl(media.posterUrl,"image"))throw new Error(`${path}.media.posterUrl 无效。`)}Object.entries(value).forEach(([key,item])=>{if(key!=="media")validateMedia(item,`${path}.${key}`)})};validateMedia(candidate);if(candidate.site.language!=="zh-CN")throw new Error("site.language 必须是 zh-CN。");const technical=new Set(["id","href","visual","theme","language","brandPrimary","type","mediaUrl","posterUrl","pageUrl"]);const check=(value,key="")=>{if(Array.isArray(value))value.forEach((item)=>check(item,key));else if(value&&typeof value==="object")Object.entries(value).forEach(([child,item])=>check(item,child));else if(typeof value==="string"&&!technical.has(key)&&!key.toLowerCase().endsWith("id")&&!key.toLowerCase().endsWith("href")&&!key.toLowerCase().endsWith("url")&&/[A-Za-z]/.test(value)&&!/[\u3400-\u9fff]/.test(value))throw new Error("除品牌缩写 GMA 外,所有读者可见文案必须使用简体中文。")};check(candidate);return true; - } - function renderSettingsTabs(){node("moduleTabs").innerHTML=MODULES.map((name)=>``).join("");showModule(currentModule)} - function showModule(name){currentModule=name;node("moduleTabs").querySelectorAll("button").forEach((button)=>button.classList.toggle("active",button.dataset.module===name));setText("editorModule",MODULE_META[name]);node("jsonEditor").value=JSON.stringify(data[name],null,2);setText("jsonValidity","JSON 有效")} - function parseEditor(){try{const value=JSON.parse(node("jsonEditor").value);if(!value||typeof value!=="object"||Array.isArray(value))throw new Error("当前模块必须是对象。");setText("jsonValidity","JSON 有效");return value}catch(error){setText("jsonValidity","JSON 无效");throw error}} - const setStatus=(message)=>setText("settingsStatus",message); - function applyEditor(){try{const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);data=candidate;writeStorage(dataKey,JSON.stringify(data));renderAll();renderSettingsTabs();setStatus("JSON 已应用并完成渲染。") }catch(error){setStatus(error.message);setText("jsonValidity","JSON 无效")}} - async function copyJson(){const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);const text=JSON.stringify(candidate,null,2);try{await navigator.clipboard.writeText(text);setStatus("完整 JSON 已复制。") }catch(_error){node("jsonEditor").select();document.execCommand("copy");setStatus("已选中当前模块,可手动复制。")}} - function downloadJson(){const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);const blob=new Blob([JSON.stringify(candidate,null,2)+"\n"],{type:"application/json"}),url=URL.createObjectURL(blob),anchor=document.createElement("a");anchor.href=url;anchor.download="gma-news-page-data.json";anchor.click();URL.revokeObjectURL(url);setStatus("JSON 下载文件已生成。")} - - node("justToggle").addEventListener("click",()=>{const open=node("justPanel").classList.toggle("open");node("justToggle").setAttribute("aria-expanded",String(open));renderJustIn()}); - node("weatherMini").addEventListener("click",()=>{weatherMiniIndex+=1;renderMiniWeather();toast(`天气已切换至${data.site.weatherMini[weatherMiniIndex%data.site.weatherMini.length].city}`)}); - node("menuOpen").addEventListener("click",()=>openLayer("drawer"));node("searchOpen").addEventListener("click",()=>{openLayer("searchOverlay");setTimeout(()=>node("searchInput").focus(),0)});node("liveButton").addEventListener("click",()=>openPlayer("live"));node("watchLive").addEventListener("click",()=>openPlayer("live")); - node("contrastToggle").addEventListener("click",()=>{const on=document.body.classList.toggle("contrast");writeStorage(themeKey,on?"contrast":"standard");toast(on?data.navigation.contrastOnLabel:data.navigation.contrastOffLabel)}); - node("weatherSelect").addEventListener("change",(event)=>{activeWeather=event.target.value;renderServices();toast(`天气已更新为${event.target.options[event.target.selectedIndex].text}`)}); - node("searchForm").addEventListener("submit",(event)=>{event.preventDefault();searchStories()});node("captionToggle").addEventListener("click",()=>{captionsOn=!captionsOn;updateCaption();toast(captionsOn?data.broadcast.modal.captionsOn:data.broadcast.modal.captionsOff)}); - node("audioPlay").addEventListener("click",()=>{playing=!playing;renderMedia();toast(playing?data.media.podcast.playLabel:data.media.podcast.pauseLabel)});node("subForm").addEventListener("submit",(event)=>{event.preventDefault();const email=node("email").value.trim();setText("subStatus",`${data.media.newsletter.successPrefix} ${email}。`);event.currentTarget.reset()}); - document.addEventListener("click",(event)=>{const close=event.target.closest("[data-close]");if(close){closeLayer(close.dataset.close);return}const topic=event.target.closest("[data-topic]");if(topic){activeTopic=topic.dataset.topic;renderNews();return}const feed=event.target.closest("[data-feed]");if(feed){activeFeed=feed.dataset.feed;renderNews();return}const service=event.target.closest("[data-service]");if(service){const item=data.services.publicItems.find((value)=>value.id===service.dataset.service);if(item)toast(item.message);return}const player=event.target.closest("[data-player]");if(player){openPlayer(player.dataset.player);return}const game=event.target.closest("[data-game]");if(game){activeGame=game.dataset.game;renderMedia();return}const module=event.target.closest("[data-module]");if(module&&module.closest("#moduleTabs")){showModule(module.dataset.module)}}); - ["drawer","searchOverlay","playerModal","settingsOverlay"].forEach((id)=>node(id).addEventListener("click",(event)=>{if(event.target===node(id))closeLayer(id)}));document.addEventListener("keydown",(event)=>{if(event.key==="Escape"){["drawer","searchOverlay","playerModal","settingsOverlay"].forEach((id)=>{if(node(id).classList.contains("open"))closeLayer(id)});node("justPanel").classList.remove("open")}});window.addEventListener("scroll",()=>document.body.classList.toggle("sticky",window.scrollY>220),{passive:true}); - node("openSettings").addEventListener("click",()=>{renderSettingsTabs();setStatus("");openLayer("settingsOverlay")});node("validateJson").addEventListener("click",()=>{try{const candidate={...data,[currentModule]:parseEditor()};validateClient(candidate);setStatus("完整 JSON 校验通过。")}catch(error){setStatus(error.message)}});node("copyJson").addEventListener("click",()=>copyJson().catch((error)=>setStatus(error.message)));node("downloadJson").addEventListener("click",()=>{try{downloadJson()}catch(error){setStatus(error.message)}});node("resetJson").addEventListener("click",()=>{data=clone(initialData);removeStorage(dataKey);removeStorage(themeKey);currentModule=MODULES[0];activeFeed="latest";activeTopic="transit";activeWeather="manila";activeGame="649";playing=false;renderAll();renderSettingsTabs();setStatus("已恢复内嵌初始 JSON。")});node("applyJson").addEventListener("click",applyEditor); - - validateClient(data);renderAll(); - function installJsonMediaUpload(){ - const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return; - const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson");button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input); - const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message}; - const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;indexindex+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null}; - button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()}); - input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)}); - } - installJsonMediaUpload(); - function installInlineMediaReplace() { - if (document.getElementById("dfInlineMediaInput")) return; - const visualHostSelector = "[data-media-path],.post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt"; - const getRootData = () => { - if (typeof pageData !== "undefined") return pageData; - if (typeof data !== "undefined") return data; - return null; - }; - const input = document.createElement("input"); - input.id = "dfInlineMediaInput"; - input.type = "file"; - input.hidden = true; - document.body.appendChild(input); - const style = document.createElement("style"); - style.dataset.dfInlineMediaReplace = "true"; - style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}"; - document.head.appendChild(style); - const notify = (message) => { - if (typeof showToast === "function") { showToast(message); return; } - if (typeof toast === "function") { toast(message); return; } - let notice = document.getElementById("dfInlineMediaToast"); - if (!notice) { - notice = document.createElement("div"); - notice.id = "dfInlineMediaToast"; - notice.className = "df-inline-media-toast"; - document.body.appendChild(notice); - } - notice.textContent = message; - window.clearTimeout(notice._timer); - notice._timer = window.setTimeout(() => notice.remove(), 2600); - }; - const sameSource = (value, source, absolute) => { - if (typeof value !== "string") return false; - if (value === source || value === absolute) return true; - try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; } - }; - const findMatches = (source, absolute) => { - const matches = []; - const stack = []; - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - stack.push(value); - Object.entries(value).forEach(([key, item]) => { - if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) { - const ancestors = stack.slice(0, -1).reverse(); - const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value; - matches.push({ owner, previous: value, key }); - } - if (item && typeof item === "object") visit(item); - }); - stack.pop(); - }; - visit(getRootData()); - return matches; - }; - const visualOwners = () => { - const owners = []; - const seen = new Set(); - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object"); - const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim()); - if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); } - Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); }); - }; - visit(getRootData()); - return owners; - }; - const domIdentifiers = (element) => { - const values = new Set(); - let cursor = element; - for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) { - Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); }); - } - return values; - }; - const exactPathTarget = (element) => { - const host = element.closest("[data-media-path]"); - const path = host && host.dataset ? host.dataset.mediaPath : ""; - if (!path) return null; - const owner = path.split(".").reduce((value, segment) => { - if (Array.isArray(value)) return value[Number(segment)]; - return value && typeof value === "object" ? value[segment] : null; - }, getRootData()); - if (!owner || typeof owner !== "object" || Array.isArray(owner)) return null; - return { owner, previous: owner.media && typeof owner.media === "object" ? owner.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseVisualTarget = (element) => { - const exact = exactPathTarget(element); - if (exact) return exact; - const identifiers = domIdentifiers(element); - const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element; - const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim(); - const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]"); - const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : ""; - let best = null; - let bestScore = 0; - visualOwners().forEach((owner) => { - let score = 0; - Object.entries(owner).forEach(([key, value]) => { - if (/id$/i.test(key) && identifiers.has(String(value))) score += 100; - }); - ["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => { - const value = typeof owner[key] === "string" ? owner[key].trim() : ""; - if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length); - }); - if (visualValue && owner.visual === visualValue) score += 20; - if (score > bestScore) { best = owner; bestScore = score; } - }); - if (!best || bestScore <= 0) return null; - return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseTarget = (element) => { - const exact = exactPathTarget(element); - if (exact) return exact; - const source = element.getAttribute("src") || ""; - const absolute = element.currentSrc || element.src || source; - const matches = findMatches(source, absolute); - if (!matches.length) return null; - const identifiers = domIdentifiers(element); - return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0]; - }; - const persist = () => { - const root = getRootData(); - if (typeof saveData === "function") return saveData(); - if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root)); - if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root)); - return false; - }; - let active = null; - const attachButton = (host, target) => { - if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return; - host.classList.add("df-inline-media-host"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "df-inline-media-button"; - button.setAttribute("aria-label", "替换图片或视频"); - button.title = "上传图片或视频并替换"; - button.innerHTML = ''; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - active = { ...target }; - input.accept = "image/*,video/*"; - input.value = ""; - input.click(); - }); - host.appendChild(button); - }; - const decorateBuiltInVisuals = () => { - document.querySelectorAll(visualHostSelector).forEach((host) => { - if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return; - if (!host.querySelector("svg") && !host.dataset.visual) return; - const target = chooseVisualTarget(host); - if (!target) return; - attachButton(host, target); - }); - }; - const decorate = () => { - decorateBuiltInVisuals(); - document.querySelectorAll("img[src],video[src]").forEach((media) => { - if (media.dataset.dfInlineMediaReady === "true") return; - const target = chooseTarget(media); - if (!target) return; - const host = media.closest(visualHostSelector) || media.parentElement; - if (!host) return; - media.dataset.dfInlineMediaReady = "true"; - attachButton(host, target); - }); - }; - input.addEventListener("change", () => { - const file = input.files && input.files[0]; - if (!file || !active) return; - const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : ""; - if (!uploadedType) { - notify("请选择图片或视频文件。"); - return; - } - const reader = new FileReader(); - reader.onerror = () => notify("文件读取失败,请重新选择。"); - reader.onload = () => { - const next = { ...(active.previous || {}) }; - const result = String(reader.result); - next.type = uploadedType; - next.mediaUrl = result; - delete next.posterUrl; - if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片"; - active.owner.media = next; - const stored = persist(); - renderAll(); - notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。"); - active = null; - }; - reader.readAsDataURL(file); - }); - let queued = false; - const queueDecorate = () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; decorate(); }); - }; - new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true }); - decorate(); - } - installInlineMediaReplace(); -})(); diff --git a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/gma-news-template.html b/offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/gma-news-template.html deleted file mode 100644 index b029ed5a..00000000 --- a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/assets/gma-news-template.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - GMA 新闻——菲律宾广播新闻门户 - - - - - -
- -
-
-

-
-

-

-

-

-
◉

- -
-

-

-
-
▶

-
-
GMA 新闻页面模板控制台

JSON 编辑器

JSON 有效
编辑当前模块并完成校验后,即可应用到页面。缓存按照当前文件的初始 JSON 指纹隔离。
- - - - diff --git a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/gma-news-page-generator/references/data-schema.md deleted file mode 100644 index fd95bf38..00000000 --- a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/references/data-schema.md +++ /dev/null @@ -1,32 +0,0 @@ -# GMA News 中文页面 JSON 契约 - -顶层只允许以下十个对象: - -`site`、`navigation`、`justIn`、`lead`、`news`、`services`、`broadcast`、`channels`、`media`、`footer`。 - -## 中文内容规则 - -- `site.language` 固定为 `zh-CN`。 -- 品牌缩写 `GMA` 可以保留;其余品牌说明、栏目、标题、摘要、作者、按钮、日期、天气、节目、频道和页脚文案必须使用简体中文。 -- JSON 键名及 `id`、`href`、`visual`、`theme` 等技术值保持英文;数字、时间、温度、百分比、分辨率和彩票号码可以保持数据格式。 - -## 模块说明 - -- `site`:品牌、口号、日期、页面信息、主题和页头天气轮播。 -- `navigation`:菜单、搜索、对比模式、直播、工具链接、主分类、抽屉和搜索文案。 -- `justIn`:即时新闻标签、主消息、展开/收起文案及 1–6 条新闻。 -- `lead`:主头条、1–4 条副头条和 2–6 条标题带新闻。 -- `news`:追踪专题、2–4 个新闻流和 3–8 条热榜。 -- `services`:2–6 个城市天气及 2–8 个公共服务入口。 -- `broadcast`:正在播出主视觉、2–8 个节目时段和直播弹窗。 -- `channels`:2–6 个数据速览和 2–6 个频道卡片。 -- `media`:1–6 个视频、播客、2–4 种彩票玩法和订阅表单。 -- `footer`:说明、1–5 个链接列、版权和法律文案。 - -## 通用限制 - -- 文本必须是纯文本,不得包含 HTML 或 `javascript:`。具有 `visual` 的对象可增加 `media` 替换视觉:图片 `{type:"image", mediaUrl, alt?, pageUrl?}`;视频 `{type:"video", mediaUrl, posterUrl?, alt?, pageUrl?}`;网页 `{type:"webpage", pageUrl, title, description?, mediaUrl?, embed?}`。仅这些媒体 URL 字段允许 `http(s)`,图片/视频也允许对应的 `data:` URL。 -- 所有 `href` 必须以 `#` 开头;同一业务数组中的 `id` 必须唯一。 -- 页面不加载远程字体、脚本或接口;默认视觉由模板内置 CSS 图形生成,只有明确提供受校验媒体 URL 时才加载对应媒体。 -- JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。 - diff --git a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/gma-news-page-generator/scripts/fast_page.py deleted file mode 100644 index 13d8753d..00000000 --- a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "_shared")) - -import render_gma_news_page as renderer -from fast_patch_page import run - -if __name__ == "__main__": - sys.exit(run(renderer, output_stem="gma-news-page")) diff --git a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/scripts/render_gma_news_page.py b/offline-backend-20260512/backend/skills/public/gma-news-page-generator/scripts/render_gma_news_page.py deleted file mode 100644 index 45365bf3..00000000 --- a/offline-backend-20260512/backend/skills/public/gma-news-page-generator/scripts/render_gma_news_page.py +++ /dev/null @@ -1,494 +0,0 @@ -#!/usr/bin/env python3 -"""Validate GMA News-style page data and render the bundled template.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_DIR = Path(__file__).resolve().parents[1] -ASSETS_DIR = SKILL_DIR / "assets" -DEFAULT_DATA_PATH = ASSETS_DIR / "default-data.json" -TEMPLATE_PATH = ASSETS_DIR / "gma-news-template.html" -RUNTIME_PATH = ASSETS_DIR / "gma-news-runtime.js" -MODULES = ( - "site", - "navigation", - "justIn", - "lead", - "news", - "services", - "broadcast", - "channels", - "media", - "footer", -) -LEAD_VISUALS = {"transit-hub", "storm", "education"} -NEWS_VISUALS = {"transit", "civic", "health", "weather", "fare", "regional", "cacao"} -CHANNEL_VISUALS = {"showbiz", "lifestyle", "sports", "money"} -VIDEO_VISUALS = {"transit-video", "rain-video", "market-video"} -FORBIDDEN_TEXT = (" dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise DataValidationError(f"input file does not exist: {path}") from exc - except json.JSONDecodeError as exc: - raise DataValidationError( - f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" - ) from exc - if not isinstance(value, dict): - raise DataValidationError("top-level JSON value must be an object") - return value - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, -) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if len(value) < minimum: - errors.append(f"{path} must contain at least {minimum} item(s)") - if len(value) > maximum: - errors.append(f"{path} must contain at most {maximum} item(s)") - return value - - -def _required_string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty string") - - -def _strings(item: dict[str, Any], path: str, keys: tuple[str, ...], errors: list[str]) -> None: - for key in keys: - _required_string(item.get(key), f"{path}.{key}", errors) - - -def _href(value: Any, path: str, errors: list[str]) -> None: - _required_string(value, path, errors) - if isinstance(value, str) and not value.startswith("#"): - errors.append(f"{path} must be an in-page anchor beginning with #") - - -def _unique_id(item: dict[str, Any], path: str, seen: set[str], errors: list[str]) -> None: - item_id = item.get("id") - _required_string(item_id, f"{path}.id", errors) - if isinstance(item_id, str): - if item_id in seen: - errors.append(f"{path}.id duplicates {item_id!r}") - seen.add(item_id) - - -def _links( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, -) -> list[Any]: - items = _array(value, path, errors, minimum=minimum, maximum=maximum) - for index, raw in enumerate(items): - item = _mapping(raw, f"{path}[{index}]", errors) - _required_string(item.get("label"), f"{path}[{index}].label", errors) - _href(item.get("href"), f"{path}[{index}].href", errors) - return items - - -def _validate_no_remote_or_markup(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, dict): - for key, item in value.items(): - if key in MEDIA_URL_KEYS: - continue - _validate_no_remote_or_markup(item, f"{path}.{key}", errors) - elif isinstance(value, list): - for index, item in enumerate(value): - _validate_no_remote_or_markup(item, f"{path}[{index}]", errors) - elif isinstance(value, str): - lowered = value.lower() - if any(token in lowered for token in FORBIDDEN_TEXT): - errors.append(f"{path} must not contain HTML, script, iframe, or remote URLs") - - -def _remote_url(value: Any, path: str, errors: list[str], *, required: bool = False, data_prefix: str = "") -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media_tree(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, list): - for index, item in enumerate(value): - _validate_remote_media_tree(item, f"{path}[{index}]", errors) - return - if not isinstance(value, dict): - return - media = value.get("media") - if isinstance(media, dict) and "type" in media: - media_path, media_type = f"{path}.media", media.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - errors.append( - f"{media_path}.type must be one of: " - f"{', '.join(sorted(REMOTE_MEDIA_TYPES))}" - ) - elif media_type in {"image", "video"}: - _remote_url( - media.get("mediaUrl"), - f"{media_path}.mediaUrl", - errors, - required=True, - data_prefix=f"data:{media_type}/", - ) - if media_type == "video": - _remote_url( - media.get("posterUrl"), - f"{media_path}.posterUrl", - errors, - data_prefix="data:image/", - ) - if media_type == "webpage": - _remote_url( - media.get("pageUrl"), - f"{media_path}.pageUrl", - errors, - required=True, - ) - _remote_url( - media.get("mediaUrl"), - f"{media_path}.mediaUrl", - errors, - data_prefix="data:image/", - ) - if "embed" in media and not isinstance(media.get("embed"), bool): - errors.append(f"{media_path}.embed must be a boolean") - elif media_type in {"image", "video"}: - _remote_url(media.get("pageUrl"), f"{media_path}.pageUrl", errors) - for key, item in value.items(): - if key != "media": - _validate_remote_media_tree(item, f"{path}.{key}", errors) - - -def _validate_chinese_reader_copy( - value: Any, - path: str, - errors: list[str], - *, - key: str | None = None, -) -> None: - if isinstance(value, dict): - for child_key, item in value.items(): - _validate_chinese_reader_copy( - item, - f"{path}.{child_key}", - errors, - key=child_key, - ) - elif isinstance(value, list): - for index, item in enumerate(value): - _validate_chinese_reader_copy(item, f"{path}[{index}]", errors, key=key) - elif ( - isinstance(value, str) - and key not in TECHNICAL_TEXT_KEYS - and not (key and key.lower().endswith(("id", "href", "url"))) - ): - has_latin = any("a" <= character.lower() <= "z" for character in value) - has_cjk = any("\u3400" <= character <= "\u9fff" for character in value) - if has_latin and not has_cjk: - errors.append(f"{path} must use Simplified Chinese reader-facing copy") - - -def _validate_items( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, - string_keys: tuple[str, ...], - href_key: str | None = None, - visual_key: str | None = None, - visuals: set[str] | None = None, -) -> list[Any]: - items = _array(value, path, errors, minimum=minimum, maximum=maximum) - seen: set[str] = set() - for index, raw in enumerate(items): - item_path = f"{path}[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen, errors) - _strings(item, item_path, string_keys, errors) - if href_key: - _href(item.get(href_key), f"{item_path}.{href_key}", errors) - if visual_key and visuals is not None and item.get(visual_key) not in visuals: - errors.append(f"{item_path}.{visual_key} must be one of {sorted(visuals)}") - return items - - -def validate_document(data: dict[str, Any]) -> dict[str, int]: - errors: list[str] = [] - missing = [name for name in MODULES if name not in data] - extra = [name for name in data if name not in MODULES] - if missing: - errors.append("missing top-level module(s): " + ", ".join(missing)) - if extra: - errors.append("unknown top-level module(s): " + ", ".join(extra)) - - site = _mapping(data.get("site"), "site", errors) - _strings(site, "site", ("brandPrimary", "brandAccent", "tagline", "dateLabel", "pageTitle", "description", "language", "skipLabel"), errors) - if site.get("language") != "zh-CN": - errors.append("site.language must be zh-CN") - if site.get("theme") not in {"light", "dark", "auto"}: - errors.append("site.theme must be light, dark, or auto") - weather_mini = _validate_items(site.get("weatherMini"), "site.weatherMini", errors, minimum=1, maximum=6, string_keys=("city", "temp", "condition")) - - navigation = _mapping(data.get("navigation"), "navigation", errors) - _strings(navigation, "navigation", ("menuLabel", "searchLabel", "contrastOnLabel", "contrastOffLabel", "liveLabel"), errors) - top_links = _links(navigation.get("topLinks"), "navigation.topLinks", errors, minimum=1, maximum=6) - primary = _links(navigation.get("primary"), "navigation.primary", errors, minimum=4, maximum=12) - drawer = _mapping(navigation.get("drawer"), "navigation.drawer", errors) - _strings(drawer, "navigation.drawer", ("eyebrow", "title", "text", "ctaLabel"), errors) - _href(drawer.get("ctaHref"), "navigation.drawer.ctaHref", errors) - search = _mapping(navigation.get("search"), "navigation.search", errors) - _strings(search, "navigation.search", ("title", "placeholder", "hint", "emptyMessage", "resultPrefix"), errors) - - just_in = _mapping(data.get("justIn"), "justIn", errors) - _strings(just_in, "justIn", ("label", "moreLabel", "closeLabel", "featured"), errors) - just_items = _validate_items(just_in.get("items"), "justIn.items", errors, minimum=1, maximum=6, string_keys=("time", "title", "category"), href_key="href") - - lead = _mapping(data.get("lead"), "lead", errors) - main = _mapping(lead.get("main"), "lead.main", errors) - _unique_id(main, "lead.main", set(), errors) - _strings(main, "lead.main", ("kicker", "title", "summary", "byline", "time", "readTime", "videoLabel", "visual"), errors) - if main.get("visual") not in LEAD_VISUALS: - errors.append(f"lead.main.visual must be one of {sorted(LEAD_VISUALS)}") - side_stories = _validate_items(lead.get("sideStories"), "lead.sideStories", errors, minimum=1, maximum=4, string_keys=("kicker", "title", "summary", "time", "visual"), visual_key="visual", visuals=LEAD_VISUALS) - headlines = _validate_items(lead.get("headlines"), "lead.headlines", errors, minimum=2, maximum=6, string_keys=("kicker", "title"), href_key="href") - - news = _mapping(data.get("news"), "news", errors) - _strings(news, "news", ("trackingTitle", "trackingStatusPrefix", "trendingTitle", "trendingLabel"), errors) - topics = _validate_items(news.get("topics"), "news.topics", errors, minimum=2, maximum=8, string_keys=("label", "count")) - feeds = _array(news.get("feeds"), "news.feeds", errors, minimum=2, maximum=4) - seen_feeds: set[str] = set() - feed_items = 0 - for feed_index, raw_feed in enumerate(feeds): - feed_path = f"news.feeds[{feed_index}]" - feed = _mapping(raw_feed, feed_path, errors) - _unique_id(feed, feed_path, seen_feeds, errors) - _required_string(feed.get("label"), f"{feed_path}.label", errors) - items = _validate_items(feed.get("items"), f"{feed_path}.items", errors, minimum=1, maximum=8, string_keys=("kicker", "title", "summary", "time", "visual"), visual_key="visual", visuals=NEWS_VISUALS) - feed_items += len(items) - trending_items = _validate_items(news.get("trendingItems"), "news.trendingItems", errors, minimum=3, maximum=8, string_keys=("title",), href_key="href") - - services = _mapping(data.get("services"), "services", errors) - _strings(services, "services", ("weatherTitle", "weatherLiveLabel", "publicTitle"), errors) - locations = _array(services.get("locations"), "services.locations", errors, minimum=2, maximum=6) - seen_locations: set[str] = set() - forecast_count = 0 - for location_index, raw_location in enumerate(locations): - location_path = f"services.locations[{location_index}]" - location = _mapping(raw_location, location_path, errors) - _unique_id(location, location_path, seen_locations, errors) - _strings(location, location_path, ("city", "temp", "condition"), errors) - forecast = _array(location.get("forecast"), f"{location_path}.forecast", errors, minimum=2, maximum=5) - forecast_count += len(forecast) - for item_index, raw_item in enumerate(forecast): - item = _mapping(raw_item, f"{location_path}.forecast[{item_index}]", errors) - _strings(item, f"{location_path}.forecast[{item_index}]", ("day", "temp", "label"), errors) - public_items = _validate_items(services.get("publicItems"), "services.publicItems", errors, minimum=2, maximum=8, string_keys=("label", "message")) - - broadcast = _mapping(data.get("broadcast"), "broadcast", errors) - _strings(broadcast, "broadcast", ("eyebrow", "title", "moreLabel", "scheduleTitle"), errors) - _href(broadcast.get("moreHref"), "broadcast.moreHref", errors) - broadcast_hero = _mapping(broadcast.get("hero"), "broadcast.hero", errors) - _strings(broadcast_hero, "broadcast.hero", ("title", "summary", "visual", "actionLabel"), errors) - if broadcast_hero.get("visual") != "newsroom": - errors.append("broadcast.hero.visual must be newsroom") - slots = _validate_items(broadcast.get("slots"), "broadcast.slots", errors, minimum=2, maximum=8, string_keys=("time", "title", "summary")) - for index, slot in enumerate(slots): - if isinstance(slot, dict) and not isinstance(slot.get("live"), bool): - errors.append(f"broadcast.slots[{index}].live must be a boolean") - modal = _mapping(broadcast.get("modal"), "broadcast.modal", errors) - _strings(modal, "broadcast.modal", ("brand", "title", "note", "quality", "captionsOn", "captionsOff"), errors) - - channels = _mapping(data.get("channels"), "channels", errors) - _strings(channels, "channels", ("glanceTitle", "glanceMoreLabel", "deskTitle", "deskMoreLabel"), errors) - _href(channels.get("glanceMoreHref"), "channels.glanceMoreHref", errors) - _href(channels.get("deskMoreHref"), "channels.deskMoreHref", errors) - glance_items = _validate_items(channels.get("glanceItems"), "channels.glanceItems", errors, minimum=2, maximum=6, string_keys=("kicker", "value", "text", "actionLabel"), href_key="actionHref") - desk_items = _validate_items(channels.get("deskItems"), "channels.deskItems", errors, minimum=2, maximum=6, string_keys=("kicker", "title", "summary", "visual"), visual_key="visual", visuals=CHANNEL_VISUALS) - - media = _mapping(data.get("media"), "media", errors) - _strings(media, "media", ("videosTitle", "videosMoreLabel"), errors) - _href(media.get("videosMoreHref"), "media.videosMoreHref", errors) - videos = _validate_items(media.get("videos"), "media.videos", errors, minimum=1, maximum=6, string_keys=("kicker", "duration", "title", "summary", "visual"), visual_key="visual", visuals=VIDEO_VISUALS) - podcast = _mapping(media.get("podcast"), "media.podcast", errors) - _strings(podcast, "media.podcast", ("eyebrow", "title", "text", "playLabel", "pauseLabel", "startTime", "playingTime"), errors) - lotto = _mapping(media.get("lotto"), "media.lotto", errors) - _strings(lotto, "media.lotto", ("eyebrow", "title", "date"), errors) - games = _array(lotto.get("games"), "media.lotto.games", errors, minimum=2, maximum=4) - seen_games: set[str] = set() - lotto_numbers = 0 - for game_index, raw_game in enumerate(games): - game_path = f"media.lotto.games[{game_index}]" - game = _mapping(raw_game, game_path, errors) - _unique_id(game, game_path, seen_games, errors) - _required_string(game.get("label"), f"{game_path}.label", errors) - numbers = _array(game.get("numbers"), f"{game_path}.numbers", errors, minimum=5, maximum=8) - lotto_numbers += len(numbers) - for number_index, number in enumerate(numbers): - _required_string(number, f"{game_path}.numbers[{number_index}]", errors) - newsletter = _mapping(media.get("newsletter"), "media.newsletter", errors) - _strings(newsletter, "media.newsletter", ("title", "text", "label", "placeholder", "button", "successPrefix"), errors) - - footer = _mapping(data.get("footer"), "footer", errors) - _strings(footer, "footer", ("description", "copyright", "legal"), errors) - footer_columns = _array(footer.get("columns"), "footer.columns", errors, minimum=1, maximum=5) - seen_footer: set[str] = set() - footer_links = 0 - for index, raw in enumerate(footer_columns): - path = f"footer.columns[{index}]" - item = _mapping(raw, path, errors) - _unique_id(item, path, seen_footer, errors) - _required_string(item.get("title"), f"{path}.title", errors) - footer_links += len(_links(item.get("links"), f"{path}.links", errors, minimum=1, maximum=8)) - - _validate_remote_media_tree(data, "$", errors) - _validate_no_remote_or_markup(data, "$", errors) - _validate_chinese_reader_copy(data, "$", errors) - if errors: - raise DataValidationError("\n".join(f"- {error}" for error in errors)) - return { - "weather_mini": len(weather_mini), - "navigation_links": len(top_links) + len(primary), - "just_in_items": len(just_items), - "lead_stories": 1 + len(side_stories), - "headlines": len(headlines), - "tracking_topics": len(topics), - "feeds": len(feeds), - "feed_items": feed_items, - "trending_items": len(trending_items), - "weather_locations": len(locations), - "forecast_items": forecast_count, - "public_services": len(public_items), - "schedule_slots": len(slots), - "glance_items": len(glance_items), - "desk_items": len(desk_items), - "videos": len(videos), - "lotto_games": len(games), - "lotto_numbers": lotto_numbers, - "footer_columns": len(footer_columns), - "footer_links": footer_links, - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - data_text = json.dumps(data, ensure_ascii=False, separators=(",", ":")) - data_text = data_text.replace(" None: - if path.exists(): - raise DataValidationError(f"refusing to overwrite existing file: {path}; choose a new path") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8", newline="\n") - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False)) - - -def _command_init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA_PATH) - counts = validate_document(data) - _write_new(output, json.dumps(data, ensure_ascii=False, indent=2) + "\n") - return {"status":"ok","action":"init","output":str(output),"modules":list(MODULES),"counts":counts} - - -def _command_validate(input_path: Path) -> dict[str, Any]: - counts = validate_document(_read_json(input_path)) - return {"status":"ok","action":"validate","input":str(input_path),"modules":list(MODULES),"counts":counts} - - -def _command_render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - counts = validate_document(data) - template = TEMPLATE_PATH.read_text(encoding="utf-8") - runtime = RUNTIME_PATH.read_text(encoding="utf-8") - _write_new(output, render_document(data, template, runtime)) - return {"status":"ok","action":"render","input":str(input_path),"output":str(output),"modules":list(MODULES),"counts":counts} - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Initialize, validate, and render Chinese GMA News-style portal JSON.") - subparsers = parser.add_subparsers(dest="command", required=True) - init_parser = subparsers.add_parser("init", help="write a fresh default JSON file") - init_parser.add_argument("--output", required=True, type=Path) - validate_parser = subparsers.add_parser("validate", help="validate a JSON file") - validate_parser.add_argument("--input", required=True, type=Path) - render_parser = subparsers.add_parser("render", help="render validated JSON into the fixed HTML template") - render_parser.add_argument("--input", required=True, type=Path) - render_parser.add_argument("--output", required=True, type=Path) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - args = parser.parse_args(argv) - try: - if args.command == "init": - payload = _command_init(args.output) - elif args.command == "validate": - payload = _command_validate(args.input) - else: - payload = _command_render(args.input, args.output) - _emit(payload) - return 0 - except (DataValidationError, OSError) as exc: - _emit({"status":"error","action":args.command,"message":str(exc)}) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/manila-times-page-generator/SKILL.md deleted file mode 100644 index c4ff6f4e..00000000 --- a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: manila-times-page-generator -description: 生成或绘制中文马尼拉时报、The Manila Times 风格菲律宾大报门户的可交付单文件 HTML;仅在用户明确要求制作此类网页或点名本技能时使用。先把材料或检索结果整理成读者文案全中文的十模块 JSON,再用内置模板确定性渲染,页面保留右上角模块化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# 马尼拉时报页面生成器 - -本技能使用固定模板生成单文件菲律宾大报风格中文门户。模型只负责材料收集和 JSON,禁止重新手写页面结构、CSS 或运行时脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块编辑 JSON,并通过“应用并渲染”即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说“生成/画/制作马尼拉时报”“The Manila Times 风格网页/菲律宾大报门户”或点名 `manila-times-page-generator` 时使用。 -- 仅讨论菲律宾新闻、总结马尼拉时报消息或普通文本中出现 The Manila Times 时不要使用。 -- 这是马尼拉时报风格单页门户,不改用 CNN、BBC、新华社或通用新闻模板。 - -## 极速工作流(默认) - -页面结构已经固定。完成必要检索或材料整理后,模型只写一份小型规格 JSON;未写字段继续使用模板默认数据与示例视觉,脚本在一次进程内合并、校验并渲染。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/manila-times-page-spec.json`。直接写模块对象会递归合并;修改现有数组项优先用 `$set`: - - ```json - {"site":{"pageTitle":"马尼拉时报 · 中文专题"},"$set":{"ticker.text":"中文快讯","lead.main.title":"中文头条","lead.main.summary":"中文摘要"}} - ``` - -2. 只执行一次 `python /mnt/skills/public/manila-times-page-generator/scripts/fast_page.py --spec /mnt/user-data/outputs/manila-times-page-spec.json --output-dir /mnt/user-data/outputs`。 -3. stdout 为 `status: ok` 后立即用 `present_files` 交付完整 JSON 与 HTML,最终只回复一句完成说明。 - -规格只允许本技能十个模块和 `$set`;路径必须指向默认 JSON 的已存在字段。除技术键、品牌名、人物名和必要缩写外,所有读者可见内容必须是简体中文。未提供媒体直链时保留模板示例视觉。禁止读取默认数据、契约、模板、运行时或脚本源码,禁止 `--help`、独立 `init/validate/render`、生成后打补丁、临时脚本和冗长过程说明。规格报错时只修正一次并重跑。只有用户明确要求改变模块结构或批量重建完整数组时,才使用下面的完整工作流。 - -## 完整工作流(仅限结构改造) - -1. 读取当前会话材料、上传附件和已有产物,确定报头、导航、快讯、头条、市场脉搏、地区、评论、商业体育、视频数字报和页脚。 -2. 用户要求当前新闻、实时市场、真实作者或近期事件时,必须先检索可靠来源再填 JSON;用户明确说不要检索时不得联网。检索到英文或菲律宾语资料时先准确翻译成简体中文再写入 JSON,不得直接复制外文标题、摘要、按钮或日期。没有材料时使用中文中性演示内容,并在页脚明确说明不是官方网站。 -3. 初始化完整 JSON: - - ```text - python /mnt/skills/public/manila-times-page-generator/scripts/render_manila_times_page.py init --output /mnt/user-data/outputs/manila-times-page-data.json - ``` - -4. 只修改 JSON。十个顶层模块必须全部保留:`site`、`navigation`、`ticker`、`lead`、`pulse`、`regions`、`opinion`、`sections`、`media`、`footer`。JSON 键名、`id`、`href`、`visual`、`direction`、`theme` 等技术值继续使用契约规定的英文标识;除此之外,所有面向读者的报头、栏目、标题、摘要、作者、按钮、日期、地区、活动和页脚文案必须使用简体中文,`site.language` 固定为 `zh-CN`。字段不确定时读取 [references/data-schema.md](references/data-schema.md)。 -5. 校验 JSON: - - ```text - python /mnt/skills/public/manila-times-page-generator/scripts/render_manila_times_page.py validate --input /mnt/user-data/outputs/manila-times-page-data.json - ``` - -6. 确定性渲染到 `outputs/`: - - ```text - python /mnt/skills/public/manila-times-page-generator/scripts/render_manila_times_page.py render --input /mnt/user-data/outputs/manila-times-page-data.json --output /mnt/user-data/outputs/manila-times-page.html - ``` - -7. 检查 stdout 的 `status` 为 `ok`、HTML 存在且占位符已消失,再用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天中粘贴完整 HTML。 - -## 内容映射 - -- 报头、日期、版次、天气和主题放入 `site`;工具链接、主分类、抽屉与搜索文案放入 `navigation`。 -- 快讯放入 `ticker`;主头条、两篇侧栏和最新/热门列表放入 `lead`。 -- 市场与经济指标放入 `pulse.items`;吕宋、维萨亚、棉兰老和马尼拉报道放入 `regions.tabs`。 -- 评论作者与观点放入 `opinion.columns`;商业、体育和市场看板放入 `sections`。 -- 视频、字幕、数字报翻页和订阅表单放入 `media`;说明、栏目链接和法律文案放入 `footer`。 -- `visual` 继续作为内置后备;具有视觉的对象可增加受校验的 `media`(`image`、`video`、`webpage`)替换当前视觉。所有外部文本由模板转义,不得包含 HTML、脚本或直接 iframe。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/manila-times-template.html` 或 `assets/manila-times-runtime.js`,不复制模板后手改 DOM。 -- 不得生成英文或菲律宾语读者文案。外文来源必须翻译为自然、准确的简体中文;只有 JSON 键名、稳定 `id`、锚点、视觉枚举、涨跌枚举和主题枚举保留英文技术值。 -- 初始化与渲染默认拒绝覆盖已有文件;路径存在时换新文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖新闻 API、CDN 或 npm;仅当 JSON 明确提供受校验的媒体 URL 时加载图片、视频或网页。 -- 保留菲律宾旗色带、深蓝工具栏、衬线报头、三栏头条、地区切换、评论、商业体育、视频弹窗、数字报翻页和订阅反馈。 -- 浏览器缓存以初始 JSON 指纹隔离;沙箱禁止持久存储时必须继续使用内嵌 JSON 正常渲染。 - diff --git a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/default-data.json deleted file mode 100644 index 17281da5..00000000 --- a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/default-data.json +++ /dev/null @@ -1,261 +0,0 @@ -{ - "site": { - "brandPrefix": "马尼拉", - "brandAccent": "时报", - "kicker": "菲律宾独立新闻", - "tagline": "记录群岛每一天", - "dateLabel": "2026年10月30日 星期五", - "editionLabel": "马尼拉都会版", - "pageTitle": "马尼拉时报——菲律宾新闻门户", - "description": "以菲律宾英文大报视觉为基础制作的中文新闻门户概念页面。", - "language": "zh-CN", - "skipLabel": "跳转到头条新闻", - "theme": "light", - "weather": [ - {"id": "manila", "city": "马尼拉", "temp": "31°", "condition": "局部多云"}, - {"id": "cebu", "city": "宿务", "temp": "29°", "condition": "小雨"}, - {"id": "davao", "city": "达沃", "temp": "30°", "condition": "微风温暖"} - ] - }, - "navigation": { - "menuLabel": "全部栏目", - "searchLabel": "搜索", - "themeLightLabel": "日间模式", - "themeDarkLabel": "夜间模式", - "utilityLeft": [ - {"label": "数字报", "href": "#edition"}, - {"label": "新闻简报", "href": "#newsletter"} - ], - "utilityRight": [ - {"label": "关于我们", "href": "#footer"}, - {"label": "订阅", "href": "#newsletter"} - ], - "primary": [ - {"label": "头条", "href": "#top-stories"}, - {"label": "全国", "href": "#regions"}, - {"label": "商业", "href": "#business"}, - {"label": "评论", "href": "#opinion"}, - {"label": "体育", "href": "#sports"}, - {"label": "视频", "href": "#watch"} - ], - "drawer": { - "eyebrow": "晨间新闻简报", - "title": "读懂菲律宾群岛每一天", - "text": "从全国事务和地方新闻,到商业、体育与文化,一站浏览各新闻板块。", - "ctaLabel": "阅读头条", - "ctaHref": "#top-stories" - }, - "search": { - "title": "搜索新闻", - "placeholder": "可搜索交通、经济、群岛等关键词", - "hint": "搜索本页面中的全部新闻内容。", - "emptyMessage": "暂未找到相关新闻。", - "resultPrefix": "条相关新闻" - } - }, - "ticker": { - "label": "突发", - "text": "多地交通部门就节假日出行时刻表达成协调安排。", - "time": "上午8:42", - "href": "#top-stories" - }, - "lead": { - "datelineLeft": "马尼拉 · 全国新闻部", - "datelineRight": "全天持续更新", - "main": { - "id": "lead-transit", - "kicker": "基础设施", - "title": "新交通网络有望缩短首都居民的日常通勤时间", - "summary": "一项轨道交通与公交协同计划进入新阶段,将通勤者、社区接驳和清晰的公共信息置于核心位置。", - "byline": "记者 玛拉·维拉纽瓦", - "time": "18分钟前", - "visual": "bay-transit" - }, - "sideStories": [ - { - "id": "port-upgrade", - "kicker": "贸易", - "title": "港口为小型出口商开通数字化通道", - "summary": "试点计划将海关、物流企业与地方生产商连接起来。", - "byline": "商业新闻部", - "time": "42分钟前", - "visual": "port" - }, - { - "id": "city-schools", - "kicker": "教育", - "title": "城市学校将闲置庭院改造成清凉学习空间", - "summary": "教师与规划人员在夏季到来前测试带遮阳设施的户外课堂。", - "byline": "都会新闻部", - "time": "1小时前", - "visual": "city" - } - ], - "feeds": [ - { - "id": "latest", - "label": "最新", - "items": [ - {"time": "09:14", "title": "渡轮航线增加清晨班次"}, - {"time": "08:56", "title": "菲律宾央行维持现行政策不变"}, - {"time": "08:31", "title": "社区诊所延长周末服务时间"}, - {"time": "07:48", "title": "国家队启动青少年训练营"} - ] - }, - { - "id": "trending", - "label": "热榜", - "items": [ - {"time": "1", "title": "海岛食品市场成为周末热门目的地"}, - {"time": "2", "title": "读者共同绘制马尼拉林荫步行路线"}, - {"time": "3", "title": "青年创业者重塑社区配送服务"}, - {"time": "4", "title": "排球决赛创下本地收视新高"} - ] - } - ] - }, - "pulse": { - "label": "经济脉搏", - "title": "市场与经济", - "items": [ - {"id": "psei", "label": "菲律宾综合指数", "value": "7,112.48", "change": "+0.72%", "direction": "up"}, - {"id": "peso", "label": "比索/美元", "value": "57.24", "change": "−0.08", "direction": "down"}, - {"id": "fuel", "label": "燃料指数", "value": "98.6", "change": "持平", "direction": "flat"}, - {"id": "rice", "label": "大米价格监测", "value": "₱51.20", "change": "−1.1%", "direction": "down"} - ] - }, - "regions": { - "eyebrow": "走遍菲律宾", - "title": "来自各大岛群的新闻", - "moreLabel": "浏览全国新闻", - "moreHref": "#regions", - "tabs": [ - { - "id": "luzon", - "label": "吕宋岛", - "feature": {"kicker": "北吕宋", "title": "山区农场在雨季前协调农产品运输路线", "summary": "合作社共享仓储资源、天气信息和运输计划。", "visual": "luzon"}, - "items": [ - {"location": "碧瑶", "title": "夜市扩大零废弃试点范围", "summary": "商贩共同使用可循环周转箱和垃圾分类站。"}, - {"location": "比科尔", "title": "沿海城镇恢复红树林自然课堂", "summary": "学生每周与海洋研究人员共同开展野外学习。"} - ] - }, - { - "id": "visayas", - "label": "维萨亚群岛", - "feature": {"kicker": "中维萨亚", "title": "跨岛渡轮枢纽试行一票式换乘服务", "summary": "试点将海上交通与地方公交班次连接起来。", "visual": "visayas"}, - "items": [ - {"location": "宿务", "title": "设计工作室让地方编织图案焕发新生", "summary": "新系列将传统工艺与现代设计相结合。"}, - {"location": "伊洛伊洛", "title": "滨河步道最后一座公共花园开放", "summary": "本土树木和防洪步道共同构成新的公共公园。"} - ] - }, - { - "id": "mindanao", - "label": "棉兰老岛", - "feature": {"kicker": "南棉兰老", "title": "可可种植户共建质量检测实验室", "summary": "小型农场如今可以在当地完成发酵与风味检测。", "visual": "mindanao"}, - "items": [ - {"location": "达沃", "title": "食品集散中心增设农户直销摊位", "summary": "种植户可通过合作社服务台预约摊位。"}, - {"location": "布基农", "title": "太阳能冷库减少蔬菜采后损耗", "summary": "偏远社区获得了更长的市场销售窗口。"} - ] - }, - { - "id": "metro", - "label": "马尼拉都会区", - "feature": {"kicker": "首都地区", "title": "社区委员会发布夜间安全出行路线", "summary": "新的照明设施和夜间公交站点按街区完成标注。", "visual": "metro"}, - "items": [ - {"location": "奎松市", "title": "图书馆延长晚间自习服务时间", "summary": "三家分馆增设安静阅读室和通勤者学习区。"}, - {"location": "帕西格", "title": "无车星期日路线延伸至河滨地区", "summary": "扩展后的环线连接多个公园和社区市场。"} - ] - } - ] - }, - "opinion": { - "eyebrow": "评论", - "title": "思考变化中的共和国", - "summary": "汇集新闻部内外的专栏、分析与观点。", - "columns": [ - {"id": "public-trust", "initial": "林", "name": "安娜·雷耶斯", "title": "公众信任源于每一个细节", "summary": "当公众能够看见服务流程和时间安排时,每一项公共服务都会得到改善。"}, - {"id": "island-economy", "initial": "陈", "name": "米格尔·桑托斯", "title": "群岛经济需要的不只是桥梁", "summary": "可靠的班次和共享的信息同样属于基础设施。"}, - {"id": "young-cities", "initial": "苏", "name": "莉娅·克鲁兹", "title": "与年轻人共同设计城市", "summary": "最好的公共空间始于建设前的认真倾听。"} - ] - }, - "sections": { - "business": { - "eyebrow": "商业", - "title": "企业与市场", - "moreLabel": "更多商业新闻", - "moreHref": "#business", - "items": [ - {"id": "retail-data", "kicker": "零售", "title": "独立商店共享社区消费需求数据", "summary": "合作式数据平台帮助小型商店更合理地安排库存。", "visual": "market"}, - {"id": "green-factory", "kicker": "工业", "title": "工业园区联合采购清洁能源", "summary": "多家制造企业签署共同可再生能源供应协议。", "visual": "factory"}, - {"id": "shipping-tech", "kicker": "物流", "title": "航运企业为微型出口商简化单证", "summary": "统一表格减少了不同承运商之间的重复填报。", "visual": "shipping"} - ] - }, - "sports": { - "eyebrow": "体育", - "title": "全民赛场", - "moreLabel": "更多体育新闻", - "moreHref": "#sports", - "hero": {"id": "basketball-final", "kicker": "篮球", "title": "年轻后卫在总决赛揭幕战中掌控比赛节奏", "summary": "积极防守和果断投篮让最后一节成为全场焦点。", "visual": "basketball"}, - "items": [ - {"id": "road-running", "kicker": "跑步", "title": "城市路跑增设林荫社区赛道", "visual": "running"}, - {"id": "volleyball-camp", "kicker": "排球", "title": "国家训练营迎来规模最大的青少年学员", "visual": "volleyball"} - ] - }, - "market": { - "title": "市场行情", - "items": [ - {"label": "金融", "value": "+1.18%", "direction": "up"}, - {"label": "工业", "value": "+0.34%", "direction": "up"}, - {"label": "房地产", "value": "−0.41%", "direction": "down"} - ] - } - }, - "media": { - "watch": { - "eyebrow": "视频", - "title": "今日影像", - "moreLabel": "全部视频", - "moreHref": "#watch", - "videos": [ - {"id": "morning-brief", "duration": "04:18", "kicker": "新闻部", "title": "晨间简报:今天值得关注的五条新闻", "summary": "编辑解读影响今天的重要新闻。", "visual": "studio"}, - {"id": "island-route", "duration": "02:46", "kicker": "新闻解读", "title": "跨岛交通线路如何协同运行", "summary": "通过图解了解班次、港口和接驳方式。", "visual": "islands"}, - {"id": "night-market", "duration": "03:22", "kicker": "文化", "title": "夜幕下的马尼拉社区市场", "summary": "城市灯光下的美食、音乐和小型商业。", "visual": "city-night"} - ], - "modal": {"title": "马尼拉时报视频", "streamSubtitle": "演示视频", "captionsOn": "字幕:已开启", "captionsOff": "字幕:已关闭"} - }, - "edition": { - "eyebrow": "数字报", - "title": "按报纸原版版式阅读", - "summary": "在当前文件中翻阅一份由四个版面组成的演示数字报。", - "openLabel": "打开今日数字报", - "subscribeLabel": "立即订阅", - "subscribeHref": "#newsletter", - "modalTitle": "今日数字报", - "date": "2026年10月30日", - "pages": [ - {"id": "front", "label": "头版", "headline": "更顺畅的首都交通始于高效连接", "text": "交通部门、地方政府和社区服务机构共同制定新的公共出行时刻表。"}, - {"id": "nation", "label": "全国", "headline": "各岛群共同建设气候信息记录平台", "text": "地方研究人员为农场、港口和学校发布实用观测信息。"}, - {"id": "business", "label": "商业", "headline": "小型出口商获得更便捷的市场通道", "text": "数字单证和共享物流服务减少了重复流程。"}, - {"id": "sports", "label": "体育", "headline": "青年队伍为总决赛注入全新活力", "text": "新一代运动员在篮球和排球赛场成为焦点。"} - ] - }, - "newsletter": { - "eyebrow": "晨间简报", - "title": "从充分了解开始新一天", - "text": "用一份精简简报掌握全国、地方与商业新闻。", - "placeholder": "请输入电子邮箱", - "button": "订阅晨间简报", - "successPrefix": "已为以下邮箱预留简报订阅" - } - }, - "footer": { - "description": "用于展示马尼拉大报风格新闻门户的中文编辑概念页面。本页面并非马尼拉时报官方网站。", - "columns": [ - {"id": "news", "title": "新闻", "links": [{"label": "头条", "href": "#top-stories"}, {"label": "地区新闻", "href": "#regions"}, {"label": "商业", "href": "#business"}]}, - {"id": "voices", "title": "观点与文体", "links": [{"label": "评论", "href": "#opinion"}, {"label": "体育", "href": "#sports"}, {"label": "视频", "href": "#watch"}]}, - {"id": "reader", "title": "读者服务", "links": [{"label": "数字报", "href": "#edition"}, {"label": "新闻简报", "href": "#newsletter"}, {"label": "关于本演示页", "href": "#footer"}]} - ], - "copyright": "© 2026 海港新闻概念页面", - "legal": "仅供演示 · 与马尼拉时报无隶属或合作关系" - } -} diff --git a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/manila-times-runtime.js b/offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/manila-times-runtime.js deleted file mode 100644 index 29525bc3..00000000 --- a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/manila-times-runtime.js +++ /dev/null @@ -1,519 +0,0 @@ -(() => { - "use strict"; - - const MODULES = ["site", "navigation", "ticker", "lead", "pulse", "regions", "opinion", "sections", "media", "footer"]; - const MODULE_META = { - site: "站点信息", - navigation: "页面导航", - ticker: "突发快讯", - lead: "头条新闻", - pulse: "经济脉搏", - regions: "地区新闻", - opinion: "评论观点", - sections: "商业体育", - media: "视频与数字报", - footer: "页面页脚" - }; - const node = (id) => document.getElementById(id); - const initialText = node("manilaTimesPageData").textContent; - const initialData = JSON.parse(initialText); - const clone = (value) => JSON.parse(JSON.stringify(value)); - const fingerprint = (() => { - let hash = 2166136261; - for (let index = 0; index < initialText.length; index += 1) { - hash ^= initialText.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0).toString(36); - })(); - const dataKey = "manila-times-page-data-v1:" + fingerprint; - const themeKey = "manila-times-page-theme-v1:" + fingerprint; - const readStorage = (key) => { - try { return window.localStorage.getItem(key); } catch (_error) { return null; } - }; - const writeStorage = (key, value) => { - try { window.localStorage.setItem(key, value); return true; } catch (_error) { return false; } - }; - const removeStorage = (key) => { - try { window.localStorage.removeItem(key); return true; } catch (_error) { return false; } - }; - const saved = readStorage(dataKey); - let data = initialData; - if (saved) { - try { data = JSON.parse(saved); } catch (_error) { data = initialData; } - } - let currentModule = MODULES[0]; - let activeFeed = "latest"; - let activeRegion = data.regions.tabs[0]?.id || ""; - let weatherIndex = 0; - let editionIndex = 0; - let captionsOn = true; - const savedStories = new Set(); - - const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (character) => ({"&":"&","<":"<",">":">","'":"'",'"':"""})[character]); - const safeHref = (value) => typeof value === "string" && value.startsWith("#") ? value : "#"; - const safeRemoteUrl = (value, kind = "link") => { - const source = String(value || "").trim(); if (!source) return ""; const lowered = source.toLowerCase(); - if (kind === "image" && lowered.startsWith("data:image/")) return source; if (kind === "video" && lowered.startsWith("data:video/")) return source; - try { const url = new URL(source); return ["http:","https:"].includes(url.protocol) ? url.href : ""; } catch (_error) { return ""; } - }; - const linkMarkup = (item) => `${esc(item.label)}`; - const setText = (id, value) => { const element = node(id); if (element) element.textContent = value ?? ""; }; - const setLink = (id, item) => { const element = node(id); if (!element) return; element.textContent = item.label ?? ""; element.href = safeHref(item.href); }; - const allText = (value, output = []) => { - if (Array.isArray(value)) value.forEach((item) => allText(item, output)); - else if (value && typeof value === "object") Object.values(value).forEach((item) => allText(item, output)); - else if (typeof value === "string") output.push(value); - return output; - }; - document.head.insertAdjacentHTML("beforeend", ''); - const externalMedia = (media, label) => { - if (!media || typeof media !== "object") return ""; - if (media.type === "image") { const source=safeRemoteUrl(media.mediaUrl,"image"),page=safeRemoteUrl(media.pageUrl); return source ? `${esc(media.alt||label||${page?`打开来源`:""}` : ""; } - if (media.type === "video") { const source=safeRemoteUrl(media.mediaUrl,"video"),poster=safeRemoteUrl(media.posterUrl,"image"); return source ? `` : ""; } - if (media.type === "webpage") { const page=safeRemoteUrl(media.pageUrl),preview=safeRemoteUrl(media.mediaUrl,"image"); if(!page)return ""; const body=media.embed===true?``:(preview?`${esc(media.alt||media.title||`:""); return `${body}${esc(media.title||label||"打开网页")}${esc(media.description||"新窗口打开")}`; } - return ""; - }; - const visual = (kind, className = "story-visual", media = null, extra = "", mediaPath = "") => { - const mediaMarkup = externalMedia(media, media?.title); - const classes = `${className}${mediaMarkup ? " has-external-media" : ""}`; - const pathAttribute = mediaPath ? ` data-media-path="${esc(mediaPath)}"` : ""; - return ``; - }; - - function renderChrome() { - document.documentElement.lang = data.site.language; - document.title = data.site.pageTitle; - document.querySelector('meta[name="description"]')?.setAttribute("content", data.site.description); - setText("skipLink", data.site.skipLabel); - setText("brandKicker", data.site.kicker); setText("brandPrefix", data.site.brandPrefix); setText("brandAccent", data.site.brandAccent); setText("brandTagline", data.site.tagline); - setText("dateLabel", data.site.dateLabel); setText("editionLabel", data.site.editionLabel); setText("menuLabel", data.navigation.menuLabel); setText("searchLabel", data.navigation.searchLabel); - node("utilityLeft").innerHTML = data.navigation.utilityLeft.map(linkMarkup).join(""); - node("utilityRight").innerHTML = data.navigation.utilityRight.map(linkMarkup).join("") + ``; - node("primaryLinks").innerHTML = data.navigation.primary.map(linkMarkup).join(""); - setText("tickerLabel", data.ticker.label); setLink("tickerText", {label:data.ticker.text,href:data.ticker.href}); setText("tickerTime", data.ticker.time); - const storedTheme = readStorage(themeKey); - const initialTheme = storedTheme || (data.site.theme === "auto" ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : data.site.theme); - applyTheme(initialTheme); - node("themeToggle").addEventListener("click", () => applyTheme(document.body.classList.contains("night") ? "light" : "dark", true)); - setText("drawerBrand", `${data.site.brandPrefix} ${data.site.brandAccent}`); setText("searchBrand", `${data.site.brandPrefix} ${data.site.brandAccent}`); - setText("drawerEyebrow", data.navigation.drawer.eyebrow); setText("drawerTitle", data.navigation.drawer.title); setText("drawerText", data.navigation.drawer.text); setLink("drawerCta", {label:data.navigation.drawer.ctaLabel,href:data.navigation.drawer.ctaHref}); - node("drawerLinks").innerHTML = data.navigation.primary.map(linkMarkup).join(""); - setText("searchTitle", data.navigation.search.title); node("searchInput").placeholder = data.navigation.search.placeholder; - setText("footerBrand", `${data.site.brandPrefix} ${data.site.brandAccent}`); - renderWeather(); - } - - function applyTheme(theme, persist = false) { - const dark = theme === "dark"; - document.body.classList.toggle("night", dark); - const button = node("themeToggle"); - if (button) button.textContent = dark ? `☀ ${data.navigation.themeLightLabel}` : `◐ ${data.navigation.themeDarkLabel}`; - if (persist) writeStorage(themeKey, dark ? "dark" : "light"); - } - - function renderWeather() { - const items = data.site.weather; - if (!items.length) return; - weatherIndex %= items.length; - const weather = items[weatherIndex]; - setText("weatherCity", weather.city); setText("weatherTemp", weather.temp); setText("weatherNote", weather.condition); - } - - function saveButton(id) { - const saved = savedStories.has(id); - return ``; - } - - function renderLead() { - setText("datelineLeft", data.lead.datelineLeft); setText("datelineRight", data.lead.datelineRight); - const story = data.lead.main; - node("leadMain").innerHTML = `${saveButton(story.id)}
${esc(story.kicker)}

${esc(story.title)}

${esc(story.summary)}

${visual(story.visual,"story-visual",story.media,"","lead.main")}`; - node("sideStories").innerHTML = data.lead.sideStories.map((item, index) => `
${saveButton(item.id)}${visual(item.visual,"story-visual",item.media,"",`lead.sideStories.${index}`)}
${esc(item.kicker)}

${esc(item.title)}

${esc(item.summary)}

`).join(""); - if (!data.lead.feeds.some((feed) => feed.id === activeFeed)) activeFeed = data.lead.feeds[0]?.id; - node("feedTabs").innerHTML = data.lead.feeds.map((feed) => ``).join(""); - const feed = data.lead.feeds.find((item) => item.id === activeFeed) || data.lead.feeds[0]; - node("feedItems").innerHTML = feed.items.map((item) => `
  • ${esc(item.time)}${esc(item.title)}
  • `).join(""); - } - - function renderPulse() { - setText("pulseLabel", data.pulse.label); setText("pulseTitle", data.pulse.title); - node("pulseItems").innerHTML = data.pulse.items.map((item) => `
    ${esc(item.label)}
    ${esc(item.value)}
    ${esc(item.change)}
    `).join(""); - } - - function renderRegions() { - setText("regionsEyebrow", data.regions.eyebrow); setText("regionsTitle", data.regions.title); setLink("regionsMore", {label:data.regions.moreLabel,href:data.regions.moreHref}); - if (!data.regions.tabs.some((tab) => tab.id === activeRegion)) activeRegion = data.regions.tabs[0]?.id; - node("regionTabs").innerHTML = data.regions.tabs.map((tab) => ``).join(""); - const tab = data.regions.tabs.find((item) => item.id === activeRegion) || data.regions.tabs[0]; - const feature = tab.feature; - const tabIndex = data.regions.tabs.indexOf(tab); - node("regionContent").innerHTML = `
    ${esc(feature.kicker)}

    ${esc(feature.title)}

    ${esc(feature.summary)}

    ${visual(feature.visual,"region-visual",feature.media,"",`regions.tabs.${tabIndex}.feature`)}
    ${tab.items.map((item) => `
    ${esc(item.location)}

    ${esc(item.title)}

    ${esc(item.summary)}

    `).join("")}
    `; - } - - function renderOpinion() { - setText("opinionEyebrow", data.opinion.eyebrow); setText("opinionTitle", data.opinion.title); setText("opinionSummary", data.opinion.summary); - node("opinionGrid").innerHTML = data.opinion.columns.map((item) => `
    ${esc(item.initial)}${esc(item.name)}

    ${esc(item.title)}

    ${esc(item.summary)}

    `).join(""); - } - - function renderSections() { - const business = data.sections.business; - setText("businessEyebrow", business.eyebrow); setText("businessTitle", business.title); setLink("businessMore", {label:business.moreLabel,href:business.moreHref}); - node("businessItems").innerHTML = business.items.map((item, index) => `
    ${visual(item.visual,"mini-visual",item.media,"",`sections.business.items.${index}`)}
    ${esc(item.kicker)}

    ${esc(item.title)}

    ${esc(item.summary)}

    `).join(""); - const sports = data.sections.sports; - setText("sportsEyebrow", sports.eyebrow); setText("sportsTitle", sports.title); setLink("sportsMore", {label:sports.moreLabel,href:sports.moreHref}); - node("sportsHero").innerHTML = `
    ${esc(sports.hero.kicker)}

    ${esc(sports.hero.title)}

    ${esc(sports.hero.summary)}

    `; - node("sportsItems").innerHTML = sports.items.map((item, index) => `
    ${visual(item.visual,"mini-visual",item.media,"",`sections.sports.items.${index}`)}
    ${esc(item.kicker)}

    ${esc(item.title)}

    `).join(""); - node("marketBoard").innerHTML = `

    ${esc(data.sections.market.title)}

    ${data.sections.market.items.map((item) => `
    ${esc(item.label)}${esc(item.value)}
    `).join("")}
    `; - } - - function renderMedia() { - const watch = data.media.watch; - setText("watchEyebrow", watch.eyebrow); setText("watchTitle", watch.title); setLink("watchMore", {label:watch.moreLabel,href:watch.moreHref}); - node("videoGrid").innerHTML = watch.videos.map((item, index) => ``).join(""); - setText("videoModalTitle", watch.modal.title); setText("videoSubtitle", watch.modal.streamSubtitle); - const edition = data.media.edition; - setText("editionEyebrow", edition.eyebrow); setText("editionTitle", edition.title); setText("editionSummary", edition.summary); setText("openEdition", edition.openLabel); setLink("editionSubscribe", {label:edition.subscribeLabel,href:edition.subscribeHref}); setText("editionModalTitle", edition.modalTitle); - const newsletter = data.media.newsletter; - setText("newsletterEyebrow", newsletter.eyebrow); setText("newsletterTitle", newsletter.title); setText("newsletterText", newsletter.text); node("newsletterEmail").placeholder = newsletter.placeholder; setText("newsletterButton", newsletter.button); - } - - function renderFooter() { - setText("footerDescription", data.footer.description); setText("footerCopyright", data.footer.copyright); setText("footerLegal", data.footer.legal); - node("footerColumns").innerHTML = data.footer.columns.map((column) => `

    ${esc(column.title)}

    ${column.links.map(linkMarkup).join("")}
    `).join(""); - } - - function renderAll() { - renderChrome(); renderLead(); renderPulse(); renderRegions(); renderOpinion(); renderSections(); renderMedia(); renderFooter(); bindDynamicControls(); - } - - function bindDynamicControls() { - document.querySelectorAll("[data-save]").forEach((button) => button.addEventListener("click", () => { const id = button.dataset.save; savedStories.has(id) ? savedStories.delete(id) : savedStories.add(id); renderLead(); bindDynamicControls(); })); - document.querySelectorAll("[data-feed]").forEach((button) => button.addEventListener("click", () => { activeFeed = button.dataset.feed; renderLead(); bindDynamicControls(); })); - document.querySelectorAll("[data-region]").forEach((button) => button.addEventListener("click", () => { activeRegion = button.dataset.region; renderRegions(); bindDynamicControls(); })); - document.querySelectorAll("[data-video]").forEach((button) => button.addEventListener("click", () => openVideo(button.dataset.video))); - } - - function openOverlay(id) { - const overlay = node(id); overlay.classList.add("open"); overlay.setAttribute("aria-hidden", "false"); document.body.style.overflow = "hidden"; - } - function closeOverlay(id) { - const overlay = node(id); overlay.classList.remove("open"); overlay.setAttribute("aria-hidden", "true"); - if (!document.querySelector(".screen-overlay.open,.modal-overlay.open,.settings-overlay.open")) document.body.style.overflow = ""; - } - function openVideo(id) { - const item = data.media.watch.videos.find((video) => video.id === id); if (!item) return; - captionsOn = true; setText("videoPlayingTitle", item.title); setText("videoCaption", item.summary); updateCaption(); openOverlay("videoOverlay"); - } - function updateCaption() { - setText("captionToggle", captionsOn ? data.media.watch.modal.captionsOn : data.media.watch.modal.captionsOff); - node("videoCaption").hidden = !captionsOn; - } - function renderEditionPage() { - const pages = data.media.edition.pages; editionIndex = Math.max(0, Math.min(editionIndex, pages.length - 1)); const page = pages[editionIndex]; - node("editionPage").innerHTML = `
    ${esc(data.site.brandPrefix)} ${esc(data.site.brandAccent)}
    ${esc(page.label)} · ${esc(data.media.edition.date)}

    ${esc(page.headline)}

    ${esc(page.text)}

    `; - setText("editionPageCount", `${editionIndex + 1} / ${pages.length}`); node("editionPrev").disabled = editionIndex === 0; node("editionNext").disabled = editionIndex === pages.length - 1; - } - - function searchStories() { - const query = node("searchInput").value.trim().toLowerCase(); - const candidates = []; - const collect = (value) => { - if (Array.isArray(value)) value.forEach(collect); - else if (value && typeof value === "object") { if (typeof value.title === "string") candidates.push({title:value.title,kicker:value.kicker || value.location || "Story"}); Object.values(value).forEach(collect); } - }; - collect({lead:data.lead,regions:data.regions,opinion:data.opinion,sections:data.sections,media:data.media.watch}); - const unique = Array.from(new Map(candidates.map((item) => [item.title,item])).values()); - const results = query ? unique.filter((item) => `${item.title} ${item.kicker}`.toLowerCase().includes(query)) : unique.slice(0,8); - setText("searchMeta", results.length ? `${results.length} ${data.navigation.search.resultPrefix}` : data.navigation.search.emptyMessage); - node("searchResults").innerHTML = results.map((item) => ``).join(""); - } - - function validateClientDocument(candidate) { - if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new Error("顶层 JSON 必须是对象。"); - const keys = Object.keys(candidate); const missing = MODULES.filter((name) => !keys.includes(name)); const extra = keys.filter((name) => !MODULES.includes(name)); - if (missing.length) throw new Error(`缺少模块:${missing.join("、")}`); if (extra.length) throw new Error(`存在未知模块:${extra.join("、")}`); - MODULES.forEach((name) => { if (!candidate[name] || typeof candidate[name] !== "object" || Array.isArray(candidate[name])) throw new Error(`${name} 必须是对象。`); }); - const text = JSON.stringify(candidate).toLowerCase(); if ([" text.includes(token))) throw new Error("不允许使用 HTML 或脚本。"); - const validateMedia=(value,path="当前模块")=>{if(Array.isArray(value))return value.forEach((item,index)=>validateMedia(item,`${path}[${index}]`));if(!value||typeof value!=="object")return;const media=value.media;if(media&&["image","video","webpage"].includes(media.type)){if(["image","video"].includes(media.type)&&!safeRemoteUrl(media.mediaUrl,media.type))throw new Error(`${path}.media.mediaUrl 无效。`);if(media.type==="webpage"&&!safeRemoteUrl(media.pageUrl))throw new Error(`${path}.media.pageUrl 无效。`);if(media.posterUrl&&!safeRemoteUrl(media.posterUrl,"image"))throw new Error(`${path}.media.posterUrl 无效。`)}Object.entries(value).forEach(([key,item])=>{if(key!=="media")validateMedia(item,`${path}.${key}`)})};validateMedia(candidate); - if (candidate.site.language !== "zh-CN") throw new Error("site.language 必须是 zh-CN。"); - const technicalKeys = new Set(["id","href","visual","direction","theme","language","type","mediaUrl","posterUrl","pageUrl"]); - const checkChinese = (value, key = "") => { - if (Array.isArray(value)) value.forEach((item) => checkChinese(item, key)); - else if (value && typeof value === "object") Object.entries(value).forEach(([childKey,item]) => checkChinese(item, childKey)); - else if (typeof value === "string" && !technicalKeys.has(key) && !key.toLowerCase().endsWith("id") && !key.toLowerCase().endsWith("href") && !key.toLowerCase().endsWith("url")) { - if (/[A-Za-z]/.test(value) && !/[\u3400-\u9fff]/.test(value)) throw new Error("所有读者可见文案必须使用简体中文。"); - } - }; - checkChinese(candidate); - if (!Array.isArray(candidate.regions.tabs) || !candidate.regions.tabs.length) throw new Error("regions.tabs 不能为空。"); - if (!Array.isArray(candidate.media.edition.pages) || !candidate.media.edition.pages.length) throw new Error("media.edition.pages 不能为空。"); - return true; - } - - function showModule(name) { - currentModule = name; node("jsonEditor").value = JSON.stringify(data[name], null, 2); setText("editorModule", MODULE_META[name]); setText("jsonValidity", "JSON 有效"); - node("moduleTabs").querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.module === name)); - } - function renderSettingsTabs() { - node("moduleTabs").innerHTML = MODULES.map((name) => ``).join(""); - node("moduleTabs").querySelectorAll("button").forEach((button) => button.addEventListener("click", () => showModule(button.dataset.module))); showModule(currentModule); - } - function parseEditor() { - try { const value = JSON.parse(node("jsonEditor").value); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("当前模块必须是对象。"); setText("jsonValidity", "JSON 有效"); return value; } - catch (error) { setText("jsonValidity", "JSON 无效"); throw error; } - } - function setStatus(message) { setText("settingsStatus", message); } - async function copyJson() { - const text = JSON.stringify({...data,[currentModule]:parseEditor()}, null, 2); - try { await navigator.clipboard.writeText(text); setStatus("完整 JSON 已复制。"); } - catch (_error) { node("jsonEditor").select(); document.execCommand("copy"); setStatus("已选中当前模块,可手动复制。"); } - } - function downloadJson() { - const candidate = {...data,[currentModule]:parseEditor()}; validateClientDocument(candidate); const blob = new Blob([JSON.stringify(candidate,null,2)+"\n"],{type:"application/json"}); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href=url; anchor.download="manila-times-page-data.json"; anchor.click(); URL.revokeObjectURL(url); setStatus("JSON 下载文件已生成。"); - } - function applyEditor() { - try { const candidate = {...data,[currentModule]:parseEditor()}; validateClientDocument(candidate); data=candidate; writeStorage(dataKey,JSON.stringify(data)); renderAll(); renderSettingsTabs(); setStatus("JSON 已应用并完成渲染。"); } - catch (error) { setStatus(error.message); setText("jsonValidity","JSON 无效"); } - } - - node("weatherButton").addEventListener("click", () => { weatherIndex += 1; renderWeather(); }); - node("openDrawer").addEventListener("click", () => openOverlay("drawerOverlay")); - node("openSearch").addEventListener("click", () => { openOverlay("searchOverlay"); searchStories(); setTimeout(() => node("searchInput").focus(),0); }); - node("searchInput").addEventListener("input", searchStories); - node("captionToggle").addEventListener("click", () => { captionsOn = !captionsOn; updateCaption(); }); - node("openEdition").addEventListener("click", () => { editionIndex=0; renderEditionPage(); openOverlay("editionOverlay"); }); - node("editionPrev").addEventListener("click", () => { editionIndex -= 1; renderEditionPage(); }); - node("editionNext").addEventListener("click", () => { editionIndex += 1; renderEditionPage(); }); - node("newsletterForm").addEventListener("submit", (event) => { event.preventDefault(); setText("newsletterNote", `${data.media.newsletter.successPrefix} ${node("newsletterEmail").value}.`); }); - node("openSettings").addEventListener("click", () => { renderSettingsTabs(); setStatus(""); openOverlay("settingsOverlay"); }); - node("validateJson").addEventListener("click", () => { try { const candidate={...data,[currentModule]:parseEditor()}; validateClientDocument(candidate); setStatus("完整 JSON 校验通过。"); } catch(error) { setStatus(error.message); } }); - node("copyJson").addEventListener("click", () => copyJson().catch((error) => setStatus(error.message))); - node("downloadJson").addEventListener("click", () => { try { downloadJson(); } catch(error) { setStatus(error.message); } }); - node("resetJson").addEventListener("click", () => { data=clone(initialData); removeStorage(dataKey); removeStorage(themeKey); currentModule=MODULES[0]; activeFeed="latest"; activeRegion=data.regions.tabs[0].id; renderAll(); renderSettingsTabs(); setStatus("已恢复内嵌初始 JSON。"); }); - node("applyJson").addEventListener("click", applyEditor); - document.querySelectorAll("[data-close]").forEach((button) => button.addEventListener("click", () => closeOverlay(button.dataset.close))); - ["drawerOverlay","searchOverlay","videoOverlay","editionOverlay","settingsOverlay"].forEach((id) => node(id).addEventListener("click", (event) => { if (event.target === node(id)) closeOverlay(id); })); - document.addEventListener("keydown", (event) => { if (event.key === "Escape") document.querySelectorAll(".screen-overlay.open,.modal-overlay.open,.settings-overlay.open").forEach((overlay) => closeOverlay(overlay.id)); }); - window.addEventListener("scroll", () => node("primaryNav").classList.toggle("stuck", window.scrollY > 180), {passive:true}); - document.addEventListener("click", (event) => { const anchor=event.target.closest("a[href^='#']"); if (anchor && anchor.closest(".screen-overlay")) closeOverlay(anchor.closest(".screen-overlay").id); }); - - validateClientDocument(data); - renderAll(); - function installJsonMediaUpload(){ - const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return; - const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson");button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input); - const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message}; - const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;indexindex+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null}; - button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()}); - input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)}); - } - installJsonMediaUpload(); - function installInlineMediaReplace() { - if (document.getElementById("dfInlineMediaInput")) return; - const visualHostSelector = ".post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt"; - const getRootData = () => { - if (typeof pageData !== "undefined") return pageData; - if (typeof data !== "undefined") return data; - return null; - }; - const input = document.createElement("input"); - input.id = "dfInlineMediaInput"; - input.type = "file"; - input.hidden = true; - document.body.appendChild(input); - const style = document.createElement("style"); - style.dataset.dfInlineMediaReplace = "true"; - style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}"; - document.head.appendChild(style); - const notify = (message) => { - if (typeof showToast === "function") { showToast(message); return; } - if (typeof toast === "function") { toast(message); return; } - let notice = document.getElementById("dfInlineMediaToast"); - if (!notice) { - notice = document.createElement("div"); - notice.id = "dfInlineMediaToast"; - notice.className = "df-inline-media-toast"; - document.body.appendChild(notice); - } - notice.textContent = message; - window.clearTimeout(notice._timer); - notice._timer = window.setTimeout(() => notice.remove(), 2600); - }; - const sameSource = (value, source, absolute) => { - if (typeof value !== "string") return false; - if (value === source || value === absolute) return true; - try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; } - }; - const findMatches = (source, absolute) => { - const matches = []; - const stack = []; - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - stack.push(value); - Object.entries(value).forEach(([key, item]) => { - if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) { - const ancestors = stack.slice(0, -1).reverse(); - const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value; - matches.push({ owner, previous: value, key }); - } - if (item && typeof item === "object") visit(item); - }); - stack.pop(); - }; - visit(getRootData()); - return matches; - }; - const visualOwners = () => { - const owners = []; - const seen = new Set(); - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object"); - const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim()); - if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); } - Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); }); - }; - visit(getRootData()); - return owners; - }; - const domIdentifiers = (element) => { - const values = new Set(); - let cursor = element; - for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) { - Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); }); - } - return values; - }; - const exactPathTarget = (element) => { - const host = element.closest("[data-media-path]"); - const path = host && host.dataset ? host.dataset.mediaPath : ""; - if (!path) return null; - const owner = path.split(".").reduce((value, segment) => { - if (Array.isArray(value)) return value[Number(segment)]; - return value && typeof value === "object" ? value[segment] : null; - }, getRootData()); - if (!owner || typeof owner !== "object" || Array.isArray(owner)) return null; - return { owner, previous: owner.media && typeof owner.media === "object" ? owner.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseVisualTarget = (element) => { - const exact = exactPathTarget(element); - if (exact) return exact; - const identifiers = domIdentifiers(element); - const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element; - const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim(); - const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]"); - const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : ""; - let best = null; - let bestScore = 0; - visualOwners().forEach((owner) => { - let score = 0; - Object.entries(owner).forEach(([key, value]) => { - if (/id$/i.test(key) && identifiers.has(String(value))) score += 100; - }); - ["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => { - const value = typeof owner[key] === "string" ? owner[key].trim() : ""; - if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length); - }); - if (visualValue && owner.visual === visualValue) score += 20; - if (score > bestScore) { best = owner; bestScore = score; } - }); - if (!best || bestScore <= 0) return null; - return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseTarget = (element) => { - const exact = exactPathTarget(element); - if (exact) return exact; - const source = element.getAttribute("src") || ""; - const absolute = element.currentSrc || element.src || source; - const matches = findMatches(source, absolute); - if (!matches.length) return null; - const identifiers = domIdentifiers(element); - return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0]; - }; - const persist = () => { - const root = getRootData(); - if (typeof saveData === "function") return saveData(); - if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root)); - if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root)); - return false; - }; - let active = null; - const attachButton = (host, target) => { - if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return; - host.classList.add("df-inline-media-host"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "df-inline-media-button"; - button.setAttribute("aria-label", "替换图片或视频"); - button.title = "上传图片或视频并替换"; - button.innerHTML = ''; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - active = { ...target }; - input.accept = "image/*,video/*"; - input.value = ""; - input.click(); - }); - host.appendChild(button); - }; - const decorateBuiltInVisuals = () => { - document.querySelectorAll(visualHostSelector).forEach((host) => { - if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return; - if (!host.querySelector("svg") && !host.dataset.visual) return; - const target = chooseVisualTarget(host); - if (!target) return; - attachButton(host, target); - }); - }; - const decorate = () => { - decorateBuiltInVisuals(); - document.querySelectorAll("img[src],video[src]").forEach((media) => { - if (media.dataset.dfInlineMediaReady === "true") return; - const target = chooseTarget(media); - if (!target) return; - const host = media.closest(visualHostSelector) || media.parentElement; - if (!host) return; - media.dataset.dfInlineMediaReady = "true"; - attachButton(host, target); - }); - }; - input.addEventListener("change", () => { - const file = input.files && input.files[0]; - if (!file || !active) return; - const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : ""; - if (!uploadedType) { - notify("请选择图片或视频文件。"); - return; - } - const reader = new FileReader(); - reader.onerror = () => notify("文件读取失败,请重新选择。"); - reader.onload = () => { - const next = { ...(active.previous || {}) }; - const result = String(reader.result); - next.type = uploadedType; - next.mediaUrl = result; - delete next.posterUrl; - if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片"; - active.owner.media = next; - const stored = persist(); - renderAll(); - notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。"); - active = null; - }; - reader.readAsDataURL(file); - }); - let queued = false; - const queueDecorate = () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; decorate(); }); - }; - new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true }); - decorate(); - } - installInlineMediaReplace(); -})(); diff --git a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/manila-times-template.html b/offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/manila-times-template.html deleted file mode 100644 index fe119df7..00000000 --- a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/assets/manila-times-template.html +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - 马尼拉时报——菲律宾新闻门户 - - - - - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -

    -

    -

    -

    -

    - -
    -
    - - - - - - - - - - diff --git a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/manila-times-page-generator/references/data-schema.md deleted file mode 100644 index f5a20fde..00000000 --- a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/references/data-schema.md +++ /dev/null @@ -1,30 +0,0 @@ -# 马尼拉时报页面 JSON 契约 - -顶层只允许以下十个对象,任何模块都不能删除: - -`site`、`navigation`、`ticker`、`lead`、`pulse`、`regions`、`opinion`、`sections`、`media`、`footer`。 - -## 模块说明 - -- `site`:`brandPrefix/brandAccent/kicker/tagline/dateLabel/editionLabel/pageTitle/description/language/skipLabel/theme/weather[]`。`theme` 为 `light|dark|auto`;天气项含唯一 `id`、`city/temp/condition`。 -- `navigation`:`menuLabel/searchLabel/themeLightLabel/themeDarkLabel/utilityLeft[]/utilityRight[]/primary[]/drawer/search`。所有链接只允许 `#锚点`。 -- `ticker`:`label/text/time/href`。 -- `lead`:`datelineLeft/datelineRight/main/sideStories[]/feeds[]`。头条和侧栏含 `id/kicker/title/summary/byline/time/visual`;feed 含唯一 `id`、`label` 和新闻项。 -- `pulse`:`label/title/items[]`,指标项含唯一 `id`、`label/value/change/direction`,`direction` 为 `up|down|flat`。 -- `regions`:`eyebrow/title/moreLabel/moreHref/tabs[]`。每个标签含唯一 `id`、`label`、`feature` 和 `items[]`。 -- `opinion`:`eyebrow/title/summary/columns[]`,专栏含唯一 `id`、`initial/name/title/summary`。 -- `sections`:`business/sports/market`。商业卡片使用 `market|factory|shipping`;体育视觉使用 `basketball|running|volleyball`。 -- `media`:`watch/edition/newsletter`。视频视觉使用 `studio|islands|city-night`;数字报页含唯一 `id`、`label/headline/text`。 -- `footer`:`description/columns[]/copyright/legal`。 - -## 通用限制 - -- `site.language` 固定为 `zh-CN`。 -- 所有报头、栏目、标题、摘要、作者、按钮、日期、地区、活动和页脚等读者可见文案必须使用简体中文;英文或菲律宾语来源先翻译后再写入 JSON。 -- JSON 键名以及 `id`、`href`、`visual`、`direction`、`theme` 等技术值按契约保留英文,不要翻译或改名;数字、时间、百分比和货币值可以保持数据格式。 -- 文本必须是纯文本,不得包含 HTML 或 `javascript:`。具有 `visual` 的对象可增加 `media` 替换视觉:图片 `{type:"image", mediaUrl, alt?, pageUrl?}`;视频 `{type:"video", mediaUrl, posterUrl?, alt?, pageUrl?}`;网页 `{type:"webpage", pageUrl, title, description?, mediaUrl?, embed?}`。仅这些媒体 URL 字段允许 `http(s)`,图片/视频也允许对应的 `data:` URL。 -- 所有 `href` 必须以 `#` 开头。 -- 同一数组中的 `id` 必须唯一。 -- 页面不加载远程字体、脚本或接口;默认视觉由模板内置 CSS 图形生成,只有明确提供受校验媒体 URL 时才加载对应媒体。 -- 内容数组有上限,校验脚本报错时以报错字段为准。 - diff --git a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/manila-times-page-generator/scripts/fast_page.py deleted file mode 100644 index 5a70ec34..00000000 --- a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "_shared")) - -import render_manila_times_page as renderer -from fast_patch_page import run - -if __name__ == "__main__": - sys.exit(run(renderer, output_stem="manila-times-page")) diff --git a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/scripts/render_manila_times_page.py b/offline-backend-20260512/backend/skills/public/manila-times-page-generator/scripts/render_manila_times_page.py deleted file mode 100644 index b1a6def7..00000000 --- a/offline-backend-20260512/backend/skills/public/manila-times-page-generator/scripts/render_manila_times_page.py +++ /dev/null @@ -1,547 +0,0 @@ -#!/usr/bin/env python3 -"""Validate Manila Times-style page data and render the bundled template.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_DIR = Path(__file__).resolve().parents[1] -ASSETS_DIR = SKILL_DIR / "assets" -DEFAULT_DATA_PATH = ASSETS_DIR / "default-data.json" -TEMPLATE_PATH = ASSETS_DIR / "manila-times-template.html" -RUNTIME_PATH = ASSETS_DIR / "manila-times-runtime.js" -MODULES = ( - "site", - "navigation", - "ticker", - "lead", - "pulse", - "regions", - "opinion", - "sections", - "media", - "footer", -) -LEAD_VISUALS = {"bay-transit", "port", "city"} -REGION_VISUALS = {"luzon", "visayas", "mindanao", "metro"} -BUSINESS_VISUALS = {"market", "factory", "shipping"} -SPORTS_VISUALS = {"basketball", "running", "volleyball"} -VIDEO_VISUALS = {"studio", "islands", "city-night"} -FORBIDDEN_TEXT = (" dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise DataValidationError(f"input file does not exist: {path}") from exc - except json.JSONDecodeError as exc: - raise DataValidationError( - f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" - ) from exc - if not isinstance(value, dict): - raise DataValidationError("top-level JSON value must be an object") - return value - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, -) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if len(value) < minimum: - errors.append(f"{path} must contain at least {minimum} item(s)") - if len(value) > maximum: - errors.append(f"{path} must contain at most {maximum} item(s)") - return value - - -def _required_string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty string") - - -def _href(value: Any, path: str, errors: list[str]) -> None: - _required_string(value, path, errors) - if isinstance(value, str) and not value.startswith("#"): - errors.append(f"{path} must be an in-page anchor beginning with #") - - -def _unique_id(item: dict[str, Any], path: str, seen: set[str], errors: list[str]) -> None: - item_id = item.get("id") - _required_string(item_id, f"{path}.id", errors) - if isinstance(item_id, str): - if item_id in seen: - errors.append(f"{path}.id duplicates {item_id!r}") - seen.add(item_id) - - -def _links( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, -) -> list[Any]: - items = _array(value, path, errors, minimum=minimum, maximum=maximum) - for index, raw in enumerate(items): - item = _mapping(raw, f"{path}[{index}]", errors) - _required_string(item.get("label"), f"{path}[{index}].label", errors) - _href(item.get("href"), f"{path}[{index}].href", errors) - return items - - -def _strings(item: dict[str, Any], path: str, keys: tuple[str, ...], errors: list[str]) -> None: - for key in keys: - _required_string(item.get(key), f"{path}.{key}", errors) - - -def _validate_no_remote_or_markup(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, dict): - for key, item in value.items(): - if key in MEDIA_URL_KEYS: - continue - _validate_no_remote_or_markup(item, f"{path}.{key}", errors) - elif isinstance(value, list): - for index, item in enumerate(value): - _validate_no_remote_or_markup(item, f"{path}[{index}]", errors) - elif isinstance(value, str): - lowered = value.lower() - if any(token in lowered for token in FORBIDDEN_TEXT): - errors.append(f"{path} must not contain HTML, script, iframe, or remote URLs") - - -def _remote_url(value: Any, path: str, errors: list[str], *, required: bool = False, data_prefix: str = "") -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media_tree(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, list): - for index, item in enumerate(value): - _validate_remote_media_tree(item, f"{path}[{index}]", errors) - return - if not isinstance(value, dict): - return - media = value.get("media") - if isinstance(media, dict) and "type" in media: - media_path = f"{path}.media" - media_type = media.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - errors.append(f"{media_path}.type must be one of: {', '.join(sorted(REMOTE_MEDIA_TYPES))}") - elif media_type in {"image", "video"}: - _remote_url(media.get("mediaUrl"), f"{media_path}.mediaUrl", errors, required=True, data_prefix=f"data:{media_type}/") - if media_type == "video": - _remote_url(media.get("posterUrl"), f"{media_path}.posterUrl", errors, data_prefix="data:image/") - if media_type == "webpage": - _remote_url(media.get("pageUrl"), f"{media_path}.pageUrl", errors, required=True) - _remote_url(media.get("mediaUrl"), f"{media_path}.mediaUrl", errors, data_prefix="data:image/") - if "embed" in media and not isinstance(media.get("embed"), bool): - errors.append(f"{media_path}.embed must be a boolean") - elif media_type in {"image", "video"}: - _remote_url(media.get("pageUrl"), f"{media_path}.pageUrl", errors) - for key, item in value.items(): - if key != "media": - _validate_remote_media_tree(item, f"{path}.{key}", errors) - - -def _validate_chinese_reader_copy( - value: Any, - path: str, - errors: list[str], - *, - key: str | None = None, -) -> None: - if isinstance(value, dict): - for child_key, item in value.items(): - _validate_chinese_reader_copy( - item, - f"{path}.{child_key}", - errors, - key=child_key, - ) - elif isinstance(value, list): - for index, item in enumerate(value): - _validate_chinese_reader_copy(item, f"{path}[{index}]", errors, key=key) - elif ( - isinstance(value, str) - and key not in TECHNICAL_TEXT_KEYS - and not (key and key.lower().endswith(("id", "href", "url"))) - ): - has_latin = any( - "a" <= character.lower() <= "z" for character in value - ) - has_cjk = any("\u3400" <= character <= "\u9fff" for character in value) - if has_latin and not has_cjk: - errors.append(f"{path} must use Simplified Chinese reader-facing copy") - - -def _story( - value: Any, - path: str, - errors: list[str], - seen: set[str], - visuals: set[str], -) -> dict[str, Any]: - item = _mapping(value, path, errors) - _unique_id(item, path, seen, errors) - _strings(item, path, ("kicker", "title", "summary", "visual"), errors) - if item.get("visual") not in visuals: - errors.append(f"{path}.visual must be one of {sorted(visuals)}") - return item - - -def validate_document(data: dict[str, Any]) -> dict[str, int]: - errors: list[str] = [] - missing = [name for name in MODULES if name not in data] - extra = [name for name in data if name not in MODULES] - if missing: - errors.append("missing top-level module(s): " + ", ".join(missing)) - if extra: - errors.append("unknown top-level module(s): " + ", ".join(extra)) - - site = _mapping(data.get("site"), "site", errors) - _strings( - site, - "site", - ( - "brandPrefix", - "brandAccent", - "kicker", - "tagline", - "dateLabel", - "editionLabel", - "pageTitle", - "description", - "language", - "skipLabel", - ), - errors, - ) - if site.get("theme") not in {"light", "dark", "auto"}: - errors.append("site.theme must be light, dark, or auto") - if site.get("language") != "zh-CN": - errors.append("site.language must be zh-CN") - weather = _array(site.get("weather"), "site.weather", errors, minimum=1, maximum=6) - seen_weather: set[str] = set() - for index, raw in enumerate(weather): - path = f"site.weather[{index}]" - item = _mapping(raw, path, errors) - _unique_id(item, path, seen_weather, errors) - _strings(item, path, ("city", "temp", "condition"), errors) - - navigation = _mapping(data.get("navigation"), "navigation", errors) - _strings( - navigation, - "navigation", - ("menuLabel", "searchLabel", "themeLightLabel", "themeDarkLabel"), - errors, - ) - utility_left = _links( - navigation.get("utilityLeft"), - "navigation.utilityLeft", - errors, - minimum=1, - maximum=5, - ) - utility_right = _links( - navigation.get("utilityRight"), - "navigation.utilityRight", - errors, - minimum=1, - maximum=5, - ) - primary = _links( - navigation.get("primary"), - "navigation.primary", - errors, - minimum=3, - maximum=9, - ) - drawer = _mapping(navigation.get("drawer"), "navigation.drawer", errors) - _strings(drawer, "navigation.drawer", ("eyebrow", "title", "text", "ctaLabel"), errors) - _href(drawer.get("ctaHref"), "navigation.drawer.ctaHref", errors) - search = _mapping(navigation.get("search"), "navigation.search", errors) - _strings(search, "navigation.search", ("title", "placeholder", "hint", "emptyMessage", "resultPrefix"), errors) - - ticker = _mapping(data.get("ticker"), "ticker", errors) - _strings(ticker, "ticker", ("label", "text", "time"), errors) - _href(ticker.get("href"), "ticker.href", errors) - - lead = _mapping(data.get("lead"), "lead", errors) - _strings(lead, "lead", ("datelineLeft", "datelineRight"), errors) - seen_lead: set[str] = set() - main = _story(lead.get("main"), "lead.main", errors, seen_lead, LEAD_VISUALS) - _strings(main, "lead.main", ("byline", "time"), errors) - side_stories = _array(lead.get("sideStories"), "lead.sideStories", errors, minimum=1, maximum=4) - for index, raw in enumerate(side_stories): - path = f"lead.sideStories[{index}]" - item = _story(raw, path, errors, seen_lead, LEAD_VISUALS) - _strings(item, path, ("byline", "time"), errors) - feeds = _array(lead.get("feeds"), "lead.feeds", errors, minimum=2, maximum=3) - seen_feeds: set[str] = set() - feed_items = 0 - for feed_index, raw_feed in enumerate(feeds): - feed_path = f"lead.feeds[{feed_index}]" - feed = _mapping(raw_feed, feed_path, errors) - _unique_id(feed, feed_path, seen_feeds, errors) - _required_string(feed.get("label"), f"{feed_path}.label", errors) - items = _array(feed.get("items"), f"{feed_path}.items", errors, minimum=1, maximum=8) - feed_items += len(items) - for item_index, raw_item in enumerate(items): - item_path = f"{feed_path}.items[{item_index}]" - item = _mapping(raw_item, item_path, errors) - _strings(item, item_path, ("time", "title"), errors) - - pulse = _mapping(data.get("pulse"), "pulse", errors) - _strings(pulse, "pulse", ("label", "title"), errors) - pulse_items = _array(pulse.get("items"), "pulse.items", errors, minimum=2, maximum=8) - seen_pulse: set[str] = set() - for index, raw in enumerate(pulse_items): - path = f"pulse.items[{index}]" - item = _mapping(raw, path, errors) - _unique_id(item, path, seen_pulse, errors) - _strings(item, path, ("label", "value", "change"), errors) - if item.get("direction") not in {"up", "down", "flat"}: - errors.append(f"{path}.direction must be up, down, or flat") - - regions = _mapping(data.get("regions"), "regions", errors) - _strings(regions, "regions", ("eyebrow", "title", "moreLabel"), errors) - _href(regions.get("moreHref"), "regions.moreHref", errors) - region_tabs = _array(regions.get("tabs"), "regions.tabs", errors, minimum=2, maximum=6) - seen_regions: set[str] = set() - region_stories = 0 - for tab_index, raw_tab in enumerate(region_tabs): - tab_path = f"regions.tabs[{tab_index}]" - tab = _mapping(raw_tab, tab_path, errors) - _unique_id(tab, tab_path, seen_regions, errors) - _required_string(tab.get("label"), f"{tab_path}.label", errors) - feature = _mapping(tab.get("feature"), f"{tab_path}.feature", errors) - _strings(feature, f"{tab_path}.feature", ("kicker", "title", "summary", "visual"), errors) - if feature.get("visual") not in REGION_VISUALS: - errors.append(f"{tab_path}.feature.visual must be one of {sorted(REGION_VISUALS)}") - items = _array(tab.get("items"), f"{tab_path}.items", errors, minimum=1, maximum=6) - region_stories += len(items) - for item_index, raw_item in enumerate(items): - item_path = f"{tab_path}.items[{item_index}]" - item = _mapping(raw_item, item_path, errors) - _strings(item, item_path, ("location", "title", "summary"), errors) - - opinion = _mapping(data.get("opinion"), "opinion", errors) - _strings(opinion, "opinion", ("eyebrow", "title", "summary"), errors) - columns = _array(opinion.get("columns"), "opinion.columns", errors, minimum=2, maximum=6) - seen_columns: set[str] = set() - for index, raw in enumerate(columns): - path = f"opinion.columns[{index}]" - item = _mapping(raw, path, errors) - _unique_id(item, path, seen_columns, errors) - _strings(item, path, ("initial", "name", "title", "summary"), errors) - - sections = _mapping(data.get("sections"), "sections", errors) - business = _mapping(sections.get("business"), "sections.business", errors) - _strings(business, "sections.business", ("eyebrow", "title", "moreLabel"), errors) - _href(business.get("moreHref"), "sections.business.moreHref", errors) - business_items = _array(business.get("items"), "sections.business.items", errors, minimum=2, maximum=6) - seen_business: set[str] = set() - for index, raw in enumerate(business_items): - _story(raw, f"sections.business.items[{index}]", errors, seen_business, BUSINESS_VISUALS) - sports = _mapping(sections.get("sports"), "sections.sports", errors) - _strings(sports, "sections.sports", ("eyebrow", "title", "moreLabel"), errors) - _href(sports.get("moreHref"), "sections.sports.moreHref", errors) - seen_sports: set[str] = set() - _story(sports.get("hero"), "sections.sports.hero", errors, seen_sports, SPORTS_VISUALS) - sports_items = _array(sports.get("items"), "sections.sports.items", errors, minimum=1, maximum=5) - for index, raw in enumerate(sports_items): - item = _mapping(raw, f"sections.sports.items[{index}]", errors) - _unique_id(item, f"sections.sports.items[{index}]", seen_sports, errors) - _strings(item, f"sections.sports.items[{index}]", ("kicker", "title", "visual"), errors) - if item.get("visual") not in SPORTS_VISUALS: - errors.append(f"sections.sports.items[{index}].visual must be one of {sorted(SPORTS_VISUALS)}") - market = _mapping(sections.get("market"), "sections.market", errors) - _required_string(market.get("title"), "sections.market.title", errors) - market_items = _array(market.get("items"), "sections.market.items", errors, minimum=1, maximum=6) - for index, raw in enumerate(market_items): - path = f"sections.market.items[{index}]" - item = _mapping(raw, path, errors) - _strings(item, path, ("label", "value"), errors) - if item.get("direction") not in {"up", "down", "flat"}: - errors.append(f"{path}.direction must be up, down, or flat") - - media = _mapping(data.get("media"), "media", errors) - watch = _mapping(media.get("watch"), "media.watch", errors) - _strings(watch, "media.watch", ("eyebrow", "title", "moreLabel"), errors) - _href(watch.get("moreHref"), "media.watch.moreHref", errors) - videos = _array(watch.get("videos"), "media.watch.videos", errors, minimum=1, maximum=6) - seen_videos: set[str] = set() - for index, raw in enumerate(videos): - path = f"media.watch.videos[{index}]" - item = _mapping(raw, path, errors) - _unique_id(item, path, seen_videos, errors) - _strings(item, path, ("duration", "kicker", "title", "summary", "visual"), errors) - if item.get("visual") not in VIDEO_VISUALS: - errors.append(f"{path}.visual must be one of {sorted(VIDEO_VISUALS)}") - modal = _mapping(watch.get("modal"), "media.watch.modal", errors) - _strings(modal, "media.watch.modal", ("title", "streamSubtitle", "captionsOn", "captionsOff"), errors) - edition = _mapping(media.get("edition"), "media.edition", errors) - _strings(edition, "media.edition", ("eyebrow", "title", "summary", "openLabel", "subscribeLabel", "modalTitle", "date"), errors) - _href(edition.get("subscribeHref"), "media.edition.subscribeHref", errors) - pages = _array(edition.get("pages"), "media.edition.pages", errors, minimum=2, maximum=8) - seen_pages: set[str] = set() - for index, raw in enumerate(pages): - path = f"media.edition.pages[{index}]" - item = _mapping(raw, path, errors) - _unique_id(item, path, seen_pages, errors) - _strings(item, path, ("label", "headline", "text"), errors) - newsletter = _mapping(media.get("newsletter"), "media.newsletter", errors) - _strings(newsletter, "media.newsletter", ("eyebrow", "title", "text", "placeholder", "button", "successPrefix"), errors) - - footer = _mapping(data.get("footer"), "footer", errors) - _strings(footer, "footer", ("description", "copyright", "legal"), errors) - footer_columns = _array(footer.get("columns"), "footer.columns", errors, minimum=1, maximum=5) - seen_footer: set[str] = set() - footer_links = 0 - for index, raw in enumerate(footer_columns): - path = f"footer.columns[{index}]" - item = _mapping(raw, path, errors) - _unique_id(item, path, seen_footer, errors) - _required_string(item.get("title"), f"{path}.title", errors) - footer_links += len(_links(item.get("links"), f"{path}.links", errors, minimum=1, maximum=8)) - - _validate_remote_media_tree(data, "$", errors) - _validate_no_remote_or_markup(data, "$", errors) - _validate_chinese_reader_copy(data, "$", errors) - if errors: - raise DataValidationError("\n".join(f"- {error}" for error in errors)) - return { - "weather_locations": len(weather), - "navigation_links": len(utility_left) + len(utility_right) + len(primary), - "lead_stories": 1 + len(side_stories), - "feeds": len(feeds), - "feed_items": feed_items, - "pulse_items": len(pulse_items), - "region_tabs": len(region_tabs), - "region_stories": region_stories, - "opinion_columns": len(columns), - "business_stories": len(business_items), - "sports_stories": 1 + len(sports_items), - "market_items": len(market_items), - "videos": len(videos), - "edition_pages": len(pages), - "footer_columns": len(footer_columns), - "footer_links": footer_links, - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - data_text = json.dumps(data, ensure_ascii=False, separators=(",", ":")) - data_text = data_text.replace(" None: - if path.exists(): - raise DataValidationError(f"refusing to overwrite existing file: {path}; choose a new path") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8", newline="\n") - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False)) - - -def _command_init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA_PATH) - counts = validate_document(data) - _write_new(output, json.dumps(data, ensure_ascii=False, indent=2) + "\n") - return {"status": "ok", "action": "init", "output": str(output), "modules": list(MODULES), "counts": counts} - - -def _command_validate(input_path: Path) -> dict[str, Any]: - counts = validate_document(_read_json(input_path)) - return {"status": "ok", "action": "validate", "input": str(input_path), "modules": list(MODULES), "counts": counts} - - -def _command_render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - counts = validate_document(data) - template = TEMPLATE_PATH.read_text(encoding="utf-8") - runtime = RUNTIME_PATH.read_text(encoding="utf-8") - _write_new(output, render_document(data, template, runtime)) - return {"status": "ok", "action": "render", "input": str(input_path), "output": str(output), "modules": list(MODULES), "counts": counts} - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Initialize, validate, and render Manila Times-style portal JSON.") - subparsers = parser.add_subparsers(dest="command", required=True) - init_parser = subparsers.add_parser("init", help="write a fresh default JSON file") - init_parser.add_argument("--output", required=True, type=Path) - validate_parser = subparsers.add_parser("validate", help="validate a JSON file") - validate_parser.add_argument("--input", required=True, type=Path) - render_parser = subparsers.add_parser("render", help="render validated JSON into the fixed HTML template") - render_parser.add_argument("--input", required=True, type=Path) - render_parser.add_argument("--output", required=True, type=Path) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - args = parser.parse_args(argv) - try: - if args.command == "init": - payload = _command_init(args.output) - elif args.command == "validate": - payload = _command_validate(args.input) - else: - payload = _command_render(args.input, args.output) - _emit(payload) - return 0 - except (DataValidationError, OSError) as exc: - _emit({"status": "error", "action": args.command, "message": str(exc)}) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/offline-backend-20260512/backend/skills/public/threads-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/threads-page-generator/SKILL.md deleted file mode 100644 index 23116767..00000000 --- a/offline-backend-20260512/backend/skills/public/threads-page-generator/SKILL.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: threads-page-generator -description: 生成或绘制 Threads 及 Threads 风格的可交付文本社交信息流 HTML 网页;仅在用户明确要求 Threads 网页、Threads 风格串文首页或点名本技能时使用。先把材料或检索结果整理成六模块 JSON,再用内置模板确定性渲染,保留右上角可视化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# Threads 页面生成器 - -本技能使用固定模板生成单文件 Threads 风格文本社交信息流。模型只负责材料收集和 JSON,禁止重新手写页面结构、CSS 或运行时脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块编辑 JSON,并通过「应用并渲染」即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说「生成/画/制作 Threads 网页」「Threads 风格串文首页/社交信息流」或点名 `threads-page-generator` 时使用。 -- 仅讨论 Threads 平台、分析社交媒体、编写串文文案,或编程语境中出现 thread/线程时不要使用。 -- 这是单页文本社交模板,不改用 X 三栏时间线、Facebook 社区页或通用多页门户模板。 - -## 极速工作流(默认) - -页面结构已经固定。模型只写账号、串文和热评需求等高层字段;脚本负责映射六个页面模块、补齐作者和统计字段、生成中文热评、保留示例视觉,并在一次进程内完成校验与渲染。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/threads-page-spec.json`。单账号单串文直接使用以下格式: - - ```json - { - "accountName": "茂达是🥚", - "handle": "@maoda", - "verified": true, - "postText": "今天想和大家分享一个值得认真讨论的话题。", - "topic": "品牌日常", - "displayReplyCount": 3, - "totalReplyCount": 128 - } - ``` - - 需要指定热评时增加 `"replies":["中文热评一","中文热评二"]`;需要多条串文时使用 `"posts":[{"text":"第一条中文串文"},{"text":"第二条中文串文"}]`。不得写六模块 JSON、`$set` 或深层字段路径。 -2. 只执行一次 `python /mnt/skills/public/threads-page-generator/scripts/fast_page.py --spec /mnt/user-data/outputs/threads-page-spec.json --output-dir /mnt/user-data/outputs`。 -3. stdout 为 `status: ok` 后立即用 `present_files` 交付返回的完整 JSON 与 HTML,最终只回复一句完成说明。 - -`accountName` 与每条 `postText`/`posts[].text` 为必填内容;串文和回复必须使用简体中文,账号、品牌名和 `handle` 可保留原文。未提供媒体直链时不要写 `media`,脚本会保留模板示例视觉供用户上传替换。禁止读取默认数据、契约、模板、运行时或脚本源码,禁止 `--help`、独立 `init/validate/render`、生成后打补丁、临时脚本和冗长过程说明。规格报错时只修正规格一次并重跑。只有用户明确要求改变模块结构或批量重建完整数组时,才使用下面的完整工作流。 - -## 完整工作流(仅限结构改造) - -1. 读取当前会话材料、上传附件和已有产物,确定站点、当前账号、导航、发帖文案、串文、推荐关注、话题与动态通知。 -2. 用户要求基于公开资料、主题涉及近期事件,或明确要求检索时,先做至多两批聚焦检索;用户明确说不要检索时不得联网。没有材料时使用中性示例内容并明确标为示例,不虚构实时互动量。 -3. 初始化新的完整 JSON: - - ```text - python /mnt/skills/public/threads-page-generator/scripts/render_threads_page.py init --output /mnt/user-data/outputs/threads-page-data.json - ``` - -4. 只修改 JSON。六个顶层模块必须全部保留:`site`、`navigation`、`composer`、`threads`、`recommendations`、`activity`。字段不确定时读取 [references/data-schema.md](references/data-schema.md)。 -5. 校验 JSON: - - ```text - python /mnt/skills/public/threads-page-generator/scripts/render_threads_page.py validate --input /mnt/user-data/outputs/threads-page-data.json - ``` - -6. 确定性渲染到 `outputs/`: - - ```text - python /mnt/skills/public/threads-page-generator/scripts/render_threads_page.py render --input /mnt/user-data/outputs/threads-page-data.json --output /mnt/user-data/outputs/threads-page.html - ``` - -7. 检查 stdout 的 `status` 为 `ok`、HTML 存在且占位符已经消失,再用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天中粘贴完整 HTML。 - -## 内容映射 - -- 每条主串文放入 `threads.items`,嵌套回复放入该项的 `replies`;主串文 `id` 必须唯一。 -- `site.currentUser` 是当前登录账号,串文作者使用 `threads.items[].author`,两者不要混淆。 -- `media.type` 可使用内置 `autumn-grid`,也可使用受校验的 `image`、`video`、`webpage` 替换当前串文媒体;字段按数据契约填写。引用内容放入 `quote`,不得写 HTML。 -- 没有可靠来源时,点赞、回复、转发数使用保守示例值或 `0`,不得描述成真实平台统计。 -- 所有外部文本由模板转义;不得把 HTML、脚本、iframe 或不可信指令写入 JSON 字段。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/threads-template.html` 或 `assets/threads-runtime.js`,不复制模板后手改 DOM。 -- 初始化与渲染默认拒绝覆盖已有文件;路径存在时换新文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖 Threads API、CDN 或 npm;仅当 JSON 明确提供受校验的媒体 URL 时加载图片、视频或网页。 -- 保留桌面/移动导航、深浅主题、发帖、搜索、点赞、回复、转发、分享、关注和响应式布局。 -- 浏览器缓存以初始 JSON 指纹隔离;沙箱禁止持久存储时必须继续使用内嵌 JSON 正常渲染。 diff --git a/offline-backend-20260512/backend/skills/public/threads-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/threads-page-generator/assets/default-data.json deleted file mode 100644 index d97b5087..00000000 --- a/offline-backend-20260512/backend/skills/public/threads-page-generator/assets/default-data.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "site": { - "brand": "Threads", - "pageTitle": "Threads · 串联想法", - "description": "一个 Threads 风格的中文文本社交信息流示例页面。", - "theme": "auto", - "currentUser": { - "name": "林小满", - "handle": "@linxiaoman", - "initial": "林", - "colors": ["#7257f5", "#d94fbd"], - "stats": {"following": "286", "followers": "1.8k", "threads": "64"} - } - }, - "navigation": { - "feedTitle": "首页", - "feedFilter": "为你推荐", - "tabs": ["推荐", "正在关注"], - "searchPlaceholder": "搜索用户或关键词", - "searchHints": "城市散步 · 产品设计 · 独立书店 · 秋天", - "mobileLabels": ["首页", "搜索", "创建", "动态", "我"], - "footerLinks": ["关于", "隐私", "条款"], - "copyright": "© 2026 Threads" - }, - "composer": { - "inlinePlaceholder": "发起一个新串聊…", - "dialogTitle": "新串聊", - "textareaPlaceholder": "分享一个想法,开启新的对话…", - "publishLabel": "发布", - "maxLength": 500, - "replyMaxLength": 180 - }, - "threads": { - "emptyTitle": "没有找到相关串聊", - "emptyText": "换一个关键词试试看。", - "items": [ - { - "id": "autumn-light", - "author": {"name": "许知夏", "handle": "@summerxu", "initial": "许", "colors": ["#08a88a", "#49a9df"], "verified": true}, - "time": "36 分钟", - "text": "今天路过公园,发现秋天不是突然抵达的。它藏在每一片开始变慢的叶子里,也藏在傍晚五点半的光线里。", - "media": {"type": "autumn-grid", "label": "秋日公园的三幅抽象插画"}, - "quote": null, - "metrics": {"likes": 328, "replies": 27, "reposts": 44}, - "followable": true, - "replies": [ - {"author": {"name": "周野", "initial": "周", "colors": ["#f97316", "#ec4899"]}, "time": "14 分钟", "text": "“每一片开始变慢的叶子”——这句好喜欢。", "metrics": {"likes": 42, "replies": 3, "reposts": 0}} - ], - "preview": "查看更多 25 条回复" - }, - { - "id": "product-restraint", - "author": {"name": "陈默", "handle": "@modesign", "initial": "陈", "colors": ["#0f7d78", "#51b35e"], "verified": false}, - "time": "2 小时", - "text": "产品越成熟,越要学会对“也许有用”的功能说不。真正的体验不是功能之和,而是每一次取舍之后,用户依然能够毫不犹豫地向前走。", - "media": null, - "quote": {"name": "宋可心", "handle": "@kexin · 昨天", "initial": "宋", "colors": ["#8b5cf6", "#3b82f6"], "text": "克制不是少做,而是把注意力留给真正重要的事情。"}, - "metrics": {"likes": 516, "replies": 63, "reposts": 109}, - "followable": false, - "replies": [], - "preview": "63 条回复 · 516 个赞" - }, - { - "id": "bookstore-map", - "author": {"name": "光合计划", "handle": "@photosynthesis", "initial": "光", "colors": ["#ff8a37", "#d93f7b"], "verified": true}, - "time": "4 小时", - "text": "这周我们拜访了 12 家藏在街巷里的独立书店。没有排行榜,也不推荐“必读书单”,只画了一张适合慢慢走的地图。", - "media": null, - "quote": {"name": "独立书店散步地图", "handle": "上海 · 12 个地点", "initial": "光", "colors": ["#ff8a37", "#d93f7b"], "text": "从旧唱片店旁的小门进去,沿着梧桐树走到河边,在一杯咖啡冷掉之前读完一个短篇。"}, - "metrics": {"likes": 902, "replies": 88, "reposts": 214}, - "followable": true, - "replies": [ - {"author": {"name": "林小满", "initial": "林", "colors": ["#7257f5", "#d94fbd"]}, "time": "3 小时", "text": "收藏了。周末就挑一条最陌生的街开始走。", "metrics": {"likes": 18, "replies": 2, "reposts": 0}} - ], - "preview": "查看更多 86 条回复" - } - ] - }, - "recommendations": { - "followTitle": "推荐关注", - "people": [ - {"name": "周野", "handle": "@zhouye", "initial": "周", "colors": ["#f97316", "#ec4899"]}, - {"name": "顾念", "handle": "@gunian", "initial": "顾", "colors": ["#06b6d4", "#10b981"]}, - {"name": "宋可心", "handle": "@kexin", "initial": "宋", "colors": ["#8b5cf6", "#3b82f6"]} - ], - "topicTitle": "热门话题", - "topics": [ - {"meta": "设计 · 2 小时前", "title": "少即是多,还是刚刚好?", "count": "3,206 条串聊"}, - {"meta": "城市生活 · 热门", "title": "周末散步地图", "count": "1,482 条串聊"}, - {"meta": "阅读 · 正在讨论", "title": "今年最意外的一本书", "count": "968 条串聊"} - ] - }, - "activity": { - "title": "动态", - "items": [ - {"name": "许知夏", "initial": "许", "colors": ["#08a88a", "#49a9df"], "text": "赞了你的串聊。", "time": "12 分钟前", "followable": false}, - {"name": "周野", "initial": "周", "colors": ["#f97316", "#ec4899"], "text": "回复了你:“这句话好喜欢。”", "time": "1 小时前", "followable": false}, - {"name": "宋可心", "initial": "宋", "colors": ["#8b5cf6", "#3b82f6"], "text": "开始关注你。", "time": "昨天", "followable": true} - ] - } -} diff --git a/offline-backend-20260512/backend/skills/public/threads-page-generator/assets/threads-runtime.js b/offline-backend-20260512/backend/skills/public/threads-page-generator/assets/threads-runtime.js deleted file mode 100644 index 9ad466c2..00000000 --- a/offline-backend-20260512/backend/skills/public/threads-page-generator/assets/threads-runtime.js +++ /dev/null @@ -1,832 +0,0 @@ -(() => { - "use strict"; - - const qs = (selector, root = document) => root.querySelector(selector); - const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); - const clone = (value) => JSON.parse(JSON.stringify(value)); - const MODULES = ["site", "navigation", "composer", "threads", "recommendations", "activity"]; - const MODULE_META = { - site: ["站点信息", "品牌、主题与当前账号"], - navigation: ["导航", "信息流、搜索与移动导航"], - composer: ["发布器", "发帖弹窗和字数限制"], - threads: ["串文", "主串文、引用与嵌套回复"], - recommendations: ["推荐", "关注建议与热门话题"], - activity: ["动态", "互动通知面板"] - }; - const embeddedData = JSON.parse(qs("#threadsPageData").textContent); - const initialData = clone(embeddedData); - - const fingerprint = (text) => { - let hash = 2166136261; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0).toString(36); - }; - const pageFingerprint = fingerprint(JSON.stringify(initialData)); - const storageKey = "threads-page-data-v1:" + pageFingerprint; - const themeKey = "threads-page-theme-v1:" + pageFingerprint; - - const readStorage = (key) => { - try { - return window.localStorage ? window.localStorage.getItem(key) : null; - } catch (_error) { - return null; - } - }; - const writeStorage = (key, value) => { - try { - if (window.localStorage) window.localStorage.setItem(key, value); - return true; - } catch (_error) { - return false; - } - }; - const removeStorage = (key) => { - try { - if (window.localStorage) window.localStorage.removeItem(key); - } catch (_error) { - /* Opaque sandbox previews may intentionally deny storage. */ - } - }; - const isObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value)); - const isCompleteData = (value) => Boolean(isObject(value) && MODULES.every((name) => isObject(value[name]))); - const cachedData = readStorage(storageKey); - let pageData = initialData; - if (cachedData) { - try { - const parsed = JSON.parse(cachedData); - if (isCompleteData(parsed)) pageData = parsed; - } catch (_error) { - pageData = initialData; - } - } - - const escapeHTML = (value) => String(value == null ? "" : value).replace(/[&<>'"]/g, (character) => ({ - "&": "&", "<": "<", ">": ">", "'": "'", '"': """ - })[character]); - const safeRemoteUrl = (value, kind = "link") => { - const source = String(value || "").trim(); - if (!source) return ""; - const lowered = source.toLowerCase(); - if (kind === "image" && lowered.startsWith("data:image/")) return source; - if (kind === "video" && lowered.startsWith("data:video/")) return source; - try { - const url = new URL(source); - return url.protocol === "http:" || url.protocol === "https:" ? url.href : ""; - } catch (_error) { return ""; } - }; - const validateRemoteMediaTree = (value, path = "当前模块") => { - if (Array.isArray(value)) return value.forEach((item, index) => validateRemoteMediaTree(item, path + "[" + index + "]")); - if (!value || typeof value !== "object") return; - const media = value.media; - if (media && ["image", "video", "webpage"].includes(media.type)) { - if ((media.type === "image" || media.type === "video") && !safeRemoteUrl(media.mediaUrl, media.type)) throw new Error(path + ".media.mediaUrl 无效"); - if (media.type === "webpage" && !safeRemoteUrl(media.pageUrl)) throw new Error(path + ".media.pageUrl 无效"); - if (media.posterUrl && !safeRemoteUrl(media.posterUrl, "image")) throw new Error(path + ".media.posterUrl 无效"); - } - Object.entries(value).forEach(([key, item]) => { if (key !== "media") validateRemoteMediaTree(item, path + "." + key); }); - }; - const safeColor = (value, fallback) => /^#[0-9a-f]{6}$/i.test(String(value || "")) ? value : fallback; - const icon = (id) => ''; - const colors = (person) => { - const list = person && Array.isArray(person.colors) ? person.colors : []; - return [safeColor(list[0], "#7257f5"), safeColor(list[1], "#d94fbd")]; - }; - const avatar = (person, className) => { - const pair = colors(person || {}); - return '' + escapeHTML((person && person.initial) || "T") + ""; - }; - const metric = (value) => { - const number = Math.max(0, Number(value) || 0); - return number ? '' + number.toLocaleString("zh-CN") + "" : ""; - }; - const toast = qs("#toast"); - let toastTimer = 0; - const showToast = (message) => { - toast.textContent = message; - toast.classList.add("show"); - window.clearTimeout(toastTimer); - toastTimer = window.setTimeout(() => toast.classList.remove("show"), 2800); - }; - const saveData = () => writeStorage(storageKey, JSON.stringify(pageData)); - - let themeInitialized = false; - const syncThemeButtons = () => { - const dark = document.documentElement.dataset.theme === "dark"; - [qs("#themeToggle"), qs("#mobileTheme")].forEach((button) => { - button.innerHTML = icon(dark ? "i-sun" : "i-moon"); - button.setAttribute("aria-label", dark ? "切换浅色模式" : "切换深色模式"); - }); - qs('meta[name="theme-color"]').content = dark ? "#0a0a0a" : "#f5f5f3"; - }; - const applyInitialTheme = () => { - if (themeInitialized) return; - themeInitialized = true; - const configured = pageData.site.theme; - const stored = readStorage(themeKey); - const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme:dark)").matches; - const next = configured === "dark" || configured === "light" - ? configured - : (stored === "dark" || stored === "light" ? stored : (prefersDark ? "dark" : "light")); - document.documentElement.dataset.theme = next; - syncThemeButtons(); - }; - const toggleTheme = () => { - const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; - document.documentElement.dataset.theme = next; - writeStorage(themeKey, next); - syncThemeButtons(); - }; - - const renderSite = () => { - const site = pageData.site || {}; - const user = site.currentUser || {}; - document.title = site.pageTitle || site.brand || "Threads"; - qs('meta[name="description"]').content = site.description || ""; - qs("#brandText").textContent = site.brand || "Threads"; - qs("#brandLink").setAttribute("aria-label", (site.brand || "Threads") + " 首页"); - qs("#mobileUser").innerHTML = avatar(user, "avatar-sm"); - qs("#composerUser").innerHTML = avatar(user); - qs("#modalUser").innerHTML = avatar(user); - qs("#modalUserName").textContent = user.name || ""; - const stats = user.stats || {}; - qs("#profileSummary").innerHTML = - '
    ' + avatar(user) + - '' + escapeHTML(user.name) + "" + escapeHTML(user.handle) + - '
    ' + - '
    ' + escapeHTML(stats.following) + '关注
    ' + - escapeHTML(stats.followers) + '关注者
    ' + escapeHTML(stats.threads) + - "串聊
    "; - applyInitialTheme(); - }; - - const renderNavigation = () => { - const navigation = pageData.navigation || {}; - qs("#feedTitle").textContent = navigation.feedTitle || "首页"; - qs("#feedFilter").textContent = navigation.feedFilter || "为你推荐"; - qs("#searchInput").placeholder = navigation.searchPlaceholder || "搜索用户或关键词"; - qs("#searchHints").textContent = navigation.searchHints || ""; - const tabs = Array.isArray(navigation.tabs) ? navigation.tabs : []; - qs("#feedTabs").innerHTML = tabs.map((label, index) => - '" - ).join(""); - const labels = Array.isArray(navigation.mobileLabels) ? navigation.mobileLabels : []; - const items = [ - ["i-home", ""], - ["i-search", ' data-panel="searchPanel"'], - ["i-plus", " data-open-composer"], - ["i-heart", ' data-panel="activityPanel"'], - ["i-user", ""] - ]; - qs("#mobileNav").innerHTML = items.map((item, index) => { - const label = labels[index] || ""; - if (index === 2) return '' + icon(item[0]) + '' + escapeHTML(label) + ""; - return '"; - }).join(""); - const links = Array.isArray(navigation.footerLinks) ? navigation.footerLinks : []; - qs("#sideFooter").innerHTML = links.map((label) => '' + escapeHTML(label) + "").join("") + - "" + escapeHTML(navigation.copyright) + ""; - }; - - const renderComposer = () => { - const composer = pageData.composer || {}; - qs("#composerButton").textContent = composer.inlinePlaceholder || ""; - qs("#inlinePublish").textContent = composer.publishLabel || "发布"; - qs("#composeTitle").textContent = composer.dialogTitle || "新串聊"; - qs("#composeText").placeholder = composer.textareaPlaceholder || ""; - qs("#composeText").maxLength = Math.max(1, Number(composer.maxLength) || 500); - qs("#modalPublish").textContent = composer.publishLabel || "发布"; - qs("#charCount").textContent = String(qs("#composeText").maxLength - qs("#composeText").value.length); - }; - - const autumnGrid = (label) => ''; - - document.head.insertAdjacentHTML("beforeend", ''); - const mediaMarkup = (media) => { - if (!isObject(media)) return ""; - if (media.type === "autumn-grid") return autumnGrid(media.label); - if (media.type === "image") { - const source = safeRemoteUrl(media.mediaUrl, "image"); - if (!source) return ""; - const pageUrl = safeRemoteUrl(media.pageUrl); - return '
    ' + escapeHTML(media.alt || media.label || ' + (pageUrl ? '打开来源' : "") + "
    "; - } - if (media.type === "video") { - const source = safeRemoteUrl(media.mediaUrl, "video"); - if (!source) return ""; - const poster = safeRemoteUrl(media.posterUrl, "image"); - const pageUrl = safeRemoteUrl(media.pageUrl); - return '
    ' + (pageUrl ? '打开来源' : "") + "
    "; - } - if (media.type === "webpage") { - const pageUrl = safeRemoteUrl(media.pageUrl); - if (!pageUrl) return ""; - const preview = safeRemoteUrl(media.mediaUrl, "image"); - const body = media.embed === true ? '' : (preview ? '' + escapeHTML(media.alt || media.title || ' : ""); - return '"; - } - return ""; - }; - - const actionsMarkup = (metrics) => '
    ' + - '" + - '" + - '" + - '
    "; - - const quoteMarkup = (quote) => { - if (!isObject(quote)) return ""; - return '
    ' + avatar(quote, "avatar-xs") + "" + - escapeHTML(quote.name) + '' + escapeHTML(quote.handle) + - "

    " + escapeHTML(quote.text) + "

    "; - }; - const rowMarkup = (thread, reply, hasNext) => { - const person = reply ? reply.author : thread.author; - const metrics = reply ? reply.metrics : thread.metrics; - const time = reply ? "· " + reply.time : escapeHTML(person.handle) + " · " + escapeHTML(thread.time); - return '
    ' + - avatar(person, reply ? "avatar-sm" : "avatar-lg") + '
    ' + - escapeHTML(person.name) + "" + (!reply && person.verified ? '' : "") + - '' + time + '' + - (!reply && thread.followable ? '' : "") + - (!reply ? '" : "") + - '

    ' + escapeHTML(reply ? reply.text : thread.text) + "

    " + - (!reply ? mediaMarkup(thread.media) : "") + - (!reply ? quoteMarkup(thread.quote) : "") + actionsMarkup(metrics || {}) + - (!reply ? '
    " : "") + - "
    "; - }; - - const threadMarkup = (thread) => { - const replies = Array.isArray(thread.replies) ? thread.replies : []; - const search = [thread.author && thread.author.name, thread.author && thread.author.handle, thread.text, thread.preview] - .concat(replies.map((reply) => reply.text)).join(" "); - return '
    ' + - rowMarkup(thread, null, replies.length > 0) + replies.map((reply, index) => rowMarkup(thread, reply, index < replies.length - 1)).join("") + - '
    ' + - replies.slice(0, 3).map((reply) => avatar(reply.author, "avatar-xs")).join("") + - '' + escapeHTML(thread.preview) + '
    "; - }; - - const renderThreads = () => { - const data = pageData.threads || {}; - const items = Array.isArray(data.items) ? data.items : []; - qs("#threadList").innerHTML = items.map(threadMarkup).join(""); - qs("#emptyTitle").textContent = data.emptyTitle || ""; - qs("#emptyText").textContent = data.emptyText || ""; - qs("#emptyState").style.display = items.length ? "none" : "block"; - }; - - const renderRecommendations = () => { - const data = pageData.recommendations || {}; - qs("#followTitle").textContent = data.followTitle || ""; - qs("#suggestions").innerHTML = (Array.isArray(data.people) ? data.people : []).map((person) => - '
    ' + avatar(person, "avatar-sm") + '' + - escapeHTML(person.name) + "" + escapeHTML(person.handle) + - '
    ' - ).join(""); - qs("#topicTitle").textContent = data.topicTitle || ""; - qs("#topics").innerHTML = (Array.isArray(data.topics) ? data.topics : []).map((topic) => - '
    ' + escapeHTML(topic.meta) + "" + escapeHTML(topic.title) + - "" + escapeHTML(topic.count) + "
    " - ).join(""); - }; - - const renderActivity = () => { - const data = pageData.activity || {}; - qs("#activityTitle").textContent = data.title || ""; - qs("#activityList").innerHTML = (Array.isArray(data.items) ? data.items : []).map((item) => - '
    ' + avatar(item, "avatar-sm") + '

    ' + - escapeHTML(item.name) + " " + escapeHTML(item.text) + "

    " + (item.followable ? '' : "") + "
    " - ).join(""); - }; - - const renderAll = () => { - renderSite(); - renderNavigation(); - renderComposer(); - renderThreads(); - renderRecommendations(); - renderActivity(); - bindDynamicInteractions(); - }; - - const applySearch = () => { - const query = qs("#searchInput").value.trim().toLocaleLowerCase(); - let visible = 0; - qsa(".thread-card").forEach((thread) => { - const match = !query || thread.dataset.search.toLocaleLowerCase().includes(query) || thread.textContent.toLocaleLowerCase().includes(query); - thread.hidden = !match; - if (match) visible += 1; - }); - qs("#emptyState").style.display = visible ? "none" : "block"; - }; - - const changeCount = (button, delta) => { - const count = qs(".action-count", button); - if (count) count.textContent = String(Math.max(0, (Number(count.textContent.replace(/,/g, "")) || 0) + delta)); - }; - const bindDynamicInteractions = () => { - qsa("[data-follow]").forEach((button) => button.addEventListener("click", () => { - const following = button.classList.toggle("following"); - button.textContent = following ? "正在关注" : "关注"; - showToast(following ? "已关注" : "已取消关注"); - })); - qsa(".feed-tab").forEach((tab) => tab.addEventListener("click", () => { - qsa(".feed-tab").forEach((item) => item.classList.remove("active")); - tab.classList.add("active"); - showToast("已切换到" + tab.textContent); - })); - qsa(".mobile-nav button").forEach((button) => button.addEventListener("click", () => { - qsa(".mobile-nav button").forEach((item) => item.classList.remove("active")); - button.classList.add("active"); - })); - qsa(".like-action").forEach((button) => button.addEventListener("click", () => { - const liked = button.classList.toggle("liked"); - changeCount(button, liked ? 1 : -1); - button.setAttribute("aria-pressed", String(liked)); - })); - qsa(".repost-action").forEach((button) => button.addEventListener("click", () => { - const reposted = button.classList.toggle("reposted"); - changeCount(button, reposted ? 1 : -1); - showToast(reposted ? "已转发" : "已撤销转发"); - })); - qsa(".reply-action").forEach((button) => button.addEventListener("click", () => { - const form = qs(".reply-form", button.closest(".post-body")); - if (!form) return; - form.classList.toggle("open"); - if (form.classList.contains("open")) qs("input", form).focus(); - })); - qsa(".reply-form").forEach((form) => form.addEventListener("submit", (event) => { - event.preventDefault(); - const input = qs("input", form); - const value = input.value.trim(); - if (!value) return; - const bubble = document.createElement("div"); - bubble.className = "reply-bubble"; - bubble.innerHTML = "" + escapeHTML(pageData.site.currentUser.name) + " · 刚刚" + escapeHTML(value); - form.before(bubble); - input.value = ""; - const button = qs(".reply-action", form.closest(".post-body")); - if (button) changeCount(button, 1); - showToast("回复已发布"); - })); - qsa(".share").forEach((button) => button.addEventListener("click", async () => { - try { - if (navigator.clipboard) await navigator.clipboard.writeText(location.href); - showToast("链接已复制"); - } catch (_error) { - showToast("链接已准备"); - } - })); - qsa(".thread-card").forEach((thread) => { - const menu = qs(".post-menu", thread); - const more = qs(".more-button", thread); - if (menu && more) more.addEventListener("click", (event) => { - event.stopPropagation(); - qsa(".post-menu.open").forEach((other) => { - if (other !== menu) other.classList.remove("open"); - }); - menu.classList.toggle("open"); - }); - }); - qsa("[data-open-composer]").forEach((button) => { - if (!button.dataset.composerBound) { - button.dataset.composerBound = "true"; - button.addEventListener("click", openCompose); - } - }); - qsa("[data-panel]").forEach((button) => { - if (!button.dataset.panelBound) { - button.dataset.panelBound = "true"; - button.addEventListener("click", openPanel); - } - }); - qs("#searchInput").value = qs("#searchInput").value || ""; - applySearch(); - }; - - const overlay = qs("#composeOverlay"); - const composeText = qs("#composeText"); - const modalPublish = qs("#modalPublish"); - const charCount = qs("#charCount"); - let modalOpener = null; - function openCompose(event) { - modalOpener = event.currentTarget; - overlay.classList.add("open"); - overlay.setAttribute("aria-hidden", "false"); - document.body.classList.add("modal-open"); - window.setTimeout(() => composeText.focus(), 80); - } - const closeCompose = () => { - overlay.classList.remove("open"); - overlay.setAttribute("aria-hidden", "true"); - document.body.classList.remove("modal-open"); - if (modalOpener) modalOpener.focus(); - }; - composeText.addEventListener("input", () => { - const left = composeText.maxLength - composeText.value.length; - charCount.textContent = String(left); - charCount.classList.toggle("warn", left <= 30); - modalPublish.disabled = !composeText.value.trim(); - }); - modalPublish.addEventListener("click", () => { - const value = composeText.value.trim(); - if (!value) return; - const user = pageData.site.currentUser; - pageData.threads.items.unshift({ - id: "new-" + Date.now(), - author: {name: user.name, handle: user.handle, initial: user.initial, colors: user.colors, verified: false}, - time: "刚刚", - text: value, - media: null, - quote: null, - metrics: {likes: 0, replies: 0, reposts: 0}, - followable: false, - replies: [], - preview: "成为第一个回复的人" - }); - saveData(); - renderThreads(); - bindDynamicInteractions(); - composeText.value = ""; - charCount.textContent = String(composeText.maxLength); - modalPublish.disabled = true; - closeCompose(); - qs(".thread-card").scrollIntoView({behavior: "smooth", block: "center"}); - showToast("串聊已发布"); - }); - qs("#closeCompose").addEventListener("click", closeCompose); - overlay.addEventListener("click", (event) => { - if (event.target === overlay) closeCompose(); - }); - - const closePanels = () => qsa(".panel.open").forEach((panel) => { - panel.classList.remove("open"); - panel.setAttribute("aria-hidden", "true"); - }); - function openPanel(event) { - event.stopPropagation(); - const panel = qs("#" + event.currentTarget.dataset.panel); - if (!panel) return; - const willOpen = !panel.classList.contains("open"); - closePanels(); - if (willOpen) { - panel.classList.add("open"); - panel.setAttribute("aria-hidden", "false"); - if (panel.id === "searchPanel") window.setTimeout(() => qs("input", panel).focus(), 80); - } - } - qsa("[data-close-panel]").forEach((button) => button.addEventListener("click", closePanels)); - qs("#searchInput").addEventListener("input", applySearch); - - const settingsOverlay = qs("#settingsOverlay"); - const jsonEditor = qs("#jsonEditor"); - const jsonStatus = qs("#jsonStatus"); - let activeModule = MODULES[0]; - let editorDraft = clone(pageData); - const validateEditor = () => { - try { - const parsed = JSON.parse(jsonEditor.value); - if (!isObject(parsed)) throw new Error("模块必须是 JSON 对象"); - validateRemoteMediaTree(parsed); - jsonStatus.textContent = "JSON 有效"; - jsonStatus.className = "json-status valid"; - return parsed; - } catch (error) { - jsonStatus.textContent = "JSON 无效:" + error.message; - jsonStatus.className = "json-status invalid"; - return null; - } - }; - const storeActiveDraft = () => { - const parsed = validateEditor(); - if (!parsed) return false; - editorDraft[activeModule] = parsed; - return true; - }; - const selectModule = (name, force) => { - if (!force && !storeActiveDraft()) return; - activeModule = name; - qsa(".module-tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.module === name)); - qs("#editorModuleTitle").textContent = MODULE_META[name][0]; - jsonEditor.value = JSON.stringify(editorDraft[name], null, 2); - validateEditor(); - }; - const renderModuleTabs = () => { - qs("#moduleTabs").innerHTML = "

    页面模块

    " + MODULES.map((name) => - '" - ).join(""); - qsa(".module-tab").forEach((tab) => tab.addEventListener("click", () => selectModule(tab.dataset.module, false))); - }; - const openSettings = () => { - editorDraft = clone(pageData); - activeModule = MODULES[0]; - renderModuleTabs(); - selectModule(activeModule, true); - settingsOverlay.classList.add("open"); - settingsOverlay.setAttribute("aria-hidden", "false"); - document.body.classList.add("modal-open", "settings-open"); - window.setTimeout(() => jsonEditor.focus(), 80); - }; - const closeSettings = () => { - settingsOverlay.classList.remove("open"); - settingsOverlay.setAttribute("aria-hidden", "true"); - document.body.classList.remove("modal-open", "settings-open"); - qs("#openSettings").focus(); - }; - qs("#openSettings").addEventListener("click", openSettings); - qs("#closeSettings").addEventListener("click", closeSettings); - settingsOverlay.addEventListener("click", (event) => { - if (event.target === settingsOverlay) closeSettings(); - }); - jsonEditor.addEventListener("input", validateEditor); - jsonEditor.addEventListener("keydown", (event) => { - if (event.key === "Tab") { - event.preventDefault(); - const start = jsonEditor.selectionStart; - const end = jsonEditor.selectionEnd; - jsonEditor.setRangeText(" ", start, end, "end"); - validateEditor(); - } - }); - qs("#applyJson").addEventListener("click", () => { - if (!storeActiveDraft() || !isCompleteData(editorDraft)) { - showToast("请修正 JSON 后再应用"); - return; - } - pageData = clone(editorDraft); - saveData(); - renderAll(); - showToast("JSON 已应用到页面"); - }); - qs("#resetJson").addEventListener("click", () => { - pageData = clone(initialData); - editorDraft = clone(initialData); - removeStorage(storageKey); - removeStorage(themeKey); - themeInitialized = false; - renderAll(); - renderModuleTabs(); - selectModule(activeModule, true); - showToast("已恢复初始 JSON"); - }); - qs("#copyJson").addEventListener("click", async () => { - if (!storeActiveDraft()) return; - const text = JSON.stringify(editorDraft, null, 2); - try { - if (navigator.clipboard) await navigator.clipboard.writeText(text); - showToast("完整 JSON 已复制"); - } catch (_error) { - jsonEditor.focus(); - jsonEditor.select(); - showToast("已选中当前模块,请手动复制"); - } - }); - qs("#downloadJson").addEventListener("click", () => { - if (!storeActiveDraft()) return; - const blob = new Blob([JSON.stringify(editorDraft, null, 2) + "\n"], {type: "application/json"}); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = "threads-page-data.json"; - link.click(); - window.setTimeout(() => URL.revokeObjectURL(link.href), 0); - showToast("JSON 已下载"); - }); - - [qs("#themeToggle"), qs("#mobileTheme")].forEach((button) => button.addEventListener("click", toggleTheme)); - document.addEventListener("click", (event) => { - if (!event.target.closest(".panel") && !event.target.closest("[data-panel]")) closePanels(); - if (!event.target.closest(".post-menu") && !event.target.closest(".more-button")) { - qsa(".post-menu.open").forEach((menu) => menu.classList.remove("open")); - } - }); - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - closePanels(); - if (overlay.classList.contains("open")) closeCompose(); - if (settingsOverlay.classList.contains("open")) closeSettings(); - } - }); - qsa('a[href^="#"]').forEach((link) => link.addEventListener("click", (event) => { - const target = link.getAttribute("href"); - if (target !== "#feed") { - event.preventDefault(); - showToast("此页面正在准备中"); - } - })); - - renderAll(); - function installJsonMediaUpload(){ - const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return; - const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson"); - button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input); - const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message}; - const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;indexindex+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null}; - button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()}); - input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)}); - } - installJsonMediaUpload(); - function installInlineMediaReplace() { - if (document.getElementById("dfInlineMediaInput")) return; - const visualHostSelector = ".post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.image-grid,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt"; - const getRootData = () => { - if (typeof pageData !== "undefined") return pageData; - if (typeof data !== "undefined") return data; - return null; - }; - const input = document.createElement("input"); - input.id = "dfInlineMediaInput"; - input.type = "file"; - input.hidden = true; - document.body.appendChild(input); - const style = document.createElement("style"); - style.dataset.dfInlineMediaReplace = "true"; - style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}"; - document.head.appendChild(style); - const notify = (message) => { - if (typeof showToast === "function") { showToast(message); return; } - if (typeof toast === "function") { toast(message); return; } - let notice = document.getElementById("dfInlineMediaToast"); - if (!notice) { - notice = document.createElement("div"); - notice.id = "dfInlineMediaToast"; - notice.className = "df-inline-media-toast"; - document.body.appendChild(notice); - } - notice.textContent = message; - window.clearTimeout(notice._timer); - notice._timer = window.setTimeout(() => notice.remove(), 2600); - }; - const sameSource = (value, source, absolute) => { - if (typeof value !== "string") return false; - if (value === source || value === absolute) return true; - try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; } - }; - const findMatches = (source, absolute) => { - const matches = []; - const stack = []; - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - stack.push(value); - Object.entries(value).forEach(([key, item]) => { - if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) { - const ancestors = stack.slice(0, -1).reverse(); - const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value; - matches.push({ owner, previous: value, key }); - } - if (item && typeof item === "object") visit(item); - }); - stack.pop(); - }; - visit(getRootData()); - return matches; - }; - const visualOwners = () => { - const owners = []; - const seen = new Set(); - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object"); - const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim()); - if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); } - Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); }); - }; - visit(getRootData()); - return owners; - }; - const domIdentifiers = (element) => { - const values = new Set(); - let cursor = element; - for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) { - Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); }); - } - return values; - }; - const chooseVisualTarget = (element) => { - const identifiers = domIdentifiers(element); - const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element; - const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim(); - const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]"); - const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : ""; - let best = null; - let bestScore = 0; - visualOwners().forEach((owner) => { - let score = 0; - Object.entries(owner).forEach(([key, value]) => { - if (/id$/i.test(key) && identifiers.has(String(value))) score += 100; - }); - ["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => { - const value = typeof owner[key] === "string" ? owner[key].trim() : ""; - if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length); - }); - if (visualValue && owner.visual === visualValue) score += 20; - if (score > bestScore) { best = owner; bestScore = score; } - }); - if (!best || bestScore <= 0) return null; - return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseTarget = (element) => { - const source = element.getAttribute("src") || ""; - const absolute = element.currentSrc || element.src || source; - const matches = findMatches(source, absolute); - if (!matches.length) return null; - const identifiers = domIdentifiers(element); - return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0]; - }; - const persist = () => { - const root = getRootData(); - if (typeof saveData === "function") return saveData(); - if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root)); - if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root)); - return false; - }; - let active = null; - const attachButton = (host, target) => { - if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return; - host.classList.add("df-inline-media-host"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "df-inline-media-button"; - button.setAttribute("aria-label", "替换图片或视频"); - button.title = "上传图片或视频并替换"; - button.innerHTML = ''; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - active = { ...target }; - input.accept = "image/*,video/*"; - input.value = ""; - input.click(); - }); - host.appendChild(button); - }; - const decorateBuiltInVisuals = () => { - document.querySelectorAll(visualHostSelector).forEach((host) => { - if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return; - if (!host.querySelector("svg") && !host.dataset.visual) return; - const target = chooseVisualTarget(host); - if (!target) return; - attachButton(host, target); - }); - }; - const decorate = () => { - decorateBuiltInVisuals(); - document.querySelectorAll("img[src],video[src]").forEach((media) => { - if (media.dataset.dfInlineMediaReady === "true") return; - const target = chooseTarget(media); - if (!target) return; - const host = media.closest(visualHostSelector) || media.parentElement; - if (!host) return; - media.dataset.dfInlineMediaReady = "true"; - attachButton(host, target); - }); - }; - input.addEventListener("change", () => { - const file = input.files && input.files[0]; - if (!file || !active) return; - const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : ""; - if (!uploadedType) { - notify("请选择图片或视频文件。"); - return; - } - const reader = new FileReader(); - reader.onerror = () => notify("文件读取失败,请重新选择。"); - reader.onload = () => { - const next = { ...(active.previous || {}) }; - const result = String(reader.result); - next.type = uploadedType; - next.mediaUrl = result; - delete next.posterUrl; - if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片"; - active.owner.media = next; - const stored = persist(); - renderAll(); - notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。"); - active = null; - }; - reader.readAsDataURL(file); - }); - let queued = false; - const queueDecorate = () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; decorate(); }); - }; - new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true }); - decorate(); - } - installInlineMediaReplace(); -})(); diff --git a/offline-backend-20260512/backend/skills/public/threads-page-generator/assets/threads-template.html b/offline-backend-20260512/backend/skills/public/threads-page-generator/assets/threads-template.html deleted file mode 100644 index c0fc621a..00000000 --- a/offline-backend-20260512/backend/skills/public/threads-page-generator/assets/threads-template.html +++ /dev/null @@ -1,379 +0,0 @@ - - - - - - - - Weave · 串联想法 - - - - - - -
    Threads
    -
    - - -
    -
    -

    首页

    -
    -
    -
    -

    -
    - -
    - - - - - - - -
    - - - - - - - - diff --git a/offline-backend-20260512/backend/skills/public/threads-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/threads-page-generator/references/data-schema.md deleted file mode 100644 index 6ab3a539..00000000 --- a/offline-backend-20260512/backend/skills/public/threads-page-generator/references/data-schema.md +++ /dev/null @@ -1,52 +0,0 @@ -# Threads 页面 JSON 契约 - -顶层必须是对象,并且完整包含 `site`、`navigation`、`composer`、`threads`、`recommendations`、`activity`。弹窗按这六个模块分别编辑,但「应用并渲染」会重新校验完整文档。 - -## 通用规则 - -- 所有 `colors` 都是两个 CSS 十六进制颜色,例如 `["#7257f5", "#d94fbd"]`。 -- 互动量是非负数字;显示文案、名称和时间是非空字符串。 -- 所有列表都有数量上限,避免单文件页面失控;数据文本不能包含 HTML。 - -## `site` - -- `brand`、`pageTitle`、`description`:站点展示信息。 -- `theme`:`auto`、`light` 或 `dark`。 -- `currentUser`:`name`、`handle`、`initial`、`colors` 和 `stats`;`stats` 包含 `following`、`followers`、`threads`。 - -## `navigation` - -- `feedTitle`、`feedFilter`、`searchPlaceholder`、`searchHints`、`copyright`:非空字符串。 -- `tabs`:1–4 个标签。 -- `mobileLabels`:恰好 5 个标签,顺序为首页、搜索、创建、动态、我。 -- `footerLinks`:0–8 个页脚链接名称。 - -## `composer` - -- `inlinePlaceholder`、`dialogTitle`、`textareaPlaceholder`、`publishLabel`:非空字符串。 -- `maxLength`:1–2000;`replyMaxLength`:1–500。 - -## `threads` - -- `emptyTitle`、`emptyText`:搜索无结果时文案。 -- `items`:1–30 条,`id` 唯一。 -- 每项包含 `author`、`time`、`text`、`metrics`、`followable`、`replies`、`preview`;`media`、`quote` 可为 `null`。 -- `author` 包含 `name`、`handle`、`initial`、`colors`、`verified`。 -- `media.type` 可为内置 `autumn-grid`,也可为 `image`、`video`、`webpage`。 -- 图片使用 `{type:"image", mediaUrl, alt, pageUrl?}`;视频使用 `{type:"video", mediaUrl, posterUrl?, alt?, pageUrl?}`;网页使用 `{type:"webpage", pageUrl, title, description?, mediaUrl?, embed?}`。URL 只接受 `http(s)`;图片和视频也允许对应的 `data:` URL。 -- `quote` 包含 `name`、`handle`、`initial`、`colors`、`text`。 -- `metrics` 包含非负 `likes`、`replies`、`reposts`。 -- `replies` 最多 8 条,每项包含作者、时间、正文和同样的 `metrics`。 - -## `recommendations` - -- `followTitle`、`topicTitle`:侧栏标题。 -- `people`:0–20 项,每项 `{name, handle, initial, colors}`。 -- `topics`:0–20 项,每项 `{meta, title, count}`。 - -## `activity` - -- `title`:动态面板标题。 -- `items`:0–30 项,每项包含 `name`、`initial`、`colors`、`text`、`time`、`followable`。 - -JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。`assets/default-data.json` 只是结构和演示内容,不是事实来源。 diff --git a/offline-backend-20260512/backend/skills/public/threads-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/threads-page-generator/scripts/fast_page.py deleted file mode 100644 index 583f20d8..00000000 --- a/offline-backend-20260512/backend/skills/public/threads-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,356 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a complete Threads page from one compact high-level JSON spec.""" - -from __future__ import annotations - -import argparse -import copy -import json -import sys -from collections.abc import Mapping -from pathlib import Path - -import render_threads_page as renderer - -REPLY_NAMES = ("林夏", "周野", "陈默", "顾念", "沈言", "宋可心", "许知夏", "苏小鹿") -REPLY_COLORS = ( - ("#5662f6", "#9f45d8"), - ("#15a88b", "#2e88cc"), - ("#f97316", "#db2777"), - ("#0f7d78", "#51b35e"), -) -REPLY_TEMPLATES = ( - "关于“{topic}”,这个表达很有意思。", - "我也在关注“{topic}”,期待看到后续更新。", - "从另一个角度看,“{topic}”还有很多值得讨论的细节。", - "这条串文把“{topic}”说得很清楚。", - "先收藏,也想听听大家对“{topic}”的看法。", - "内容不长,但“{topic}”这个重点已经交代得很完整。", - "这正是我最近在思考的“{topic}”问题。", - "希望评论区可以继续理性讨论“{topic}”。", -) - - -def _read_spec(path: Path) -> dict[str, object]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - raise renderer.DataValidationError(f"Invalid spec JSON: {exc}") from exc - if not isinstance(value, dict): - raise renderer.DataValidationError("Spec JSON must be an object") - return value - - -def _string(value: object, path: str, *, required: bool = False) -> str: - if value is None and not required: - return "" - if not isinstance(value, str) or (required and not value.strip()): - suffix = "a non-empty string" if required else "a string" - raise renderer.DataValidationError(f"{path} must be {suffix}") - return value.strip() - - -def _integer(value: object, path: str, *, default: int = 0) -> int: - if value is None: - return default - if not isinstance(value, int) or isinstance(value, bool) or value < 0: - raise renderer.DataValidationError(f"{path} must be a non-negative integer") - return value - - -def _boolean(value: object, path: str, *, default: bool = False) -> bool: - if value is None: - return default - if not isinstance(value, bool): - raise renderer.DataValidationError(f"{path} must be a boolean") - return value - - -def _strings(value: object, path: str) -> list[str]: - if value is None: - return [] - if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value): - raise renderer.DataValidationError(f"{path} must be an array of non-empty strings") - return [item.strip() for item in value] - - -def _require_chinese(value: str, path: str) -> None: - if not any("\u3400" <= char <= "\u9fff" for char in value): - raise renderer.DataValidationError(f"{path} must contain Simplified Chinese reader-visible text") - - -def _reply_objects( - count: int, - topics: list[str], - custom_replies: list[object], -) -> list[dict[str, object]]: - if not 0 <= count <= 8: - raise renderer.DataValidationError("displayReplyCount must be between 0 and 8") - if len(custom_replies) > count: - raise renderer.DataValidationError("displayReplyCount cannot be smaller than replies.length") - replies: list[dict[str, object]] = [] - for index in range(count): - name = REPLY_NAMES[index % len(REPLY_NAMES)] - reply: dict[str, object] = { - "author": { - "name": name, - "initial": name[:1], - "colors": list(REPLY_COLORS[index % len(REPLY_COLORS)]), - }, - "time": f"{index + 1} 分钟", - "text": REPLY_TEMPLATES[index % len(REPLY_TEMPLATES)].format(topic=topics[index % len(topics)]), - "metrics": { - "likes": max(0, (count - index) * 9), - "replies": index % 4, - "reposts": index % 2, - }, - } - if index < len(custom_replies): - custom = custom_replies[index] - if isinstance(custom, str): - reply["text"] = custom.strip() - elif isinstance(custom, Mapping): - reply["text"] = _string( - custom.get("text"), - f"replies[{index}].text", - required=True, - ) - if "author" in custom: - author_name = _string( - custom.get("author"), - f"replies[{index}].author", - required=True, - ) - reply["author"]["name"] = author_name - reply["author"]["initial"] = author_name[:1] - if "time" in custom: - reply["time"] = _string( - custom.get("time"), - f"replies[{index}].time", - required=True, - ) - metrics = reply["metrics"] - for key in ("likes", "replies", "reposts"): - if key in custom: - metrics[key] = _integer( - custom.get(key), - f"replies[{index}].{key}", - ) - else: - raise renderer.DataValidationError(f"replies[{index}] must be a string or object") - _require_chinese(str(reply["text"]), f"replies[{index}].text") - replies.append(reply) - return replies - - -def _media(value: object, path: str) -> dict[str, object] | None: - if value is None: - return None - if not isinstance(value, Mapping): - raise renderer.DataValidationError(f"{path} must be an object or null") - media_type = _string(value.get("type"), f"{path}.type") or "image" - if media_type not in {"image", "video", "webpage"}: - raise renderer.DataValidationError(f"{path}.type must be image, video, or webpage") - media: dict[str, object] = {"type": media_type} - if media_type == "webpage": - media["pageUrl"] = _string( - value.get("pageUrl"), - f"{path}.pageUrl", - required=True, - ) - media["title"] = _string( - value.get("title"), - f"{path}.title", - required=True, - ) - media["description"] = _string( - value.get("description"), - f"{path}.description", - required=True, - ) - else: - media["mediaUrl"] = _string( - value.get("url"), - f"{path}.url", - required=True, - ) - media["alt"] = _string(value.get("alt"), f"{path}.alt") or "用户提供的媒体" - if value.get("pageUrl"): - media["pageUrl"] = _string(value.get("pageUrl"), f"{path}.pageUrl") - return media - - -def build_document(spec: dict[str, object]) -> dict[str, object]: - allowed = { - "accountName", - "handle", - "verified", - "description", - "pageTitle", - "topic", - "replyTopics", - "displayReplyCount", - "totalReplyCount", - "replies", - "postText", - "posts", - "media", - "likes", - "reposts", - "following", - "followers", - "threadCount", - } - unknown = sorted(set(spec) - allowed) - if unknown: - raise renderer.DataValidationError("Unknown spec keys: " + ", ".join(unknown)) - account_name = _string(spec.get("accountName"), "accountName", required=True) - handle = _string(spec.get("handle"), "handle") or "@threads_user" - topic = _string(spec.get("topic"), "topic") or account_name - reply_topics = _strings(spec.get("replyTopics"), "replyTopics") or [topic] - raw_posts = spec.get("posts") - if raw_posts is None: - raw_posts = [ - { - "text": _string(spec.get("postText"), "postText", required=True), - "media": spec.get("media") if "media" in spec else "__example__", - "likes": spec.get("likes", 0), - "reposts": spec.get("reposts", 0), - } - ] - if not isinstance(raw_posts, list) or not 1 <= len(raw_posts) <= 10: - raise renderer.DataValidationError("posts must contain between 1 and 10 objects") - if not all(isinstance(item, Mapping) for item in raw_posts): - raise renderer.DataValidationError("posts must contain only objects") - - data = renderer._read_json(renderer.DEFAULT_DATA_PATH) - defaults = data["threads"]["items"] - custom_replies = spec.get("replies", []) - if not isinstance(custom_replies, list): - raise renderer.DataValidationError("replies must be an array") - display_reply_count = _integer( - spec.get("displayReplyCount"), - "displayReplyCount", - default=max(3, len(custom_replies)), - ) - total_reply_count = _integer( - spec.get("totalReplyCount"), - "totalReplyCount", - default=display_reply_count, - ) - if total_reply_count < display_reply_count: - raise renderer.DataValidationError("totalReplyCount cannot be smaller than displayReplyCount") - - items: list[dict[str, object]] = [] - for index, raw_post in enumerate(raw_posts): - post = copy.deepcopy(defaults[index % len(defaults)]) - text = _string( - raw_post.get("text"), - f"posts[{index}].text", - required=True, - ) - _require_chinese(text, f"posts[{index}].text") - author_name = _string(raw_post.get("authorName"), f"posts[{index}].authorName") or account_name - post["id"] = f"thread-{index + 1:03d}" - post["author"] = { - "name": author_name, - "handle": _string(raw_post.get("handle"), f"posts[{index}].handle") or handle, - "initial": author_name[:1], - "colors": post["author"]["colors"], - "verified": _boolean( - raw_post.get("verified"), - f"posts[{index}].verified", - default=_boolean(spec.get("verified"), "verified"), - ), - } - post["time"] = _string(raw_post.get("time"), f"posts[{index}].time") or "刚刚" - post["text"] = text - post["followable"] = _boolean( - raw_post.get("followable"), - f"posts[{index}].followable", - ) - post["quote"] = None - if "media" in raw_post and raw_post.get("media") != "__example__": - post["media"] = _media(raw_post.get("media"), f"posts[{index}].media") - if index == 0: - replies = _reply_objects(display_reply_count, reply_topics, custom_replies) - replies_metric = total_reply_count - else: - replies = [] - replies_metric = _integer( - raw_post.get("totalReplyCount"), - f"posts[{index}].totalReplyCount", - ) - post["replies"] = replies - post["metrics"] = { - "likes": _integer(raw_post.get("likes"), f"posts[{index}].likes"), - "replies": replies_metric, - "reposts": _integer(raw_post.get("reposts"), f"posts[{index}].reposts"), - } - post["preview"] = f"查看全部 {replies_metric} 条回复" - items.append(post) - - data["site"]["pageTitle"] = _string(spec.get("pageTitle"), "pageTitle") or f"Threads · {account_name}" - data["site"]["description"] = _string(spec.get("description"), "description") or f"{account_name} 的 Threads 中文页面。" - data["site"]["currentUser"].update( - { - "name": account_name, - "handle": handle, - "initial": account_name[:1], - "stats": { - "following": str(spec.get("following", "0")), - "followers": str(spec.get("followers", "0")), - "threads": str(spec.get("threadCount", len(items))), - }, - } - ) - data["threads"]["items"] = items - return data - - -def _next_paths(output_dir: Path) -> tuple[Path, Path]: - for index in range(1, 1001): - stem = "threads-page" if index == 1 else f"threads-page-{index}" - data_path = output_dir / f"{stem}-data.json" - html_path = output_dir / f"{stem}.html" - if not data_path.exists() and not html_path.exists(): - return data_path, html_path - raise renderer.DataValidationError("Unable to allocate output filenames") - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--spec", type=Path, required=True) - parser.add_argument("--output-dir", type=Path, required=True) - args = parser.parse_args(argv) - try: - data = build_document(_read_spec(args.spec)) - summary = renderer.validate_document(data) - rendered = renderer.render_document( - data, - renderer.TEMPLATE_PATH.read_text(encoding="utf-8"), - renderer.RUNTIME_PATH.read_text(encoding="utf-8"), - ) - data_path, html_path = _next_paths(args.output_dir) - renderer._write_new( - data_path, - json.dumps(data, ensure_ascii=False, indent=2) + "\n", - ) - renderer._write_new(html_path, rendered) - except (renderer.DataValidationError, OSError, UnicodeError) as exc: - renderer._emit({"status": "error", "action": "fast-threads-page", "error": str(exc)}) - return 2 - renderer._emit( - { - "status": "ok", - "action": "fast-threads-page", - "data": str(data_path), - "html": str(html_path), - **summary, - } - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/offline-backend-20260512/backend/skills/public/threads-page-generator/scripts/render_threads_page.py b/offline-backend-20260512/backend/skills/public/threads-page-generator/scripts/render_threads_page.py deleted file mode 100644 index 85740ee2..00000000 --- a/offline-backend-20260512/backend/skills/public/threads-page-generator/scripts/render_threads_page.py +++ /dev/null @@ -1,540 +0,0 @@ -#!/usr/bin/env python3 -"""Validate Threads page data and inject it into the bundled HTML template.""" - -from __future__ import annotations - -import argparse -import html as html_lib -import json -import re -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_ROOT = Path(__file__).resolve().parent.parent -ASSETS_DIR = SKILL_ROOT / "assets" -DEFAULT_DATA_PATH = ASSETS_DIR / "default-data.json" -TEMPLATE_PATH = ASSETS_DIR / "threads-template.html" -RUNTIME_PATH = ASSETS_DIR / "threads-runtime.js" -DATA_MARKER = "__THREADS_PAGE_DATA__" -RUNTIME_MARKER = "__THREADS_PAGE_RUNTIME__" -TITLE_RE = re.compile(r"[^<]*", re.IGNORECASE) -MODULES = ( - "site", - "navigation", - "composer", - "threads", - "recommendations", - "activity", -) -THEMES = {"auto", "light", "dark"} -MEDIA_TYPES = {"autumn-grid", "image", "video", "webpage"} -REMOTE_MEDIA_TYPES = {"image", "video", "webpage"} - - -class DataValidationError(ValueError): - """Raised when the Threads page JSON cannot satisfy the template contract.""" - - -def _read_json(path: Path) -> dict[str, Any]: - try: - data = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise DataValidationError(f"JSON file does not exist: {path}") from exc - except json.JSONDecodeError as exc: - raise DataValidationError( - f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" - ) from exc - if not isinstance(data, dict): - raise DataValidationError("Top-level JSON value must be an object") - return data - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array( - value: Any, - path: str, - errors: list[str], - *, - limit: int | None = None, -) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if limit is not None and len(value) > limit: - errors.append(f"{path} may contain at most {limit} items") - return value - - -def _non_empty_string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty string") - - -def _remote_url(value: Any, path: str, errors: list[str], *, required: bool = False, data_prefix: str = "") -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media(media: dict[str, Any], path: str, errors: list[str]) -> None: - media_type = media.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - return - if media_type in {"image", "video"}: - _remote_url(media.get("mediaUrl"), f"{path}.mediaUrl", errors, required=True, data_prefix=f"data:{media_type}/") - if media_type == "video": - _remote_url(media.get("posterUrl"), f"{path}.posterUrl", errors, data_prefix="data:image/") - if media_type == "webpage": - _remote_url(media.get("pageUrl"), f"{path}.pageUrl", errors, required=True) - _remote_url(media.get("mediaUrl"), f"{path}.mediaUrl", errors, data_prefix="data:image/") - if "embed" in media and not isinstance(media.get("embed"), bool): - errors.append(f"{path}.embed must be a boolean") - else: - _remote_url(media.get("pageUrl"), f"{path}.pageUrl", errors) - - -def _non_negative_number(value: Any, path: str, errors: list[str]) -> None: - if ( - not isinstance(value, (int, float)) - or isinstance(value, bool) - or value < 0 - ): - errors.append(f"{path} must be a non-negative number") - - -def _integer_range( - value: Any, - path: str, - minimum: int, - maximum: int, - errors: list[str], -) -> None: - if ( - not isinstance(value, int) - or isinstance(value, bool) - or not minimum <= value <= maximum - ): - errors.append(f"{path} must be an integer from {minimum} to {maximum}") - - -def _boolean(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, bool): - errors.append(f"{path} must be a boolean") - - -def _colors(value: Any, path: str, errors: list[str]) -> None: - if ( - not isinstance(value, list) - or len(value) != 2 - or not all(isinstance(color, str) for color in value) - ): - errors.append(f"{path} must contain exactly two color strings") - - -def _person( - value: Any, - path: str, - errors: list[str], - *, - handle: bool, -) -> dict[str, Any]: - person = _mapping(value, path, errors) - keys = ["name", "initial"] - if handle: - keys.append("handle") - for key in keys: - _non_empty_string(person.get(key), f"{path}.{key}", errors) - _colors(person.get("colors"), f"{path}.colors", errors) - return person - - -def _metrics(value: Any, path: str, errors: list[str]) -> None: - metrics = _mapping(value, path, errors) - for key in ("likes", "replies", "reposts"): - _non_negative_number(metrics.get(key), f"{path}.{key}", errors) - - -def validate_document(data: dict[str, Any]) -> dict[str, Any]: - errors: list[str] = [] - for module in MODULES: - if module not in data: - errors.append(f"Missing top-level module: {module}") - - site = _mapping(data.get("site"), "site", errors) - for key in ("brand", "pageTitle", "description"): - _non_empty_string(site.get(key), f"site.{key}", errors) - if site.get("theme") not in THEMES: - errors.append("site.theme must be one of: auto, dark, light") - current_user = _person( - site.get("currentUser"), - "site.currentUser", - errors, - handle=True, - ) - stats = _mapping( - current_user.get("stats"), - "site.currentUser.stats", - errors, - ) - for key in ("following", "followers", "threads"): - _non_empty_string( - stats.get(key), - f"site.currentUser.stats.{key}", - errors, - ) - - navigation = _mapping(data.get("navigation"), "navigation", errors) - for key in ( - "feedTitle", - "feedFilter", - "searchPlaceholder", - "searchHints", - "copyright", - ): - _non_empty_string(navigation.get(key), f"navigation.{key}", errors) - tabs = _array(navigation.get("tabs"), "navigation.tabs", errors, limit=4) - if not tabs: - errors.append("navigation.tabs must contain at least one item") - for index, label in enumerate(tabs): - _non_empty_string(label, f"navigation.tabs[{index}]", errors) - mobile_labels = _array( - navigation.get("mobileLabels"), - "navigation.mobileLabels", - errors, - limit=5, - ) - if len(mobile_labels) != 5: - errors.append("navigation.mobileLabels must contain exactly 5 items") - for index, label in enumerate(mobile_labels): - _non_empty_string( - label, - f"navigation.mobileLabels[{index}]", - errors, - ) - footer_links = _array( - navigation.get("footerLinks"), - "navigation.footerLinks", - errors, - limit=8, - ) - for index, label in enumerate(footer_links): - _non_empty_string( - label, - f"navigation.footerLinks[{index}]", - errors, - ) - - composer = _mapping(data.get("composer"), "composer", errors) - for key in ( - "inlinePlaceholder", - "dialogTitle", - "textareaPlaceholder", - "publishLabel", - ): - _non_empty_string(composer.get(key), f"composer.{key}", errors) - _integer_range( - composer.get("maxLength"), - "composer.maxLength", - 1, - 2000, - errors, - ) - _integer_range( - composer.get("replyMaxLength"), - "composer.replyMaxLength", - 1, - 500, - errors, - ) - - threads = _mapping(data.get("threads"), "threads", errors) - for key in ("emptyTitle", "emptyText"): - _non_empty_string(threads.get(key), f"threads.{key}", errors) - thread_items = _array( - threads.get("items"), - "threads.items", - errors, - limit=30, - ) - if not thread_items: - errors.append("threads.items must contain at least one thread") - thread_ids: set[str] = set() - reply_count = 0 - for index, value in enumerate(thread_items): - path = f"threads.items[{index}]" - thread = _mapping(value, path, errors) - thread_id = thread.get("id") - _non_empty_string(thread_id, f"{path}.id", errors) - if isinstance(thread_id, str): - if thread_id in thread_ids: - errors.append(f"{path}.id must be unique") - thread_ids.add(thread_id) - author = _person( - thread.get("author"), - f"{path}.author", - errors, - handle=True, - ) - _boolean( - author.get("verified"), - f"{path}.author.verified", - errors, - ) - for key in ("time", "text", "preview"): - _non_empty_string(thread.get(key), f"{path}.{key}", errors) - _boolean(thread.get("followable"), f"{path}.followable", errors) - _metrics(thread.get("metrics"), f"{path}.metrics", errors) - - media = thread.get("media") - if media is not None: - media_data = _mapping(media, f"{path}.media", errors) - if media_data.get("type") not in MEDIA_TYPES: - errors.append( - f"{path}.media.type must be one of: " - f"{', '.join(sorted(MEDIA_TYPES))}" - ) - _validate_remote_media(media_data, f"threads.items[{index}].media", errors) - if "label" in media_data: - _non_empty_string( - media_data.get("label"), - f"{path}.media.label", - errors, - ) - - quote = thread.get("quote") - if quote is not None: - quote_data = _person( - quote, - f"{path}.quote", - errors, - handle=True, - ) - _non_empty_string( - quote_data.get("text"), - f"{path}.quote.text", - errors, - ) - - replies = _array( - thread.get("replies"), - f"{path}.replies", - errors, - limit=8, - ) - reply_count += len(replies) - for reply_index, reply_value in enumerate(replies): - reply_path = f"{path}.replies[{reply_index}]" - reply = _mapping(reply_value, reply_path, errors) - _person( - reply.get("author"), - f"{reply_path}.author", - errors, - handle=False, - ) - for key in ("time", "text"): - _non_empty_string( - reply.get(key), - f"{reply_path}.{key}", - errors, - ) - _metrics(reply.get("metrics"), f"{reply_path}.metrics", errors) - - recommendations = _mapping( - data.get("recommendations"), - "recommendations", - errors, - ) - for key in ("followTitle", "topicTitle"): - _non_empty_string( - recommendations.get(key), - f"recommendations.{key}", - errors, - ) - people = _array( - recommendations.get("people"), - "recommendations.people", - errors, - limit=20, - ) - for index, value in enumerate(people): - _person( - value, - f"recommendations.people[{index}]", - errors, - handle=True, - ) - topics = _array( - recommendations.get("topics"), - "recommendations.topics", - errors, - limit=20, - ) - for index, value in enumerate(topics): - topic = _mapping( - value, - f"recommendations.topics[{index}]", - errors, - ) - for key in ("meta", "title", "count"): - _non_empty_string( - topic.get(key), - f"recommendations.topics[{index}].{key}", - errors, - ) - - activity = _mapping(data.get("activity"), "activity", errors) - _non_empty_string(activity.get("title"), "activity.title", errors) - activity_items = _array( - activity.get("items"), - "activity.items", - errors, - limit=30, - ) - for index, value in enumerate(activity_items): - path = f"activity.items[{index}]" - item = _person(value, path, errors, handle=False) - for key in ("text", "time"): - _non_empty_string(item.get(key), f"{path}.{key}", errors) - _boolean(item.get("followable"), f"{path}.followable", errors) - - if errors: - raise DataValidationError("; ".join(errors)) - - return { - "modules": list(MODULES), - "counts": { - "threads": len(thread_items), - "replies": reply_count, - "people": len(people), - "topics": len(topics), - "activity": len(activity_items), - }, - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - if template.count(DATA_MARKER) != 1: - raise DataValidationError( - "Bundled template must contain exactly one data marker" - ) - if template.count(RUNTIME_MARKER) != 1: - raise DataValidationError( - "Bundled template must contain exactly one runtime marker" - ) - serialized = json.dumps(data, ensure_ascii=False, indent=2).replace( - "{html_lib.escape(page_title)}", - rendered, - count=1, - ) - return rendered - - -def _write_new(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - try: - with path.open("x", encoding="utf-8", newline="\n") as handle: - handle.write(content) - except FileExistsError as exc: - raise DataValidationError( - f"Refusing to overwrite existing file: {path}" - ) from exc - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False)) - - -def _command_init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA_PATH) - summary = validate_document(data) - _write_new( - output, - json.dumps(data, ensure_ascii=False, indent=2) + "\n", - ) - return {"status": "ok", "action": "init", "output": str(output), **summary} - - -def _command_validate(input_path: Path) -> dict[str, Any]: - data = _read_json(input_path) - summary = validate_document(data) - return {"status": "ok", "action": "validate", "input": str(input_path), **summary} - - -def _command_render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - template = TEMPLATE_PATH.read_text(encoding="utf-8") - runtime = RUNTIME_PATH.read_text(encoding="utf-8") - rendered = render_document(data, template, runtime) - _write_new(output, rendered) - summary = validate_document(data) - return { - "status": "ok", - "action": "render", - "input": str(input_path), - "output": str(output), - **summary, - } - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - init_parser = subparsers.add_parser("init", help="Create a new editable JSON document") - init_parser.add_argument("--output", type=Path, required=True) - - validate_parser = subparsers.add_parser("validate", help="Validate an existing JSON document") - validate_parser.add_argument("--input", type=Path, required=True) - - render_parser = subparsers.add_parser("render", help="Render validated JSON into the bundled HTML template") - render_parser.add_argument("--input", type=Path, required=True) - render_parser.add_argument("--output", type=Path, required=True) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - args = parser.parse_args(argv) - try: - if args.command == "init": - result = _command_init(args.output) - elif args.command == "validate": - result = _command_validate(args.input) - else: - result = _command_render(args.input, args.output) - except (DataValidationError, OSError) as exc: - _emit({"status": "error", "action": args.command, "error": str(exc)}) - return 2 - _emit(result) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) - diff --git a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/tiktok-page-generator/SKILL.md deleted file mode 100644 index 0cbb6ef7..00000000 --- a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/SKILL.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -name: tiktok-page-generator -description: 生成或绘制 TikTok、抖音国际版及 TikTok 风格的可交付短视频信息流 HTML 网页;仅在用户明确要求 TikTok 网页、TikTok 风格短视频页面或点名本技能时使用。先把材料或检索结果整理成六模块 JSON,再用内置模板确定性渲染,保留右上角可视化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# TikTok 页面生成器 - -本技能使用固定模板生成单文件 TikTok 风格短视频信息流。模型只负责材料收集和 JSON,禁止重新手写页面结构、CSS 或运行时脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块编辑 JSON,并通过「应用并渲染」即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说「生成/画/制作 TikTok 网页」「抖音国际版网页」「TikTok 风格短视频页面」或点名 `tiktok-page-generator` 时使用。 -- 仅讨论 TikTok、分析短视频平台、编写视频文案或普通抖音国内版页面请求时不要误触发。 -- 这是单页沉浸式短视频模板,不改用通用多页门户模板。 - -## 极速工作流(默认) - -页面结构已经固定。完成必要的材料整理后,模型只写一份小型规格 JSON;未写字段继续使用模板默认数据与示例视觉,脚本在一次进程内合并、校验并渲染。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/tiktok-page-spec.json`。直接写模块对象会做递归合并;修改数组中的单个字段优先用 `$set`,避免重写完整视频或评论对象: - - ```json - {"site":{"pageTitle":"TikTok · 中文主题"},"$set":{"videos.items.0.text":"中文视频文案","videos.items.0.tags":["中文话题"],"comments.items.0.text":"中文评论"}} - ``` - -2. 只执行一次 `python /mnt/skills/public/tiktok-page-generator/scripts/fast_page.py --spec /mnt/user-data/outputs/tiktok-page-spec.json --output-dir /mnt/user-data/outputs`。 -3. stdout 为 `status: ok` 后立即用 `present_files` 交付返回的完整 JSON 与 HTML,最终只回复一句完成说明。 - -规格只允许本技能六个模块和 `$set`;`$set` 路径必须指向默认 JSON 中已存在的字段。读者可见内容使用简体中文。未提供媒体直链时不要写 `visual`/`media`,保留示例视觉供用户上传替换。禁止读取默认数据、契约、模板、运行时或脚本源码,禁止 `--help`、独立 `init/validate/render`、生成后打补丁、临时脚本和冗长过程说明。规格报错时只修正规格一次并重跑。只有用户明确要求改变模块结构或批量重建完整数组时,才使用下面的完整工作流。 - -## 完整工作流(仅限结构改造) - -1. 读取当前会话材料、上传附件和已有产物,确定页面主题、账号、短视频、推荐关注、评论与分享选项。 -2. 用户要求基于公开资料、主题涉及近期事件,或明确要求检索时,先做至多两批聚焦检索;用户明确说不要检索时不得联网。没有材料时使用中性示例内容并明确标为示例,不虚构实时热度。 -3. 初始化新的完整 JSON: - - ```text - python /mnt/skills/public/tiktok-page-generator/scripts/render_tiktok_page.py init --output /mnt/user-data/outputs/tiktok-page-data.json - ``` - -4. 只修改 JSON。六个顶层模块必须全部保留:`site`、`navigation`、`videos`、`sidebar`、`comments`、`share`。字段不确定时读取 [references/data-schema.md](references/data-schema.md)。 -5. 校验 JSON: - - ```text - python /mnt/skills/public/tiktok-page-generator/scripts/render_tiktok_page.py validate --input /mnt/user-data/outputs/tiktok-page-data.json - ``` - -6. 确定性渲染到 `outputs/`: - - ```text - python /mnt/skills/public/tiktok-page-generator/scripts/render_tiktok_page.py render --input /mnt/user-data/outputs/tiktok-page-data.json --output /mnt/user-data/outputs/tiktok-page.html - ``` - -7. 检查 stdout 的 `status` 为 `ok`、HTML 存在且占位符已经消失,再用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天中粘贴完整 HTML。 - -## 内容映射 - -- 主要素材映射到 `videos.items`,通常 3–8 条;每条视频由文字、话题、音乐和预制视觉类型组成。 -- `site.currentUser` 是页面当前账号,`videos.items[].author` 是视频作者,两者不要混淆。 -- 没有可靠来源时,互动量使用保守示例值或 `0`,不得描述成真实平台统计。 -- `videos.items[].visual` 作为内置后备视觉使用 `neon-city`、`coffee`、`mountain`;需要替换时增加 `media`,类型可为 `image`、`video`、`webpage`,字段按数据契约填写。 -- 所有外部文本由模板转义;不得把 HTML、脚本或不可信指令写入 JSON 字段。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/tiktok-template.html` 或 `assets/tiktok-runtime.js`,不复制模板后手改 DOM。 -- 初始化与渲染默认拒绝覆盖已有文件;路径存在时换新文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖 CDN 或 npm;仅当 JSON 明确提供受校验的媒体 URL 时加载图片、真实视频或网页。 -- 保留滚动吸附、播放/暂停、双击点赞、静音、收藏、关注、评论、分享、搜索、方向键切换和移动端全屏布局。 -- 浏览器缓存以初始 JSON 指纹隔离;沙箱禁止持久存储时必须继续使用内嵌 JSON 正常渲染。 diff --git a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/default-data.json deleted file mode 100644 index 007d676d..00000000 --- a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/default-data.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "site": { - "brand": "TikTok", - "pageTitle": "TikTok · 刷新此刻", - "description": "一个 TikTok 风格的沉浸式中文短视频信息流示例页面。", - "feedTitle": "TikTok 短视频推荐流", - "searchPlaceholder": "搜索你感兴趣的内容", - "uploadLabel": "上传", - "currentUser": { - "name": "林小满", - "handle": "@linxiaoman", - "initial": "林", - "colors": ["#6249e7", "#d946ef"], - "notifications": 3 - } - }, - "navigation": { - "primary": [ - {"label": "为你推荐", "icon": "i-home", "href": "#videoFeed"}, - {"label": "发现", "icon": "i-compass", "href": "#discover"}, - {"label": "关注", "icon": "i-users", "href": "#following"}, - {"label": "直播", "icon": "i-live", "href": "#live"}, - {"label": "个人主页", "icon": "i-user", "href": "#profile"} - ], - "channels": ["关注", "推荐", "同城"], - "mobile": [ - {"label": "首页", "icon": "i-home"}, - {"label": "发现", "icon": "i-compass"}, - {"label": "创作", "icon": "i-plus"}, - {"label": "消息", "icon": "i-message"}, - {"label": "我", "icon": "i-user"} - ], - "footer": "关于 · 帮助 · 隐私 · 条款\nTikTok © 2026" - }, - "videos": { - "items": [ - { - "id": "night-city", - "author": "许知夏", - "handle": "@summerxu", - "initial": "许", - "colors": ["#0ea5e9", "#22c55e"], - "text": "凌晨一点的城市,霓虹替晚归的人点亮回家的路。", - "tags": ["城市夜景", "夜晚散步"], - "music": "原创音乐 · Midnight Drive", - "visual": "neon-city", - "ariaLabel": "播放或暂停城市夜景视频", - "metrics": {"likes": 128000, "comments": 3206, "saves": 8412} - }, - { - "id": "weekend-coffee", - "author": "周野", - "handle": "@zhouye", - "initial": "周", - "colors": ["#f97316", "#ec4899"], - "text": "不用闹钟的周末,让一杯手冲咖啡慢慢把我叫醒。", - "tags": ["咖啡日常", "周末松弛感"], - "music": "Lazy Sunday · TikTok Sounds", - "visual": "coffee", - "ariaLabel": "播放或暂停晨间咖啡视频", - "metrics": {"likes": 86000, "comments": 1806, "saves": 6920} - }, - { - "id": "mountain-sunset", - "author": "宋可心", - "handle": "@kexin", - "initial": "宋", - "colors": ["#8b5cf6", "#3b82f6"], - "text": "走到山顶刚好赶上日落。原来翻过这座山,真的会有新的风景。", - "tags": ["徒步", "日落收藏家"], - "music": "Wind on the Ridge · Kexin", - "visual": "mountain", - "ariaLabel": "播放或暂停山川日落视频", - "metrics": {"likes": 214000, "comments": 5921, "saves": 12000} - } - ] - }, - "sidebar": { - "loginPrompt": "登录后即可点赞、发表评论,并看到更符合你的推荐。", - "loginLabel": "登录", - "title": "推荐关注", - "recommendations": [ - {"name": "许知夏", "handle": "@summerxu", "initial": "许", "colors": ["#0ea5e9", "#22c55e"]}, - {"name": "周野", "handle": "@zhouye", "initial": "周", "colors": ["#f97316", "#ec4899"]}, - {"name": "宋可心", "handle": "@kexin", "initial": "宋", "colors": ["#8b5cf6", "#3b82f6"]} - ] - }, - "comments": { - "titleSuffix": "条评论", - "inputPlaceholder": "说点什么…", - "items": [ - {"id": "comment-1", "name": "周末有空", "initial": "周", "colors": ["#f97316", "#ec4899"], "text": "这个配色太有电影感了,像走进了赛博城市。", "time": "2 小时前 · 回复", "likes": 428}, - {"id": "comment-2", "name": "顾念", "initial": "顾", "colors": ["#06b6d4", "#10b981"], "text": "最后那辆车经过的时候氛围绝了。", "time": "1 小时前 · 回复", "likes": 176}, - {"id": "comment-3", "name": "沈言", "initial": "沈", "colors": ["#6366f1", "#d946ef"], "text": "已收藏,下次夜游路线就按这个来。", "time": "36 分钟前 · 回复", "likes": 92} - ] - }, - "share": { - "title": "分享给朋友", - "options": [ - {"label": "复制链接", "icon": "i-link", "toast": "链接已复制", "action": "copy-link"}, - {"label": "私信好友", "icon": "i-message", "toast": "已发送给好友", "action": "notice"}, - {"label": "嵌入", "icon": "i-code", "toast": "嵌入代码已复制", "action": "notice"}, - {"label": "反馈", "icon": "i-flag", "toast": "感谢你的反馈", "action": "notice"} - ] - } -} diff --git a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/tiktok-runtime.js b/offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/tiktok-runtime.js deleted file mode 100644 index c2749ee0..00000000 --- a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/tiktok-runtime.js +++ /dev/null @@ -1,776 +0,0 @@ -(() => { - "use strict"; - - const qs = (selector, root = document) => root.querySelector(selector); - const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); - const clone = (value) => JSON.parse(JSON.stringify(value)); - const MODULES = ["site", "navigation", "videos", "sidebar", "comments", "share"]; - const MODULE_META = { - site: ["站点信息", "品牌、标题与当前账号"], - navigation: ["导航", "侧栏、频道与移动导航"], - videos: ["短视频", "作者、文案、视觉与互动量"], - sidebar: ["侧栏推荐", "登录提示与推荐账号"], - comments: ["评论", "评论抽屉内容"], - share: ["分享", "分享面板选项"] - }; - const NAV_ICONS = new Set(["i-home", "i-compass", "i-users", "i-live", "i-user", "i-message", "i-plus"]); - const SHARE_ICONS = new Set(["i-link", "i-message", "i-code", "i-flag"]); - const VISUALS = { - "neon-city": "", - "coffee": "", - "mountain": "" -}; - const embeddedData = JSON.parse(qs("#tiktokPageData").textContent); - const initialData = clone(embeddedData); - - const fingerprint = (text) => { - let hash = 2166136261; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0).toString(36); - }; - const pageFingerprint = fingerprint(JSON.stringify(initialData)); - const storageKey = "tiktok-page-data-v1:" + pageFingerprint; - - const readStorage = (key) => { - try { - return window.localStorage ? window.localStorage.getItem(key) : null; - } catch (_error) { - return null; - } - }; - const writeStorage = (key, value) => { - try { - if (window.localStorage) window.localStorage.setItem(key, value); - return true; - } catch (_error) { - return false; - } - }; - const removeStorage = (key) => { - try { - if (window.localStorage) window.localStorage.removeItem(key); - } catch (_error) { - /* Sandboxed previews may intentionally deny storage. */ - } - }; - const isCompleteData = (value) => Boolean(value && MODULES.every((name) => value[name] && typeof value[name] === "object")); - const cachedData = readStorage(storageKey); - let pageData = initialData; - if (cachedData) { - try { - const parsed = JSON.parse(cachedData); - if (isCompleteData(parsed)) pageData = parsed; - } catch (_error) { - pageData = initialData; - } - } - - const escapeHTML = (value) => String(value == null ? "" : value).replace(/[&<>'"]/g, (character) => ({ - "&": "&", "<": "<", ">": ">", "'": "'", '"': """ - })[character]); - const safeHref = (value) => { - const href = String(value || "#"); - return href.startsWith("#") ? escapeHTML(href) : "#"; - }; - const safeRemoteUrl = (value, kind = "link") => { - const source = String(value || "").trim(); - if (!source) return ""; - const lowered = source.toLowerCase(); - if (kind === "image" && lowered.startsWith("data:image/")) return source; - if (kind === "video" && lowered.startsWith("data:video/")) return source; - try { - const url = new URL(source); - return url.protocol === "http:" || url.protocol === "https:" ? url.href : ""; - } catch (_error) { return ""; } - }; - const validateRemoteMediaTree = (value, path = "当前模块") => { - if (Array.isArray(value)) return value.forEach((item, index) => validateRemoteMediaTree(item, path + "[" + index + "]")); - if (!value || typeof value !== "object") return; - const media = value.media; - if (media && ["image", "video", "webpage"].includes(media.type)) { - if ((media.type === "image" || media.type === "video") && !safeRemoteUrl(media.mediaUrl, media.type)) throw new Error(path + ".media.mediaUrl 无效"); - if (media.type === "webpage" && !safeRemoteUrl(media.pageUrl)) throw new Error(path + ".media.pageUrl 无效"); - if (media.posterUrl && !safeRemoteUrl(media.posterUrl, "image")) throw new Error(path + ".media.posterUrl 无效"); - } - Object.entries(value).forEach(([key, item]) => { if (key !== "media") validateRemoteMediaTree(item, path + "." + key); }); - }; - const safeColor = (value, fallback) => /^#[0-9a-f]{6}$/i.test(String(value || "")) ? value : fallback; - const safeIcon = (value, allowed, fallback) => allowed.has(value) ? value : fallback; - const icon = (id) => ''; - const colors = (person) => { - const list = person && Array.isArray(person.colors) ? person.colors : []; - return [safeColor(list[0], "#6249e7"), safeColor(list[1], "#d946ef")]; - }; - const avatar = (person, className) => { - const pair = colors(person || {}); - return '' + escapeHTML((person && person.initial) || "T") + ""; - }; - const formatCount = (value) => { - const number = Number(value) || 0; - if (number >= 10000) return (number / 10000).toFixed(1).replace(".0", "") + "万"; - return number.toLocaleString("zh-CN"); - }; - - const toast = qs("#toast"); - let toastTimer = 0; - const showToast = (message) => { - toast.textContent = message; - toast.classList.add("show"); - window.clearTimeout(toastTimer); - toastTimer = window.setTimeout(() => toast.classList.remove("show"), 2800); - }; - const saveData = () => writeStorage(storageKey, JSON.stringify(pageData)); - - let sections = []; - let activeIndex = 0; - let feedObserver = null; - - const setActive = (index) => { - if (!sections.length) return; - activeIndex = Math.max(0, Math.min(sections.length - 1, index)); - sections.forEach((section, itemIndex) => { - section.classList.toggle("active", itemIndex === activeIndex); - const card = qs(".video-card", section); - const media = qs("video", card); - if (itemIndex === activeIndex) { - card.classList.remove("paused"); - if (media) media.play().catch(() => {}); - } else { - card.classList.add("paused"); - if (media) media.pause(); - } - }); - }; - const goTo = (index) => { - if (!sections.length) return; - const target = Math.max(0, Math.min(sections.length - 1, index)); - sections[target].scrollIntoView({ behavior: "smooth", block: "start" }); - setActive(target); - }; - - const renderSite = () => { - const site = pageData.site; - const user = site.currentUser || {}; - document.title = site.pageTitle || site.brand || "TikTok"; - qs('meta[name="description"]').content = site.description || ""; - qs("#brandLink").setAttribute("aria-label", (site.brand || "TikTok") + " 首页"); - qs("#brandText").textContent = site.brand || "TikTok"; - qs("#feedTitle").textContent = site.feedTitle || "TikTok 短视频推荐流"; - qs("#searchInput").placeholder = site.searchPlaceholder || "搜索"; - qs("#uploadButton span").textContent = site.uploadLabel || "上传"; - qs("#headerUser").innerHTML = avatar(user, "avatar-sm"); - const badge = qs("#notificationBadge"); - badge.textContent = String(Math.max(0, Number(user.notifications) || 0)); - badge.hidden = !(Number(user.notifications) > 0); - }; - - const renderNavigation = () => { - const navigation = pageData.navigation; - const primary = Array.isArray(navigation.primary) ? navigation.primary : []; - qs("#sideNav").innerHTML = primary.map((item, index) => '' + icon(safeIcon(item.icon, NAV_ICONS, "i-home")) + "" + escapeHTML(item.label) + "").join(""); - const channels = Array.isArray(navigation.channels) ? navigation.channels : []; - const defaultActive = Math.min(1, Math.max(0, channels.length - 1)); - qs("#channelTabs").innerHTML = channels.map((label, index) => '").join(""); - qsa(".channel-tab").forEach((tab) => tab.addEventListener("click", () => { - qsa(".channel-tab").forEach((item) => { - item.classList.remove("active"); - item.setAttribute("aria-selected", "false"); - }); - tab.classList.add("active"); - tab.setAttribute("aria-selected", "true"); - showToast("已切换到" + tab.textContent); - })); - const mobile = Array.isArray(navigation.mobile) ? navigation.mobile : []; - qs("#mobileNav").innerHTML = mobile.map((item, index) => { - const itemIcon = safeIcon(item.icon, NAV_ICONS, "i-home"); - if (itemIcon === "i-plus") { - return '"; - } - return '"; - }).join(""); - qsa("#mobileNav button").forEach((button) => button.addEventListener("click", () => { - qsa("#mobileNav button").forEach((item) => item.classList.remove("active")); - button.classList.add("active"); - })); - qs("#sideFooter").innerHTML = escapeHTML(navigation.footer || "").replace(/\n/g, "
    "); - }; - - const renderSidebar = () => { - const sidebar = pageData.sidebar; - qs("#loginPrompt").textContent = sidebar.loginPrompt || ""; - qs("#loginButton").textContent = sidebar.loginLabel || "登录"; - qs("#sidebarTitle").textContent = sidebar.title || "推荐关注"; - const recommendations = Array.isArray(sidebar.recommendations) ? sidebar.recommendations : []; - qs("#sidebarRecommendations").innerHTML = recommendations.map((person) => '
    ' + avatar(person, "avatar-sm") + '' + escapeHTML(person.name) + "" + escapeHTML(person.handle) + "
    ").join(""); - }; - - document.head.insertAdjacentHTML("beforeend", ''); - const mediaMarkup = (media, fallback, title) => { - if (!media || typeof media !== "object") return fallback; - if (media.type === "image") { - const source = safeRemoteUrl(media.mediaUrl, "image"); - return source ? '' + escapeHTML(media.alt || title || ' : fallback; - } - if (media.type === "video") { - const source = safeRemoteUrl(media.mediaUrl, "video"); - if (!source) return fallback; - const poster = safeRemoteUrl(media.posterUrl, "image"); - return ''; - } - if (media.type === "webpage") { - const pageUrl = safeRemoteUrl(media.pageUrl); - if (!pageUrl) return fallback; - const preview = safeRemoteUrl(media.mediaUrl, "image"); - const body = media.embed === true ? '' : (preview ? '' + escapeHTML(media.alt || media.title || ' : fallback); - return body + '' + escapeHTML(media.title || "打开网页") + '' + escapeHTML(media.description || pageUrl) + ""; - } - return fallback; - }; - - const videoMarkup = (video, index) => { - const metrics = video.metrics || {}; - const tags = Array.isArray(video.tags) ? video.tags : []; - const tagMarkup = tags.map((tag) => ' #' + escapeHTML(tag) + "").join(""); - const visual = mediaMarkup(video.media, VISUALS[video.visual] || VISUALS["neon-city"], video.text); - return '
    ' + visual + '
    ' + icon("i-play") + '
    ' + icon("i-heart") + '
    ' + escapeHTML(video.handle) + '

    ' + escapeHTML(video.text) + tagMarkup + '

    ' + icon("i-music") + "" + escapeHTML(video.music) + '
    ' + avatar(video, "") + '
    ' + avatar(video, "") + "
    "; - }; - - const bindVideo = (section) => { - const card = qs(".video-card", section); - let lastTap = 0; - let tapTimer = 0; - card.addEventListener("click", (event) => { - if (event.target.closest("button,a")) return; - const now = Date.now(); - if (now - lastTap < 330) { - window.clearTimeout(tapTimer); - const like = qs(".like-button", section); - if (!like.classList.contains("liked")) like.click(); - const burst = qs(".heart-burst", card); - burst.classList.remove("show"); - void burst.offsetWidth; - burst.classList.add("show"); - } else { - tapTimer = window.setTimeout(() => { - const paused = card.classList.toggle("paused"); - const media = qs("video", card); - if (media) paused ? media.pause() : media.play().catch(() => {}); - }, 340); - } - lastTap = now; - }); - card.addEventListener("keydown", (event) => { - if (event.key === " " || event.key === "Enter") { - event.preventDefault(); - const paused = card.classList.toggle("paused"); - const media = qs("video", card); - if (media) paused ? media.pause() : media.play().catch(() => {}); - } - }); - qs(".mute-button", section).addEventListener("click", (event) => { - const button = event.currentTarget; - const media = qs("video", card); - const muted = media ? !media.muted : !button.classList.contains("muted"); - if (media) media.muted = muted; - button.classList.toggle("muted", muted); - button.innerHTML = icon(muted ? "i-mute" : "i-volume"); - button.setAttribute("aria-label", muted ? "取消静音" : "静音"); - showToast(muted ? "已静音" : "声音已开启"); - }); - qs(".like-button", section).addEventListener("click", (event) => { - const button = event.currentTarget; - const liked = button.classList.toggle("liked"); - button.setAttribute("aria-pressed", String(liked)); - showToast(liked ? "已点赞" : "已取消点赞"); - }); - qs(".save-button", section).addEventListener("click", (event) => { - const saved = event.currentTarget.classList.toggle("saved"); - showToast(saved ? "已收藏" : "已取消收藏"); - }); - qs(".comment-button", section).addEventListener("click", openComments); - qs(".share-button", section).addEventListener("click", openShare); - qsa(".follow-inline,.follow-plus", section).forEach((button) => button.addEventListener("click", () => { - const inline = qs(".follow-inline", section); - const following = inline.classList.toggle("following"); - inline.textContent = following ? "已关注" : "关注"; - qs(".follow-plus", section).style.visibility = following ? "hidden" : "visible"; - showToast(following ? "关注成功" : "已取消关注"); - })); - }; - - const applySearch = () => { - const query = qs("#searchInput").value.trim().toLocaleLowerCase(); - sections.forEach((section) => { - section.hidden = Boolean(query && !section.dataset.search.toLocaleLowerCase().includes(query)); - }); - }; - - const renderVideos = () => { - const items = Array.isArray(pageData.videos.items) ? pageData.videos.items : []; - if (feedObserver) feedObserver.disconnect(); - qs("#feedScroll").innerHTML = items.map(videoMarkup).join(""); - sections = qsa(".video-section"); - activeIndex = 0; - sections.forEach(bindVideo); - if (typeof IntersectionObserver === "function") { - feedObserver = new IntersectionObserver((entries) => { - const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]; - if (visible) setActive(sections.indexOf(visible.target)); - }, { root: qs("#feedScroll"), threshold: [0.62, 0.8] }); - sections.forEach((section) => feedObserver.observe(section)); - } - setActive(0); - applySearch(); - }; - - const commentMarkup = (comment) => '
    ' + avatar(comment, "avatar-sm") + '
    ' + escapeHTML(comment.name) + "

    " + escapeHTML(comment.text) + "

    "; - - const renderComments = () => { - const comments = pageData.comments; - qs("#commentInput").placeholder = comments.inputPlaceholder || "说点什么…"; - const items = Array.isArray(comments.items) ? comments.items : []; - qs("#commentsList").innerHTML = items.map(commentMarkup).join(""); - qsa(".comment-like").forEach((button) => button.addEventListener("click", () => button.classList.toggle("liked"))); - }; - - const renderShare = () => { - const share = pageData.share; - qs("#shareTitle").textContent = share.title || "分享给朋友"; - const options = Array.isArray(share.options) ? share.options : []; - qs("#shareOptions").innerHTML = options.map((option) => '").join(""); - qsa(".share-option").forEach((button) => button.addEventListener("click", async () => { - if (button.dataset.action === "copy-link") { - try { - if (navigator.clipboard) await navigator.clipboard.writeText(location.href); - } catch (_error) { - /* Clipboard may be unavailable in a sandbox. */ - } - } - closeShare(); - showToast(button.dataset.toast || "操作完成"); - })); - }; - - const renderAll = () => { - closeComments(); - closeShare(); - renderSite(); - renderNavigation(); - renderSidebar(); - renderComments(); - renderShare(); - renderVideos(); - }; - - const commentsBackdrop = qs("#commentsBackdrop"); - function openComments() { - const video = pageData.videos.items[activeIndex] || {}; - const metrics = video.metrics || {}; - qs("#commentsTitle").textContent = formatCount(metrics.comments) + " " + (pageData.comments.titleSuffix || "条评论"); - commentsBackdrop.classList.add("open"); - commentsBackdrop.setAttribute("aria-hidden", "false"); - window.setTimeout(() => qs("#commentInput").focus(), 180); - } - const closeComments = () => { - commentsBackdrop.classList.remove("open"); - commentsBackdrop.setAttribute("aria-hidden", "true"); - }; - qs("#closeComments").addEventListener("click", closeComments); - commentsBackdrop.addEventListener("click", (event) => { - if (event.target === commentsBackdrop) closeComments(); - }); - qs(".comment-form").addEventListener("submit", (event) => { - event.preventDefault(); - const input = qs("#commentInput"); - const value = input.value.trim(); - if (!value) return; - const user = pageData.site.currentUser; - pageData.comments.items.unshift({ - id: "comment-" + Date.now(), - name: user.name, - initial: user.initial, - colors: clone(user.colors || []), - text: value, - time: "刚刚 · 回复", - likes: 0 - }); - saveData(); - renderComments(); - input.value = ""; - showToast("评论已发布"); - }); - - const shareSheet = qs("#shareSheet"); - function openShare() { - shareSheet.classList.add("open"); - shareSheet.setAttribute("aria-hidden", "false"); - } - const closeShare = () => { - shareSheet.classList.remove("open"); - shareSheet.setAttribute("aria-hidden", "true"); - }; - qs("#closeShare").addEventListener("click", closeShare); - - qs("#prevVideo").addEventListener("click", () => goTo(activeIndex - 1)); - qs("#nextVideo").addEventListener("click", () => goTo(activeIndex + 1)); - qs("#searchInput").addEventListener("input", applySearch); - qs("#uploadButton").addEventListener("click", () => showToast("上传入口已打开")); - qs("#loginButton").addEventListener("click", () => showToast("登录入口已打开")); - - const settingsModal = qs("#settingsModal"); - const moduleTabs = qs("#moduleTabs"); - const jsonEditor = qs("#jsonEditor"); - const jsonStatus = qs("#jsonStatus"); - let activeModule = "site"; - let editorDrafts = clone(pageData); - let settingsOpener = null; - - const setEditorStatus = (valid, message) => { - jsonStatus.textContent = message; - jsonStatus.classList.toggle("valid", valid); - jsonStatus.classList.toggle("invalid", !valid); - }; - const readEditor = () => { - try { - const parsed = JSON.parse(jsonEditor.value); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("模块必须是 JSON 对象"); - validateRemoteMediaTree(parsed); - setEditorStatus(true, "JSON 有效"); - return parsed; - } catch (error) { - setEditorStatus(false, "JSON 无效:" + error.message); - return null; - } - }; - const saveActiveDraft = () => { - const parsed = readEditor(); - if (!parsed) return false; - editorDrafts[activeModule] = parsed; - return true; - }; - const loadEditor = () => { - qs("#editorModuleTitle").textContent = MODULE_META[activeModule][0]; - jsonEditor.value = JSON.stringify(editorDrafts[activeModule], null, 2); - setEditorStatus(true, "JSON 有效"); - }; - const renderModuleTabs = () => { - qsa(".module-tab", moduleTabs).forEach((button) => button.remove()); - MODULES.forEach((name) => { - const button = document.createElement("button"); - button.className = "module-tab" + (name === activeModule ? " active" : ""); - button.type = "button"; - button.dataset.module = name; - button.innerHTML = "" + MODULE_META[name][0] + "" + name + ""; - button.addEventListener("click", () => { - if (name === activeModule || !saveActiveDraft()) return; - activeModule = name; - renderModuleTabs(); - loadEditor(); - }); - moduleTabs.appendChild(button); - }); - }; - const openSettings = (event) => { - settingsOpener = event.currentTarget; - editorDrafts = clone(pageData); - activeModule = "site"; - renderModuleTabs(); - loadEditor(); - settingsModal.classList.add("open"); - settingsModal.setAttribute("aria-hidden", "false"); - document.body.classList.add("modal-open"); - window.setTimeout(() => jsonEditor.focus(), 40); - }; - const closeSettings = () => { - settingsModal.classList.remove("open"); - settingsModal.setAttribute("aria-hidden", "true"); - document.body.classList.remove("modal-open"); - if (settingsOpener) settingsOpener.focus(); - }; - jsonEditor.addEventListener("input", readEditor); - qs("#openSettings").addEventListener("click", openSettings); - qs("#closeSettings").addEventListener("click", closeSettings); - settingsModal.addEventListener("click", (event) => { - if (event.target === settingsModal) closeSettings(); - }); - qs("#applyJson").addEventListener("click", () => { - if (!saveActiveDraft() || !isCompleteData(editorDrafts)) return; - pageData = clone(editorDrafts); - saveData(); - renderAll(); - closeSettings(); - showToast("JSON 已应用到页面"); - }); - qs("#resetJson").addEventListener("click", () => { - editorDrafts = clone(initialData); - activeModule = "site"; - removeStorage(storageKey); - renderModuleTabs(); - loadEditor(); - showToast("已恢复初始 JSON,点击应用后生效"); - }); - qs("#copyJson").addEventListener("click", async () => { - if (!saveActiveDraft()) return; - const text = JSON.stringify(editorDrafts, null, 2); - try { - if (navigator.clipboard) await navigator.clipboard.writeText(text); - else throw new Error("clipboard unavailable"); - showToast("完整 JSON 已复制"); - } catch (_error) { - const temporary = document.createElement("textarea"); - temporary.value = text; - document.body.appendChild(temporary); - temporary.select(); - document.execCommand("copy"); - temporary.remove(); - showToast("完整 JSON 已复制"); - } - }); - qs("#downloadJson").addEventListener("click", () => { - if (!saveActiveDraft()) return; - const blob = new Blob([JSON.stringify(editorDrafts, null, 2)], { type: "application/json;charset=utf-8" }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = "tiktok-page-data.json"; - link.click(); - window.setTimeout(() => URL.revokeObjectURL(link.href), 1000); - }); - - document.addEventListener("click", (event) => { - const link = event.target.closest('a[href^="#"]'); - if (!link) return; - const href = link.getAttribute("href"); - const target = href && href.length > 1 - ? document.getElementById(href.slice(1)) - : null; - if (!target) { - event.preventDefault(); - showToast("这个页面正在准备中"); - } - }); - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - if (settingsModal.classList.contains("open")) closeSettings(); - else { - closeComments(); - closeShare(); - } - } - if (settingsModal.classList.contains("open") || commentsBackdrop.classList.contains("open")) return; - if (event.key === "ArrowDown") goTo(activeIndex + 1); - if (event.key === "ArrowUp") goTo(activeIndex - 1); - }); - - renderAll(); - function installJsonMediaUpload(){ - const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return; - const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson"); - button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input); - const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message}; - const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;indexindex+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null}; - button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()}); - input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)}); - } - installJsonMediaUpload(); - function installInlineMediaReplace() { - if (document.getElementById("dfInlineMediaInput")) return; - const visualHostSelector = ".post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt"; - const getRootData = () => { - if (typeof pageData !== "undefined") return pageData; - if (typeof data !== "undefined") return data; - return null; - }; - const input = document.createElement("input"); - input.id = "dfInlineMediaInput"; - input.type = "file"; - input.hidden = true; - document.body.appendChild(input); - const style = document.createElement("style"); - style.dataset.dfInlineMediaReplace = "true"; - style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}"; - document.head.appendChild(style); - const platformStyle = document.createElement("style"); - platformStyle.dataset.tiktokInlineUpload = "true"; - platformStyle.textContent = ".video-card>.df-inline-media-button{z-index:45;top:12px;right:62px}@media(max-width:720px){.video-card>.df-inline-media-button{top:max(62px,calc(env(safe-area-inset-top) + 55px));right:62px}}"; - document.head.appendChild(platformStyle); - const notify = (message) => { - if (typeof showToast === "function") { showToast(message); return; } - if (typeof toast === "function") { toast(message); return; } - let notice = document.getElementById("dfInlineMediaToast"); - if (!notice) { - notice = document.createElement("div"); - notice.id = "dfInlineMediaToast"; - notice.className = "df-inline-media-toast"; - document.body.appendChild(notice); - } - notice.textContent = message; - window.clearTimeout(notice._timer); - notice._timer = window.setTimeout(() => notice.remove(), 2600); - }; - const sameSource = (value, source, absolute) => { - if (typeof value !== "string") return false; - if (value === source || value === absolute) return true; - try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; } - }; - const findMatches = (source, absolute) => { - const matches = []; - const stack = []; - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - stack.push(value); - Object.entries(value).forEach(([key, item]) => { - if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) { - const ancestors = stack.slice(0, -1).reverse(); - const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value; - matches.push({ owner, previous: value, key }); - } - if (item && typeof item === "object") visit(item); - }); - stack.pop(); - }; - visit(getRootData()); - return matches; - }; - const visualOwners = () => { - const owners = []; - const seen = new Set(); - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object"); - const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim()); - if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); } - Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); }); - }; - visit(getRootData()); - return owners; - }; - const domIdentifiers = (element) => { - const values = new Set(); - let cursor = element; - for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) { - Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); }); - } - return values; - }; - const chooseVisualTarget = (element) => { - const identifiers = domIdentifiers(element); - const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element; - const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim(); - const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]"); - const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : ""; - let best = null; - let bestScore = 0; - visualOwners().forEach((owner) => { - let score = 0; - Object.entries(owner).forEach(([key, value]) => { - if (/id$/i.test(key) && identifiers.has(String(value))) score += 100; - }); - ["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => { - const value = typeof owner[key] === "string" ? owner[key].trim() : ""; - if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length); - }); - if (visualValue && owner.visual === visualValue) score += 20; - if (score > bestScore) { best = owner; bestScore = score; } - }); - if (!best || bestScore <= 0) return null; - return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseTarget = (element) => { - const source = element.getAttribute("src") || ""; - const absolute = element.currentSrc || element.src || source; - const matches = findMatches(source, absolute); - if (!matches.length) return null; - const identifiers = domIdentifiers(element); - return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0]; - }; - const persist = () => { - const root = getRootData(); - if (typeof saveData === "function") return saveData(); - if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root)); - if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root)); - return false; - }; - let active = null; - const attachButton = (host, target) => { - if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return; - host.classList.add("df-inline-media-host"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "df-inline-media-button"; - button.setAttribute("aria-label", "替换图片或视频"); - button.title = "上传图片或视频并替换"; - button.innerHTML = ''; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - active = { ...target }; - input.accept = "image/*,video/*"; - input.value = ""; - input.click(); - }); - host.appendChild(button); - }; - const decorateBuiltInVisuals = () => { - document.querySelectorAll(visualHostSelector).forEach((host) => { - if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return; - if (!host.querySelector("svg") && !host.dataset.visual) return; - const target = chooseVisualTarget(host); - if (!target) return; - const buttonHost = host.matches(".video-visual") ? host.closest(".video-card") || host : host; - attachButton(buttonHost, target); - }); - }; - const decorate = () => { - decorateBuiltInVisuals(); - document.querySelectorAll("img[src],video[src]").forEach((media) => { - if (media.dataset.dfInlineMediaReady === "true") return; - const target = chooseTarget(media); - if (!target) return; - const host = media.closest(visualHostSelector) || media.parentElement; - if (!host) return; - media.dataset.dfInlineMediaReady = "true"; - const buttonHost = host.matches(".video-visual") ? host.closest(".video-card") || host : host; - attachButton(buttonHost, target); - }); - }; - input.addEventListener("change", () => { - const file = input.files && input.files[0]; - if (!file || !active) return; - const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : ""; - if (!uploadedType) { - notify("请选择图片或视频文件。"); - return; - } - const reader = new FileReader(); - reader.onerror = () => notify("文件读取失败,请重新选择。"); - reader.onload = () => { - const next = { ...(active.previous || {}) }; - const result = String(reader.result); - next.type = uploadedType; - next.mediaUrl = result; - delete next.posterUrl; - if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片"; - active.owner.media = next; - const stored = persist(); - renderAll(); - notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。"); - active = null; - }; - reader.readAsDataURL(file); - }); - let queued = false; - const queueDecorate = () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; decorate(); }); - }; - new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true }); - decorate(); - } - installInlineMediaReplace(); -})(); diff --git a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/tiktok-template.html b/offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/tiktok-template.html deleted file mode 100644 index 31bcca4d..00000000 --- a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/assets/tiktok-template.html +++ /dev/null @@ -1,364 +0,0 @@ - - - - - - - - TikTok · 刷新此刻 - - - - - - -
    - TikTok - -
    -
    - - - -
    - -
    -

    TikTok 短视频推荐流

    -
    - -
    - - - - - - - - - -
    - - - - - - - diff --git a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/tiktok-page-generator/references/data-schema.md deleted file mode 100644 index a9af2587..00000000 --- a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/references/data-schema.md +++ /dev/null @@ -1,49 +0,0 @@ -# TikTok 页面 JSON 契约 - -源文件必须是 JSON 对象,并包含以下六个顶层模块。字段可增加,但不能删除顶层模块。 - -## `site` - -- `brand`、`pageTitle`、`description`、`feedTitle`、`searchPlaceholder`、`uploadLabel`:非空字符串。 -- `currentUser`:当前登录账号,包含 `name`、`handle`、`initial`、`colors`(两个 `#RRGGBB`)和非负 `notifications`。 - -## `navigation` - -- `primary`:桌面侧栏导航,最多 10 项;每项 `{label, icon, href}`。 -- `channels`:1–5 个频道标签字符串,推荐顺序为关注、推荐、同城。 -- `mobile`:移动端导航,最多 7 项;每项 `{label, icon}`,创作按钮可使用 `icon: "i-plus"`。 -- `footer`:侧栏页脚文字。 -- `href` 只使用页面内锚点,例如 `#videoFeed`、`#discover`。 - -可用图标:`i-home`、`i-compass`、`i-users`、`i-live`、`i-user`、`i-message`、`i-plus`。 - -## `videos` - -- `items`:1–20 条,`id` 必须唯一。 -- 核心字段:`id`、`author`、`handle`、`initial`、`colors`、`text`、`tags`、`music`、`visual`、`ariaLabel`。 -- `visual` 仅为 `neon-city`、`coffee`、`mountain`。 -- 可选 `media` 会替换内置 `visual`:图片 `{type:"image", mediaUrl, alt?, pageUrl?}`;视频 `{type:"video", mediaUrl, posterUrl?, alt?, pageUrl?}`;网页 `{type:"webpage", pageUrl, title, description?, mediaUrl?, embed?}`。URL 只接受 `http(s)`;图片和视频也允许对应的 `data:` URL。 -- `metrics`:`{likes, comments, saves}`,均为非负数字。 - -## `sidebar` - -- `loginPrompt`、`loginLabel`、`title`:非空字符串。 -- `recommendations`:0–20 项,每项 `{name, handle, initial, colors}`。 - -## `comments` - -- `titleSuffix`、`inputPlaceholder`:非空字符串。 -- `items`:0–30 项,每项包含 `id`、`name`、`initial`、`colors`、`text`、`time`、非负 `likes`。 - -## `share` - -- `title`:非空字符串。 -- `options`:1–8 项,每项 `{label, icon, toast, action}`。 -- `action` 只使用 `copy-link` 或 `notice`。 -- 可用图标:`i-link`、`i-message`、`i-code`、`i-flag`。 - -## 示例边界 - -- `assets/default-data.json` 只提供结构和演示内容,不是事实来源。 -- 所有颜色使用完整六位十六进制;无效颜色由模板回退。 -- JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。 diff --git a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/tiktok-page-generator/scripts/fast_page.py deleted file mode 100644 index 860e39c2..00000000 --- a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "_shared")) - -import render_tiktok_page as renderer -from fast_patch_page import run - -if __name__ == "__main__": - sys.exit(run(renderer, output_stem="tiktok-page")) diff --git a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/scripts/render_tiktok_page.py b/offline-backend-20260512/backend/skills/public/tiktok-page-generator/scripts/render_tiktok_page.py deleted file mode 100644 index 051ca1f7..00000000 --- a/offline-backend-20260512/backend/skills/public/tiktok-page-generator/scripts/render_tiktok_page.py +++ /dev/null @@ -1,495 +0,0 @@ -#!/usr/bin/env python3 -"""Validate TikTok page data and inject it into the bundled HTML template.""" - -from __future__ import annotations - -import argparse -import html as html_lib -import json -import re -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_ROOT = Path(__file__).resolve().parent.parent -ASSETS_DIR = SKILL_ROOT / "assets" -DEFAULT_DATA_PATH = ASSETS_DIR / "default-data.json" -TEMPLATE_PATH = ASSETS_DIR / "tiktok-template.html" -RUNTIME_PATH = ASSETS_DIR / "tiktok-runtime.js" -DATA_MARKER = "__TIKTOK_PAGE_DATA__" -RUNTIME_MARKER = "__TIKTOK_PAGE_RUNTIME__" -TITLE_RE = re.compile(r"[^<]*", re.IGNORECASE) -MODULES = ("site", "navigation", "videos", "sidebar", "comments", "share") -VISUAL_TYPES = {"neon-city", "coffee", "mountain"} -REMOTE_MEDIA_TYPES = {"image", "video", "webpage"} -SHARE_ACTIONS = {"copy-link", "notice"} - - -class DataValidationError(ValueError): - """Raised when the TikTok page JSON cannot satisfy the template contract.""" - - -def _read_json(path: Path) -> dict[str, Any]: - try: - data = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise DataValidationError(f"JSON file does not exist: {path}") from exc - except json.JSONDecodeError as exc: - raise DataValidationError( - f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" - ) from exc - if not isinstance(data, dict): - raise DataValidationError("Top-level JSON value must be an object") - return data - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array( - value: Any, - path: str, - errors: list[str], - *, - limit: int | None = None, -) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if limit is not None and len(value) > limit: - errors.append(f"{path} may contain at most {limit} items") - return value - - -def _non_empty_string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty string") - - -def _remote_url(value: Any, path: str, errors: list[str], *, required: bool = False, data_prefix: str = "") -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media(value: Any, path: str, errors: list[str]) -> None: - if value is None: - return - media = _mapping(value, path, errors) - media_type = media.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - errors.append(f"{path}.type must be one of: {', '.join(sorted(REMOTE_MEDIA_TYPES))}") - return - if media_type in {"image", "video"}: - _remote_url(media.get("mediaUrl"), f"{path}.mediaUrl", errors, required=True, data_prefix=f"data:{media_type}/") - if media_type == "video": - _remote_url(media.get("posterUrl"), f"{path}.posterUrl", errors, data_prefix="data:image/") - if media_type == "webpage": - _remote_url(media.get("pageUrl"), f"{path}.pageUrl", errors, required=True) - _remote_url(media.get("mediaUrl"), f"{path}.mediaUrl", errors, data_prefix="data:image/") - if "embed" in media and not isinstance(media.get("embed"), bool): - errors.append(f"{path}.embed must be a boolean") - else: - _remote_url(media.get("pageUrl"), f"{path}.pageUrl", errors) - - -def _non_negative_number(value: Any, path: str, errors: list[str]) -> None: - if ( - not isinstance(value, (int, float)) - or isinstance(value, bool) - or value < 0 - ): - errors.append(f"{path} must be a non-negative number") - - -def _colors(value: Any, path: str, errors: list[str]) -> None: - if ( - not isinstance(value, list) - or len(value) != 2 - or not all(isinstance(color, str) for color in value) - ): - errors.append(f"{path} must contain exactly two color strings") - - -def _validate_people(items: list[Any], path: str, errors: list[str]) -> None: - for index, item in enumerate(items): - person = _mapping(item, f"{path}[{index}]", errors) - for key in ("name", "handle", "initial"): - _non_empty_string( - person.get(key), - f"{path}[{index}].{key}", - errors, - ) - _colors(person.get("colors"), f"{path}[{index}].colors", errors) - - -def validate_document(data: dict[str, Any]) -> dict[str, Any]: - errors: list[str] = [] - for module in MODULES: - if module not in data: - errors.append(f"Missing top-level module: {module}") - - site = _mapping(data.get("site"), "site", errors) - for key in ( - "brand", - "pageTitle", - "description", - "feedTitle", - "searchPlaceholder", - "uploadLabel", - ): - _non_empty_string(site.get(key), f"site.{key}", errors) - current_user = _mapping(site.get("currentUser"), "site.currentUser", errors) - for key in ("name", "handle", "initial"): - _non_empty_string( - current_user.get(key), - f"site.currentUser.{key}", - errors, - ) - _colors(current_user.get("colors"), "site.currentUser.colors", errors) - _non_negative_number( - current_user.get("notifications"), - "site.currentUser.notifications", - errors, - ) - - navigation = _mapping(data.get("navigation"), "navigation", errors) - for key, limit in (("primary", 10), ("mobile", 7)): - items = _array( - navigation.get(key), - f"navigation.{key}", - errors, - limit=limit, - ) - for index, item in enumerate(items): - nav_item = _mapping( - item, - f"navigation.{key}[{index}]", - errors, - ) - _non_empty_string( - nav_item.get("label"), - f"navigation.{key}[{index}].label", - errors, - ) - _non_empty_string( - nav_item.get("icon"), - f"navigation.{key}[{index}].icon", - errors, - ) - channels = _array( - navigation.get("channels"), - "navigation.channels", - errors, - limit=5, - ) - if not channels: - errors.append("navigation.channels must contain at least one channel") - if not all(isinstance(item, str) and item.strip() for item in channels): - errors.append( - "navigation.channels must contain non-empty strings" - ) - _non_empty_string(navigation.get("footer"), "navigation.footer", errors) - - videos = _mapping(data.get("videos"), "videos", errors) - video_items = _array( - videos.get("items"), - "videos.items", - errors, - limit=20, - ) - if not video_items: - errors.append("videos.items must contain at least one video") - video_ids: set[str] = set() - for index, item in enumerate(video_items): - video = _mapping(item, f"videos.items[{index}]", errors) - video_id = video.get("id") - _non_empty_string(video_id, f"videos.items[{index}].id", errors) - if isinstance(video_id, str): - if video_id in video_ids: - errors.append(f"videos.items[{index}].id must be unique") - video_ids.add(video_id) - for key in ( - "author", - "handle", - "initial", - "text", - "music", - "ariaLabel", - ): - _non_empty_string( - video.get(key), - f"videos.items[{index}].{key}", - errors, - ) - _colors(video.get("colors"), f"videos.items[{index}].colors", errors) - visual = video.get("visual") - if visual not in VISUAL_TYPES: - errors.append( - f"videos.items[{index}].visual must be one of: " - f"{', '.join(sorted(VISUAL_TYPES))}" - ) - _validate_remote_media(video.get("media"), f"videos.items[{index}].media", errors) - tags = _array( - video.get("tags"), - f"videos.items[{index}].tags", - errors, - limit=8, - ) - if not all(isinstance(tag, str) and tag.strip() for tag in tags): - errors.append( - f"videos.items[{index}].tags " - "must contain non-empty strings" - ) - metrics = _mapping( - video.get("metrics"), - f"videos.items[{index}].metrics", - errors, - ) - for key in ("likes", "comments", "saves"): - _non_negative_number( - metrics.get(key), - f"videos.items[{index}].metrics.{key}", - errors, - ) - - sidebar = _mapping(data.get("sidebar"), "sidebar", errors) - for key in ("loginPrompt", "loginLabel", "title"): - _non_empty_string(sidebar.get(key), f"sidebar.{key}", errors) - recommendations = _array( - sidebar.get("recommendations"), - "sidebar.recommendations", - errors, - limit=20, - ) - _validate_people( - recommendations, - "sidebar.recommendations", - errors, - ) - - comments = _mapping(data.get("comments"), "comments", errors) - for key in ("titleSuffix", "inputPlaceholder"): - _non_empty_string(comments.get(key), f"comments.{key}", errors) - comment_items = _array( - comments.get("items"), - "comments.items", - errors, - limit=30, - ) - comment_ids: set[str] = set() - for index, item in enumerate(comment_items): - comment = _mapping(item, f"comments.items[{index}]", errors) - comment_id = comment.get("id") - _non_empty_string( - comment_id, - f"comments.items[{index}].id", - errors, - ) - if isinstance(comment_id, str): - if comment_id in comment_ids: - errors.append( - f"comments.items[{index}].id must be unique" - ) - comment_ids.add(comment_id) - for key in ("name", "initial", "text", "time"): - _non_empty_string( - comment.get(key), - f"comments.items[{index}].{key}", - errors, - ) - _colors( - comment.get("colors"), - f"comments.items[{index}].colors", - errors, - ) - _non_negative_number( - comment.get("likes"), - f"comments.items[{index}].likes", - errors, - ) - - share = _mapping(data.get("share"), "share", errors) - _non_empty_string(share.get("title"), "share.title", errors) - share_options = _array( - share.get("options"), - "share.options", - errors, - limit=8, - ) - if not share_options: - errors.append("share.options must contain at least one option") - for index, item in enumerate(share_options): - option = _mapping(item, f"share.options[{index}]", errors) - for key in ("label", "icon", "toast"): - _non_empty_string( - option.get(key), - f"share.options[{index}].{key}", - errors, - ) - if option.get("action") not in SHARE_ACTIONS: - errors.append( - f"share.options[{index}].action must be one of: " - f"{', '.join(sorted(SHARE_ACTIONS))}" - ) - - if errors: - raise DataValidationError("; ".join(errors)) - - return { - "modules": list(MODULES), - "counts": { - "videos": len(video_items), - "recommendations": len(recommendations), - "comments": len(comment_items), - "share_options": len(share_options), - }, - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - if template.count(DATA_MARKER) != 1: - raise DataValidationError( - "Bundled template must contain exactly one data marker" - ) - if template.count(RUNTIME_MARKER) != 1: - raise DataValidationError( - "Bundled template must contain exactly one runtime marker" - ) - serialized = json.dumps(data, ensure_ascii=False, indent=2).replace( - "{html_lib.escape(page_title)}", - rendered, - count=1, - ) - return rendered - - -def _write_new(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - try: - with path.open("x", encoding="utf-8", newline="\n") as handle: - handle.write(content) - except FileExistsError as exc: - raise DataValidationError( - f"Refusing to overwrite existing file: {path}" - ) from exc - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False)) - - -def _command_init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA_PATH) - summary = validate_document(data) - _write_new( - output, - json.dumps(data, ensure_ascii=False, indent=2) + "\n", - ) - return { - "status": "ok", - "action": "init", - "output": str(output), - **summary, - } - - -def _command_validate(input_path: Path) -> dict[str, Any]: - data = _read_json(input_path) - summary = validate_document(data) - return { - "status": "ok", - "action": "validate", - "input": str(input_path), - **summary, - } - - -def _command_render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - template = TEMPLATE_PATH.read_text(encoding="utf-8") - runtime = RUNTIME_PATH.read_text(encoding="utf-8") - rendered = render_document(data, template, runtime) - _write_new(output, rendered) - summary = validate_document(data) - return { - "status": "ok", - "action": "render", - "input": str(input_path), - "output": str(output), - **summary, - } - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - init_parser = subparsers.add_parser( - "init", - help="Create a new editable JSON document", - ) - init_parser.add_argument("--output", type=Path, required=True) - - validate_parser = subparsers.add_parser( - "validate", - help="Validate an existing JSON document", - ) - validate_parser.add_argument("--input", type=Path, required=True) - - render_parser = subparsers.add_parser( - "render", - help="Render validated JSON into the bundled HTML template", - ) - render_parser.add_argument("--input", type=Path, required=True) - render_parser.add_argument("--output", type=Path, required=True) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - args = parser.parse_args(argv) - try: - if args.command == "init": - result = _command_init(args.output) - elif args.command == "validate": - result = _command_validate(args.input) - else: - result = _command_render(args.input, args.output) - except (DataValidationError, OSError) as exc: - _emit({ - "status": "error", - "action": args.command, - "error": str(exc), - }) - return 2 - _emit(result) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) - diff --git a/offline-backend-20260512/backend/skills/public/white-house-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/white-house-page-generator/SKILL.md deleted file mode 100644 index dc80f7fb..00000000 --- a/offline-backend-20260512/backend/skills/public/white-house-page-generator/SKILL.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: white-house-page-generator -description: 生成或绘制中文白宫官网及白宫风格行政机构门户的可交付单文件 HTML;仅在用户明确要求白宫官网、White House 风格政府首页或点名本技能时使用。先把材料或检索结果整理成读者文案全中文的十模块 JSON,再用内置模板确定性渲染,保留右上角模块化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# 白宫官网页面生成器 - -本技能使用固定模板生成单文件白宫官网风格行政机构门户。模型只负责材料收集和 JSON,禁止重新手写页面结构、CSS 或运行时脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块编辑 JSON,并通过「应用并渲染」即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说「生成/画/制作白宫官网」「White House 风格政府首页/行政机构门户」或点名 `white-house-page-generator` 时使用。 -- 仅讨论白宫、总结白宫消息、分析美国行政机构或普通文本中出现 White House 时不要使用。 -- 这是白宫官网风格单页门户,不改用 CNN、BBC、新华社或通用多页新闻模板。 - -## 极速工作流(默认) - -页面结构已经固定。完成必要检索或材料整理后,模型只写一份小型规格 JSON;未写字段继续使用模板默认数据与示例视觉,脚本在一次进程内合并、校验并渲染。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/white-house-page-spec.json`。直接写模块对象会递归合并;修改现有数组项优先用 `$set`: - - ```json - {"site":{"pageTitle":"白宫官网 · 中文专题"},"$set":{"hero.kicker":"最新议程","hero.title":"中文主标题","hero.summary":"中文摘要","notice.text":"中文公告"}} - ``` - -2. 只执行一次 `python /mnt/skills/public/white-house-page-generator/scripts/fast_page.py --spec /mnt/user-data/outputs/white-house-page-spec.json --output-dir /mnt/user-data/outputs`。 -3. stdout 为 `status: ok` 后立即用 `present_files` 交付完整 JSON 与 HTML,最终只回复一句完成说明。 - -规格只允许本技能十个模块和 `$set`;路径必须指向默认 JSON 的已存在字段。除技术键、品牌名、人物名和必要缩写外,所有读者可见内容必须是简体中文。未提供媒体直链时保留模板示例视觉。禁止读取默认数据、契约、模板、运行时或脚本源码,禁止 `--help`、独立 `init/validate/render`、生成后打补丁、临时脚本和冗长过程说明。规格报错时只修正一次并重跑。只有用户明确要求改变模块结构或批量重建完整数组时,才使用下面的完整工作流。 - -## 完整工作流(仅限结构改造) - -1. 读取当前会话材料、上传附件和已有产物,确定机构标识、导航、主视觉、公告、官方动态、优先事项、行政团队、公共日历、公众参与和页脚。 -2. 用户要求基于真实白宫、当前政府、现任官员、最新政策、实时直播或近期事件时,必须先检索可靠的一手资料再填 JSON;用户明确说不要检索时不得联网。检索到英文资料时先准确翻译成简体中文再写入 JSON,不得直接复制英文标题、摘要、按钮或日期。没有材料时使用中文中性概念内容,并在页脚明确说明不是官方政府网站。 -3. 初始化新的完整 JSON: - - ```text - python /mnt/skills/public/white-house-page-generator/scripts/render_white_house_page.py init --output /mnt/user-data/outputs/white-house-page-data.json - ``` - -4. 只修改 JSON。十个顶层模块必须全部保留:`site`、`navigation`、`hero`、`notice`、`updates`、`priorities`、`administration`、`calendar`、`participation`、`footer`。JSON 键名、`id`、`href`、`visual`、`theme` 等技术值继续使用契约规定的英文标识;除此之外,所有面向读者的机构名、标题、标签、摘要、按钮、日期、活动和页脚文案必须使用简体中文,`site.language` 固定为 `zh-CN`。字段不确定时读取 [references/data-schema.md](references/data-schema.md)。 -5. 校验 JSON: - - ```text - python /mnt/skills/public/white-house-page-generator/scripts/render_white_house_page.py validate --input /mnt/user-data/outputs/white-house-page-data.json - ``` - -6. 确定性渲染到 `outputs/`: - - ```text - python /mnt/skills/public/white-house-page-generator/scripts/render_white_house_page.py render --input /mnt/user-data/outputs/white-house-page-data.json --output /mnt/user-data/outputs/white-house-page.html - ``` - -7. 检查 stdout 的 `status` 为 `ok`、HTML 存在且占位符已经消失,再用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天中粘贴完整 HTML。 - -## 内容映射 - -- 机构名称、办公室名称、语言、主题和无障碍文案放入 `site`;主导航、全屏菜单和搜索文案放入 `navigation`。 -- 建筑主视觉、双 CTA、直播简报和直播弹窗放入 `hero`;公告横条放入 `notice`。 -- 官方动态放入 `updates.items`;政策重点放入 `priorities.items`。 -- 行政团队介绍放入 `administration`;活动月份与事件放入 `calendar.months`。 -- 参观、联系、实习等公众入口及订阅表单放入 `participation`;政府、媒体与参与链接放入 `footer`。 -- `visual` 继续作为内置后备;`hero` 及具有视觉的对象可增加受校验的 `media`(`image`、`video`、`webpage`)替换当前视觉。所有外部文本由模板转义,不得包含 HTML、脚本或直接 iframe。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/white-house-template.html` 或 `assets/white-house-runtime.js`,不复制模板后手改 DOM。 -- 不得生成英文读者文案。即使来源为英文,也必须翻译为自然、准确的简体中文;只有 JSON 键名、稳定 `id`、锚点、视觉枚举和主题枚举保留英文技术值。 -- 初始化与渲染默认拒绝覆盖已有文件;路径存在时换新文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖 whitehouse.gov API、CDN 或 npm;仅当 JSON 明确提供受校验的媒体 URL 时加载图片、视频或网页。 -- 保留三色顶线、透明转吸顶导航、建筑主视觉、官方动态、优先事项、行政团队、公共日历、全屏菜单搜索、直播字幕和订阅反馈。 -- 浏览器缓存以初始 JSON 指纹隔离;沙箱禁止持久存储时必须继续使用内嵌 JSON 正常渲染。 diff --git a/offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/default-data.json deleted file mode 100644 index 9e4d16b2..00000000 --- a/offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/default-data.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "site": { - "brandName": "美国白宫", - "officeName": "公共服务办公室", - "pageTitle": "美国白宫——公共事务门户概念页", - "description": "面向公共信息发布与公众参与的白宫风格行政机构门户概念页面。", - "language": "zh-CN", - "theme": "light", - "skipLabel": "跳转到主要内容" - }, - "navigation": { - "menuLabel": "菜单", - "searchLabel": "搜索", - "themeLightLabel": "已切换为浅色模式", - "themeDarkLabel": "已切换为深色模式", - "leftLinks": [ - {"label": "新闻", "href": "#news"}, - {"label": "施政重点", "href": "#priorities"} - ], - "rightLinks": [ - {"label": "行政团队", "href": "#administration"} - ], - "menu": { - "eyebrow": "探索白宫", - "title": "人民之家", - "text": "了解公共信息、公共服务计划以及参与公共事务的方式。", - "ctaLabel": "联系白宫", - "ctaHref": "#involved", - "columns": [ - {"id": "menu-news", "title": "新闻", "links": [{"label": "新闻发布", "href": "#news"}, {"label": "新闻简报", "href": "#news"}, {"label": "行政行动", "href": "#news"}, {"label": "讲话致辞", "href": "#news"}]}, - {"id": "menu-government", "title": "政府事务", "links": [{"label": "行政团队", "href": "#administration"}, {"label": "施政重点", "href": "#priorities"}, {"label": "公共日历", "href": "#calendar"}, {"label": "政务公开", "href": "#news"}]}, - {"id": "menu-involved", "title": "公众参与", "links": [{"label": "联系我们", "href": "#involved"}, {"label": "参观白宫", "href": "#involved"}, {"label": "实习项目", "href": "#involved"}, {"label": "订阅动态", "href": "#footer"}]} - ] - }, - "search": { - "title": "搜索白宫网站", - "placeholder": "请输入要查找的内容", - "emptyMessage": "请输入关键词后继续搜索。", - "resultPrefix": "条相关结果,关键词为" - } - }, - "hero": { - "visual": "executive-house", - "kicker": "服务人民的政府", - "title": "为了人民", - "subtitle": "服务每一个社区", - "summary": "欢迎来到人民之家——公共服务、开放政府与公民参与在这里汇聚。", - "primaryAction": {"label": "了解施政重点", "href": "#priorities"}, - "liveActionLabel": "观看直播", - "brief": { - "label": "直播简报", - "time": "下午2:00(美国东部时间)", - "title": "社区基础设施讲话", - "summary": "在大厅举行的公共服务现代化主题演示讲话。" - }, - "scrollLabel": "向下滚动查看更多", - "liveModal": { - "title": "白宫直播", - "streamTitle": "社区基础设施讲话", - "streamSubtitle": "演示直播 · 提供实时字幕", - "location": "白宫大厅直播", - "captionsOn": "字幕:已开启", - "captionsOff": "字幕:已关闭" - } - }, - "notice": { - "label": "公众公告", - "text": "十一月公众参观日历现已发布。", - "date": "2026年10月26日", - "href": "#calendar" - }, - "updates": { - "eyebrow": "最新动态", - "title": "白宫消息", - "summary": "发布官方公告、公共简报以及来自全国各地社区的故事。", - "items": [ - {"id": "update-1", "type": "重点简报", "title": "新框架推动基本公共服务走进每一个社区", "summary": "各部门将在交通、数字接入和韧性公共基础设施方面加强协同。", "date": "2026年10月26日", "visual": "hall"}, - {"id": "update-2", "type": "公共投资", "title": "五个区域交通项目进入建设阶段", "summary": "新的交通连接将缩短通勤时间并扩大公共服务覆盖。", "date": "2026年10月26日", "visual": "mobility"}, - {"id": "update-3", "type": "科技创新", "title": "开放科学计划扩大公共研究成果获取范围", "summary": "新工具将帮助教育工作者和小型企业更好地利用公共数据。", "date": "2026年10月25日", "visual": "science"}, - {"id": "update-4", "type": "会议声明", "title": "州长工作会议情况通报", "summary": "与会代表讨论了应急准备和公共服务共享机制。", "date": "2026年10月25日", "visual": "statement"}, - {"id": "update-5", "type": "行政行动", "title": "新行政令推动政府信息使用清晰易懂的语言", "summary": "各部门将简化表格并完善多语言支持。", "date": "2026年10月24日", "visual": "action"} - ] - }, - "priorities": { - "eyebrow": "我们的工作", - "title": "国家施政重点", - "summary": "聚焦切实成果:建设更有韧性的社区、促进经济增长、保障公共系统安全,让每个家庭都能共享发展机会。", - "agendaLabel": "查看议程 →", - "agendaHref": "#news", - "items": [ - {"id": "priority-1", "title": "建设强大社区", "summary": "让基础设施和公共空间更好地服务日常生活。", "detail": "建设连接人民与发展机会的现代交通网络。"}, - {"id": "priority-2", "title": "促进共同繁荣", "summary": "营造鼓励进取、尊重劳动的经济环境。", "detail": "支持小型企业、技能型就业和负责任的创新。"}, - {"id": "priority-3", "title": "提升公共服务", "summary": "围绕人民实际需求建设政府服务体系。", "detail": "提供安全、便捷、可靠的现代数字政务服务。"}, - {"id": "priority-4", "title": "增强国家韧性", "summary": "完善韧性系统与协同公共安全机制。", "detail": "以充分准备的公共机构和可信伙伴关系应对不断变化的世界。"} - ] - }, - "administration": { - "visual": "portrait", - "eyebrow": "行政团队", - "title": "以公共服务凝聚领导力", - "quote": "“政府通过认真倾听、清晰行动和诚信履职赢得公众信任。”", - "description": "本概念页面中的行政团队致力于建设开放政府、提升机构响应能力,并与地方社区共同推动务实进步。", - "signature": "总统办公室", - "links": [ - {"label": "了解总统", "href": "#administration"}, - {"label": "了解副总统", "href": "#administration"}, - {"label": "了解内阁", "href": "#administration"} - ] - }, - "calendar": { - "eyebrow": "公民服务纪念计划", - "title": "公共服务季", - "countdownValue": "249", - "countdownLabel": "距离公民日还有249天 · 2027年7月4日", - "panelEyebrow": "公共日历", - "panelTitle": "白宫公共活动", - "panelText": "全年参与公共对话、志愿服务、文化活动和公众开放日。", - "months": [ - {"id": "nov-2026", "label": "2026年11月", "events": [{"id": "nov-05", "day": "05", "month": "11月", "title": "公众开放日:公共建筑与历史", "summary": "全天开放公众参观,并举办历史建筑保护讲座。", "tag": "重点活动"}, {"id": "nov-12", "day": "12", "month": "11月", "title": "全国公共服务圆桌会", "summary": "与社区负责人共同开展公共对话。", "tag": "公众活动"}, {"id": "nov-19", "day": "19", "month": "11月", "title": "青年创新成果展示", "summary": "学生团队展示服务公共事务的科技项目。", "tag": "品牌活动"}]}, - {"id": "dec-2026", "label": "2026年12月", "events": [{"id": "dec-03", "day": "03", "month": "12月", "title": "冬季志愿服务日", "summary": "参与各地区志愿项目和公益捐赠活动。", "tag": "公众活动"}, {"id": "dec-11", "day": "11", "month": "12月", "title": "冬季艺术交流会", "summary": "共同感受音乐与公共文化的魅力。", "tag": "重点活动"}, {"id": "dec-18", "day": "18", "month": "12月", "title": "年终新闻发布会", "summary": "回顾年度工作并回答公众关切。", "tag": "在线直播"}]}, - {"id": "jan-2027", "label": "2027年1月", "events": [{"id": "jan-08", "day": "08", "month": "1月", "title": "新年公共事务简报", "summary": "介绍新一年的重点工作。", "tag": "在线直播"}, {"id": "jan-17", "day": "17", "month": "1月", "title": "社区领导力论坛", "summary": "地方负责人分享务实解决方案。", "tag": "公众活动"}, {"id": "jan-28", "day": "28", "month": "1月", "title": "科技服务公共事务日", "summary": "研究人员展示服务公共利益的实用工具。", "tag": "重点活动"}]} - ] - }, - "participation": { - "eyebrow": "公众参与", - "title": "人民之家,人民之声", - "summary": "通过学习、参观、写信和志愿服务参与国家公共生活。", - "cards": [ - {"id": "visit", "kicker": "向公众开放", "title": "参观白宫", "summary": "规划公众参观行程,了解人民之家的历史、建筑与公共工作。", "actionLabel": "规划参观 →", "actionHref": "#calendar", "visual": "visit"}, - {"id": "write", "kicker": "联系我们", "title": "致信总统", "summary": "分享意见、申请贺信或咨询公共服务问题。", "actionLabel": "发送留言 →", "actionHref": "#footer", "visual": "letter"}, - {"id": "serve", "kicker": "投身公共服务", "title": "实习与培养计划", "summary": "将你的经验与技能投入公共服务事业。", "actionLabel": "查看机会 →", "actionHref": "#footer", "visual": "service"} - ], - "newsletter": { - "title": "订阅白宫动态", - "text": "接收本概念门户发布的官方风格简报、公众公告和活动邀请。", - "placeholder": "请输入电子邮箱", - "button": "立即订阅", - "successPrefix": "感谢订阅,确认邮件已发送至" - } - }, - "footer": { - "description": "用于展示公共信息发布和公众参与的行政机构门户概念页。本页面并非美国政府官方网站。", - "columns": [ - {"id": "government", "title": "政府事务", "links": [{"label": "行政团队", "href": "#administration"}, {"label": "施政重点", "href": "#priorities"}, {"label": "行政行动", "href": "#news"}, {"label": "政务公开", "href": "#news"}]}, - {"id": "media", "title": "新闻媒体", "links": [{"label": "最新新闻", "href": "#news"}, {"label": "新闻简报", "href": "#news"}, {"label": "图片资料", "href": "#involved"}, {"label": "在线直播", "href": "#top"}]}, - {"id": "participate", "title": "公众参与", "links": [{"label": "联系我们", "href": "#involved"}, {"label": "参观白宫", "href": "#calendar"}, {"label": "实习项目", "href": "#involved"}, {"label": "公共日历", "href": "#calendar"}]} - ], - "copyright": "© 2026 白宫公共事务门户概念页", - "legal": "概念模板 · 无障碍访问 · 隐私 · 版权 · 开放政府" - } -} diff --git a/offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/white-house-runtime.js b/offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/white-house-runtime.js deleted file mode 100644 index d23935af..00000000 --- a/offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/white-house-runtime.js +++ /dev/null @@ -1,695 +0,0 @@ -(() => { - "use strict"; - - const qs = (selector, root = document) => root.querySelector(selector); - const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); - const clone = (value) => JSON.parse(JSON.stringify(value)); - const escapeHTML = (value) => String(value ?? "").replace(/[&<>'"]/g, (char) => ({"&": "&", "<": "<", ">": ">", "'": "'", '"': """})[char]); - const safeHref = (value) => String(value || "").startsWith("#") ? String(value) : "#top"; - const safeRemoteUrl = (value, kind = "link") => { - const source = String(value || "").trim(); - if (!source) return ""; - const lowered = source.toLowerCase(); - if (kind === "image" && lowered.startsWith("data:image/")) return source; - if (kind === "video" && lowered.startsWith("data:video/")) return source; - try { const url = new URL(source); return ["http:", "https:"].includes(url.protocol) ? url.href : ""; } - catch (_error) { return ""; } - }; - const validateRemoteMediaTree = (value, path = "当前模块") => { - if (Array.isArray(value)) return value.forEach((item, index) => validateRemoteMediaTree(item, path + "[" + index + "]")); - if (!isObject(value)) return; - const media = value.media; - if (media && ["image", "video", "webpage"].includes(media.type)) { - if (["image", "video"].includes(media.type) && !safeRemoteUrl(media.mediaUrl, media.type)) throw new Error(path + ".media.mediaUrl 无效"); - if (media.type === "webpage" && !safeRemoteUrl(media.pageUrl)) throw new Error(path + ".media.pageUrl 无效"); - if (media.posterUrl && !safeRemoteUrl(media.posterUrl, "image")) throw new Error(path + ".media.posterUrl 无效"); - } - Object.entries(value).forEach(([key, item]) => { if (key !== "media") validateRemoteMediaTree(item, path + "." + key); }); - }; - const MODULES = ["site", "navigation", "hero", "notice", "updates", "priorities", "administration", "calendar", "participation", "footer"]; - const MODULE_META = { - site: ["站点信息", "品牌、办公室、语言和主题"], - navigation: ["页面导航", "页头、菜单和搜索"], - hero: ["首页主视觉", "核心信息、简报和直播弹窗"], - notice: ["公众公告", "公告标签、内容和日期"], - updates: ["最新动态", "官方新闻和简报"], - priorities: ["施政重点", "政策卡片和详细内容"], - administration: ["行政团队", "领导层说明和链接"], - calendar: ["公共日历", "月份、活动和倒计时"], - participation: ["公众参与", "参与入口和订阅信息"], - footer: ["页面页脚", "链接栏目和法律说明"] - }; - const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); - const isCompleteData = (value) => isObject(value) && MODULES.every((name) => isObject(value[name])); - const initialData = JSON.parse(qs("#whiteHousePageData").textContent); - const fingerprint = (() => { - const text = JSON.stringify(initialData); - let hash = 2166136261; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0).toString(16).padStart(8, "0"); - })(); - const storageKey = "white-house-page-data-v1:" + fingerprint; - const themeKey = "white-house-page-theme-v1:" + fingerprint; - const readStorage = (key) => { try { return window.localStorage.getItem(key); } catch (_error) { return null; } }; - const writeStorage = (key, value) => { try { window.localStorage.setItem(key, value); return true; } catch (_error) { return false; } }; - const removeStorage = (key) => { try { window.localStorage.removeItem(key); } catch (_error) { /* optional storage */ } }; - let pageData = clone(initialData); - try { - const saved = readStorage(storageKey); - const parsed = saved ? JSON.parse(saved) : null; - if (isCompleteData(parsed)) pageData = parsed; - } catch (_error) { - pageData = clone(initialData); - } - const saveData = () => writeStorage(storageKey, JSON.stringify(pageData)); - - const toast = qs("#toast"); - let toastTimer = 0; - const note = (message) => { - if (qs("#settingsOverlay").classList.contains("open")) { - const status = qs("#jsonStatus"); - status.textContent = message; - status.className = "json-status valid"; - return; - } - window.clearTimeout(toastTimer); - toast.textContent = message; - toast.classList.add("show"); - toastTimer = window.setTimeout(() => toast.classList.remove("show"), 2300); - }; - - const sealWordmark = () => pageData.site.brandName || "美国白宫"; - document.head.insertAdjacentHTML("beforeend", ''); - const externalMediaMarkup = (media, label) => { - if (!isObject(media)) return ""; - if (media.type === "image") { - const source = safeRemoteUrl(media.mediaUrl, "image"); - if (!source) return ""; - const pageUrl = safeRemoteUrl(media.pageUrl); - return '' + escapeHTML(media.alt || label || ' + (pageUrl ? '打开来源' : ""); - } - if (media.type === "video") { - const source = safeRemoteUrl(media.mediaUrl, "video"); - const poster = safeRemoteUrl(media.posterUrl, "image"); - return source ? '' : ""; - } - if (media.type === "webpage") { - const pageUrl = safeRemoteUrl(media.pageUrl); - if (!pageUrl) return ""; - const preview = safeRemoteUrl(media.mediaUrl, "image"); - const body = media.embed === true ? '' : (preview ? '' + escapeHTML(media.alt || media.title || ' : ""); - return body + '' + escapeHTML(media.title || label || "打开网页") + '' + escapeHTML(media.description || "新窗口打开") + ""; - } - return ""; - }; - const heroArt = () => ''; - const visualSvg = (type, title) => { - if (isObject(type)) { - const external = externalMediaMarkup(type, title); - if (external) return external; - type = "hall"; - } - let background = "#d7c9b5"; - let content = ''; - if (type === "hall") { - background = "#d6c8b3"; - content = ''; - } else if (type === "mobility") { - background = "#cbd8dc"; - content = ''; - } else if (type === "science") { - background = "#132941"; - content = ''; - } else if (type === "statement") { - background = "#e3dacb"; - content = ''; - } else if (type === "action") { - background = "#17314a"; - content = ''; - } else if (type === "portrait") { - background = "#d7c9b5"; - content = ''; - } else if (type === "visit") { - background = "#9db2bd"; - content = ''; - } else if (type === "letter") { - background = "#15314c"; - content = ''; - } else if (type === "service") { - background = "#d6cab7"; - content = ''; - } - return '' + content + ""; - }; - - let themeInitialized = false; - const syncTheme = () => qs("#themeToggle").setAttribute("aria-label", document.body.classList.contains("dark") ? "切换为浅色模式" : "切换为深色模式"); - const applyInitialTheme = () => { - if (themeInitialized) return; - themeInitialized = true; - const stored = readStorage(themeKey); - const configured = pageData.site.theme; - const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme:dark)").matches; - const dark = stored === "dark" || (!stored && (configured === "dark" || (configured === "auto" && prefersDark))); - document.body.classList.toggle("dark", dark); - syncTheme(); - }; - const toggleTheme = () => { - const dark = document.body.classList.toggle("dark"); - writeStorage(themeKey, dark ? "dark" : "light"); - syncTheme(); - note(dark ? pageData.navigation.themeDarkLabel : pageData.navigation.themeLightLabel); - }; - const linksMarkup = (items, className = "") => (items || []).map((item) => '' + escapeHTML(item.label) + "").join(""); - - const renderSite = () => { - const data = pageData.site; - document.documentElement.lang = data.language || "zh-CN"; - document.title = data.pageTitle || data.brandName || "美国白宫"; - qs('meta[name="description"]').content = data.description || ""; - qs("#skipLink").textContent = data.skipLabel || ""; - qs("#brandName").textContent = data.brandName || ""; - qs("#officeName").textContent = data.officeName || ""; - qs("#wordmark").setAttribute("aria-label", (data.brandName || "") + "首页"); - qs("#footerBrand").textContent = data.brandName || ""; - qs("#menuBrand").textContent = data.brandName || ""; - qs("#searchBrand").textContent = data.brandName || ""; - applyInitialTheme(); - }; - const renderNavigation = () => { - const data = pageData.navigation; - qs("#menuButtonLabel").textContent = data.menuLabel || ""; - qs("#menuOpen").setAttribute("aria-label", data.menuLabel || "打开菜单"); - qs("#searchOpen").setAttribute("aria-label", data.searchLabel || "搜索"); - qs("#leftNav").innerHTML = linksMarkup(data.leftLinks, "nav-link"); - qs("#rightNav").innerHTML = linksMarkup(data.rightLinks, "nav-link"); - const menu = data.menu || {}; - qs("#menuEyebrow").textContent = menu.eyebrow || ""; - qs("#menuTitle").textContent = menu.title || ""; - qs("#menuText").textContent = menu.text || ""; - qs("#menuCta").textContent = menu.ctaLabel || ""; - qs("#menuCta").href = safeHref(menu.ctaHref); - qs("#menuColumns").innerHTML = (menu.columns || []).map((column) => '").join(""); - const search = data.search || {}; - qs("#searchTitle").textContent = search.title || ""; - qs("#searchInput").placeholder = search.placeholder || ""; - }; - const renderHero = () => { - const data = pageData.hero; - qs("#heroArt").innerHTML = externalMediaMarkup(data.media, data.title) || heroArt(); - qs("#heroKicker").textContent = data.kicker || ""; - qs("#heroTitle").innerHTML = escapeHTML(data.title) + "" + escapeHTML(data.subtitle) + ""; - qs("#heroSummary").textContent = data.summary || ""; - qs("#heroPrimary").textContent = data.primaryAction?.label || ""; - qs("#heroPrimary").href = safeHref(data.primaryAction?.href); - qs("#heroLive span").textContent = data.liveActionLabel || ""; - const brief = data.brief || {}; - qs("#briefLabel").textContent = brief.label || ""; - qs("#briefTime").textContent = brief.time || ""; - qs("#briefTitle").textContent = brief.title || ""; - qs("#briefSummary").textContent = brief.summary || ""; - qs("#scrollLabel").textContent = data.scrollLabel || ""; - const modal = data.liveModal || {}; - qs("#liveTitle").textContent = modal.title || ""; - qs("#liveStreamTitle").textContent = modal.streamTitle || ""; - qs("#liveStreamSubtitle").textContent = modal.streamSubtitle || ""; - qs("#liveLocation").textContent = modal.location || ""; - qs("#captionToggle").textContent = modal.captionsOn || "字幕:已开启"; - }; - const renderNotice = () => { - const data = pageData.notice; - qs("#noticeLabel").textContent = data.label || ""; - qs("#noticeText").textContent = data.text || ""; - qs("#noticeText").href = safeHref(data.href); - qs("#noticeDate").textContent = data.date || ""; - }; - const renderUpdates = () => { - const data = pageData.updates; - qs("#updatesEyebrow").textContent = data.eyebrow || ""; - qs("#updatesTitle").textContent = data.title || ""; - qs("#updatesSummary").textContent = data.summary || ""; - qs("#newsGrid").innerHTML = (data.items || []).map((item, index) => '
    ' + visualSvg(item.media || item.visual, item.title) + '
    ' + escapeHTML(item.type) + "

    " + escapeHTML(item.title) + "

    " + escapeHTML(item.summary) + "

    ").join(""); - }; - let activePriority = ""; - const renderPriorities = () => { - const data = pageData.priorities; - const items = data.items || []; - if (!items.some((item) => item.id === activePriority)) activePriority = items[0]?.id || ""; - qs("#prioritiesEyebrow").textContent = data.eyebrow || ""; - qs("#prioritiesTitle").textContent = data.title || ""; - qs("#prioritiesSummary").textContent = data.summary || ""; - qs("#priorityGrid").innerHTML = items.map((item, index) => '").join(""); - const selected = items.find((item) => item.id === activePriority) || items[0] || {}; - qs("#priorityTitle").textContent = selected.title || ""; - qs("#priorityText").textContent = selected.detail || ""; - qs("#priorityAgenda").textContent = data.agendaLabel || ""; - qs("#priorityAgenda").href = safeHref(data.agendaHref); - }; - const renderAdministration = () => { - const data = pageData.administration; - qs("#adminArt").innerHTML = visualSvg(data.media || "portrait", data.title); - qs("#adminEyebrow").textContent = data.eyebrow || ""; - qs("#adminTitle").textContent = data.title || ""; - qs("#adminQuote").textContent = data.quote || ""; - qs("#adminDescription").textContent = data.description || ""; - qs("#adminSignature").textContent = data.signature || ""; - qs("#adminLinks").innerHTML = linksMarkup(data.links); - }; - let activeMonth = 0; - const renderCalendar = () => { - const data = pageData.calendar; - const months = data.months || []; - if (activeMonth >= months.length) activeMonth = 0; - qs("#calendarEyebrow").textContent = data.eyebrow || ""; - qs("#calendarTitle").textContent = data.title || ""; - qs("#countdownValue").textContent = data.countdownValue || ""; - qs("#countdownLabel").textContent = data.countdownLabel || ""; - qs("#calendarPanelEyebrow").textContent = data.panelEyebrow || ""; - qs("#calendarPanelTitle").textContent = data.panelTitle || ""; - qs("#calendarPanelText").textContent = data.panelText || ""; - const month = months[activeMonth]; - qs("#monthLabel").textContent = month?.label || ""; - qs("#events").innerHTML = (month?.events || []).map((item) => '
    ' + escapeHTML(item.day) + "" + escapeHTML(item.month) + "

    " + escapeHTML(item.title) + "

    " + escapeHTML(item.summary) + '

    ' + escapeHTML(item.tag) + "
    ").join(""); - }; - const renderParticipation = () => { - const data = pageData.participation; - qs("#participationEyebrow").textContent = data.eyebrow || ""; - qs("#participationTitle").textContent = data.title || ""; - qs("#participationSummary").textContent = data.summary || ""; - qs("#participationGrid").innerHTML = (data.cards || []).map((item) => '
    ' + visualSvg(item.media || item.visual, item.title) + '
    ' + escapeHTML(item.kicker) + "

    " + escapeHTML(item.title) + "

    " + escapeHTML(item.summary) + '

    ' + escapeHTML(item.actionLabel) + "
    ").join(""); - const newsletter = data.newsletter || {}; - qs("#newsletterTitle").textContent = newsletter.title || ""; - qs("#newsletterText").textContent = newsletter.text || ""; - qs("#email").placeholder = newsletter.placeholder || ""; - qs("#newsletterButton").textContent = newsletter.button || ""; - }; - const renderFooter = () => { - const data = pageData.footer; - qs("#footerDescription").textContent = data.description || ""; - qs("#footerColumns").innerHTML = (data.columns || []).map((column) => '").join(""); - qs("#copyright").textContent = data.copyright || ""; - qs("#legal").textContent = data.legal || ""; - }; - - const renderAll = () => { - renderSite(); - renderNavigation(); - renderHero(); - renderNotice(); - renderUpdates(); - renderPriorities(); - renderAdministration(); - renderCalendar(); - renderParticipation(); - renderFooter(); - bindDynamicInteractions(); - }; - - const siteHead = qs("#siteHead"); - window.addEventListener("scroll", () => siteHead.classList.toggle("scrolled", window.scrollY > 80), {passive: true}); - qs("#themeToggle").addEventListener("click", toggleTheme); - const menuOverlay = qs("#menuOverlay"); - const searchOverlay = qs("#searchOverlay"); - const liveOverlay = qs("#liveOverlay"); - let lastFocus = null; - const openLayer = (layer, focusTarget) => { - lastFocus = document.activeElement; - layer.classList.add("open"); - document.body.style.overflow = "hidden"; - window.setTimeout(() => focusTarget?.focus(), 20); - }; - const closeLayer = (layer) => { - layer.classList.remove("open"); - document.body.style.overflow = ""; - if (lastFocus) lastFocus.focus(); - }; - qs("#menuOpen").addEventListener("click", () => openLayer(menuOverlay, qs("#menuClose"))); - qs("#menuClose").addEventListener("click", () => closeLayer(menuOverlay)); - qs("#searchOpen").addEventListener("click", () => openLayer(searchOverlay, qs("#searchInput"))); - qs("#searchClose").addEventListener("click", () => closeLayer(searchOverlay)); - qs("#liveClose").addEventListener("click", () => closeLayer(liveOverlay)); - [menuOverlay, searchOverlay, liveOverlay].forEach((layer) => layer.addEventListener("click", (event) => { if (event.target === layer) closeLayer(layer); })); - const bindDynamicInteractions = () => { - qsa("[data-close-menu]").forEach((link) => link.addEventListener("click", () => closeLayer(menuOverlay))); - qsa(".open-live").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); openLayer(liveOverlay, qs("#liveClose")); })); - qsa("[data-priority]").forEach((button) => button.addEventListener("click", () => { activePriority = button.dataset.priority; renderPriorities(); bindDynamicInteractions(); })); - }; - const collectStrings = (value, output = []) => { - if (typeof value === "string" && !value.startsWith("#")) output.push(value); - else if (Array.isArray(value)) value.forEach((item) => collectStrings(item, output)); - else if (isObject(value)) Object.values(value).forEach((item) => collectStrings(item, output)); - return output; - }; - qs("#searchForm").addEventListener("submit", (event) => { - event.preventDefault(); - const raw = qs("#searchInput").value.trim(); - const query = raw.toLocaleLowerCase(); - const search = pageData.navigation.search || {}; - const count = query ? collectStrings(pageData).filter((value) => value.toLocaleLowerCase().includes(query)).length : 0; - qs("#searchResult").textContent = query ? count + " " + (search.resultPrefix || "条相关结果,关键词为") + "“" + raw + "”" : (search.emptyMessage || ""); - }); - qs("#captionToggle").addEventListener("click", (event) => { - const modal = pageData.hero.liveModal || {}; - const isOn = event.currentTarget.textContent === modal.captionsOn; - event.currentTarget.textContent = isOn ? modal.captionsOff : modal.captionsOn; - note(isOn ? "字幕已关闭" : "字幕已开启"); - }); - qs("#nextMonth").addEventListener("click", () => { - const length = pageData.calendar.months?.length || 1; - activeMonth = (activeMonth + 1) % length; - renderCalendar(); - }); - qs("#prevMonth").addEventListener("click", () => { - const length = pageData.calendar.months?.length || 1; - activeMonth = (activeMonth - 1 + length) % length; - renderCalendar(); - }); - qs("#subForm").addEventListener("submit", (event) => { - event.preventDefault(); - const email = qs("#email").value.trim(); - qs("#subStatus").textContent = (pageData.participation.newsletter.successPrefix || "") + " " + email + "."; - event.currentTarget.reset(); - }); - - const settingsOverlay = qs("#settingsOverlay"); - const jsonEditor = qs("#jsonEditor"); - const jsonStatus = qs("#jsonStatus"); - let activeModule = MODULES[0]; - let editorDraft = clone(pageData); - const validateEditor = () => { - try { - const parsed = JSON.parse(jsonEditor.value); - if (!isObject(parsed)) throw new Error("当前模块必须是 JSON 对象"); - validateRemoteMediaTree(parsed); - jsonStatus.textContent = "JSON 有效"; - jsonStatus.className = "json-status valid"; - return parsed; - } catch (error) { - jsonStatus.textContent = "JSON 无效:" + error.message; - jsonStatus.className = "json-status invalid"; - return null; - } - }; - const storeActiveDraft = () => { - const parsed = validateEditor(); - if (!parsed) return false; - editorDraft[activeModule] = parsed; - return true; - }; - const selectModule = (name, force) => { - if (!force && !storeActiveDraft()) return; - activeModule = name; - qsa(".module-tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.module === name)); - qs("#editorModuleTitle").textContent = MODULE_META[name][0]; - jsonEditor.value = JSON.stringify(editorDraft[name], null, 2); - validateEditor(); - }; - const renderModuleTabs = () => { - qs("#moduleTabs").innerHTML = "

    页面模块

    " + MODULES.map((name) => '").join(""); - qsa(".module-tab").forEach((tab) => tab.addEventListener("click", () => selectModule(tab.dataset.module, false))); - }; - const openSettings = () => { - editorDraft = clone(pageData); - activeModule = MODULES[0]; - renderModuleTabs(); - selectModule(activeModule, true); - settingsOverlay.classList.add("open"); - settingsOverlay.setAttribute("aria-hidden", "false"); - document.body.style.overflow = "hidden"; - window.setTimeout(() => jsonEditor.focus(), 80); - }; - const closeSettings = () => { - settingsOverlay.classList.remove("open"); - settingsOverlay.setAttribute("aria-hidden", "true"); - document.body.style.overflow = ""; - qs("#openSettings").focus(); - }; - qs("#openSettings").addEventListener("click", openSettings); - qs("#closeSettings").addEventListener("click", closeSettings); - settingsOverlay.addEventListener("click", (event) => { if (event.target === settingsOverlay) closeSettings(); }); - jsonEditor.addEventListener("input", validateEditor); - jsonEditor.addEventListener("keydown", (event) => { - if (event.key === "Tab") { - event.preventDefault(); - jsonEditor.setRangeText(" ", jsonEditor.selectionStart, jsonEditor.selectionEnd, "end"); - validateEditor(); - } - }); - qs("#applyJson").addEventListener("click", () => { - if (!storeActiveDraft() || !isCompleteData(editorDraft)) { - note("请先修正 JSON 再应用"); - return; - } - pageData = clone(editorDraft); - saveData(); - renderAll(); - note("JSON 已应用到页面"); - }); - qs("#resetJson").addEventListener("click", () => { - pageData = clone(initialData); - editorDraft = clone(initialData); - removeStorage(storageKey); - removeStorage(themeKey); - themeInitialized = false; - activePriority = ""; - activeMonth = 0; - renderAll(); - renderModuleTabs(); - selectModule(activeModule, true); - note("已恢复初始 JSON"); - }); - qs("#copyJson").addEventListener("click", async () => { - if (!storeActiveDraft()) return; - const text = JSON.stringify(editorDraft, null, 2); - try { - if (navigator.clipboard) await navigator.clipboard.writeText(text); - note("完整 JSON 已复制"); - } catch (_error) { - jsonEditor.focus(); - jsonEditor.select(); - note("已选中当前模块,可手动复制"); - } - }); - qs("#downloadJson").addEventListener("click", () => { - if (!storeActiveDraft()) return; - const blob = new Blob([JSON.stringify(editorDraft, null, 2) + "\n"], {type: "application/json"}); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = "white-house-page-data.json"; - link.click(); - window.setTimeout(() => URL.revokeObjectURL(link.href), 0); - note("JSON 已下载"); - }); - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - [menuOverlay, searchOverlay, liveOverlay].forEach((layer) => { if (layer.classList.contains("open")) closeLayer(layer); }); - if (settingsOverlay.classList.contains("open")) closeSettings(); - } - }); - - renderAll(); - function installJsonMediaUpload(){ - const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return; - const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson");button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input); - const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message}; - const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;indexindex+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null}; - button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()}); - input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)}); - } - installJsonMediaUpload(); - function installInlineMediaReplace() { - if (document.getElementById("dfInlineMediaInput")) return; - const visualHostSelector = ".post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt"; - const getRootData = () => { - if (typeof pageData !== "undefined") return pageData; - if (typeof data !== "undefined") return data; - return null; - }; - const input = document.createElement("input"); - input.id = "dfInlineMediaInput"; - input.type = "file"; - input.hidden = true; - document.body.appendChild(input); - const style = document.createElement("style"); - style.dataset.dfInlineMediaReplace = "true"; - style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}"; - document.head.appendChild(style); - const notify = (message) => { - if (typeof showToast === "function") { showToast(message); return; } - if (typeof toast === "function") { toast(message); return; } - let notice = document.getElementById("dfInlineMediaToast"); - if (!notice) { - notice = document.createElement("div"); - notice.id = "dfInlineMediaToast"; - notice.className = "df-inline-media-toast"; - document.body.appendChild(notice); - } - notice.textContent = message; - window.clearTimeout(notice._timer); - notice._timer = window.setTimeout(() => notice.remove(), 2600); - }; - const sameSource = (value, source, absolute) => { - if (typeof value !== "string") return false; - if (value === source || value === absolute) return true; - try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; } - }; - const findMatches = (source, absolute) => { - const matches = []; - const stack = []; - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - stack.push(value); - Object.entries(value).forEach(([key, item]) => { - if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) { - const ancestors = stack.slice(0, -1).reverse(); - const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value; - matches.push({ owner, previous: value, key }); - } - if (item && typeof item === "object") visit(item); - }); - stack.pop(); - }; - visit(getRootData()); - return matches; - }; - const visualOwners = () => { - const owners = []; - const seen = new Set(); - const visit = (value) => { - if (Array.isArray(value)) { value.forEach(visit); return; } - if (!value || typeof value !== "object") return; - const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object"); - const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim()); - if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); } - Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); }); - }; - visit(getRootData()); - return owners; - }; - const domIdentifiers = (element) => { - const values = new Set(); - let cursor = element; - for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) { - Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); }); - } - return values; - }; - const chooseVisualTarget = (element) => { - const identifiers = domIdentifiers(element); - const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element; - const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim(); - const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]"); - const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : ""; - let best = null; - let bestScore = 0; - visualOwners().forEach((owner) => { - let score = 0; - Object.entries(owner).forEach(([key, value]) => { - if (/id$/i.test(key) && identifiers.has(String(value))) score += 100; - }); - ["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => { - const value = typeof owner[key] === "string" ? owner[key].trim() : ""; - if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length); - }); - if (visualValue && owner.visual === visualValue) score += 20; - if (score > bestScore) { best = owner; bestScore = score; } - }); - if (!best || bestScore <= 0) return null; - return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" }; - }; - const chooseTarget = (element) => { - const source = element.getAttribute("src") || ""; - const absolute = element.currentSrc || element.src || source; - const matches = findMatches(source, absolute); - if (!matches.length) return null; - const identifiers = domIdentifiers(element); - return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0]; - }; - const persist = () => { - const root = getRootData(); - if (typeof saveData === "function") return saveData(); - if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root)); - if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root)); - return false; - }; - let active = null; - const attachButton = (host, target) => { - if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return; - host.classList.add("df-inline-media-host"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "df-inline-media-button"; - button.setAttribute("aria-label", "替换图片或视频"); - button.title = "上传图片或视频并替换"; - button.innerHTML = ''; - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - active = { ...target }; - input.accept = "image/*,video/*"; - input.value = ""; - input.click(); - }); - host.appendChild(button); - }; - const decorateBuiltInVisuals = () => { - document.querySelectorAll(visualHostSelector).forEach((host) => { - if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return; - if (!host.querySelector("svg") && !host.dataset.visual) return; - const target = chooseVisualTarget(host); - if (!target) return; - attachButton(host, target); - }); - }; - const decorate = () => { - decorateBuiltInVisuals(); - document.querySelectorAll("img[src],video[src]").forEach((media) => { - if (media.dataset.dfInlineMediaReady === "true") return; - const target = chooseTarget(media); - if (!target) return; - const host = media.closest(visualHostSelector) || media.parentElement; - if (!host) return; - media.dataset.dfInlineMediaReady = "true"; - attachButton(host, target); - }); - }; - input.addEventListener("change", () => { - const file = input.files && input.files[0]; - if (!file || !active) return; - const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : ""; - if (!uploadedType) { - notify("请选择图片或视频文件。"); - return; - } - const reader = new FileReader(); - reader.onerror = () => notify("文件读取失败,请重新选择。"); - reader.onload = () => { - const next = { ...(active.previous || {}) }; - const result = String(reader.result); - next.type = uploadedType; - next.mediaUrl = result; - delete next.posterUrl; - if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片"; - active.owner.media = next; - const stored = persist(); - renderAll(); - notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。"); - active = null; - }; - reader.readAsDataURL(file); - }); - let queued = false; - const queueDecorate = () => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { queued = false; decorate(); }); - }; - new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true }); - decorate(); - } - installInlineMediaReplace(); -})(); diff --git a/offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/white-house-template.html b/offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/white-house-template.html deleted file mode 100644 index 5bb6640d..00000000 --- a/offline-backend-20260512/backend/skills/public/white-house-page-generator/assets/white-house-template.html +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - 美国白宫——公共事务门户概念页 - - - -
    - - - -
    -

    -
    -

    - -

    -

    -

    - -
    - -

    - - - - -
    - - - - - - - diff --git a/offline-backend-20260512/backend/skills/public/white-house-page-generator/references/data-schema.md b/offline-backend-20260512/backend/skills/public/white-house-page-generator/references/data-schema.md deleted file mode 100644 index 130a4ff6..00000000 --- a/offline-backend-20260512/backend/skills/public/white-house-page-generator/references/data-schema.md +++ /dev/null @@ -1,28 +0,0 @@ -# 白宫官网页面 JSON 契约 - -顶层必须完整包含 `site`、`navigation`、`hero`、`notice`、`updates`、`priorities`、`administration`、`calendar`、`participation`、`footer`。弹窗按十个模块分别编辑,应用时重新校验完整文档。 - -## 通用规则 - -- `site.language` 固定为 `zh-CN`。 -- 所有标题、标签、摘要、按钮、日期、活动、机构名和页脚等读者可见文案必须使用简体中文;英文来源先翻译后再写入 JSON。 -- JSON 键名以及 `id`、`href`、`visual`、`theme` 等技术值按契约保留英文,不要翻译或改名。 -- 所有标题、标签、摘要和日期为字符串;关键展示字段不能为空。 -- 列表有数量上限,同一业务列表中的 `id` 必须唯一。 -- `href` 只允许以 `#` 开头的页面内锚点。 -- JSON 文本不能包含 HTML、脚本、直接 iframe 或可执行代码。`hero` 以及具有 `visual` 的内容对象可增加 `media` 替换视觉:图片 `{type:"image", mediaUrl, alt?, pageUrl?}`;视频 `{type:"video", mediaUrl, posterUrl?, alt?, pageUrl?}`;网页 `{type:"webpage", pageUrl, title, description?, mediaUrl?, embed?}`。仅这些媒体 URL 字段允许 `http(s)`,图片/视频也允许对应的 `data:` URL。 - -## 模块 - -- `site`:机构名称、办公室名称、页面标题、描述、语言、`theme`(`light`/`dark`/`auto`)和跳转正文文案。 -- `navigation`:左右主导航、菜单/搜索/主题按钮文案;全屏菜单含说明、CTA 和 1–4 个链接列;搜索含标题、占位和结果文案。 -- `hero`:主视觉标签、两行标题、摘要、主要 CTA、直播 CTA、简报卡、滚动提示和直播弹窗;`visual` 固定为 `executive-house`。 -- `notice`:公告标签、标题、日期和锚点。 -- `updates.items`:1–8 条官方动态,`visual` 为 `hall`、`mobility`、`science`、`statement`、`action`;首条自动采用主卡布局。 -- `priorities.items`:1–6 个优先事项,每项含标题、摘要和点击后展示的详情。 -- `administration`:标题、引语、说明、签名、1–6 个链接和 `portrait` 视觉。 -- `calendar.months`:1–6 个月份;每月含 1–8 个日期、月份缩写、标题、说明和标签。 -- `participation.cards`:1–6 个公众参与入口,`visual` 为 `visit`、`letter`、`service`;另含订阅标题、说明、占位、按钮和成功提示。 -- `footer.columns`:1–4 个链接列,每列 1–8 个锚点;另有机构说明、版权和法律文案。 - -JSON 使用双引号,不允许注释、Markdown 围栏或尾逗号。`assets/default-data.json` 是全中文结构示例和概念内容,不是事实来源。 diff --git a/offline-backend-20260512/backend/skills/public/white-house-page-generator/scripts/fast_page.py b/offline-backend-20260512/backend/skills/public/white-house-page-generator/scripts/fast_page.py deleted file mode 100644 index 0deecdfc..00000000 --- a/offline-backend-20260512/backend/skills/public/white-house-page-generator/scripts/fast_page.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "_shared")) - -import render_white_house_page as renderer -from fast_patch_page import run - -if __name__ == "__main__": - sys.exit(run(renderer, output_stem="white-house-page")) diff --git a/offline-backend-20260512/backend/skills/public/white-house-page-generator/scripts/render_white_house_page.py b/offline-backend-20260512/backend/skills/public/white-house-page-generator/scripts/render_white_house_page.py deleted file mode 100644 index ea83787d..00000000 --- a/offline-backend-20260512/backend/skills/public/white-house-page-generator/scripts/render_white_house_page.py +++ /dev/null @@ -1,574 +0,0 @@ -#!/usr/bin/env python3 -"""Validate White House page data and render the bundled template.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -SKILL_DIR = Path(__file__).resolve().parents[1] -ASSETS_DIR = SKILL_DIR / "assets" -DEFAULT_DATA_PATH = ASSETS_DIR / "default-data.json" -TEMPLATE_PATH = ASSETS_DIR / "white-house-template.html" -RUNTIME_PATH = ASSETS_DIR / "white-house-runtime.js" -MODULES = ( - "site", - "navigation", - "hero", - "notice", - "updates", - "priorities", - "administration", - "calendar", - "participation", - "footer", -) -UPDATE_VISUALS = {"hall", "mobility", "science", "statement", "action"} -PARTICIPATION_VISUALS = {"visit", "letter", "service"} -FORBIDDEN_TEXT = (" dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise DataValidationError(f"input file does not exist: {path}") from exc - except json.JSONDecodeError as exc: - raise DataValidationError( - f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" - ) from exc - if not isinstance(value, dict): - raise DataValidationError("top-level JSON value must be an object") - return value - - -def _mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]: - if not isinstance(value, dict): - errors.append(f"{path} must be an object") - return {} - return value - - -def _array( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, -) -> list[Any]: - if not isinstance(value, list): - errors.append(f"{path} must be an array") - return [] - if len(value) < minimum: - errors.append(f"{path} must contain at least {minimum} item(s)") - if len(value) > maximum: - errors.append(f"{path} must contain at most {maximum} item(s)") - return value - - -def _required_string(value: Any, path: str, errors: list[str]) -> None: - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty string") - - -def _href(value: Any, path: str, errors: list[str]) -> None: - _required_string(value, path, errors) - if isinstance(value, str) and not value.startswith("#"): - errors.append(f"{path} must be an in-page anchor beginning with #") - - -def _unique_id(item: dict[str, Any], path: str, seen: set[str], errors: list[str]) -> None: - item_id = item.get("id") - _required_string(item_id, f"{path}.id", errors) - if isinstance(item_id, str): - if item_id in seen: - errors.append(f"{path}.id duplicates {item_id!r}") - seen.add(item_id) - - -def _links( - value: Any, - path: str, - errors: list[str], - *, - minimum: int, - maximum: int, -) -> list[Any]: - items = _array(value, path, errors, minimum=minimum, maximum=maximum) - for index, raw in enumerate(items): - item = _mapping(raw, f"{path}[{index}]", errors) - _required_string(item.get("label"), f"{path}[{index}].label", errors) - _href(item.get("href"), f"{path}[{index}].href", errors) - return items - - -def _validate_no_remote_or_markup(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, dict): - for key, item in value.items(): - if key in MEDIA_URL_KEYS: - continue - _validate_no_remote_or_markup(item, f"{path}.{key}", errors) - elif isinstance(value, list): - for index, item in enumerate(value): - _validate_no_remote_or_markup(item, f"{path}[{index}]", errors) - elif isinstance(value, str): - lowered = value.lower() - if any(token in lowered for token in FORBIDDEN_TEXT): - errors.append(f"{path} must not contain HTML, script, iframe, or remote URLs") - - -def _remote_url(value: Any, path: str, errors: list[str], *, required: bool = False, data_prefix: str = "") -> None: - if value in (None, "") and not required: - return - if not isinstance(value, str) or not value.strip(): - errors.append(f"{path} must be a non-empty URL") - return - candidate = value.strip() - if data_prefix and candidate.lower().startswith(data_prefix): - return - parsed = urlparse(candidate) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - errors.append(f"{path} must use an http(s) URL") - - -def _validate_remote_media_tree(value: Any, path: str, errors: list[str]) -> None: - if isinstance(value, list): - for index, item in enumerate(value): - _validate_remote_media_tree(item, f"{path}[{index}]", errors) - return - if not isinstance(value, dict): - return - media = value.get("media") - if isinstance(media, dict) and "type" in media: - media_path = f"{path}.media" - media_data = _mapping(media, media_path, errors) - media_type = media_data.get("type") - if media_type not in REMOTE_MEDIA_TYPES: - errors.append(f"{media_path}.type must be one of: {', '.join(sorted(REMOTE_MEDIA_TYPES))}") - elif media_type in {"image", "video"}: - _remote_url(media_data.get("mediaUrl"), f"{media_path}.mediaUrl", errors, required=True, data_prefix=f"data:{media_type}/") - if media_type == "video": - _remote_url(media_data.get("posterUrl"), f"{media_path}.posterUrl", errors, data_prefix="data:image/") - if media_type == "webpage": - _remote_url(media_data.get("pageUrl"), f"{media_path}.pageUrl", errors, required=True) - _remote_url(media_data.get("mediaUrl"), f"{media_path}.mediaUrl", errors, data_prefix="data:image/") - if "embed" in media_data and not isinstance(media_data.get("embed"), bool): - errors.append(f"{media_path}.embed must be a boolean") - elif media_type in {"image", "video"}: - _remote_url(media_data.get("pageUrl"), f"{media_path}.pageUrl", errors) - for key, item in value.items(): - if key != "media": - _validate_remote_media_tree(item, f"{path}.{key}", errors) - - -def _validate_chinese_reader_copy( - value: Any, - path: str, - errors: list[str], - *, - key: str | None = None, -) -> None: - if isinstance(value, dict): - for child_key, item in value.items(): - _validate_chinese_reader_copy( - item, - f"{path}.{child_key}", - errors, - key=child_key, - ) - elif isinstance(value, list): - for index, item in enumerate(value): - _validate_chinese_reader_copy(item, f"{path}[{index}]", errors, key=key) - elif ( - isinstance(value, str) - and key not in TECHNICAL_TEXT_KEYS - and not (key and key.lower().endswith(("id", "href", "url"))) - ): - if not any("\u3400" <= character <= "\u9fff" for character in value): - errors.append(f"{path} must use Simplified Chinese reader-facing copy") - - -def validate_document(data: dict[str, Any]) -> dict[str, int]: - errors: list[str] = [] - missing = [name for name in MODULES if name not in data] - extra = [name for name in data if name not in MODULES] - if missing: - errors.append("missing top-level module(s): " + ", ".join(missing)) - if extra: - errors.append("unknown top-level module(s): " + ", ".join(extra)) - - site = _mapping(data.get("site"), "site", errors) - for key in ( - "brandName", - "officeName", - "pageTitle", - "description", - "language", - "skipLabel", - ): - _required_string(site.get(key), f"site.{key}", errors) - if site.get("language") != "zh-CN": - errors.append("site.language must be zh-CN") - if site.get("theme") not in {"light", "dark", "auto"}: - errors.append("site.theme must be light, dark, or auto") - - navigation = _mapping(data.get("navigation"), "navigation", errors) - for key in ("menuLabel", "searchLabel", "themeLightLabel", "themeDarkLabel"): - _required_string(navigation.get(key), f"navigation.{key}", errors) - left_links = _links( - navigation.get("leftLinks"), - "navigation.leftLinks", - errors, - minimum=1, - maximum=5, - ) - right_links = _links( - navigation.get("rightLinks"), - "navigation.rightLinks", - errors, - minimum=1, - maximum=5, - ) - menu = _mapping(navigation.get("menu"), "navigation.menu", errors) - for key in ("eyebrow", "title", "text", "ctaLabel"): - _required_string(menu.get(key), f"navigation.menu.{key}", errors) - _href(menu.get("ctaHref"), "navigation.menu.ctaHref", errors) - menu_columns = _array( - menu.get("columns"), - "navigation.menu.columns", - errors, - minimum=1, - maximum=4, - ) - seen_menu: set[str] = set() - menu_links = 0 - for index, raw in enumerate(menu_columns): - item_path = f"navigation.menu.columns[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_menu, errors) - _required_string(item.get("title"), f"{item_path}.title", errors) - menu_links += len( - _links( - item.get("links"), - f"{item_path}.links", - errors, - minimum=1, - maximum=8, - ) - ) - search = _mapping(navigation.get("search"), "navigation.search", errors) - for key in ("title", "placeholder", "emptyMessage", "resultPrefix"): - _required_string(search.get(key), f"navigation.search.{key}", errors) - - hero = _mapping(data.get("hero"), "hero", errors) - if hero.get("visual") != "executive-house": - errors.append("hero.visual must be executive-house") - for key in ( - "kicker", - "title", - "subtitle", - "summary", - "liveActionLabel", - "scrollLabel", - ): - _required_string(hero.get(key), f"hero.{key}", errors) - primary = _mapping(hero.get("primaryAction"), "hero.primaryAction", errors) - _required_string(primary.get("label"), "hero.primaryAction.label", errors) - _href(primary.get("href"), "hero.primaryAction.href", errors) - brief = _mapping(hero.get("brief"), "hero.brief", errors) - for key in ("label", "time", "title", "summary"): - _required_string(brief.get(key), f"hero.brief.{key}", errors) - live_modal = _mapping(hero.get("liveModal"), "hero.liveModal", errors) - for key in ( - "title", - "streamTitle", - "streamSubtitle", - "location", - "captionsOn", - "captionsOff", - ): - _required_string(live_modal.get(key), f"hero.liveModal.{key}", errors) - - notice = _mapping(data.get("notice"), "notice", errors) - for key in ("label", "text", "date"): - _required_string(notice.get(key), f"notice.{key}", errors) - _href(notice.get("href"), "notice.href", errors) - - updates = _mapping(data.get("updates"), "updates", errors) - for key in ("eyebrow", "title", "summary"): - _required_string(updates.get(key), f"updates.{key}", errors) - update_items = _array( - updates.get("items"), "updates.items", errors, minimum=1, maximum=8 - ) - seen_updates: set[str] = set() - for index, raw in enumerate(update_items): - item_path = f"updates.items[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_updates, errors) - for key in ("type", "title", "summary", "date"): - _required_string(item.get(key), f"{item_path}.{key}", errors) - if item.get("visual") not in UPDATE_VISUALS: - errors.append(f"{item_path}.visual must be one of {sorted(UPDATE_VISUALS)}") - - priorities = _mapping(data.get("priorities"), "priorities", errors) - for key in ("eyebrow", "title", "summary", "agendaLabel"): - _required_string(priorities.get(key), f"priorities.{key}", errors) - _href(priorities.get("agendaHref"), "priorities.agendaHref", errors) - priority_items = _array( - priorities.get("items"), - "priorities.items", - errors, - minimum=1, - maximum=6, - ) - seen_priorities: set[str] = set() - for index, raw in enumerate(priority_items): - item_path = f"priorities.items[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_priorities, errors) - for key in ("title", "summary", "detail"): - _required_string(item.get(key), f"{item_path}.{key}", errors) - - administration = _mapping(data.get("administration"), "administration", errors) - if administration.get("visual") != "portrait": - errors.append("administration.visual must be portrait") - for key in ("eyebrow", "title", "quote", "description", "signature"): - _required_string(administration.get(key), f"administration.{key}", errors) - admin_links = _links( - administration.get("links"), - "administration.links", - errors, - minimum=1, - maximum=6, - ) - - calendar = _mapping(data.get("calendar"), "calendar", errors) - for key in ( - "eyebrow", - "title", - "countdownValue", - "countdownLabel", - "panelEyebrow", - "panelTitle", - "panelText", - ): - _required_string(calendar.get(key), f"calendar.{key}", errors) - months = _array( - calendar.get("months"), "calendar.months", errors, minimum=1, maximum=6 - ) - seen_months: set[str] = set() - event_count = 0 - for month_index, raw_month in enumerate(months): - month_path = f"calendar.months[{month_index}]" - month = _mapping(raw_month, month_path, errors) - _unique_id(month, month_path, seen_months, errors) - _required_string(month.get("label"), f"{month_path}.label", errors) - events = _array( - month.get("events"), - f"{month_path}.events", - errors, - minimum=1, - maximum=8, - ) - event_count += len(events) - seen_events: set[str] = set() - for event_index, raw_event in enumerate(events): - event_path = f"{month_path}.events[{event_index}]" - event = _mapping(raw_event, event_path, errors) - _unique_id(event, event_path, seen_events, errors) - for key in ("day", "month", "title", "summary", "tag"): - _required_string(event.get(key), f"{event_path}.{key}", errors) - - participation = _mapping(data.get("participation"), "participation", errors) - for key in ("eyebrow", "title", "summary"): - _required_string(participation.get(key), f"participation.{key}", errors) - cards = _array( - participation.get("cards"), - "participation.cards", - errors, - minimum=1, - maximum=6, - ) - seen_cards: set[str] = set() - for index, raw in enumerate(cards): - item_path = f"participation.cards[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_cards, errors) - for key in ("kicker", "title", "summary", "actionLabel"): - _required_string(item.get(key), f"{item_path}.{key}", errors) - _href(item.get("actionHref"), f"{item_path}.actionHref", errors) - if item.get("visual") not in PARTICIPATION_VISUALS: - errors.append( - f"{item_path}.visual must be one of {sorted(PARTICIPATION_VISUALS)}" - ) - newsletter = _mapping( - participation.get("newsletter"), "participation.newsletter", errors - ) - for key in ("title", "text", "placeholder", "button", "successPrefix"): - _required_string(newsletter.get(key), f"participation.newsletter.{key}", errors) - - footer = _mapping(data.get("footer"), "footer", errors) - _required_string(footer.get("description"), "footer.description", errors) - footer_columns = _array( - footer.get("columns"), "footer.columns", errors, minimum=1, maximum=4 - ) - seen_footer: set[str] = set() - footer_links = 0 - for index, raw in enumerate(footer_columns): - item_path = f"footer.columns[{index}]" - item = _mapping(raw, item_path, errors) - _unique_id(item, item_path, seen_footer, errors) - _required_string(item.get("title"), f"{item_path}.title", errors) - footer_links += len( - _links( - item.get("links"), - f"{item_path}.links", - errors, - minimum=1, - maximum=8, - ) - ) - _required_string(footer.get("copyright"), "footer.copyright", errors) - _required_string(footer.get("legal"), "footer.legal", errors) - - _validate_remote_media_tree(data, "$", errors) - _validate_no_remote_or_markup(data, "$", errors) - _validate_chinese_reader_copy(data, "$", errors) - if errors: - raise DataValidationError("\n".join(f"- {error}" for error in errors)) - return { - "header_links": len(left_links) + len(right_links), - "menu_columns": len(menu_columns), - "menu_links": menu_links, - "updates": len(update_items), - "priorities": len(priority_items), - "administration_links": len(admin_links), - "months": len(months), - "events": event_count, - "participation_cards": len(cards), - "footer_columns": len(footer_columns), - "footer_links": footer_links, - } - - -def render_document(data: dict[str, Any], template: str, runtime: str) -> str: - validate_document(data) - data_text = json.dumps(data, ensure_ascii=False, separators=(",", ":")) - data_text = data_text.replace(" None: - if path.exists(): - raise DataValidationError( - f"refusing to overwrite existing file: {path}; choose a new path" - ) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8", newline="\n") - - -def _emit(payload: dict[str, Any]) -> None: - print(json.dumps(payload, ensure_ascii=False)) - - -def _command_init(output: Path) -> dict[str, Any]: - data = _read_json(DEFAULT_DATA_PATH) - counts = validate_document(data) - _write_new(output, json.dumps(data, ensure_ascii=False, indent=2) + "\n") - return { - "status": "ok", - "action": "init", - "output": str(output), - "modules": list(MODULES), - "counts": counts, - } - - -def _command_validate(input_path: Path) -> dict[str, Any]: - counts = validate_document(_read_json(input_path)) - return { - "status": "ok", - "action": "validate", - "input": str(input_path), - "modules": list(MODULES), - "counts": counts, - } - - -def _command_render(input_path: Path, output: Path) -> dict[str, Any]: - data = _read_json(input_path) - counts = validate_document(data) - template = TEMPLATE_PATH.read_text(encoding="utf-8") - runtime = RUNTIME_PATH.read_text(encoding="utf-8") - _write_new(output, render_document(data, template, runtime)) - return { - "status": "ok", - "action": "render", - "input": str(input_path), - "output": str(output), - "modules": list(MODULES), - "counts": counts, - } - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Initialize, validate, and render White House-style portal JSON." - ) - subparsers = parser.add_subparsers(dest="command", required=True) - init_parser = subparsers.add_parser("init", help="write a fresh default JSON file") - init_parser.add_argument("--output", required=True, type=Path) - validate_parser = subparsers.add_parser("validate", help="validate a JSON file") - validate_parser.add_argument("--input", required=True, type=Path) - render_parser = subparsers.add_parser( - "render", help="render validated JSON into the fixed HTML template" - ) - render_parser.add_argument("--input", required=True, type=Path) - render_parser.add_argument("--output", required=True, type=Path) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _build_parser() - args = parser.parse_args(argv) - try: - if args.command == "init": - payload = _command_init(args.output) - elif args.command == "validate": - payload = _command_validate(args.input) - else: - payload = _command_render(args.input, args.output) - _emit(payload) - return 0 - except (DataValidationError, OSError) as exc: - _emit({"status": "error", "action": args.command, "message": str(exc)}) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/offline-backend-20260512/backend/skills/public/x-page-generator/SKILL.md b/offline-backend-20260512/backend/skills/public/x-page-generator/SKILL.md deleted file mode 100644 index 6f3065c5..00000000 --- a/offline-backend-20260512/backend/skills/public/x-page-generator/SKILL.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: x-page-generator -description: 生成或绘制 X(原 Twitter/推特)及 X 风格的可交付社交时间线 HTML 网页;仅在用户明确要求 X 平台网页、Twitter/推特网页或点名本技能时使用。先把材料或检索结果整理成六模块 JSON,再用内置模板确定性渲染,保留右上角可视化 JSON 编辑弹窗。 -allowed-tools: - - read_file - - write_file - - bash - - present_files - - web_search ---- - -# X 页面生成器 - -本技能使用固定模板生成单文件 X 风格 HTML。模型只负责材料收集和 JSON,禁止重新手写页面结构、CSS 或渲染脚本。最终页面自带右上角设置按钮;用户可在弹窗中按模块继续编辑 JSON,并通过「应用并渲染」即时更新页面。 - -编辑弹窗还提供“上传图片/视频”:先把光标放进目标报道、帖子、视频或带 `visual`/`media` 的内容对象,上传文件会转换为 `data:` 地址并写入该对象的 `media` 字段。不设文件大小拦截;较大文件可能超过浏览器本地缓存容量,应同时下载 JSON 留存。 - -页面中已由 `media` 数据渲染的图片和视频还带悬浮上传图标:鼠标移入媒体后点击可任选图片或视频并直接替换,文件的实际类型会自动写入新的 `media.type`,结果同步写回页面 JSON 与浏览器缓存。头像、Logo 和无法映射回 JSON 的装饰图不会显示该入口;不按原媒体类型限制,也不设置文件大小拦截。 - -默认视觉直接复用模板原有的示例图:不要调用图像生成工具,不要让模型重新生成、拼装或内嵌新的内容型 SVG,也不要为了寻找“标准配图”延长检索。用户没有提供可直接使用的图片/视频且聚焦检索未得到可靠直链时,保留默认 JSON 的 `visual` 或内置视觉类型;这些是模板自带的静态示例,调用技能时不重新绘制。不要把大段 base64 示例图重复写进 JSON,图不合适时由用户在成品页面悬浮上传替换。 - -## 触发边界 - -- 用户明确说「生成/画/制作 X 平台网页」「Twitter/推特网页」「X 风格社交时间线」或点名 `x-page-generator` 时使用。 -- 仅讨论 X/Twitter、分析社交媒体、编写普通推文或出现字母 x 时不要使用。 -- 这是单页社交时间线模板,不改用通用多页门户模板。 - -## 单帖快速路径(强制) - -只要请求是「一个账号 + 一条推文 + N 条热评」,无论是否指定图片、总评论数、热评主题或具体热评文本,都必须走本路径。模型只生成一份小型规格 JSON;脚本负责补齐六模块数据、内部校验并一次渲染 HTML。 - -1. 用 `write_file` 创建 `/mnt/user-data/outputs/x-single-post-spec.json`。推文正文和回复必须使用简体中文;人物姓名、账号 `@handle`、品牌与必要缩写可保留原文。最小规格示例: - - ```json - { - "author": "账号名称", - "handle": "@account", - "verified": true, - "text": "中文推文正文", - "topic": "中心话题", - "tags": ["话题标签"], - "displayReplyCount": 20, - "totalReplyCount": 10000, - "replyTopics": ["中美经济影响", "两岸关系"], - "likes": 0, - "reposts": 0, - "views": 0 - } - ``` - - `displayReplyCount` 是实际预载展示数,最多 100;`totalReplyCount` 是页面指标,可大于展示数。脚本会围绕 `replyTopics` 自动生成不同中文热评。只有用户明确给出逐条热评时才增加 `replies: ["中文热评", ...]`;未写满的部分仍由脚本补齐。媒体直链放入 `media: {"type":"image|video","url":"...","alt":"中文说明"}`;没有可靠直链时省略 `media`,直接沿用模板示例图,禁止搜索或造 SVG。 - -2. 只执行一次: - - ```text - python /mnt/skills/public/x-page-generator/scripts/fast_single_post.py --spec /mnt/user-data/outputs/x-single-post-spec.json --output-dir /mnt/user-data/outputs - ``` - -3. stdout 为 `status: ok` 后,立即用 `present_files` 交付返回的 `data` 与 `html`;最终回复只需一句完成说明。 - -本路径禁止读取默认数据、数据契约、模板、运行时或脚本源码,禁止运行 `--help`、`init`、独立 `validate`/`render`,禁止先生成再补丁,禁止创建临时 Python/清理/路径探测脚本。执行中不要复述需求、讨论路径或逐步解释;除工具调用外最多发一条简短进度。若脚本返回规格错误,只修正规格 JSON 一次并重跑,不得转入源码排查。用户说不搜索或直接生成时,直接把其描述写成中文情景数据,不检索、不填写 `sourceUrl`、不添加情景说明条。只有用户明确要求两条及以上帖子,或明确要求改造趋势/推荐关注等其他模块时,才进入通用工作流。 - -## 通用工作流(仅限非单帖场景) - -1. 读取当前会话材料、上传附件和已有产物,确定页面主题、当前账号、动态、趋势与推荐关注。 -2. 用户要求基于公开资料、主题涉及近期事件,或明确要求检索时,先做至多两批聚焦检索;用户明确说不要检索时不得联网。没有材料时使用中性示例内容,不虚构实时趋势,也不在帖文中添加「情景模拟页面」或「演示内容」说明条。 - - 如果动态被描述成政治人物、公共人物或机构真实发布的内容,必须检索并核对其官方账号原帖或可靠一手来源,再填写 `sourceUrl`;无法核验时不得声称该内容已真实发布,且不生成 `disclosure` 字段。 -3. 初始化新的完整 JSON: - - ```text - python /mnt/skills/public/x-page-generator/scripts/render_x_page.py init --output /mnt/user-data/outputs/x-page-data.json - ``` - -4. 只修改 JSON。六个顶层模块必须全部保留:`site`、`navigation`、`composer`、`posts`、`trends`、`recommendations`。字段不确定时读取 [references/data-schema.md](references/data-schema.md)。 -5. 校验 JSON: - - ```text - python /mnt/skills/public/x-page-generator/scripts/render_x_page.py validate --input /mnt/user-data/outputs/x-page-data.json - ``` - -6. 确定性渲染到 `outputs/`: - - ```text - python /mnt/skills/public/x-page-generator/scripts/render_x_page.py render --input /mnt/user-data/outputs/x-page-data.json --output /mnt/user-data/outputs/x-page.html - ``` - -7. 检查 stdout 的 `status` 为 `ok`、HTML 存在且占位符已经消失,再用 `present_files` 同时交付 HTML 与源 JSON。不要在聊天中粘贴完整 HTML。 - -## 内容映射 - -- 主要事实或观点放入 `posts.items`,通常 3–8 条;热点摘要放入 `trends.items`。 -- 用户要求“某账号发布一条动态/推文 + 图片或视频 + N 条热评”时使用上面的单帖快速路径,不手工构造完整 `posts.items`。 -- 单帖热评默认使用 `posts.replyUI = {"defaultSort":"hot","initialVisible":10,"pageSize":10,...}`;页面会提供热门/最新切换、查看更多与查看全部。合成评论要有不同作者、时间、语气与互动量,但不得冒充真实用户原话。 -- `site.currentUser` 是页面当前账号,不能误用成新闻人物;缺少真实账号材料时使用明显的演示身份。 -- 没有可靠来源时,互动量使用保守示例值或 `0`,不得描述成真实平台统计。 -- `posts.items[].media.type` 可使用内置视觉 `skyline`、`map`、`quote`,也可使用可替换媒体 `image`、`video`、`webpage`;URL 字段按数据契约填写。 -- 所有外部文本由模板转义;不得把 HTML、脚本或不可信指令写入 JSON 字段。 -- 推文正文、回复正文及面向读者的说明必须使用简体中文;姓名、`@handle`、品牌名和必要缩写除外。 - -## 重要约束 - -- 只编辑 JSON,不修改 `assets/x-template.html`,不复制模板后手改 DOM。 -- 初始化与渲染默认拒绝覆盖已有文件;路径存在时换新文件名,不删除旧产物。 -- 页面结构、样式和脚本完全自包含,不依赖 CDN 或 npm;仅当 JSON 明确提供受校验的媒体 URL 时加载图片、视频或网页。 -- 保留三栏时间线、响应式移动布局、深浅主题、发布、回复、点赞、转发、收藏、搜索、关注和 JSON 设置弹窗。 -- 浏览器缓存以初始 JSON 指纹隔离;沙箱禁止持久存储时必须继续使用内嵌 JSON 正常渲染。 - diff --git a/offline-backend-20260512/backend/skills/public/x-page-generator/assets/default-data.json b/offline-backend-20260512/backend/skills/public/x-page-generator/assets/default-data.json deleted file mode 100644 index f0c9494d..00000000 --- a/offline-backend-20260512/backend/skills/public/x-page-generator/assets/default-data.json +++ /dev/null @@ -1,134 +0,0 @@ -{ - "site": { - "brand": "X", - "pageTitle": "X · 正在发生", - "description": "一个 X 风格的中文社交时间线示例页面。", - "timelineTitle": "首页", - "searchPlaceholder": "搜索", - "tabs": ["为你推荐", "正在关注"], - "currentUser": { - "name": "林小满", - "handle": "@linxiaoman", - "initial": "林", - "colors": ["#5662f6", "#9f45d8"] - } - }, - "navigation": { - "primary": [ - {"label": "首页", "icon": "i-home", "href": "#mainFeed"}, - {"label": "探索", "icon": "i-search", "href": "#explore"}, - {"label": "通知", "icon": "i-bell", "href": "#notifications", "badge": 4}, - {"label": "私信", "icon": "i-message", "href": "#messages"}, - {"label": "圈子", "icon": "i-users", "href": "#circle"}, - {"label": "书签", "icon": "i-bookmark", "href": "#bookmarks"}, - {"label": "个人资料", "icon": "i-user", "href": "#profile"}, - {"label": "更多", "icon": "i-more", "href": "#more"} - ], - "postLabel": "发布", - "mobile": [ - {"label": "首页", "icon": "i-home"}, - {"label": "探索", "icon": "i-search"}, - {"label": "通知", "icon": "i-bell"}, - {"label": "私信", "icon": "i-message"}, - {"label": "书签", "icon": "i-bookmark"} - ], - "footerLinks": [ - {"label": "服务条款", "href": "#terms"}, - {"label": "隐私政策", "href": "#privacy"}, - {"label": "Cookie", "href": "#cookies"}, - {"label": "无障碍", "href": "#accessibility"} - ], - "copyright": "© 2026 X" - }, - "composer": { - "audience": "所有人", - "placeholder": "有什么新鲜事?", - "replyPermission": "所有人都可以回复", - "draftsLabel": "草稿", - "maxLength": 280 - }, - "posts": { - "replyUI": { - "defaultSort": "hot", - "initialVisible": 10, - "pageSize": 10, - "hotLabel": "热门", - "latestLabel": "最新", - "showMoreLabel": "查看更多", - "showAllLabel": "查看全部" - }, - "items": [ - { - "id": "city-sky", - "author": "许知夏", - "handle": "@summerxu", - "initial": "许", - "colors": ["#15a88b", "#2e88cc"], - "verified": true, - "time": "32 分钟", - "text": "今天的天空像一封刚拆开的蓝色情书。", - "tags": ["城市漫步"], - "media": {"type": "skyline", "caption": "南浦大桥 · 06:42", "alt": "蓝色天空与城市大桥的抽象插画"}, - "metrics": {"replies": 18, "reposts": 34, "likes": 276, "views": 84000} - }, - { - "id": "design-restraint", - "author": "陈默", - "handle": "@modesign", - "initial": "陈", - "colors": ["#0f7d78", "#51b35e"], - "verified": false, - "time": "1 小时", - "text": "设计并不是不断加东西。更多时候,是在所有“还可以再做一点”的时刻,知道什么时候停下。", - "tags": [], - "media": {"type": "quote", "author": "周野", "handle": "@zhouye", "initial": "周", "colors": ["#f97316", "#db2777"], "time": "昨天", "text": "复杂很容易,克制才需要勇气。"}, - "metrics": {"replies": 12, "reposts": 91, "likes": 538, "views": 120000} - }, - { - "id": "bookstore-map", - "author": "光合计划", - "handle": "@photosynthesis", - "initial": "光", - "colors": ["#ff8a37", "#d93f7b"], - "verified": true, - "time": "3 小时", - "text": "我们把城市里 12 家小书店画成了一张散步地图。这个周末,选一条从没走过的街,去遇见一本意料之外的书吧。", - "tags": ["独立书店", "城市阅读"], - "media": {"type": "map", "caption": "独立书店散步地图", "alt": "城市书店散步地图抽象插画"}, - "metrics": {"replies": 46, "reposts": 182, "likes": 902, "views": 240000} - }, - { - "id": "commute-playlist", - "author": "宋可心", - "handle": "@kexin", - "initial": "宋", - "colors": ["#8b5cf6", "#3b82f6"], - "verified": false, - "time": "5 小时", - "text": "下班路上的随机播放,总会精准地挑中你今天最需要听到的那一句。你们最近单曲循环的是哪首?", - "tags": ["通勤歌单"], - "metrics": {"replies": 68, "reposts": 21, "likes": 309, "views": 56000} - } - ] - }, - "trends": { - "title": "正在发生", - "showMoreLabel": "显示更多", - "items": [ - {"category": "中国 · 热门", "title": "#秋日城市漫步", "count": "2.8 万条动态", "href": "#trend1"}, - {"category": "科技 · 热门", "title": "AI 产品设计", "count": "1.7 万条动态", "href": "#trend2"}, - {"category": "文化 · 热门", "title": "独立书店地图", "count": "8,942 条动态", "href": "#trend3"}, - {"category": "音乐 · 趋势", "title": "通勤歌单", "count": "5,610 条动态", "href": "#trend4"} - ] - }, - "recommendations": { - "title": "推荐关注", - "showMoreLabel": "显示更多", - "items": [ - {"name": "周野", "handle": "@zhouye", "initial": "周", "colors": ["#f97316", "#db2777"]}, - {"name": "顾念", "handle": "@gunian", "initial": "顾", "colors": ["#0ea5e9", "#10b981"]}, - {"name": "理想生活研究所", "handle": "@betterlife", "initial": "理", "colors": ["#6366f1", "#ec4899"]} - ] - } -} - diff --git a/offline-backend-20260512/backend/skills/public/x-page-generator/assets/x-runtime.js b/offline-backend-20260512/backend/skills/public/x-page-generator/assets/x-runtime.js deleted file mode 100644 index deeeefa2..00000000 --- a/offline-backend-20260512/backend/skills/public/x-page-generator/assets/x-runtime.js +++ /dev/null @@ -1,855 +0,0 @@ -(() => { - "use strict"; - - const qs = (selector, root = document) => root.querySelector(selector); - const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); - const clone = (value) => JSON.parse(JSON.stringify(value)); - const MODULES = ["site", "navigation", "composer", "posts", "trends", "recommendations"]; - const MODULE_META = { - site: ["站点信息", "品牌、标题与当前账号"], - navigation: ["导航", "主导航、移动导航与页脚"], - composer: ["发布框", "占位语与字数限制"], - posts: ["动态", "时间线内容与互动量"], - trends: ["趋势", "右栏热门话题"], - recommendations: ["推荐关注", "右栏推荐账号"] - }; - const ALLOWED_ICONS = new Set(["i-home", "i-search", "i-bell", "i-message", "i-users", "i-bookmark", "i-user", "i-more"]); - const embeddedData = JSON.parse(qs("#xPageData").textContent); - const initialData = clone(embeddedData); - - const fingerprint = (text) => { - let hash = 2166136261; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0).toString(36); - }; - const pageFingerprint = fingerprint(JSON.stringify(initialData)); - const storageKey = "x-page-data-v1:" + pageFingerprint; - const themeKey = "x-page-theme-v1:" + pageFingerprint; - - const readStorage = (key) => { - try { - return window.localStorage ? window.localStorage.getItem(key) : null; - } catch (_error) { - return null; - } - }; - const writeStorage = (key, value) => { - try { - if (window.localStorage) window.localStorage.setItem(key, value); - return true; - } catch (_error) { - return false; - } - }; - const removeStorage = (key) => { - try { - if (window.localStorage) window.localStorage.removeItem(key); - } catch (_error) { - /* Sandboxed previews may intentionally deny storage. */ - } - }; - const isCompleteData = (value) => Boolean(value && MODULES.every((name) => value[name] && typeof value[name] === "object")); - const cachedData = readStorage(storageKey); - let pageData = initialData; - if (cachedData) { - try { - const parsed = JSON.parse(cachedData); - if (isCompleteData(parsed)) pageData = parsed; - } catch (_error) { - pageData = initialData; - } - } - - const escapeHTML = (value) => String(value == null ? "" : value).replace(/[&<>'"]/g, (character) => ({ - "&": "&", "<": "<", ">": ">", "'": "'", '"': """ - })[character]); - const safeHref = (value) => { - const href = String(value || "#"); - return /^(#|https?:\/\/)/i.test(href) ? escapeHTML(href) : "#"; - }; - const safeRemoteUrl = (value, kind = "link") => { - const source = String(value || "").trim(); - if (!source) return ""; - const lowered = source.toLowerCase(); - if (kind === "image" && lowered.startsWith("data:image/")) return source; - if (kind === "video" && lowered.startsWith("data:video/")) return source; - try { - const url = new URL(source); - return url.protocol === "http:" || url.protocol === "https:" ? url.href : ""; - } catch (_error) { return ""; } - }; - const validateRemoteMediaTree = (value, path = "当前模块") => { - if (Array.isArray(value)) return value.forEach((item, index) => validateRemoteMediaTree(item, path + "[" + index + "]")); - if (!value || typeof value !== "object") return; - const media = value.media; - if (media && ["image", "video", "webpage"].includes(media.type)) { - if ((media.type === "image" || media.type === "video") && !safeRemoteUrl(media.mediaUrl, media.type)) throw new Error(path + ".media.mediaUrl 无效"); - if (media.type === "webpage" && !safeRemoteUrl(media.pageUrl)) throw new Error(path + ".media.pageUrl 无效"); - if (media.posterUrl && !safeRemoteUrl(media.posterUrl, "image")) throw new Error(path + ".media.posterUrl 无效"); - } - Object.entries(value).forEach(([key, item]) => { if (key !== "media") validateRemoteMediaTree(item, path + "." + key); }); - }; - const safeColor = (value, fallback) => /^#[0-9a-f]{3,8}$/i.test(String(value || "")) ? value : fallback; - const safeIcon = (value) => ALLOWED_ICONS.has(value) ? value : "i-more"; - const icon = (id, className) => '