fix(weixin): strip typographic quotes from pasted URLs

Reviewed-by: codex-mini0
Reviewed-by: First-principles-0
This commit is contained in:
Zhongyue Lin
2026-06-01 02:14:58 +08:00
committed by GitHub
parent a73301f7bf
commit 2615d331e8
2 changed files with 161 additions and 7 deletions
+55 -7
View File
@@ -14,17 +14,65 @@ import { downloadArticle } from '@jackwener/opencli/download/article-download';
/**
* Normalize a pasted WeChat article URL.
*/
// Wrapping quote characters to strip from a pasted URL. Covers ASCII plus
// CJK typographic / smart quotes, which are common when users copy URLs from
// Chinese-language environments (WeChat itself, macOS smart-quote
// substitution, Word / Pages, …).
//
// Pairs:
// " " ASCII straight double
// ' ' ASCII straight single
// “ ” curly double (U+201C / U+201D)
// curly single (U+2018 / U+2019)
// 「 」 CJK corner brackets (U+300C / U+300D)
// 『 』 CJK white corner brackets (U+300E / U+300F)
// „ ‟ German-style double quotes (U+201E / U+201F)
// single guillemets (U+2039 / U+203A)
// « » double guillemets (U+00AB / U+00BB)
const WRAPPING_QUOTE_PAIRS = [
['"', '"'],
["'", "'"],
['“', '”'],
['', ''],
['「', '」'],
['『', '』'],
['„', '‟'],
['', ''],
['«', '»'],
];
const LEADING_WRAP_CHARS = new Set(WRAPPING_QUOTE_PAIRS.map(([open]) => open).concat('<'));
const TRAILING_WRAP_CHARS = new Set(WRAPPING_QUOTE_PAIRS.map(([, close]) => close).concat('>'));
function stripBoundaryWrapChars(value) {
let s = value;
for (let i = 0; i < 4; i += 1) {
const before = s;
for (const [open, close] of WRAPPING_QUOTE_PAIRS) {
if (s.length >= 2 && s.startsWith(open) && s.endsWith(close)) {
s = s.slice(open.length, s.length - close.length).trim();
break;
}
}
while (s && LEADING_WRAP_CHARS.has(s[0])) {
s = s.slice(1).trimStart();
}
while (s && TRAILING_WRAP_CHARS.has(s[s.length - 1])) {
s = s.slice(0, -1).trimEnd();
}
if (s === before)
break;
}
return s;
}
export function normalizeWechatUrl(raw) {
let s = (raw || '').trim();
if (!s)
return s;
// Strip wrapping quotes / angle brackets
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
s = s.slice(1, -1).trim();
}
if (s.startsWith('<') && s.endsWith('>')) {
s = s.slice(1, -1).trim();
}
// Strip quote / angle-bracket characters only at the pasted boundary. This
// handles both paired wrappers ("<url>", "“url”") and common one-sided
// trailing punctuation ("url”") without touching encoded URL content.
s = stripBoundaryWrapChars(s);
// Remove backslash escapes before URL-significant characters
s = s.replace(/\\+([:/&?=#%])/g, '$1');
// Decode HTML entities
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import { normalizeWechatUrl } from './download.js';
describe('normalizeWechatUrl', () => {
const canonical = 'https://mp.weixin.qq.com/s/oBz-oik0i9YM2Uia_aadjw';
it('returns the input unchanged when already canonical', () => {
expect(normalizeWechatUrl(canonical)).toBe(canonical);
});
it('returns empty string for empty / nullish input', () => {
expect(normalizeWechatUrl('')).toBe('');
expect(normalizeWechatUrl(null)).toBe('');
expect(normalizeWechatUrl(undefined)).toBe('');
});
it('strips ASCII straight double quotes', () => {
expect(normalizeWechatUrl(`"${canonical}"`)).toBe(canonical);
});
it('strips ASCII straight single quotes', () => {
expect(normalizeWechatUrl(`'${canonical}'`)).toBe(canonical);
});
it('strips CJK curly double quotes (U+201C / U+201D)', () => {
// Wrap the URL in left/right curly double quotes — common when
// copy-pasting from WeChat / macOS smart-quote substitution.
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
});
it('strips CJK curly single quotes (U+2018 / U+2019)', () => {
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
});
it('strips CJK corner brackets 「 」 (U+300C / U+300D)', () => {
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
});
it('strips CJK white corner brackets 『 』 (U+300E / U+300F)', () => {
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
});
it('strips German-style double quotes „ ‟ (U+201E / U+201F)', () => {
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
});
it('strips single guillemets (U+2039 / U+203A)', () => {
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
});
it('strips double guillemets « » (U+00AB / U+00BB)', () => {
expect(normalizeWechatUrl(`«${canonical}»`)).toBe(canonical);
});
it('strips wrapping angle brackets < >', () => {
expect(normalizeWechatUrl(`<${canonical}>`)).toBe(canonical);
});
it('handles whitespace around the wrapping quotes', () => {
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
});
it('strips one-sided trailing smart quotes from pasted URL text', () => {
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
expect(normalizeWechatUrl(`${canonical}>`)).toBe(canonical);
});
it('strips one-sided leading smart quotes from pasted URL text', () => {
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
expect(normalizeWechatUrl(`${canonical}`)).toBe(canonical);
expect(normalizeWechatUrl(`<${canonical}`)).toBe(canonical);
});
it('strips asymmetric boundary quote punctuation without touching the URL body', () => {
expect(normalizeWechatUrl(`${canonical}"`)).toBe(canonical);
});
it('does not strip encoded quote-like characters inside the URL', () => {
const withEncodedQuote = 'https://mp.weixin.qq.com/s/oBz-oik0i9YM2Uia_aadjw?note=%E2%80%9D#frag';
expect(normalizeWechatUrl(withEncodedQuote)).toBe(withEncodedQuote);
});
it('removes backslash escapes inserted by some shells', () => {
const escaped = 'https\\://mp.weixin.qq.com/s/oBz-oik0i9YM2Uia_aadjw';
expect(normalizeWechatUrl(escaped)).toBe(canonical);
});
it('decodes &amp; HTML entity', () => {
const html = 'https://mp.weixin.qq.com/s?foo=1&amp;bar=2';
expect(normalizeWechatUrl(html)).toBe('https://mp.weixin.qq.com/s?foo=1&bar=2');
});
it('handles bare mp.weixin.qq.com hostname (no protocol)', () => {
expect(normalizeWechatUrl('mp.weixin.qq.com/s/oBz-oik0i9YM2Uia_aadjw')).toBe(canonical);
});
it('handles //mp.weixin.qq.com/... protocol-relative URL', () => {
expect(normalizeWechatUrl('//mp.weixin.qq.com/s/oBz-oik0i9YM2Uia_aadjw')).toBe(canonical);
});
it('forces https:// for mp.weixin.qq.com http:// links', () => {
const http = 'http://mp.weixin.qq.com/s/oBz-oik0i9YM2Uia_aadjw';
expect(normalizeWechatUrl(http)).toBe(canonical);
});
});