Compare commits

..

7 Commits

Author SHA1 Message Date
codex-bot b5c0618be5 Handle standalone italic author blocks in the historical archive
Metadata integrity checks / metadata (push) Successful in 2m6s
2026-11-09 16:24:42 +08:00
codex-bot 81864e48ae Preserve complete fragmented ICLR 2014 author paragraphs
Metadata integrity checks / metadata (push) Successful in 2m9s
2026-11-09 16:19:19 +08:00
codex-bot 348ebe0695 Collect archived ICLR accepted lists with track and presentation evidence
Metadata integrity checks / metadata (push) Successful in 2m15s
2026-11-09 16:09:13 +08:00
Zayden-Jung 02a408ba8f Merge pull request '保留 AAMAS 发表来源的同届 DOI 投稿身份' (#39) from codex/aamas-publication-identity into main
Metadata integrity checks / metadata (push) Successful in 2m15s
Reviewed-on: #39
2026-11-09 06:43:15 +00:00
Zayden-Jung cc78e837d2 Merge pull request '按发表形式采集 AAMAS 官方论文集' (#38) from codex/aamas-proceedings into main
Metadata integrity checks / metadata (push) Successful in 2m26s
Reviewed-on: #38
2026-11-09 05:52:57 +00:00
codex-bot ae88367fac Scope AAMAS tags to their exact source namespace
Metadata integrity checks / metadata (push) Successful in 8m56s
2026-11-09 13:31:46 +08:00
codex-bot a305d00c75 Collect evidenced AAMAS proceedings metadata by publication format
Metadata integrity checks / metadata (push) Failing after 2m2s
2026-11-09 13:27:23 +08:00
9 changed files with 829 additions and 0 deletions
+2
View File
@@ -49,6 +49,8 @@ jobs:
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','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','iclr-archive','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','postgres','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','edition-tracks','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','joint-editions','--help'],cwd=outside,check=True)
+4
View File
@@ -6,6 +6,8 @@ Paper Atlas 将 arXiv、AI 会议论文集及公开投稿决定整理到可追
项目仍在建设中。候选范围包含 43 个会议(含 ACM MM、MLSys),尚未完成全部历史数据和最终去重复核。实际进度以数据库、来源清单和覆盖报告为准,而不是仓库文件数量。
历史 ICLR 2014–2016 官方录用页由 `collect iclr-archive` 适配,保留主会/workshop及重复展示证据;运行和身份核验边界见 [历史档案说明](docs/coverage/iclr-archive-2014-2016.md)。
## 项目结构
```text
@@ -62,6 +64,8 @@ paper-atlas collect colm --cache /data/paper-index/raw/http-cache \
早期主系列的Workshop名称按[ICML/IROS精确届次证据](docs/coverage/historical-primary-series-2026-11-09.md)识别,保留原名与范围证据;代码支持不代表已补齐生产数据。
AAMAS 的[官方论文集采集器](docs/aamas-proceedings.md)目前核验了 2012 版式,可从缓存或已校验快照采集标题、作者及页面提供的摘要;发表形式单独保存,全文/扩展摘要中未明确的投稿 track 保持待明确,不根据会话主题推定主会轨道。
`paper-atlas collect --help` 查看来源;`paper-atlas import --help` 查看导入方式。完整批次步骤见[使用指南](docs/usage.md)。
共享实例名为 `foundry-postgres`,数据位于 `foundry_data` 库的 `research` schema;其他项目可按权限复用同一组表。库名通过 libpq service 配置、schema 通过 search_path 或 `--pg-schema` 指定,不写死在查询中。PostgreSQL 凭据放在服务器权限为 `0600` 的 libpq service 文件中;命令行只使用服务名称。`paper-atlas maintain postgres --help` 提供迁移入口:先完整核验 21 张业务表,再发布目标 schema。SQLite FTS5 内部表由 PostgreSQL GIN 索引替代。切换后 SQLite 为只读备份;`import records`、`import arxiv` 和 `maintain backfill` 使用 `--pg-service foundry-research` 写入主库,整个写入会话只允许一个协调写入者,会议文件失败时整体回滚并保留失败批次记录。
+43
View File
@@ -0,0 +1,43 @@
# AAMAS 官方论文集元数据
`paper_atlas.collectors.aamas` 是按需运行的采集适配器,通过统一入口 `collect aamas-proceedings` 使用。目前只支持已独立核验的 **AAMAS 2012** HTML 版式;其他年份需先核对官方目录和逐篇结构,再增加适配,不能仅替换年份。
## 来源与范围
[官方论文集介绍](https://www.ifaamas.org/Proceedings/aamas2012/)及其 Full Papers、Extended Abstracts、Demos、Doctorial Consortium Abstracts 四个栏目分别列出 136、146、28、24 条,共 334 条。旧 DBLP 候选卷的 310 条不能替代这四类官方目录,也不能全部标记为主会全文。栏目数量来自 2026-11-09 冻结的原始页面;页面结构或数量发生变化时,采集器停止,留待重新核验。
采集标题、逐篇作者、页面实际提供的摘要、官方条目 ID、目录锚点和 PDF 地址。**只保存 PDF 链接,不下载 PDF。** 全文页面的摘要藏在标题链接内部的 `abstract` 元素,作者链接里的 “has N papers” 是界面徽标,二者不会混入标题或姓名。未提供的摘要保存为 `null`,不会用标题、说明文字或其他论文填充。
官方介绍说明了 general、robotics、virtual agents、innovative applications 等投稿轨道,但逐篇目录的主题会话不能独立证明某篇论文的投稿轨道。因此全文和扩展摘要均使用 `proceedings_unspecified`;演示、博士栏目使用 `demo`、`doctoral`。发表形式保留在 `metadata.publication_format` 和 `aamas_proceedings:publication_format` 标签中,会话原文单独保存。它们不是研究类型标注,也不会写入自定义 annotation。
目录条目仅提供发表录用证据,不是原始决定信。`observed_at` 是页面采集时间,不能当作 2012 年的决定日期;官方介绍中的投稿、撤稿、录用汇总数不能识别逐篇拒稿或撤稿,采集器不会由总量相减生成这些决定。
## 运行
在已确认的远程运行环境中,使用 HTTP 缓存采集:
```sh
paper-atlas collect aamas-proceedings --year 2012 \
--cache /data/paper-index/cache/aamas \
--out /data/paper-index/collected/aamas-2012
```
也可使用已冻结的官方页面:
```sh
paper-atlas collect aamas-proceedings --year 2012 \
--snapshots /data/paper-index/collected/historical-next-source-evidence-v1 \
--out /data/paper-index/collected/aamas-2012
```
快照目录需含 `receipts.json`,其中每个响应包含原始 `url`、HTTP `status`、相对 `file`、`bytes`、`sha256` 和 `finished_at`。要求五个准确官方 URL 均唯一匹配且返回 200;验证全部字节数和哈希后才解析,不接受目录穿越或绝对文件路径。快照中其他来源的响应不会被当成 AAMAS 页面。
输出为按数据库 track 分组的三个 JSONL 文件和对应 manifest、五份原始 HTML 证据,以及最后写出的 `aamas-proceedings-2012.json` 采集报告。锁文件避免同一输出目录并发运行。manifest 始终为 `partial`,记录发表形式数量和缺失摘要数;**采集命令不连接数据库,也不导入。**
## 复核与恢复
每条记录携带对应官方页面的 URL、采集时间、字节数、SHA-256 与输出内证据路径。重新运行前先检查已有采集报告和文件哈希;采集中断不等于主库已导入,报告缺失或哈希不一致的目录不能直接作为导入队列。输出中的原始证据应随文件一起保存。
生产导入前仍需单独与主库核验稳定标识、标题、作者及已有投稿,按证据补充 DOI/arXiv 等标识;目录页上的作者计数徽标和会话 ID 不能作为这些标识。需要另建经过核验的增量清单、保护旧历史,完成独立验收后才能计入覆盖。跨来源同一论文不得仅因同题就新增或合并投稿。
本功能的代码、测试或采集成功均不代表 AAMAS 2012 已导入生产,也不证明拒稿全集完整。全库最终去重、摘要和来源覆盖验收属于独立后续阶段。
+44
View File
@@ -0,0 +1,44 @@
# ICLR 2014–2016 官方历史录用档案
此适配器是长期维护工具的一部分,按需运行。只识别已经独立核对的三种页面布局;后续页面发生变化必须重新核查,不能放松计数或轨道边界以使采集成功。
| 官方页面 | 页面链接次数 | 整理后的不同记录 |
|---|---:|---:|
| [2014 Conference Proceedings](https://iclr.cc/archive/2014/conference-proceedings/) | 35 | main 35 |
| [2015 accepted-main](https://iclr.cc/archive/www/doku.php%3Fid=iclr2015:accepted-main.html) | 116 | main 31、workshop 74 |
| [2016 accepted-main](https://iclr.cc/archive/www/doku.php%3Fid=iclr2016:accepted-main.html) | 80 | main 80 |
2015 的 11 篇 oral 全部再次出现在 poster 段落中。相同年份、轨道及 arXiv 标识的重复展示只生成一条来源记录,但每次展示的标题、作者显示字符串、链接、版本和段落均保留在 `source_variants`。两处标题或解析作者冲突时停止采集、等待人工核查。不同年份或不同轨道不合并投稿。
2014 的作者以分号列出,一条有末尾分号,一条标题和作者斜体块不在普通段落中,部分作者段落拆成多个格式片段;必须读取完整作者段落或独立斜体块,不能只读首个格式片段。2016 的代码链接不属于作者。保留原文,解析结果不替代来源证据。oral、poster 作为 `iclr_archive:presentation` 标签,不改成独立投稿轨道。arXiv 版本留在来源变体中,稳定标识使用去版本号的编号。
## 来源快照与运行
2026-11-09 已冻结的官方响应 SHA256:
- 2014:`a33858176fbffae0066ed3c10310f7dd98a66d12a36334b6339ef1f915ef07af`
- 2015:`131491663742cb084662a4804009b8318e0fa7bc184d4a9044d16dd28f05b888`
- 2016:`7320b8652431793b6ea0743f871ee446bf08b6361ccd4e421b6b53449e71f4f6`
在服务器运行,输出新目录,不下载论文 PDF:
```sh
paper-atlas collect iclr-archive --year 2015 \
--snapshots /data/paper-index/collected/historical-next-source-evidence-v1 \
--out /data/paper-index/collected/iclr-archive-source-v1/2015
# 或重新下载官方 HTML,并保留 HTTP 缓存与收据
paper-atlas collect iclr-archive --year 2016 \
--cache /data/paper-index/cache/iclr-archive \
--out /data/paper-index/collected/iclr-archive-source-v1/2016
```
冻结输入目录必须包含 `receipts.json`,每项有精确 `url`、HTTP `status: 200`、相对 `file`、`bytes`、`sha256` 和带时区的 `finished_at`。读取时重新核对尺寸、哈希和路径;拒绝越界及指向目录外的软链接。已完成输出不可覆盖,应检查报告后另建明确版本目录。解析或计数失败时不生成完成报告,也不导入数据库。
输出分轨道 JSONL、manifest、原始 HTML 证据和年度采集报告。仅支持这三年已审查的页面。2014 workshop、其他未列轨道、逐篇摘要及公开拒稿范围尚未通过这些页面核验,不能据此声称完整。缺席不代表 rejected、withdrawn 或从未被录用。
## 导入前后的边界
这三页合计 220 条不同来源记录,不等于新增 220 篇论文;同一工作可能已存在于 arXiv 或其他会议。先核验来源字段与原始页面,再执行 PostgreSQL 只读预检,比较稳定标识、标题、作者和原始来源。标题/作者差异、版本关联及冲突必须保留证据并明确评估;不能仅靠标题猜测身份,也不能覆盖原有摘要。
采集器不连接数据库。正式导入使用既有 `import records`,必须有新的生产保护基线和独立验收;全部导入结束后仍需全库去重和历史覆盖审计。来自官方录用清单的 accepted 是清单观察,不是原始审稿决定签名或决定日期。
+2
View File
@@ -13,6 +13,7 @@ COMMANDS = {
('download',): ('paper_atlas.io.snapshots', {}, '下载并校验固定版本快照'),
('collect','pmlr'): ('paper_atlas.collectors.pmlr', {}, 'PMLR 官方元数据'),
('collect','acl'): ('paper_atlas.collectors.acl', {}, 'ACL Anthology XML'),
('collect','iclr-archive'): ('paper_atlas.collectors.iclr_archive', {}, '核对ICLR 2014–2016官方历史录用档案'),
('collect','iclr-program'): ('paper_atlas.collectors.iclr', {}, 'ICLR 官方公开程序'),
('collect','iclr-snapshot'): ('paper_atlas.collectors.openreview', {}, 'ICLR 公开投稿与决定快照'),
('collect','public-openreview'): ('paper_atlas.collectors.public_openreview', {}, '核验其他会议的公开拒稿及撤稿标签'),
@@ -23,6 +24,7 @@ COMMANDS = {
('collect','mlsys'): ('paper_atlas.collectors.proceedings', {'source':'mlsys'}, 'MLSys 官方历年标题与摘要'),
('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 发表形式和摘要'),
('collect','www-archive'): ('paper_atlas.collectors.www_archive', {}, '核验早期WWW官方档案并分开待核查条目'),
('collect','dblp'): ('paper_atlas.collectors.dblp_records', {}, '核对并整理 DBLP 历史发表记录'),
('catalogue','dblp'): ('paper_atlas.collectors.dblp', {}, '盘点 DBLP 历史会议目录'),
+210
View File
@@ -0,0 +1,210 @@
"""Official AAMAS proceedings metadata, with publication format separate from track."""
import argparse
from collections import Counter, defaultdict
import fcntl
import hashlib
import json
from pathlib import Path
import re
import tempfile
from urllib.parse import urljoin, urlsplit
from paper_atlas.db.submissions import timestamp
from paper_atlas.io.html import Document, Node, clean
from paper_atlas.io.http import fetch
SOURCE = 'aamas_proceedings'
ROOT = 'https://www.ifaamas.org/Proceedings/aamas2012/'
# Counts independently checked against the frozen publisher sections. This
# adapter deliberately refuses other editions/layouts until they are reviewed.
SECTIONS = {
'fullpapers': ('Full Papers', 'full_paper', 'proceedings_unspecified', 136),
'extabstracts': ('Extended Abstracts', 'extended_abstract', 'proceedings_unspecified', 146),
'demos': ('Demos', 'demo', 'demo', 28),
'doctorals': ('Doctorial Consortium Abstracts', 'doctoral_abstract', 'doctoral', 24),
}
def classes(node):
return set(node.attrs.get('class', '').split())
def text_without(node, omitted):
if node.tag in ('script', 'style') or classes(node) & omitted:
return ''
parts = [text_without(child, omitted) if isinstance(child, Node) else child
for child in node.children]
value = ''.join(parts)
return ' '+value+' ' if node.tag in ('p', 'div', 'section', 'li', 'br') else value
def one(nodes, description):
if len(nodes) != 1:
raise ValueError('Expected one '+description)
return nodes[0]
def parse_section(html, section, observed_at, evidence):
heading, publication_format, track, _ = SECTIONS[section]
document = Document(html)
titles = [clean(n.text()) for n in document.root.walk() if n.tag == 'title']
if titles != [heading+' - AAMAS 2012']:
raise ValueError('Unexpected proceedings edition or section title')
headings = [clean(n.text()) for n in document.root.walk() if n.tag == 'h1']
if headings != [heading]:
raise ValueError('Unexpected proceedings section heading')
url = ROOT+'resources/'+section+'.html'
records = []
seen = set()
session = None
for node in document.root.walk():
if node.tag == 'h2' and 'section' in classes(node):
session = {'id': node.attrs.get('id'), 'title': clean(node.text())}
if node.tag != 'div' or 'paper' not in classes(node):
continue
paper_id = node.attrs.get('id', '')
if not re.fullmatch(r'[A-Za-z0-9]+_[0-9]+', paper_id) or paper_id in seen:
raise ValueError('Missing or repeated publisher paper ID')
seen.add(paper_id)
title_node = one([n for n in node.walk() if n.tag == 'span' and 'title' in classes(n)], 'paper title')
link = one([n for n in title_node.children if isinstance(n, Node) and n.tag == 'a'], 'paper link')
paper_url = urljoin(url, link.attrs.get('href', ''))
if paper_url != ROOT+'papers/'+paper_id+'.pdf':
raise ValueError('Paper link differs from publisher ID or official host')
title = clean(text_without(link, {'abstract'}))
abstract_nodes = [n for n in link.walk() if n.tag == 'span' and 'abstract' in classes(n)]
if len(abstract_nodes) > 1:
raise ValueError('Multiple abstracts for one paper')
abstract = (clean(abstract_nodes[0].text()) or None) if abstract_nodes else None
author_node = one([n for n in node.walk() if n.tag == 'span' and 'authors' in classes(n)], 'author list')
author_display = clean(text_without(author_node, {'amount'}))
authors = [clean(name) for name in author_display.split(',')]
if not title or not author_display or any(not name for name in authors):
raise ValueError('Empty paper title or author')
tags = [{'namespace': 'aamas_proceedings:publication_format', 'value': publication_format}]
if session:
tags.append({'namespace': 'aamas_proceedings:session', 'value': session['title']})
records.append({
'source': SOURCE, 'external_id': '2012/'+paper_id,
'url': url+'#'+paper_id, 'title': title, 'abstract': abstract,
'authors': authors, 'identifiers': {}, 'venue': 'AAMAS', 'venue_aliases': [],
'year': 2012, 'track': track, 'status': 'accepted',
'observed_at': observed_at,
'raw_decision': 'listed in official AAMAS 2012 proceedings '+heading,
'metadata': {'publisher_paper_id': paper_id, 'paper_url': paper_url,
'section_url': url, 'section_heading': heading,
'publication_format': publication_format, 'session': session,
'authors_display': author_display, 'source_evidence': evidence,
'publication_evidence': 'official published proceedings entry; not an original decision note',
'track_scope_note': ('Full papers and extended abstracts include general and special submission tracks; '
'session topics do not establish an individual submission track.'
if track == 'proceedings_unspecified' else 'Explicit publisher section.'),
'decision_date_precision': 'not_supplied'},
'tags': tags})
if not records:
raise ValueError('No publisher paper entries found')
return records
def read_pages(cache=None, snapshots=None):
if bool(cache) == bool(snapshots):
raise ValueError('Choose exactly one HTTP cache or frozen snapshot directory')
urls = {'introduction': ROOT, **{key: ROOT+'resources/'+key+'.html' for key in SECTIONS}}
pages = {}
receipts = []
frozen = json.loads((Path(snapshots)/'receipts.json').read_text())['receipts'] if snapshots else []
for key, url in urls.items():
if snapshots:
receipt = one([r for r in frozen if r['url'] == url], 'frozen response receipt')
filename = Path(receipt['file'])
if filename.is_absolute() or '..' in filename.parts or receipt.get('status') != 200:
raise ValueError('Unsafe or unsuccessful frozen response')
data = (Path(snapshots)/filename).read_bytes()
if len(data) > 8*1024**2 or hashlib.sha256(data).hexdigest() != receipt['sha256'] or len(data) != receipt['bytes']:
raise ValueError('Frozen publisher response checksum or size mismatch')
observed = receipt['finished_at']
else:
data = fetch(url, cache, 8*1024**2, delay=1)
receipt = json.loads((Path(cache)/hashlib.sha256(url.encode()).hexdigest()).with_suffix('.json').read_text())
observed = receipt['retrieved_at']
evidence = {'url': url, 'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data),
'file': 'evidence/'+hashlib.sha256(data).hexdigest()+'.html', 'observed_at': observed,
'http_status': 200, 'response_receipt': receipt}
pages[key] = (data, evidence)
receipts.append(evidence)
return pages, receipts
def _collect(year, out, *, cache=None, snapshots=None):
if year != 2012:
raise ValueError('Only the independently reviewed AAMAS 2012 layout is supported')
pages, receipts = read_pages(cache, snapshots)
intro = Document(pages['introduction'][0].decode('utf-8'))
if [clean(n.text()) for n in intro.root.walk() if n.tag == 'title'] != ['AAMAS 2012']:
raise ValueError('Unexpected proceedings introduction')
records = []
for section, (_, _, _, expected) in SECTIONS.items():
data, evidence = pages[section]
group = parse_section(data.decode('utf-8'), section, evidence['observed_at'], evidence)
if len(group) != expected:
raise ValueError('Publisher section count differs from reviewed inventory: '+section)
records.extend(group)
if len({r['external_id'] for r in records}) != len(records):
raise ValueError('Paper IDs collide across publisher sections')
grouped = defaultdict(list)
for record in records:
grouped[record['track']].append(record)
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
files = []
with tempfile.TemporaryDirectory(prefix='.aamas-', dir=out) as temporary:
stage = Path(temporary)
(stage/'evidence').mkdir()
for data, evidence in pages.values():
(stage/evidence['file']).write_bytes(data)
for track, group in sorted(grouped.items()):
path = stage/f'aamas-proceedings-2012-{track}.jsonl'
path.write_text(''.join(json.dumps(r, ensure_ascii=False)+'\n' for r in group))
manifest = {'source': SOURCE, 'parser_version': 1, 'venue': 'AAMAS', 'year': 2012, 'track': track,
'count': len(group), 'file': str(out/path.name), 'file_sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'list_url': ROOT, 'status': 'partial', 'missing_abstract_count': sum(not r['abstract'] for r in group),
'publication_format_counts': dict(Counter(r['metadata']['publication_format'] for r in group)),
'note': 'Publisher sections verified; individual full/extended submission track, decision history and independent identities still need review.'}
path.with_suffix('.manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2)+'\n')
files.append(manifest)
result = {'source': SOURCE, 'year': year, 'finished_at': timestamp(), 'status': 'collected_pending_identity_review',
'records': len(records), 'files': files, 'source_receipts': receipts,
'publication_format_counts': dict(Counter(r['metadata']['publication_format'] for r in records)),
'database_written': False, 'pdfs_downloaded': False,
'note': 'Aggregate submission/withdrawal totals do not identify individual rejected or withdrawn papers.'}
(stage/'aamas-proceedings-2012.json').write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n')
# Validated files first; write the completed collection report last.
(out/'evidence').mkdir(exist_ok=True)
for path in (stage/'evidence').iterdir():
path.replace(out/'evidence'/path.name)
for path in stage.iterdir():
if path.is_file() and path.name != 'aamas-proceedings-2012.json':
path.replace(out/path.name)
(stage/'aamas-proceedings-2012.json').replace(out/'aamas-proceedings-2012.json')
return result
def collect(year, out, *, cache=None, snapshots=None):
if year != 2012:
raise ValueError('Only the independently reviewed AAMAS 2012 layout is supported')
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
with (out/'aamas.collect.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
return _collect(year, out, cache=cache, snapshots=snapshots)
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--year', type=int, choices=[2012], required=True)
parser.add_argument('--out', required=True)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument('--cache')
source.add_argument('--snapshots')
args = parser.parse_args(argv)
print(json.dumps(collect(args.year, args.out, cache=args.cache, snapshots=args.snapshots), ensure_ascii=False))
+225
View File
@@ -0,0 +1,225 @@
"""Frozen official ICLR 2014–2016 lists; presentation evidence is not a track."""
import argparse
from collections import Counter, defaultdict
import fcntl
import hashlib
import json
from pathlib import Path
import re
import tempfile
from urllib.parse import urlsplit
from paper_atlas.db.submissions import timestamp
from paper_atlas.io.html import Document, Node, clean
from paper_atlas.io.http import fetch
SOURCE = 'iclr_archive'
PAGES = {
2014: ('https://iclr.cc/archive/2014/conference-proceedings/', {'main': 35}),
2015: ('https://iclr.cc/archive/www/doku.php%3Fid=iclr2015:accepted-main.html', {'main': 31, 'workshop': 74}),
2016: ('https://iclr.cc/archive/www/doku.php%3Fid=iclr2016:accepted-main.html', {'main': 80}),
}
SECTIONS = {
2015: {'Main Conference - Oral Presentations': ('main', 'oral'),
'Main Conference - Poster Presentations': ('main', 'poster'),
'Workshop Papers': ('workshop', 'workshop')},
2016: {'Oral Presentations': ('main', 'oral'), 'Poster Presentations': ('main', 'poster')},
}
def arxiv_link(url):
parsed = urlsplit(url)
if parsed.hostname != 'arxiv.org':
return None
match = re.fullmatch(r'/(?:abs|pdf)/(\d{4}\.\d{4,5})(v[1-9]\d*)?(?:\.pdf)?', parsed.path)
if (parsed.scheme not in ('http', 'https') or parsed.netloc != 'arxiv.org'
or parsed.query or parsed.fragment or not match):
raise ValueError('Unexpected official arXiv link')
return match[1], match[2] or None
def without_links(node):
if node.tag in ('a', 'script', 'style'):
return ''
value = ''.join(without_links(n) if isinstance(n, Node) else n for n in node.children)
return ' '+value+' ' if node.tag in ('div', 'li', 'p', 'br') else value
def authors(display, year):
parts = display.rstrip(';').split(';') if year == 2014 else re.split(r',\s*(?:and\s+)?|\s+and\s+', display)
names = [clean(p) for p in parts]
if not names or any(not name for name in names):
raise ValueError('Missing author or unsupported author delimiter')
return names
def parse_page(html, year, evidence):
if year not in PAGES:
raise ValueError('Unreviewed ICLR archive year')
document = Document(html)
nodes = list(document.root.walk())
headings = [clean(n.text()) for n in nodes if n.tag in ('h1', 'h2', 'h3')]
if year == 2014:
if 'Conference Proceedings' not in headings or 'conference papers accepted' not in clean(document.root.text()):
raise ValueError('Unexpected ICLR 2014 accepted list')
elif (set(SECTIONS[year]) - set(headings)
or (year == 2016 and 'Accepted Papers (Conference Track)' not in headings)):
raise ValueError('Unexpected ICLR accepted sections')
entries = []
section = None
for index, node in enumerate(nodes):
if node.tag in ('h1', 'h2', 'h3'):
section = clean(node.text())
if year == 2014:
if node.tag != 'a' or not arxiv_link(node.attrs.get('href', '')):
continue
link = node
# This archived Google Sites page has an entry outside a paragraph.
# Authors can span several differently formatted italic spans.
# Read their whole paragraph, never only the first styled fragment.
display = None
for following in nodes[index+sum(1 for _ in link.walk()):]:
if following.tag == 'a' and arxiv_link(following.attrs.get('href', '')):
break
if following.tag in ('i', 'em') or (following.tag == 'p' and any(
n.tag in ('i', 'em') or 'font-style:italic' in n.attrs.get('style', '').replace(' ', '')
for n in following.walk())):
text = clean(following.text())
if text:
display = text
break
if not display:
raise ValueError('Missing explicit ICLR 2014 author span')
track, presentation = 'main', 'conference'
else:
if node.tag != 'li':
continue
links = [n for n in node.walk() if n.tag == 'a' and arxiv_link(n.attrs.get('href', ''))]
if not links:
continue
if len(links) != 1 or section not in SECTIONS[year]:
raise ValueError('Ambiguous paper link or unknown accepted section')
link, = links
display = clean(without_links(node)).lstrip(',').strip()
track, presentation = SECTIONS[year][section]
arxiv, version = arxiv_link(link.attrs['href'])
title = clean(link.text())
if not title:
raise ValueError('Empty official title')
entries.append({'arxiv': arxiv, 'version': version, 'title': title,
'authors': authors(display, year), 'authors_display': display,
'paper_url': link.attrs['href'], 'track': track,
'section': section, 'presentation': presentation})
# Check every arXiv occurrence, including links outside the expected layout.
occurrences = sum(n.tag == 'a' and bool(arxiv_link(n.attrs.get('href', ''))) for n in nodes)
if len(entries) != occurrences or not entries:
raise ValueError('Some official paper links were not parsed')
grouped = defaultdict(list)
for entry in entries:
grouped[(entry['track'], entry['arxiv'])].append(entry)
result = []
for (track, arxiv), variants in grouped.items():
first = variants[0]
if any((v['title'], v['authors']) != (first['title'], first['authors']) for v in variants):
raise ValueError('Repeated presentation has conflicting title or authors; review required')
presentations = sorted({v['presentation'] for v in variants})
result.append({'source': SOURCE, 'external_id': f'{year}/{track}/{arxiv}',
'url': PAGES[year][0], 'title': first['title'], 'abstract': None,
'authors': first['authors'], 'identifiers': {'arxiv': arxiv},
'venue': 'ICLR', 'venue_aliases': [], 'year': year, 'track': track,
'status': 'accepted', 'observed_at': evidence['observed_at'],
'raw_decision': 'listed in official ICLR accepted '+track+' list',
'metadata': {'source_evidence': evidence, 'source_variants': variants,
'presentations': presentations, 'decision_date_precision': 'not_supplied',
'identity_note': 'Official arXiv link, not title matching; validate canonical identity before import.',
'decision_evidence': 'Archived accepted list, not an original decision note.'},
'tags': [{'namespace': SOURCE+':presentation', 'value': value} for value in presentations]})
return result
def read_page(year, *, cache=None, snapshots=None):
if bool(cache) == bool(snapshots):
raise ValueError('Choose exactly one cache or frozen snapshot directory')
url = PAGES[year][0]
if snapshots:
root = Path(snapshots).resolve()
receipts = json.loads((root/'receipts.json').read_bytes())['receipts']
matches = [r for r in receipts if r['url'] == url]
if len(matches) != 1:
raise ValueError('Expected one exact official response receipt')
receipt, = matches
filename = Path(receipt['file'])
path = root/filename
if (filename.is_absolute() or '..' in filename.parts or not path.resolve().is_relative_to(root)
or receipt['status'] != 200):
raise ValueError('Unsafe or unsuccessful archive snapshot')
data = path.read_bytes()
if (len(data) > 8*1024**2 or len(data) != receipt['bytes']
or hashlib.sha256(data).hexdigest() != receipt['sha256']):
raise ValueError('Archive snapshot checksum or size differs')
observed = timestamp(receipt['finished_at'])
else:
data = fetch(url, cache, 8*1024**2, delay=1)
receipt = json.loads((Path(cache)/hashlib.sha256(url.encode()).hexdigest()).with_suffix('.json').read_bytes())
observed = timestamp(receipt['retrieved_at'])
evidence = {'url': url, 'sha256': hashlib.sha256(data).hexdigest(), 'bytes': len(data),
'file': 'evidence/'+hashlib.sha256(data).hexdigest()+'.html',
'observed_at': observed, 'http_status': 200, 'response_receipt': receipt}
return data, evidence
def collect(year, out, *, cache=None, snapshots=None):
if year not in PAGES:
raise ValueError('Only independently reviewed ICLR archive years are supported')
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
with (out/'iclr-archive.collect.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
report = out/f'iclr-archive-{year}.json'
if report.exists():
raise FileExistsError('Existing completed archive output must be inspected, not overwritten')
data, evidence = read_page(year, cache=cache, snapshots=snapshots)
records = parse_page(data.decode('utf-8'), year, evidence)
counts = Counter(r['track'] for r in records)
if dict(counts) != PAGES[year][1]:
raise ValueError('Distinct archive entries differ from reviewed inventory')
files = []
with tempfile.TemporaryDirectory(prefix='.iclr-archive-', dir=out) as temporary:
stage = Path(temporary)
(stage/'evidence').mkdir()
(stage/evidence['file']).write_bytes(data)
for track in sorted(counts):
group = [r for r in records if r['track'] == track]
path = stage/f'iclr-archive-{year}-{track}.jsonl'
path.write_text(''.join(json.dumps(r, ensure_ascii=False)+'\n' for r in group))
manifest = {'source': SOURCE, 'parser_version': 1, 'venue': 'ICLR', 'year': year,
'track': track, 'count': len(group), 'file': str(out/path.name),
'file_sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'list_url': PAGES[year][0], 'status': 'partial', 'missing_abstract_count': len(group)}
path.with_suffix('.manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2)+'\n')
files.append(manifest)
result = {'source': SOURCE, 'year': year, 'records': len(records), 'files': files,
'source_evidence': evidence, 'database_written': False, 'pdfs_downloaded': False,
'status': 'collected_pending_identity_review',
'note': 'Accepted list only. Oral/poster are presentation evidence; absent papers are not rejected. '
'ICLR2014 workshop and other unlisted tracks remain outside these reviewed pages.'}
(stage/report.name).write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n')
(out/'evidence').mkdir(exist_ok=True)
for path in (stage/'evidence').iterdir():
path.replace(out/'evidence'/path.name)
for path in stage.iterdir():
if path.is_file() and path.name != report.name:
path.replace(out/path.name)
(stage/report.name).replace(report)
return result
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--year', type=int, choices=sorted(PAGES), required=True)
parser.add_argument('--out', required=True)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument('--cache')
source.add_argument('--snapshots')
args = parser.parse_args(argv)
print(json.dumps(collect(args.year, args.out, cache=args.cache, snapshots=args.snapshots), ensure_ascii=False))
+151
View File
@@ -0,0 +1,151 @@
"""Publisher evidence, section boundaries and native-ingestion regression checks."""
import hashlib
import json
import re
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from paper_atlas.collectors import aamas
from paper_atlas.ingestion.records import ingest_file
def paper(pid, abstract=True):
summary = '<span class="abstract">Research with <i>evidence</i>.<br>More detail.</span>' if abstract else ''
return f'''<div class="paper" id="{pid}"><span class="id">{pid}</span>
<p class="paper"><span class="title"><a href="../papers/{pid}.pdf">{summary}
A structured &amp; auditable paper {pid}</a></span><span class="authors">
<a href="authors.html#Jane">Jane Doe<span class="amount">has 7 papers</span></a>, Bob Smith
</span></p></div>'''
def page(section, pid, abstract=True):
heading = aamas.SECTIONS[section][0]
return (f'<html><head><title>{heading} - AAMAS 2012</title></head><body>'
f'<h1>{heading}</h1><h2 class="section" id="s-1A">Session 1A – Robotics I</h2>'
+paper(pid, abstract)+'</body></html>')
class AamasProceedingsTests(unittest.TestCase):
def snapshots(self, root):
root.mkdir()
receipts = []
items = {'introduction': ('<title>AAMAS 2012</title><p>Aggregate withdrawals do not identify papers.</p>', aamas.ROOT)}
for index, section in enumerate(aamas.SECTIONS):
items[section] = (page(section, f'P{index}_1', section != 'extabstracts'), aamas.ROOT+'resources/'+section+'.html')
for key, (html, url) in items.items():
data = html.encode()
(root/(key+'.html')).write_bytes(data)
receipts.append({'url': url, 'status': 200, 'file': key+'.html', 'bytes': len(data),
'sha256': hashlib.sha256(data).hexdigest(), 'finished_at': '2026-11-09T00:00:00+00:00'})
(root/'receipts.json').write_text(json.dumps({'receipts': receipts}))
return root
def test_hidden_abstract_and_author_badges_are_not_title_or_authors(self):
rows = aamas.parse_section(page('fullpapers', '1A_1'), 'fullpapers', '2026-11-09T00:00:00Z', {'sha256': 'proof'})
row, = rows
self.assertEqual(row['title'], 'A structured & auditable paper 1A_1')
self.assertEqual(row['abstract'], 'Research with evidence. More detail.')
self.assertEqual(row['authors'], ['Jane Doe', 'Bob Smith'])
self.assertEqual(row['metadata']['authors_display'], 'Jane Doe, Bob Smith')
self.assertEqual(row['url'], aamas.ROOT+'resources/fullpapers.html#1A_1')
self.assertEqual(row['metadata']['paper_url'], aamas.ROOT+'papers/1A_1.pdf')
self.assertEqual(row['metadata']['source_evidence'], {'sha256': 'proof'})
def test_publication_formats_and_session_topics_do_not_invent_submission_tracks(self):
for section, (_, fmt, track, _) in aamas.SECTIONS.items():
row, = aamas.parse_section(page(section, 'P_1', False), section, '2026-11-09T00:00:00Z', {})
self.assertEqual((row['track'], row['metadata']['publication_format']), (track, fmt))
self.assertIsNone(row['abstract'])
self.assertEqual(row['status'], 'accepted')
self.assertEqual(row['metadata']['decision_date_precision'], 'not_supplied')
self.assertEqual(row['metadata']['session']['title'], 'Session 1A – Robotics I')
self.assertIn({'namespace': 'aamas_proceedings:publication_format', 'value': fmt}, row['tags'])
self.assertEqual(aamas.SECTIONS['fullpapers'][2], 'proceedings_unspecified')
self.assertEqual(aamas.SECTIONS['extabstracts'][2], 'proceedings_unspecified')
def test_changed_edition_section_duplicate_id_or_pdf_link_is_rejected(self):
html = page('fullpapers', '1A_1')
for changed in [html.replace('AAMAS 2012', 'AAMAS 2025'),
html.replace('<h1>Full Papers</h1>', '<h1>Rejected Papers</h1>'),
html.replace('</body>', paper('1A_1')+'</body>'),
html.replace('../papers/1A_1.pdf', 'https://example.org/1A_1.pdf'),
html.replace('../papers/1A_1.pdf', '../papers/1A_2.pdf')]:
with self.subTest(changed=changed):
with self.assertRaises(ValueError):
aamas.parse_section(changed, 'fullpapers', '2026-11-09T00:00:00Z', {})
def test_frozen_responses_require_checksums_success_and_safe_paths(self):
for failure in ['checksum', 'status', 'path']:
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary:
root = self.snapshots(Path(temporary)/'source')
path = root/'receipts.json'
receipts = json.loads(path.read_text())
item = receipts['receipts'][0]
if failure == 'checksum': item['sha256'] = '0'*64
elif failure == 'status': item['status'] = 404
else: item['file'] = '../outside.html'
path.write_text(json.dumps(receipts))
with self.assertRaises(ValueError):
aamas.read_pages(snapshots=root)
def test_reviewed_section_counts_and_year_are_required(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.snapshots(root/'source')
with self.assertRaises(ValueError):
aamas.collect(2012, root/'out', snapshots=source)
self.assertFalse((root/'out/aamas-proceedings-2012.json').exists())
with self.assertRaises(ValueError):
aamas.collect(2025, root/'future', snapshots=source)
self.assertFalse((root/'future').exists())
def test_ids_colliding_across_sections_do_not_produce_a_completed_collection(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.snapshots(root/'source')
receipts_path = source/'receipts.json'
receipts = json.loads(receipts_path.read_text())
for item in receipts['receipts'][1:]:
path = source/item['file']
data = re.sub(r'P[0-9]_1', 'SAME_1', path.read_text()).encode()
path.write_bytes(data)
item.update(bytes=len(data), sha256=hashlib.sha256(data).hexdigest())
receipts_path.write_text(json.dumps(receipts))
small = {key: (*value[:3], 1) for key, value in aamas.SECTIONS.items()}
with patch.dict(aamas.SECTIONS, small, clear=True), self.assertRaises(ValueError):
aamas.collect(2012, root/'out', snapshots=source)
self.assertFalse((root/'out/aamas-proceedings-2012.json').exists())
def test_collection_and_native_ingestion_preserve_formats_evidence_and_missing_abstracts(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.snapshots(root/'source')
small = {key: (*value[:3], 1) for key, value in aamas.SECTIONS.items()}
with patch.dict(aamas.SECTIONS, small, clear=True):
result = aamas.collect(2012, root/'out', snapshots=source)
self.assertEqual(result['records'], 4)
self.assertEqual(len(result['files']), 3)
self.assertFalse(result['database_written'])
self.assertFalse(result['pdfs_downloaded'])
db = root/'sample.sqlite'
for manifest in result['files']:
path = Path(manifest['file'])
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), manifest['file_sha256'])
for raw in path.read_text().splitlines():
record = json.loads(raw)
evidence = record['metadata']['source_evidence']
self.assertEqual(hashlib.sha256((root/'out'/evidence['file']).read_bytes()).hexdigest(), evidence['sha256'])
ingest_file(db, path)
with sqlite3.connect(db) as connection:
self.assertEqual(connection.execute('SELECT count(*) FROM papers').fetchone()[0], 4)
self.assertEqual(connection.execute('SELECT count(*) FROM submissions').fetchone()[0], 4)
self.assertEqual(connection.execute("SELECT count(*) FROM decisions WHERE status='accepted'").fetchone()[0], 4)
self.assertEqual(connection.execute('SELECT count(*) FROM papers WHERE abstract IS NULL').fetchone()[0], 1)
metadata = [json.loads(r[0]) for r in connection.execute('SELECT metadata_json FROM source_records')]
self.assertEqual({r['publication_format'] for r in metadata}, {'full_paper','extended_abstract','demo','doctoral_abstract'})
namespaces = {r[0] for r in connection.execute('SELECT DISTINCT namespace FROM tags')}
self.assertEqual(namespaces, {'aamas_proceedings:publication_format', 'aamas_proceedings:session'})
self.assertEqual(connection.execute('PRAGMA foreign_key_check').fetchall(), [])
+148
View File
@@ -0,0 +1,148 @@
"""Historical accepted lists, repeated presentations and preserved work histories."""
import hashlib
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from paper_atlas.collectors import iclr_archive as archive
from paper_atlas.db.store import Store
from paper_atlas.ingestion.records import ingest_file
OBSERVED = '2026-11-09T00:00:00Z'
def entry(identifier, title='A shared research paper', display='Jane Doe and Bob Smith'):
return f'<li><div><a href="http://arxiv.org/abs/{identifier}">{title}</a>, {display}</div></li>'
def page2015():
return ('<h3>Main Conference - Oral Presentations</h3><ol>'+entry('1412.0001v2')+'</ol>'
'<h3>Main Conference - Poster Presentations</h3><ol>'+entry('1412.0001v2')+'</ol>'
'<h3>Workshop Papers</h3><ol>'+entry('1412.0002', 'A workshop paper')+'</ol>')
class IclrArchiveTests(unittest.TestCase):
def frozen(self, root, year, html):
root.mkdir()
data = html.encode()
(root/'page.html').write_bytes(data)
receipt = {'url': archive.PAGES[year][0], 'file': 'page.html', 'status': 200,
'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest(), 'finished_at': OBSERVED}
(root/'receipts.json').write_text(json.dumps({'receipts': [receipt]}))
return root
def test_oral_poster_repeat_is_one_attempt_with_all_source_variants(self):
rows = archive.parse_page(page2015(), 2015, {'observed_at': OBSERVED})
main, workshop = rows
self.assertEqual((main['track'], workshop['track']), ('main', 'workshop'))
self.assertEqual(main['identifiers'], {'arxiv': '1412.0001'})
self.assertEqual(main['external_id'], '2015/main/1412.0001')
self.assertEqual(main['metadata']['presentations'], ['oral', 'poster'])
self.assertEqual(len(main['metadata']['source_variants']), 2)
self.assertEqual(main['metadata']['source_variants'][0]['version'], 'v2')
self.assertEqual(main['authors'], ['Jane Doe', 'Bob Smith'])
self.assertTrue(all(r['status'] == 'accepted' and r['abstract'] is None for r in rows))
def test_2014_author_span_outside_paragraph_and_trailing_separator(self):
html = ('<h3>Conference Proceedings</h3><p>conference papers accepted</p>'
'<div><b><a href="http://arxiv.org/abs/1312.0001">A <i>research</i> title</a></b></div>'
'<p><span style="font-style: italic">Jane Doe; </span>'
'<span style="font-style:italic">Bob Smith; Carol Wu;</span></p>'
'<div><a href="http://arxiv.org/abs/1312.0002">Another title</a>'
'<div><i><span>First Author; </span><span>Second Author</span></i></div></div>')
row, unwrapped = archive.parse_page(html, 2014, {'observed_at': OBSERVED})
self.assertEqual(row['title'], 'A research title')
self.assertEqual(row['authors'], ['Jane Doe', 'Bob Smith', 'Carol Wu'])
self.assertEqual(row['metadata']['source_variants'][0]['authors_display'], 'Jane Doe; Bob Smith; Carol Wu;')
self.assertEqual(row['track'], 'main')
self.assertEqual(unwrapped['authors'], ['First Author', 'Second Author'])
def test_same_arxiv_in_different_tracks_keeps_separate_source_attempts(self):
html = page2015().replace('1412.0002', '1412.0001v3')
rows = archive.parse_page(html, 2015, {'observed_at': OBSERVED})
self.assertEqual(len(rows), 2)
self.assertEqual({r['external_id'] for r in rows}, {'2015/main/1412.0001', '2015/workshop/1412.0001'})
self.assertEqual(rows[1]['metadata']['source_variants'][0]['version'], 'v3')
def test_2016_code_links_and_breaks_do_not_become_authors(self):
html = ('<h1>Accepted Papers (Conference Track)</h1><h3>Oral Presentations</h3><ol>'
'<li><a href="http://arxiv.org/abs/1511.00001">A title</a>'
'<a href="https://example.org/code">[code]</a><br>Jane Doe, Bob Smith</li></ol>'
'<h3>Poster Presentations</h3>')
row, = archive.parse_page(html, 2016, {'observed_at': OBSERVED})
self.assertEqual(row['authors'], ['Jane Doe', 'Bob Smith'])
self.assertEqual(row['metadata']['presentations'], ['oral'])
def test_conflicting_repeat_unknown_sections_and_unparsed_links_fail(self):
variants = [page2015().replace('Poster Presentations', 'Other Presentations'),
page2015().replace('</ol>', '</ol><a href="http://arxiv.org/abs/1412.0003">stray</a>', 1),
page2015().replace('Jane Doe and Bob Smith', 'Different Author', 1)]
for html in variants:
with self.subTest(html=html), self.assertRaises(ValueError):
archive.parse_page(html, 2015, {'observed_at': OBSERVED})
def test_arxiv_versions_are_preserved_and_unsafe_links_rejected(self):
self.assertEqual(archive.arxiv_link('https://arxiv.org/pdf/1511.00001v3.pdf'), ('1511.00001', 'v3'))
self.assertIsNone(archive.arxiv_link('https://arxiv.org.example.org/abs/1511.00001'))
for url in ['https://arxiv.org/abs/1511.00001?other=1', 'ftp://arxiv.org/abs/1511.00001',
'https://user@arxiv.org/abs/1511.00001', 'https://arxiv.org/abs/not-a-paper']:
with self.subTest(url=url), self.assertRaises(ValueError):
archive.arxiv_link(url)
def test_receipt_hash_status_path_and_symlink_cannot_escape_snapshot(self):
for failure in ['hash', 'status', 'path', 'symlink']:
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.frozen(root/'source', 2015, page2015())
file = source/'receipts.json'
data = json.loads(file.read_text())
receipt = data['receipts'][0]
if failure == 'hash': receipt['sha256'] = '0'*64
elif failure == 'status': receipt['status'] = 404
elif failure == 'path': receipt['file'] = '../outside.html'
else:
(root/'outside.html').write_text(page2015())
(source/'page.html').unlink()
(source/'page.html').symlink_to(root/'outside.html')
file.write_text(json.dumps(data))
with self.assertRaises(ValueError):
archive.read_page(2015, snapshots=source)
def test_changed_inventory_leaves_no_completed_output(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.frozen(root/'source', 2015, page2015())
with self.assertRaises(ValueError):
archive.collect(2015, root/'out', snapshots=source)
self.assertFalse((root/'out/iclr-archive-2015.json').exists())
def test_native_import_keeps_rejected_to_accepted_history_and_abstract(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = self.frozen(root/'source', 2015, page2015())
with patch.dict(archive.PAGES, {2015: (archive.PAGES[2015][0], {'main': 1, 'workshop': 1})}):
result = archive.collect(2015, root/'out', snapshots=source)
with self.assertRaises(FileExistsError):
archive.collect(2015, root/'out', snapshots=source)
db = root/'sample.sqlite'
store = Store(db)
pid, rid = store.record('arxiv', '1412.0001', 'https://arxiv.org/abs/1412.0001',
'A shared research paper', 'Keep this canonical abstract.',
['Jane Doe', 'Bob Smith'], {'arxiv': '1412.0001'}, {})
store.decision(pid, rid, 'Earlier Conference', 2014, 'main', 'original-attempt',
'https://example.org/rejected', 'rejected', 'explicit earlier decision', observed_at=OBSERVED)
store.conn.commit()
store.conn.close()
for manifest in result['files']:
ingest_file(db, manifest['file'])
ingest_file(db, manifest['file'])
with sqlite3.connect(db) as c:
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 2)
self.assertEqual(c.execute('SELECT abstract FROM papers WHERE id=?', (pid,)).fetchone()[0], 'Keep this canonical abstract.')
history = c.execute('SELECT v.acronym,e.year,d.status FROM decisions d JOIN submissions s ON s.id=d.submission_id JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE s.paper_id=? ORDER BY e.year', (pid,)).fetchall()
self.assertEqual(history, [('Earlier Conference', 2014, 'rejected'), ('ICLR', 2015, 'accepted')])
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 3)
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(), [])