From 7588a4e904af51458a2c8d6d09f6eb3fceb62b8f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 15 Nov 2026 22:43:05 +0800 Subject: [PATCH] Collect reviewed RSS 2026 publisher metadata with separate program variants --- .gitea/workflows/check.yml | 9 ++ README.md | 2 + docs/rss2026-proceedings.md | 21 +++ paper_atlas/cli.py | 1 + paper_atlas/collectors/rss2026.py | 219 ++++++++++++++++++++++++++++++ tests/test_rss2026.py | 98 +++++++++++++ 6 files changed, 350 insertions(+) create mode 100644 docs/rss2026-proceedings.md create mode 100644 paper_atlas/collectors/rss2026.py create mode 100644 tests/test_rss2026.py diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 3a210b0..c84cd00 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -48,6 +48,15 @@ jobs: outside.mkdir() subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True) subprocess.run([python,'-I','-B','-m','paper_atlas','collect','cvf','--help'],cwd=outside,check=True) + subprocess.run([python,'-I','-B','-m','paper_atlas','collect','rss2026-proceedings','--help'],cwd=outside,check=True) + rss_fixture = json.loads(gzip.decompress((src/'tests/fixtures/rss2026-source.json.gz').read_bytes())) + rss_snapshots = root/'rss2026-snapshots' + rss_snapshots.mkdir() + for name, html in rss_fixture['pages'].items(): + (rss_snapshots/name).write_bytes(html.encode('utf-8')) + (rss_snapshots/'receipts.json').write_text(json.dumps(rss_fixture['receipts'])) + subprocess.run([python,'-I','-B','-m','paper_atlas','collect','rss2026-proceedings','--snapshots',str(rss_snapshots),'--out',str(root/'installed-rss2026')],cwd=outside,check=True) + assert json.loads((root/'installed-rss2026/collection-report.json').read_text())['records'] == 210 subprocess.run([python,'-I','-B','-m','paper_atlas','collect','www-archive','--help'],cwd=outside,check=True) subprocess.run([python,'-I','-B','-m','paper_atlas','collect','aamas-proceedings','--help'],cwd=outside,check=True) subprocess.run([python,'-I','-B','-m','paper_atlas','collect','neurips-program','--help'],cwd=outside,check=True) diff --git a/README.md b/README.md index b4c42f2..3dd80ad 100644 --- a/README.md +++ b/README.md @@ -118,3 +118,5 @@ CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目 已导入整卷的逐条轨道修正见 [edition-tracks 维护流程](docs/edition-tracks.md):按完整证据生成只读计划,保留原投稿和决定 ID,事务性修正 edition、标识与覆盖,并防止旧整卷刷新覆盖分类。 入库前可使用通用的[来源身份只读预检](docs/source-identity-audit.md),导出候选论文、稳定标识和既有投稿决定证据;候选结果需要审核,不会自动批准导入。 + +RSS 2026 已独立适配官方论文集与录用表;使用方法和与 2025 demo 分类的区别见 [RSS 2026 来源说明](docs/rss2026-proceedings.md)。 diff --git a/docs/rss2026-proceedings.md b/docs/rss2026-proceedings.md new file mode 100644 index 0000000..ed0d2c4 --- /dev/null +++ b/docs/rss2026-proceedings.md @@ -0,0 +1,21 @@ +# RSS 2026 论文集来源 + +`collect rss2026-proceedings` 是独立的年度来源适配器,不依赖尚未合入主线的 RSS 2025 PR #43,也不改变其 demo 分类。使用包内 HTML、HTTP 工具和统一 CLI;不连接数据库,不下载 PDF。 + +```sh +paper-atlas collect rss2026-proceedings --cache /data/paper-index/cache/rss2026 \ + --out /data/paper-index/collected/rss2026-native-v1 +# 固定证据复现:包含 receipts.json 和对应 HTML 文件 +paper-atlas collect rss2026-proceedings --snapshots /data/paper-index/collected/rss2026-primary-v1 \ + --out /data/paper-index/collected/rss2026-native-replay-v1 +``` + +核对的来源为 [第22卷论文集](https://www.roboticsproceedings.org/rss22/)、[录用表](https://roboticsconference.org/2026/program/papers/)、[CFP](https://roboticsconference.org/2026/information/cfp/) 和 [Sponsor Demos](https://roboticsconference.org/2026/program/demos/)。当前审查目录为210篇。CFP明确2026没有单独 demo paper 类别,Science/Systems论文放在 main;赞助商展示不作为论文,也不推断逐篇是否现场演示。投稿录用后才公开、评审不公开;此来源不能提供拒稿或评审全集。 + +每篇保留出版社原始标题、完整作者顺序、摘要、日期、BibTeX、原始 DOI,以及独立节目标题、完整作者和缩略显示、session、显示链接。节目显示编号不是原始 OpenReview 投稿编号,不写入 submission_identifiers。六个缩略作者预览按完整隐藏副本恢复核验,不假设固定字符长度;六个姓名差异和一处 RLinf/RLux 标题差异按已审核原文指纹识别并保留,不推断人名别名或官方勘误。未知差异会停止。 + +快照收据必须逐文件匹配固定URL、HTTP200、字节数、哈希和带时区的真实采集时间;拒绝路径穿越、重复收据和符号链接。缓存模式只请求已审核的固定公开HTML URL,访问限制由HTTP工具正常报错,不绕过。每份原始HTML按哈希保存到输出 evidence/。 + +输出目录必须不存在;失败目录留存供检查,换新目录重试。`collection-report.json` 最后原子写入,缺少此文件不能视为完成。清单始终 partial;已采集不代表主库完整、身份已核验或科学作品已去重。DOI也是待核对的候选标识,入库前仍需实时 PostgreSQL 身份审计、版本/作者关联复核和保护基线;入库后独立验收,所有导入结束后再做全库去重。 + +2026原始来源审计已冻结214份HTML且210篇均有摘要。回归 fixture 同时包含原始来源和独立生成的逐篇预期值,测试比较210篇完整标题/作者/摘要/DOI/日期;构建和测试仅在已授权 ces-control:host 远程 runner 进行。 diff --git a/paper_atlas/cli.py b/paper_atlas/cli.py index 1e17980..e681d3b 100644 --- a/paper_atlas/cli.py +++ b/paper_atlas/cli.py @@ -23,6 +23,7 @@ COMMANDS = { ('collect','colm'): ('paper_atlas.collectors.proceedings', {'source':'colm'}, 'COLM 官方论文集'), ('collect','neurips'): ('paper_atlas.collectors.proceedings', {'source':'neurips'}, 'NeurIPS 官方论文集'), ('collect','mlsys'): ('paper_atlas.collectors.proceedings', {'source':'mlsys'}, 'MLSys 官方历年标题与摘要'), + ('collect','rss2026-proceedings'): ('paper_atlas.collectors.rss2026', {}, '核验 RSS 2026 官方论文集与独立录用表'), ('collect','cvf'): ('paper_atlas.collectors.vision', {'source':'cvf'}, 'CVPR / ICCV 官方开放目录'), ('collect','ecva'): ('paper_atlas.collectors.vision', {'source':'ecva'}, 'ECCV 官方开放目录'), ('collect','aamas-proceedings'): ('paper_atlas.collectors.aamas', {}, '按官方目录区分 AAMAS 发表形式和摘要'), diff --git a/paper_atlas/collectors/rss2026.py b/paper_atlas/collectors/rss2026.py new file mode 100644 index 0000000..93cfe0b --- /dev/null +++ b/paper_atlas/collectors/rss2026.py @@ -0,0 +1,219 @@ +"""Reviewed RSS 2026 publisher metadata, with separate program evidence.""" +REVIEWED_AUTHOR_VARIANTS = {16: '04de7e813b7c68b5c1764e90fe41bcd2e45259d46749bb2cc54bac5150c8f25b', 27: '975f6adc4d15045f9c8829689a759c5e3a44eecb3410a18ff99b37226d9c8b97', 78: '8208d0950468295c2b73dae28658566b1c726f65a4167f490039cf3449aa2db4', 132: '31eee6289de404f3d859dd0d9de4dd40e968f86b89312de41df87ae01c529711', 199: 'ced0bc9e38b28b4a490799fd94315f104ece0df28be428b3cc417db8ea797b9d', 204: '7c6959e6668bf44af09959388f6b9e2d883a76106bf7f191dc9949368c969ee5'} +import argparse +from datetime import datetime, date +import hashlib +import json +from pathlib import Path +import re +import tempfile +import unicodedata + +from paper_atlas.io.html import Document, Node, clean +from paper_atlas.io.http import fetch + +SOURCE = 'rss_proceedings' +ROOT = 'https://www.roboticsproceedings.org/rss22/' +HEADING = 'Robotics: Science and Systems XXII' +PAGES = {'index.html': ROOT, + 'accepted.html': 'https://roboticsconference.org/2026/program/papers/', + 'cfp.html': 'https://roboticsconference.org/2026/information/cfp/', + 'sponsor-demos.html': 'https://roboticsconference.org/2026/program/demos/'} + + +def digest(raw): + return hashlib.sha256(raw).hexdigest() + + +def one(values): + if len(values) != 1: + raise ValueError('Expected one reviewed HTML field') + return values[0] + + +def comparable(value): + return unicodedata.normalize('NFC', clean(value)) + + +def parse_lists(index_html, program_html, policy_html, sponsors_html): + policy = clean(Document(policy_html).root.text()) + for text in ('RSS 2026 will not have a separate category for demo papers.', + 'papers will only be public after acceptance', 'reviews will not be publicly visible'): + if text not in policy: + raise ValueError('Reviewed 2026 publication/demo/privacy policy changed') + if [clean(n.text()) for n in Document(sponsors_html).root.walk() if n.tag == 'h1'] != ['Sponsor Demos']: + raise ValueError('Sponsor activity page changed; never classify these as papers') + nodes = list(Document(index_html).root.walk()) + if [clean(n.text()) for n in nodes if n.tag == 'h1'] != [HEADING]: + raise ValueError('Unexpected publisher edition') + index = {} + for row in (n for n in nodes if n.tag == 'tr'): + links = [n for n in row.walk() if n.tag == 'a' and re.fullmatch(r'p[0-9]{3}\.html', n.attrs.get('href', ''))] + if not links: + continue + link = one(links); pid = int(link.attrs['href'][1:4]) + if pid in index: + raise ValueError('Repeated publisher ID') + index[pid] = {'title': clean(link.text()), 'authors': clean(one([n for n in row.walk() if n.tag == 'i']).text())} + program = {} + if [clean(n.text()) for n in Document(program_html).root.walk() if n.tag == 'h1'] != ['Accepted Papers']: + raise ValueError('Unexpected accepted program') + for row in (n for n in Document(program_html).root.walk() if n.tag == 'tr'): + cells = [n for n in row.children if isinstance(n, Node) and n.tag == 'td'] + if not cells: + continue + if len(cells) != 4 or not re.fullmatch('[1-9][0-9]*', clean(cells[0].text())): + raise ValueError('Unexpected program row') + pid = int(clean(cells[0].text())) + if pid in program: + raise ValueError('Repeated program ID') + preview = clean(' '.join(n for n in cells[3].children if isinstance(n, str))) + full = one([clean(n.text()) for n in cells[3].walk() if n.tag == 'div' + and 'display:none' in n.attrs.get('style', '').replace(' ', '')]) + if not preview or not full.startswith(preview): + raise ValueError('Author preview is not a prefix of complete author evidence') + links = [n.attrs.get('href') for n in cells[2].walk() if n.tag == 'a'] + if links != [f'/2026/program/papers/{pid}/']: + raise ValueError('Program display link changed') + program[pid] = {'display_id': pid, 'title': clean(cells[2].text()), 'authors_complete': full, + 'authors_preview': preview, 'session': clean(cells[1].text()), + 'program_url': 'https://roboticsconference.org'+links[0]} + if set(index) != set(program) or set(index) != set(range(1, 211)): + raise ValueError('Reviewed 210-entry source census changed') + return index, program + + +def parse_paper(html, pid, index, program): + nodes = list(Document(html).root.walk()); meta = {} + for n in nodes: + if n.tag == 'meta' and n.attrs.get('name', '').startswith('citation_'): + meta.setdefault(n.attrs['name'], []).append(n.attrs.get('content', '')) + title = one(meta.get('citation_title', [])); authors = meta.get('citation_author', []) + day = one(meta.get('citation_publication_date', [])) + if not re.fullmatch(r'2026/[0-9]{2}/[0-9]{2}', day): + raise ValueError('Unexpected publication date') + date.fromisoformat(day.replace('/', '-')) + if (meta.get('citation_volume') != ['22'] or meta.get('citation_conference_title') != [HEADING] + or [clean(n.text()) for n in nodes if n.tag == 'h1'] != [HEADING] + or not authors or any(not clean(a) for a in authors) + or comparable(title) != comparable(index['title']) + or comparable(', '.join(authors)) != comparable(index['authors']) + or [comparable(n.text()) for n in nodes if n.tag == 'h3'] != [comparable(title)]): + raise ValueError('Publisher citation/index/visible metadata disagree') + pdf = one(meta.get('citation_pdf_url', [])) + if pdf not in (ROOT+f'p{pid:03d}.pdf', ROOT.replace('https:', 'http:')+f'p{pid:03d}.pdf'): + raise ValueError('Citation PDF ID mismatch; PDFs are never fetched') + bib = one([n.text() for n in nodes if n.tag == 'pre']) + doi = one(re.findall(r'^\s*DOI\s*=\s*\{([^{}]+)\}', bib, re.I | re.M)) + year = one(re.findall(r'^\s*YEAR\s*=\s*\{([^{}]+)\}', bib, re.I | re.M)) + if doi != f'10.15607/RSS.2026.XXII.{pid:03d}' or year != '2026': + raise ValueError('BibTeX DOI/year mismatch') + variants = [] + if comparable(', '.join(authors)) != comparable(program['authors_complete']): + key = digest(json.dumps([authors, program['authors_complete']], ensure_ascii=False, separators=(',', ':')).encode()) + if REVIEWED_AUTHOR_VARIANTS.get(pid) != key: + raise ValueError('Unreviewed complete author variant') + variants.append('reviewed_program_author_name_variant_not_person_alias') + if comparable(title) != comparable(program['title']): + if (pid, title, program['title']) != (89, + 'RLinf-VLA: A Unified and Efficient Framework for Reinforcement Learning of Vision-Language-Action Models', + 'RLux-VLA: A Unified and Efficient Framework for Reinforcement Learning of Vision-Language-Action Models'): + raise ValueError('Unreviewed program title variant') + variants.append('reviewed_program_title_variant_not_erratum') + inside = False; parts = []; markers = [] + for n in (n for n in nodes if n.tag == 'p'): + labels = [clean(b.text()) for b in n.walk() if b.tag == 'b'] + if 'Abstract:' in labels: + inside = True; markers.append('start'); value = clean(n.text()).removeprefix('Abstract:').strip() + if value: parts.append(value) + elif 'Download:' in labels: + inside = False; markers.append('end') + elif inside: + parts.append(clean(n.text())) + if markers != ['start', 'end'] or not any(parts): + raise ValueError('Missing or changed publisher abstract layout') + return {'title': title, 'authors': authors, 'abstract': '\n\n'.join(v for v in parts if v), + 'doi_raw': doi, 'publication_date': day, 'bibtex': bib, 'program_variants': variants} + + +def collect(out, *, snapshots=None, cache=None): + if (snapshots is None) == (cache is None): + raise ValueError('Specify snapshots or cache') + out = Path(out) + if out.exists(): + raise ValueError('Output directory must not exist; inspect partial/completed runs first') + out.parent.mkdir(parents=True, exist_ok=True) + out.mkdir() + evidence = {}; receipts = {} + if snapshots is not None: + root = Path(snapshots) + for r in json.loads((root/'receipts.json').read_bytes()): + name = r['file'] + if not isinstance(name, str) or Path(name).name != name or name in receipts: + raise ValueError('Unsafe or repeated snapshot receipt') + receipts[name] = r + def page(name, url): + if snapshots is not None: + path = root/name + if path.is_symlink(): raise ValueError('Snapshot symlink rejected') + raw = path.read_bytes(); r = receipts[name] + if (r['url'] != url or r.get('final_url', url) != url or r['http_status'] != 200 + or r['bytes'] != len(raw) or r['sha256'] != digest(raw)): + raise ValueError('Snapshot hash/receipt mismatch') + at = r['at'] + else: + raw = fetch(url, cache, limit=8*1024*1024) + sidecar = json.loads((Path(cache)/digest(url.encode())).with_suffix('.json').read_bytes()) + if sidecar['sha256'] != digest(raw): raise ValueError('Cache receipt mismatch') + at = sidecar['retrieved_at'] + if len(raw) > 8*1024*1024 or datetime.fromisoformat(at.replace('Z', '+00:00')).utcoffset() is None: + raise ValueError('Invalid source size/timezone') + relative = 'evidence/'+digest(raw)+'.html'; target = out/relative + target.parent.mkdir(exist_ok=True); target.write_bytes(raw) + evidence[name] = {'file': relative, 'sha256': digest(raw), 'url': url, 'observed_at': at} + return raw.decode('utf-8') + try: + pages = {name: page(name, url) for name, url in PAGES.items()} + index, program = parse_lists(pages['index.html'], pages['accepted.html'], pages['cfp.html'], pages['sponsor-demos.html']) + records = [] + for pid in sorted(index): + name = f'p{pid:03d}.html'; fields = parse_paper(page(name, ROOT+name), pid, index[pid], program[pid]) + row = {'source': SOURCE, 'external_id': f'2026/p{pid:03d}', 'url': ROOT+name, + 'title': fields['title'], 'abstract': fields['abstract'], 'authors': fields['authors'], + 'identifiers': {'doi': fields['doi_raw'].lower()}, 'venue': 'RSS', 'venue_aliases': [], + 'year': 2026, 'track': 'main', 'status': 'accepted', + 'raw_decision': 'listed in official RSS 2026 proceedings and accepted papers', + 'observed_at': evidence[name]['observed_at'], + 'metadata': {'publisher_date': fields['publication_date'], 'publisher_doi_raw': fields['doi_raw'], + 'original_bibtex': fields['bibtex'], 'program': program[pid], + 'program_variants': fields['program_variants'], 'source_evidence': evidence[name], + 'index_evidence': evidence['index.html'], 'program_evidence': evidence['accepted.html'], + 'policy_evidence': evidence['cfp.html'], + 'scope': '2026 Science/Systems papers; no separate demo-paper category. Program display ID is not an original submission ID.'}} + records.append(row) + path = out/'rss-proceedings-2026-main.jsonl' + raw = b''.join((json.dumps(r, ensure_ascii=False)+'\n').encode() for r in records) + path.write_bytes(raw) + manifest = {'source': SOURCE, 'parser_version': 1, 'venue': 'RSS', 'year': 2026, 'track': 'main', + 'count': 210, 'file': str(path), 'file_sha256': digest(raw), 'list_url': ROOT, + 'status': 'partial', 'missing_abstract_count': 0} + path.with_suffix('.manifest.json').write_text(json.dumps(manifest, indent=2)+'\n') + report = {'status': 'collected_pending_identity_review', 'source': SOURCE, 'records': 210, + 'files': [manifest], 'source_evidence': evidence, 'database_written': False, 'pdfs_downloaded': False, + 'limitations': 'Publisher metadata only; not database import, scientific-work deduplication, private reviews or a public rejection set.'} + temporary = out/'collection-report.json.part'; temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2)+'\n') + temporary.replace(out/'collection-report.json') + return report + except Exception as error: + (out/'failed.json').write_text(json.dumps({'status': 'failed_not_complete', 'error_type': type(error).__name__, 'database_written': False})+'\n') + raise + + +def main(argv=None, *, prog=None): + p = argparse.ArgumentParser(prog=prog, description=__doc__) + p.add_argument('--out', required=True, type=Path) + g = p.add_mutually_exclusive_group(required=True) + g.add_argument('--snapshots', type=Path); g.add_argument('--cache', type=Path) + args = p.parse_args(argv) + report = collect(args.out, snapshots=args.snapshots, cache=args.cache) + print(json.dumps({'status': report['status'], 'records': report['records'], 'database_written': False})) diff --git a/tests/test_rss2026.py b/tests/test_rss2026.py new file mode 100644 index 0000000..52e04df --- /dev/null +++ b/tests/test_rss2026.py @@ -0,0 +1,98 @@ +import gzip +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +from paper_atlas.collectors import rss2026 as rss + +FIXTURE = Path(__file__).parent/'fixtures/rss2026-source.json.gz' + +class Rss2026Tests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.fixture=json.loads(gzip.decompress(FIXTURE.read_bytes())) + + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) + self.root=Path(self.tmp.name);self.snap=self.root/'snapshots';self.snap.mkdir() + for name,text in self.fixture['pages'].items():(self.snap/name).write_bytes(text.encode()) + (self.snap/'receipts.json').write_text(json.dumps(self.fixture['receipts'])) + self.pages=self.fixture['pages'];self.index,self.program=self.lists() + + def lists(self,**changes): + pages={**self.pages,**changes} + return rss.parse_lists(*(pages[n] for n in rss.PAGES)) + + def test_all210_records_match_independent_primary_inventory_and_evidence(self): + report=rss.collect(self.root/'out',snapshots=self.snap) + self.assertEqual(report['records'],210);self.assertFalse(report['database_written']) + self.assertFalse(report['pdfs_downloaded']);self.assertEqual(len(report['source_evidence']),214) + manifest,=report['files'];self.assertEqual(manifest['status'],'partial') + rows=[json.loads(l) for l in Path(manifest['file']).read_text().splitlines()] + for row,gold in zip(rows,self.fixture['independent_gold']): + self.assertEqual(row['external_id'],f"2026/p{gold['paper_id']:03d}") + for key in ('title','authors','abstract'):self.assertEqual(row[key],gold[key]) + self.assertEqual(row['identifiers'],{'doi':gold['doi_raw'].lower()}) + self.assertEqual(row['metadata']['publisher_date'],gold['publication_date']) + self.assertNotIn('submission_identifiers',row);self.assertEqual(row['track'],'main') + e=row['metadata']['source_evidence'];self.assertEqual(hashlib.sha256((self.root/'out'/e['file']).read_bytes()).hexdigest(),e['sha256']) + self.assertEqual(manifest['file_sha256'],hashlib.sha256(Path(manifest['file']).read_bytes()).hexdigest()) + self.assertEqual(manifest['missing_abstract_count'],0) + + def test_full_hidden_authors_are_separate_from_six_short_previews(self): + shortened=[p for p in self.program.values() if p['authors_complete']!=p['authors_preview']] + self.assertEqual(len(shortened),6) + p=rss.parse_paper(self.pages['p087.html'],87,self.index[87],self.program[87]) + self.assertEqual(len(p['authors']),55) + self.assertFalse(p['program_variants']) + + def test_six_reviewed_name_variants_preserve_original_strings(self): + for pid in rss.REVIEWED_AUTHOR_VARIANTS: + p=rss.parse_paper(self.pages[f'p{pid:03d}.html'],pid,self.index[pid],self.program[pid]) + self.assertIn('reviewed_program_author_name_variant_not_person_alias',p['program_variants']) + self.assertNotEqual(', '.join(p['authors']),self.program[pid]['authors_complete']) + + def test_reviewed_title_variant_is_not_an_automatic_erratum(self): + p=rss.parse_paper(self.pages['p089.html'],89,self.index[89],self.program[89]) + self.assertTrue(p['title'].startswith('RLinf-VLA')) + self.assertTrue(self.program[89]['title'].startswith('RLux-VLA')) + self.assertIn('reviewed_program_title_variant_not_erratum',p['program_variants']) + + def test_unreviewed_author_or_title_variant_stops(self): + for changed in [{'authors_complete':'An Unrelated Person'},{'title':'A different paper'}]: + with self.assertRaisesRegex(ValueError,'Unreviewed'): + rss.parse_paper(self.pages['p001.html'],1,self.index[1],{**self.program[1],**changed}) + + def test_demo_policy_cannot_be_reused_from2025(self): + with self.assertRaisesRegex(ValueError,'policy'): + self.lists(**{'cfp.html':self.pages['cfp.html'].replace('will not have a separate category','will have a separate category')}) + with self.assertRaises(ValueError): + self.lists(**{'sponsor-demos.html':self.pages['sponsor-demos.html'].replace('Sponsor Demos','Demo Papers')}) + + def test_wrong_doi_year_pdf_or_missing_abstract_is_rejected(self): + html=self.pages['p001.html'] + for old,new in [('10.15607/RSS.2026.XXII.001','10.15607/RSS.2025.XXI.001'), + ('2026/07/13','2025/07/13'),('rss22/p001.pdf','rss22/p002.pdf'),('Abstract:','Summary:')]: + self.assertIn(old,html) + with self.assertRaises(ValueError):rss.parse_paper(html.replace(old,new),1,self.index[1],self.program[1]) + + def test_index_census_and_preview_tampering_stop(self): + with self.assertRaises(ValueError):self.lists(**{'index.html':self.pages['index.html'].replace('p001.html','p999.html')}) + bad=self.pages['accepted.html'].replace('Huayi Zhou, Kui Jia','Somebody Else',1) + with self.assertRaisesRegex(ValueError,'prefix'):self.lists(**{'accepted.html':bad}) + + def test_snapshot_tamper_has_no_completion_marker_and_never_fetches(self): + p=self.snap/'p001.html';p.write_bytes(p.read_bytes()+b'changed') + with patch.object(rss,'fetch',side_effect=AssertionError('network must not be used')): + with self.assertRaisesRegex(ValueError,'receipt'):rss.collect(self.root/'out',snapshots=self.snap) + self.assertFalse((self.root/'out/collection-report.json').exists()) + self.assertTrue((self.root/'out/failed.json').exists()) + + def test_existing_output_and_unsafe_receipt_are_rejected(self): + out=self.root/'existing';out.mkdir() + with self.assertRaises(ValueError):rss.collect(out,snapshots=self.snap) + receipts=[*self.fixture['receipts'],{**self.fixture['receipts'][0],'file':'../outside'}] + (self.snap/'receipts.json').write_text(json.dumps(receipts)) + with self.assertRaisesRegex(ValueError,'Unsafe'):rss.collect(self.root/'unsafe',snapshots=self.snap)