fix(weixin): strip typographic quotes from pasted URLs
Reviewed-by: codex-mini0 Reviewed-by: First-principles-0
This commit is contained in:
+55
-7
@@ -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
|
||||
|
||||
@@ -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 & HTML entity', () => {
|
||||
const html = 'https://mp.weixin.qq.com/s?foo=1&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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user