first commit

This commit is contained in:
Longtail committed 2026-10-31 14:33:07 +08:00
commit e20e6e36dd
10 files changed
+829

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
.DS_Store
downloads/
.venv/
+57
View File
@@ -0,0 +1,57 @@
# 探数新闻下载器
在本机浏览器中填写 API Key、获取频道、选择数量,下载新闻正文和图片。所有项目文件与下载结果都位于本目录,不修改微信下载器。
## 启动
安装 `uv` 后,macOS 可以双击 `start.command`。也可以在本目录运行:
```sh
uv run --no-project --python 3.12 --with-requirements requirements.txt python app.py
```
启动后自动打开 http://127.0.0.1:8765 。保留启动窗口,退出时按 Ctrl+C。端口被占用时加 `--port 8766`;不自动打开浏览器时加 `--no-browser`。
1. 填写探数 API Key,点击「获取新闻频道」。频道来自真实接口,不使用写死的列表。
2. 选择频道、输入目标数量,点击「开始下载」。
3. 页面显示成功保存的数量、百分比、重复数、失败数和最近 300 条日志。日志固定高度,可滚动;向上查看时不会强制滚回底部。
4. 下载结果保存在本目录的 `downloads` 文件夹。点击「停止下载」后,等待当前请求或文章保存结束。
5. 「调试终端」默认折叠,支持频道查询以及列表查询的 `type`、`num`、`start` 参数。测试请求只显示响应,不保存文章;同样消耗 API 调用额度。输出最多保留 100000 字符。
密钥仅在页面和服务运行内存中使用,不保存到配置、历史数据库或浏览器存储。服务仅监听本机地址。刷新页面可查看当前任务进度,开始新任务需重新填写密钥。单个服务同时只运行一个下载任务,请勿同时启动多个实例操作同一下载目录。
## 保存格式
沿用 `wechat_official_acc_article_downloader` 的格式和图片处理规则:
```text
downloads/
├── .history.sqlite3
└── 新闻标题/
├── 新闻标题.md
├── image001.jpg
└── image002.png
```
Markdown 开头是一级标题,空行后直接接由接口 `content` 转换的正文,不额外添加接口 `time` 对应的发布时间或分隔线。末尾为 `来源:{src},如有侵权,请联系删除。`,来源为空时使用「未知作者」。不在开头添加原文链接或作者。正文中的图片下载到同一目录、使用相对引用、按真实文件类型命名,同一图片地址只下载一次。接口的 `pic` 封面不单独插入正文。
标题中的不合法字符会替换;不同文章碰巧同名时添加 ` (2)` 等后缀,目录和 Markdown 文件保持同名。任何正文图片下载失败,该篇不计入完成数,并清理半成品,再继续其他新闻;下次任务可以重试。
## 去重与分页
- 下载图片前,检查原文 URL / PC URL(移除常见跟踪参数和片段)及规范化正文指纹。任一匹配即跳过,标题相同但正文和网址不同不会单凭标题去重。
- SQLite 保存成功文章的指纹,跨任务、跨频道和服务重启后继续去重。被删除的文章目录不会阻止重新下载。不要删除 `.history.sqlite3`,否则会失去对应历史记录。
- 每次任务从最新位置 `start=0` 开始,按返回数量推进,每页最多 40 条;重复和失败不计入成功数量,并继续后面的新闻。
- 接口限制 `start ≤ 400`,一次任务最多覆盖约 440 条,具体还受返回数量和实时更新影响。达到分页边界或收到空列表时停止,未达目标就显示「未达到目标」,不会伪装成 100%。
- 列表接口不自动重试,防止隐蔽消耗计费额度。API 业务错误、网络错误或响应格式错误会中断任务,已成功保存的文章保留。图片请求沿用原下载器的重试策略。
- 新闻列表会实时变化,无法保证服务器在不同请求中不返回同一新闻;本工具在保存前去重,尽量避免重复图片下载与文件保存。
接口依据:[探数官方新闻 API 文档](https://www.tanshuapi.com/market/detail-85)。
## 验证
```sh
uv run --no-project --python 3.12 --with-requirements requirements.txt python -m unittest discover -s tests -v
```
测试使用模拟新闻响应与临时目录,不消耗真实 API 额度。覆盖格式与图片引用、图片失败清理、历史去重、分页边界、失败重试、取消、日志上限和本地 HTTP 接口。真实账号权限、余额、返回内容与远程图片可达性,需要在页面填入有效 Key 后验证。
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""仅在本机运行的探数新闻下载器。"""
from collections import deque
from datetime import datetime
from hashlib import sha256
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
import argparse
import json
import secrets
import sqlite3
import threading
import time
import unicodedata
import webbrowser
import requests
from bs4 import BeautifulSoup
from article_writer import save_article
ROOT = Path(__file__).resolve().parent
API = 'https://api.tanshuapi.com/api/toutiao/v1/'
def api_request(key, endpoint, params=None):
if endpoint not in ('channel', 'index'):
raise ValueError('未知接口')
try:
# 不自动重试计费接口,避免一次操作产生不可见的重复调用。
response = requests.get(API + endpoint, params={'key': key, **(params or {})}, timeout=(10, 40))
response.raise_for_status()
payload = response.json()
except requests.RequestException:
raise ValueError('接口网络请求失败或超时,请在调试终端检查后重试') from None
except ValueError:
raise ValueError('接口未返回有效 JSON') from None
if not isinstance(payload, dict):
raise ValueError('接口响应格式错误')
return payload
def result_list(payload):
if payload.get('code') != 1:
raise ValueError(f"探数接口错误 {payload.get('code')}:{payload.get('msg', '未知错误')}")
data = payload.get('data')
if not isinstance(data, dict) or not isinstance(data.get('list'), list):
raise ValueError('接口响应缺少 data.list')
return data['list']
def fingerprints(article):
values = []
for field in ('url', 'weburl'):
value = str(article.get(field) or '').strip()
if value:
u = urlsplit(value)
query = [(k, v) for k, v in parse_qsl(u.query) if not k.lower().startswith('utm_') and k.lower() not in ('spm', 'from', 'pos', 'vt')]
values.append('url:' + urlunsplit(('', u.netloc.lower(), u.path, urlencode(sorted(query)), '')))
body = BeautifulSoup(str(article.get('content') or ''), 'html.parser').get_text(' ', strip=True)
body = ''.join(unicodedata.normalize('NFKC', body).split())
if body:
values.append('body:' + body)
return [sha256(value.encode()).hexdigest() for value in values]
class Downloader:
def __init__(self, root=ROOT / 'downloads', requester=api_request, writer=save_article):
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True)
self.requester, self.writer = requester, writer
self.lock = threading.RLock()
self.stop = threading.Event()
self.thread = None
self.logs = deque(maxlen=300)
self.seq = 0
self.state = dict(status='idle', saved=0, target=0, skipped=0, failed=0, scanned=0, requests=0, channel='', message='等待开始')
with self.db() as db:
db.execute('CREATE TABLE IF NOT EXISTS seen (fingerprint TEXT PRIMARY KEY, directory TEXT NOT NULL)')
def db(self):
return sqlite3.connect(self.root / '.history.sqlite3')
def log(self, message):
with self.lock:
self.seq += 1
self.logs.append(dict(id=self.seq, time=datetime.now().strftime('%H:%M:%S'), message=message))
def update(self, **values):
with self.lock:
self.state.update(values)
def snapshot(self):
with self.lock:
return {**self.state, 'logs': list(self.logs), 'output': str(self.root)}
def start(self, key, channel, target):
with self.lock:
if self.thread and self.thread.is_alive():
raise ValueError('已有任务正在运行')
self.stop.clear()
self.logs.clear()
self.state = dict(status='running', saved=0, target=target, skipped=0, failed=0, scanned=0, requests=0, channel=channel, message='正在下载')
self.thread = threading.Thread(target=self.run, args=(key, channel, target), daemon=True)
self.thread.start()
def run(self, key, channel, target):
saved = skipped = failed = scanned = calls = 0
seen_in_run = set()
self.log(f'开始下载频道「{channel}」,目标 {target} 条;历史重复新闻将跳过')
try:
with self.db() as db:
offset = 0
while offset <= 400 and saved < target and not self.stop.is_set():
count = min(40, target - saved)
calls += 1
self.update(requests=calls)
self.log(f'请求第 {calls} 页:start={offset},num={count}')
items = result_list(self.requester(key, 'index', {'type': channel, 'num': count, 'start': offset}))
if not items:
self.log('接口返回空列表,已停止')
break
for article in items:
if saved >= target or self.stop.is_set():
break
scanned += 1
try:
if not isinstance(article, dict):
raise ValueError('新闻条目格式错误')
keys = fingerprints(article)
existing = any(k in seen_in_run for k in keys)
for k in keys:
row = db.execute('SELECT directory FROM seen WHERE fingerprint=?', (k,)).fetchone()
if row and Path(row[0]).is_dir():
existing = True
seen_in_run.update(keys)
if existing:
skipped += 1
self.log('跳过重复新闻:' + str(article.get('title', '无标题'))[:160])
continue
result = self.writer(article, self.root)
try:
db.executemany('INSERT OR REPLACE INTO seen VALUES (?, ?)', [(k, result.directory) for k in keys])
db.commit()
except Exception:
# 保存目录尚未登记成功,回滚本次产物,避免下次重复保存。
import shutil
shutil.rmtree(result.directory, ignore_errors=True)
raise
saved += 1
self.log(f'下载新闻 {saved}/{target} 条:{result.title}')
except Exception as exc:
failed += 1
self.log(f'保存失败:{str(exc).replace(key, "[已隐藏]")[:400]}')
finally:
self.update(saved=saved, skipped=skipped, failed=failed, scanned=scanned)
# 避免越过 start=400;已消费的条目决定下一起始位置。
offset += len(items)
if offset > 400:
self.log('已到达接口允许的分页范围')
if saved < target and offset <= 400:
self.stop.wait(0.3)
status = 'completed' if saved >= target else 'cancelled' if self.stop.is_set() else 'partial'
message = f'已保存 {saved}/{target} 条,跳过重复 {skipped} 条,失败 {failed} 条'
if status == 'partial':
message += ';当前可获取新闻不足,未达到目标数量'
if status == 'cancelled':
message += ';任务已停止'
self.update(status=status, message=message)
self.log(message)
except Exception as exc:
message = str(exc).replace(key, '[已隐藏]')[:500]
self.update(status='error', message=message)
self.log('任务中断:' + message)
def make_handler(manager, token):
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def send(self, status, payload, mime='application/json; charset=utf-8'):
data = json.dumps(payload, ensure_ascii=False).encode() if isinstance(payload, dict) else payload
self.send_response(status)
self.send_header('Content-Type', mime)
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.end_headers()
self.wfile.write(data)
def local(self):
return self.headers.get('Host') in (f'127.0.0.1:{self.server.server_port}', f'localhost:{self.server.server_port}')
def do_GET(self):
if not self.local():
return self.send(403, {'error': '仅允许本机访问'})
path = urlsplit(self.path).path
if path == '/api/state':
return self.send(200, manager.snapshot())
if path == '/api/session':
return self.send(200, {'token': token})
files = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript; charset=utf-8'), '/style.css': ('style.css', 'text/css; charset=utf-8')}
if path not in files:
return self.send(404, {'error': '未找到页面'})
name, mime = files[path]
self.send(200, (ROOT / 'static' / name).read_bytes(), mime)
def do_POST(self):
if not self.local() or self.headers.get('X-Session-Token') != token:
return self.send(403, {'error': '页面会话失效,请刷新页面'})
key = ''
try:
length = int(self.headers.get('Content-Length', 0))
if not 0 < length <= 16384:
raise ValueError('请求大小无效')
body = json.loads(self.rfile.read(length))
if not isinstance(body, dict):
raise ValueError('请求必须是 JSON 对象')
if self.path == '/api/stop':
manager.stop.set()
return self.send(200, {'ok': True})
key = str(body.get('key') or '').strip()
if not key or len(key) > 512:
raise ValueError('请填写有效 API Key')
if self.path == '/api/channels':
channels = result_list(manager.requester(key, 'channel'))
if not channels or not all(isinstance(c, str) and c for c in channels):
raise ValueError('频道列表为空或格式错误')
return self.send(200, {'channels': channels})
if self.path == '/api/start':
target = body.get('target')
channel = body.get('channel')
if type(target) is not int or not 1 <= target <= 100000:
raise ValueError('下载数量须为 1 至 100000 的整数')
if not isinstance(channel, str) or not channel.strip() or len(channel) > 100:
raise ValueError('请选择新闻频道')
manager.start(key, channel, target)
return self.send(200, {'ok': True})
if self.path == '/api/debug':
endpoint = body.get('endpoint')
params = {}
if endpoint == 'index':
for field, low, high in [('num', 1, 40), ('start', 0, 400)]:
value = body.get(field)
if type(value) is not int or not low <= value <= high:
raise ValueError(f'{field} 必须在 {low}–{high} 之间')
params[field] = value
params['type'] = str(body.get('channel') or '')
before = time.monotonic()
payload = manager.requester(key, endpoint, params)
# 服务端响应同样隐藏 key,且只作为文本展示,不渲染上游 HTML。
clean = json.loads(json.dumps(payload, ensure_ascii=False).replace(key, '[已隐藏]'))
return self.send(200, {'elapsed': round(time.monotonic() - before, 2), 'request': API + str(endpoint) + '?' + urlencode({'key': '[已隐藏]', **params}), 'response': clean})
self.send(404, {'error': '未知操作'})
except Exception as exc:
message = str(exc)
if key:
message = message.replace(key, '[已隐藏]')
self.send(400, {'error': message[:500]})
return Handler
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', type=int, default=8765)
parser.add_argument('--no-browser', action='store_true')
args = parser.parse_args()
manager = Downloader()
server = ThreadingHTTPServer(('127.0.0.1', args.port), make_handler(manager, secrets.token_urlsafe(32)))
url = f'http://127.0.0.1:{server.server_port}'
print(f'新闻下载器:{url}\n保存目录:{manager.root}\n按 Ctrl+C 退出', flush=True)
if not args.no_browser:
webbrowser.open(url)
try:
server.serve_forever()
except KeyboardInterrupt:
manager.stop.set()
if manager.thread:
manager.thread.join(timeout=45)
finally:
server.server_close()
if __name__ == '__main__':
main()
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""将探数新闻正文保存为与微信下载器相同的 Markdown 目录格式。"""
from __future__ import annotations
import base64
from dataclasses import asdict, dataclass
from pathlib import Path
import re
import shutil
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(article: dict, output_root: str | Path = '.', *, timeout: float = 30,
allow_missing_images: bool = False) -> ArticleResult:
"""从 API 正文保存文章;图片失败时清理半成品,不覆盖已有同名目录。"""
url = str(article.get('weburl') or article.get('url') or '')
title = str(article.get('title') or '').strip()
raw_content = str(article.get('content') or '').strip()
if not title or not raw_content:
raise ArticleDownloadError('新闻标题或正文为空')
if timeout <= 0:
raise ValueError('timeout 必须大于 0')
with _session() as session:
final_url = url
soup = BeautifulSoup('<article>' + raw_content + '</article>', 'html.parser')
content = soup.article
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 "未命名图片"}]'))
# 新闻图片后常带空的图注标题,避免生成单独的 ## 和多余空行。
for heading in content.select('h1, h2, h3, h4, h5, h6'):
if not heading.get_text(strip=True) and not heading.find('img'):
heading.decompose()
body = markdownify(str(content), heading_style='ATX', bullets='-',
strip=['picture', 'source']).strip()
# 微信富文本中各段常没有标准 p 标签;markdownify 仍会保留文字和图片顺序。
author = str(article.get('src') or '未知作者').strip() or '未知作者'
text = f'# {title}\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)
+3
View File
@@ -0,0 +1,3 @@
requests>=2.32,<3
beautifulsoup4>=4.12,<5
markdownify>=1.1,<2
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/zsh
cd "${0:A:h}"
if ! command -v uv >/dev/null 2>&1; then
print '未找到 uv,请先安装 uv 后再启动。'
read '?按回车关闭…'
exit 1
fi
uv run --no-project --python 3.12 --with-requirements requirements.txt python app.py
+79
View File
@@ -0,0 +1,79 @@
'use strict';
const $ = id => document.getElementById(id);
let token = '', running = false, starting = false, loadingChannels = false, loadedKey = '', lastLog = -1;
const labels = {idle:'等待开始',running:'下载中',completed:'已完成',partial:'未达到目标',cancelled:'已停止',error:'任务中断'};
const key = () => $('key').value.trim();
function controls() {
const busy = running || starting;
$('start').disabled = busy || !loadedKey || loadedKey !== key() || !$('channel').value;
$('stop').disabled = !running;
$('channels').disabled = busy || loadingChannels;
for (const id of ['key','channel','target']) $(id).disabled = busy || (id === 'channel' && !loadedKey);
}
async function post(path, body) {
const response = await fetch('/api/' + path, {method:'POST',headers:{'Content-Type':'application/json','X-Session-Token':token},body:JSON.stringify(body)});
const data = await response.json();
if (!response.ok) throw new Error(data.error || '请求失败');
return data;
}
$('key').addEventListener('input', () => {loadedKey=''; $('channel').replaceChildren(new Option('请重新获取频道','')); controls();});
$('channels').addEventListener('click', async () => {
loadingChannels=true; controls(); $('notice').textContent='正在获取频道…';
const submittedKey = key();
try {
const data = await post('channels',{key:submittedKey});
if (key() !== submittedKey) return;
$('channel').replaceChildren(...data.channels.map(c => new Option(c,c)));
loadedKey=submittedKey; $('notice').textContent=`已获取 ${data.channels.length} 个频道。`;
$('debug-channel').value=$('channel').value;
} catch(error) {$('notice').textContent=error.message;}
finally {loadingChannels=false; controls();}
});
$('channel').addEventListener('change', () => {$('debug-channel').value=$('channel').value; controls();});
$('download-form').addEventListener('submit', async event => {
event.preventDefault(); starting=true; controls(); $('notice').textContent='';
try {await post('start',{key:key(),channel:$('channel').value,target:Number($('target').value)}); await refresh();}
catch(error) {$('notice').textContent=error.message;}
finally {starting=false; controls();}
});
$('stop').addEventListener('click', async () => {
$('stop').disabled=true;
try {await post('stop',{}); $('notice').textContent='正在停止,等待当前请求或文章保存结束…';}
catch(error) {$('notice').textContent=error.message;}
});
$('debug-form').addEventListener('submit', async event => {
event.preventDefault(); $('debug-submit').disabled=true; $('debug-output').textContent='正在请求…';
try {
const data=await post('debug',{key:key(),endpoint:$('endpoint').value,channel:$('debug-channel').value,num:Number($('debug-num').value),start:Number($('debug-start').value)});
const output=JSON.stringify(data,null,2);
$('debug-output').textContent=output.slice(0,100000)+(output.length>100000?'\n…输出超过 100000 字符,已截断。':'');
} catch(error) {$('debug-output').textContent=error.message;}
finally {$('debug-submit').disabled=false;}
});
async function refresh() {
const response=await fetch('/api/state');
if (!response.ok) throw new Error('无法读取下载状态');
const data=await response.json();
running=data.status==='running'; controls();
const percent=data.target ? Math.floor(data.saved/data.target*100) : 0;
$('status').textContent=labels[data.status] || data.status;
$('progress-text').textContent=`下载新闻 ${data.saved}/${data.target} 条`;
$('percent').textContent=`已完成 ${percent}%`; $('progress').value=percent;
for (const id of ['saved','skipped','failed','requests','message','output']) $(id).textContent=data[id];
const latest=data.logs.length ? data.logs[data.logs.length-1].id : 0;
if (latest!==lastLog) {
const logs=$('logs'); const bottom=logs.scrollHeight-logs.scrollTop-logs.clientHeight<40;
logs.replaceChildren(...data.logs.map(log => {const row=document.createElement('div'); const stamp=document.createElement('time'); stamp.textContent=log.time; row.append(stamp,document.createTextNode(log.message)); return row;}));
if (bottom) logs.scrollTop=logs.scrollHeight;
lastLog=latest;
}
}
async function poll() {
try {await refresh();}
catch(error) {$('notice').textContent='与本地服务连接中断,请检查启动窗口。';}
setTimeout(poll,700);
}
(async () => {
try {const response=await fetch('/api/session'); token=(await response.json()).token; await poll();}
catch(error) {$('notice').textContent='初始化失败,请刷新页面。';}
})();
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>探数 · 新闻下载器</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
<body><main>
<header><div class="brand">TANSHU / NEWS ARCHIVE</div><h1>把新闻,保存到本地。</h1><p>选择频道,收集新闻。正文与图片一起保存,自动跳过已下载的内容。</p></header>
<section class="card"><div class="section-heading"><h2>下载设置</h2><span class="badge">本地运行</span></div>
<form id="download-form"><label for="key">API Key</label><div class="key-row"><input id="key" type="password" placeholder="填写探数 API Key" autocomplete="off" required maxlength="512"><button id="channels" type="button" class="secondary">获取新闻频道</button></div><p class="hint">密钥仅用于本次页面会话,不写入文件或浏览器存储。</p>
<div class="fields"><div><label for="channel">新闻频道</label><select id="channel" required disabled><option value="">先填写密钥并获取频道</option></select></div><div><label for="target">下载数量</label><input id="target" type="number" min="1" max="100000" step="1" value="10" required></div></div>
<p class="hint">接口每次最多 40 条,起始位置最多 400。遇到重复会继续向后查找;可用新闻不足时会说明原因。</p><div class="actions"><button id="start" disabled>开始下载</button><button id="stop" type="button" class="secondary" disabled>停止下载</button></div><p id="notice" role="alert"></p></form></section>
<section class="card"><div class="section-heading"><h2>下载进度</h2><span id="status" class="badge">等待开始</span></div><div class="progress-heading"><strong id="progress-text">下载新闻 0/0 条</strong><span id="percent">已完成 0%</span></div><progress id="progress" max="100" value="0" aria-label="下载进度"></progress><div class="stats"><span>已保存 <b id="saved">0</b></span><span>重复跳过 <b id="skipped">0</b></span><span>保存失败 <b id="failed">0</b></span><span>接口请求 <b id="requests">0</b></span></div><p id="message">等待开始</p><p class="hint path">保存位置:<span id="output">加载中…</span></p></section>
<section class="card"><div class="section-heading"><h2>运行输出</h2><span class="hint">保留最近 300 条</span></div><div id="logs" class="console" role="log" aria-label="运行输出"><div class="empty">运行情况将在这里显示。</div></div></section>
<details class="card"><summary>调试终端 <span>手动请求接口</span></summary><p class="hint">使用上方 API Key。请求会消耗接口调用额度,但不会保存新闻。</p><form id="debug-form"><div class="fields"><div><label for="endpoint">请求接口</label><select id="endpoint"><option value="channel">新闻频道查询 /channel</option><option value="index">新闻列表查询 /index</option></select></div><div><label for="debug-channel">type · 频道</label><input id="debug-channel" value="头条"></div><div><label for="debug-num">num · 数量(1–40)</label><input id="debug-num" type="number" min="1" max="40" value="1" required></div><div><label for="debug-start">start · 起始位置(0–400)</label><input id="debug-start" type="number" min="0" max="400" value="0" required></div></div><button id="debug-submit" class="secondary">发送测试请求</button></form><pre id="debug-output" class="console">尚未发送请求。</pre></details>
<footer>Markdown 正文 · 本地图片 · 历史去重</footer>
</main></body></html>
+1
View File
@@ -0,0 +1 @@
:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:#20312e;background:#f3f6f3;font-size:15px}*{box-sizing:border-box}body{margin:0}main{max-width:960px;margin:0 auto;padding:48px 24px 24px}header{margin-bottom:32px}.brand{font-size:12px;letter-spacing:2px;font-weight:700;color:#437b69}h1{font-size:36px;letter-spacing:-1px;margin:14px 0}header p{color:#687a74;line-height:1.8}h2{font-size:17px;margin:0}.card{padding:26px 28px;background:#fff;border:1px solid #dfe7e1;border-radius:16px;margin-bottom:20px;box-shadow:0 4px 16px #243c2904}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:24px}.badge{background:#edf4ef;color:#4c7564;padding:6px 10px;border-radius:20px;font-size:12px}.key-row{display:flex;gap:12px}.key-row input{flex:1;min-width:0}label{display:block;font-weight:600;margin-bottom:9px;font-size:13px}input,select,button{font:inherit;border-radius:8px}input,select{background:#fbfcfb;border:1px solid #d7e0d9;width:100%;padding:12px;color:inherit}input:focus,select:focus,button:focus-visible,summary:focus-visible{outline:3px solid #8dcab5;outline-offset:2px}button{cursor:pointer;border:1px solid #286850;background:#286850;color:#fff;padding:12px 22px;font-weight:600;white-space:nowrap}.secondary{background:#fff;color:#345e4f;border-color:#cedbd3}button:disabled{opacity:.45;cursor:not-allowed}.fields{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:24px 0 16px}.hint{font-size:12px;color:#79867f;line-height:1.8;margin:10px 0}.actions{display:flex;gap:12px;margin-top:22px}#notice{color:#ac4c32;font-size:13px;white-space:pre-wrap}#notice:empty{display:none}.progress-heading{display:flex;justify-content:space-between;gap:16px;font-size:14px}#percent{color:#3f7b60}progress{display:block;width:100%;height:12px;border:0;border-radius:10px;overflow:hidden;margin:18px 0 20px;background:#edf2ef}progress::-webkit-progress-bar{background:#edf2ef}progress::-webkit-progress-value{background:#3c8b68;transition:width .25s}progress::-moz-progress-bar{background:#3c8b68}.stats{display:flex;gap:28px;flex-wrap:wrap;color:#7a8880;font-size:13px}.stats b{color:#304e40;margin-left:8px;font-size:17px}#message{font-size:13px;color:#52685b;line-height:1.8}.path{overflow-wrap:anywhere}.console{height:260px;overflow:auto;background:#15251f;color:#c5dacf;border-radius:10px;padding:18px;font:12px/1.9 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.console time{color:#7eac94;margin-right:14px}.empty{color:#7e9889}summary{cursor:pointer;font-weight:600}summary span{font-size:12px;font-weight:400;color:#79867f;margin-left:12px}#debug-output{margin-bottom:0;height:300px}footer{text-align:center;color:#89958e;font-size:12px;margin:32px 0}@media(max-width:600px){main{padding:26px 14px}h1{font-size:28px}.card{padding:22px 18px}.key-row{flex-direction:column}.fields{grid-template-columns:1fr}.stats{gap:14px}.progress-heading{font-size:12px}}
+164
View File
@@ -0,0 +1,164 @@
import base64
import json
from pathlib import Path
import sys
import tempfile
import threading
import unittest
from http.server import ThreadingHTTPServer
import requests
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app import Downloader, make_handler, fingerprints
from article_writer import save_article
def article(i):
return dict(title=f'新闻{i}', content=f'<p>第{i}篇新闻内容</p>', url=f'https://example.com/{i}', src='测试来源', time='2026-10-31')
class DownloadTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
def tearDown(self):
self.temp.cleanup()
def run_job(self, manager, target):
manager.start('test-key', '头条', target)
manager.thread.join(10)
self.assertFalse(manager.thread.is_alive())
return manager.snapshot()
def test_format_and_local_image(self):
data = article(1)
image = 'data:image/png;base64,' + base64.b64encode(b'\x89PNG\r\n\x1a\nexample').decode()
data['content'] += f'<img src="{image}"><img src="{image}">'
result = save_article(data, self.root)
body = Path(result.markdown).read_text()
self.assertTrue(body.startswith('# 新闻1\n\n第1篇新闻内容'))
self.assertNotIn('发布时间:2026-10-31', body)
self.assertNotIn('\n---\n', body)
self.assertTrue(body.endswith('来源:测试来源,如有侵权,请联系删除。\n'))
self.assertEqual(len(result.images), 1)
self.assertEqual(body.count('image001.png'), 2)
self.assertNotIn('data:image', body)
second = save_article(data, self.root)
self.assertEqual(Path(second.markdown).name, '新闻1 (2).md')
def test_bad_image_cleans_partial(self):
data = article(1)
data['content'] += '<img src="data:image/png;base64,aW52YWxpZA==">'
with self.assertRaises(Exception):
save_article(data, self.root)
self.assertEqual(list(self.root.iterdir()), [])
def test_duplicate_then_fill_and_restart(self):
items = [article(1), article(1), article(2), article(3)]
calls = []
def request(key, endpoint, params):
calls.append(params.copy())
return {'code': 1, 'data': {'list': items[params['start']:params['start']+params['num']]}}
manager = Downloader(self.root, request)
state = self.run_job(manager, 3)
self.assertEqual((state['status'], state['saved'], state['skipped']), ('completed', 3, 1))
self.assertEqual([c['start'] for c in calls], [0, 3])
restarted = Downloader(self.root, request)
state = self.run_job(restarted, 3)
self.assertEqual((state['status'], state['saved'], state['skipped']), ('partial', 0, 4))
self.assertEqual(len(list(self.root.glob('*/*.md'))), 3)
def test_offset_limit_and_partial_progress(self):
offsets = []
def request(key, endpoint, params):
offsets.append(params['start'])
return {'code': 1, 'data': {'list': [article(i) for i in range(params['start'], params['start']+params['num'])]}}
state = self.run_job(Downloader(self.root, request), 500)
self.assertEqual(state['status'], 'partial')
self.assertEqual(state['saved'], 440)
self.assertEqual(max(offsets), 400)
def test_error_and_key_redaction(self):
def request(*args):
return {'code': 10001, 'msg': 'invalid test-key'}
manager = Downloader(self.root, request)
state = self.run_job(manager, 10)
self.assertEqual(state['status'], 'error')
self.assertNotIn('test-key', json.dumps(state))
def test_stop_and_concurrent_start(self):
entered, release = threading.Event(), threading.Event()
def request(*args):
entered.set()
release.wait(2)
return {'code': 1, 'data': {'list': [article(1)]}}
manager = Downloader(self.root, request)
manager.start('key', '头条', 10)
self.assertTrue(entered.wait(2))
with self.assertRaises(ValueError):
manager.start('key', '头条', 10)
manager.stop.set()
release.set()
manager.thread.join(3)
self.assertEqual(manager.snapshot()['status'], 'cancelled')
self.assertEqual(manager.snapshot()['saved'], 0)
def test_failed_save_can_retry_next_job(self):
attempts = []
def writer(data, root):
attempts.append(1)
if len(attempts) == 1:
raise ValueError('模拟图片失败')
return save_article(data, root)
def request(key, endpoint, params):
return {'code': 1, 'data': {'list': [article(1)] if params['start'] == 0 else []}}
manager = Downloader(self.root, request, writer)
self.assertEqual(self.run_job(manager, 1)['failed'], 1)
self.assertEqual(self.run_job(manager, 1)['saved'], 1)
def test_fingerprint_content_and_tracking(self):
a, b = article(1), article(2)
b['content'] = a['content'].replace('<p>', '<div>').replace('</p>', '</div>')
self.assertTrue(set(fingerprints(a)) & set(fingerprints(b)))
b['content'] = '其他内容'
b['url'] = a['url'] + '?utm_source=abc#fragment'
self.assertTrue(set(fingerprints(a)) & set(fingerprints(b)))
def test_log_cap(self):
manager = Downloader(self.root)
for i in range(400):
manager.log(str(i))
self.assertEqual(len(manager.snapshot()['logs']), 300)
self.assertEqual(manager.snapshot()['logs'][0]['message'], '100')
def test_http_routes_and_access(self):
def request(key, endpoint, params=None):
return {'code': 1, 'data': {'list': ['头条', '科技']}}
manager = Downloader(self.root, request)
server = ThreadingHTTPServer(('127.0.0.1', 0), make_handler(manager, 'token'))
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
url = f'http://127.0.0.1:{server.server_port}'
try:
self.assertIn('新闻下载器', requests.get(url).text)
self.assertEqual(requests.get(url+'/api/state').json()['status'], 'idle')
self.assertEqual(requests.post(url+'/api/channels', json={'key': 'test'}).status_code, 403)
self.assertEqual(requests.get(url, headers={'Host':'evil.example'}).status_code, 403)
headers = {'X-Session-Token': 'token'}
self.assertEqual(requests.post(url+'/api/channels', headers=headers, json={'key':'test'}).json()['channels'], ['头条','科技'])
invalid = requests.post(url+'/api/start', headers=headers, json={'key':'test', 'target':0,'channel':'头条'})
self.assertEqual(invalid.status_code, 400)
invalid = requests.post(url+'/api/debug', headers=headers, json={'key':'test','endpoint':'index','num':41,'start':0})
self.assertEqual(invalid.status_code, 400)
debug = requests.post(url+'/api/debug', headers=headers, json={'key':'test','endpoint':'channel'})
self.assertEqual(debug.status_code, 200)
self.assertNotIn('key=test', debug.text)
finally:
server.shutdown()
server.server_close()
worker.join()
if __name__ == '__main__':
unittest.main()