Skip to content

Commit 12fbfc1

Browse files
longsizhuocopilot-pull-request-reviewer[bot]
andauthored
fix(community/og): 微信 msg_cdn_url 兜底 + http→https + body cap 16MB (#33)
* fix(community/og): 微信 msg_cdn_url 兜底 + http→https 升级 + body cap 16MB ## 病根 线上 13 条 APPROVED 分享里只有 4 条能正常显示封面: - 5/7 公众号 og_cover NULL(公众号 head 没 og:image,封面图埋在 inline script 的 var msg_cdn_url JS 变量里,Jsoup meta 选择器扫不到) - 2/2 小红书 og_cover 是 http://sns-webpic-qc.xhscdn.com/... (HTTPS 页面被浏览器 mixed-content policy 拦掉) - id=20 那条公众号触发 "response body exceeded max size" (head 之前 inline base64 logo + 编辑器 JSON 超过原 8MB 上限) ## 修复 OgFetchService.parseOg 的 cover 查找顺序: og:image -> twitter:image -> findWeixinCover -> upgradeMediaProtocol - findWeixinCover: 正则扫 var msg_cdn_url / cdn_url_1_1 / msg_cover_url 强约束开头必须是 http(s):// 防 XSS 注入 - upgradeMediaProtocol: http:// 盲升 https://(mmbiz / xhscdn / zhimg 三大图床都同时支持 https) - MAX_BODY_BYTES 8MB -> 16MB 前端 sanitizeMediaUrl 加 defense-in-depth 的 http -> https 升级, 万一历史数据 / LLM 兜底回填漏了 https 前端再升一次。 ## 测试 OgFetchServiceTests 新增 4 个用例覆盖 WeChat fallback + 协议升级 + XSS 边界, 10/10 通过。 ## 历史数据回填 docs/community/og-cover-fallbacks.md 写了三种回填方式(SQL 直升 / admin refetch API / CommandLineRunner),本次 7 条用 SQL+API 即可。 * fix(community/og)!: WeChat 正则改成数组按优先级顺序匹配(CR PR#33) Copilot CR 指出原 alternation 正则 `(msg_cdn_url|cdn_url_1_1|msg_cover_url)` + Matcher.find() 返回 HTML 文档顺序里**最早出现**的变量,与注释声明的 "msg_cdn_url 优先" 不一致;微信模板偶尔把 cdn_url_1_1 排在前面就会选错。 改成 Pattern[] 数组按优先级顺序逐个 find(),第一个命中即返回。补 findWeixinCover_priorityIndependentOfDocumentOrder 测试锁定行为。 顺手修 docs/community/og-cover-fallbacks.md "两种回填方式" 与下文 A/B/C 三个方式不一致的描述。 Co-authored-by: copilot-pull-request-reviewer[bot] <copilot-pull-request-reviewer[bot]@users.noreply.github.com> --------- Co-authored-by: copilot-pull-request-reviewer[bot] <copilot-pull-request-reviewer[bot]@users.noreply.github.com>
1 parent 48853dd commit 12fbfc1

4 files changed

Lines changed: 329 additions & 8 deletions

File tree

docs/community/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# community 模块文档
2+
3+
社区分享墙(feed)相关的运维 / 调试笔记。
4+
5+
- [`og-cover-fallbacks.md`](og-cover-fallbacks.md) —— OG 封面抓取兜底链(微信
6+
msg_cdn_url、xhscdn http→https 升级)+ 历史数据回填步骤
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# OG 封面抓取兜底链 & 历史数据回填
2+
3+
> 2026-05-12 一次性修复:线上 13 条 APPROVED 分享里 5 条公众号 og_cover 为 NULL、
4+
> 2 条小红书 og_cover 是 `http://` 被浏览器 mixed-content 拦截,整个 feed 卡片
5+
> 几乎全是裂图/占位。本文记录修复方案 + 历史数据回填步骤。
6+
7+
## 病根
8+
9+
`OgFetchService.parseOg` 原本只查 `<meta property="og:image">`
10+
`<meta name="twitter:image">`。但:
11+
12+
1. **微信公众号 (mp.weixin.qq.com)**`<head>`**没有** og:image
13+
封面图埋在 inline `<script>` 的 JS 变量:
14+
```html
15+
<script>
16+
var msg_cdn_url = "http://mmbiz.qpic.cn/sz_mmbiz_jpg/xxx/0?wx_fmt=jpeg";
17+
var cdn_url_1_1 = "http://mmbiz.qpic.cn/.../640";
18+
</script>
19+
```
20+
→ Jsoup meta 选择器扫不到,cover 留 NULL。
21+
22+
2. **小红书 (xiaohongshu.com)** 的 og:image 值是
23+
`http://sns-webpic-qc.xhscdn.com/...`(明明站点是 HTTPS,CDN 也支持 HTTPS,
24+
就是模板里写了 http)→ feed 卡片用 `<img src="http://...">`,HTTPS 页面会
25+
被浏览器 mixed-content policy 拦掉。
26+
27+
3. **MAX_BODY_BYTES = 8MB** 偶尔被微信公众号撑爆。公众号在 `</head>` 之前会
28+
inline 几 MB 的 base64 logo + 编辑器初始化 JSON,命中早停 marker 之前就
29+
把 8MB 上限读满了 → 整篇 OG 抓取失败。
30+
31+
## 修复
32+
33+
`OgFetchService.parseOg` 现在的 cover 查找顺序:
34+
35+
```
36+
1. <meta property="og:image"> ← 标准 OG
37+
2. <meta name="twitter:image"> ← Twitter Card 兜底
38+
3. var msg_cdn_url / cdn_url_1_1 ← WeChat fallback(findWeixinCover)
39+
4. http:// → https:// 升级 ← upgradeMediaProtocol
40+
```
41+
42+
WeChat 正则强约束开头必须是 `http(s)://`,杜绝 `javascript:` 等 XSS 注入。
43+
44+
`MAX_BODY_BYTES` 提到 16MB。如果将来还有站点撑爆 16MB,得改走图片代理方案
45+
(见下文「长期方向」)。
46+
47+
前端 `lib/url-safety.ts``sanitizeMediaUrl` 也加了 defense-in-depth 的
48+
http→https 升级 —— 万一历史数据或 LLM 兜底回填漏了 https,前端再升一次。
49+
50+
## 回填生产已有数据
51+
52+
代码上线后**新提交的分享会自动走新逻辑**,但已经入库的 NULL / http:// 数据
53+
不会自动重抓。三种回填方式按工作量从小到大:
54+
55+
### 方式 A:SQL 直接升级 http→https(最快,覆盖 2/13)
56+
57+
```sql
58+
-- 直接把 og_cover 是 http:// 的升级成 https://
59+
UPDATE shared_links
60+
SET og_cover = 'https://' || substr(og_cover, 8),
61+
updated_at = now()
62+
WHERE og_cover LIKE 'http://%';
63+
```
64+
65+
### 方式 B:通过 admin 重抓 API 触发完整 enrichment(覆盖 5/13 NULL 公众号)
66+
67+
`/api/admin/community/links/{id}/refetch-og` 端点(M2 PR #23 加的)会重跑
68+
`OgFetchService` + `OgFallbackService`,能把 NULL 的 og_cover 补上。
69+
70+
```bash
71+
# 拿到所有 og_cover 为 NULL 的 APPROVED 链接 id
72+
docker exec involution-postgres psql -U neondb_owner -d involution_hell -At \
73+
-c "SELECT id FROM shared_links WHERE status='APPROVED' AND og_cover IS NULL;"
74+
75+
# 对每个 id 调 admin refetch(需要 admin satoken cookie)
76+
for id in 28 26 25 24 22 20; do
77+
curl -X POST "https://api.involutionhell.com/api/admin/community/links/$id/refetch-og" \
78+
-H "Cookie: satoken=<your-admin-token>"
79+
done
80+
```
81+
82+
### 方式 C:写个 Spring `CommandLineRunner` 一次性扫库重抓
83+
84+
如果回填规模大,可以加一个 profile=backfill-og 才启用的 runner,
85+
启动时扫所有 `og_cover IS NULL OR og_cover LIKE 'http://%'` 的行,
86+
逐条丢给 `SharedLinkEnrichmentWorker.enqueue(id)`。本次只有 7 条,方式 A+B 足够。
87+
88+
## 长期方向:图片代理
89+
90+
即便修好了抓取阶段,浏览器端拉 mmbiz.qpic.cn / xhscdn 还要担心:
91+
- 防盗链:微信 mmbiz.qpic.cn 会按 Referer 判定,`referrerPolicy="no-referrer"`
92+
目前能绕,但腾讯哪天收紧立刻全裂
93+
- 跨域 CDN 偶发 timeout / 403
94+
- 用户上微信公众号「外部链接保护」改 URL 后历史 cover 失效
95+
96+
更稳的方案是自建图片代理 `/api/og-image?u=<encoded-url>`
97+
- 后端拉图(带不带 Referer 自己控)
98+
- 走 R2 / 本地磁盘缓存(7 天 TTL)
99+
- 返回 image/* + 强缓存 header
100+
101+
工程量:~150 行后端 + 改前端 LinkCard 的 src。下一个 milestone 再做。
102+
103+
## 测试
104+
105+
`OgFetchServiceTests` 新增 4 个用例:
106+
107+
- `parseOg_weixinNoOgImage_fallsBackToMsgCdnUrl` —— 公众号 head 没 og:image
108+
时从 inline script 兜底,且协议升级到 https
109+
- `parseOg_httpOgImage_upgradesToHttps` —— og:image 是 http:// 自动升级
110+
- `upgradeMediaProtocol_handlesCaseAndIdempotency` —— 协议升级幂等 & 大小写不敏感
111+
- `findWeixinCover_picksFirstMatchAndIgnoresOtherVars` —— 正则白名单 & XSS 拦截
112+
113+
跑:`./mvnw -Dtest='OgFetchServiceTests' test`

src/main/java/com/involutionhell/backend/community/service/OgFetchService.java

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import java.time.Duration;
2222
import java.util.Locale;
2323
import java.util.Optional;
24+
import java.util.regex.Matcher;
25+
import java.util.regex.Pattern;
2426

2527
/**
2628
* Open Graph 元数据抓取服务(M2)。
@@ -102,17 +104,21 @@ public class OgFetchService {
102104
static final int MAX_REDIRECTS = 3;
103105

104106
/**
105-
* 响应体读取上限(8 MB)。OG meta 全在 {@code <head>} 里,理论上几 KB 够用,
106-
* 但部分站点(如微信公众号)会在 head 之前塞 megabyte 量级的 inline base64
107-
* 图片 + 内联 CSS。8MB 覆盖目前已知所有"正常但臃肿"的站点。
107+
* 响应体读取上限(16 MB)。OG meta 全在 {@code <head>} 里,理论上几 KB 够用,
108+
* 但部分站点(尤其微信公众号 mp.weixin.qq.com)会在 head 之前塞 megabyte 量级
109+
* 的 inline base64 logo + 内联 CSS + 大段编辑器初始化 JSON。
110+
*
111+
* 历史:原 8MB,线上 id=20 那条微信文章触发了 "response body exceeded max size"
112+
* —— head 还没读到 inline base64 资源就把上限吃满了。提到 16MB 覆盖目前已知
113+
* 所有"正常但臃肿"的站点,再大就走图片代理(长期方案)兜底。
108114
*
109115
* 真正的优化是 {@link #HEAD_END_MARKER} 早停 —— 流式扫描遇到 {@code </head>}
110116
* 立即停读,绝大多数站点只读几十 KB 就够。这个上限只是无限流防御兜底。
111117
*
112118
* 之所以要在应用层兜底限流:JDK {@code BodyHandlers.ofString()} 本身
113119
* 没有 size 限制,配上 10 秒 timeout 仍然可能被攻击者的无限流撑爆堆。
114120
*/
115-
static final int MAX_BODY_BYTES = 8 * 1024 * 1024;
121+
static final int MAX_BODY_BYTES = 16 * 1024 * 1024;
116122

117123
private final HttpClient httpClient;
118124
private final UrlNormalizer urlNormalizer;
@@ -440,11 +446,45 @@ private static String resolveRedirect(URI base, String location) {
440446
return base.resolve(location).toString();
441447
}
442448

449+
/**
450+
* 微信公众号封面图正则(每个变量名一个,按优先级独立匹配)。
451+
* <p>
452+
* 公众号文章 head 里**没有** {@code <meta property="og:image">},封面 URL 埋在
453+
* {@code <script>} 里的 JS 变量:
454+
* <pre>
455+
* var msg_cdn_url = "http://mmbiz.qpic.cn/sz_mmbiz_jpg/xxxxx/0?wx_fmt=jpeg";
456+
* var cdn_url_1_1 = "http://mmbiz.qpic.cn/.../640"; // 备用
457+
* var msg_cover_url = "..."; // 极少数模板
458+
* </pre>
459+
* <p>
460+
* 历史踩坑:原来一条 alternation 正则 + {@code Matcher#find()} 只能返回 HTML
461+
* 中**最先出现**的变量,而 WeChat 模板偶尔把 cdn_url_1_1 排在 msg_cdn_url 之前,
462+
* 这种情况下旧实现会拿到低优先级变量。改成三条独立正则,按 array 顺序依次扫,
463+
* 第一条命中就返回,确保优先级和注释一致。
464+
* <p>
465+
* 安全:内容必须以 http(s):// 开头,杜绝 javascript: / data: 等被偷换的可能。
466+
*/
467+
private static final Pattern[] WEIXIN_COVER_PATTERNS = {
468+
compileVarPattern("msg_cdn_url"),
469+
compileVarPattern("cdn_url_1_1"),
470+
compileVarPattern("msg_cover_url"),
471+
};
472+
473+
private static Pattern compileVarPattern(String varName) {
474+
return Pattern.compile(
475+
"var\\s+" + varName + "\\s*=\\s*[\"'](https?://[^\"'\\s]+)[\"']",
476+
Pattern.CASE_INSENSITIVE);
477+
}
478+
443479
/**
444480
* 用 Jsoup 解析 HTML,提取 Open Graph meta 标签。
445481
*
446-
* 优先取 og: 命名空间的标准标签;
447-
* og:image 找不到时退而求其次取 twitter:image(不少平台两者都填了)。
482+
* 封面查找顺序:
483+
* 1. {@code <meta property="og:image">}(标准 OG)
484+
* 2. {@code <meta name="twitter:image">}(部分平台只填 Twitter Card)
485+
* 3. WeChat fallback:扫 {@code var msg_cdn_url = "..."} 一类 JS 变量
486+
* (公众号 head 里没有 og:image,封面图全埋在 inline script 里)
487+
* 4. 最后做 http -> https 升级,避免在 HTTPS 页面上被 mixed-content 拦截
448488
*/
449489
OgFetchResult parseOg(String html, String baseUrl) {
450490
Document doc = Jsoup.parse(html, baseUrl);
@@ -454,11 +494,22 @@ OgFetchResult parseOg(String html, String baseUrl) {
454494
String cover = metaContent(doc, "og:image");
455495
String siteName = metaContent(doc, "og:site_name");
456496

457-
// 封面降级:部分平台只填 twitter:image
497+
// 封面降级 1:部分平台只填 twitter:image
458498
if (cover == null) {
459499
cover = metaContent(doc, "twitter:image");
460500
}
461501

502+
// 封面降级 2:微信公众号专项 —— 公众号 head 没 og:image,得扫 JS 变量
503+
// 不限定 host:少数自建站点(如转载公众号文章的内容农场)也保留了这些
504+
// 变量名,多兜一手没坏处。正则强约束开头必须是 http(s):// 防 XSS。
505+
if (cover == null) {
506+
cover = findWeixinCover(html);
507+
}
508+
509+
// 统一升级 http -> https:xhscdn / mmbiz / pic.zhimg 等主流图床都支持 https,
510+
// 留 http 会被浏览器 mixed-content policy 直接拦掉
511+
cover = upgradeMediaProtocol(cover);
512+
462513
// 标题降级:og:title 没有时取 <title> 标签
463514
if (title == null) {
464515
Element titleEl = doc.selectFirst("title");
@@ -467,10 +518,48 @@ OgFetchResult parseOg(String html, String baseUrl) {
467518
}
468519
}
469520

470-
log.debug("og-fetch 解析完成: title={} siteName={}", title, siteName);
521+
log.debug("og-fetch 解析完成: title={} siteName={} hasCover={}",
522+
title, siteName, cover != null);
471523
return new OgFetchResult(title, description, cover, siteName, null);
472524
}
473525

526+
/**
527+
* 微信公众号封面提取:按 {@link #WEIXIN_COVER_PATTERNS} 数组顺序依次扫,
528+
* 第一条命中的变量即返回;都没命中返回 null。
529+
* <p>
530+
* 优先级靠数组顺序而非单条 alternation 正则,避免 {@code Matcher#find()}
531+
* 返回 HTML 文档顺序里最早出现的变量(与注释声明的优先级不一致)。
532+
*/
533+
static String findWeixinCover(String html) {
534+
if (html == null || html.isEmpty()) return null;
535+
for (Pattern p : WEIXIN_COVER_PATTERNS) {
536+
Matcher m = p.matcher(html);
537+
if (m.find()) {
538+
return m.group(1).trim();
539+
}
540+
}
541+
return null;
542+
}
543+
544+
/**
545+
* 把媒体 URL 的 http:// 升级为 https://。
546+
* <p>
547+
* 触发场景:小红书 og:image 值是 {@code http://sns-webpic-qc.xhscdn.com/...},
548+
* 微信 msg_cdn_url 是 {@code http://mmbiz.qpic.cn/...}。浏览器在 HTTPS 页面
549+
* 加载这些资源会被 mixed-content policy 拦截。三大主流图床(xhscdn / mmbiz /
550+
* pic.zhimg)都同时支持 https,盲升级安全。
551+
* <p>
552+
* 不动协议相对 URL({@code //example.com/x.jpg})和已是 https 的 URL。
553+
* 非 http/https 协议(如 data:、ftp:)原样返回,由上层的 sanitizeMediaUrl 处理。
554+
*/
555+
static String upgradeMediaProtocol(String url) {
556+
if (url == null || url.isEmpty()) return url;
557+
if (url.regionMatches(true, 0, "http://", 0, 7)) {
558+
return "https://" + url.substring(7);
559+
}
560+
return url;
561+
}
562+
474563
/**
475564
* 提取 <meta property="..." content="..."> 或 <meta name="..." content="..."> 的 content 值。
476565
* 返回 null 表示该标签不存在。

0 commit comments

Comments
 (0)