agentic-scholarone 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ jobs:
7
+ test:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: "3.13"
14
+ - run: pip install -e ".[keyring,dev]"
15
+ - run: ruff check .
16
+ - run: mypy agentic_scholarone
17
+ - run: pytest --cov=agentic_scholarone
@@ -0,0 +1,17 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ release:
4
+ types: [published]
5
+ jobs:
6
+ publish:
7
+ runs-on: ubuntu-latest
8
+ environment: pypi
9
+ permissions:
10
+ id-token: write # trusted publishing, no token
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.13"
16
+ - run: pip install build && python -m build
17
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .coverage
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ # live-session secrets and data
12
+ credentials.json
13
+ credentials.json.tmp
14
+ mc_actions.jsonl
15
+ *.har
16
+ *.pdf
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martin Monperrus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentic-scholarone
3
+ Version: 0.1.0
4
+ Summary: Drive ScholarOne / ManuscriptCentral as a journal editor: a Python library for agents and an interactive REPL for humans
5
+ Project-URL: Homepage, https://github.com/monperrus/agentic-scholarone
6
+ Author: Martin Monperrus
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: agent,editor,manuscriptcentral,peer review,scholarone
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: beautifulsoup4>=4.12
15
+ Requires-Dist: curl-cffi>=0.7
16
+ Requires-Dist: tqdm>=4.66
17
+ Provides-Extra: dev
18
+ Requires-Dist: mypy>=1.10; extra == 'dev'
19
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
20
+ Requires-Dist: pytest>=8; extra == 'dev'
21
+ Requires-Dist: ruff>=0.6; extra == 'dev'
22
+ Provides-Extra: keyring
23
+ Requires-Dist: keyring>=24; extra == 'keyring'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # agentic-scholarone
27
+
28
+ Drive [ScholarOne Manuscripts](https://mc.manuscriptcentral.com) (ManuscriptCentral) as a journal
29
+ editor, without a browser. You can use it two ways:
30
+
31
+ * **as a library**, for coding agents and scripts: list your queues, read manuscripts, reviews and
32
+ decision letters, search and screen reviewers, and send invitations;
33
+ * **as an interactive REPL**, for humans at the keyboard: `agentic-scholarone`.
34
+
35
+ ScholarOne has no public API for editors. This client speaks the web app's own protocol: form POSTs
36
+ that carry the page's hidden `XIK_*` tokens, plus `popWindow(...)` popups fetched with GETs. The
37
+ HTTP layer is [`curl_cffi`](https://github.com/lexiforest/curl_cffi) with a consistent Chrome
38
+ fingerprint, so the login's device check treats the client as an ordinary browser.
39
+
40
+ > ⚠️ ScholarOne is a live production system with real authors and reviewers on the other end.
41
+ > Reading needs no care. **Invitations are real emails**: have a human confirm every one.
42
+ > Reuse one session instead of logging in per call, because each fresh login can send a
43
+ > verification code to the editor's inbox.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install agentic-scholarone # library + REPL
49
+ pip install "agentic-scholarone[keyring]" # + passwords from the OS secret store
50
+ ```
51
+
52
+ Python ≥ 3.10.
53
+
54
+ ## Credentials
55
+
56
+ A `credentials.json` in the working directory (keep it out of version control). The client also
57
+ saves session cookies into it, so later runs resume without logging in again:
58
+
59
+ ```json
60
+ {
61
+ "journal": "abc-cs",
62
+ "email": "jane.doe@example.org",
63
+ "auth_method": "clarivate",
64
+ "password": "…"
65
+ }
66
+ ```
67
+
68
+ * `journal`: the slug after `mc.manuscriptcentral.com/` in your journal's URL.
69
+ * `auth_method`: `"clarivate"` (default, Web of Science single sign-on) or `"direct"` (ScholarOne's
70
+ own login form, with your ScholarOne user id in `email` and the native password in
71
+ `direct_password`).
72
+ * Password lookup order: `MC_PASSWORD` env, then `password_keyring` (`"name"` or `"service/name"`,
73
+ read live from the OS secret store, so the file holds no password), then `direct_password`, then
74
+ `password`.
75
+
76
+ `MC_JOURNAL`, `MC_EMAIL`, `MC_PASSWORD`, `MC_AUTH` override the file.
77
+
78
+ **Two-factor codes.** When a login triggers the emailed device check, the code is taken from, in
79
+ order: `MC_2FA_CODE`, any provider you plug in (see below), the file named by `MC_2FA_CODE_FILE`
80
+ (polled for up to 15 min, so an agent can start the login and the code can be dropped in when the
81
+ mail arrives), then an interactive prompt.
82
+
83
+ ## Use 1: library, for agents and scripts
84
+
85
+ ```python
86
+ from agentic_scholarone import ScholarOneSession
87
+
88
+ mc = ScholarOneSession.from_credentials() # resumes saved cookies, else logs in
89
+ print(mc.ae_dashboard()) # papers needing reviewers, awaiting revision
90
+
91
+ # every queue you can see
92
+ link_map, src = mc._get_dashboard_link_map()
93
+ for queue in mc._ALL_QUEUE_KEYS:
94
+ if href := link_map.get(queue):
95
+ for p in mc.fetch_queue_papers_all_pages(href, src):
96
+ print(queue, p["ms_id"], p["counts"], p["stage"], p["title"][:60])
97
+ ```
98
+
99
+ `counts` reads `2 received / 1 waiting / 1 pending / 5 declined`. ScholarOne's own cell
100
+ (`3 invited; 2 agreed; 5 declined; 2 returned`) is cumulative, so the client turns it into
101
+ disjoint numbers. `stage` is the pending task, e.g. `Make Recommendation (due 28-Oct-2026)`.
102
+
103
+ The session is stateful. Navigate first, then read the page you are on.
104
+
105
+ ```python
106
+ mc.navigate_to_any_manuscript("ABC-2026-04-0342")
107
+ meta = mc.current_manuscript_metadata() # ms_id, title, authors, abstract, keywords
108
+ pdf = mc.download_current_manuscript_pdf() # bytes
109
+ reviews = mc.current_reviews() # parsed score sheets
110
+ letters = mc.current_decision_letters() # [{label, text}]
111
+ mc.download_reviews_txt("ABC-2026-04-0342") # all reviews in one text file, headed by verdicts
112
+ ```
113
+
114
+ Reviewers:
115
+
116
+ ```python
117
+ mc.navigate_to_reviewer_tab("ABC-2026-04-0342")
118
+ for row in mc.selected_reviewer_rows(): # already selected or invited
119
+ stats = mc.reviewer_history_analysis(row)
120
+ print(row["name"], row["status"], stats.get("average_turnaround_time"))
121
+
122
+ for c in mc.search_reviewer_by_name("Jane Doe"): # people usually have several accounts
123
+ info = mc.person_account_info(c) # email, affiliation, roles, keywords
124
+ stats = mc.reviewer_history_analysis(c) # agreed / declined / turnaround / ORCID
125
+ print(info.get("email"), stats.get("times_agreed"), stats.get("last_invitation_date"))
126
+
127
+ # outward-facing: sends the invitation email
128
+ result = mc.invite_by_email("ABC-2026-04-0342", "jane.doe@example.org")
129
+ print(result.ok, result.status, result.message) # result.ambiguous lists several matches
130
+ ```
131
+
132
+ A row reading `Selected` was added but never mailed; `mc.send_pending_invitation(name)` sends it.
133
+
134
+ Every state-changing action is appended to `mc_actions.jsonl` in the state directory
135
+ (`MC_STATE_DIR`, default `~/.local/state/agentic-scholarone`); `mc.worklog.entries()` reads it back.
136
+
137
+ When a page parses to nothing, `MC_DEBUG=1` traces every navigation, and `mc.page_title()`,
138
+ `mc.page_text()`, `mc.list_links()`, `mc.show_form_fields()`, `mc.show_tables()` show what the
139
+ session is looking at.
140
+
141
+ ## Use 2: interactive REPL, for humans
142
+
143
+ ```bash
144
+ agentic-scholarone # reads credentials.json in the current directory
145
+ ```
146
+
147
+ ```
148
+ editor show all papers currently assigned to you as AE
149
+ select pick a paper by number and open its reviewer details
150
+ invitees show reviewers you've invited, sorted by turnaround
151
+ reviewers [ms_id] show invited reviewers + candidates for a manuscript
152
+ invite <ms_id> <email|name> search by email or "First Last" and send invitation
153
+ abstract <ms_id> show abstract for any accessible manuscript
154
+ download <ms_id> download proof PDF for any accessible manuscript
155
+ reviews <ms_id> download all reviews of a manuscript to one txt file
156
+ log [n|ms_id] show the last n logged state-changing actions
157
+ login (re)authenticate
158
+ ```
159
+
160
+ ## Extending: plug in your own infrastructure
161
+
162
+ Subclass `ScholarOneSession` and override what you need. The defaults are generic or do nothing.
163
+
164
+ | Extension point | Default | Typical override |
165
+ | --- | --- | --- |
166
+ | `default_journal` | `""` (then `MC_JOURNAL`) | your journal slug |
167
+ | `ms_id_pattern` | `[A-Z][A-Z0-9]+-\d{4}[-.\w]*` | your journal's id format |
168
+ | `keyring_service` | `agentic-scholarone` | your secret-store service name |
169
+ | `state_dir` | `~/.local/state/agentic-scholarone` | a project directory |
170
+ | `two_factor_code_providers()` | `[]` | read the code from a local mailbox |
171
+ | `invitation_paragraph(candidate, **opts)` | `""` | a paragraph added to every invitation |
172
+
173
+ ```python
174
+ from agentic_scholarone import ScholarOneSession, main
175
+
176
+ class MyJournal(ScholarOneSession):
177
+ default_journal = "abc-cs"
178
+ ms_id_pattern = r"ABC-\d{4}-\d{2}-\d+[\w.]*"
179
+
180
+ def two_factor_code_providers(self):
181
+ return [read_code_from_my_mailbox] # fn(timeout_s) -> "123456" or ""
182
+
183
+ def invitation_paragraph(self, candidate, note="", **_):
184
+ return note # mc.invite_by_email(ms, email, note="…")
185
+
186
+ def cmd_hello(mc, arg): # an extra REPL command
187
+ print("hello", mc.journal, arg)
188
+
189
+ if __name__ == "__main__":
190
+ main(MyJournal, {"hello": cmd_hello})
191
+ ```
192
+
193
+ Keyword arguments given to `invite_by_email` / `invite_by_name` reach `invitation_paragraph`.
194
+
195
+ ## Development
196
+
197
+ ```bash
198
+ uv venv && uv pip install -e ".[keyring,dev]"
199
+ pytest && ruff check . && mypy agentic_scholarone
200
+ ```
201
+
202
+ Tests run on hand-written HTML fixtures. Never commit pages captured from a live session: they hold
203
+ the names of authors and reviewers.
204
+
205
+ ## License
206
+
207
+ MIT
@@ -0,0 +1,182 @@
1
+ # agentic-scholarone
2
+
3
+ Drive [ScholarOne Manuscripts](https://mc.manuscriptcentral.com) (ManuscriptCentral) as a journal
4
+ editor, without a browser. You can use it two ways:
5
+
6
+ * **as a library**, for coding agents and scripts: list your queues, read manuscripts, reviews and
7
+ decision letters, search and screen reviewers, and send invitations;
8
+ * **as an interactive REPL**, for humans at the keyboard: `agentic-scholarone`.
9
+
10
+ ScholarOne has no public API for editors. This client speaks the web app's own protocol: form POSTs
11
+ that carry the page's hidden `XIK_*` tokens, plus `popWindow(...)` popups fetched with GETs. The
12
+ HTTP layer is [`curl_cffi`](https://github.com/lexiforest/curl_cffi) with a consistent Chrome
13
+ fingerprint, so the login's device check treats the client as an ordinary browser.
14
+
15
+ > ⚠️ ScholarOne is a live production system with real authors and reviewers on the other end.
16
+ > Reading needs no care. **Invitations are real emails**: have a human confirm every one.
17
+ > Reuse one session instead of logging in per call, because each fresh login can send a
18
+ > verification code to the editor's inbox.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install agentic-scholarone # library + REPL
24
+ pip install "agentic-scholarone[keyring]" # + passwords from the OS secret store
25
+ ```
26
+
27
+ Python ≥ 3.10.
28
+
29
+ ## Credentials
30
+
31
+ A `credentials.json` in the working directory (keep it out of version control). The client also
32
+ saves session cookies into it, so later runs resume without logging in again:
33
+
34
+ ```json
35
+ {
36
+ "journal": "abc-cs",
37
+ "email": "jane.doe@example.org",
38
+ "auth_method": "clarivate",
39
+ "password": "…"
40
+ }
41
+ ```
42
+
43
+ * `journal`: the slug after `mc.manuscriptcentral.com/` in your journal's URL.
44
+ * `auth_method`: `"clarivate"` (default, Web of Science single sign-on) or `"direct"` (ScholarOne's
45
+ own login form, with your ScholarOne user id in `email` and the native password in
46
+ `direct_password`).
47
+ * Password lookup order: `MC_PASSWORD` env, then `password_keyring` (`"name"` or `"service/name"`,
48
+ read live from the OS secret store, so the file holds no password), then `direct_password`, then
49
+ `password`.
50
+
51
+ `MC_JOURNAL`, `MC_EMAIL`, `MC_PASSWORD`, `MC_AUTH` override the file.
52
+
53
+ **Two-factor codes.** When a login triggers the emailed device check, the code is taken from, in
54
+ order: `MC_2FA_CODE`, any provider you plug in (see below), the file named by `MC_2FA_CODE_FILE`
55
+ (polled for up to 15 min, so an agent can start the login and the code can be dropped in when the
56
+ mail arrives), then an interactive prompt.
57
+
58
+ ## Use 1: library, for agents and scripts
59
+
60
+ ```python
61
+ from agentic_scholarone import ScholarOneSession
62
+
63
+ mc = ScholarOneSession.from_credentials() # resumes saved cookies, else logs in
64
+ print(mc.ae_dashboard()) # papers needing reviewers, awaiting revision
65
+
66
+ # every queue you can see
67
+ link_map, src = mc._get_dashboard_link_map()
68
+ for queue in mc._ALL_QUEUE_KEYS:
69
+ if href := link_map.get(queue):
70
+ for p in mc.fetch_queue_papers_all_pages(href, src):
71
+ print(queue, p["ms_id"], p["counts"], p["stage"], p["title"][:60])
72
+ ```
73
+
74
+ `counts` reads `2 received / 1 waiting / 1 pending / 5 declined`. ScholarOne's own cell
75
+ (`3 invited; 2 agreed; 5 declined; 2 returned`) is cumulative, so the client turns it into
76
+ disjoint numbers. `stage` is the pending task, e.g. `Make Recommendation (due 28-Oct-2026)`.
77
+
78
+ The session is stateful. Navigate first, then read the page you are on.
79
+
80
+ ```python
81
+ mc.navigate_to_any_manuscript("ABC-2026-04-0342")
82
+ meta = mc.current_manuscript_metadata() # ms_id, title, authors, abstract, keywords
83
+ pdf = mc.download_current_manuscript_pdf() # bytes
84
+ reviews = mc.current_reviews() # parsed score sheets
85
+ letters = mc.current_decision_letters() # [{label, text}]
86
+ mc.download_reviews_txt("ABC-2026-04-0342") # all reviews in one text file, headed by verdicts
87
+ ```
88
+
89
+ Reviewers:
90
+
91
+ ```python
92
+ mc.navigate_to_reviewer_tab("ABC-2026-04-0342")
93
+ for row in mc.selected_reviewer_rows(): # already selected or invited
94
+ stats = mc.reviewer_history_analysis(row)
95
+ print(row["name"], row["status"], stats.get("average_turnaround_time"))
96
+
97
+ for c in mc.search_reviewer_by_name("Jane Doe"): # people usually have several accounts
98
+ info = mc.person_account_info(c) # email, affiliation, roles, keywords
99
+ stats = mc.reviewer_history_analysis(c) # agreed / declined / turnaround / ORCID
100
+ print(info.get("email"), stats.get("times_agreed"), stats.get("last_invitation_date"))
101
+
102
+ # outward-facing: sends the invitation email
103
+ result = mc.invite_by_email("ABC-2026-04-0342", "jane.doe@example.org")
104
+ print(result.ok, result.status, result.message) # result.ambiguous lists several matches
105
+ ```
106
+
107
+ A row reading `Selected` was added but never mailed; `mc.send_pending_invitation(name)` sends it.
108
+
109
+ Every state-changing action is appended to `mc_actions.jsonl` in the state directory
110
+ (`MC_STATE_DIR`, default `~/.local/state/agentic-scholarone`); `mc.worklog.entries()` reads it back.
111
+
112
+ When a page parses to nothing, `MC_DEBUG=1` traces every navigation, and `mc.page_title()`,
113
+ `mc.page_text()`, `mc.list_links()`, `mc.show_form_fields()`, `mc.show_tables()` show what the
114
+ session is looking at.
115
+
116
+ ## Use 2: interactive REPL, for humans
117
+
118
+ ```bash
119
+ agentic-scholarone # reads credentials.json in the current directory
120
+ ```
121
+
122
+ ```
123
+ editor show all papers currently assigned to you as AE
124
+ select pick a paper by number and open its reviewer details
125
+ invitees show reviewers you've invited, sorted by turnaround
126
+ reviewers [ms_id] show invited reviewers + candidates for a manuscript
127
+ invite <ms_id> <email|name> search by email or "First Last" and send invitation
128
+ abstract <ms_id> show abstract for any accessible manuscript
129
+ download <ms_id> download proof PDF for any accessible manuscript
130
+ reviews <ms_id> download all reviews of a manuscript to one txt file
131
+ log [n|ms_id] show the last n logged state-changing actions
132
+ login (re)authenticate
133
+ ```
134
+
135
+ ## Extending: plug in your own infrastructure
136
+
137
+ Subclass `ScholarOneSession` and override what you need. The defaults are generic or do nothing.
138
+
139
+ | Extension point | Default | Typical override |
140
+ | --- | --- | --- |
141
+ | `default_journal` | `""` (then `MC_JOURNAL`) | your journal slug |
142
+ | `ms_id_pattern` | `[A-Z][A-Z0-9]+-\d{4}[-.\w]*` | your journal's id format |
143
+ | `keyring_service` | `agentic-scholarone` | your secret-store service name |
144
+ | `state_dir` | `~/.local/state/agentic-scholarone` | a project directory |
145
+ | `two_factor_code_providers()` | `[]` | read the code from a local mailbox |
146
+ | `invitation_paragraph(candidate, **opts)` | `""` | a paragraph added to every invitation |
147
+
148
+ ```python
149
+ from agentic_scholarone import ScholarOneSession, main
150
+
151
+ class MyJournal(ScholarOneSession):
152
+ default_journal = "abc-cs"
153
+ ms_id_pattern = r"ABC-\d{4}-\d{2}-\d+[\w.]*"
154
+
155
+ def two_factor_code_providers(self):
156
+ return [read_code_from_my_mailbox] # fn(timeout_s) -> "123456" or ""
157
+
158
+ def invitation_paragraph(self, candidate, note="", **_):
159
+ return note # mc.invite_by_email(ms, email, note="…")
160
+
161
+ def cmd_hello(mc, arg): # an extra REPL command
162
+ print("hello", mc.journal, arg)
163
+
164
+ if __name__ == "__main__":
165
+ main(MyJournal, {"hello": cmd_hello})
166
+ ```
167
+
168
+ Keyword arguments given to `invite_by_email` / `invite_by_name` reach `invitation_paragraph`.
169
+
170
+ ## Development
171
+
172
+ ```bash
173
+ uv venv && uv pip install -e ".[keyring,dev]"
174
+ pytest && ruff check . && mypy agentic_scholarone
175
+ ```
176
+
177
+ Tests run on hand-written HTML fixtures. Never commit pages captured from a live session: they hold
178
+ the names of authors and reviewers.
179
+
180
+ ## License
181
+
182
+ MIT
@@ -0,0 +1,20 @@
1
+ """agentic-scholarone: drive ScholarOne / ManuscriptCentral as an editor, from code or a REPL."""
2
+ from __future__ import annotations
3
+
4
+ from agentic_scholarone._session import (
5
+ BASE_URL,
6
+ InviteResult,
7
+ ScholarOneSession,
8
+ WorkJournal,
9
+ load_credentials,
10
+ main,
11
+ run_repl,
12
+ )
13
+
14
+ ManuscriptCentralSession = ScholarOneSession
15
+
16
+ __all__ = [
17
+ "BASE_URL", "InviteResult", "ManuscriptCentralSession", "ScholarOneSession",
18
+ "WorkJournal", "load_credentials", "main", "run_repl",
19
+ ]
20
+ __version__ = "0.1.0"