scrapbox
scrapbox-client
A client for Scrapbox (Helpfeel Cosense)
日本語版: README.ja.md
Documentation
scrapbox-clientPython API Documentation- Cosense HTTP API Unofficial Documentation
- Cosense HTTP API Unofficial OpenAPI Spec
Install
# mise
mise use -g pipx:scrapbox-client
# pipx
pipx install scrapbox-client
# pip
pip install scrapbox-client
CLI
$ sbc
usage: sbc [-h] [--version] [--connect-sid CONNECT_SID | --connect-sid-file CONNECT_SID_FILE] [--pat PAT | --pat-file PAT_FILE] [--service-account-key SERVICE_ACCOUNT_KEY | --service-account-key-file SERVICE_ACCOUNT_KEY_FILE] {pages,all-pages,page,text,icon,page-v2,links,search,vector-search,commits,members,projects,project,whoami,file,file-info,edit-preview,edit-submit,login,info} ...
Scrapbox API client CLI
positional arguments:
{pages,all-pages,page,text,icon,page-v2,links,search,vector-search,commits,members,projects,project,whoami,file,file-info,edit-preview,edit-submit,login,info}
Available commands
pages Get page list from a project
all-pages Get all pages from a project
page Get detailed information about a page
text Get text content of a page
icon Get icon URL for a page
page-v2 Get page details from the v2 endpoint
links Get the 1-hop or 2-hop neighbourhood of a page
search Search the full text of a project
vector-search Search pages by vector similarity
commits Get the edit history of a page
members Get the members of a project
projects Get the projects you belong to
project Get a single project by name
whoami Get the authenticated user
file Download a file from Scrapbox
file-info Get metadata and extracted text of a file
edit-preview Dry-run a page edit and get a preview ID (no cookie
auth)
edit-submit Commit a previewed page edit (no cookie auth)
login Save a credential read from stdin
info Show the environment and the state of each credential
options:
-h, --help show this help message and exit
--version, -V Show program's version number and exit
--connect-sid CONNECT_SID
Scrapbox authentication cookie (connect.sid)
--connect-sid-file CONNECT_SID_FILE
Path to file containing connect.sid (default: ~/.config/sbc/connect.sid)
--pat PAT Scrapbox personal access token (takes precedence over connect.sid)
--pat-file PAT_FILE Path to file containing a personal access token (default: ~/.config/sbc/pat)
--service-account-key SERVICE_ACCOUNT_KEY
Service account access key, scoped to one Business project (takes precedence over connect.sid)
--service-account-key-file SERVICE_ACCOUNT_KEY_FILE
Path to file containing a service account access key (default: ~/.config/sbc/service-account-key)
examples:
sbc pages my-project --limit 10 --skip 10 --json
sbc pages my-project --sort linked --filter my-name
sbc all-pages my-project --batch-size 500 --json
sbc page my-project "Page Title" --json
sbc page-v2 my-project "Page Title" --json
sbc links my-project "Page Title" --hop 2
sbc links my-project "Page Title" --all --json
sbc search my-project "word1 word2" --or --sort updated
sbc vector-search my-project "some idea"
sbc commits my-project 6a78192b3a6ddc39bdf42b47 --since <commitId>
sbc members my-project
sbc projects
sbc project my-project
sbc whoami
sbc text my-project "Page Title"
sbc icon my-project "Page Title"
sbc file 60190edf1176d9001c13f8e8.png --output image.png
sbc file-info 60190edf1176d9001c13f8e8.png
echo '{"ops":[{"insertBefore":"_end","text":"hello"}]}' \
| sbc edit-preview my-project --page-id <pageId>
sbc edit-submit my-project <previewId>
echo "pat_xxxxxxxx" | sbc login
sbc info
sbc info --project my-business-project --json
`edit-preview` and `edit-submit` need a personal access token or a
service account access key: the API rejects `connect.sid` for them
a service account is registered on one project of a Business plan and
reaches only that one, so any other project answers 400 and `projects`,
`project` and `whoami` are out of its reach
`sbc login` saves the credential read from stdin, choosing the file by its
prefix: `s%` for ~/.config/sbc/connect.sid, `pat_` for ~/.config/sbc/pat,
`cs_` for ~/.config/sbc/service-account-key
`sbc info` reports the environment and, for each of the three credentials,
where it was read from and whether the API still accepts it; a service
account access key is only checked when `--project` names the project it
belongs to, since every other project refuses a good key and a bogus one
alike
priority of `connect.sid` source:
1. --connect-sid argument
2. --connect-sid-file argument
3. ~/.config/sbc/connect.sid file
4. SBC_CONNECT_SID environment variable
priority of personal access token source:
1. --pat argument
2. --pat-file argument
3. ~/.config/sbc/pat file
4. SBC_PAT environment variable
priority of service account access key source:
1. --service-account-key argument
2. --service-account-key-file argument
3. ~/.config/sbc/service-account-key file
4. SBC_SERVICE_ACCOUNT_KEY environment variable
a personal access token takes precedence over a service account access
key, which takes precedence over `connect.sid`
Saving a credential
sbc login reads one credential from stdin and saves it under ~/.config/sbc/.
| Input prefix | Credential | Saved to |
|---|---|---|
s% |
connect.sid cookie |
~/.config/sbc/connect.sid |
pat_ |
personal access token | ~/.config/sbc/pat |
cs_ |
service account access key | ~/.config/sbc/service-account-key |
$ echo "pat_xxxxxxxx" | sbc login
Saved to /home/you/.config/sbc/pat
$ sbc login
Enter connect.sid, personal access token or service account access key:
Saved to /home/you/.config/sbc/connect.sid
Checking the environment and the credentials
sbc info prints the version, the interpreter and the config directory, then
reports each of the three credentials: where it was read from, its value masked
down to the type prefix and its length, and whether the API still accepts it.
Only a credential that is set costs a request, and [in use] marks the one the
other commands would send.
$ sbc info
sbc: 0.4.0
python: 3.14.7 (CPython)
executable: /usr/bin/python3
platform: Linux-7.0.0-27-generic-x86_64-with-glibc2.43
httpx: 0.28.1
config dir: /home/you/.config/sbc
=== credentials ===
- personal access token: valid [in use]
source: /home/you/.config/sbc/pat
value: pat_... (68 chars)
detail: you (You)
- service account access key: unknown
source: $SBC_SERVICE_ACCOUNT_KEY
value: cs_a... (67 chars)
detail: pass --project <name> to check this key against the project it belongs to
- connect.sid cookie: invalid
source: /home/you/.config/sbc/connect.sid
value: s%3A... (92 chars)
detail: the API answered as a guest, so it did not accept this credential
| Status | Meaning |
|---|---|
valid |
the API accepted it |
invalid |
the API refused it, or answered as a guest |
unknown |
it could not be checked |
not set |
none of the sources held it |
A service account access key reaches one project only, and every other project
refuses a good key and a bogus one alike, so it stays unknown until
--project names the project it belongs to:
$ sbc info --project my-business-project
...
- service account access key: valid [in use]
source: /home/you/.config/sbc/service-account-key
value: cs_a... (67 chars)
detail: accepted by project 'my-business-project'
sbc info --json reports the same findings as JSON.
Creating and editing a page (PAT / service account access key only)
Try the change with sbc edit-preview, then pass the returned previewId to
sbc edit-submit to commit it.
A preview is a dry run that writes nothing, expires in a few minutes, and can be
submitted only once.
The change is given as JSON with an ops key, either on stdin or via --input-file.
Look up a lineId in lines[].id of sbc page-v2 <project> <title> --json.
| op | Meaning |
|---|---|
{"insertBefore": "<lineId>" | "_end", "text": "..."} |
Insert a line. _end is the end of the page. Text containing newlines is split into several lines |
{"replace": "<lineId>", "text": "..."} |
Replace a line. Multi-line text is rejected |
{"delete": "<lineId>"} |
Delete a line |
Create
Omitting --page-id creates a new page. The text of the first line becomes the page title.
A status of create means a new page, update means an existing page is updated.
Lines marked with > are the ones being inserted, and the ID on the right is the
line ID generated by the client.
When creating a page, the line ID of the first line becomes the page ID.
If a page with the same title already exists, _2 is appended to the title and the
text of the first line is rewritten at this point, without waiting for the submit.
$ echo '{"ops":[{"insertBefore":"_end","text":"シンプルな新規ページ"}]}' \
| sbc edit-preview my-project
previewId: 6a784f6497b7c9f8474230ea
expireAt: 2026-08-09T10:04:00.687Z
status: create
title: シンプルな新規ページ
page (after apply):
> シンプルな新規ページ # 1f777fb354af9527c1583d2e
$ sbc edit-submit my-project 6a784f6497b7c9f8474230ea
commitId: 6a7847a7021948351af3e9ed
pageId: 1f777fb354af9527c1583d2e
title: シンプルな新規ページ
url: https://scrapbox.io/my-project/シンプルな新規ページ
Edit
Pass the ID of the target page to --page-id. The ops are applied in array order.
A line ID used as an anchor must exist at the moment its op is applied.
$ cat edit.json
{
"ops": [
{"replace": "6a78194f00000000007455fe", "text": "書き換えた行"},
{"insertBefore": "_end", "text": "末尾に足した行"}
]
}
$ sbc edit-preview my-project --page-id 6a78192b3a6ddc39bdf42b47 --input-file edit.json
previewId: 6a7850835d9cbe48c6602555
expireAt: 2026-08-09T10:08:47.865Z
status: update
title: test
page (after apply):
test
書き換えた行
[https://scrapbox.io/files/6a781e51d393133856f18a12.png]
> 末尾に足した行 # 26a76acbe1093acdc2c1ca37
Delete
Deleting a page is done from the browser UI.
See: https://helpfeel.com/help/--67e0bedcc6d6e5bea3a235b8
Library
Overview
from scrapbox.client import ScrapboxClient
PROJECT_NAME = "help-jp"
PAGE_TITLE = "ブラケティング"
# A public project can be accessed without authentication
with ScrapboxClient() as client:
# Get the page list
pages = client.get_pages(PROJECT_NAME, skip=0, limit=5)
print(f"Project: {pages.project_name}")
print(f"Total pages: {pages.count}")
print()
print("First 5 pages:")
for page in pages.pages:
print(f" - {page.title} (views: {page.views})")
print()
print()
# Get the details of an individual page
print("Get page details:")
page_detail = client.get_page(PROJECT_NAME, PAGE_TITLE)
print(f"Title: {page_detail.title}")
print(f"Lines: {page_detail.lines_count}")
print(f"Characters: {page_detail.chars_count}")
print(f"First 5 lines:")
for line in page_detail.lines[:5]:
print(f" {line.text}")
print()
print()
# Get the text of the page
print("Page text:")
text = client.get_page_text(PROJECT_NAME, PAGE_TITLE)
print(text[:200] + "...")
print()
print()
# Get the icon URL
print("Icon URL:")
icon_url = client.get_page_icon_url(PROJECT_NAME, PAGE_TITLE)
print(icon_url)
print()
print()
# A private project is accessed with authentication
# A personal access token is issued from the Cosense settings page
print("=== Example with authentication ===")
pat = "pat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
with ScrapboxClient(pat=pat) as client:
try:
pages = client.get_pages("your-private-pj", limit=3)
print(f"Project: {pages.project_name}")
for page in pages.pages:
print(f" - {page.title}")
except Exception as e:
print(f"Error: {e}")
Page size limit
Walk a project too large for one page with skip, or use sbc all-pages.
from scrapbox import ScrapboxClient
from scrapbox.client import MAX_PAGE_SIZE # 1000
with ScrapboxClient() as client:
client.get_pages("help-jp", limit=1001)
# ValueError: limit must be between 1 and 1000, got 1001
client.iter_links_1hop("help-jp", "ブラケティング", per_page=0)
# ValueError: per_page must be between 1 and 1000, got 0
pages = client.get_pages("help-jp", limit=MAX_PAGE_SIZE) # OK
Search and traversal
from scrapbox.client import ScrapboxClient
with ScrapboxClient() as client:
# Full-text search. Pass match_any=True to return the pages matching any
# of the words.
result = client.search_pages("help-jp", "リンク 検索", match_any=True)
for page in result.pages:
print(page.title, page.words)
# Vector search over page titles and the link notations in page bodies.
similar = client.search_titles_by_vector("help-jp", "ページを繋げる")
for page in similar.pages:
print(f"{page.score:.3f} {page.title}")
# The 1-hop and 2-hop neighbourhoods. They can be narrowed with a query.
for page in client.get_links_1hop("help-jp", "ブラケティング").links1hop:
print(page.title, page.linked, page.page_rank)
print(len(client.get_links_2hop("help-jp", "ブラケティング").links2hop))
# One response holds at most 1000 neighbours. iter_links_* follows the
# cursor on its own, yielding one page at a time as they are consumed.
for page in client.iter_links_1hop("help-jp", "ブラケティング"):
print(page.title)
# A single project, with its settings and member list. No authentication
# is needed for a public project.
project = client.get_project("help-jp")
print(project.display_name, project.theme, len(project.users))
# The v2 page endpoint carries the normalized *_lc fields.
page_v2 = client.get_page_v2("help-jp", "ブラケティング")
print(page_v2.links_lc, page_v2.icons_lc)
# The member list, for resolving an author id to a name. Departed members
# and service accounts are listed separately.
members = client.get_project_users("help-jp")
print([member.name for member in members.users])
The vector search answers HTTP 490 while it is being updated, so it has to be retried after a while.
from scrapbox import ScrapboxClient, SearchServerUpdatingError
with ScrapboxClient() as client:
try:
client.search_titles_by_vector("help-jp", "リンク")
except SearchServerUpdatingError:
... # try again later
get_me() raises a NotAuthenticatedError when no credential was accepted.
That endpoint does not answer 401.
Without a credential it answers 200 with {"isGuest": true} and no user at all,
so being logged out has to be read out of the body.
Editing a page
Editing is a two-step flow.
Preview the change first, then submit the preview id it returns.
A preview is a dry run that writes nothing, expires after a few minutes, and can
only be submitted once.
A connect.sid cookie is rejected.
from scrapbox import ScrapboxClient, changes_from_ops
with ScrapboxClient(pat="pat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") as client:
changes = changes_from_ops([{"insertBefore": "_end", "text": "a new line"}])
preview = client.preview_page_edit("my-project", changes, page_id="<pageId>")
print(preview.preview_id, preview.expire_at)
for line in preview.page_preview.lines:
print(line.text)
result = client.submit_page_edit("my-project", preview.preview_id)
print(result.commit_id, result.page.id, result.page.title)
Files and history
from scrapbox.client import ScrapboxClient
with ScrapboxClient(pat="pat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") as client:
# Metadata of a file and the text extracted from it (OCR of an image, body of a PDF)
info = client.get_file_info("60190edf1176d9001c13f8e8.png")
print(info.originalname, info.content_type, info.size, info.text)
# The scaled down version of an image
thumb = client.get_file("60190edf1176d9001c13f8e8.png", thumbnail=True)
# The edit history of a page. Keyed by page id, so it survives a rename.
# Pass since= to get only what changed after a commit you already know.
for commit in client.get_commits("my-project", "<pageId>").commits:
print(commit.id, commit.user_id, commit.changes)
# The authenticated user and the projects they belong to
print(client.get_me().name)
print([project.name for project in client.get_projects().projects])
Authentication
A private project can be accessed with a
Personal Access Token, a
Service Account Access Key or a
connect.sid cookie.
from scrapbox.client import ScrapboxClient
with ScrapboxClient(pat="pat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") as client:
pages = client.get_pages("your-private-pj", limit=3)
with ScrapboxClient(service_account_key="cs_xxxxxxxxxxxxxxxxxxxx") as client:
pages = client.get_pages("your-business-pj", limit=3)
with ScrapboxClient(connect_sid="s%3AykQ__xxxxx-.xxxxx") as client:
pages = client.get_pages("your-private-pj", limit=3)
A service account comes with the following limits.
- It is registered on one project of a Business plan and can only operate on that project.
- It stands for no particular user, so
get_me(),get_projects()andget_project()are not available to it. - Unlike the other credentials, a project's IP address restrictions do not apply to it.
connect.sid cannot be used for preview_page_edit() and submit_page_edit().
Images
from scrapbox.client import ScrapboxClient
with ScrapboxClient() as client:
# Get an image by its file ID
file_id = "1a2b3c4d5e6f7g8h9i0j.JPG"
print(f"Fetching file: {file_id}")
try:
image_data = client.get_file(file_id)
print(f"Successfully fetched: {len(image_data)} bytes")
# Save it to a file
output_path = "downloaded_image.jpg"
with open(output_path, "wb") as f:
f.write(image_data)
print(f"Saved: {output_path}")
except Exception as e:
print(f"Error: {e}")
print()
# It can also be fetched with a full URL
print("Fetch with full URL:")
try:
full_url = "https://gyazo.com/da78df293f9e83a74b5402411e2f2e01"
image_data2 = client.get_file(full_url)
print(f"Successfully fetched: {len(image_data2)} bytes")
except Exception as e:
print(f"Error: {e}")
License
MIT
1""".. include:: ../README.md""" # noqa: D415 2 3import importlib.metadata 4 5from .client import ScrapboxClient 6from .edits import changes_from_ops, new_line_id 7from .exceptions import ( 8 NotAuthenticatedError, 9 PersonalAccessTokenRequiredError, 10 ScrapboxError, 11 SearchServerUpdatingError, 12) 13from .models import ( 14 Commit, 15 CommitsResponse, 16 EditPreviewResponse, 17 EditSubmitResponse, 18 FileInfo, 19 GyazoOEmbedResponse, 20 Line, 21 LinkPage, 22 Links1hopResponse, 23 Links2hopResponse, 24 Me, 25 PageDetail, 26 PageDetailV2, 27 PageListItem, 28 PageListResponse, 29 Project, 30 ProjectDetail, 31 ProjectsResponse, 32 ProjectUsersResponse, 33 SearchResponse, 34 User, 35 VectorSearchResponse, 36) 37 38try: 39 __version__ = importlib.metadata.version(__name__) 40except importlib.metadata.PackageNotFoundError: 41 __version__ = "0.0.0" 42 43__all__ = ( 44 "Commit", 45 "CommitsResponse", 46 "EditPreviewResponse", 47 "EditSubmitResponse", 48 "FileInfo", 49 "GyazoOEmbedResponse", 50 "Line", 51 "LinkPage", 52 "Links1hopResponse", 53 "Links2hopResponse", 54 "Me", 55 "NotAuthenticatedError", 56 "PageDetail", 57 "PageDetailV2", 58 "PageListItem", 59 "PageListResponse", 60 "PersonalAccessTokenRequiredError", 61 "Project", 62 "ProjectDetail", 63 "ProjectUsersResponse", 64 "ProjectsResponse", 65 "ScrapboxClient", 66 "ScrapboxError", 67 "SearchResponse", 68 "SearchServerUpdatingError", 69 "User", 70 "VectorSearchResponse", 71 "changes_from_ops", 72 "new_line_id", 73)
455class Commit(ScrapboxModel): 456 """A commit in the history of a page.""" 457 458 id: str 459 kind: str | None = None 460 changes: list[PageChange] = Field(default_factory=list) 461 parent_id: str | None = None 462 page_id: str | None = None 463 user_id: str | None = None 464 created: int | None = None
A commit in the history of a page.
467class CommitsResponse(ScrapboxModel): 468 """Response from the commit history API.""" 469 470 commits: list[Commit] = Field(default_factory=list)
Response from the commit history API.
503class EditPreviewResponse(ScrapboxModel): 504 """Response from the page edit preview API. 505 506 The preview is a dry run: nothing is written until `preview_id` is submitted, 507 and it expires a few minutes after it is issued. 508 """ 509 510 preview_id: str 511 expire_at: str | None = None 512 page_preview: PagePreview | None = None
Response from the page edit preview API.
The preview is a dry run: nothing is written until preview_id is submitted,
and it expires a few minutes after it is issued.
538class EditSubmitResponse(ScrapboxModel): 539 """Response from the page edit submit API.""" 540 541 commit_id: str 542 page: SubmittedPage | None = None
Response from the page edit submit API.
473class FileInfo(ScrapboxModel): 474 """Metadata of a file uploaded to a project.""" 475 476 id: str 477 project_name: str | None = None 478 text: str | None = None 479 """Text extracted from the file (OCR of an image, body of a PDF), truncated by the API.""" 480 originalname: str | None = None 481 content_type: str | None = None 482 size: int | None = None
Metadata of a file uploaded to a project.
600class GyazoOEmbedResponse(RootModel[GyazoOEmbedResponsePhoto | GyazoOEmbedResponseVideo]): 601 """Response from the Gyazo oEmbed API. 602 603 See: https://gyazo.com/api/docs/image#oembed 604 """ 605 606 model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
Response from the Gyazo oEmbed API.
181class Line(ScrapboxModel): 182 """Line data in a page.""" 183 184 id: str 185 text: str 186 user_id: str = Field(alias="userId") 187 created: int 188 updated: int
Line data in a page.
191class LinkPage(ScrapboxModel): 192 """A page in the 1-hop or 2-hop neighbourhood of another page. 193 194 Which fields the API fills in varies between entries, so nearly everything is 195 optional here. 196 """ 197 198 id: str 199 title: str 200 title_lc: str | None = None 201 image: str | None = None 202 descriptions: list[str] = Field(default_factory=list) 203 links_lc: list[str] = Field(default_factory=list) 204 linked: int | None = None 205 page_rank: float | None = None 206 views: int | None = None 207 lines_count: int | None = None 208 chars_count: int | None = None 209 created: int | None = None 210 updated: int | None = None 211 accessed: int | None = None 212 last_accessed: int | None = None 213 user: User | None = None 214 last_update_user: User | None = None 215 users: list[User] = Field(default_factory=list) 216 infobox_definition: list[str] | None = None 217 infobox_disable_links: list[str] | None = None 218 infobox_result: list[InfoboxResult] | None = None 219 search: Any = None 220 """Search highlight information, present only when the request carried a query."""
A page in the 1-hop or 2-hop neighbourhood of another page.
Which fields the API fills in varies between entries, so nearly everything is optional here.
237class Links1hopResponse(ScrapboxModel): 238 """Response from the 1-hop related pages API.""" 239 240 links1hop: list[LinkPage] = Field(default_factory=list, alias="links1hop") 241 chars_count: int | None = None 242 has_back_links_or_icons: bool | None = None 243 kcs_control_tags_lc: list[str] = Field(default_factory=list) 244 synonyms: list[Any] = Field(default_factory=list) 245 search_backend: str | None = None 246 pagination: Pagination | None = None
Response from the 1-hop related pages API.
249class Links2hopResponse(ScrapboxModel): 250 """Response from the 2-hop related pages API. 251 252 The direct 1-hop neighbourhood is not included. 253 """ 254 255 links2hop: list[LinkPage] = Field(default_factory=list, alias="links2hop") 256 hidden_headwords_lc: list[str] = Field(default_factory=list) 257 synonyms: list[Any] = Field(default_factory=list) 258 search_backend: str | None = None 259 pagination: Pagination | None = None
Response from the 2-hop related pages API.
The direct 1-hop neighbourhood is not included.
48class Me(User): 49 """The authenticated user, as returned by the `users/me` endpoint.""" 50 51 provider: str | None = None 52 page_filters: list[PageFilter] = Field(default_factory=list) 53 created: int | None = None 54 updated: int | None = None 55 is_guest: bool | None = None 56 config: dict[str, Any] = Field(default_factory=dict)
The authenticated user, as returned by the users/me endpoint.
31class NotAuthenticatedError(ScrapboxError): 32 """Raised when an endpoint answers as if no one is logged in. 33 34 `users/me` does not answer 401 without a credential: it answers 200 with 35 `{"isGuest": true}` and nothing else, so the absence of a credential has to be 36 read out of the body rather than the status code. 37 """ 38 39 def __init__(self) -> None: 40 """Initialize the error.""" 41 super().__init__( 42 "Not authenticated. Pass pat= or connect_sid= to ScrapboxClient.", 43 )
Raised when an endpoint answers as if no one is logged in.
users/me does not answer 401 without a credential: it answers 200 with
{"isGuest": true} and nothing else, so the absence of a credential has to be
read out of the body rather than the status code.
324class PageDetail(PageBase): 325 """Detailed information about a page, from the v1 endpoint.""" 326 327 related_pages: RelatedPages | None = None
Detailed information about a page, from the v1 endpoint.
330class PageDetailV2(PageBase): 331 """Detailed information about a page, from the v2 endpoint. 332 333 Compared with `PageDetail` this carries the normalized `*_lc` variants but no 334 embedded related pages. 335 """ 336 337 links_lc: list[str] = Field(default_factory=list) 338 icons_lc: list[str] = Field(default_factory=list) 339 project_links_lc: list[str] = Field(default_factory=list)
Detailed information about a page, from the v2 endpoint.
Compared with PageDetail this carries the normalized *_lc variants but no
embedded related pages.
151class PageListItem(ScrapboxModel): 152 """An item in the page list.""" 153 154 id: str 155 title: str 156 image: str | None = None 157 descriptions: list[str] 158 user: User 159 last_update_user: User | None = None 160 pin: int 161 views: int 162 linked: int 163 created: int 164 updated: int 165 accessed: int 166 lines_count: int = Field(alias="linesCount") 167 chars_count: int = Field(alias="charsCount") 168 helpfeels: list[str]
An item in the page list.
171class PageListResponse(ScrapboxModel): 172 """Response from the page list API.""" 173 174 project_name: str = Field(alias="projectName") 175 skip: int 176 limit: int 177 count: int 178 pages: list[PageListItem]
Response from the page list API.
9class PersonalAccessTokenRequiredError(ScrapboxError): 10 """Raised when a write endpoint is called without a credential it accepts. 11 12 The page editing endpoints (`page-edit-for-ai/preview` and 13 `page-edit-for-ai/submit`) reject `connect.sid` cookie authentication with 14 HTTP 403, so the client refuses to send the request in the first place. They do 15 accept a service account access key, which writes as the service account. 16 """ 17 18 def __init__(self, endpoint: str) -> None: 19 """Initialize the error. 20 21 Args: 22 endpoint: Path of the endpoint that needs a header credential. 23 """ 24 super().__init__( 25 f"{endpoint} needs a personal access token or a service account access key. " 26 f"Pass pat= or service_account_key= to ScrapboxClient." 27 ) 28 self.endpoint = endpoint
Raised when a write endpoint is called without a credential it accepts.
The page editing endpoints (page-edit-for-ai/preview and
page-edit-for-ai/submit) reject connect.sid cookie authentication with
HTTP 403, so the client refuses to send the request in the first place. They do
accept a service account access key, which writes as the service account.
18 def __init__(self, endpoint: str) -> None: 19 """Initialize the error. 20 21 Args: 22 endpoint: Path of the endpoint that needs a header credential. 23 """ 24 super().__init__( 25 f"{endpoint} needs a personal access token or a service account access key. " 26 f"Pass pat= or service_account_key= to ScrapboxClient." 27 ) 28 self.endpoint = endpoint
Initialize the error.
Arguments:
- endpoint: Path of the endpoint that needs a header credential.
93class Project(ScrapboxModel): 94 """A project the authenticated user belongs to.""" 95 96 id: str 97 name: str 98 display_name: str | None = None 99 public_visible: bool | None = None 100 login_strategies: list[str] = Field(default_factory=list) 101 plan: str | None = None 102 additional_plans: dict[str, bool] = Field(default_factory=dict) 103 alert: dict[str, Any] | None = None 104 users_count: int | None = None 105 is_member: bool | None = None 106 billing_id: str | None = None 107 created: int | None = None 108 updated: int | None = None 109 is_owner: bool | None = None 110 is_admin: bool | None = None 111 admins_count: int | None = None
A project the authenticated user belongs to.
120class ProjectDetail(Project): 121 """A single project, as returned by the project detail API. 122 123 Compared with the entries of the project list, this carries the project's own 124 settings and its member list, but not the counters (`users_count`, 125 `admins_count`) that only the list fills in. 126 """ 127 128 theme: str | None = None 129 image: str | None = None 130 gyazo_teams_name: str | None = None 131 translation: bool | None = None 132 infobox: bool | None = None 133 disable_realtime_collaboration: bool | None = None 134 users: list[User] = Field(default_factory=list) 135 """Members of the project. A public project answers with `id` and `name` alone."""
A single project, as returned by the project detail API.
Compared with the entries of the project list, this carries the project's own
settings and its member list, but not the counters (users_count,
admins_count) that only the list fills in.
80class ProjectUsersResponse(ScrapboxModel): 81 """Response from the project members API. 82 83 A page or line author may be a current member, a departed one or a service 84 account, so all four lists are needed to resolve an author id to a name. 85 """ 86 87 users: list[ProjectMember] = Field(default_factory=list) 88 member_snapshots: list[MemberSnapshot] = Field(default_factory=list) 89 service_accounts: list[ServiceAccount] = Field(default_factory=list) 90 service_account_snapshots: list[ServiceAccount] = Field(default_factory=list)
Response from the project members API.
A page or line author may be a current member, a departed one or a service account, so all four lists are needed to resolve an author id to a name.
114class ProjectsResponse(ScrapboxModel): 115 """Response from the project list API.""" 116 117 projects: list[Project] = Field(default_factory=list)
Response from the project list API.
145class ScrapboxClient: 146 """Scrapbox API client. 147 148 This client provides methods to interact with the Scrapbox API, 149 including retrieving page lists, page details, page text, and files. 150 """ 151 152 """Base URL for the Scrapbox API.""" 153 BASE_URL = f"{SCRAPBOX_ORIGIN}/api" 154 155 def __init__( 156 self, 157 connect_sid: str | None = None, 158 pat: str | None = None, 159 service_account_key: str | None = None, 160 transport: httpx2.BaseTransport | None = None, 161 ) -> None: 162 """Initialize the Scrapbox API client. 163 164 Authentication is optional for public projects. Only one credential is ever 165 sent: given more than one, a personal access token wins over a service 166 account key, which in turn wins over a cookie. 167 168 Args: 169 connect_sid: Scrapbox authentication cookie (connect.sid). 170 pat: Scrapbox personal access token, sent as the `x-personal-access-token` 171 header. 172 service_account_key: Access key of a service account, sent as the 173 `x-service-account-access-key` header. A service account is registered 174 on one project of a Business plan and can read and write only that 175 one: any other project, even a public one, answers 400. It stands for 176 no user, so `get_me` and `get_projects` are out of its reach, and 177 `get_project` refuses it as well. 178 transport: Transport used by the underlying HTTP client. Intended for 179 tests, which pass an `httpx2.MockTransport` so that header handling 180 is still exercised. 181 """ 182 self.pat = pat 183 self.service_account_key = None if pat else service_account_key 184 self.connect_sid = None if pat or service_account_key else connect_sid 185 self.client = httpx2.Client( 186 cookies={"connect.sid": self.connect_sid} if self.connect_sid else None, 187 follow_redirects=True, 188 transport=transport, 189 ) 190 if self.pat or self.service_account_key: 191 # Attach the credential per request instead of as a default header: 192 # get_file() follows redirects to third-party hosts (Gyazo), which must 193 # not receive it. 194 self.client.event_hooks["request"].append(self._attach_credential) 195 196 def _attach_credential(self, request: httpx2.Request) -> None: 197 """Attach the header credential to requests sent to Scrapbox. 198 199 Args: 200 request: The outgoing request. 201 """ 202 if request.url.host != SCRAPBOX_HOST: 203 return 204 if self.pat: 205 request.headers[PAT_HEADER] = self.pat 206 elif self.service_account_key: 207 request.headers[SERVICE_ACCOUNT_HEADER] = self.service_account_key 208 209 def __enter__(self: Self) -> Self: 210 """Enter the runtime context related to this object.""" 211 return self 212 213 def __exit__( 214 self, 215 typ: type[BaseException] | None, 216 exc: BaseException | None, 217 tb: TracebackType | None, 218 /, 219 ) -> None: 220 """Exit the runtime context related to this object.""" 221 self.client.close() 222 223 def close(self) -> None: 224 """Close the HTTP client.""" 225 self.client.close() 226 227 @staticmethod 228 def _raise_for_status(response: httpx2.Response) -> None: 229 """Turn an error response into an exception. 230 231 The status code alone rarely says what went wrong -- a service account asked 232 for the wrong project gets a bare 400 -- so the API's own explanation is 233 carried into the error message. 234 235 Args: 236 response: The response to inspect. 237 238 Raises: 239 SearchServerUpdatingError: If the search backend is being updated. 240 httpx2.HTTPStatusError: If the response carries any other error status. 241 """ 242 if response.status_code == SearchServerUpdatingError.STATUS_CODE: 243 # The name adds nothing here: the exception class already carries it. 244 try: 245 message = response.json().get("message") 246 except ValueError: 247 message = None 248 raise SearchServerUpdatingError(message) 249 try: 250 response.raise_for_status() 251 except httpx2.HTTPStatusError as e: 252 detail = error_detail(response) 253 if detail is None: 254 raise 255 msg = f"{e}\n{detail}" 256 raise httpx2.HTTPStatusError(msg, request=e.request, response=e.response) from None 257 258 def _get(self, path: str, params: Mapping[str, Any] | None = None) -> httpx2.Response: 259 """Send a GET request to a path under `BASE_URL`. 260 261 Args: 262 path: Path below `BASE_URL`, starting with a slash. 263 params: Query parameters. Entries are sent as given. 264 265 Returns: 266 The successful response. 267 """ 268 response = self.client.get(f"{self.BASE_URL}{path}", params=params) 269 self._raise_for_status(response) 270 return response 271 272 def _get_json(self, path: str, params: Mapping[str, Any] | None = None) -> Any: # noqa: ANN401 273 """Send a GET request and decode the JSON body. 274 275 Args: 276 path: Path below `BASE_URL`, starting with a slash. 277 params: Query parameters. Entries are sent as given. 278 279 Returns: 280 The decoded JSON body. 281 """ 282 return self._get(path, params).json() 283 284 def _post_json(self, path: str, payload: Mapping[str, Any]) -> Any: # noqa: ANN401 285 """Send a POST request and decode the JSON body. 286 287 Write endpoints take a header credential -- a personal access token or a 288 service account access key -- and refuse a cookie with HTTP 403, so the 289 request is not even sent without one. 290 291 Args: 292 path: Path below `BASE_URL`, starting with a slash. 293 payload: JSON body to send. 294 295 Returns: 296 The decoded JSON body. 297 298 Raises: 299 PersonalAccessTokenRequiredError: If neither a personal access token nor a 300 service account access key is set. 301 """ 302 if not (self.pat or self.service_account_key): 303 raise PersonalAccessTokenRequiredError(path) 304 response = self.client.post(f"{self.BASE_URL}{path}", json=dict(payload)) 305 self._raise_for_status(response) 306 return response.json() 307 308 def get_pages( 309 self, 310 project_name: str, 311 skip: int = 0, 312 limit: int = 100, 313 sort: PageSort | None = None, 314 filter_value: str | None = None, 315 ) -> PageListResponse: 316 """Get a list of pages from a project. 317 318 The page bodies are not included. At most 1000 pages come back per request, 319 so `skip` is how a whole project is walked. 320 321 Args: 322 project_name: The name of the project. 323 skip: Number of pages to skip (default: 0). 324 limit: Number of pages to retrieve (default: 100, max: 1000). 325 sort: Order of the returned pages (default: the API's own, `updated`). 326 filter_value: Keep only the pages carrying a `[<value>.icon]` reference, 327 plus the pages that user has edited. Use the login name, not the 328 display name. 329 330 Returns: 331 PageListResponse: The response containing the page list. 332 333 Raises: 334 ValueError: If `limit` is outside 1 to `MAX_PAGE_SIZE`. 335 """ 336 check_page_size(limit, "limit") 337 params: dict[str, Any] = {"skip": skip, "limit": limit} 338 if sort is not None: 339 params["sort"] = sort 340 if filter_value is not None: 341 params["filterType"] = "icon" 342 params["filterValue"] = filter_value 343 return PageListResponse.model_validate(self._get_json(f"/pages/{project_name}", params)) 344 345 def get_page(self, project_name: str, page_title: str) -> PageDetail: 346 """Get detailed information about a specific page. 347 348 Args: 349 project_name: The name of the project. 350 page_title: The title of the page. 351 352 Returns: 353 PageDetail: The detailed information about the page. 354 """ 355 encoded_title = quote(page_title, safe="") 356 return PageDetail.model_validate(self._get_json(f"/pages/{project_name}/{encoded_title}")) 357 358 def get_page_v2(self, project_name: str, page_title: str) -> PageDetailV2: 359 """Get detailed information about a page from the v2 endpoint. 360 361 Compared with `get_page`, this carries the normalized `links_lc` /`icons_lc` / 362 `project_links_lc` fields but no embedded related pages; use `get_links_1hop` 363 and `get_links_2hop` for those. 364 365 Args: 366 project_name: The name of the project. 367 page_title: The title of the page. 368 369 Returns: 370 PageDetailV2: The detailed information about the page. 371 """ 372 encoded_title = quote(page_title, safe="") 373 return PageDetailV2.model_validate(self._get_json(f"/pages/v2/{project_name}/{encoded_title}")) 374 375 def get_links_1hop( # noqa: PLR0913 - one parameter per query parameter the endpoint takes 376 self, 377 project_name: str, 378 page_title: str, 379 search: str | None = None, 380 *, 381 match_any: bool = False, 382 per_page: int | None = None, 383 next_id: str | None = None, 384 ) -> Links1hopResponse: 385 """Get one page of the 1-hop neighbourhood of a page. 386 387 Use `iter_links_1hop` to walk a neighbourhood larger than one page. 388 389 Args: 390 project_name: The name of the project. 391 page_title: The title of the page. 392 search: Keep only the neighbours whose body matches this query. 393 match_any: Match pages containing any of the words in `search` instead of 394 all of them. 395 per_page: Number of neighbours per page (default: the API's own, 1000). 396 Must be between 1 and `MAX_PAGE_SIZE`. 397 next_id: Continue after this entry, as reported by `pagination.next_id` 398 of the previous page. 399 400 Returns: 401 Links1hopResponse: The neighbouring pages. 402 403 Raises: 404 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 405 """ 406 encoded_title = quote(page_title, safe="") 407 return Links1hopResponse.model_validate( 408 self._get_json( 409 f"/pages/v2/{project_name}/{encoded_title}/links1hop", 410 self._related_params(search, match_any=match_any, per_page=per_page, next_id=next_id), 411 ) 412 ) 413 414 def get_links_2hop( # noqa: PLR0913 - one parameter per query parameter the endpoint takes 415 self, 416 project_name: str, 417 page_title: str, 418 search: str | None = None, 419 *, 420 match_any: bool = False, 421 per_page: int | None = None, 422 next_id: str | None = None, 423 ) -> Links2hopResponse: 424 """Get one page of the 2-hop neighbourhood of a page. 425 426 The direct 1-hop neighbours are not included. Use `iter_links_2hop` to walk a 427 neighbourhood larger than one page. 428 429 Args: 430 project_name: The name of the project. 431 page_title: The title of the page. 432 search: Keep only the neighbours whose body matches this query. 433 match_any: Match pages containing any of the words in `search` instead of 434 all of them. 435 per_page: Number of neighbours per page (default: the API's own, 1000). 436 Must be between 1 and `MAX_PAGE_SIZE`. 437 next_id: Continue after this entry, as reported by `pagination.next_id` 438 of the previous page. 439 440 Returns: 441 Links2hopResponse: The neighbouring pages. 442 443 Raises: 444 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 445 """ 446 encoded_title = quote(page_title, safe="") 447 return Links2hopResponse.model_validate( 448 self._get_json( 449 f"/pages/v2/{project_name}/{encoded_title}/links2hop", 450 self._related_params(search, match_any=match_any, per_page=per_page, next_id=next_id), 451 ) 452 ) 453 454 def iter_links_1hop( 455 self, 456 project_name: str, 457 page_title: str, 458 search: str | None = None, 459 *, 460 match_any: bool = False, 461 per_page: int | None = None, 462 ) -> Iterator[LinkPage]: 463 """Iterate over the whole 1-hop neighbourhood of a page. 464 465 A single response holds at most 1000 neighbours, so a larger neighbourhood is 466 walked with the cursor the API reports. Pages are fetched as they are consumed. 467 468 Args: 469 project_name: The name of the project. 470 page_title: The title of the page. 471 search: Keep only the neighbours whose body matches this query. 472 match_any: Match pages containing any of the words in `search` instead of 473 all of them. 474 per_page: Number of neighbours fetched per request (default: the API's 475 own, 1000). Must be between 1 and `MAX_PAGE_SIZE`. 476 477 Returns: 478 An iterator over each neighbouring page, in the order the API returns 479 them. 480 481 Raises: 482 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 483 """ 484 if per_page is not None: 485 check_page_size(per_page, "per_page") 486 return self._iter_links( 487 lambda next_id: self.get_links_1hop( 488 project_name, 489 page_title, 490 search, 491 match_any=match_any, 492 per_page=per_page, 493 next_id=next_id, 494 ), 495 lambda response: response.links1hop, 496 ) 497 498 def iter_links_2hop( 499 self, 500 project_name: str, 501 page_title: str, 502 search: str | None = None, 503 *, 504 match_any: bool = False, 505 per_page: int | None = None, 506 ) -> Iterator[LinkPage]: 507 """Iterate over the whole 2-hop neighbourhood of a page. 508 509 The direct 1-hop neighbours are not included. 510 511 Args: 512 project_name: The name of the project. 513 page_title: The title of the page. 514 search: Keep only the neighbours whose body matches this query. 515 match_any: Match pages containing any of the words in `search` instead of 516 all of them. 517 per_page: Number of neighbours fetched per request (default: the API's 518 own, 1000). Must be between 1 and `MAX_PAGE_SIZE`. 519 520 Returns: 521 An iterator over each neighbouring page, in the order the API returns 522 them. 523 524 Raises: 525 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 526 """ 527 if per_page is not None: 528 check_page_size(per_page, "per_page") 529 return self._iter_links( 530 lambda next_id: self.get_links_2hop( 531 project_name, 532 page_title, 533 search, 534 match_any=match_any, 535 per_page=per_page, 536 next_id=next_id, 537 ), 538 lambda response: response.links2hop, 539 ) 540 541 @staticmethod 542 def _iter_links[T: Links1hopResponse | Links2hopResponse]( 543 fetch: Callable[[str | None], T], 544 entries: Callable[[T], list[LinkPage]], 545 ) -> Iterator[LinkPage]: 546 """Walk a related pages endpoint until its cursor runs out. 547 548 A page narrowed by `search` can come back with fewer entries than asked for, 549 or none at all, while the cursor still points further on: the filter applies 550 within a page rather than to the whole neighbourhood. Only `has_next` decides 551 whether to stop. 552 553 Args: 554 fetch: Fetches one page, given the cursor to continue from. 555 entries: Reads the neighbours out of a fetched page. 556 557 Yields: 558 LinkPage: Each neighbouring page. 559 """ 560 next_id: str | None = None 561 while True: 562 response = fetch(next_id) 563 yield from entries(response) 564 pagination = response.pagination 565 if pagination is None or not pagination.has_next or pagination.next_id is None: 566 return 567 if pagination.next_id == next_id: 568 # The cursor has stopped advancing; continuing would loop forever. 569 return 570 next_id = pagination.next_id 571 572 @staticmethod 573 def _related_params( 574 search: str | None, 575 *, 576 match_any: bool, 577 per_page: int | None = None, 578 next_id: str | None = None, 579 ) -> dict[str, Any]: 580 """Build the query parameters shared by the related pages endpoints. 581 582 Args: 583 search: Full-text query to filter the neighbours with. 584 match_any: Whether to match any word instead of all of them. 585 per_page: Number of neighbours per page. 586 next_id: Cursor to continue from. 587 588 Returns: 589 The query parameters to send. 590 591 Raises: 592 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 593 """ 594 params: dict[str, Any] = {} 595 if search is not None: 596 params["search"] = search 597 if match_any: 598 params["op"] = "or" 599 if per_page is not None: 600 params["perPage"] = check_page_size(per_page, "per_page") 601 if next_id is not None: 602 params["nextId"] = next_id 603 return params 604 605 def search_pages( 606 self, 607 project_name: str, 608 query: str, 609 *, 610 match_any: bool = False, 611 sort: SearchSort | None = None, 612 ) -> SearchResponse: 613 """Search the full text of the pages in a project. 614 615 Args: 616 project_name: The name of the project. 617 query: The search query. 618 match_any: Match pages containing any of the words instead of all of them. 619 sort: Order of the results (default: the API's own, `pageRank`). 620 621 Returns: 622 SearchResponse: The matching pages. 623 """ 624 params: dict[str, Any] = {"q": query} 625 if match_any: 626 params["op"] = "or" 627 if sort is not None: 628 params["sort"] = sort 629 return SearchResponse.model_validate(self._get_json(f"/pages/{project_name}/search/query", params)) 630 631 def search_titles_by_vector(self, project_name: str, query: str) -> VectorSearchResponse: 632 """Search pages by vector similarity. 633 634 Only page titles and the link notations in page bodies are searched; ordinary 635 body text is not. 636 637 Args: 638 project_name: The name of the project. 639 query: The search query. 640 641 Returns: 642 VectorSearchResponse: The matching pages, most similar first. 643 644 Raises: 645 SearchServerUpdatingError: If the search backend is temporarily updating. 646 Retrying later usually succeeds. 647 """ 648 return VectorSearchResponse.model_validate( 649 self._get_json(f"/pages/{project_name}/search/vector/titles", {"q": query}) 650 ) 651 652 def get_commits(self, project_name: str, page_id: str, since: str | None = None) -> CommitsResponse: 653 """Get the edit history of a page. 654 655 The history is keyed by page id rather than title, so it can be followed 656 across renames. 657 658 Args: 659 project_name: The name of the project. 660 page_id: The immutable id of the page. 661 since: Return only the commits after this commit id. Omit for the whole 662 history. 663 664 Returns: 665 CommitsResponse: The commits, oldest first. 666 """ 667 params = {"head": since} if since is not None else None 668 return CommitsResponse.model_validate(self._get_json(f"/commits/{project_name}/{page_id}", params)) 669 670 def get_project_users(self, project_name: str) -> ProjectUsersResponse: 671 """Get the members of a project. 672 673 Args: 674 project_name: The name of the project. 675 676 Returns: 677 ProjectUsersResponse: Current members, departed members and service accounts. 678 """ 679 return ProjectUsersResponse.model_validate(self._get_json(f"/projects/{project_name}/users")) 680 681 def get_projects(self) -> ProjectsResponse: 682 """Get the projects the authenticated user belongs to. 683 684 Requires authentication. 685 686 Returns: 687 ProjectsResponse: The projects. 688 """ 689 return ProjectsResponse.model_validate(self._get_json("/projects")) 690 691 def get_project(self, project_name: str) -> ProjectDetail: 692 """Get a single project by name. 693 694 Unlike `get_projects`, this needs no authentication for a public project, and 695 carries the project's settings and member list rather than the counters. 696 697 A service account is refused here with HTTP 401, even for the project it 698 belongs to, though `get_project_users` on that same project works. 699 700 Args: 701 project_name: The name of the project. 702 703 Returns: 704 ProjectDetail: The project. 705 """ 706 return ProjectDetail.model_validate(self._get_json(f"/projects/{project_name}")) 707 708 def get_me(self) -> Me: 709 """Get the authenticated user. 710 711 Requires authentication. The `name` shown here, not `display_name`, is what 712 `get_pages(filter_value=...)` expects. 713 714 Returns: 715 Me: The authenticated user. 716 717 Raises: 718 NotAuthenticatedError: If no credential was accepted. This endpoint does 719 not answer 401: without one it answers 200 with `{"isGuest": true}`, 720 which carries no user to return. 721 """ 722 payload = self._get_json("/users/me") 723 if "id" not in payload: 724 raise NotAuthenticatedError 725 return Me.model_validate(payload) 726 727 def get_file_info(self, file_id: str) -> FileInfo: 728 """Get the metadata of a file uploaded to a project. 729 730 Args: 731 file_id: The file id, optionally with an extension, or the full file URL. 732 733 Returns: 734 FileInfo: The metadata, including any text extracted from the file. 735 """ 736 return FileInfo.model_validate(self._get_json(f"/gcs/{bare_file_id(file_id)}/info")) 737 738 def preview_page_edit( 739 self, 740 project_name: str, 741 changes: Sequence[PageChange], 742 page_id: str | None = None, 743 ) -> EditPreviewResponse: 744 """Dry-run an edit and get a preview id for it. 745 746 Nothing is written until the returned preview id is passed to 747 `submit_page_edit`, and the preview expires a few minutes after it is issued. 748 Use `scrapbox.edits.changes_from_ops` to build `changes`. 749 750 Args: 751 project_name: The name of the project. 752 changes: The changes to apply, in order. 753 page_id: The id of the page to edit. Omit to create a new page. 754 755 Returns: 756 EditPreviewResponse: The preview id and the resulting page. 757 758 Raises: 759 PersonalAccessTokenRequiredError: If neither a personal access token nor a 760 service account access key is set. 761 """ 762 payload: dict[str, Any] = { 763 "changes": [ 764 change if isinstance(change, dict) else change.model_dump(by_alias=True, exclude_none=True) 765 for change in changes 766 ] 767 } 768 if page_id is not None: 769 payload["pageId"] = page_id 770 return EditPreviewResponse.model_validate( 771 self._post_json(f"/pages/v2/{project_name}/page-edit-for-ai/preview", payload) 772 ) 773 774 def submit_page_edit(self, project_name: str, preview_id: str) -> EditSubmitResponse: 775 """Commit an edit that was previewed earlier. 776 777 A preview id can only be submitted once, and the project must be the one the 778 preview was created for. 779 780 Args: 781 project_name: The name of the project. 782 preview_id: The preview id returned by `preview_page_edit`. 783 784 Returns: 785 EditSubmitResponse: The created commit and the page written to. 786 787 Raises: 788 PersonalAccessTokenRequiredError: If neither a personal access token nor a 789 service account access key is set. 790 """ 791 return EditSubmitResponse.model_validate( 792 self._post_json( 793 f"/pages/v2/{project_name}/page-edit-for-ai/submit", 794 {"previewId": preview_id}, 795 ) 796 ) 797 798 def get_page_text(self, project_name: str, page_title: str) -> str: 799 """Get the text content of a page. 800 801 Args: 802 project_name: The name of the project. 803 page_title: The title of the page. 804 805 Returns: 806 str: The text content of the page. 807 """ 808 encoded_title = quote(page_title, safe="") 809 return self._get(f"/pages/{project_name}/{encoded_title}/text").text 810 811 def get_page_icon_url(self, project_name: str, page_title: str) -> str: 812 """Get the icon image URL for a page. 813 814 This method returns the redirect destination URL of the page icon. 815 816 Args: 817 project_name: The name of the project. 818 page_title: The title of the page. 819 820 Returns: 821 str: The URL of the icon image. 822 """ 823 encoded_title = quote(page_title, safe="") 824 url = f"{self.BASE_URL}/pages/{project_name}/{encoded_title}/icon" 825 826 response = self.client.get(url, follow_redirects=False) 827 828 if response.status_code == httpx2.codes.FOUND: 829 return response.headers.get("location", "") 830 if response.status_code == httpx2.codes.OK: 831 return url 832 response.raise_for_status() 833 return url 834 835 def get_file(self, file_id: str, *, thumbnail: bool = False) -> bytes: 836 """Get a file uploaded to Scrapbox. 837 838 Args: 839 file_id: The file ID (e.g., "1a2b3c4d5e6f7g8h9i0j.JPG") 840 or full URL (e.g., "https://scrapbox.io/files/1a2b3c4d5e6f7g8h9i0j.JPG" 841 or "https://gyazo.com/1a2b3c4d5e6f7g8h9i0j1a2b3c4d5e6f"). 842 thumbnail: Fetch the scaled down version. Files that have no thumbnail 843 (anything but JPEG and PNG) come back at full size. Ignored for Gyazo 844 URLs, which are resolved through oEmbed instead. 845 846 Returns: 847 bytes: The binary data of the file. 848 """ 849 url = file_id if file_id.startswith(("http://", "https://")) else f"https://scrapbox.io/files/{file_id}" 850 851 parsed_url = urlparse(url) 852 is_gyazo = "gyazo.com" in (parsed_url.hostname or "") 853 params = {"type": "thumbnail"} if thumbnail and not is_gyazo else None 854 if is_gyazo: 855 # If URL already has a file extension (e.g., .mp4, .jpg), directly convert to i.gyazo.com 856 path = parsed_url.path.strip("/") 857 if "." in path.split("/")[-1]: # Check if last path segment has extension 858 url = f"https://i.gyazo.com/{path}" 859 else: 860 # Use oEmbed API to get the actual file URL 861 json = self._get_json("/oembed-proxy/gyazo", {"url": url}) 862 if (oembed_type := json.get("type")) not in ("photo", "video"): 863 msg = f"Unsupported Gyazo oEmbed type: {oembed_type}" 864 raise ValueError(msg) 865 oembed_data = GyazoOEmbedResponse.model_validate(json) 866 if isinstance(oembed_data.root, GyazoOEmbedResponsePhoto): 867 url = oembed_data.root.url 868 else: # video 869 # Extract Gyazo ID from the original URL and construct direct video URL 870 gyazo_id = parsed_url.path.strip("/") 871 url = f"https://i.gyazo.com/{gyazo_id}.mp4" 872 response = self.client.get(url, params=params) 873 response.raise_for_status() 874 875 return response.content
Scrapbox API client.
This client provides methods to interact with the Scrapbox API, including retrieving page lists, page details, page text, and files.
155 def __init__( 156 self, 157 connect_sid: str | None = None, 158 pat: str | None = None, 159 service_account_key: str | None = None, 160 transport: httpx2.BaseTransport | None = None, 161 ) -> None: 162 """Initialize the Scrapbox API client. 163 164 Authentication is optional for public projects. Only one credential is ever 165 sent: given more than one, a personal access token wins over a service 166 account key, which in turn wins over a cookie. 167 168 Args: 169 connect_sid: Scrapbox authentication cookie (connect.sid). 170 pat: Scrapbox personal access token, sent as the `x-personal-access-token` 171 header. 172 service_account_key: Access key of a service account, sent as the 173 `x-service-account-access-key` header. A service account is registered 174 on one project of a Business plan and can read and write only that 175 one: any other project, even a public one, answers 400. It stands for 176 no user, so `get_me` and `get_projects` are out of its reach, and 177 `get_project` refuses it as well. 178 transport: Transport used by the underlying HTTP client. Intended for 179 tests, which pass an `httpx2.MockTransport` so that header handling 180 is still exercised. 181 """ 182 self.pat = pat 183 self.service_account_key = None if pat else service_account_key 184 self.connect_sid = None if pat or service_account_key else connect_sid 185 self.client = httpx2.Client( 186 cookies={"connect.sid": self.connect_sid} if self.connect_sid else None, 187 follow_redirects=True, 188 transport=transport, 189 ) 190 if self.pat or self.service_account_key: 191 # Attach the credential per request instead of as a default header: 192 # get_file() follows redirects to third-party hosts (Gyazo), which must 193 # not receive it. 194 self.client.event_hooks["request"].append(self._attach_credential)
Initialize the Scrapbox API client.
Authentication is optional for public projects. Only one credential is ever sent: given more than one, a personal access token wins over a service account key, which in turn wins over a cookie.
Arguments:
- connect_sid: Scrapbox authentication cookie (connect.sid).
- pat: Scrapbox personal access token, sent as the
x-personal-access-tokenheader. - service_account_key: Access key of a service account, sent as the
x-service-account-access-keyheader. A service account is registered on one project of a Business plan and can read and write only that one: any other project, even a public one, answers 400. It stands for no user, soget_meandget_projectsare out of its reach, andget_projectrefuses it as well. - transport: Transport used by the underlying HTTP client. Intended for
tests, which pass an
httpx2.MockTransportso that header handling is still exercised.
308 def get_pages( 309 self, 310 project_name: str, 311 skip: int = 0, 312 limit: int = 100, 313 sort: PageSort | None = None, 314 filter_value: str | None = None, 315 ) -> PageListResponse: 316 """Get a list of pages from a project. 317 318 The page bodies are not included. At most 1000 pages come back per request, 319 so `skip` is how a whole project is walked. 320 321 Args: 322 project_name: The name of the project. 323 skip: Number of pages to skip (default: 0). 324 limit: Number of pages to retrieve (default: 100, max: 1000). 325 sort: Order of the returned pages (default: the API's own, `updated`). 326 filter_value: Keep only the pages carrying a `[<value>.icon]` reference, 327 plus the pages that user has edited. Use the login name, not the 328 display name. 329 330 Returns: 331 PageListResponse: The response containing the page list. 332 333 Raises: 334 ValueError: If `limit` is outside 1 to `MAX_PAGE_SIZE`. 335 """ 336 check_page_size(limit, "limit") 337 params: dict[str, Any] = {"skip": skip, "limit": limit} 338 if sort is not None: 339 params["sort"] = sort 340 if filter_value is not None: 341 params["filterType"] = "icon" 342 params["filterValue"] = filter_value 343 return PageListResponse.model_validate(self._get_json(f"/pages/{project_name}", params))
Get a list of pages from a project.
The page bodies are not included. At most 1000 pages come back per request,
so skip is how a whole project is walked.
Arguments:
- project_name: The name of the project.
- skip: Number of pages to skip (default: 0).
- limit: Number of pages to retrieve (default: 100, max: 1000).
- sort: Order of the returned pages (default: the API's own,
updated). - filter_value: Keep only the pages carrying a
[<value>.icon]reference, plus the pages that user has edited. Use the login name, not the display name.
Returns:
PageListResponse: The response containing the page list.
Raises:
- ValueError: If
limitis outside 1 toMAX_PAGE_SIZE.
345 def get_page(self, project_name: str, page_title: str) -> PageDetail: 346 """Get detailed information about a specific page. 347 348 Args: 349 project_name: The name of the project. 350 page_title: The title of the page. 351 352 Returns: 353 PageDetail: The detailed information about the page. 354 """ 355 encoded_title = quote(page_title, safe="") 356 return PageDetail.model_validate(self._get_json(f"/pages/{project_name}/{encoded_title}"))
Get detailed information about a specific page.
Arguments:
- project_name: The name of the project.
- page_title: The title of the page.
Returns:
PageDetail: The detailed information about the page.
358 def get_page_v2(self, project_name: str, page_title: str) -> PageDetailV2: 359 """Get detailed information about a page from the v2 endpoint. 360 361 Compared with `get_page`, this carries the normalized `links_lc` /`icons_lc` / 362 `project_links_lc` fields but no embedded related pages; use `get_links_1hop` 363 and `get_links_2hop` for those. 364 365 Args: 366 project_name: The name of the project. 367 page_title: The title of the page. 368 369 Returns: 370 PageDetailV2: The detailed information about the page. 371 """ 372 encoded_title = quote(page_title, safe="") 373 return PageDetailV2.model_validate(self._get_json(f"/pages/v2/{project_name}/{encoded_title}"))
Get detailed information about a page from the v2 endpoint.
Compared with get_page, this carries the normalized links_lc /icons_lc /
project_links_lc fields but no embedded related pages; use get_links_1hop
and get_links_2hop for those.
Arguments:
- project_name: The name of the project.
- page_title: The title of the page.
Returns:
PageDetailV2: The detailed information about the page.
375 def get_links_1hop( # noqa: PLR0913 - one parameter per query parameter the endpoint takes 376 self, 377 project_name: str, 378 page_title: str, 379 search: str | None = None, 380 *, 381 match_any: bool = False, 382 per_page: int | None = None, 383 next_id: str | None = None, 384 ) -> Links1hopResponse: 385 """Get one page of the 1-hop neighbourhood of a page. 386 387 Use `iter_links_1hop` to walk a neighbourhood larger than one page. 388 389 Args: 390 project_name: The name of the project. 391 page_title: The title of the page. 392 search: Keep only the neighbours whose body matches this query. 393 match_any: Match pages containing any of the words in `search` instead of 394 all of them. 395 per_page: Number of neighbours per page (default: the API's own, 1000). 396 Must be between 1 and `MAX_PAGE_SIZE`. 397 next_id: Continue after this entry, as reported by `pagination.next_id` 398 of the previous page. 399 400 Returns: 401 Links1hopResponse: The neighbouring pages. 402 403 Raises: 404 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 405 """ 406 encoded_title = quote(page_title, safe="") 407 return Links1hopResponse.model_validate( 408 self._get_json( 409 f"/pages/v2/{project_name}/{encoded_title}/links1hop", 410 self._related_params(search, match_any=match_any, per_page=per_page, next_id=next_id), 411 ) 412 )
Get one page of the 1-hop neighbourhood of a page.
Use iter_links_1hop to walk a neighbourhood larger than one page.
Arguments:
- project_name: The name of the project.
- page_title: The title of the page.
- search: Keep only the neighbours whose body matches this query.
- match_any: Match pages containing any of the words in
searchinstead of all of them. - per_page: Number of neighbours per page (default: the API's own, 1000).
Must be between 1 and
MAX_PAGE_SIZE. - next_id: Continue after this entry, as reported by
pagination.next_idof the previous page.
Returns:
Links1hopResponse: The neighbouring pages.
Raises:
- ValueError: If
per_pageis outside 1 toMAX_PAGE_SIZE.
414 def get_links_2hop( # noqa: PLR0913 - one parameter per query parameter the endpoint takes 415 self, 416 project_name: str, 417 page_title: str, 418 search: str | None = None, 419 *, 420 match_any: bool = False, 421 per_page: int | None = None, 422 next_id: str | None = None, 423 ) -> Links2hopResponse: 424 """Get one page of the 2-hop neighbourhood of a page. 425 426 The direct 1-hop neighbours are not included. Use `iter_links_2hop` to walk a 427 neighbourhood larger than one page. 428 429 Args: 430 project_name: The name of the project. 431 page_title: The title of the page. 432 search: Keep only the neighbours whose body matches this query. 433 match_any: Match pages containing any of the words in `search` instead of 434 all of them. 435 per_page: Number of neighbours per page (default: the API's own, 1000). 436 Must be between 1 and `MAX_PAGE_SIZE`. 437 next_id: Continue after this entry, as reported by `pagination.next_id` 438 of the previous page. 439 440 Returns: 441 Links2hopResponse: The neighbouring pages. 442 443 Raises: 444 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 445 """ 446 encoded_title = quote(page_title, safe="") 447 return Links2hopResponse.model_validate( 448 self._get_json( 449 f"/pages/v2/{project_name}/{encoded_title}/links2hop", 450 self._related_params(search, match_any=match_any, per_page=per_page, next_id=next_id), 451 ) 452 )
Get one page of the 2-hop neighbourhood of a page.
The direct 1-hop neighbours are not included. Use iter_links_2hop to walk a
neighbourhood larger than one page.
Arguments:
- project_name: The name of the project.
- page_title: The title of the page.
- search: Keep only the neighbours whose body matches this query.
- match_any: Match pages containing any of the words in
searchinstead of all of them. - per_page: Number of neighbours per page (default: the API's own, 1000).
Must be between 1 and
MAX_PAGE_SIZE. - next_id: Continue after this entry, as reported by
pagination.next_idof the previous page.
Returns:
Links2hopResponse: The neighbouring pages.
Raises:
- ValueError: If
per_pageis outside 1 toMAX_PAGE_SIZE.
454 def iter_links_1hop( 455 self, 456 project_name: str, 457 page_title: str, 458 search: str | None = None, 459 *, 460 match_any: bool = False, 461 per_page: int | None = None, 462 ) -> Iterator[LinkPage]: 463 """Iterate over the whole 1-hop neighbourhood of a page. 464 465 A single response holds at most 1000 neighbours, so a larger neighbourhood is 466 walked with the cursor the API reports. Pages are fetched as they are consumed. 467 468 Args: 469 project_name: The name of the project. 470 page_title: The title of the page. 471 search: Keep only the neighbours whose body matches this query. 472 match_any: Match pages containing any of the words in `search` instead of 473 all of them. 474 per_page: Number of neighbours fetched per request (default: the API's 475 own, 1000). Must be between 1 and `MAX_PAGE_SIZE`. 476 477 Returns: 478 An iterator over each neighbouring page, in the order the API returns 479 them. 480 481 Raises: 482 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 483 """ 484 if per_page is not None: 485 check_page_size(per_page, "per_page") 486 return self._iter_links( 487 lambda next_id: self.get_links_1hop( 488 project_name, 489 page_title, 490 search, 491 match_any=match_any, 492 per_page=per_page, 493 next_id=next_id, 494 ), 495 lambda response: response.links1hop, 496 )
Iterate over the whole 1-hop neighbourhood of a page.
A single response holds at most 1000 neighbours, so a larger neighbourhood is walked with the cursor the API reports. Pages are fetched as they are consumed.
Arguments:
- project_name: The name of the project.
- page_title: The title of the page.
- search: Keep only the neighbours whose body matches this query.
- match_any: Match pages containing any of the words in
searchinstead of all of them. - per_page: Number of neighbours fetched per request (default: the API's
own, 1000). Must be between 1 and
MAX_PAGE_SIZE.
Returns:
An iterator over each neighbouring page, in the order the API returns them.
Raises:
- ValueError: If
per_pageis outside 1 toMAX_PAGE_SIZE.
498 def iter_links_2hop( 499 self, 500 project_name: str, 501 page_title: str, 502 search: str | None = None, 503 *, 504 match_any: bool = False, 505 per_page: int | None = None, 506 ) -> Iterator[LinkPage]: 507 """Iterate over the whole 2-hop neighbourhood of a page. 508 509 The direct 1-hop neighbours are not included. 510 511 Args: 512 project_name: The name of the project. 513 page_title: The title of the page. 514 search: Keep only the neighbours whose body matches this query. 515 match_any: Match pages containing any of the words in `search` instead of 516 all of them. 517 per_page: Number of neighbours fetched per request (default: the API's 518 own, 1000). Must be between 1 and `MAX_PAGE_SIZE`. 519 520 Returns: 521 An iterator over each neighbouring page, in the order the API returns 522 them. 523 524 Raises: 525 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 526 """ 527 if per_page is not None: 528 check_page_size(per_page, "per_page") 529 return self._iter_links( 530 lambda next_id: self.get_links_2hop( 531 project_name, 532 page_title, 533 search, 534 match_any=match_any, 535 per_page=per_page, 536 next_id=next_id, 537 ), 538 lambda response: response.links2hop, 539 )
Iterate over the whole 2-hop neighbourhood of a page.
The direct 1-hop neighbours are not included.
Arguments:
- project_name: The name of the project.
- page_title: The title of the page.
- search: Keep only the neighbours whose body matches this query.
- match_any: Match pages containing any of the words in
searchinstead of all of them. - per_page: Number of neighbours fetched per request (default: the API's
own, 1000). Must be between 1 and
MAX_PAGE_SIZE.
Returns:
An iterator over each neighbouring page, in the order the API returns them.
Raises:
- ValueError: If
per_pageis outside 1 toMAX_PAGE_SIZE.
605 def search_pages( 606 self, 607 project_name: str, 608 query: str, 609 *, 610 match_any: bool = False, 611 sort: SearchSort | None = None, 612 ) -> SearchResponse: 613 """Search the full text of the pages in a project. 614 615 Args: 616 project_name: The name of the project. 617 query: The search query. 618 match_any: Match pages containing any of the words instead of all of them. 619 sort: Order of the results (default: the API's own, `pageRank`). 620 621 Returns: 622 SearchResponse: The matching pages. 623 """ 624 params: dict[str, Any] = {"q": query} 625 if match_any: 626 params["op"] = "or" 627 if sort is not None: 628 params["sort"] = sort 629 return SearchResponse.model_validate(self._get_json(f"/pages/{project_name}/search/query", params))
Search the full text of the pages in a project.
Arguments:
- project_name: The name of the project.
- query: The search query.
- match_any: Match pages containing any of the words instead of all of them.
- sort: Order of the results (default: the API's own,
pageRank).
Returns:
SearchResponse: The matching pages.
631 def search_titles_by_vector(self, project_name: str, query: str) -> VectorSearchResponse: 632 """Search pages by vector similarity. 633 634 Only page titles and the link notations in page bodies are searched; ordinary 635 body text is not. 636 637 Args: 638 project_name: The name of the project. 639 query: The search query. 640 641 Returns: 642 VectorSearchResponse: The matching pages, most similar first. 643 644 Raises: 645 SearchServerUpdatingError: If the search backend is temporarily updating. 646 Retrying later usually succeeds. 647 """ 648 return VectorSearchResponse.model_validate( 649 self._get_json(f"/pages/{project_name}/search/vector/titles", {"q": query}) 650 )
Search pages by vector similarity.
Only page titles and the link notations in page bodies are searched; ordinary body text is not.
Arguments:
- project_name: The name of the project.
- query: The search query.
Returns:
VectorSearchResponse: The matching pages, most similar first.
Raises:
- SearchServerUpdatingError: If the search backend is temporarily updating. Retrying later usually succeeds.
652 def get_commits(self, project_name: str, page_id: str, since: str | None = None) -> CommitsResponse: 653 """Get the edit history of a page. 654 655 The history is keyed by page id rather than title, so it can be followed 656 across renames. 657 658 Args: 659 project_name: The name of the project. 660 page_id: The immutable id of the page. 661 since: Return only the commits after this commit id. Omit for the whole 662 history. 663 664 Returns: 665 CommitsResponse: The commits, oldest first. 666 """ 667 params = {"head": since} if since is not None else None 668 return CommitsResponse.model_validate(self._get_json(f"/commits/{project_name}/{page_id}", params))
Get the edit history of a page.
The history is keyed by page id rather than title, so it can be followed across renames.
Arguments:
- project_name: The name of the project.
- page_id: The immutable id of the page.
- since: Return only the commits after this commit id. Omit for the whole history.
Returns:
CommitsResponse: The commits, oldest first.
670 def get_project_users(self, project_name: str) -> ProjectUsersResponse: 671 """Get the members of a project. 672 673 Args: 674 project_name: The name of the project. 675 676 Returns: 677 ProjectUsersResponse: Current members, departed members and service accounts. 678 """ 679 return ProjectUsersResponse.model_validate(self._get_json(f"/projects/{project_name}/users"))
Get the members of a project.
Arguments:
- project_name: The name of the project.
Returns:
ProjectUsersResponse: Current members, departed members and service accounts.
681 def get_projects(self) -> ProjectsResponse: 682 """Get the projects the authenticated user belongs to. 683 684 Requires authentication. 685 686 Returns: 687 ProjectsResponse: The projects. 688 """ 689 return ProjectsResponse.model_validate(self._get_json("/projects"))
Get the projects the authenticated user belongs to.
Requires authentication.
Returns:
ProjectsResponse: The projects.
691 def get_project(self, project_name: str) -> ProjectDetail: 692 """Get a single project by name. 693 694 Unlike `get_projects`, this needs no authentication for a public project, and 695 carries the project's settings and member list rather than the counters. 696 697 A service account is refused here with HTTP 401, even for the project it 698 belongs to, though `get_project_users` on that same project works. 699 700 Args: 701 project_name: The name of the project. 702 703 Returns: 704 ProjectDetail: The project. 705 """ 706 return ProjectDetail.model_validate(self._get_json(f"/projects/{project_name}"))
Get a single project by name.
Unlike get_projects, this needs no authentication for a public project, and
carries the project's settings and member list rather than the counters.
A service account is refused here with HTTP 401, even for the project it
belongs to, though get_project_users on that same project works.
Arguments:
- project_name: The name of the project.
Returns:
ProjectDetail: The project.
708 def get_me(self) -> Me: 709 """Get the authenticated user. 710 711 Requires authentication. The `name` shown here, not `display_name`, is what 712 `get_pages(filter_value=...)` expects. 713 714 Returns: 715 Me: The authenticated user. 716 717 Raises: 718 NotAuthenticatedError: If no credential was accepted. This endpoint does 719 not answer 401: without one it answers 200 with `{"isGuest": true}`, 720 which carries no user to return. 721 """ 722 payload = self._get_json("/users/me") 723 if "id" not in payload: 724 raise NotAuthenticatedError 725 return Me.model_validate(payload)
Get the authenticated user.
Requires authentication. The name shown here, not display_name, is what
get_pages(filter_value=...) expects.
Returns:
Me: The authenticated user.
Raises:
- NotAuthenticatedError: If no credential was accepted. This endpoint does
not answer 401: without one it answers 200 with
{"isGuest": true}, which carries no user to return.
727 def get_file_info(self, file_id: str) -> FileInfo: 728 """Get the metadata of a file uploaded to a project. 729 730 Args: 731 file_id: The file id, optionally with an extension, or the full file URL. 732 733 Returns: 734 FileInfo: The metadata, including any text extracted from the file. 735 """ 736 return FileInfo.model_validate(self._get_json(f"/gcs/{bare_file_id(file_id)}/info"))
Get the metadata of a file uploaded to a project.
Arguments:
- file_id: The file id, optionally with an extension, or the full file URL.
Returns:
FileInfo: The metadata, including any text extracted from the file.
738 def preview_page_edit( 739 self, 740 project_name: str, 741 changes: Sequence[PageChange], 742 page_id: str | None = None, 743 ) -> EditPreviewResponse: 744 """Dry-run an edit and get a preview id for it. 745 746 Nothing is written until the returned preview id is passed to 747 `submit_page_edit`, and the preview expires a few minutes after it is issued. 748 Use `scrapbox.edits.changes_from_ops` to build `changes`. 749 750 Args: 751 project_name: The name of the project. 752 changes: The changes to apply, in order. 753 page_id: The id of the page to edit. Omit to create a new page. 754 755 Returns: 756 EditPreviewResponse: The preview id and the resulting page. 757 758 Raises: 759 PersonalAccessTokenRequiredError: If neither a personal access token nor a 760 service account access key is set. 761 """ 762 payload: dict[str, Any] = { 763 "changes": [ 764 change if isinstance(change, dict) else change.model_dump(by_alias=True, exclude_none=True) 765 for change in changes 766 ] 767 } 768 if page_id is not None: 769 payload["pageId"] = page_id 770 return EditPreviewResponse.model_validate( 771 self._post_json(f"/pages/v2/{project_name}/page-edit-for-ai/preview", payload) 772 )
Dry-run an edit and get a preview id for it.
Nothing is written until the returned preview id is passed to
submit_page_edit, and the preview expires a few minutes after it is issued.
Use scrapbox.edits.changes_from_ops to build changes.
Arguments:
- project_name: The name of the project.
- changes: The changes to apply, in order.
- page_id: The id of the page to edit. Omit to create a new page.
Returns:
EditPreviewResponse: The preview id and the resulting page.
Raises:
- PersonalAccessTokenRequiredError: If neither a personal access token nor a service account access key is set.
774 def submit_page_edit(self, project_name: str, preview_id: str) -> EditSubmitResponse: 775 """Commit an edit that was previewed earlier. 776 777 A preview id can only be submitted once, and the project must be the one the 778 preview was created for. 779 780 Args: 781 project_name: The name of the project. 782 preview_id: The preview id returned by `preview_page_edit`. 783 784 Returns: 785 EditSubmitResponse: The created commit and the page written to. 786 787 Raises: 788 PersonalAccessTokenRequiredError: If neither a personal access token nor a 789 service account access key is set. 790 """ 791 return EditSubmitResponse.model_validate( 792 self._post_json( 793 f"/pages/v2/{project_name}/page-edit-for-ai/submit", 794 {"previewId": preview_id}, 795 ) 796 )
Commit an edit that was previewed earlier.
A preview id can only be submitted once, and the project must be the one the preview was created for.
Arguments:
- project_name: The name of the project.
- preview_id: The preview id returned by
preview_page_edit.
Returns:
EditSubmitResponse: The created commit and the page written to.
Raises:
- PersonalAccessTokenRequiredError: If neither a personal access token nor a service account access key is set.
798 def get_page_text(self, project_name: str, page_title: str) -> str: 799 """Get the text content of a page. 800 801 Args: 802 project_name: The name of the project. 803 page_title: The title of the page. 804 805 Returns: 806 str: The text content of the page. 807 """ 808 encoded_title = quote(page_title, safe="") 809 return self._get(f"/pages/{project_name}/{encoded_title}/text").text
Get the text content of a page.
Arguments:
- project_name: The name of the project.
- page_title: The title of the page.
Returns:
str: The text content of the page.
811 def get_page_icon_url(self, project_name: str, page_title: str) -> str: 812 """Get the icon image URL for a page. 813 814 This method returns the redirect destination URL of the page icon. 815 816 Args: 817 project_name: The name of the project. 818 page_title: The title of the page. 819 820 Returns: 821 str: The URL of the icon image. 822 """ 823 encoded_title = quote(page_title, safe="") 824 url = f"{self.BASE_URL}/pages/{project_name}/{encoded_title}/icon" 825 826 response = self.client.get(url, follow_redirects=False) 827 828 if response.status_code == httpx2.codes.FOUND: 829 return response.headers.get("location", "") 830 if response.status_code == httpx2.codes.OK: 831 return url 832 response.raise_for_status() 833 return url
Get the icon image URL for a page.
This method returns the redirect destination URL of the page icon.
Arguments:
- project_name: The name of the project.
- page_title: The title of the page.
Returns:
str: The URL of the icon image.
835 def get_file(self, file_id: str, *, thumbnail: bool = False) -> bytes: 836 """Get a file uploaded to Scrapbox. 837 838 Args: 839 file_id: The file ID (e.g., "1a2b3c4d5e6f7g8h9i0j.JPG") 840 or full URL (e.g., "https://scrapbox.io/files/1a2b3c4d5e6f7g8h9i0j.JPG" 841 or "https://gyazo.com/1a2b3c4d5e6f7g8h9i0j1a2b3c4d5e6f"). 842 thumbnail: Fetch the scaled down version. Files that have no thumbnail 843 (anything but JPEG and PNG) come back at full size. Ignored for Gyazo 844 URLs, which are resolved through oEmbed instead. 845 846 Returns: 847 bytes: The binary data of the file. 848 """ 849 url = file_id if file_id.startswith(("http://", "https://")) else f"https://scrapbox.io/files/{file_id}" 850 851 parsed_url = urlparse(url) 852 is_gyazo = "gyazo.com" in (parsed_url.hostname or "") 853 params = {"type": "thumbnail"} if thumbnail and not is_gyazo else None 854 if is_gyazo: 855 # If URL already has a file extension (e.g., .mp4, .jpg), directly convert to i.gyazo.com 856 path = parsed_url.path.strip("/") 857 if "." in path.split("/")[-1]: # Check if last path segment has extension 858 url = f"https://i.gyazo.com/{path}" 859 else: 860 # Use oEmbed API to get the actual file URL 861 json = self._get_json("/oembed-proxy/gyazo", {"url": url}) 862 if (oembed_type := json.get("type")) not in ("photo", "video"): 863 msg = f"Unsupported Gyazo oEmbed type: {oembed_type}" 864 raise ValueError(msg) 865 oembed_data = GyazoOEmbedResponse.model_validate(json) 866 if isinstance(oembed_data.root, GyazoOEmbedResponsePhoto): 867 url = oembed_data.root.url 868 else: # video 869 # Extract Gyazo ID from the original URL and construct direct video URL 870 gyazo_id = parsed_url.path.strip("/") 871 url = f"https://i.gyazo.com/{gyazo_id}.mp4" 872 response = self.client.get(url, params=params) 873 response.raise_for_status() 874 875 return response.content
Get a file uploaded to Scrapbox.
Arguments:
- file_id: The file ID (e.g., "1a2b3c4d5e6f7g8h9i0j.JPG") or full URL (e.g., "https://scrapbox.io/files/1a2b3c4d5e6f7g8h9i0j.JPG" or "https://gyazo.com/1a2b3c4d5e6f7g8h9i0j1a2b3c4d5e6f").
- thumbnail: Fetch the scaled down version. Files that have no thumbnail (anything but JPEG and PNG) come back at full size. Ignored for Gyazo URLs, which are resolved through oEmbed instead.
Returns:
bytes: The binary data of the file.
Base class for every error raised by this package.
362class SearchResponse(ScrapboxModel): 363 """Response from the full-text search API.""" 364 365 project_name: str | None = None 366 search_query: str | None = None 367 query: Any = None 368 field: str | None = None 369 backend: str | None = None 370 count: int | None = None 371 limit: int | None = None 372 exists_exact_title_match: bool | None = None 373 pages: list[SearchResultPage] = Field(default_factory=list)
Response from the full-text search API.
46class SearchServerUpdatingError(ScrapboxError): 47 """Raised when the search backend is being updated and cannot serve the request. 48 49 The vector search endpoint answers with the non-standard status code 490 while 50 its backend is updating. This is transient: the same request usually succeeds 51 on a later attempt. No retry is performed automatically, because the wait is 52 unbounded; deciding when to retry is left to the caller. 53 """ 54 55 STATUS_CODE = 490 56 """Non-standard HTTP status code used for this condition.""" 57 58 def __init__(self, message: str | None = None) -> None: 59 """Initialize the error. 60 61 Args: 62 message: Message returned by the API, if any. 63 """ 64 super().__init__(message or "Search server is updating. Please try again later.")
Raised when the search backend is being updated and cannot serve the request.
The vector search endpoint answers with the non-standard status code 490 while its backend is updating. This is transient: the same request usually succeeds on a later attempt. No retry is performed automatically, because the wait is unbounded; deciding when to retry is left to the caller.
58 def __init__(self, message: str | None = None) -> None: 59 """Initialize the error. 60 61 Args: 62 message: Message returned by the API, if any. 63 """ 64 super().__init__(message or "Search server is updating. Please try again later.")
Initialize the error.
Arguments:
- message: Message returned by the API, if any.
400class VectorSearchResponse(ScrapboxModel): 401 """Response from the vector search API.""" 402 403 pages: list[VectorSearchPage] = Field(default_factory=list)
Response from the vector search API.
144def changes_from_ops(ops: Sequence[Mapping[str, Any]]) -> list[PageChange]: 145 """Convert ops into the changes the edit API expects. 146 147 An op is one of: 148 149 - `{"insertBefore": "<lineId>" | "_end", "text": "..."}` 150 - `{"replace": "<lineId>", "text": "..."}` 151 - `{"delete": "<lineId>"}` 152 153 Ops are applied in order, and every anchor must exist at the time it is applied. 154 155 Args: 156 ops: The ops to convert. 157 158 Returns: 159 The changes to send to the edit API. 160 161 Raises: 162 TypeError: If `ops` is not a sequence of objects, or a field has the wrong type. 163 ValueError: If an op is malformed. 164 """ 165 if isinstance(ops, str) or not isinstance(ops, Sequence): 166 msg = "ops must be a list" 167 raise TypeError(msg) 168 169 changes: list[PageChange] = [] 170 for op in ops: 171 if not isinstance(op, Mapping): 172 msg = f"each op must be an object, got: {op!r}" 173 raise TypeError(msg) 174 kind = _op_kind(op) 175 if kind == "insertBefore": 176 changes.extend(_insert_changes(op)) 177 elif kind == "replace": 178 changes.append(_replace_change(op)) 179 else: 180 changes.append(_delete_change(op)) 181 return changes
Convert ops into the changes the edit API expects.
An op is one of:
{"insertBefore": "<lineId>" | "_end", "text": "..."}{"replace": "<lineId>", "text": "..."}{"delete": "<lineId>"}
Ops are applied in order, and every anchor must exist at the time it is applied.
Arguments:
- ops: The ops to convert.
Returns:
The changes to send to the edit API.
Raises:
- TypeError: If
opsis not a sequence of objects, or a field has the wrong type. - ValueError: If an op is malformed.
25def new_line_id() -> str: 26 """Generate a line id for a newly inserted line. 27 28 The client, not the server, chooses the id of an inserted line. 29 30 Returns: 31 A 24 digit hexadecimal id. 32 """ 33 return secrets.token_hex(LINE_ID_BYTES)
Generate a line id for a newly inserted line.
The client, not the server, chooses the id of an inserted line.
Returns:
A 24 digit hexadecimal id.