211 lines
9.8 KiB
Python
211 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Local controller: serves UI and authenticated browser bridge on loopback only."""
|
|
import argparse
|
|
import hmac
|
|
import importlib.util
|
|
import json
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
import secrets
|
|
import sys
|
|
import threading
|
|
from urllib.parse import urlsplit
|
|
import webbrowser
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(ROOT / 'modules/downloader'))
|
|
spec = importlib.util.spec_from_file_location('news_downloader', ROOT / 'modules/downloader/app.py')
|
|
downloader = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(downloader)
|
|
from store import Store
|
|
|
|
|
|
class Controller:
|
|
def __init__(self, data=ROOT / 'data', downloads=ROOT / 'downloads', ledger=ROOT / '发布去重记录.json'):
|
|
self.store = Store(Path(data) / 'queue.sqlite3', ledger)
|
|
self.ui_token, self.bridge_token = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
|
self.manager = downloader.Downloader(downloads, on_saved=self.saved)
|
|
self.scan_lock = threading.Lock()
|
|
|
|
def saved(self, article, result, keys):
|
|
directory = Path(result.directory)
|
|
marker = directory / '.article.json'
|
|
pending = directory / '.article.json.tmp'
|
|
pending.write_text(json.dumps({'fingerprints': keys, 'source_url': result.source_url}, ensure_ascii=False), encoding='utf-8')
|
|
pending.replace(marker)
|
|
self.store.enqueue(directory)
|
|
|
|
def scan(self, root):
|
|
if self.manager.thread and self.manager.thread.is_alive():
|
|
raise ValueError('下载进行中,请等待完成后扫描;新下载文章会自动入队')
|
|
root = Path(root).expanduser().resolve(strict=True)
|
|
if not root.is_dir():
|
|
raise ValueError('文章根目录不存在')
|
|
if not self.scan_lock.acquire(blocking=False):
|
|
raise ValueError('正在扫描,请稍候')
|
|
try:
|
|
directories = [root] if list(root.glob('*.md')) else sorted(p for p in root.iterdir() if p.is_dir() and not p.name.startswith('.'))
|
|
result = []
|
|
for directory in directories:
|
|
try:
|
|
status = self.store.enqueue(directory)
|
|
except (ValueError, OSError) as error:
|
|
status = str(error)
|
|
result.append({'directory': directory.name, 'status': status})
|
|
return {'results': result}
|
|
finally:
|
|
self.scan_lock.release()
|
|
|
|
def ui(self, route, body):
|
|
if route == 'state':
|
|
return {**self.store.state(), 'download': self.manager.snapshot()}
|
|
if route == 'pair':
|
|
return {'token': self.bridge_token}
|
|
if route == 'channels':
|
|
key = str(body.get('key', '')).strip()
|
|
if not key or len(key) > 512:
|
|
raise ValueError('请填写 API Key')
|
|
items = downloader.result_list(downloader.api_request(key, 'channel'))
|
|
if not items or not all(isinstance(x, str) and x for x in items):
|
|
raise ValueError('频道响应格式不正确')
|
|
return {'channels': items}
|
|
if route == 'download':
|
|
key, channel, target = str(body.get('key', '')).strip(), body.get('channel'), body.get('target')
|
|
if not key or len(key) > 512 or not isinstance(channel, str) or not channel.strip() or type(target) is not int or not 1 <= target <= 100000:
|
|
raise ValueError('请填写 Key、频道和有效下载数量')
|
|
self.manager.start(key, channel, target)
|
|
return {'ok': True}
|
|
if route == 'download-stop':
|
|
self.manager.stop.set()
|
|
return {'ok': True}
|
|
if route == 'scan':
|
|
return self.scan(body.get('root', '')) if body.get('root') else self.scan(self.manager.root)
|
|
if route == 'configure':
|
|
self.store.configure(body)
|
|
return {'ok': True}
|
|
if route in ('start', 'pause', 'stop'):
|
|
if route == 'stop':
|
|
self.manager.stop.set()
|
|
return self.store.control(route)
|
|
if route == 'reconcile':
|
|
return self.store.reconcile(body)
|
|
if route == 'resolve':
|
|
return self.store.resolve(body)
|
|
if route == 'remove':
|
|
return self.store.remove(body)
|
|
if route == 'history':
|
|
records = body.get('records')
|
|
if not isinstance(records, list):
|
|
raise ValueError('历史记录需包含 records 数组')
|
|
self.store.import_history(records)
|
|
return {'ok': True}
|
|
raise ValueError('未知操作')
|
|
|
|
def bridge(self, route, body):
|
|
methods = {'heartbeat': self.store.heartbeat, 'next': self.store.next, 'package': self.store.package,
|
|
'event': self.store.event, 'accept': self.store.accept, 'switch-begin': self.store.switch_begin,
|
|
'takeover': self.store.takeover, 'current': self.store.current, 'skip': self.store.skip}
|
|
if route in ('start', 'pause', 'stop'):
|
|
if route == 'stop':
|
|
self.manager.stop.set()
|
|
return self.store.control(route)
|
|
if route not in methods:
|
|
raise ValueError('未知浏览器操作')
|
|
return methods[route](body)
|
|
|
|
|
|
def make_handler(controller):
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def log_message(self, *args):
|
|
pass # Never echo request bodies, API keys or pairing tokens.
|
|
|
|
def send(self, code, payload, mime='application/json; charset=utf-8'):
|
|
content = payload if isinstance(payload, bytes) else json.dumps(payload, ensure_ascii=False).encode()
|
|
self.send_response(code)
|
|
self.send_header('Content-Type', mime)
|
|
self.send_header('Content-Length', str(len(content)))
|
|
self.send_header('Cache-Control', 'no-store')
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'")
|
|
self.end_headers()
|
|
try:
|
|
self.wfile.write(content)
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
|
|
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 origin_ok(self):
|
|
origin = self.headers.get('Origin')
|
|
return not origin or origin in (f'http://127.0.0.1:{self.server.server_port}', f'http://localhost:{self.server.server_port}')
|
|
|
|
def do_GET(self):
|
|
if not self.local() or not self.origin_ok():
|
|
return self.send(403, {'error': '仅允许本机控制台访问'})
|
|
route = urlsplit(self.path).path
|
|
if route == '/api/session':
|
|
return self.send(200, {'token': controller.ui_token})
|
|
files = {'/': ('static/index.html', 'text/html; charset=utf-8'),
|
|
'/app.js': ('static/app.js', 'text/javascript; charset=utf-8'),
|
|
'/style.css': ('static/style.css', 'text/css; charset=utf-8'),
|
|
'/browser/auto-news.user.js': ('browser/auto-news.user.js', 'text/javascript; charset=utf-8'),
|
|
'/browser/local-bridge.user.js': ('browser/local-bridge.user.js', 'text/javascript; charset=utf-8')}
|
|
if route not in files:
|
|
return self.send(404, {'error': '不存在'})
|
|
name, mime = files[route]
|
|
file = ROOT / name
|
|
if not file.exists():
|
|
return self.send(404, {'error': '请先运行 build_browser.py'})
|
|
return self.send(200, file.read_bytes(), mime)
|
|
|
|
def do_POST(self):
|
|
if not self.local():
|
|
return self.send(403, {'error': 'Host 不匹配'})
|
|
route = urlsplit(self.path).path
|
|
bridge = route.startswith('/bridge/')
|
|
expected = controller.bridge_token if bridge else controller.ui_token
|
|
provided = self.headers.get('X-Session-Token', '')
|
|
if not hmac.compare_digest(expected, provided) or (not bridge and not self.origin_ok()):
|
|
return self.send(403, {'error': '连接凭据无效,请重新配对'})
|
|
if not bridge and not route.startswith('/api/'):
|
|
return self.send(404, {'error': '不存在'})
|
|
try:
|
|
length = int(self.headers.get('Content-Length', 0))
|
|
if not 0 < length <= 2 * 1024 * 1024:
|
|
raise ValueError('请求大小无效')
|
|
body = json.loads(self.rfile.read(length))
|
|
if not isinstance(body, dict):
|
|
raise ValueError('请求必须是对象')
|
|
result = (controller.bridge if bridge else controller.ui)(route.rsplit('/', 1)[1], body)
|
|
self.send(200, result)
|
|
except (ValueError, KeyError, OSError) as error:
|
|
self.send(400, {'error': str(error)[:1000]})
|
|
except Exception:
|
|
self.send(500, {'error': '内部错误;请停止当前任务并检查本地服务'})
|
|
return Handler
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--port', type=int, default=8770)
|
|
parser.add_argument('--no-browser', action='store_true')
|
|
args = parser.parse_args()
|
|
controller = Controller()
|
|
server = ThreadingHTTPServer(('127.0.0.1', args.port), make_handler(controller))
|
|
url = f'http://127.0.0.1:{args.port}/'
|
|
print('新闻工作台:' + url, flush=True)
|
|
if not args.no_browser:
|
|
webbrowser.open(url)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
controller.manager.stop.set()
|
|
controller.store.control('stop')
|
|
finally:
|
|
server.server_close()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|