alipay-h5-program-generator 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-skill.json +10 -0
- package/LICENSE +15 -0
- package/README.md +94 -2
- package/SKILL.md +54 -0
- package/agents/openai.yaml +3 -0
- package/assets/frontend-template/.eslintrc.cjs +9 -0
- package/assets/frontend-template/.prettierrc +1 -0
- package/assets/frontend-template/components/AlipayGuard.tsx +27 -0
- package/assets/frontend-template/components/Detail.client.tsx +22 -0
- package/assets/frontend-template/components/example-detail.page.tsx +22 -0
- package/assets/frontend-template/container.ts +84 -0
- package/assets/frontend-template/test/AlipayGuard.test.tsx +16 -0
- package/assets/frontend-template/vitest.config.ts +7 -0
- package/package.json +54 -4
- package/references/architecture.md +23 -0
- package/references/coding-standards.md +51 -0
- package/references/container.md +32 -0
- package/references/cross-page-comm.md +17 -0
- package/references/env-degradation.md +32 -0
- package/references/pitfalls.md +34 -0
- package/references/release-perf.md +35 -0
- package/references/release.md +20 -0
- package/references/requirements.md +28 -0
- package/references/responsive-cross-end.md +44 -0
- package/references/ssr-seo-sharing.md +29 -0
- package/references/tech-selection.md +27 -0
- package/references/testing-acceptance.md +45 -0
- package/references/ux-interaction.md +42 -0
- package/scripts/check-build.mjs +61 -0
- package/scripts/check-env.mjs +34 -0
- package/scripts/check-project.mjs +72 -0
- package/scripts/check-seo.mjs +73 -0
- package/scripts/cli.mjs +106 -0
- package/scripts/install-skill.mjs +28 -0
- package/scripts/lib/skill-registrar.mjs +63 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# 易踩坑(AI 常见误判,勿重复)
|
|
2
|
+
|
|
3
|
+
本领域 AI(含本 skill 早期草稿)出现过/易出现的误判与校正依据。证据均来自公开 opendocs.alipay.com。
|
|
4
|
+
|
|
5
|
+
## 1. 把 AlipayJSBridge 容器 JSAPI 当"第三方 H5 可选/待验证"留着
|
|
6
|
+
- 误判:以为 `pushWindow`/`popWindow`/`setTitle`/`setShareContent` 在支付宝 WebView 里"可能可用,先写成 opt-in 占位留待验证"。
|
|
7
|
+
- 真相:这些是**小程序容器**能力(私有栈),**第三方纯 H5 拿不到**。opendocs 全站搜 `pushWindow`/`setShareContent` 均 **0 命中**;`AlipayJSBridge` 命中均在 `/mini/*`。
|
|
8
|
+
- 做法:**不写、不留 opt-in 占位**。多页用标准 `location`/history;标题用 `<title>`;分享用 OG + scheme。
|
|
9
|
+
- 注:少数受限合作场景(如发票/抬头)有特例 `AlipayJSBridge.call` JSAPI,不属本 skill 适用范围——也不据此引入 pushWindow/setTitle/setShareContent 等通用容器 JSAPI。
|
|
10
|
+
|
|
11
|
+
## 2. 混淆 `my.*`(小程序)与 H5 能力
|
|
12
|
+
- 误判:用户贴 `opendocs.alipay.com/mini/api` 链接就被带偏,以为 H5 能用 `my.*`。
|
|
13
|
+
- 真相:`my.*`(`my.share`/`my.navigateTo`/`my.tradePay`/`my.getAuthCode`…)只在**真·小程序**内;第三方纯 H5 没有。H5 等价:支付走服务端 `alipay.trade.wap.pay`,授权走 `publicAppAuthorize`(可选 `ap.getAuthCode`)。
|
|
14
|
+
- 做法:H5 一律用服务端接口 + 可选 `ap.*` JSSDK;要 `my.*` 体验 → 那是小程序,不在本 skill。
|
|
15
|
+
|
|
16
|
+
## 3. `AlipayJSBridge` 存在 ≠ 其 JSAPI 可调
|
|
17
|
+
- 误判:检测到 `window.AlipayJSBridge` 就尝试 `AlipayJSBridge.call('pushWindow'…)`。
|
|
18
|
+
- 真相:第三方 H5 里该对象可能存在,但其导航/标题/分享接口对第三方不可调用("存在不等于可用")。
|
|
19
|
+
- 做法:只用其**对象存在性**做环境判定(`isAlipayClient`),绝不调用其容器 JSAPI。
|
|
20
|
+
|
|
21
|
+
## 4. 服务端按 UA 给爬虫/用户出不同内容 = cloaking
|
|
22
|
+
- 误判:服务端判"是不是 Googlebot / 是不是支付宝内",据此减内容或给不同可见内容/head。
|
|
23
|
+
- 真相:被搜索引擎判作弊(cloaking)。
|
|
24
|
+
- 做法:公开 HTML 对爬虫与用户**完全一致**;"缺失"的只是"本就需要登录、hydrate 才追加"的部分。需服务端判定用 `isAlipayClientSSR`,仅做极轻分流(如不预取登录态),不改可见内容/head。
|
|
25
|
+
|
|
26
|
+
## 5. 猜 SDK / npm 包名
|
|
27
|
+
- 误判:臆造 `@alipay/jssdk` 之类写进 import。
|
|
28
|
+
- 真相:实测 `@alipay/jssdk` 在 npm **404**(多个猜测名均 404)。公开 H5 客户端 JSSDK 来源/包名以 **open/08xgr7** 为准,且**默认无需前端 SDK**(服务端跳转/scheme 即可);服务端集成用公开 npm 包 `alipay-sdk`。
|
|
29
|
+
- 做法:前端默认零 SDK;确需 `ap.*` 时按官方文档核对来源再引入,绝不臆造包名。
|
|
30
|
+
|
|
31
|
+
## 6. 把"离线包/客户端开关"当纯 H5 能力
|
|
32
|
+
- 误判:以为纯 H5 也能离线包秒开、客户端开关灰度。
|
|
33
|
+
- 真相:离线包/客户端开关是**小程序/私有**基建,第三方纯 H5 无。
|
|
34
|
+
- 做法:秒开 = SSR/SSG + CDN + HTTP 缓存 + 图片优化;灰度 = CDN 权重 + feature flag。别在 H5 路径期望离线包。
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# 发布与性能(公开方案,不依赖私有基建)
|
|
2
|
+
|
|
3
|
+
没有离线包/客户端开关/企业监控/真机调试平台。等价公开件:
|
|
4
|
+
|
|
5
|
+
## 性能 / 秒开(取代离线包)
|
|
6
|
+
- SSR/SSG 首屏真实 HTML + 关键 CSS 内联 + JS `defer`/异步 + `preload`。
|
|
7
|
+
- 路由级 code split;图片 WebP/AVIF + 懒加载 + 按视口尺寸;字体子集化。
|
|
8
|
+
- 动画用 transform/opacity。
|
|
9
|
+
- **Service Worker(默认不依赖 → 真机验证后决定)**:SW 在支付宝 WKWebView(iOS)/Android WebView 是否生效**无官方文档**,默认只用 SSR/SSG + CDN + HTTP 缓存。真机验证后再决定是否作离线壳,步骤:
|
|
10
|
+
1) 跑 `scripts/check-env.mjs` 的 `serviceWorker in navigator` 探针;
|
|
11
|
+
2) 实际 `navigator.serviceWorker.register('/sw.js')` 后**断网刷新**验证缓存命中;
|
|
12
|
+
3) iOS(WKWebView) 与 Android **两端均生效** 才作为可选离线壳预缓存静态资源;任一不生效/未验证 → 不引入。
|
|
13
|
+
|
|
14
|
+
## 编码侧 / 运行时性能(交互态与水合后)
|
|
15
|
+
- **首屏体量给预算**:首屏 JS / 关键 CSS 设上限,CI 跑 bundle 分析对红线,超标即阻断。
|
|
16
|
+
- **React 渲染**:长列表虚拟化(react-window 等)或服务端分页;列表 `key` 用稳定业务 id(别用 index);重计算用 `useMemo`/`useCallback`,纯展示组件 `React.memo`,避免无谓 re-render 蔓延。
|
|
17
|
+
- **状态热路径**:别在 render 或 input `onChange` 里做重计算/深拷贝;大数据勿全量入 state;计算下沉到 `useMemo` 或事件外。
|
|
18
|
+
- **CLS 预防**:图片/iframe/广告位预留尺寸盒(`width`/`height` 或 `aspect-ratio`);字体 `font-display` + 回退,避免文字回弹与布局抖动。
|
|
19
|
+
- **INP / 交互**:拆分长任务(`requestIdleCallback`/`scheduler`/零宽 `setTimeout`);首屏外重组件 code split & `lazy`;交互回调里的重活延后做。
|
|
20
|
+
- **防布局抖动**:不在循环里读写 `offsetWidth`;批量改 className;动画只用 `transform`/`opacity`。
|
|
21
|
+
- **资源 / HTTP**:首屏关键资源 `preconnect`/`preload`;三方/统计 `defer` 或闲置加载;接口合并、并行请求避免瀑布;列表分页且首屏只取必要字段。
|
|
22
|
+
- **水合后**:避免同步大批量 DOM 写与重型 effect,先 paint 再消费剩余工作。
|
|
23
|
+
|
|
24
|
+
## 灰度 / 发布
|
|
25
|
+
- CDN 按 URL/权重灰度 + CI/CD(<分支名/环境> 部署到不同 CDN 路径/域名)。
|
|
26
|
+
- Feature Flag(自建或第三方)控制灰度人群。
|
|
27
|
+
- 版本号 + 静态资源长缓存 + HTML 短缓存/回源;出问题分钟级回滚。
|
|
28
|
+
|
|
29
|
+
## 监控 / 打点
|
|
30
|
+
- 错误:Sentry(或同类)。
|
|
31
|
+
- 性能:RUM 采集 Core Web Vitals(LCP/INP/CLS)。
|
|
32
|
+
- 业务打点:自建或第三方(如神策等)。
|
|
33
|
+
|
|
34
|
+
## 安全
|
|
35
|
+
- 全站 HTTPS + HSTS;输出转义 + CSP(防 XSS);接口防 CSRF;敏感信息不入前端代码与 source map。
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# 发布与发布前门(G5)
|
|
2
|
+
|
|
3
|
+
性能/秒开、灰度策略、监控、安全 等执行细节见 references/release-perf.md;本页只管「发布流程与发布前门」(G5 出口)。
|
|
4
|
+
|
|
5
|
+
## 发布前门(过完才能发)
|
|
6
|
+
1. 护栏脚本全绿(rc 0):`check-env` / `check-project` / `check-seo` / `check-build`。
|
|
7
|
+
2. 测试与验收(见 testing-acceptance.md §1–9)全过。
|
|
8
|
+
3. 服务端密钥不入前端:私钥只在服务端 env;前端 bundle 与 source map 不含密钥串(`check-build.mjs` 兜底)。
|
|
9
|
+
4. 灰度计划:CDN 按 URL/权重逐量;量级 → 观察指标(LCP/INP/CLS、错误率) → 放量/回滚阈值。
|
|
10
|
+
5. 监控接入:错误(Sentry/同类) + 性能 RUM(CWV) + 业务打点;告警阈值与负责盯盘人。
|
|
11
|
+
6. 回滚演练:知道如何分钟级回滚(版本号 + 静态资源长缓存 + HTML 短缓存,切版本即可)。
|
|
12
|
+
|
|
13
|
+
## 发布流程
|
|
14
|
+
1) `next build` → 产物;跑 `check-build.mjs`(产物首屏可抓内容、secret 不入包、 bundle 体积)。
|
|
15
|
+
2) 部署到 CDN(按分支/环境到不同路径/域名);HTML 短缓存回源、静态资产长缓存。
|
|
16
|
+
3) 按 CDN 权重灰度;观察 RUM / Sentry;超阈值即回滚切版本。
|
|
17
|
+
4) 发布后核对:sitemap/robots 可达、OG 调试器卡片、支付 e2e、降级屏真机。
|
|
18
|
+
|
|
19
|
+
## 与能力边界的关系
|
|
20
|
+
- 无离线包 / 客户端开关 / 原生灰度;发布、灰度、监控、安全全走公开件(CDN / feature flag / Sentry / RUM),勿臆造私有基建(见 pitfalls.md / release-perf.md)。
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# 需求理解与细化(G1:开工前必做)
|
|
2
|
+
|
|
3
|
+
第三方纯 H5 的成败在「需求能否落到 URL 与数据契约」。本 skill 工作流第一步不是写代码,是先把需求问清、拆成 A/B/C 页与接口、确认收录/分享/支付/授权边界。下面是给需求方的问题清单 + 给开发者的细化产物。
|
|
4
|
+
|
|
5
|
+
## 1. Intake 问题清单(逐条问清,落到下表)
|
|
6
|
+
- 受众:公网访客(被搜索引擎带来) / 支付宝内用户 / 两者?主要设备,是否需桌面可达?
|
|
7
|
+
- 收录:哪些内容让搜索引擎抓、目标关键词/主题、是否要结构化数据(Article/Product/ItemList)?
|
|
8
|
+
- 内容来源:CMS / 接口 / 静态?「公开内容(无登录)」与「登录态内容」的边界在哪?
|
|
9
|
+
- 分享:外部(微信/QQ/浏览器粘贴)卡片标题/图/描述;App 内分享是 URL 还是自定义卡片?(第三方纯 H5 拿不到自定义分享 → 用 URL)
|
|
10
|
+
- 支付:要支付吗?走手机网站支付(服务端 wap.pay 跳转) → 需 AppId/密钥配置;下单与结果回跳 URL?
|
|
11
|
+
- 授权:要拿支付宝用户身份吗?默认 publicAppAuthorize(跳转)即可;是否要半屏 ap.getAuthCode(不离开页面,可选)?
|
|
12
|
+
- 页面分类:哪些是公开页(A 类,可索引)、哪些是 App 内功能页(B 类,noindex)、哪些是引导去支付宝(C 类)?
|
|
13
|
+
- 数据契约:每屏公开内容字段(无登录可拼) 与 登录态/功能字段(hydrate 后拉) 分别是什么?接口在哪、是否 mock?
|
|
14
|
+
- 非功能:是否多语言?无障碍(a11y)是否硬指标(见 coding-standards.md / testing-acceptance.md)?首屏预算(LCP/CLS/INP)?
|
|
15
|
+
|
|
16
|
+
## 2. 细化产物(填表,作为 G2 设计输入)
|
|
17
|
+
| 屏(路径) | 分类 A/B/C | 渲染 SSG/SSR/CSR | 公开内容字段(无登录) | 登录态/功能字段 | 收录/分享卡片 | 支付 | 授权 |
|
|
18
|
+
|---|---|---|---|---|---|---|---|
|
|
19
|
+
| / | A | SSG | 首页聚合内容 | 个人化推荐/登录态 | title/desc/og | ✗ | ✗ |
|
|
20
|
+
| /detail/:id | A | SSG+ISR | 商品标题/摘要/图 | 登录态/购买/分享 | og:image | ✓(可选) | ✓(可选) |
|
|
21
|
+
| /publish , /order , /result/:tradeNo | B | CSR noindex | — | 全功能 | 不进 sitemap | ✓ | ✓ |
|
|
22
|
+
|
|
23
|
+
## 3. 从需求到结构的落地步骤
|
|
24
|
+
1) 把每屏归到 A/B/C(见 architecture.md)。
|
|
25
|
+
2) 填「公开内容字段」——决定 SSR 能否无登录拼出可抓 HTML(反 cloaking,见 pitfalls.md §4)。
|
|
26
|
+
3) 给容器层列出每屏需要的公开能力:支付/授权/唤起/降级(见 container.md)。
|
|
27
|
+
4) 出接口清单 + mock 约定(见 tech-selection.md / coding-standards.md)。
|
|
28
|
+
5) 在 testing-acceptance.md 取得对应验收条目,作为 G4 出口。
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# 响应式与跨端适配
|
|
2
|
+
|
|
3
|
+
主战场是手机:支付宝内(iOS WKWebView / Android WebView)+ 外部手机浏览器;同一公网 URL 也会被桌面/平板浏览器打开(A 类公开页 SSG 后应能正常展示)。口径「移动端优先,桌面不死板」。以下均为公开 Web 标准,与支付宝能力边界无关(不引入任何私有 JSAPI)。
|
|
4
|
+
|
|
5
|
+
## viewport
|
|
6
|
+
- `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`。
|
|
7
|
+
- `viewport-fit=cover` 才能拿到安全区;配合 CSS `env(safe-area-inset-*)` 给刘海/底部 Home 指示条留白。
|
|
8
|
+
- 是否禁缩放(`maximum-scale=1, user-scalable=no`)按业务权衡:禁缩放防误触双击放大,但牺牲可访问性;交互密集表单页倾向禁缩放,内容浏览页倾向允许。
|
|
9
|
+
|
|
10
|
+
## 尺寸单位 / rem 适配(antd-mobile 官方方案)
|
|
11
|
+
- antd-mobile v5 用 px;跨屏等比用 antd-mobile 官方「rem 适配」方案(`postcss-pxtorem` 把 px 转 rem,按视口宽度设 root `font-size`),默认基准 375px 设计稿。勿用 `rpx`(小程序专用,H5 无效)。
|
|
12
|
+
- 图标用 antd-mobile 自带或 SVG,随设计稿等比放大,避免位图糊。
|
|
13
|
+
- 文字最小可读约 14px(设计稿)。
|
|
14
|
+
|
|
15
|
+
## 高 DPI / 图片
|
|
16
|
+
- 多倍图按 DPR 选源(`1x/2x/3x` 或 CDN 裁切参数);用 `<img srcset>` / 背景图媒体查询。优先 WebP/AVIF(与发布侧一致,见 release-perf.md)。
|
|
17
|
+
- 1px 边框用 `transform: scale(.5)` 或 `0.5px`,别物理 1px(移动端偏粗)。
|
|
18
|
+
|
|
19
|
+
## 安全区 / 固定栏
|
|
20
|
+
- 顶部贴 `env(safe-area-inset-top)`,底部贴 `env(safe-area-inset-bottom)` 给 Home 手势条/导航留位;横屏同样留左右安全区。
|
|
21
|
+
- 固定吸底栏(支付按钮 / 底导航)加 `padding-bottom: calc(env(safe-area-inset-bottom) + Npx)`,避免被手势条遮挡。
|
|
22
|
+
|
|
23
|
+
## 布局
|
|
24
|
+
- 移动优先 CSS:默认竖屏样式 + `@media (min-width:768px)` 增强;Flex/Grid + `gap`;宽度用 `vw`/`%` 自适应,避免写死 `px` 宽。
|
|
25
|
+
- 桌面收敛:内容区 `max-width`(如 480–600px)居中,避免桌面铺满拉扯;A 类公开页桌面可正常展示公开内容。
|
|
26
|
+
- 长文本/长表:`overflow-wrap`/`word-break`;弹性表格横向滚动。
|
|
27
|
+
|
|
28
|
+
## 触摸与点击
|
|
29
|
+
- 可点击元素最小热区 44×44px(Apple HIG);间距足够防误触。
|
|
30
|
+
- 列表项整行可点与右侧操作区分离,避免点穿(用 `<button>`/可点击元素天然边界)。
|
|
31
|
+
- 现代浏览器在含 `width=device-width` 的 viewport 下已自动消除 300ms 延迟,别再加第三方 fastclick。
|
|
32
|
+
- 限手势按需设 `touch-action`(如轮播设 `pan-x`)。
|
|
33
|
+
|
|
34
|
+
## 输入与键盘
|
|
35
|
+
- 输入框 `font-size ≥ 16px` 防 iOS 聚焦自动放大;弹起键盘时聚焦元素 `scrollIntoView({block:'center'})` 防遮挡。
|
|
36
|
+
- 表单用 antd-mobile `Form`;长表单分组 + 顶部进度。
|
|
37
|
+
- 用对 `type`/`inputmode`(`number`/`tel`/`decimal`)调出对应键盘。
|
|
38
|
+
|
|
39
|
+
## 横竖屏与方向
|
|
40
|
+
- 横屏不崩:布局用 Flex/Grid + 百分比,别按竖屏写死;必要时 `orientation: landscape` 媒体查询调整。
|
|
41
|
+
- 纯 H5 无法强制旋转系统方向;需竖屏的功能(扫码/支付确认页)以文案引导用户竖屏使用即可。
|
|
42
|
+
|
|
43
|
+
## 跨端探针
|
|
44
|
+
- 开工前跑 scripts/check-env.mjs(含 viewport meta 探针),在 iOS 模拟器、Android 模拟器、外部手机浏览器各核一遍;底导航/安全区/键盘遮挡、横竖屏逐一过。
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SSR / SEO / 分享
|
|
2
|
+
|
|
3
|
+
## 渲染选型
|
|
4
|
+
- 内容多静态(详情/列表/落地) → **SSG**(构建期) 或 **SSG+ISR**(按 id 重生成)。SEO 最佳、CDN 部署。
|
|
5
|
+
- 内容按请求动态(首页推荐、个人化强) → **SSR**。
|
|
6
|
+
- 功能页(发布/订单) → CSR + `noindex`。
|
|
7
|
+
- AntD Mobile v5 SSR 兼容(官方有 Next.js 示例);App Router 里作 `'use client'` 组件,SSR 仍渲染首屏 HTML。注意模块顶层别用 `window`,防 hydration mismatch。
|
|
8
|
+
|
|
9
|
+
## 公开可索引页的 head(必须进 SSR HTML,不要 JS 动态塞)
|
|
10
|
+
```html
|
|
11
|
+
<title>{内容标题} - {站点名}</title>
|
|
12
|
+
<meta name="description" content="{摘要}" />
|
|
13
|
+
<link rel="canonical" href="https://m.example.com/detail/123" />
|
|
14
|
+
<meta property="og:type" content="article" />
|
|
15
|
+
<meta property="og:url" content="https://m.example.com/detail/123" />
|
|
16
|
+
<meta property="og:title" content="{内容标题}" />
|
|
17
|
+
<meta property="og:description" content="{摘要}" />
|
|
18
|
+
<meta property="og:image" content="https://m.example.com/covers/123.jpg" />
|
|
19
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
20
|
+
<script type="application/ld+json">{ ... Article/Product/ItemList ... }</script>
|
|
21
|
+
```
|
|
22
|
+
- `canonical` = `og:url` = 分享 url = 公网 URL,**四处一致**。
|
|
23
|
+
- 可抓内容须**无登录**可拼出;登录态内容 hydrate 后追加。反 cloaking(见 references/pitfalls.md §4):爬虫与用户拿同一 HTML。
|
|
24
|
+
- sitemap.xml 只含 A 类 URL(含分页),每页各自 canonical;robots.txt 双保险屏蔽 B 类。
|
|
25
|
+
|
|
26
|
+
## 分享(双路径)
|
|
27
|
+
- **外部分享**(微信/QQ/浏览器粘贴):靠 `og:*`(上面已进 HTML)。
|
|
28
|
+
- **App 内分享**:第三方 H5 拿不到 `setShareContent`;默认就是分享当前 URL,用户点开后经 scheme/landing 唤起进支付宝(见 env-degradation.md)。App 内自定义卡片属小程序能力,不在本路径。
|
|
29
|
+
- 标题统一用 HTML `<title>`(没有原生 setTitle)。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# 技术选型(G2)
|
|
2
|
+
|
|
3
|
+
栈是固定的:React + TypeScript + AntD Mobile v5 + Next(App Router,SSR/SSG);服务端用公开 `alipay-sdk`。本页只给「固定栈内的选择」与「何时偏离」的决策依据,选择必须落到每页、不靠默认。
|
|
4
|
+
|
|
5
|
+
## 渲染选型决策树(逐页选)
|
|
6
|
+
- 内容静态/低频变更(详情/列表/落地)→ **SSG** 或 **SSG+ISR**(按 id 重生成);SEO 最优、CDN 直发。
|
|
7
|
+
- 内容按请求动态/强个人化(首页推荐)→ **SSR**。
|
|
8
|
+
- 功能页(发布/订单/结果)→ **CSR + `noindex`**;浏览器给降级屏(AlipayGuard,见 env-degradation.md)。
|
|
9
|
+
- 判别原则:要被收录/被分享 → 必有独立公网 URL + 可抓首屏 HTML → SSG/SSR;纯属 App 内操作 → CSR + noindex。
|
|
10
|
+
|
|
11
|
+
## 能力选型(固定,勿偏离)
|
|
12
|
+
- 支付:手机网站支付 `alipay.trade.wap.pay`,服务端建跳转 URL → 前端 `location.assign`(见 container.md)。
|
|
13
|
+
- 授权:默认 `publicAppAuthorize` 服务端跳转;仅当「不离开页面半屏授权」确有体验需求,才上可选 `ap.getAuthCode`(公开来源 `/open/01zsdu`、`/mini/02xtl8`,默认不引入)。
|
|
14
|
+
- 浏览器→App 唤起:alipays scheme + render landing(非 alipay.com 域名需开放平台配白名单)。
|
|
15
|
+
|
|
16
|
+
## 缓存 / CDN
|
|
17
|
+
- HTML 短缓存回源、静态资产哈希文件名长缓存;按 URL/权重灰度;动静拆分(公开内容静态化、登录态动态拉)。细节见 release-perf.md。
|
|
18
|
+
|
|
19
|
+
## 监控
|
|
20
|
+
- 错误:Sentry(或同类);性能:RUM 采集 LCP/INP/CLS;业务打点:自建/第三方(无私有监控基建)。接入见 release-perf.md / release.md。
|
|
21
|
+
|
|
22
|
+
## 接口 / Mock
|
|
23
|
+
- 接口层统一在 `lib/api`(见 coding-standards.md);开发期 mock 优先 MSW(拦截 fetch)或 Next 路由 handler 兜底;SSR 期别让 mock 抛错致整页降级。
|
|
24
|
+
|
|
25
|
+
## 何时偏离(写明理由记档)
|
|
26
|
+
- 需要原生小程序能力(`my.*` / 多 webview / 自定义分享 / 离线包)→ 那不是本 skill,改做小程序。
|
|
27
|
+
- 某公开能力但本 skill 结论为「不可用」→ 先核对 opendocs,勿臆造;确属受限合作场景另议,不据此引入通用容器 JSAPI(见 pitfalls.md)。
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# 测试与验收(G4:上生产前必过)
|
|
2
|
+
|
|
3
|
+
确定性护栏脚本(rc 0/1,非测试框架):`check-env`(环境能力)、`check-project`(禁用 API 静态扫)、`check-seo`(收录一致性)、`check-build`(secret/产物)。脚本之外的功能与体验验收如下。
|
|
4
|
+
|
|
5
|
+
## 1. 单元 / 组件测试
|
|
6
|
+
- 选型:Vitest + @testing-library/react + jsdom;配置见 assets/frontend-template/vitest.config.ts。
|
|
7
|
+
- client 组件(如 `AlipayGuard`)测分支:浏览器分支显示降级屏、子内容不可见;示例见 assets/frontend-template/test/AlipayGuard.test.tsx。
|
|
8
|
+
- server 组件(page)不直接渲染网络:对取数/`generateMetadata` 返回结构断言,或把逻辑提纯到 `lib` 再测。
|
|
9
|
+
- `lib/api` 用 mock 单测;`pay-server`/`auth-server` 测 URL 构建且签名/密钥不泄露。
|
|
10
|
+
|
|
11
|
+
## 2. 接口 / 契约 Mock
|
|
12
|
+
- 优先 MSW 拦截 fetch(开发与测试同源);或 Next 路由 handler 兜底 mock。
|
|
13
|
+
- 公开内容 mock 须无登录可拼 → 顺带验证反 cloaking。
|
|
14
|
+
|
|
15
|
+
## 3. SEO 收录验收(必过,由 check-seo.mjs 兜底)
|
|
16
|
+
- `canonical` == `og:url` == 分享 URL == 公网 URL,四处一致。
|
|
17
|
+
- 每个可索引页 SSR HTML 含可抓首屏内容(无登录可拼);登录态 hydrate 后追加,不为爬虫减内容(反 cloaking)。
|
|
18
|
+
- JSON-LD 在场且 schema 有效(Article/Product/ItemList);title/description 非占位。
|
|
19
|
+
- `sitemap.ts` 只含 A 类(含分页,每页 canonical);`robots.ts` 屏蔽 B 类且 B 类 `noindex`;站点公网可达。
|
|
20
|
+
|
|
21
|
+
## 4. 分享验收
|
|
22
|
+
- 外部:OG 卡片预览(微信/QQ/开放图谱调试器)标题/图/描述正确且与 canonical 一致。
|
|
23
|
+
- App 内:分享当前 URL → 点开经 scheme/landing 唤起进支付宝(env-degradation.md);自定义分享不可达,勿臆造 `setShareContent`。
|
|
24
|
+
|
|
25
|
+
## 5. 支付端到端
|
|
26
|
+
- `POST /api/pay` 生成 wap.pay URL → 前端 `container.startPay` 跳转 → 回 `/result/:tradeNo` 展示结果。
|
|
27
|
+
- 校验:私钥只在服务端 env;前端 bundle 无密钥串(见 check-build.mjs);wap.pay URL 域名正确。
|
|
28
|
+
|
|
29
|
+
## 6. 降级屏 / B 类页
|
|
30
|
+
- B 类页浏览器打开给 AlipayGuard「请在支付宝中打开」+ 唤起按钮;在支付宝内正常渲染子内容。
|
|
31
|
+
- robots:B 类 noindex + 不进 sitemap。
|
|
32
|
+
|
|
33
|
+
## 7. 移动适配真机核对(iOS WKWebView / Android WebView / 手机浏览器)
|
|
34
|
+
- viewport/安全区(刘海/Home 手势条)不裁切;固定吸底栏不被遮挡;输入聚焦键盘不遮挡(`scrollIntoView`)。
|
|
35
|
+
- 横屏不崩;触控热区 ≥44px;高 DPI 图不糊;细节见 responsive-cross-end.md。
|
|
36
|
+
|
|
37
|
+
## 8. 性能预算(发布前对照,见 release-perf.md)
|
|
38
|
+
- LCP<2.5s、CLS<0.1、INP<200ms(移动网络);首屏 JS / 关键 CSS 在 bundle 预算内;图片 WebP/AVIF + 懒加载。
|
|
39
|
+
|
|
40
|
+
## 9. 无障碍(a11y)验收
|
|
41
|
+
- 键盘可达(tab 序合理、焦点可见、Esc/回车行为正常)、屏幕阅读器试读标题/图片 alt、对比达标、触控目标 ≥44px(见 coding-standards.md);可用 axe 对关键页扫一轮。
|
|
42
|
+
|
|
43
|
+
## 10. 发布前验收 checklist(G5 入口)
|
|
44
|
+
- [ ] check-env 能力矩阵对齐 / [ ] check-project 0 禁用项 / [ ] check-seo 收录一致 0 / [ ] check-build secret/产物 0
|
|
45
|
+
- [ ] §1–9 全过(SEO/分享/支付/降级/移动/性能预算/a11y)/ [ ] 监控接入(Sentry/RUM) / [ ] 灰度计划 + 回滚演练 / [ ] 发布前门见 release.md
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# 交互体验(移动 H5 通用规则)
|
|
2
|
+
|
|
3
|
+
第三方纯 H5 没有原生转场/弹层/Toast 容器,交互反馈一律标准 Web + antd-mobile。下面是面向「内嵌支付宝 WebView + 公网浏览器」的体验底线。组件名以 antd-mobile v5 官方为准。
|
|
4
|
+
|
|
5
|
+
## 加载与就绪态
|
|
6
|
+
- 首屏 SSR/SSG 真实 HTML(见 architecture.md)是最好的 loading;hydrate 后再注入登录态/交互。
|
|
7
|
+
- 数据区配骨架屏(antd-mobile `Skeleton`)而非空白;首屏数据已 SSR 直出的不再闪骨架。
|
|
8
|
+
- 接口约 ≥300ms 才到才给 loading/骨架;更短直接出结果,避免闪烁。
|
|
9
|
+
|
|
10
|
+
## 空 / 错 / 重试
|
|
11
|
+
- 空数据:空态插画 + 文案 + 主操作(antd-mobile `Empty` / 自绘 + `Button`)。
|
|
12
|
+
- 接口失败:「加载失败」+「重试」(antd-mobile `ErrorBlock` / `Toast`);网络异常与 5xx 区分文案。
|
|
13
|
+
- 关键支付/下单失败给兜底(回上一屏 / 客服),别让用户卡死。
|
|
14
|
+
|
|
15
|
+
## 列表 / 滚动加载 / 下拉刷新
|
|
16
|
+
- 长列表用 antd-mobile `InfiniteScroll` 触底加载 + 底部 loading/「没有更多」;勿一次拉全量。
|
|
17
|
+
- 顶部刷新用 antd-mobile `PullToRefresh`。
|
|
18
|
+
- 列表项 `key` 用稳定业务 id,别用 index(防状态错乱 + 利复用)。
|
|
19
|
+
|
|
20
|
+
## 点击反馈与防抖
|
|
21
|
+
- 可点元素给 `:active` 反馈态或用 antd-mobile `Button`/`ListItem` 自带 activeStyle,点之有触觉感。
|
|
22
|
+
- 高频触发按钮(提交/点赞/拉取)防抖(debounce);表单提交防重复(提交即禁用按钮直到结果)。
|
|
23
|
+
- 量大滚动监听用节流(throttle)或 `requestAnimationFrame` 批处理,别每帧重算。
|
|
24
|
+
|
|
25
|
+
## 表单与键盘
|
|
26
|
+
- 聚焦时 `scrollIntoView` 防键盘遮挡;失焦收起键盘(点空白)。
|
|
27
|
+
- 实时校验给即时反馈,提交前总校验。
|
|
28
|
+
- 密码/支付类输入:走服务端校验与服务端支付跳转(见 container.md),敏感逻辑不留前端常驻。
|
|
29
|
+
|
|
30
|
+
## 返回与导航
|
|
31
|
+
- 「返回」一律 `history.back()`(无原生 popWindow);`history.length<=1` 时退到首页或指定回退路径,别让用户卡空白。
|
|
32
|
+
- 离开未保存表单给二次确认(`onbeforeunload` 在支付宝 WebView 约束较弱,以应用内确认弹层为主)。
|
|
33
|
+
- 多步流程放单页(见 architecture.md「多步流程用单页状态」),别拆成多屏靠返回拼状态。
|
|
34
|
+
|
|
35
|
+
## 转场与反馈
|
|
36
|
+
- 页间是整页加载(无原生转场),靠骨架 + 数据直出降低「白」感;可用淡入/位移 CSS 过渡做首屏进场。
|
|
37
|
+
- Toast / 确认 / 对话框用 antd-mobile `Toast`/`Dialog`/`Modal`,别用简陋 `alert/confirm`。
|
|
38
|
+
- 支付/关键操作前 `Dialog.confirm`;支付走服务端跳转(见 container.md `startPay`)。
|
|
39
|
+
|
|
40
|
+
## 弱网与降级
|
|
41
|
+
- 弱网:骨架 + 超时兜底文案;图片懒加载 + 占位(见 release-perf.md)。
|
|
42
|
+
- 离线态:纯 H5 默认无离线,给「网络异常,请重试」+ 重试;不假设刷新即有缓存(除非真机验证 SW 生效,见 release-perf.md)。
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-build.mjs — 第三方纯 H5 发布前门(secret/产物)确定性检查
|
|
4
|
+
* 用法: node check-build.mjs [项目根] 默认 cwd
|
|
5
|
+
* 退出码: 0 通过 / 1 命中须修
|
|
6
|
+
* 规则源: references/release.md / coding-standards.md(env 边界)/ testing-acceptance.md §5
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const root = process.argv[2] ? path.resolve(process.argv[2]) : process.cwd();
|
|
12
|
+
const SECRET = /PRIVATE|SECRET|KEY|TOKEN|PASS|MERCHANT/i;
|
|
13
|
+
const hard = [], warn = [];
|
|
14
|
+
|
|
15
|
+
// 1) .env* 里禁止把 secret 以 NEXT_PUBLIC_ 暴露给前端
|
|
16
|
+
for (const f of ['.env', '.env.local', '.env.production', '.env.development']) {
|
|
17
|
+
const p = path.join(root, f);
|
|
18
|
+
if (!fs.existsSync(p)) continue;
|
|
19
|
+
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
|
|
20
|
+
const m = /^\s*NEXT_PUBLIC_([A-Z0-9_]+)\s*=/.exec(line);
|
|
21
|
+
if (m && SECRET.test(m[1])) hard.push({ f: p, m: `NEXT_PUBLIC_${m[1]} 命中 secret 关键字 — 服务端密钥不得用 NEXT_PUBLIC_(会进前端 bundle),见 coding-standards.md「环境变量边界」` });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// 2) app/api 服务端路由引用 process.env.NEXT_PUBLIC_*(服务端不应依赖 public 变量)
|
|
25
|
+
function scanApi(dir) {
|
|
26
|
+
if (!fs.existsSync(dir)) return;
|
|
27
|
+
for (const n of fs.readdirSync(dir)) {
|
|
28
|
+
if (n.startsWith('.')) continue;
|
|
29
|
+
const p = path.join(dir, n); if (!fs.existsSync(p)) continue;
|
|
30
|
+
const st = fs.statSync(p);
|
|
31
|
+
if (st.isDirectory()) scanApi(p);
|
|
32
|
+
else if (/\.(ts|tsx|js|jsx)$/.test(n) && /process\.env\.NEXT_PUBLIC_/.test(fs.readFileSync(p, 'utf8')))
|
|
33
|
+
warn.push({ f: p, m: 'app/api 服务端用了 process.env.NEXT_PUBLIC_* — 服务端应直接用非 public 服务端变量' });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const d of [path.join(root, 'app', 'api'), path.join(root, 'src', 'app', 'api')]) scanApi(d);
|
|
37
|
+
|
|
38
|
+
// 3) 若 .next 已构建:扫静态产物防私钥类串入 client bundle(best-effort)
|
|
39
|
+
const next = path.join(root, '.next');
|
|
40
|
+
if (fs.existsSync(next)) {
|
|
41
|
+
const leaks = [];
|
|
42
|
+
(function scan(d) {
|
|
43
|
+
for (const n of fs.readdirSync(d)) {
|
|
44
|
+
const p = path.join(d, n);
|
|
45
|
+
const st = fs.statSync(p);
|
|
46
|
+
if (st.isDirectory() && n !== 'cache') scan(p);
|
|
47
|
+
else if (/\.js$/.test(n) && /-----BEGIN [A-Z]+ PRIVATE KEY-----/.test(fs.readFileSync(p, 'utf8'))) leaks.push(p);
|
|
48
|
+
}
|
|
49
|
+
})(path.join(next, 'static'));
|
|
50
|
+
for (const x of leaks) hard.push({ f: x, m: '构建产物含私钥 PEM 串 — 私钥不得进客户端 bundle,见 release.md「发布前门」' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(`=== 第三方纯 H5 发布前门(secret/产物):${root}`);
|
|
54
|
+
if (!hard.length && !warn.length) { console.log('✅ 通过:env 边界安全、(如已构建)产物无私钥串。'); process.exit(0); }
|
|
55
|
+
if (warn.length) { console.error('\n⚠ 提示(不阻断):'); for (const w of warn) console.error(` ${path.relative(root, w.f)}\n ${w.m}\n`); }
|
|
56
|
+
if (hard.length) {
|
|
57
|
+
console.error(`\n❌ 命中 ${hard.length} 项须修(见 release.md / coding-standards.md):\n`);
|
|
58
|
+
for (const h of hard) console.error(` ${path.relative(root, h.f)}\n ${h.m}\n`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
console.log('(仅提示项)'); process.exit(0);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 打印「支付宝第三方纯 H5」能力矩阵 + 一段可粘贴进目标页面控制台的探针。
|
|
3
|
+
// 用途:开工前对齐"什么能用/不能用";真机核对环境。
|
|
4
|
+
console.log('=== 支付宝第三方纯 H5 能力矩阵(公网核实,源 opendocs.alipay.com)===');
|
|
5
|
+
const rows = [
|
|
6
|
+
['手机网站支付 alipay.trade.wap.pay','可用','/open/203/107091 服务端生成URL跳转,无需前端SDK'],
|
|
7
|
+
['OAuth 网页授权/获取用户信息','可用','/mini/02xtl8 服务端 publicAppAuthorize 跳转;可选 ap.getAuthCode'],
|
|
8
|
+
['PC/H5 唤起支付宝(浏览器→App)','可用','/open/02zlo5 仅服务端SDK + alipays scheme'],
|
|
9
|
+
['网页&移动应用应用类型','可用','/open/03lo16 服务端接口(支付/资金/营销/安全)'],
|
|
10
|
+
['小程序内嵌H5(web-view)','可用(变形态)','/mini/tool/h5 想要宿主能力可走此路,H5不再独立被收录'],
|
|
11
|
+
['ap.getAuthCode (H5半屏授权)','可选(授权增强)','/mini/02xtl8 /open/01zsdu;默认用服务端跳转'],
|
|
12
|
+
['ap.tradePay(H5前端支付)','勿用/未确认','H5支付走服务端 alipay.trade.wap.pay;ap.tradePay公网只见my.tradePay'],
|
|
13
|
+
['pushWindow/popWindow 多WebView','不可用','opendocs 0命中;属小程序/私有'],
|
|
14
|
+
['setTitle 原生标题栏','不可用','用 HTML <title>'],
|
|
15
|
+
['setShareContent 自定义分享','不可用','外部用OG;App内用URL+scheme'],
|
|
16
|
+
['私有RPC网关/my.*/离线包/客户端开关','不可用','私有基建'],
|
|
17
|
+
['Service Worker(WKWebView/Android WebView)','待真机验证','无官方文档'],
|
|
18
|
+
];
|
|
19
|
+
for (const [k,v,s] of rows) console.log(`- ${k.padEnd(30)}${v.padEnd(12)}${s}`);
|
|
20
|
+
|
|
21
|
+
console.log('\n=== 浏览器探针(粘贴进目标页面 Console,真机核对) ===');
|
|
22
|
+
console.log(`(function probe(){
|
|
23
|
+
var ua = navigator && navigator.userAgent || '';
|
|
24
|
+
var inApp = /AlipayClient|AliApp|Alipay/i.test(ua);
|
|
25
|
+
var hasBridge = typeof window.AlipayJSBridge !== 'undefined';
|
|
26
|
+
console.log('UA:', ua);
|
|
27
|
+
console.log('inAlipay:', inApp);
|
|
28
|
+
console.log('AlipayJSBridge present:', hasBridge); // 存在不代表容器JSAPI对第三方可用
|
|
29
|
+
console.log('ServiceWorker supported:', ('serviceWorker' in navigator));
|
|
30
|
+
console.log('localStorage:', (function(){try{localStorage.setItem('__p_','1');localStorage.removeItem('__p_');return 'OK';}catch(e){return 'ERR '+e.message;}})());
|
|
31
|
+
console.log('viewport:', document.querySelector('meta[name=viewport]') && document.querySelector('meta[name=viewport]').content);
|
|
32
|
+
console.log('title:', document.title);
|
|
33
|
+
return {inApp, hasBridge};
|
|
34
|
+
})();`);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-project.mjs — 支付宝第三方纯 H5 项目护栏确定性检查
|
|
4
|
+
* 扫描项目源码,拦截 skill 明令禁止的私有/容器/小程序 API 调用遗址,输出可验证结果。
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* node scripts/check-project.mjs [项目目录] 默认 cwd
|
|
8
|
+
* 退出码: 0 通过 / 1 命中禁止项 / 2 参数错
|
|
9
|
+
* 规则源: references/pitfalls.md 摘(§1/§2/§3/§5);改ihar 修规则请同步 pitfalls.md。
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
const root = process.argv[2] ? path.resolve(process.argv[2]) : process.cwd();
|
|
15
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
16
|
+
console.error(`目录不存在: ${root}`);
|
|
17
|
+
process.exit(2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
21
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', 'out', '.git', '.turbo', 'coverage', '.cache']);
|
|
22
|
+
|
|
23
|
+
// 硬禁止:仅匹配「调用形态」,避免注释里出现的名字误报
|
|
24
|
+
const RULES = [
|
|
25
|
+
{ re: /AlipayJSBridge\s*\.\s*call\s*\(/, why: 'AlipayJSBridge 调用容器 JSAPI(第三方纯 H5 不可调,见 pitfalls §3)' },
|
|
26
|
+
{ re: /AlipayJSBridge\s*\.\s*(?:pushWindow|popWindow|setTitle|setShareContent)\s*\(/, why: 'AlipayJSBridge.<容器JSAPI>() 多webview/原生栏/自定义分享(第三方不可用)' },
|
|
27
|
+
{ re: /\.\s*call\s*\(\s*['"]pushWindow['"]\s*,/, why: ".call('pushWindow'…) 多 webview(第三方不可用,用 location/历史后退)" },
|
|
28
|
+
{ re: /\.\s*call\s*\(\s*['"]popWindow['"]\s*,/, why: ".call('popWindow'…)(无原生 popWindow,用 history.back())" },
|
|
29
|
+
{ re: /\.\s*call\s*\(\s*['"]setTitle['"]\s*,/, why: ".call('setTitle'…) 原生标题栏(第三方不可用,统一 HTML <title>)" },
|
|
30
|
+
{ re: /\.\s*call\s*\(\s*['"]setShareContent['"]\s*,/, why: ".call('setShareContent'…) 自定义分享(第三方不可用,用 OG + scheme)" },
|
|
31
|
+
{ re: /\bap\.tradePay\s*\(/, why: 'ap.tradePay 调用(公网无第三方 H5 版;支付一律服务端 alipay.trade.wap.pay 跳转)' },
|
|
32
|
+
{ re: /\bmy\.tradePay\s*\(/, why: 'my.tradePay 调用(是小程序 API,非 H5)' },
|
|
33
|
+
{ re: /(?:\bfrom\s+['"]@alipay\/jssdk['"]|\brequire\s*\(\s*['"]@alipay\/jssdk['"])/, why: 'import/require @alipay/jssdk(npm 实测 404;前端默认无需 SDK,来源以 open/08xgr7 为准)' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const hits = [];
|
|
37
|
+
|
|
38
|
+
function scan(file) {
|
|
39
|
+
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
40
|
+
lines.forEach((line, i) => {
|
|
41
|
+
for (const r of RULES) {
|
|
42
|
+
if (r.re.test(line)) {
|
|
43
|
+
hits.push({ file: path.relative(root, file), line: i + 1, code: line.trim().slice(0, 160), why: r.why });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function walk(dir) {
|
|
50
|
+
for (const name of fs.readdirSync(dir)) {
|
|
51
|
+
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
|
|
52
|
+
const p = path.join(dir, name);
|
|
53
|
+
const st = fs.statSync(p);
|
|
54
|
+
if (st.isDirectory()) walk(p);
|
|
55
|
+
else if (EXT.has(path.extname(name))) scan(p);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
walk(root);
|
|
60
|
+
|
|
61
|
+
console.log(`=== 支付宝第三方纯 H5 项目护栏:${root}`);
|
|
62
|
+
if (!hits.length) {
|
|
63
|
+
console.log('✅ 通过:未发现禁止的容器/小程序/私有 API 调用遗址。');
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
console.error(`\n❌ 命中 ${hits.length} 项禁止用法(见 references/pitfalls.md):\n`);
|
|
67
|
+
for (const h of hits) {
|
|
68
|
+
console.error(` ${h.file}:${h.line}`);
|
|
69
|
+
console.error(` ${h.code}`);
|
|
70
|
+
console.error(` → ${h.why}\n`);
|
|
71
|
+
}
|
|
72
|
+
process.exit(1);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-seo.mjs — 第三方纯 H5「SEO 收录一致性」确定性检查
|
|
4
|
+
* 用法: node check-seo.mjs [项目根] 默认 cwd(找 app/ 或 src/app/)
|
|
5
|
+
* 退出码: 0 通过 / 1 命中须修 / 2 未找到 app 目录(非 Next App Router)
|
|
6
|
+
* 规则源: references/ssr-seo-sharing.md / testing-acceptance.md §3
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const root = process.argv[2] ? path.resolve(process.argv[2]) : process.cwd();
|
|
12
|
+
const SKIP = new Set(['node_modules', '.next', 'dist', 'build', 'out', '.git', '.turbo']);
|
|
13
|
+
|
|
14
|
+
function findApp() {
|
|
15
|
+
for (const c of ['app', 'src/app']) {
|
|
16
|
+
const p = path.join(root, c);
|
|
17
|
+
if (fs.existsSync(p) && fs.statSync(p).isDirectory()) return p;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const appDir = findApp();
|
|
22
|
+
if (!appDir) { console.error(`未找到 app/ 或 src/app/(非 Next App Router?跳过): ${root}`); process.exit(2); }
|
|
23
|
+
|
|
24
|
+
const pages = [];
|
|
25
|
+
function walk(dir) {
|
|
26
|
+
for (const name of fs.readdirSync(dir)) {
|
|
27
|
+
if (SKIP.has(name) || name.startsWith('.')) continue;
|
|
28
|
+
const p = path.join(dir, name);
|
|
29
|
+
const st = fs.statSync(p);
|
|
30
|
+
if (st.isDirectory()) walk(p);
|
|
31
|
+
else if (/^page\.(tsx|ts|jsx|js)$/.test(name)) pages.push(p);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
walk(appDir);
|
|
35
|
+
|
|
36
|
+
const hard = [], warn = [];
|
|
37
|
+
// 代码锚点检测:避免把正文里描述性的 "canonical/OG" 文字误判为元数据
|
|
38
|
+
const reCanonical = /\balternates\b|canonical\s*:/;
|
|
39
|
+
const reOG = /\bopenGraph\b/;
|
|
40
|
+
const reSEO = /generateMetadata|export\s+const\s+metadata\b|\bMetadata\b|\bopenGraph\b|\balternates\b|canonical\s*:/;
|
|
41
|
+
let anyCanonicalPage = false;
|
|
42
|
+
|
|
43
|
+
for (const pg of pages) {
|
|
44
|
+
const t = fs.readFileSync(pg, 'utf8');
|
|
45
|
+
const c = reCanonical.test(t), g = reOG.test(t);
|
|
46
|
+
if (c) anyCanonicalPage = true;
|
|
47
|
+
const isSEO = reSEO.test(t);
|
|
48
|
+
if (isSEO && c && !g)
|
|
49
|
+
hard.push({ f: pg, m: '设了 canonical(alternates) 但缺 openGraph(canonical==og:url==公网URL 应四处一致)' });
|
|
50
|
+
if (isSEO && g && !c)
|
|
51
|
+
hard.push({ f: pg, m: '设了 openGraph 但缺 canonical(alternates)(同上)' });
|
|
52
|
+
if (/AlipayGuard/.test(t) && !/noindex|index:\s*false/i.test(t))
|
|
53
|
+
warn.push({ f: pg, m: '引入 AlipayGuard(B 类)但未见 noindex — 确认该页 robots: { index: false }' });
|
|
54
|
+
if (/user-agent/i.test(t) && !/container|isAlipayClientSSR/.test(t))
|
|
55
|
+
hard.push({ f: pg, m: 'page 直接判 user-agent 且未走 container.isAlipayClientSSR — 有 cloaking 嫌疑,见 pitfalls.md §4' });
|
|
56
|
+
}
|
|
57
|
+
if (anyCanonicalPage) {
|
|
58
|
+
const has = (n) => fs.existsSync(path.join(appDir, n));
|
|
59
|
+
const hasSitemap = ['sitemap.ts', 'sitemap.js', 'sitemap.tsx'].some(has);
|
|
60
|
+
const hasRobots = ['robots.ts', 'robots.js', 'robots.tsx'].some(has);
|
|
61
|
+
if (!hasSitemap) hard.push({ f: path.join(appDir, 'sitemap'), m: 'A 类(含 canonical)页存在但缺 app/sitemap.ts(sitemap 只含 A 类,含分页)' });
|
|
62
|
+
if (!hasRobots) hard.push({ f: path.join(appDir, 'robots'), m: 'A 类页存在但缺 app/robots.ts(robots 屏蔽 B 类)' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(`=== 第三方纯 H5 SEO 收录一致性:${root}`);
|
|
66
|
+
if (!hard.length && !warn.length) { console.log('✅ 通过:canonical/og 一致、sitemap/robots 在场、无 cloaking 嫌疑。'); process.exit(0); }
|
|
67
|
+
if (warn.length) { console.error('\n⚠ 提示(不阻断):'); for (const w of warn) console.error(` ${path.relative(root, w.f)}\n ${w.m}\n`); }
|
|
68
|
+
if (hard.length) {
|
|
69
|
+
console.error(`\n❌ 命中 ${hard.length} 项须修(见 ssr-seo-sharing.md / testing-acceptance.md §3):\n`);
|
|
70
|
+
for (const h of hard) console.error(` ${path.relative(root, h.f)}\n ${h.m}\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
console.log('(仅提示项,无须修)'); process.exit(0);
|