# 在线免费工具页最小方案（2026-10-30）

> 目标：在 pinnadash.com 上加「浏览器里就能用、不用安装」的免费工具页，吃搜索长尾词，页尾 CTA 导流到对应桌面产品。
> 本文只出方案，不实现、不发布、不提交。所有官网仓引用均为**只读实读**，路径相对 `D:\code\work\lin\official_website`。
> 口径提醒：本文引用的官网仓事实截至 2026-10-30 的本地工作区（`dist/` 为最近一次构建产物）。

---

## 零、官网仓真实结构（摸底结论，先于方案）

**构建**：单文件 `build.py`（802 行，入口 `main()` 在 `build.py:346`），Jinja2 模板 + JSON 数据 → 纯静态 `dist/`。
构建第一步是文案泄露闸 `check_copy_leaks()`（`build.py:41-52`，规则 `build.py:35-38`）：扫 `data/` + `templates/`，命中技术栈词/对标产品名直接 `SystemExit`。

**语言（真实存在 i18n 机制）**：`LANGS = ["zh","en","de","ja","es"]`（`build.py:28`），hreflang 码映射 `build.py:30`；文案字典 `data/i18n.json`（51,380 B）在 `build.py:363` 加载、`build.py:422` 以全局名 `t` 注入；模板按 `t.<组>.<键>[lang]` 取值（例：`templates/apps.html:3` 用 `t.titles.apps[lang]`）。`i18n.json` 现有顶层组：`lang_btn/btn/dl/badge/footer/titles/apps/tv/support/qq/mac137/notfound/home/desc/product/about/faq/blog`，其中 `titles` 组 10 个键（index/apps/blog/tv/support/qq/mac137/notfound/about/faq），`desc` 只有 `site` 一个键。
路径规则：en 在根，其余 `/zh/ /de/ /ja/ /es/`（`lang_prefix()` `build.py:281-283`；`lp()` `build.py:271-278`，`/assets/` 不加语言前缀）。

**页面清单（全部由 `main()` 硬编码生成）**：
- 普通页 `pages` 字典 `build.py:437-446`（index/apps/support/about/faq/pricing/auth-callback/404），渲染循环 `build.py:468-474`
- 产品详情页 `build.py:478-483`（每产品一页，`templates/product.html`）
- 法律页 /terms /privacy /refund `build.py:486-491`
- 博客**只出英文** `build.py:495-508`（`blog_ctx` 关掉 hreflang 簇，`build.py:498`）
- 产物形状先例：`dist/blog/index.html` + `dist/blog/<slug>/index.html`（实测存在：`dist/blog/free-disk-space-analyzer-windows/` 等 3+ 目录）→ 目录式 URL 在这套 CF Pages 上是成立形态

**sitemap（真实存在，不是假设）**：`public_hrefs` 列表 `build.py:448`，各页 append 在 `build.py:473/481/489/501/507`；写出逻辑 `build.py:510-524`（`robots.txt` 在 `:513-514`，sitemap 在 `:516-523`）。实测本地 `dist/sitemap.xml` 现有 **84 条 `<loc>`**（`Select-String '<loc>'` 计数）。lastmod 口径 `build.py:511-512`：只有产品页（发版日）/博客（发布日）带，其余不带。
IndexNow：`build.py:526-539` 由 `public_hrefs` 生成 `dist/indexnow-urls.json`，`publish.sh` 尾部发布后 POST（回 202/200 记受理，失败只 WARN）。**进 sitemap = 自动进 IndexNow**，不用另接。

**canonical / URL 终态**：`canonical_href()` `build.py:171-178` —— `.html` 在 CF Pages 会被 308 到无后缀形态（注释里写明 09-28 线上实测）；`seo_ctx()` `build.py:286-300` 产出 `own_href`（canonical/og:url）与全语言 `alt_hrefs` 簇。
模板侧：`templates/base.html:6` title、`:7` description、`:8-14` og、`:12` canonical、`:17-20` hreflang 全簇、`:15` favicon、`:16` CSS 带内容 hash 版本号。

**结构化数据（已有三套 JSON-LD）**：Organization+WebSite `build.py:181-214`（全局注入 `base.html:22`）；产品 SoftwareApplication `build.py:217-247`（页面级注入 `templates/product.html:8` 的 `extra_head` block）；博客 BlogPosting `build.py:250-268`。

**静态资源与缓存**：只拷 `assets/css/js` 三个目录（`build.py:606-611`）；带 hash 版本号的只有 `style.css/main.js/auth.js` 三个（`asset_v` 白名单 `build.py:427-431`）。规则正本 `docs/CONVENTIONS.md:18`（CSS/JS 无版本号会被 CF 缓存 4h）。
`_redirects` 由 `build.py:597-604` 生成（只处理旧路由与 `/go/` 短链）。

**发布链**：`publish.sh` 全文 = `python build.py` → `git add -A` →（无改动则只重部署）commit → `git push`（失败只告警）→ `npx wrangler pages deploy dist --project-name=official-website --commit-dirty=true` → IndexNow POST。**没有 CI、没有测试套件，唯一闸门是 `build.py` 里的 SystemExit 检查。**

**产品数据**：`data/products.json`（118,525 B）现有 **7 条**，不是 5 条：`easy_image`(:4,页面 :26)、`easyscreenshot`(:288,:306)、`easyclipboard`(:730,:748)、`easyflow`(:1026,:1048)、`easydisk`(:1352,:1374)、`easy-explorer`(:1689,:1711)、`failbook`(:1820,:1846，纯网页产品，`download` 是站点域名 `:1830`)。

**现无 /tools 任何痕迹**：全仓（py/json/html/md/js/sh）搜 `/tools|在线工具|免费工具|online tool` **0 命中** —— 这是一条全新路径，不存在存量冲突文件。

**现状数据（另一个仓，作为判断依据引用）**：`D:\code\work\lin\company\ops\data\gsc.json`（2026-10-28 拉取）= 提交 84 URL、**收录 0**（`urls_indexed: 0`，`sitemaps[0].indexed: 0`）、28 天展示 102 / 点击 0、7 天展示 17 / 点击 0；且只存 `top_pages`，**没有 queries 维度**。
**口径冲突预警**：`docs/ROADMAP.md` 已完成段 2026-09-07 条写着「Easy Image 定位厘清：网页端仅内部使用…公开页面移除网页版入口（免在线使用 CTA）」。本方案与该条口径相反，上线前需老板重新拍板（见 §⑤ R4）。

---

## ① 首批 3 个工具页候选

命名沿用仓内连字符风格（`build.py:311-343` 命名标准的同款形态）：`/tools/compress-image/`、`/tools/convert-image/`、`/tools/screenshot-to-text/`。

| # | 页面 | 目标长尾词（3~5） | 搜索意图 | 竞争判断依据 |
|---|---|---|---|---|
| 1 | 图片压缩 `/tools/compress-image/` | `compress image online` / `compress jpg to 100kb` / `reduce image size without losing quality` / `compress png keep transparency` / `bulk compress images free` | 工具型事务意图：要「立刻能用、不注册、不出门」，忠诚度低、看完就走 | **未查实（需 Ahrefs/GSC queries 补）**。家底旁证：`products.json:284` 现有 keywords 已含 `compress images`/`image converter`，但站上 84 URL 收录 0（`gsc.json:urls_indexed`），连曝光样本都没有，**无法从自家数据判竞争**。词形上属 TinyPNG/iLoveIMG/Adobe 正面战场——这是我按词形做的推断，不是实测排名 |
| 2 | 格式转换 `/tools/convert-image/` | `png to jpg converter` / `convert webp to png` / `jpg to webp online` / `bulk image converter free` / `convert heic?`（**不做**，HEIC 解码需额外 wasm 库，首版排除） | 工具型事务意图 + 少量「为上传/为兼容」目的性 | **未查实**。旁证：`products.json:7-8` Easy Image 主打 tagline 就是「转格式 · 压缩」，`:43-44` 特性写明「格式转换与压缩，画质尽量少损失」——产品侧判断这词有商业价值，非搜索竞争证据 |
| 3 | 截图转文字 `/tools/screenshot-to-text/` | `screenshot to text` / `extract text from image free` / `copy text from screenshot` / `image to text converter free` / `ocr screenshot online` | 工具型 + 轻办公任务意图，落地即取文本 | **未查实**。产品侧旁证：Easy Screenshot 的 OCR 是**会员功能**（`products.json:426-427`、`:448-449`），能力描述在 `:482-483`（Windows 内置引擎、零下载、全程本机）→ 团队已认定该能力有付费价值；但浏览器端引擎能力弱于系统内置（见 §② 体积代价） |

**判断依据怎么补（可执行）**：① 扩 `company/ops/gsc.py` 增加 `searchAnalytics/query` 维度（现有 `gsc.json` 只有 `top_pages`，无 queries 字段，属事实缺口）；② 用 GSC 现有 34 个曝光页反推已进词；③ 或临时买 Ahrefs/Semrush 单月关键词包。**在这三步做完前，上表「竞争判断」一栏全部按未实证处理，不许当结论用。**

---

## ② 技术形态选型：纯前端、文件不上传

**结论：三个页面全部纯前端，零上传、零后端、零 Workers。** 文件只在浏览器内存里过一遍，输出走 `Blob` + `<a download>`。

**为什么选它（三条硬理由，都能对到仓里）**：
1. **隐私一致性**：`data/products.json:27-28` 公开文案已承诺「所有处理在本机完成，图片永不上传任何服务器」；`:19` chip 写「全程本机 · 图片不上传」。工具页若上传文件，等于官网自己打自己脸，且要先改隐私政策（`data/legal.json`）。
2. **零服务器成本**：`publish.sh` 只做 `wrangler pages deploy dist`，全站是静态产物；加工具页不引入任何运行时计费（无 Workers/无 R2/无函数）。
3. **与现有构建契合**：`build.py:606-611` 直接把 `js/` 拷进 `dist/`，`js/main.js` 是原生 ES5 风格 IIFE（`js/main.js:1-11`），无打包器、无 npm 构建步骤——工具脚本按同款形态放 `js/` 即可，构建链一行不改。

**每页要用的浏览器 API**：

| 页面 | 主链路 API |
|---|---|
| 压缩 | `<input type="file" multiple>` / `File` API 读字节 → `createImageBitmap(file)` 解码 → `OffscreenCanvas` + `convertToBlob({type, quality})`（或 `HTMLCanvasElement.toBlob(type, quality)`）重编码 → 质量档滑杆（0.6/0.75/0.85）→ `URL.createObjectURL` 预览 + 原/后体积对比 → `a[download]` 保存。降级：`canvas` 双缓冲 for 老 Safari |
| 格式转换 | 同上 + `canvas.toBlob` 的 MIME 能力探测（`'image/webp'` 因浏览器而异）→ `png↔jpg↔webp` 矩阵；`createImageBitmap(file, {imageOrientation: 'from-image'})` 修 EXIF 旋转；重编码天然剥 EXIF（与 `products.json:27` 承诺一致）；透明通道丢失去向要在页面上明说（png→jpg 白底） |
| 截图转文字 | `File`/剪贴板粘贴（`navigator.clipboard.read()` 取 `image/*`）→ 懒加载 OCR 引擎（Tesseract.js 类 WASM，`Worker` 里跑避免卡 UI）→ 进度条 → 结果文本区 + `navigator.clipboard.writeText()` 一键复制。**点击后才加载引擎**，首屏不带它 |

**体积代价（诚实标口径）**：OCR 引擎 core + `eng` 语言包合计约数 MB 级（`chi_sim` 更大）——**具体字节数未在本机实测，标「未查实」**，§④ 的 Lighthouse 门槛就是用来卡这件事的。压缩/转换两页首版可以完全不用 WASM（Canvas 原生编码），包体积接近 0。
**公开文案约束**：页面上的说明文字**不许出现实现技术词**（`build.py:35-38` 命中即构建失败；规则同 `docs/CONVENTIONS.md:5`）——写用户价值（「在你的浏览器里完成，文件不上传」），不写「WASM/Canvas/引擎实现」。

---

## ③ 与现有 build/publish 流程的接缝（逐项对着真实文件给落点）

**1) 子路径与产物**：新增 `templates/tools.html`（`{% extends "base.html" %}`，仿 `templates/product.html:1`）。
在 `build.py:437-446` 的 `pages` 字典里加映射是**最省事但源于 5 语的**做法：`"tools.html": "tools/compress-image/index.html"` 之类 → 渲染循环 `build.py:468-474` 自动对 `LANGS`（`build.py:28`）出 5 语。但一个模板出一个 slug 不成立，所以实际做法是**在 `build.py:495-508` 博客那种独立渲染块旁再开一块**：对 3 个 slug 各 `render_to(...)` 一次，模板吃 `{"tool": slug, ...seo_ctx(href, "en")}`。
**en-only 还是 5 语，必须二选一并写进代码**：en-only 就照抄博客的 `blog_ctx`（`build.py:498`，`alt_hrefs: []` 关掉 hreflang 簇），并在 `data/site.json` 入口项加 `"nolang": true`（先例 `site.json:46`，渲染支持在 `base.html:47` 与 `:76`）。

**2) sitemap：要进，必须进**。落点 = `public_hrefs.append((href, "en", None))`，紧跟 `build.py:473` 那行同款写法（产品页写法见 `build.py:481`）。写出在 `build.py:510-524` 自动完成；`canonical_href(lang_prefix(lb) + href)`（`build.py:519`）会把 `.html` 削掉，所以**不要手写 loc**。
lastmod：按 `build.py:511-512` 的口径，工具页内容不随产品发版变 → **不带 lastmod**（`None`）。进了 sitemap 就自动进 IndexNow 载荷（`build.py:531-538`）+ `publish.sh` 尾部推送，不用另接。

**3) i18n**：`data/i18n.json` 加一个 `tools` 组（键结构照 `titles` 那层的 `[lang]` 形态），模板写 `t.tools.<键>[lang]`；`build.py:363` 加载、`:422` 注入是现成的，**不用改 build.py 的 i18n 部分**。注意 `i18n.json` 的 `desc` 组目前只有 `site` 一键（实测），工具页 description 要么走 `tools` 组新键、要么模板里 `{% block desc %}` 写死英文——前者优先，后者会把英文硬编码进 HTML（可接受但不优雅）。

**4) SEO（title/description/canonical/schema）**：
- title/description：覆盖 `base.html:6/:7` 两个 block（写法同 `templates/product.html:3-4`）
- canonical/og:url：**不手写**，由 `seo_ctx()`（`build.py:286-300`）经 `own_href` 注入（`base.html:12`）
- hreflang：`base.html:17-20` 自动全簇；en-only 时传空簇（同 `build.py:498`）
- og:image：`base.html:14` 默认值可用；要给工具页专属图就覆盖 `{% block ogimage %}`
- schema.org：页面级新增一段 JSON-LD，仿 `build_product_json_ld()`（`build.py:217-247`）+ 注入点 `{% block extra_head %}`（`templates/product.html:8`）。类型建议 `WebApplication`（`applicationCategory: "UtilitiesApplication"`、`isAccessibleForFree: true`、`offers.price: "0"`）。**别再输出一份 Organization/WebSite**——那份是全局的（`build.py:181-214`，`base.html:22`），重复输出会变成两份 @graph
- 内部链接：工具页 → 对应产品页用 **200 终态 URL，不带 `.html`**（`.html` 会 308，依据 `build.py:171-178`）

**5) 静态资源与缓存**：工具 JS 放 `js/`（`build.py:607` 只拷 `assets/css/js`），**并且必须加进 `asset_v` 白名单**（`build.py:427-431`），否则 `?v=` 版本号不生成、CF 缓存 4h 内改版不生效（`docs/CONVENTIONS.md:18`）。若用 WASM/语言包，放 `assets/`（同被拷贝）并懒加载。

**6) 导航/页脚入口**：只改 `data/site.json`——nav 在 `:13-91`、footer 产品列在 `:102-172`，模板不用动（`base.html:45-49`、`:74-77` 是数据驱动）。en-only 记得 `nolang`。

**7) `_redirects`**：**不用改**。`build.py:597-604` 只处理旧路由与 `/go/bili`；目录式输出（`dist/blog/<slug>/` 先例）在 CF Pages 上天然 200。

**8) 页尾 CTA 落点**：

| 工具页 | 主 CTA（终态 URL） | 依据 | 次 CTA |
|---|---|---|---|
| `/tools/compress-image/` | `/easy-image` | `products.json:4,26`（页面 `easy-image.html`）；`:7-8` tagline 就是压缩/转格式 | `/easy-flow`（`products.json:1026,1048`，`:1049-1050` 自动压缩，`:1065-1066` 目标体积） |
| `/tools/convert-image/` | `/easy-image` | 同上 | `/easy-flow` |
| `/tools/screenshot-to-text/` | `/easy-screenshot` | `products.json:288,306`；OCR 能力 `:482-483` | `/pricing/`（OCR 在桌面端是会员功能，`:426-427`） |

**9) 构建闸门（会让构建红的两个东西）**：`check_copy_leaks()`（`build.py:41-52`）扫新模板文案；`check_naming()`（`build.py:311-343`）只扫 `products.json`，不动产品数据就不受影响。**没有测试套件**，改完只有 `python build.py` 一条自检。

**10) llms.txt**：`build.py:541-571` 里 Products 段是数据驱动的，工具的入口若要给 AI 爬虫看到，得在 `:560-562` 那段旁边手动加一段（现有代码没有工具组）。

---

## ④ 上线判据（每条都可判，不写「大概」）

| # | 判据 | 怎么判（命令/字段） | 通过阈值 |
|---|---|---|---|
| 1 | 页面上线 200 | `curl -sI https://pinnadash.com/tools/<slug>/` 与 `curl -sI https://pinnadash.com/tools/<slug>` | 两者都 `HTTP/2 200`，或第二个 301→200（最多一跳）；**不许 404、不许 308 到别处** |
| 2 | 每页 CTA 通 | 页面 HTML 里 CTA `href` ∈ `https://pinnadash.com/easy-image` / `/easy-screenshot` / `/easy-flow`；再对每个 href `curl -sI` | CTA 链接全部 200；页面内站内链接抽查 0 个 404 |
| 3 | sitemap 收录 | 本地 `Select-String ''dist/sitemap.xml'' -Pattern ''<loc>''` 计数；线上 `curl -s https://pinnadash.com/sitemap.xml` 搜 `tools` | 本地条数 = 84 + N（N=新增 URL 数，en-only 时 N=3）；线上出现全部 N 条 `https://pinnadash.com/tools/<slug>/` |
| 4 | GSC 已提交 | GSC「站点地图」页 submitted 数（口径同 `company/ops/data/gsc.json` 的 `sitemaps[0].submitted`） | 由 84 涨到 84+N，`errors: 0` |
| 5 | GSC 开始出现该词展示 | GSC「效果」按页面+查询筛选该 URL（**前置条件**：网址检查显示「已编入索引」） | 收录后 28 天内该页 `impressions ≥ 1`，且至少 1 个 §① 目标词有 `impressions`。**注意：当前 84 URL 收录 0（`gsc.json:urls_indexed=0`），这条现在必挂**——先解收录（`company/docs/需求/2026-10-10-SEO-零收录抢救…`），否则判据 5 不成立 |
| 6 | 性能 | `npx lighthouse https://pinnadash.com/tools/<slug>/ --form-factor=mobile --output=json --output-path=./lh.json`（只读本地结果） | mobile Performance ≥ 90、SEO = 100、Accessibility ≥ 90、Best Practices ≥ 90；LCP ≤ 2.5s、CLS ≤ 0.1、TBT ≤ 200ms；首屏传输 JS ≤ 150KB（OCR 引擎不计入首屏，点击后才加载） |
| 7 | 没打坏主站 | 发布后 `curl -sI https://pinnadash.com/easy-image`、`/pricing/`、`/zh/` | 全部 200；`python build.py` 退出码 0（新模板过文案闸） |

---

## ⑤ 工时与风险

**最小可发版（能发、能被收录、能导流）**：3 个页面只出**英文**，压缩/转换用纯 Canvas 原生（0 WASM），OCR 用懒加载 WASM 引擎；1 个 `templates/tools.html` + 3 个 `js/tools-*.js` + `build.py` 改 6 处（渲染块、`public_hrefs.append`、`asset_v` 白名单、`site.json` 入口、`i18n.json` tools 组、`llms.txt` 可选）→ **约 2.5~3.5 人日**。
**完整版**：5 语（`i18n.json` 全语键 + 每页文案）+ WASM 高质量编码档 + 批量/拖拽/粘贴 + `/tools/` 索引页 + nav 入口 + `WebApplication`/`HowTo` schema + 与现有 blog 互链 → **再加 3~4 人日**。
工时口径声明：本仓无 CI、无测试，工时按「改动点 × 单点熟悉度」估，**未用历史 commit 数据校准（未查实）**。

**风险**：

| 风险 | 说明与依据 | 缓解 |
|---|---|---|
| R1 关键词判断未实证 | 无 Ahrefs/Semrush；`gsc.json` 无 queries 维度，自家 84 URL 收录 0 | 先扩 `ops/gsc.py` 拉 queries；先只发 1 页试水，看 GSC 是否进词再铺第 2、3 页 |
| R2 收录为 0（会否掉整条假设） | `gsc.json: urls_indexed=0`，sitemap 84 提交 0 收录 | 工具页上线前/同时推进收录抢救；判据 5 以「已编入索引」为前置，不许跳过 |
| R3 WASM 包体积 | OCR 引擎+语言包数 MB 级（**精确字节未实测，未查实**）；与判据 6 的首屏 ≤150KB 冲突 | 引擎放 `assets/`、点击后才 fetch、Worker 内跑；首屏绝不预载 |
| R4 与产品功能重复感（**要老板拍板**） | Easy Image 已含压缩/转格式（`products.json:7-8,27-28`）；且 `docs/ROADMAP.md` 2026-09-07 条曾拍板「公开页面移除网页版入口（免在线使用 CTA）」 | 上线前老板确认新口径：在线工具=获客漏斗，不做桌面功能替代；页面明写「桌面版能批量/自动化，网页版只做单张快用」 |
| R5 构建闸误伤 | 新模板文案若写实现技术词 → `check_copy_leaks()`（`build.py:41-52`）直接构建失败 | 文案先本地跑 `python build.py`，别等 `publish.sh` 里才炸 |
| R6 缓存导致「改了看不到」 | 新 js 不进 `asset_v`（`build.py:427-431`）就没有 `?v=` 版本号，CF 缓存 4h（`docs/CONVENTIONS.md:18`） | 加白名单 + 发布后看 HTML 里引用 URL 是否带新 hash |
| R7 站内放二进制撞平台限制 | CF Pages 单文件大小上限（**具体数值未在本仓查实**） | 若把 WASM 放站内，先核对平台限制；超限就改 CDN `?v=` 引外部源（会牺牲「零外部依赖」的干净度） |

---

## 收尾：先做哪个

**先做 `/tools/compress-image/`。** 纯浏览器原生编码，零 WASM、零体积风险，一页即可验证「工具页能不能进词」这个唯一核心假设；它和 Easy Image 本是同一件事（`products.json:7-8,27-28`），CTA 不牵强。跑通一页再复制另两页。
