想让 Claude Code 之类的 Agent 直接读 Google Search Console 和 GA4 的统计做 SEO 分析,不能靠 OAuth 浏览器授权(Agent 没有交互式登录),正确做法是 GCP service account:建一个服务账号 → 启用两个 API → 把这个服务账号邮箱加到 Search Console / GA4 里授权 → Agent 拿 JSON key 调 REST API。本文记录完整流程和踩的坑。
Agent (Claude Code)
→ CLI 脚本 (gsc.py / ga.py)
→ service account JSON key 认证
→ Search Console API / Analytics Data API
→ 你的站点数据
关键点:service account 不是「你的 Google 账号」,而是 GCP 项目里的一个机器身份。权限不是靠账号密码,而是靠「把这个机器身份的邮箱加进目标资源」。
feat: unified cache TTL — PageCache found + TLDR cache expire on same schedule (default 7 months) PageCache found entries and the TLDR cache both used 604800s (7 days) as separate hardcoded literals — they had already drifted conceptually and would diverge silently if either changed. Extract PHPMAN_CACHE_TTL_FOUND (= PHPMAN_CACHE_TTL_MONTHS × 30 days, default 7 months = 18144000s) in src/config.php and switch both call sites to it: - src/cache.php: found + search-not_found TTLs use the constant (not_found stays 1 day, emoji formats stay never-expire) - src/tldr.php: read-side expiry check uses the constant (was inline 604800) - phpman.config.php.example: document PHPMAN_CACHE_TTL_MONTHS override - test_page_cache.php: TTL assertions reference the constants - docs (03-CACHE / 01-PRODUCT / AGENTS / README): describe current behavior; historical changelog lines kept as-is Local suite: 389 passed, 0 failed.
perf: markdown formatter fast path — hoist baseUrl/closures out of loop, guard regex passes with strpos Staging staged-timing showed md formatter CPU cost was comparable to HTML (160-370ms vs 200-230ms on 30k-line pages), but per-line regex passes with per-match baseUrl()+urlencode() added avoidable overhead. - baseUrl() is request-constant: compute once before the loop - link closures hoisted out of the loop (was created per line) - 4 regex passes guarded by strpos triggers (@/http/(/::): plain prose lines (the majority) now do a single C-level scan instead of 4 preg passes Verified on staging: zshall/gcc-12/ls markdown output byte-identical (1.66MB/1.39MB/8.9KB), local test suite 389/389 passed.
fix: filter stale info dir-menu entries by real .info files getInfoIndex() ran `info` and blindly linked every (name)/(group)node entry in the dir menu. On a shared host the dir file lists packages that were uninstalled without updating the menu, so ~180 of 252 entries had no backing .info file — producing dead links (empty page → noindex, 92.3% of info mode). Add getValidInfoFiles() (glob /usr/share/info/*.info*, strip .info/.gz/-N suffixes) and intersect the menu against it across all three formats (markdown, json/mcp, html). Entries without a backing file stay as plain text. Co-Authored-By: Claude Code <noreply@anthropic.com>
v4.11: shard page cache per mode to eliminate SQLite write-lock contention
Production was throwing 'database is locked' / 'cache set retries exhausted'
in phpman_error.log under concurrent bot/man traffic — all modes (man,
perldoc, info, pydoc, ri, search) wrote to ONE SQLite file (phpman_cache.db)
alongside the search infrastructure.
Refactor: each mode now gets its own page-cache file
phpman_cache_{mode}.db holding just the cache + cache_fts tables. The central
DB keeps only the search/tldr infra (meta, search_fts, search_index_meta,
tldr_cache), which is written mostly during batch reindex (single writer).
Changes:
- src/cache.php: add pageCacheModes() + pageCacheDb(); route PageCache
get/set/delete/deleteEntry/syncFts through the mode shard; clear()/stats()
and cacheOrExecute TTL cleanup iterate all shards.
- phpMan.php: ETag emoji check + emoji staleness query now target the mode shard.
- src/search_index.php: clear the 'search' shard on reindex instead of central.
- cli/build-sitemap.php: iterate per-mode shards (with legacy central fallback).
- test/unit/test_page_cache.php: verify against pageCacheDb() shards.
All 389 unit + integration tests pass.
fix: MCP 信封也要暴露限额(formatMcpStructured 是白名单式挑字段) formatMcpStructured() 只挑固定字段返回,所以加在 IR 顶层的 content_truncated / content_budget_bytes / content_retained_bytes 在 MCP 响应里 被丢掉了——staging 实测 ?format=json 有标记、/mcp 没有。section 级标记因为 sections 整体透传所以没受影响。 - src/format_mcp.php: formatMcpStructured() 在截断时透出三个字段; formatMcpMarkdown() 的收尾说明在截断时改为告知「本节文本已封顶, 带 truncated 标记的条目附 content_bytes/content_lines,章节列表完整」, 不再承诺不再携带的 full documentation - test/integration/test_json_content_cap.php: 补 5 条 MCP 信封断言 验证:7 个页面的 MCP 信封与改动前逐字节一致(含 perlfunc 324KB subsection); 测试 384 → 389 通过。
feat: json/mcp 载荷限额,派生字段改为折行时单遍计算 info py 的 section 正文共 14.4MB(单 "Index" 一节就有 1.37MB),原本要整页 驻留内存才能序列化。现在折行阶段就按预算丢弃超出部分:全局 1MB (PHPMAN_JSON_MAX_CONTENT_BYTES) + 单节 512KB (PHPMAN_JSON_MAX_SECTION_BYTES), 两个常量都可在 phpman.config.php 覆盖。 因为超出预算的正文不再驻留,summary/synopsis/flags/examples/see_also 改为在 折行时从完整行流里累积,而不是事后从 sections 再扫一遍——否则被丢弃的 OPTIONS/EXAMPLES 正文会让 flags 丢失。被截断的条目带 truncated / content_bytes / content_lines,顶层带 content_truncated / content_budget_bytes / content_retained_bytes;section_outline 仍然完整,导航不受影响。 单节上限取 512KB 是实测结论:73 个常见 man 页里 perlfunc 有 324KB 的 subsection(bash 36KB、sudoers 59KB 的 section),32KB 会误伤这类总量远低于 预算的正常页面。放得下的页面必须逐字节不变。 - src/format_json.php: buildJsonData() 折行时按预算保留正文并累积派生字段 - src/config.php: 新增两个限额常量 - phpman.config.php.example: 记录这两个开关 - test/integration/test_json_content_cap.php: 17 条断言覆盖标记、预算上限、 数组/非 JSON 边界,以及最关键的「截断后派生字段仍完整」 验证:62 个 man 页 + perldoc 与改动前逐字节一致(脚本比对 HEAD 与工作区); 小预算下 flags/summary/examples/see_also 与不限额时完全相同;真实 info py 在 生产环境 PHP 8.5 下 JSON 16.1MB → 2.88MB(-82%)、峰值 67.4 → 46.5MB。
fix: 索引的 url 字段指向可用的地址(/man/json 实际是页面查询) getManIndex() / getInfoIndex() 输出的 url 自引用写的是 /man/json、/info/json, 但索引没有 command 段,PATH_INFO 表达不了格式——这两个地址会被路由成「名为 json 的页面」并返回 HTML。索引取格式只能走查询参数,phpMan.php 里页脚的格式 链接用的就是 ?mode=X&format=json。 - src/source_man.php / src/source_info.php: url 改为 ?mode=man|info&format=json 验证:四个地址均返回合法 JSON(man 索引 count=10、info 索引 count=195, mcp 形式为合法信封)。
fix: 大页面的 MCP/JSON 不再经由 IR 字符串(info py 内存超限)
info py 是 430k 行 / 19.5MB 的页面。MCP 路径原本把 IR 序列化成 ~15MB 字符
串、再由 formatForOutput() 解码回来,转换过程中同时持有三份整页文本,在
128MB memory_limit 下直接 fatal(生产日志累计 8 次,最近 02-Sep)。
- src/format_json.php: formatToJSON() 拆出 buildJsonData(): array;行缓冲折
进 $sections 后立即释放,编码前 unset($sections)。释放必须通过引用写空
($lines = array())——unset() 只去掉本地别名,调用方的数组仍然存活
- src/format_mcp.php: 抽出 formatMcpEnvelope(array),新增 formatPageOutput()
—— mcp 直传数组,json 走原路径;数组已在手的 search/索引页同样跳过
encode+decode
- source_{man,info,perldoc,pydoc,ri}.php: 9 处调用点改走 formatPageOutput
- test/integration/test_formatter_mcp.php: 锁定「数组路径 == 字符串路径」
验证:新旧路径输出字节级一致(mcp/json × 页面/索引,仅 generated 时间戳因
跨秒不同);info py chunk 预留峰值 127.4 → 104.0MB,peak_real 不变(67.4MB);
staging 真实请求 3×200,日志零新增。
fix: ?debug=1 不再为追加 _profiling 键重序列化整个响应 phpMan.php 原本用 json_decode() + json_encode() 给 json/mcp 响应加一个 _profiling 键。对 info py 这种 19.5MB 的响应,decoded 数组本身就要 44MB, 单独测峰值 62MB real / 127.6MB chunk,自己就贴着 128MB 上限——staging (PHPMAN_DEBUG=true)对该页面因此稳定 500,而生产因为 debug 关闭不受影响。 - src/cache.php: 新增 appendProfilingJson(),把 _profiling 注入到已序列化的 JSON 对象末尾(结果仍是合法 JSON);JSON 数组与非 JSON 响应原样返回, 避免旧实现把数组响应悄悄变成对象 - phpMan.php: 改调 appendProfilingJson() - test/unit/test_profiling_append.php: 15 条断言覆盖普通/紧凑/空对象、 尾随空白、JSON 数组、非 JSON、空响应、2000-section 大响应 验证:staging(debug 开启)连续 3 次未缓存请求全部 200,JSON 合法、 _profiling 为最后一个键、10757 个 section 完整,日志零新增。