接通审核中结果核对、受理记账与后续调度

This commit is contained in:
Longtail committed 2026-10-31 17:20:36 +08:00
1 parent d157c179c8
commit f0be4f5596
18 files changed
+510 -57

No files matched your search

+75 -12
View File
@@ -1,12 +1,14 @@
"""Persistent serial queue. Browser submissions are always initiated by the user."""
import json
import copy
import re
from contextlib import contextmanager
from pathlib import Path
import secrets
import sqlite3
import threading
import time
from urllib.parse import urlsplit
from urllib.parse import urlsplit, parse_qs
from articles import inspect_article
@@ -25,10 +27,21 @@ class Store:
CREATE TABLE IF NOT EXISTS ledger(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS settings(id INTEGER PRIMARY KEY CHECK(id=1), value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS posts(id TEXT PRIMARY KEY, job TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS post_aliases(url TEXT PRIMARY KEY, thread_id TEXT NOT NULL, job TEXT NOT NULL);
''')
row = db.execute('SELECT value FROM settings WHERE id=1').fetchone()
self.run = json.loads(row[0]) if row else dict(running=False, epoch=0, config={}, batch=None, active=None, owner=None, switch=None, reason='请配置任务后开始')
self.run.update(running=False, epoch=self.run['epoch'] + 1, reason='服务已启动,请明确点击开始/继续;未决任务不会重发')
for row in db.execute('SELECT value FROM jobs').fetchall():
job = json.loads(row[0])
if job['state'] in ACCEPTED and job.get('post_url'):
canonical = job.get('thread_id')
if not canonical:
job['legacy_url_id'] = job.get('post_id')
job['identity_needs_reconciliation'] = True
self._save(db, job)
db.execute('INSERT OR IGNORE INTO post_aliases VALUES(?,?,?)',
(job['post_url'], canonical or 'unknown', job['id']))
self._persist(db)
self.clients = {}
if ledger and Path(ledger).is_file():
@@ -37,9 +50,14 @@ class Store:
@contextmanager
def db(self):
connection = sqlite3.connect(self.path, timeout=20)
previous = copy.deepcopy(getattr(self, 'run', None))
try:
with connection:
yield connection
except Exception:
if previous is not None:
self.run = previous
raise
finally:
connection.close()
@@ -151,6 +169,12 @@ class Store:
self.run.update(batch=self._batch(account), switch=None)
self._persist(db)
else:
ext = data.get('extension', {})
elapsed = time.time() - switch.get('clicked_at', switch['created_at'])
if ext.get('paused') or (switch['state'] == 'clicked' and elapsed > 10 and ext.get('idle')) or elapsed > 120:
self.run.update(running=False, reason='换号已暂停、超时或账号未改变,请检查插件并人工恢复,禁止重复抽号')
self._persist(db)
return dict(action='idle', run=self.run)
return dict(action='switch' if switch['state'] == 'pending' else 'switch_wait', run=self.run)
if self.run['batch'] is None:
self.run['batch'] = self._batch(account)
@@ -158,6 +182,13 @@ class Store:
self.run.update(running=False, reason='检测到账号变化,请先核对当前任务')
self._persist(db)
return dict(action='idle', run=self.run)
if self.run.get('return_home'):
url = urlsplit(str(data.get('url', '')))
if url.scheme == 'https' and url.netloc in ('www.dww11.com', 'dww11.com') and url.path == '/' and not url.query:
self.run['return_home'] = False
self._persist(db)
else:
return dict(action='home', run=self.run)
if self.run['active']:
job = self._job(db, self.run['active'])
return dict(action='resume', job=job, run=self.run)
@@ -207,7 +238,9 @@ class Store:
self._check_owner(data['owner'])
if not self.run['running'] or not self.run['switch'] or self.run['switch']['state'] != 'pending':
raise ValueError('换号状态已改变,禁止重复点击')
self.run['switch']['state'] = 'clicked'
if data.get('epoch', self.run['epoch']) != self.run['epoch']:
raise ValueError('换号请求已过期')
self.run['switch'].update(state='clicked', clicked_at=time.time())
self._persist(db)
return {'ok': True}
@@ -234,7 +267,7 @@ class Store:
transitions = {'preparing': {'importing', 'needs_attention'}, 'importing': {'formatting', 'needs_attention'},
'formatting': {'awaiting_user_review', 'needs_attention'},
'awaiting_user_review': {'user_submitting', 'needs_attention'},
'user_submitting': {'submit_unknown', 'needs_attention'}, 'submit_unknown': {'needs_attention'}}
'user_submitting': {'submit_unknown', 'needs_attention', 'rejected'}, 'submit_unknown': {'needs_attention'}}
if target not in transitions.get(job['state'], set()):
raise ValueError('任务状态不允许此操作:' + job['state'] + ' → ' + target)
if target in ('importing', 'formatting', 'awaiting_user_review') and (not self.run['running'] or data.get('epoch') != self.run['epoch']):
@@ -244,13 +277,27 @@ class Store:
job.update(submitted_at=time.time(), snapshot=data.get('snapshot', {}), attempt=secrets.token_hex(8))
if target == 'awaiting_user_review':
job['prepared'] = data.get('snapshot', {})
if target == 'needs_attention':
if target in ('needs_attention', 'rejected'):
self.run.update(running=False, reason=job['error'] or '当前任务需要处理')
self._persist(db)
self._save(db, job)
return {'ok': True}
def reconcile(self, data):
if data.get('confirmed') is not True:
raise ValueError('请先确认这是当前文章的已受理结果')
with self.lock, self.db() as db:
if self.run['running']:
raise ValueError('请先暂停流程再补核历史受理记录')
job = self._job(db, data['id'])
if data.get('article_key') != job['article_key']:
raise ValueError('文章身份不匹配')
return self._accept({**data, 'owner': self.run['owner']}, count_batch=False)
def accept(self, data):
return self._accept(data)
def _accept(self, data, count_batch=True):
with self.lock, self.db() as db:
self._check_owner(data['owner'])
job = self._job(db, data['id'])
@@ -259,32 +306,46 @@ class Store:
if self.run['active'] != job['id'] or job['state'] not in SUBMITTED:
raise ValueError('只有已观察到人工提交的任务才能登记结果')
url = urlsplit(str(data.get('post_url', '')))
import re
match = re.fullmatch(r'/thread/(\d+)\.html', url.path)
if url.scheme != 'https' or url.netloc not in ('www.dww11.com', 'dww11.com') or not match:
query = parse_qs(url.query)
thread_id = str(data.get('thread_id', ''))
if not re.fullmatch(r'[1-9]\d*', thread_id):
raise ValueError('缺少详情页真实主题 ID,不能以地址数字代替')
if url.scheme != 'https' or url.netloc not in ('www.dww11.com', 'dww11.com') or not (match or (url.path == '/forum.php' and query.get('mod') == ['viewthread'] and query.get('tid') == [thread_id])):
raise ValueError('未识别到帖子详情页')
if data.get('author_uid') != job['account']['uid'] or data.get('fid') != job['fid']:
raise ValueError('结果页作者或版块与任务不符,请人工核对')
if data.get('title') != job.get('snapshot', {}).get('title'):
if ' '.join(str(data.get('title', '')).split()) != ' '.join(str(job.get('snapshot', {}).get('title', '')).split()):
raise ValueError('结果页标题与人工提交快照不一致')
if data.get('state') not in ACCEPTED:
raise ValueError('需要明确的公开或审核中状态')
if not data.get('evidence'):
raise ValueError('缺少页面结果证据')
post_id = match[1]
evidence = data['evidence']
if not isinstance(evidence, dict) or str(evidence.get('thread_id')) != thread_id:
raise ValueError('主题 ID 与页面证据不一致')
alias = db.execute('SELECT thread_id, job FROM post_aliases WHERE url=?', (url.geturl(),)).fetchone()
if alias and (alias[0] != thread_id or alias[1] != job['id']):
raise ValueError('帖子链接已绑定其他记录')
post_id = thread_id
existing = db.execute('SELECT job FROM posts WHERE id=?', (post_id,)).fetchone()
if existing and existing[0] != job['id']:
raise ValueError('帖子已绑定其他任务,不能重复计数')
db.execute('INSERT OR IGNORE INTO posts VALUES(?,?)', (post_id, job['id']))
job.update(state=data['state'], post_id=post_id, post_url=url.geturl(), result=data['evidence'])
db.execute('INSERT OR IGNORE INTO post_aliases VALUES(?,?,?)', (url.geturl(), thread_id, job['id']))
job.update(state=data['state'], thread_id=thread_id, post_id=post_id, first_post_pid=data.get('first_post_pid'), post_url=url.geturl(), result=evidence, integrity_issues=evidence.get('integrity_issues', []))
keys = ['article:' + job['article_key'], 'md:' + job['markdown_sha256']] + ['source:' + str(k) for k in job['source_keys']]
for key in keys:
db.execute('INSERT OR REPLACE INTO ledger VALUES(?,?)', (key, json.dumps(job, ensure_ascii=False)))
self.run['batch']['count'] += 1
self.run.update(active=None, not_before=time.time() + self.run['config']['interval'])
if count_batch:
self.run['batch']['count'] += 1
job['historical_reconciliation'] = not count_batch
self.run.update(active=None, return_home=True, not_before=time.time() + self.run['config']['interval'])
if job['integrity_issues']:
self.run.update(running=False, reason='网站已受理,内容核对发现差异,请检查帖子后明确继续')
self._save(db, job)
self._persist(db)
return {'ok': True}
return {'ok': True, 'run': dict(self.run)}
def resolve(self, data):
"""Explicit local UI resolution, never an automatic retry of an unknown submission."""
@@ -297,6 +358,8 @@ class Store:
if data.get('confirmed_not_accepted') is not True:
raise ValueError('必须先人工核对网站未受理,并清理编辑器/草稿')
job.update(state='queued' if data.get('retry') else 'cancelled', error='', snapshot={})
for key in ('submitted_at', 'attempt', 'prepared'):
job.pop(key, None)
if self.run['active'] == job['id']:
self.run.update(active=None, owner=None)
self._save(db, job)