#!/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()