Compare commits
3 Commits
main
...
codex/rss2026
| Author | SHA1 | Date | |
|---|---|---|---|
| 93f00d1a64 | |||
| 8b7bc6d871 | |||
| 7588a4e904 |
@@ -48,6 +48,15 @@ jobs:
|
|||||||
outside.mkdir()
|
outside.mkdir()
|
||||||
subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True)
|
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','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','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','aamas-proceedings','--help'],cwd=outside,check=True)
|
||||||
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','neurips-program','--help'],cwd=outside,check=True)
|
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','neurips-program','--help'],cwd=outside,check=True)
|
||||||
|
|||||||
@@ -118,3 +118,5 @@ CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目
|
|||||||
已导入整卷的逐条轨道修正见 [edition-tracks 维护流程](docs/edition-tracks.md):按完整证据生成只读计划,保留原投稿和决定 ID,事务性修正 edition、标识与覆盖,并防止旧整卷刷新覆盖分类。
|
已导入整卷的逐条轨道修正见 [edition-tracks 维护流程](docs/edition-tracks.md):按完整证据生成只读计划,保留原投稿和决定 ID,事务性修正 edition、标识与覆盖,并防止旧整卷刷新覆盖分类。
|
||||||
|
|
||||||
入库前可使用通用的[来源身份只读预检](docs/source-identity-audit.md),导出候选论文、稳定标识和既有投稿决定证据;候选结果需要审核,不会自动批准导入。
|
入库前可使用通用的[来源身份只读预检](docs/source-identity-audit.md),导出候选论文、稳定标识和既有投稿决定证据;候选结果需要审核,不会自动批准导入。
|
||||||
|
|
||||||
|
RSS 2026 已独立适配官方论文集与录用表;使用方法和与 2025 demo 分类的区别见 [RSS 2026 来源说明](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 进行。
|
||||||
@@ -23,6 +23,7 @@ COMMANDS = {
|
|||||||
('collect','colm'): ('paper_atlas.collectors.proceedings', {'source':'colm'}, 'COLM 官方论文集'),
|
('collect','colm'): ('paper_atlas.collectors.proceedings', {'source':'colm'}, 'COLM 官方论文集'),
|
||||||
('collect','neurips'): ('paper_atlas.collectors.proceedings', {'source':'neurips'}, 'NeurIPS 官方论文集'),
|
('collect','neurips'): ('paper_atlas.collectors.proceedings', {'source':'neurips'}, 'NeurIPS 官方论文集'),
|
||||||
('collect','mlsys'): ('paper_atlas.collectors.proceedings', {'source':'mlsys'}, 'MLSys 官方历年标题与摘要'),
|
('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','cvf'): ('paper_atlas.collectors.vision', {'source':'cvf'}, 'CVPR / ICCV 官方开放目录'),
|
||||||
('collect','ecva'): ('paper_atlas.collectors.vision', {'source':'ecva'}, 'ECCV 官方开放目录'),
|
('collect','ecva'): ('paper_atlas.collectors.vision', {'source':'ecva'}, 'ECCV 官方开放目录'),
|
||||||
('collect','aamas-proceedings'): ('paper_atlas.collectors.aamas', {}, '按官方目录区分 AAMAS 发表形式和摘要'),
|
('collect','aamas-proceedings'): ('paper_atlas.collectors.aamas', {}, '按官方目录区分 AAMAS 发表形式和摘要'),
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""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 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)
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
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 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}))
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,100 @@
|
|||||||
|
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)
|
||||||
|
self.assertTrue((self.root/'unsafe/failed.json').exists())
|
||||||
|
self.assertFalse((self.root/'unsafe/collection-report.json').exists())
|
||||||
Reference in New Issue
Block a user