first commit

This commit is contained in:
Longtail committed 2026-10-31 14:47:15 +08:00
commit 30739db7ce
14 files changed
+1627

No files matched your search

@@ -0,0 +1,89 @@
# 网页文章离线下载
使用 uv 管理 Python 环境和依赖(Python 3.10 或更新版本)。将 `download_article.py` 与 `requirements.txt` 放在同一个目录,并确保已安装 uv。
## 安装与运行
在这两个文件所在目录打开终端,执行:
```sh
uv run --no-project --python 3.12 --with-requirements requirements.txt python download_article.py 'https://mp.weixin.qq.com/s/Jcu2J5YlaMdxbAUgLMd56A'
```
uv 会自动准备 Python 3.12 和所需依赖,无需手动创建或激活虚拟环境。后续运行相同命令即可复用缓存。依赖继续通过 `requirements.txt` 管理,无需创建 `pyproject.toml`。
默认在**运行命令时的当前目录**创建文章目录,也可以用 `-o /你的/保存目录` 指定父目录。
```text
当前目录/
└── 文章标题/
├── 文章标题.md
├── image001.png
└── image002.jpg
```
图片后缀按实际文件内容判断;同一地址的图片只下载一次。正文中的图片引用使用相对路径,离线阅读时请保留整个目录。支持微信的延迟加载图片和行内 CSS 背景图,保留常见的标题、段落、列表、链接、表格等 Markdown 格式;公众号的复杂视觉排版不会原样复刻。
Markdown 开头不添加原文链接和作者/公众号信息,文章末尾统一追加 `来源:公众号作者,如有侵权,请联系删除。`。无法获取公众号作者时使用“未知作者”。接口返回的 `source_url` 仍保留,方便调用方追踪来源。
文件名中系统不允许的字符会替换为 `_`,过长标题会截短。已有同名目录时创建 `文章标题 (2)/文章标题 (2).md`,不会覆盖已有内容。
## 其他任务通过命令行调用
```sh
uv run --no-project --python 3.12 --with-requirements requirements.txt python download_article.py 'https://mp.weixin.qq.com/s/Jcu2J5YlaMdxbAUgLMd56A' --json
```
也可以从标准输入读取单个 JSON 请求:
```sh
printf '%s' '{"url":"https://mp.weixin.qq.com/s/Jcu2J5YlaMdxbAUgLMd56A","output_root":".","timeout":30,"allow_missing_images":false}' | uv run --no-project --python 3.12 --with-requirements requirements.txt python download_article.py --stdin-json
```
每次进程处理一个网址。JSON 模式下,标准输出只有一个 JSON 对象,路径均为绝对路径。调用方应解析 JSON,不要依赖错误消息的具体文字。
成功返回退出码 `0`:
```json
{
"ok": true,
"title": "文章标题",
"source_url": "https://example.com/article",
"directory": "/完整路径/文章标题",
"markdown": "/完整路径/文章标题/文章标题.md",
"images": ["/完整路径/文章标题/image001.png"],
"warnings": []
}
```
失败返回退出码 `1`:
```json
{"ok": false, "error": "错误说明", "error_type": "ArticleDownloadError"}
```
命令行参数格式错误由 argparse 返回退出码 `2`,说明写入标准错误。`--stdin-json` 使用 JSON 中的参数,不使用其他命令行下载选项。
## 其他 Python 任务直接调用
将脚本放入调用方的 Python 模块搜索路径,并在调用方的 uv 环境中安装本脚本 `requirements.txt` 所列依赖后:
```python
from download_article import save_article
result = save_article(
url="https://mp.weixin.qq.com/s/Jcu2J5YlaMdxbAUgLMd56A",
output_root=".",
timeout=30,
allow_missing_images=False,
)
saved_directory = result.directory # 已保存文件所在目录(绝对路径字符串)
print(saved_directory)
# 完整结果可调用 result.to_dict()
```
函数失败时抛出异常,调用方可自行捕获。默认任何图片失败都会终止保存并清理半成品。若只希望尽量保存正文,可传 `allow_missing_images=True` 或命令行添加 `--allow-missing-images`,失败图片会替换为文字提示,详情写入 `warnings`。
微信可能返回验证、登录或失效页面,脚本会报告未找到正文,不会绕过访问限制。普通网页使用 article/main/body 提取,不适用于必须执行 JavaScript 才出现正文的页面。原文中的外链仍需联网,文章文字及成功下载的图片可离线查看。
如果接口对外提供服务,请由调用方限制网址及重定向目的地,只允许可信站点;此脚本本身允许访问运行机器能连接的 HTTP 地址。
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""下载微信/普通网页文章为 Markdown。Python 接口:save_article(url, output_root='.')。"""
from __future__ import annotations
import argparse
import base64
from dataclasses import asdict, dataclass
import json
from pathlib import Path
import re
import shutil
import sys
import tempfile
from urllib.parse import unquote, urljoin, urlsplit
import requests
from bs4 import BeautifulSoup
from markdownify import markdownify
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class ArticleDownloadError(RuntimeError):
"""页面不是文章、图片下载失败等可向调用方报告的错误。"""
@dataclass
class ArticleResult:
title: str
source_url: str
directory: str
markdown: str
images: list[str]
warnings: list[str]
def to_dict(self) -> dict:
return asdict(self)
def safe_name(title: str) -> str:
"""跨平台目录/文件名;目录名与其中 Markdown 的文件名保持一致。"""
name = re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f]', '_', title)
name = re.sub(r'\s+', ' ', name).strip(' .')
# 同时限制 UTF-8 字节数,给 .md 和重名后缀保留空间。
name = name.encode('utf-8')[:180].decode('utf-8', errors='ignore').rstrip(' .')
if not name:
name = '未命名文章'
if re.fullmatch(r'(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?', name):
name = '_' + name
return name
def _session() -> requests.Session:
session = requests.Session()
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
})
retry = Retry(total=3, backoff_factor=0.6,
status_forcelist=[429, 500, 502, 503, 504], allowed_methods=['GET'])
session.mount('https://', HTTPAdapter(max_retries=retry))
session.mount('http://', HTTPAdapter(max_retries=retry))
return session
def _get(session: requests.Session, url: str, timeout: float, limit: int,
referer: str | None = None) -> tuple[bytes, str, str]:
if urlsplit(url).scheme not in ('http', 'https'):
raise ArticleDownloadError(f'不支持的下载地址:{url[:200]}')
headers = {'Referer': referer} if referer else {}
with session.get(url, timeout=(min(timeout, 15), timeout),
headers=headers, stream=True) as response:
response.raise_for_status()
chunks, size = [], 0
for chunk in response.iter_content(65536):
size += len(chunk)
if size > limit:
raise ArticleDownloadError(f'文件超过大小限制({limit // 1024 // 1024} MB):{url[:200]}')
chunks.append(chunk)
return b''.join(chunks), response.headers.get('Content-Type', ''), response.url
def _image_ext(data: bytes, mime: str) -> str:
if data.startswith(b'\xff\xd8\xff'):
return '.jpg'
if data.startswith(b'\x89PNG\r\n\x1a\n'):
return '.png'
if data.startswith((b'GIF87a', b'GIF89a')):
return '.gif'
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
return '.webp'
if len(data) >= 12 and data[4:8] == b'ftyp' and data[8:12] in (b'avif', b'avis'):
return '.avif'
if b'<svg' in data[:1024].lower() and mime.split(';')[0] == 'image/svg+xml':
return '.svg'
raise ArticleDownloadError(f'返回内容不是支持的图片(Content-Type: {mime or "未知"})')
def _image_source(img) -> str:
for attr in ('data-src', 'data-original', 'data-actualsrc', 'data-lazy-src', 'src'):
value = img.get(attr)
if value and str(value).strip():
return str(value).strip()
srcset = img.get('data-srcset') or img.get('srcset')
if srcset:
return str(srcset).split(',')[-1].strip().split()[0]
return ''
def save_article(url: str, output_root: str | Path = '.', *, timeout: float = 30,
allow_missing_images: bool = False) -> ArticleResult:
"""返回绝对路径。默认任意图片失败即报错,不留下半成品;已有目录不会被覆盖。
allow_missing_images=True 时,失败图片被替换为文字占位,并记入 warnings。
网络/HTTP 错误可能抛出 requests.RequestException,文章错误抛出 ArticleDownloadError。
调用方应只传入可信的公开网页 URL(本函数允许访问调用机器能访问的 HTTP 地址)。
"""
if not isinstance(url, str) or urlsplit(url).scheme not in ('http', 'https') or not urlsplit(url).hostname:
raise ValueError('url 必须是完整的 http:// 或 https:// 地址')
if timeout <= 0:
raise ValueError('timeout 必须大于 0')
if not isinstance(allow_missing_images, bool):
raise ValueError('allow_missing_images 必须是布尔值')
with _session() as session:
raw, content_type, final_url = _get(session, url, timeout, 20 * 1024 * 1024)
if content_type and not any(t in content_type.lower() for t in ('html', 'xhtml', 'text/plain')):
raise ArticleDownloadError('网址返回的不是 HTML 网页')
# 微信为 UTF-8;普通网页由 HTML 的 charset 标记/BeautifulSoup 自动识别。
soup = BeautifulSoup(raw, 'html.parser')
is_wechat = urlsplit(url).hostname == 'mp.weixin.qq.com'
content = soup.select_one('#js_content')
if is_wechat and content is None:
raise ArticleDownloadError('未找到微信文章正文,可能需要验证、登录,或文章已失效。请在浏览器中确认链接可访问。')
if content is None:
content = soup.select_one('article') or soup.select_one('main') or soup.body
if content is None:
raise ArticleDownloadError('网页没有可保存的正文')
title_tag = soup.select_one('#activity-name') or soup.select_one('h1')
title_meta = soup.select_one('meta[property="og:title"]')
title = (title_tag.get_text(' ', strip=True) if title_tag else '')
title = title or (title_meta.get('content', '').strip() if title_meta else '')
title = title or (soup.title.get_text(' ', strip=True) if soup.title else '') or '未命名文章'
for tag in content.select('script, style, noscript, iframe, form, button'):
tag.decompose()
# 部分微信排版把插图放在行内 CSS 背景中,也转为普通图片参与下载。
for tag in list(content.find_all(style=True)):
for match in re.finditer(r'url\(\s*[\'\"]?(.*?)[\'\"]?\s*\)', str(tag['style'])):
source = match.group(1).strip()
if source and not source.startswith('#'):
background = soup.new_tag('img', src=source, alt='')
tag.insert(0, background)
if not content.get_text(strip=True) and not content.find('img'):
raise ArticleDownloadError('文章正文为空,可能需要浏览器执行 JavaScript')
for link in content.find_all('a', href=True):
href = str(link['href']).strip()
if href and not href.startswith('#'):
absolute = urljoin(final_url, href)
if urlsplit(absolute).scheme in ('http', 'https', 'mailto', 'tel'):
link['href'] = absolute
else:
del link['href']
root = Path(output_root).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
stage = Path(tempfile.mkdtemp(prefix='.article-', dir=root))
images, warnings, downloaded = [], [], {}
try:
for img in list(content.find_all('img')):
source = _image_source(img)
if not source:
img.replace_with(soup.new_string('[图片缺少下载地址]'))
if not allow_missing_images:
raise ArticleDownloadError('正文中存在没有下载地址的图片')
warnings.append('正文中存在没有下载地址的图片')
continue
image_url = urljoin(final_url, source)
try:
if image_url not in downloaded:
if source.startswith('data:image/'):
header, payload = source.split(',', 1)
data = base64.b64decode(payload, validate=True) if ';base64' in header else unquote(payload).encode()
mime = header[5:].split(';')[0]
if len(data) > 50 * 1024 * 1024:
raise ArticleDownloadError('内嵌图片超过 50 MB')
else:
data, mime, _ = _get(session, image_url, timeout, 50 * 1024 * 1024, final_url)
ext = _image_ext(data, mime)
filename = f'image{len(images) + 1:03d}{ext}'
(stage / filename).write_bytes(data)
images.append(filename)
downloaded[image_url] = filename
# 清除懒加载/响应式属性,使转换器只引用本地文件。
alt = str(img.get('alt') or '')
img.attrs = {'src': downloaded[image_url], 'alt': alt}
except (requests.RequestException, ArticleDownloadError, ValueError) as exc:
if not allow_missing_images:
raise ArticleDownloadError(f'图片下载失败:{image_url[:200]};{exc}') from exc
warnings.append(f'图片下载失败:{image_url[:200]};{exc}')
img.replace_with(soup.new_string(f'[图片下载失败:{img.get("alt") or "未命名图片"}]'))
body = markdownify(str(content), heading_style='ATX', bullets='-',
strip=['picture', 'source']).strip()
# 微信富文本中各段常没有标准 p 标签;markdownify 仍会保留文字和图片顺序。
author_tag = soup.select_one('#js_name')
date_tag = soup.select_one('#publish_time')
author = author_tag.get_text(' ', strip=True) if author_tag else ''
author = author or '未知作者'
metadata = []
if date_tag and date_tag.get_text(strip=True):
metadata.append(f'发布时间:{date_tag.get_text(" ", strip=True)}')
text = f'# {title}\n\n'
if metadata:
text += '\n\n'.join(metadata) + '\n\n---\n\n'
text += body + f'\n\n来源:{author},如有侵权,请联系删除。\n'
basename = safe_name(title)
# 原子认领新目录,支持多个进程并发保存同名文章。
index = 1
while True:
name = basename if index == 1 else f'{basename} ({index})'
destination = root / name
try:
destination.mkdir()
break
except FileExistsError:
index += 1
try:
(stage / f'{name}.md').write_text(text, encoding='utf-8')
for item in stage.iterdir():
shutil.move(str(item), destination / item.name)
except BaseException:
shutil.rmtree(destination, ignore_errors=True)
raise
return ArticleResult(title, url, str(destination), str(destination / f'{name}.md'),
[str(destination / x) for x in images], warnings)
finally:
shutil.rmtree(stage, ignore_errors=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('url', nargs='?', help='文章网址')
parser.add_argument('-o', '--output-root', default='.', help='保存到此目录下,默认当前目录')
parser.add_argument('--timeout', type=float, default=30, help='每次网络请求超时秒数')
parser.add_argument('--allow-missing-images', action='store_true', help='允许图片失败,保留文字与失败提示')
parser.add_argument('--json', action='store_true', help='向标准输出写入 JSON,便于其他任务调用')
parser.add_argument('--stdin-json', action='store_true', help='从标准输入读取一个 JSON 请求,并返回 JSON')
args = parser.parse_args()
json_mode = args.json or args.stdin_json
try:
if args.stdin_json:
payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise ValueError('JSON 请求必须是对象')
unknown = set(payload) - {'url', 'output_root', 'timeout', 'allow_missing_images'}
if unknown:
raise ValueError(f'未知参数:{", ".join(sorted(unknown))}')
if 'url' not in payload:
raise ValueError('JSON 请求缺少 url')
result = save_article(**payload)
else:
if not args.url:
raise ValueError('请提供网址,或使用 --stdin-json')
result = save_article(args.url, args.output_root, timeout=args.timeout,
allow_missing_images=args.allow_missing_images)
if json_mode:
print(json.dumps({'ok': True, **result.to_dict()}, ensure_ascii=False))
else:
print(result.directory)
for warning in result.warnings:
print(f'警告:{warning}', file=sys.stderr)
return 0
except Exception as exc:
if json_mode:
print(json.dumps({'ok': False, 'error': str(exc), 'error_type': type(exc).__name__}, ensure_ascii=False))
else:
print(f'下载失败:{exc}', file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,3 @@
requests>=2.32,<3
beautifulsoup4>=4.12,<5
markdownify>=1.1,<2
@@ -0,0 +1,73 @@
# 为什么年轻人爱玩手机,背后的真相太无奈!
很多家长和长辈都在抱怨,现在的年轻人怎么整天抱着个手机,不是刷短视频就是打游戏,一点上进心都没有。
但老赵今天想替年轻人说句公道话!他们不是天生就爱玩手机,而是手机成了他们在有限条件下,唯一的消遣!
先算一笔账,现在出门随便玩点什么不要花钱?
一杯普通的奶茶二三十块,一场电影五六十块,吃顿火锅人均一百多,剧本杀,商场里买件衣服几百块。
就算是去公园散散步,来回的交通费、买瓶水也得花个十几块。
而玩手机呢?只要有一部手机和网络,就能看电影、听音乐、玩游戏、看小说,一毛钱不花就能娱乐一整天。
对于很多年轻人来说,这是唯一的放松方式。
刚毕业的大学生,月薪三四千,除去房租水电和吃饭,能剩下的钱寥寥无几。
工作几年的年轻人,要还房贷车贷,要攒钱过日子,每一分钱都得掰成两半花。
他们不是不想去旅游,不是不想培养什么高雅的兴趣爱好,而是这些都太贵了。
打个羽毛球/网球,都得交场地费!还不一定能约到有时间的朋友。
学个乐器、报个健身课,动辄几千上万的学费,对于普通年轻人来说,实在是太奢侈了。
![](image001.jpg)
比没钱更无解的,是他们根本没有完整的时间。
现在的年轻人,早就失去了属于自己的休闲时间,每天写完作业/忙完工作,就已经天黑了。
这个时代的年轻人,学业压力/工作压力,比我们那个时代要大的多,根本没有那么多时间精力,去参加户外活动。
洗完澡躺到床上可能只剩一小时就要睡觉了。这些时间你能做什么?去跑步?换衣服热身都不够。
去看书?刚看两行就该睡觉了。
去约朋友?别人可能也在忙着自己的事情。
只有手机,能完美适配这种被切割得支离破碎的时间。拿起来就能玩,放下就能停,不需要任何准备,不需要任何人配合。
现实生活里没有即时反馈,但手机不一样子你刷一个短视频,3秒就能让你笑。你发一条动态,有人点赞马上就有被关注的感觉。
这种做了就有结果的确定感,是现实中极度稀缺的东西。年轻人不是爱玩手机,是他们太需要这种即时的快乐了。
![](image002.jpg)
很多人说,那可以约朋友出来玩啊。
现在的社交不仅要花钱,还要花精力。约朋友不是说走就走的,你要提前一周约时间,要迁就每个人的时间安排。
要找大家都满意的地方,要照顾每个人的情绪。对于已经被工作榨干了所有精力的年轻人来说,这种社交比上班还累!
而手机上的社交是低压力的,你想聊就聊,不想聊就隔两个小时再回,累了就直接下线,没有人会怪你。
老赵不是在鼓励大家沉迷手机,而是希望大家能看到问题的本质。
![](image003.jpg)
不是年轻人变懒了,是免费的快乐消失了。
以前我们小时候,跳皮筋、踢毽子、捉迷藏,随时能叫来一堆朋友一块玩,而且这些都不需要花钱。
但现在的城市里,到处都是高楼大厦,没有那么多可以自由玩耍的地方,打羽毛球/打网球/游泳,这些场地都是收费的!
所有的快乐都变成了商品,明码标价摆在那里。
与其指责年轻人沉迷手机,不如问问他们为什么没有其他更好的选择。如果他们有足够的钱和时间,有更多元的娱乐选择,谁谁不愿意去体验丰富多彩的生活呢?
所以,下次再看到年轻人玩手机的时候,不要急着去批评他们。多给他们一点理解和包容。毕竟,谁不想活得轻松一点,快乐一点呢?
来源:老赵讲道理,如有侵权,请联系删除。
@@ -0,0 +1,59 @@
# 微信公众号一键导入并排版
脚本版本:1.0.7。脚本文件:`dww11-wechat-oneclick.user.js`。与原有“本地文章目录导入”脚本独立,可按需同时保留。
## 安装与使用
1. 在 Tampermonkey 中新建脚本,把上述文件的全部内容复制进去,保存脚本。
2. 刷新广谈大湾网的发帖页面。编辑器工具栏右侧会出现绿色“一键导入微信公众号并排版”按钮。
3. 点击按钮,粘贴微信文章链接。首次运行如 Tampermonkey 询问是否允许访问 `mp.weixin.qq.com`,允许后才能自动读取标题和作者。
4. 脚本通过网站原有 `.wxcj` 组件打开采集输入框,填写链接并触发采集。完成后请人工检查文章。
安装步骤中的“保存”仅指保存 Tampermonkey 脚本。脚本没有点击网站的保存草稿或发表按钮,也不会自动提交帖子。
## 处理规则
- 标题:从微信页面的标题元素读取纯文本,避免把 `<span class="js_title_inner">` 当成标题。
- 来源名称:只读取蓝色链接 `#js_name` 的公众号名称,不使用灰色 `#js_author_name` 署名作者或作者元数据。缺失时提示手动填写公众号名称。
- 正文:工具栏的 4 档字号,使用原生 HTML `font size="4"`,不再附加18px样式,避免网站转换时字号错档,统一黑色。保留图片、段落、链接、粗体等结构。
- 末尾追加:`来源:<作者>,如有侵权,请联系删除。`,使用原生 `font size="1" color="#c0c0c0"`,即1档字号和截图中的银色。
- 如果当前处于纯文本模式,导入前自动切到可视化模式,完成后保持可视化,交由网站原有提交流程转换正文。
- 采集会按网站原有方式替换当前标题、正文,并处理图片附件;这不是追加导入。
## 验证范围与失败处理
已只读检查 Chrome 中网站的工具栏、采集插件代码、标题框和字号菜单。未在真实网站上运行导入、保存或发表。
本地模拟页验证通过:按钮→URL输入→调用现有采集组件→等待成功响应→修正完整标题→正文4号黑色→来源1号银色;图片、链接和粗体保留。模拟页使用虚构采集响应,并不访问微信或网站采集接口。
已对照用户提供的微信 F12 页面结构和 `wechat_official_acc_article_downloader/download_article.py` 的解析方式。实际结构解析结果为完整标题“为什么年轻人爱玩手机,背后的真相太无奈!”和作者“老赵讲道理”,作者不重复。11项离线解析回归测试和完整模拟导入流程均通过。解析前还会检查 `#js_content`,避免把验证页误认为文章。
本次浏览器安全策略阻止读取微信源页面,因此微信网络请求尚未做真实联网验证。微信验证页、登录要求、网络限制或页面结构变化都可能使自动读取失败。这时脚本会提示手动填写完整标题和作者,取消任一步则停止后续采集。
采集失败、返回格式异常或超时,不继续排版。网站采集器自身可能已改动编辑器;脚本不保证撤销网站采集操作。超过90秒时原采集器仍可能返回,先确认状态再重试,避免重复采集。
微信正文及图片导入能力沿用网站插件,脚本不负责修复网站插件自身的遗漏或失效。标题仍须符合网站长度限制,脚本不自动截断标题。
## 1.0.3 手机端排版字符修复
旧版在正文颜色样式中使用了 `!important`,网站转换为论坛代码时会生成类似 `[color=#000 !important]` 的无效标签,手机版可能将其直接显示。新版清理正文内联字号/颜色样式及其他样式优先级,展开嵌套 FONT,只用 `font size="4" color="#000000"` 设置正文、`font size="1" color="#c0c0c0"` 设置来源,避免污染论坛代码。
更新脚本不能自动修复已经保存的帖子。受影响的文章需要在编辑页面重新导入,再由用户自行检查和保存。没有自动修改、保存或发布现有帖子。
## 1.0.4 图片间距
正文文字与图片相邻时,自动补足一个空白行(两个换行)。识别已有 BR 和段落边界,不重复增加已有空行;文字、图片和段落的顺序保持不变。
## 1.0.5 图片上下空行修正
修正图片容器末尾 BR 会被折叠而使图片下方没有空行的问题。图文间的换行移至下一段内容开头,使用 `<font size="4" color="#000000"><br></font>` 表示纯换行,不插入空格或 NBSP,并清理图文间原有的占位空格。行内图文保留两个 BR,独立块图文补一行;已有较多换行保留。
## 1.0.6 连续4号字体与手机排版
不再使用仅含 BR 的空 FONT。正文、图片及图文间的两个 BR 全部位于同一个连续的 `font size="4" color="#000000"` 内,来源仍单独使用1号银色。将微信段落容器的隐式边界转成真实 BR,避免 SECTION 边界被忽略;不插入空格或 NBSP。图片在编辑器内限制最大宽度并保持比例。
新增桌面820px和手机375px宽度的本地检查,以及 HTML→论坛标签→HTML 的简化模拟转换检查;这不是网站真实提交流程的验证。安装新版后需重新导入,已有文章不会自动改写。
## 1.0.7 来源名称修正
来源标识改为 `#js_name` 蓝色链接的公众号名称。例如公众号为“室内设计师有料儿”、灰色署名作者为“粉条条”时,末尾显示“来源:室内设计师有料儿,如有侵权,请联系删除。”。缺少公众号节点时提示人工填写,不回退到署名作者。1.0.6 的字体、段落和图片间距处理保持原样。