fix: truncate article titles to safe UTF-8 budget
This commit is contained in:
7 files changed
+115
-15
No files matched your search
@@ -69,7 +69,7 @@ downloads/ 本工具下载结果(本地)
|
||||
|
||||
- `test/history/` 保存从本目录移入的两张演示截图与演示任务 JSON。根目录其他测试文件未搬动。
|
||||
- `发布去重记录.json` 是实际受理证据,继续留在原处,并在服务启动时迁入去重库。它不代表完整历史已对账。原文件可找到时还建立内容指纹;原文件丢失时仍保留原 Markdown 摘要。
|
||||
- 原始下载文件不改写。长标题生成保守 UTF-16 长度不超过 80 的派生标题并加 `...`,原题仍展示在队列;网站真实计数规则尚需实测。
|
||||
- 原始下载文件不改写。标题按 UTF-8 字节数计长:ASCII 为 1,中文和中文标点为 3,emoji 通常为 4。超过 75 时保留不超过 75 的完整前缀,再加 `...`,总长最多 78;原题仍保留。
|
||||
- 排版仅处理与文章来源一致的末尾标准尾注,保留正文中的其他“来源”文字。图片顺序、标识改变会回滚排版。
|
||||
- WebP/AVIF/SVG 当前明确拦截,需用户另存支持格式并更新 Markdown;没有静默丢图或自动转码。单图须小于 20 MiB,图片 base64 总大小不超过 48 MiB。
|
||||
- 本地库含文章内容快照和账号标识,不含账号密码、Cookie 或上传凭据。运行数据和历史截图已加入忽略规则。
|
||||
@@ -122,3 +122,9 @@ node test/browser.test.cjs
|
||||
## 0.2.1 临时审核提示规则
|
||||
|
||||
更新油猴中的 `browser/auto-news.user.js` 至 0.2.1,并重启本地服务、重新配对后刷新论坛页面。连接脚本无需更新。结果适配器和执行器中的旧详情页核对代码以行注释保留;后端旧核对接口继续保留供人工历史补核使用。自动受理使用当前任务的人工提交批次校验,审核提示可不含帖子链接,不创建虚构帖子 ID。
|
||||
|
||||
## 标题自动截断
|
||||
|
||||
自动队列、手动目录和文章包导入均应用上述规则;已截断的标题不会重复添加省略号。已有队列在领取未开始的任务时更新标题,已导入、待审核或已提交任务保持现场标题。
|
||||
|
||||
应用更新:重启本地服务,并在油猴重新安装 `browser/auto-news.user.js`。原始 Markdown 不改写。
|
||||
+7
-4
@@ -56,15 +56,18 @@ def validate_images(body, directory):
|
||||
raise ValueError('配图为空或达到 20 MiB:' + target)
|
||||
|
||||
|
||||
def short_title(title, budget=80):
|
||||
# Conservative UTF-16 budget: do not split astral characters / surrogate pairs.
|
||||
if len(title.encode('utf-16-le')) // 2 <= budget:
|
||||
def short_title(title, budget=75):
|
||||
"""Keep a UTF-8 prefix within budget, adding three ASCII dots if shortened."""
|
||||
if len(title.encode('utf-8')) <= budget or (title.endswith('...') and len(title.encode('utf-8')) <= budget + 3):
|
||||
return title
|
||||
out = ''
|
||||
used = 0
|
||||
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
|
||||
out += ch
|
||||
used += size
|
||||
return out + '...'
|
||||
|
||||
|
||||
|
||||
@@ -153,12 +153,31 @@
|
||||
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) {
|
||||
let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\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 (!title || Array.from(title).length > 80) throw new Error('标题为空或超过 80 字,请修改 Markdown 标题后重试。');
|
||||
if (!title) throw new Error('标题为空,请修改 Markdown 标题后重试。');
|
||||
if (!body.trim()) throw new Error('正文为空。');
|
||||
const warnings = [];
|
||||
if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。');
|
||||
@@ -531,7 +550,7 @@
|
||||
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
|
||||
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.
|
||||
|
||||
+22
-3
@@ -153,12 +153,31 @@
|
||||
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) {
|
||||
let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\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 (!title || Array.from(title).length > 80) throw new Error('标题为空或超过 80 字,请修改 Markdown 标题后重试。');
|
||||
if (!title) throw new Error('标题为空,请修改 Markdown 标题后重试。');
|
||||
if (!body.trim()) throw new Error('正文为空。');
|
||||
const warnings = [];
|
||||
if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。');
|
||||
@@ -531,5 +550,5 @@
|
||||
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
|
||||
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 };
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit, parse_qs
|
||||
|
||||
from articles import inspect_article
|
||||
from articles import inspect_article, short_title
|
||||
|
||||
ACCEPTED = {'published', 'accepted_pending_moderation'}
|
||||
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._persist(db)
|
||||
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='')
|
||||
self.run['active'] = job['id']
|
||||
self._save(db, job)
|
||||
|
||||
+37
-2
@@ -29,12 +29,11 @@ test('测试文章:标题、3 张配图、插入顺序、来源与段落完整
|
||||
assert.equal(output, originalBody);
|
||||
});
|
||||
|
||||
test('缺图、远程图片、超出目录、超大图片与超长标题在上传前被拒绝', () => {
|
||||
test('缺图、远程图片、超出目录与超大图片在上传前被拒绝', () => {
|
||||
assert.throws(() => core.compile('# 标题\n\n', 'a.md', new Map()), /找不到配图/);
|
||||
assert.throws(() => core.compile('# 标题\n\n', 'a.md', new Map()), /目录内/);
|
||||
assert.throws(() => core.imagePath('a.md', '../secret.jpg'), /超出/);
|
||||
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', '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);
|
||||
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
@@ -104,9 +104,26 @@ class QueueTests(unittest.TestCase):
|
||||
def test_title_does_not_break_emoji(self):
|
||||
short = short_title('😀' * 50)
|
||||
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('短标题'), '短标题')
|
||||
|
||||
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):
|
||||
(self.article / 'a.webp').write_bytes(b'webp')
|
||||
with self.assertRaisesRegex(ValueError, '暂不支持'):
|
||||
|
||||
Reference in new issue
Block a user