fix: truncate article titles to safe UTF-8 budget

This commit is contained in:
Longtail committed 2026-10-31 18:38:41 +08:00
1 parent 24a73b19c0
commit 7ab0bc3485
7 files changed
+115 -15

No files matched your search

+7 -1
View File
@@ -69,7 +69,7 @@ downloads/ 本工具下载结果(本地)
- `test/history/` 保存从本目录移入的两张演示截图与演示任务 JSON。根目录其他测试文件未搬动。 - `test/history/` 保存从本目录移入的两张演示截图与演示任务 JSON。根目录其他测试文件未搬动。
- `发布去重记录.json` 是实际受理证据,继续留在原处,并在服务启动时迁入去重库。它不代表完整历史已对账。原文件可找到时还建立内容指纹;原文件丢失时仍保留原 Markdown 摘要。 - `发布去重记录.json` 是实际受理证据,继续留在原处,并在服务启动时迁入去重库。它不代表完整历史已对账。原文件可找到时还建立内容指纹;原文件丢失时仍保留原 Markdown 摘要。
- 原始下载文件不改写。长标题生成保守 UTF-16 长度不超过 80 的派生标题并加 `...`,原题仍展示在队列;网站真实计数规则尚需实测。 - 原始下载文件不改写。标题按 UTF-8 字节数计长:ASCII 为 1,中文和中文标点为 3,emoji 通常为 4。超过 75 时保留不超过 75 的完整前缀,再加 `...`,总长最多 78;原题仍保留。
- 排版仅处理与文章来源一致的末尾标准尾注,保留正文中的其他“来源”文字。图片顺序、标识改变会回滚排版。 - 排版仅处理与文章来源一致的末尾标准尾注,保留正文中的其他“来源”文字。图片顺序、标识改变会回滚排版。
- WebP/AVIF/SVG 当前明确拦截,需用户另存支持格式并更新 Markdown;没有静默丢图或自动转码。单图须小于 20 MiB,图片 base64 总大小不超过 48 MiB。 - WebP/AVIF/SVG 当前明确拦截,需用户另存支持格式并更新 Markdown;没有静默丢图或自动转码。单图须小于 20 MiB,图片 base64 总大小不超过 48 MiB。
- 本地库含文章内容快照和账号标识,不含账号密码、Cookie 或上传凭据。运行数据和历史截图已加入忽略规则。 - 本地库含文章内容快照和账号标识,不含账号密码、Cookie 或上传凭据。运行数据和历史截图已加入忽略规则。
@@ -122,3 +122,9 @@ node test/browser.test.cjs
## 0.2.1 临时审核提示规则 ## 0.2.1 临时审核提示规则
更新油猴中的 `browser/auto-news.user.js` 至 0.2.1,并重启本地服务、重新配对后刷新论坛页面。连接脚本无需更新。结果适配器和执行器中的旧详情页核对代码以行注释保留;后端旧核对接口继续保留供人工历史补核使用。自动受理使用当前任务的人工提交批次校验,审核提示可不含帖子链接,不创建虚构帖子 ID。 更新油猴中的 `browser/auto-news.user.js` 至 0.2.1,并重启本地服务、重新配对后刷新论坛页面。连接脚本无需更新。结果适配器和执行器中的旧详情页核对代码以行注释保留;后端旧核对接口继续保留供人工历史补核使用。自动受理使用当前任务的人工提交批次校验,审核提示可不含帖子链接,不创建虚构帖子 ID。
## 标题自动截断
自动队列、手动目录和文章包导入均应用上述规则;已截断的标题不会重复添加省略号。已有队列在领取未开始的任务时更新标题,已导入、待审核或已提交任务保持现场标题。
应用更新:重启本地服务,并在油猴重新安装 `browser/auto-news.user.js`。原始 Markdown 不改写。
+7 -4
View File
@@ -56,15 +56,18 @@ def validate_images(body, directory):
raise ValueError('配图为空或达到 20 MiB:' + target) raise ValueError('配图为空或达到 20 MiB:' + target)
def short_title(title, budget=80): def short_title(title, budget=75):
# Conservative UTF-16 budget: do not split astral characters / surrogate pairs. """Keep a UTF-8 prefix within budget, adding three ASCII dots if shortened."""
if len(title.encode('utf-16-le')) // 2 <= budget: if len(title.encode('utf-8')) <= budget or (title.endswith('...') and len(title.encode('utf-8')) <= budget + 3):
return title return title
out = '' out = ''
used = 0
for ch in title: for ch in title:
if len((out + ch).encode('utf-16-le')) // 2 > budget - 3: size = len(ch.encode('utf-8'))
if used + size > budget:
break break
out += ch out += ch
used += size
return out + '...' return out + '...'
+22 -3
View File
@@ -153,12 +153,31 @@
return output.join('\n').trim(); return output.join('\n').trim();
} }
function shortTitle(title, budget = 75) {
const size = ch => {
const code = ch.codePointAt(0);
return code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
};
// A generated prefix plus "..." (up to 78 bytes) is already safe.
const total = Array.from(title).reduce((sum, ch) => sum + size(ch), 0);
if (total <= budget || (title.endsWith('...') && total <= budget + 3)) return title;
let prefix = '', used = 0;
for (const ch of title) {
const bytes = size(ch);
if (used + bytes > budget) break;
prefix += ch;
used += bytes;
}
return prefix + '...';
}
function compile(markdown, mdPath, fileMap, titleOverride) { function compile(markdown, mdPath, fileMap, titleOverride) {
let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/); const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/);
const title = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim(); const originalTitle = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim();
const title = shortTitle(originalTitle);
if (firstHeading) body = body.slice(firstHeading[0].length).trim(); if (firstHeading) body = body.slice(firstHeading[0].length).trim();
if (!title || Array.from(title).length > 80) throw new Error('标题为空或超过 80 字,请修改 Markdown 标题后重试。'); if (!title) throw new Error('标题为空,请修改 Markdown 标题后重试。');
if (!body.trim()) throw new Error('正文为空。'); if (!body.trim()) throw new Error('正文为空。');
const warnings = []; const warnings = [];
if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。'); if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。');
@@ -531,7 +550,7 @@
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json'); $('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
win.addEventListener('pagehide', releasePreviews, { once: true }); win.addEventListener('pagehide', releasePreviews, { once: true });
} }
return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, attachmentId, verifyEditorImages, mount }; return { VERSION, shortTitle, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, attachmentId, verifyEditorImages, mount };
}); });
// Extracted from dww11-wechat-oneclick.user.js v1.0.9; package source is authoritative. // Extracted from dww11-wechat-oneclick.user.js v1.0.9; package source is authoritative.
+22 -3
View File
@@ -153,12 +153,31 @@
return output.join('\n').trim(); return output.join('\n').trim();
} }
function shortTitle(title, budget = 75) {
const size = ch => {
const code = ch.codePointAt(0);
return code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
};
// A generated prefix plus "..." (up to 78 bytes) is already safe.
const total = Array.from(title).reduce((sum, ch) => sum + size(ch), 0);
if (total <= budget || (title.endsWith('...') && total <= budget + 3)) return title;
let prefix = '', used = 0;
for (const ch of title) {
const bytes = size(ch);
if (used + bytes > budget) break;
prefix += ch;
used += bytes;
}
return prefix + '...';
}
function compile(markdown, mdPath, fileMap, titleOverride) { function compile(markdown, mdPath, fileMap, titleOverride) {
let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/); const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/);
const title = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim(); const originalTitle = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim();
const title = shortTitle(originalTitle);
if (firstHeading) body = body.slice(firstHeading[0].length).trim(); if (firstHeading) body = body.slice(firstHeading[0].length).trim();
if (!title || Array.from(title).length > 80) throw new Error('标题为空或超过 80 字,请修改 Markdown 标题后重试。'); if (!title) throw new Error('标题为空,请修改 Markdown 标题后重试。');
if (!body.trim()) throw new Error('正文为空。'); if (!body.trim()) throw new Error('正文为空。');
const warnings = []; const warnings = [];
if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。'); if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。');
@@ -531,5 +550,5 @@
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json'); $('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
win.addEventListener('pagehide', releasePreviews, { once: true }); win.addEventListener('pagehide', releasePreviews, { once: true });
} }
return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, attachmentId, verifyEditorImages, mount }; return { VERSION, shortTitle, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, attachmentId, verifyEditorImages, mount };
}); });
+2 -1
View File
@@ -10,7 +10,7 @@ import threading
import time import time
from urllib.parse import urlsplit, parse_qs from urllib.parse import urlsplit, parse_qs
from articles import inspect_article from articles import inspect_article, short_title
ACCEPTED = {'published', 'accepted_pending_moderation'} ACCEPTED = {'published', 'accepted_pending_moderation'}
SUBMITTED = {'user_submitting', 'submit_unknown'} SUBMITTED = {'user_submitting', 'submit_unknown'}
@@ -204,6 +204,7 @@ class Store:
self.run['switch'] = dict(state='pending', **{'from': account['uid']}, created_at=time.time()) self.run['switch'] = dict(state='pending', **{'from': account['uid']}, created_at=time.time())
self._persist(db) self._persist(db)
return dict(action='switch', run=self.run) return dict(action='switch', run=self.run)
job['title'] = short_title(job.get('original_title', job['title']))
job.update(state='preparing', account=account, fid=self.run['config']['fid'], error='') job.update(state='preparing', account=account, fid=self.run['config']['fid'], error='')
self.run['active'] = job['id'] self.run['active'] = job['id']
self._save(db, job) self._save(db, job)
+37 -2
View File
@@ -29,12 +29,11 @@ test('测试文章:标题、3 张配图、插入顺序、来源与段落完整
assert.equal(output, originalBody); assert.equal(output, originalBody);
}); });
test('缺图、远程图片、超出目录、超大图片与超长标题在上传前被拒绝', () => { test('缺图、远程图片、超出目录与超大图片在上传前被拒绝', () => {
assert.throws(() => core.compile('# 标题\n\n![](missing.jpg)', 'a.md', new Map()), /找不到配图/); assert.throws(() => core.compile('# 标题\n\n![](missing.jpg)', 'a.md', new Map()), /找不到配图/);
assert.throws(() => core.compile('# 标题\n\n![](https://x/a.jpg)', 'a.md', new Map()), /目录内/); assert.throws(() => core.compile('# 标题\n\n![](https://x/a.jpg)', 'a.md', new Map()), /目录内/);
assert.throws(() => core.imagePath('a.md', '../secret.jpg'), /超出/); assert.throws(() => core.imagePath('a.md', '../secret.jpg'), /超出/);
assert.throws(() => core.imagePath('a.md', '%2fetc/passwd'), /相对路径/); assert.throws(() => core.imagePath('a.md', '%2fetc/passwd'), /相对路径/);
assert.throws(() => core.compile('# ' + '长'.repeat(81) + '\n正文', 'a.md', new Map()), /80/);
assert.throws(() => core.compile('# 标题\n\n![](big.png)', 'a.md', new Map([['big.png', {size:20*1024*1024}]])), /20 MB/); assert.throws(() => core.compile('# 标题\n\n![](big.png)', 'a.md', new Map([['big.png', {size:20*1024*1024}]])), /20 MB/);
}); });
@@ -209,3 +208,39 @@ test('正文图片尚在加载时等待 load;终止后释放等待', async ()
const cancelled=core.verifyEditorImages({querySelectorAll:()=>[pending]},['8'],controller.signal); const cancelled=core.verifyEditorImages({querySelectorAll:()=>[pending]},['8'],controller.signal);
controller.abort();await assert.rejects(cancelled,/终止/);assert.equal(pending.events.size,0); controller.abort();await assert.rejects(cancelled,/终止/);assert.equal(pending.events.size,0);
}); });
test('标题按 UTF-8 预算截断,目录和文章包保持一致且重复处理稳定', async () => {
const cases = [
['A'.repeat(75), 'A'.repeat(75)],
['A'.repeat(76), 'A'.repeat(75) + '...'],
['中'.repeat(25), '中'.repeat(25)],
['中'.repeat(26), '中'.repeat(25) + '...'],
['A中,'.repeat(12), 'A中,'.repeat(10) + 'A中...'],
['😀'.repeat(20), '😀'.repeat(18) + '...'],
];
for (const [title, expected] of cases) {
assert.equal(core.shortTitle(title), expected);
assert.equal(core.shortTitle(expected), expected);
assert.ok(Buffer.byteLength(expected) <= 78);
const markdown = '# ' + title + '\n正文';
const direct = core.compile(markdown, 'a.md', new Map());
const packaged = await core.preparePackage({schemaVersion:1, markdown, title, images:[]});
assert.equal(direct.title, expected);
assert.equal(packaged.title, expected);
const python = execFileSync('python3', ['-c', 'import sys; from articles import short_title; print(short_title(sys.argv[1]), end="")', title], {cwd:path.join(__dirname, '..'), encoding:'utf8'});
assert.equal(python, expected);
}
});
test('长标题预览、实际填写和返回结果一致', async () => {
const h = harness();
const title = '新闻,'.repeat(20);
await h.api.stagePackage({schemaVersion:1, markdown:'# ' + title + '\n正文', images:[]});
const expected = '新闻,'.repeat(8) + '新...';
assert.equal(h.api.getState().title, expected);
const result = await h.api.importPrepared();
assert.equal(h.ids.get('subject').value, expected);
assert.equal(result.title, expected);
assert.equal(h.requests.length, 0);
});
+18 -1
View File
@@ -104,9 +104,26 @@ class QueueTests(unittest.TestCase):
def test_title_does_not_break_emoji(self): def test_title_does_not_break_emoji(self):
short = short_title('😀' * 50) short = short_title('😀' * 50)
self.assertTrue(short.endswith('...')) self.assertTrue(short.endswith('...'))
self.assertLessEqual(len(short.encode('utf-16-le')) // 2, 80) self.assertLessEqual(len(short.encode('utf-8')), 78)
self.assertEqual(short_title('短标题'), '短标题') self.assertEqual(short_title('短标题'), '短标题')
def test_existing_queued_title_updates_before_import_only(self):
original = '中' * 30
self.md.write_text('# ' + original + '\n\n正文。\n\n来源:测试,如有侵权,请联系删除。')
self.store.enqueue(self.article)
with self.store.db() as db:
job = self.store.state()['jobs'][0]
job['title'] = original # Simulate a queue created by the old version.
self.store._save(db, job)
self.store.control('start')
job = self.store.next(self.owner)['job']
self.assertEqual(job['title'], '中' * 25 + '...')
self.assertEqual(self.store.package(dict(owner='tab-a', id=job['id']))['title'], job['title'])
self.submit(job)
restarted = Store(self.root / 'queue.sqlite3')
self.assertEqual(restarted.state()['jobs'][0]['snapshot']['title'], job['title'])
self.assertEqual(restarted.state()['jobs'][0]['original_title'], original)
def test_unsupported_images_stop_before_queue(self): def test_unsupported_images_stop_before_queue(self):
(self.article / 'a.webp').write_bytes(b'webp') (self.article / 'a.webp').write_bytes(b'webp')
with self.assertRaisesRegex(ValueError, '暂不支持'): with self.assertRaisesRegex(ValueError, '暂不支持'):