diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 85cffa8..ef0b058 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -12,7 +12,7 @@ jobs: - name: Verify metadata storage and query commands run: | python3 -B - <<'PY' - import io, os, pathlib, re, subprocess, tarfile, tempfile, urllib.request + import gzip, io, json, os, pathlib, re, subprocess, tarfile, tempfile, urllib.request sha = os.environ['ARCHIVE_SHA'] if not re.fullmatch(r'[0-9a-fA-F]{40,64}', sha): raise SystemExit('Invalid source commit') @@ -53,6 +53,18 @@ jobs: subprocess.run([python,'-I','-B','-m','paper_atlas','collect','aamas-proceedings','--year','2025','--snapshots',str(src/'tests/fixtures/aamas2025'),'--out',str(root/'installed-aamas2025')],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','collect','acl','--help'],cwd=outside,check=True) + subprocess.run([python,'-I','-B','-m','paper_atlas','collect','springer-selected','--help'],cwd=outside,check=True) + springer = json.loads(gzip.decompress((src/'tests/fixtures/springer-aistats1993-fixture.json.gz').read_bytes())) + for group in ('toc','chapters'): + folder = root/'springer-snapshots'/group + folder.mkdir(parents=True) + for name, content in springer[group].items(): + (folder/name).write_text(content) + subprocess.run([python,'-I','-B','-m','paper_atlas','collect','springer-selected','--toc-snapshots',str(root/'springer-snapshots/toc'),'--chapter-snapshots',str(root/'springer-snapshots/chapters'),'--out',str(root/'installed-springer')],cwd=outside,check=True) + springer_report = json.loads((root/'installed-springer/collection-report.json').read_bytes()) + assert springer_report['records'] == 49 and not springer_report['database_written'] + springer_manifest, = springer_report['files'] + assert springer_manifest['status'] == 'partial' and springer_manifest['missing_abstract_count'] == 0 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) diff --git a/README.md b/README.md index 603aca2..f411afd 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Paper Atlas 将 arXiv、AI 会议论文集及公开投稿决定整理到可追 历史 ICLR 2014–2016 官方录用页由 `collect iclr-archive` 适配,保留主会/workshop及重复展示证据;运行和身份核验边界见 [历史档案说明](docs/coverage/iclr-archive-2014-2016.md)。 +`collect springer-selected` 核验限定的会后选集,分开保存会议年份、出版年份及选集事件身份。目前支持 AISTATS 1993 对应的 1994 年出版选集,原始会议决定仍须单独核查;配置、证据和恢复见 [会后选集说明](docs/springer-selected-volumes.md)。 + ## 项目结构 ACL 1978 的年会与 TINLAP-2 来源关系按精确卷适配,保留原系列和不明逐篇角色;1965 的替代关系不转成全局别名。来源、排除条目和恢复边界见 [ACL 早期来源核查](docs/coverage/acl-early-sources-2026-11-09.md)。 diff --git a/docs/springer-selected-volumes.md b/docs/springer-selected-volumes.md new file mode 100644 index 0000000..ad1ccd1 --- /dev/null +++ b/docs/springer-selected-volumes.md @@ -0,0 +1,36 @@ +# 会后选集的元数据采集 + +`collect springer-selected` 读取已冻结、带 HTTP 回执的公开 HTML,不访问 PDF、不写业务库。目前仅支持核验过的 *Selecting Models from Data: Artificial Intelligence and Statistics IV*,书籍 DOI 为 `10.1007/978-1-4612-2660-4`。增加其他卷前必须另行核对会议关系、目录、年份和字段格式。 + +[会议官方旧站](https://www.gatsby.ucl.ac.uk/aistats/workshops.html)区分现场分发的 Preliminary Papers 与之后修订、再次评审的选集。[出版社卷说明](https://link.springer.com/book/10.1007/978-1-4612-2660-4)将本书关联到 1993 年 1 月的第四届 workshop,书籍出版年份为 1994。三页目录共有 49 个章节 DOI;分区 Front Matter 不作为章节论文导入。 + +规范记录保留: + +- 会议年份 1993、独立 track `selected_volume` 与事件标识 `aistats-1993-selected-volume-1994`;出版年份单独记为 1994。 +- 章节本身的 DOI、作者原始引用写法及顺序、姓名首字母、标题、摘要、页码和逐页原始证据。不扩写作者首字母。 +- 明确提供本章 `publication_doi` 投稿出版标识,不把它转移到旧的原始会议评审记录,也不因短标题相同而强制合并实体。 +- `springer_selected_volume:publication_format` 标签与独立来源 namespace。 +- `accepted` 仅表示章节已列入这本出版选集,原始 1993 投稿评审决定、决定日期和全部录用清单均未知。后续原会议 main、拒稿或撤稿证据可以独立保留。 + +不能据此宣称会议全部录用、全部投稿或公开拒稿覆盖完整,也不能把出版社机器添加的关键词视为作者标签。未命中选集不等于被拒。 + +## 按需运行与恢复 + +输入分别是卷目录/旧站说明和章节页的快照目录。每个目录的 `receipts.json` 包含 `responses` 列表,每条保留 `url`、`final_url`、`file`、`http_status`、`headers`、`bytes`、`sha256`、`at`。需要三页目录、官方旧站说明及全部 49 个章节页面。 + +```sh +paper-atlas collect springer-selected \ + --toc-snapshots /data/paper-index/collected/aistats1993-book-evidence-v1 \ + --chapter-snapshots /data/paper-index/collected/aistats1993-chapter-evidence-v1 \ + --out /data/paper-index/collected/aistats1993-source-v1 +``` + +采集器验证回执和内容哈希、文件路径、HTTP 类型、目录/章节 DOI 和标题、作者格式、出版年份及页码。Springer 对公开元数据返回的同路径 `cookies_not_supported` 诊断参数会原样保留;不接受其他域、路径、分页变化或未知查询参数。官方旧站说明固定哈希为 `6db205b9dbc82b4a096556efd2aa67e065cad8e0bd6351176b1962ffd1fca179`,变化时须重新审核。 + +全部文件先核验,输出使用排他锁和暂存目录,完成报告最后发布。已有输出拒绝覆盖;发现不完整或已消费文件,应先查运行状态与报告,另建明确版本目录,不能重放生产导入。 + +输出 JSONL 配套 manifest 始终为 `partial`。收集完成后还必须做 PostgreSQL 稳定标识/标题/作者身份预检、排除已导入记录、准备限定增量文件、保存旧数据保护基线、原生导入和独立验收。49 条来源不代表 49 个新增论文实体,全部来源处理后仍须全库去重审计。 + +## 固定测试资料 + +`tests/fixtures/springer-aistats1993-fixture.json.gz` 保存 4 份范围/目录 HTML、49 份章节 HTML、原始请求回执及独立原始字段审计的预期元数据,SHA256 为 `94028fd37080f9d8027e5790e379cc151ee6eab17ed0d1dcc0f59e6e75f9d4d0`。摘要按 HTML 段落边界分隔;未修改原始 HTML 或回执。它是压缩的公开元数据测试资料,不包含论文 PDF。测试覆盖全部真实章节字段、缺失和漂移输入、来源安全边界、幂等导入及旧拒稿历史保留。构建和测试只在已授权远程 runner 执行。 diff --git a/paper_atlas/cli.py b/paper_atlas/cli.py index 43248da..16b0408 100644 --- a/paper_atlas/cli.py +++ b/paper_atlas/cli.py @@ -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','springer-selected'): ('paper_atlas.collectors.springer_selected', {}, '核验限定的会后选集章节与原会议关系'), ('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 公开投稿与决定快照'), diff --git a/paper_atlas/collectors/springer_selected.py b/paper_atlas/collectors/springer_selected.py new file mode 100644 index 0000000..a801219 --- /dev/null +++ b/paper_atlas/collectors/springer_selected.py @@ -0,0 +1,231 @@ +"""Reviewed postconference selected volumes from frozen public HTML metadata.""" +import argparse +from collections import defaultdict +import fcntl +import hashlib +import json +from pathlib import Path +import re +import tempfile +from urllib.parse import parse_qs, urlsplit + +from paper_atlas.db.submissions import timestamp +from paper_atlas.io.html import Document, clean + +SOURCE = 'springer_selected_volume' +BOOK = '10.1007/978-1-4612-2660-4' +BOOK_URL = 'https://link.springer.com/book/'+BOOK +HISTORY_URL = 'https://www.gatsby.ucl.ac.uk/aistats/workshops.html' +HISTORY_SHA = '6db205b9dbc82b4a096556efd2aa67e065cad8e0bd6351176b1962ffd1fca179' +SCOPE = { + 'venue': 'AISTATS', 'year': 1993, 'track': 'selected_volume', + 'event_key': 'aistats-1993-selected-volume-1994', + 'publication_year': 1994, 'book_doi': BOOK, + 'book_title': 'Selecting Models from Data', 'expected_chapters': 49, + 'relationship': 'Selected workshop papers were revised and rereviewed for later publication; this is not the original complete workshop decision list.', +} + + +def snapshot_index(root): + root = Path(root).resolve() + receipts = json.loads((root/'receipts.json').read_bytes())['responses'] + index = {} + for receipt in receipts: + url = receipt['url'] + if url in index: + raise ValueError('Duplicate snapshot URL') + index[url] = (root, receipt) + return index + + +def read_page(index, url): + if url not in index: + raise ValueError('Missing required public HTML snapshot: '+url) + root, receipt = index[url] + filename = Path(receipt['file']) + path = root/filename + content_type = {k.lower():v for k,v in receipt.get('headers', {}).items()}.get('content-type', '') + requested, final = urlsplit(url), urlsplit(receipt.get('final_url', '')) + query = parse_qs(final.query, keep_blank_values=True) + original_query = parse_qs(requested.query, keep_blank_values=True) + # Springer returns the public metadata with an explicit cookie diagnostic. + # Retain that final URL verbatim; accept no other redirect or changed page. + if query != original_query and requested.netloc == 'link.springer.com': + if (query.pop('error', None) != ['cookies_not_supported'] + or not re.fullmatch(r'[0-9a-f-]{36}', ''.join(query.pop('code', [])))): + raise ValueError('Unreviewed Springer redirect query') + if (filename.is_absolute() or '..' in filename.parts or not path.resolve().is_relative_to(root) + or receipt.get('http_status') != 200 + or (final.scheme, final.netloc, final.path) != (requested.scheme, requested.netloc, requested.path) + or final.fragment or query != original_query + or 'text/html' not in content_type.lower()): + raise ValueError('Unsafe, redirected or unsuccessful public HTML snapshot') + raw = path.read_bytes() + digest = hashlib.sha256(raw).hexdigest() + if len(raw) > 2_000_000 or len(raw) != receipt['bytes'] or digest != receipt['sha256']: + raise ValueError('Snapshot checksum or size differs') + evidence = {'url':url, 'sha256':digest, 'bytes':len(raw), + 'file':'evidence/'+digest+'.html', 'http_status':200, + 'observed_at':timestamp(receipt['at']), 'response_receipt':receipt} + return raw, evidence + + +def parse_toc(html, *, excluded_materials=None): + entries = {} + for node in Document(html).root.walk(): + if node.tag != 'li' or node.attrs.get('data-test') != 'chapter': + continue + links = [n for n in node.walk() if n.tag == 'a' and re.fullmatch( + r'/chapter/'+re.escape(BOOK)+r'_[1-9]\d*', n.attrs.get('href', ''))] + if not links: + headings = [clean(n.text()) for n in node.walk() if n.tag in ('h3','h4')] + frontmatter = [n.attrs.get('href','') for n in node.walk() + if n.tag == 'a' and n.attrs.get('data-test') == 'front-matter-pdf'] + if headings == ['Front Matter'] and len(frontmatter) == 1: + if excluded_materials is not None: + excluded_materials.append({'title':'Front Matter', 'url':frontmatter[0], + 'reason':'Section front matter without a chapter DOI; not an authored chapter.'}) + continue + if len(links) != 1: + raise ValueError('Chapter entry lacks one reviewed-book DOI') + link, = links + doi = link.attrs['href'].removeprefix('/chapter/') + if doi in entries or not clean(link.text()): + raise ValueError('Duplicate DOI or missing chapter title') + entries[doi] = clean(link.text()) + return entries + + +def parse_chapter(html, doi): + nodes = list(Document(html).root.walk()) + meta = defaultdict(list) + for node in nodes: + if node.tag == 'meta' and node.attrs.get('name', '').startswith('citation_'): + meta[node.attrs['name']].append(node.attrs.get('content', '')) + def one(key): + values = meta[key] + if len(values) != 1 or not values[0]: + raise ValueError('Missing or ambiguous citation field: '+key) + return values[0] + if (one('citation_doi') != doi or one('citation_inbook_title') != SCOPE['book_title'] + or one('citation_publication_date') != str(SCOPE['publication_year'])): + raise ValueError('Chapter DOI, book or publication year differs from reviewed scope') + raw_authors = meta['citation_author'] + if not raw_authors: + raise ValueError('Missing named chapter authors') + authors = [] + for raw in raw_authors: + if raw.count(',') != 1: + raise ValueError('Unreviewed citation author format') + family, given = [clean(s) for s in raw.split(',', 1)] + if not family or not given: + raise ValueError('Incomplete citation author') + authors.append({'given':given, 'family':family}) + abstract_nodes = [n for n in nodes if n.attrs.get('id') == 'Abs1-content'] + if len(abstract_nodes) > 1: + raise ValueError('Ambiguous abstract section') + abstract = clean(abstract_nodes[0].text()) if abstract_nodes else None + first, last = one('citation_firstpage'), one('citation_lastpage') + if not first.isdigit() or not last.isdigit() or int(first) > int(last): + raise ValueError('Invalid chapter pages') + return {'doi':doi, 'title':one('citation_title'), 'authors':authors, + 'authors_citation_raw':list(raw_authors), 'abstract':abstract or None, + 'first_page':first, 'last_page':last} + + +def record_for(chapter, evidence, scope_evidence): + return { + 'source':SOURCE, 'external_id':chapter['doi'], + 'url':'https://link.springer.com/chapter/'+chapter['doi'], + 'title':chapter['title'], 'abstract':chapter['abstract'], 'authors':chapter['authors'], + 'identifiers':{'doi':chapter['doi']}, + 'submission_identifiers':[{'scheme':'publication_doi','value':chapter['doi']}], + **{k:SCOPE[k] for k in ('venue','year','track','event_key')}, + 'status':'accepted', 'observed_at':evidence['observed_at'], + 'raw_decision':'listed in the 1994 selected postconference volume; original 1993 review decision not supplied', + 'tags':[{'namespace':SOURCE+':publication_format','value':'selected_volume_chapter'}], + 'metadata':{ + 'publication_format':'selected_volume_chapter', 'bibliographic_year':1994, + 'related_workshop_year':1993, 'book_doi':BOOK, 'book_title':SCOPE['book_title'], + 'year_basis':'Reviewed workshop year; bibliographic publication year is separately retained.', + 'first_page':chapter['first_page'], 'last_page':chapter['last_page'], + 'authors_citation_raw':chapter['authors_citation_raw'], + 'publication_evidence':'Published selected-volume chapter, not an original workshop decision note.', + 'scope_relationship':SCOPE['relationship'], 'decision_date_precision':'not_supplied', + 'source_evidence':evidence, 'scope_evidence':scope_evidence, + }, + } + + +def collect(out, *, toc_snapshots, chapter_snapshots): + toc_index, chapter_index = snapshot_index(toc_snapshots), snapshot_index(chapter_snapshots) + frozen = {} + def read(index, url): + raw, evidence = read_page(index, url) + frozen[evidence['file']] = raw + return raw.decode('utf-8'), evidence + history, history_evidence = read(toc_index, HISTORY_URL) + if hashlib.sha256(history.encode()).hexdigest() != HISTORY_SHA: + raise ValueError('Reviewed selected-volume relationship evidence changed') + scope_evidence = [history_evidence] + titles, excluded = {}, [] + for page in range(1, 4): + html, evidence = read(toc_index, BOOK_URL+('?page='+str(page) if page > 1 else '')) + scope_evidence.append(evidence) + entries = parse_toc(html, excluded_materials=excluded) + if titles.keys() & entries.keys(): + raise ValueError('DOI repeated across TOC pages') + titles.update(entries) + expected = {BOOK+'_'+str(i) for i in range(1, 50)} + if titles.keys() != expected: + raise ValueError('Reviewed 49-chapter inventory changed') + chapter_urls = {'https://link.springer.com/chapter/'+doi for doi in expected} + if chapter_index.keys() != chapter_urls: + raise ValueError('Missing or extraneous chapter snapshot') + records = [] + for doi in sorted(expected, key=lambda s:int(s.rsplit('_',1)[1])): + html, evidence = read(chapter_index, 'https://link.springer.com/chapter/'+doi) + chapter = parse_chapter(html, doi) + if chapter['title'] != titles[doi]: + raise ValueError('Chapter title disagrees with official TOC') + records.append(record_for(chapter, evidence, scope_evidence)) + out = Path(out) + out.mkdir(parents=True, exist_ok=True) + with (out/'springer-selected.collect.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + if any(p.name != 'springer-selected.collect.lock' for p in out.iterdir()): + raise FileExistsError('Existing output must be inspected, not overwritten') + with tempfile.TemporaryDirectory(prefix='.springer-selected-', dir=out) as temporary: + stage = Path(temporary) + (stage/'evidence').mkdir() + for filename, raw in frozen.items(): + (stage/filename).write_bytes(raw) + path = stage/'springer-aistats-1993-selected-volume.jsonl' + path.write_text(''.join(json.dumps(r,ensure_ascii=False)+'\n' for r in records)) + manifest = {'source':SOURCE, 'parser_version':1, + **{k:SCOPE[k] for k in ('venue','year','track','event_key')}, + 'count':len(records), 'file':str(out/path.name), + 'file_sha256':hashlib.sha256(path.read_bytes()).hexdigest(), + 'list_url':BOOK_URL, 'status':'partial', + 'excluded_materials':excluded, + 'missing_abstract_count':sum(not r['abstract'] for r in records)} + path.with_suffix('.manifest.json').write_text(json.dumps(manifest,indent=2)+'\n') + report = {'status':'collected_pending_identity_review', 'records':len(records), + 'files':[manifest], 'reviewed_scope':SCOPE, 'database_written':False, + 'pdfs_downloaded':False, 'scope_evidence':scope_evidence} + (stage/'collection-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n') + for item in stage.iterdir(): + if item.name != 'collection-report.json': + item.replace(out/item.name) + (stage/'collection-report.json').replace(out/'collection-report.json') + return report + + +def main(argv=None, *, prog=None): + parser = argparse.ArgumentParser(prog=prog) + parser.add_argument('--toc-snapshots', required=True) + parser.add_argument('--chapter-snapshots', required=True) + parser.add_argument('--out', required=True) + args = parser.parse_args(argv) + print(json.dumps(collect(args.out, toc_snapshots=args.toc_snapshots, + chapter_snapshots=args.chapter_snapshots),ensure_ascii=False)) diff --git a/tests/fixtures/springer-aistats1993-fixture.json.gz b/tests/fixtures/springer-aistats1993-fixture.json.gz new file mode 100644 index 0000000..e8dfb47 Binary files /dev/null and b/tests/fixtures/springer-aistats1993-fixture.json.gz differ diff --git a/tests/test_springer_selected.py b/tests/test_springer_selected.py new file mode 100644 index 0000000..24b4922 --- /dev/null +++ b/tests/test_springer_selected.py @@ -0,0 +1,160 @@ +"""Real 49-chapter snapshots compared with a separately frozen metadata audit.""" +import copy +import gzip +import hashlib +import json +from pathlib import Path +import sqlite3 +import tempfile +import unittest + +from paper_atlas.collectors.springer_selected import ( + BOOK, SOURCE, collect, parse_chapter, parse_toc, read_page, snapshot_index, +) +from paper_atlas.db.store import Store +from paper_atlas.ingestion.records import ingest_file + +FIXTURE = Path(__file__).parent/'fixtures/springer-aistats1993-fixture.json.gz' + + +class SpringerSelectedTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + raw = FIXTURE.read_bytes() + if hashlib.sha256(raw).hexdigest() != '94028fd37080f9d8027e5790e379cc151ee6eab17ed0d1dcc0f59e6e75f9d4d0': + raise AssertionError('Fixed source fixture differs') + cls.fixture = json.loads(gzip.decompress(raw)) + + def snapshots(self, root): + for group in ['toc','chapters']: + folder = root/group + folder.mkdir() + for name, text in self.fixture[group].items(): + (folder/name).write_text(text) + return {'toc_snapshots':root/'toc','chapter_snapshots':root/'chapters'} + + def test_all49_original_fields_against_independent_audit(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report = collect(root/'out', **self.snapshots(root)) + manifest, = report['files'] + rows = [json.loads(s) for s in Path(manifest['file']).read_text().splitlines()] + self.assertEqual(len(rows), 49) + self.assertEqual(manifest['status'], 'partial') + self.assertEqual(manifest['missing_abstract_count'], 0) + self.assertTrue(manifest['excluded_materials']) + self.assertTrue(all(x['title'] == 'Front Matter' for x in manifest['excluded_materials'])) + for row, expected in zip(rows, self.fixture['expected']): + for field in ['title','abstract']: + self.assertEqual(row[field], expected[field]) + self.assertEqual(row['identifiers'], {'doi':expected['doi']}) + self.assertEqual(row['submission_identifiers'], [{'scheme':'publication_doi','value':expected['doi']}]) + self.assertEqual(row['external_id'], expected['doi']) + self.assertEqual(row['metadata']['authors_citation_raw'], expected['authors_citation_raw']) + self.assertEqual(row['authors'], [{'family':s.split(',',1)[0].strip(), 'given':s.split(',',1)[1].strip()} for s in expected['authors_citation_raw']]) + self.assertEqual(row['metadata']['first_page'], expected['first_page']) + self.assertEqual(row['metadata']['last_page'], expected['last_page']) + self.assertEqual((row['year'],row['metadata']['bibliographic_year']), (1993,1994)) + self.assertEqual(row['track'], 'selected_volume') + self.assertEqual(row['event_key'], 'aistats-1993-selected-volume-1994') + self.assertNotIn('decision_date', row) + self.assertIn('original 1993 review decision not supplied', row['raw_decision']) + for e in [row['metadata']['source_evidence'], *row['metadata']['scope_evidence']]: + self.assertEqual(hashlib.sha256((root/'out'/e['file']).read_bytes()).hexdigest(), e['sha256']) + self.assertFalse(report['database_written']) + with self.assertRaises(FileExistsError): + collect(root/'out', toc_snapshots=root/'toc', chapter_snapshots=root/'chapters') + + def test_native_history_namespaces_and_idempotent_import(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report = collect(root/'out', **self.snapshots(root)) + manifest, = report['files'] + first = json.loads(Path(manifest['file']).read_text().splitlines()[0]) + db = root/'sample.sqlite' + store = Store(db) + pid, rid = store.record('prior_public','earlier','https://example.org/earlier',first['title'],first['abstract'],first['authors']) + store.decision(pid,rid,'AISTATS',1993,'main','earlier','https://example.org/earlier','rejected','original public rejection') + # Explicitly verified work identity; the short title alone must not + # force a merge or attach this publication DOI to the old attempt. + store.conn.execute('INSERT INTO identifiers VALUES(?,?,?)', ('doi',first['identifiers']['doi'],pid)) + store.conn.commit() + old = store.conn.execute('SELECT * FROM decisions').fetchall() + store.conn.close() + ingest_file(db, manifest['file']) + ingest_file(db, manifest['file']) + with sqlite3.connect(db) as c: + self.assertEqual(c.execute('SELECT * FROM decisions WHERE status=\'rejected\'').fetchall(), [tuple(r) for r in old]) + self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0], 49) + self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0], 50) + self.assertEqual(c.execute('SELECT count(*) FROM decisions').fetchone()[0], 50) + self.assertEqual(c.execute('SELECT e.year,e.track,e.event_key FROM editions e ORDER BY e.id').fetchall(), [(1993,'main',''),(1993,'selected_volume','aistats-1993-selected-volume-1994')]) + self.assertEqual(c.execute('SELECT count(*) FROM paper_tags pt JOIN tags t ON t.id=pt.tag_id WHERE t.namespace=?', (SOURCE+':publication_format',)).fetchone()[0], 49) + self.assertEqual(c.execute('SELECT count(*) FROM submission_identifiers WHERE scheme=\'publication_doi\'').fetchone()[0], 49) + self.assertIsNone(c.execute("SELECT 1 FROM submission_identifiers si JOIN submissions s ON s.id=si.submission_id JOIN editions e ON e.id=s.edition_id WHERE e.track='main'").fetchone()) + + def test_short_unanchored_title_keeps_identity_unconfirmed(self): + with tempfile.TemporaryDirectory() as tmp: + store = Store(Path(tmp)/'identity.sqlite') + title = 'Statistical strategy: step 1' + authors = [{'given':'D. J.','family':'Hand'}] + old, _ = store.record('prior_public','earlier','https://example.org/old',title,'Same abstract',authors) + new, _ = store.record(SOURCE,BOOK+'_1','https://link.springer.com/chapter/'+BOOK+'_1',title,'Same abstract',authors,{'doi':BOOK+'_1'}) + self.assertNotEqual(old, new) + self.assertEqual(store.conn.execute('SELECT count(*) FROM match_candidates WHERE method=\'exact_title_unconfirmed\'').fetchone()[0], 1) + store.conn.close() + + def test_receipt_hash_path_redirect_and_content_type_guards(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.snapshots(root) + index = snapshot_index(root/'chapters') + url = 'https://link.springer.com/chapter/'+BOOK+'_1' + self.assertTrue(read_page(index,url)[0]) + for key, value in [('sha256','0'*64),('file','../escape.html'),('final_url','https://example.org/chapter'),('http_status',403),('headers',{'Content-Type':'application/pdf'})]: + changed = copy.deepcopy(index) + changed[url][1][key] = value + with self.subTest(key=key), self.assertRaises((ValueError,KeyError)): + read_page(changed,url) + changed = copy.deepcopy(index) + changed[url][1]['final_url'] = url+'?page=2' + with self.assertRaises(ValueError): + read_page(changed,url) + + def test_missing_or_duplicate_snapshot_stops_collection(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + args = self.snapshots(root) + path = root/'chapters/receipts.json' + d = json.loads(path.read_bytes()) + original = copy.deepcopy(d) + d['responses'].pop() + path.write_text(json.dumps(d)) + with self.assertRaises(ValueError): + collect(root/'out', **args) + self.assertFalse((root/'out').exists()) + original['responses'].append(original['responses'][0]) + path.write_text(json.dumps(original)) + with self.assertRaises(ValueError): + snapshot_index(root/'chapters') + + def test_book_doi_year_and_duplicate_citation_fields_reject_drift(self): + html = self.fixture['chapters']['chapter-01.html'] + for key, value in [('citation_doi',BOOK+'_2'),('citation_inbook_title','Another Book'),('citation_publication_date','1993')]: + import re + changed, count = re.subn(r'(]*name="'+key+r'"[^>]*content=")[^"]*', lambda m:m[1]+value, html) + self.assertEqual(count, 1) + with self.subTest(key=key), self.assertRaises(ValueError): + parse_chapter(changed, BOOK+'_1') + with self.assertRaises(ValueError): + parse_chapter(html+'', BOOK+'_1') + + def test_missing_abstract_is_explicit_and_does_not_use_other_page_text(self): + html = self.fixture['chapters']['chapter-01.html'].replace('id="Abs1-content"','id="not-an-abstract"') + self.assertIsNone(parse_chapter(html, BOOK+'_1')['abstract']) + + def test_toc_ignores_frontmatter_but_rejects_duplicate_publications(self): + html = self.fixture['toc']['springer-toc1.html'] + self.assertEqual(len(parse_toc(html)), 17) + with self.assertRaises(ValueError): + parse_toc(html+html)