Skip to content

fix(scraper): encode YouTube Data API query params through a shared builder - #24

Merged
oratis merged 1 commit into
mainfrom
claude/heuristic-cerf-9541b5
Aug 9, 2026
Merged

fix(scraper): encode YouTube Data API query params through a shared builder#24
oratis merged 1 commit into
mainfrom
claude/heuristic-cerf-9541b5

Conversation

@oratis

@oratis oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner

背景

server/scraper.jsserver/index.js 里的 YouTube Data API v3 请求 URL 是用模板字符串手工拼的。其中三处的值仍然没有编码就直接插进 query string:

位置 来源
scraper.js:134 videoIds.join(',') YouTube API 响应
index.js:6327 uploadsPlaylistId YouTube API 响应
index.js:6426 videoIds.join(',') YouTube API 响应

host 是写死的,所以不是 SSRF;但这是往一个带鉴权、计配额的第三方 API 调用里注入参数,会产生错误结果和莫名其妙的配额消耗。

两种失效方式(已用旧字符串形式实测复现):

旧 videos.list    part= 个数: 2      id 值: vid1       ← 参数被注入 + 截断
旧 playlistItems  key= 值: null      fragment: "#&maxResults=1&key=secret-api-key"

# 是更糟的一种 —— 它把 &key=... 挤进了 URL fragment,那部分根本没发给 Google

补充一个威胁模型上的细节:handle / customName / channelId 三条路径此前已经encodeURIComponent,而且它们的提取正则是 [^/?&]+,在到达 URL 之前就把 & 剥掉了 —— 这两条路的 & 注入是双重挡住的。但 # 不在那个字符类里。

改动

手拼模板的写法本身会招来下一次裸插值,所以这里做的是结构性修复而不是逐点打补丁:

  • 新增 server/youtube-api.js —— youtubeApiUrl(endpoint, params),对每个值编码,并且把 nullish 值直接丢弃(而不是像旧模板那样序列化成字符串 "undefined"
  • 全部 11 个调用点改走它:scraper.js 6 个、index.js 5 个。两个文件里已无残留的裸 googleapis.com/youtube 模板字符串

一处与常规建议不同的取舍

没有用 new URLSearchParams({...}) 当编码器。因为它会把 , 编码成 %2C,而逗号正是 Data API 在 part / id 上的列表分隔符。在一个"目的就是不要静默产生错误结果"的修复里去赌 Google 解析器接受 %2C,不划算。所以 builder 的做法是:逐个元素编码 + 字面逗号拼接 —— 这正是 index.js:6311 已经在生产跑着的形状。

已验证:对真实的 channel / playlist id,产出的 URL 与旧 URL 逐字节一致。唯一有意的线上格式变化是 publishedAfter,其中的冒号现在编码成 %3A(标准行为,解码后完全相同)。

测试

新增 server/__tests__/youtube-api-url.test.js,7 条:

  • 6 条覆盖 builder,含本次要求的那条 —— handle 为 abc&part=contentDetails 时产出的 URL 只有一个 part=;另有 API key 被顶替、# 截断、列表编码、nullish 丢弃、固定 host
  • 第 7 条驱动真实的 scrapeYouTube(stub 掉 ./proxy-fetch),断言发出去的每一条 URL 都只有一个 part=、一个 key=、API key 完好、无 fragment。这条同时覆盖了没有任何正则过滤的 API 响应侧 videoId 路径

这条集成测试第一次是失败的,而且失败得对 —— 我原本假设 & 能穿过 handle 提取,实际穿不过。于是该路径的 payload 改成了 #& 注入则在 builder 层和无过滤的 video-id 路径上验证。

npm test → 663/663 通过(原 656,+7)

旧字符串形式已单独验证会在同样的断言上失败,确认测试有牙齿。

说明

  • 客户端零改动;本 worktree 内没有 client/node_modules,因此没跑 vite build
  • server/youtube-discovery.js(64、88 行)和 server/content-metrics.js(71 行)同样手拼 YouTube URL,但当前都已正确编码,属于本次范围之外,未动。它们是下一个人会照抄的写法 —— 需要的话可以另开一个 PR 一并收进这个 builder
  • 未部署

🤖 Generated with Claude Code

…uilder

Several YouTube Data API v3 request URLs were assembled by interpolating
values straight into a template literal. Three were still raw: the video-id
lists in scraper.js and index.js, and uploadsPlaylistId in the batch-discovery
path. The host is fixed so this is not SSRF, but a value carrying `&` or `#`
injects or truncates parameters in an authenticated, quota-metered third-party
call. `#` was the worse case — it pushed `&key=...` into a URL fragment that
never reached Google — and an injected `part=` silently doubles the parameter
and changes the response shape.

The handle / customName / channelId paths were already covered by
encodeURIComponent (and their extraction regex strips `&` before it reaches the
URL), but hand-rolled templates invite the next raw interpolation, so the fix
is structural rather than site-by-site.

- add server/youtube-api.js: youtubeApiUrl(endpoint, params), which encodes
  every value and drops nullish ones instead of serialising "undefined"
- route all 11 call sites through it — scraper.js (6), index.js (5)
- list params (part / id) keep the API's literal comma separator with each
  element encoded individually; URLSearchParams is deliberately not the
  encoder, since it emits that separator as %2C

Wire format is byte-identical to the previous URLs for real channel and
playlist ids. The only intentional change is publishedAfter, whose colons now
encode as %3A.

Verified: npm test 663/663 (was 656; +7 in server/__tests__/youtube-api-url.test.js
covering the builder plus a scrapeYouTube run against a stubbed proxy-fetch).
The old string form was checked to fail the same assertions — two `part=`
parameters, and the API key lost to the fragment. Client is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis merged commit 7df4183 into main Aug 9, 2026
5 checks passed
@oratis
oratis deleted the claude/heuristic-cerf-9541b5 branch August 9, 2026 15:08
oratis added a commit that referenced this pull request Aug 9, 2026
…lake

Review follow-up on this PR. The numbers went stale during review: #23
(+9 tests, +1 file) and #24 (+7, +1) merged while this branch sat open,
so "656 / 69 files" was already wrong by the time it could land — the
exact failure mode the PR exists to fix.

Measured on main at 7df4183: 678 tests across 71 files. Corrected in all
five places (CLAUDE.md ×2, memory.md §5.2 / §7.3 / Last-reviewed footer,
which also still said "post #20").

Also documents why a clean checkout can show 2-5 red files: `npm test` is
`node --test`, which runs files concurrently against the one shared
influencex.db at the repo root, so writes collide and report
`{ code: 'SQLITE_BUSY' }` on a rotating cast of files. It reproduces on
main with no changes applied, and the triage step is a serialized re-run
(--test-concurrency=1 → stable 678/678). Without this written down the
next session reads the flake as its own regression — which is what the
rest of this PR is trying to prevent.

Verified: 678/678 serialized on main, and on main + #25 + this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oratis added a commit that referenced this pull request Aug 9, 2026
)

#24 introduced youtubeApiUrl() and migrated scraper.js and index.js, but
youtube-discovery.js and content-metrics.js kept hand-rolling their
request URLs. Both were safe — an earlier pass had added
encodeURIComponent inline — but a builder that only covers some call
sites is exactly the shape the original bug had: the injection survived
because the construction was spread across ~13 places and each one had to
be remembered separately. Their inputs are user-derived too (the search
keyword, and a videoId parsed out of a submitted content URL).

Adds a coverage guard that walks server/ and fails if any module builds a
Data API URL directly again, so the next call site can't quietly opt out.
youtube-api.js owns the one literal; publish/oauth.js is allowlisted
because its OAuth userinfo URL is static (mine=true, nothing
interpolated). Verified the guard fails and names the file when a raw URL
is reintroduced.

npm test 678/678.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
oratis added a commit that referenced this pull request Aug 9, 2026
* docs: 修正入门文档里已经失效的运维事实

排查一份 brand-voice embedding 的 bug 报告时发现问题本身已经在 #20
(`0c5323b`)修掉了,但顺手核对上下文的过程中撞上好几处文档与现状不符 ——
每一处都会让下一个会话走一段冤枉路,所以单独收一个 docs PR。

改了什么:

- **prod DB 口令来源**:CLAUDE.md 和 memory.md 有 5 处说密码"在 .env 里",
  但这台机器上根本没有 .env(只有 .env.example)。全部改为从 Secret Manager
  取:`gcloud secrets versions access latest --secret=DATABASE_URL`。
- **测试数字**:CLAUDE.md 同时写着 377 和 234,memory.md 写 234 —— 实际是
  656 个服务端测试 / 69 个文件、~1 秒。前端测试也不再是"4 个组件测试"或
  "还没有",是 13 个 vitest 文件 + 5 条 Playwright(3 个 spec)。
- **Sentry / OTEL**:memory.md §5.4 还写着"没有(Sprint 1 待加)",与同文件
  §6 已关闭表和 CLAUDE.md 自相矛盾。两者早已接入,只是要配 DSN / OTLP 才上报。
- **brand_voices 生产现状**:借这次机会连 prod 只读查了一次 —— 表 0 行。
  也就是说 embedding 写入路径坏了这么久没有造成数据损失,不需要 backfill。
  这条结论写进 memory.md §6,省得以后有人再问一遍。
- 新增一条"仍然成立":embedding 只在 `POST /api/brand-voices` 创建时写,
  目前没有 update 路由所以无害,但以后加编辑接口必须重新 embed。

验证:纯文档改动,未动任何代码路径;`npm test` 656/656 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: correct this PR's own test counts, and record the SQLITE_BUSY flake

Review follow-up on this PR. The numbers went stale during review: #23
(+9 tests, +1 file) and #24 (+7, +1) merged while this branch sat open,
so "656 / 69 files" was already wrong by the time it could land — the
exact failure mode the PR exists to fix.

Measured on main at 7df4183: 678 tests across 71 files. Corrected in all
five places (CLAUDE.md ×2, memory.md §5.2 / §7.3 / Last-reviewed footer,
which also still said "post #20").

Also documents why a clean checkout can show 2-5 red files: `npm test` is
`node --test`, which runs files concurrently against the one shared
influencex.db at the repo root, so writes collide and report
`{ code: 'SQLITE_BUSY' }` on a rotating cast of files. It reproduces on
main with no changes applied, and the triage step is a serialized re-run
(--test-concurrency=1 → stable 678/678). Without this written down the
next session reads the flake as its own regression — which is what the
rest of this PR is trying to prevent.

Verified: 678/678 serialized on main, and on main + #25 + this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant