first commit
This commit is contained in:
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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,73 @@
|
||||
# 为什么年轻人爱玩手机,背后的真相太无奈!
|
||||
|
||||
很多家长和长辈都在抱怨,现在的年轻人怎么整天抱着个手机,不是刷短视频就是打游戏,一点上进心都没有。
|
||||
|
||||
但老赵今天想替年轻人说句公道话!他们不是天生就爱玩手机,而是手机成了他们在有限条件下,唯一的消遣!
|
||||
|
||||
先算一笔账,现在出门随便玩点什么不要花钱?
|
||||
|
||||
一杯普通的奶茶二三十块,一场电影五六十块,吃顿火锅人均一百多,剧本杀,商场里买件衣服几百块。
|
||||
|
||||
就算是去公园散散步,来回的交通费、买瓶水也得花个十几块。
|
||||
|
||||
而玩手机呢?只要有一部手机和网络,就能看电影、听音乐、玩游戏、看小说,一毛钱不花就能娱乐一整天。
|
||||
|
||||
对于很多年轻人来说,这是唯一的放松方式。
|
||||
|
||||
刚毕业的大学生,月薪三四千,除去房租水电和吃饭,能剩下的钱寥寥无几。
|
||||
|
||||
工作几年的年轻人,要还房贷车贷,要攒钱过日子,每一分钱都得掰成两半花。
|
||||
|
||||
他们不是不想去旅游,不是不想培养什么高雅的兴趣爱好,而是这些都太贵了。
|
||||
|
||||
打个羽毛球/网球,都得交场地费!还不一定能约到有时间的朋友。
|
||||
|
||||
学个乐器、报个健身课,动辄几千上万的学费,对于普通年轻人来说,实在是太奢侈了。
|
||||
|
||||

|
||||
|
||||
比没钱更无解的,是他们根本没有完整的时间。
|
||||
|
||||
现在的年轻人,早就失去了属于自己的休闲时间,每天写完作业/忙完工作,就已经天黑了。
|
||||
|
||||
这个时代的年轻人,学业压力/工作压力,比我们那个时代要大的多,根本没有那么多时间精力,去参加户外活动。
|
||||
|
||||
洗完澡躺到床上可能只剩一小时就要睡觉了。这些时间你能做什么?去跑步?换衣服热身都不够。
|
||||
|
||||
去看书?刚看两行就该睡觉了。
|
||||
|
||||
去约朋友?别人可能也在忙着自己的事情。
|
||||
|
||||
只有手机,能完美适配这种被切割得支离破碎的时间。拿起来就能玩,放下就能停,不需要任何准备,不需要任何人配合。
|
||||
|
||||
现实生活里没有即时反馈,但手机不一样子你刷一个短视频,3秒就能让你笑。你发一条动态,有人点赞马上就有被关注的感觉。
|
||||
|
||||
这种做了就有结果的确定感,是现实中极度稀缺的东西。年轻人不是爱玩手机,是他们太需要这种即时的快乐了。
|
||||
|
||||

|
||||
|
||||
很多人说,那可以约朋友出来玩啊。
|
||||
|
||||
现在的社交不仅要花钱,还要花精力。约朋友不是说走就走的,你要提前一周约时间,要迁就每个人的时间安排。
|
||||
|
||||
要找大家都满意的地方,要照顾每个人的情绪。对于已经被工作榨干了所有精力的年轻人来说,这种社交比上班还累!
|
||||
|
||||
而手机上的社交是低压力的,你想聊就聊,不想聊就隔两个小时再回,累了就直接下线,没有人会怪你。
|
||||
|
||||
老赵不是在鼓励大家沉迷手机,而是希望大家能看到问题的本质。
|
||||
|
||||

|
||||
|
||||
不是年轻人变懒了,是免费的快乐消失了。
|
||||
|
||||
以前我们小时候,跳皮筋、踢毽子、捉迷藏,随时能叫来一堆朋友一块玩,而且这些都不需要花钱。
|
||||
|
||||
但现在的城市里,到处都是高楼大厦,没有那么多可以自由玩耍的地方,打羽毛球/打网球/游泳,这些场地都是收费的!
|
||||
|
||||
所有的快乐都变成了商品,明码标价摆在那里。
|
||||
|
||||
与其指责年轻人沉迷手机,不如问问他们为什么没有其他更好的选择。如果他们有足够的钱和时间,有更多元的娱乐选择,谁谁不愿意去体验丰富多彩的生活呢?
|
||||
|
||||
所以,下次再看到年轻人玩手机的时候,不要急着去批评他们。多给他们一点理解和包容。毕竟,谁不想活得轻松一点,快乐一点呢?
|
||||
|
||||
来源:老赵讲道理,如有侵权,请联系删除。
|
||||
@@ -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 的字体、段落和图片间距处理保持原样。
|
||||
Reference in new issue
Block a user