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.
141class ScrapboxClient: 142 """Scrapbox API client. 143 144 This client provides methods to interact with the Scrapbox API, 145 including retrieving page lists, page details, page text, and files. 146 """ 147 148 """Base URL for the Scrapbox API.""" 149 BASE_URL = f"{SCRAPBOX_ORIGIN}/api" 150 151 def __init__( 152 self, 153 connect_sid: str | None = None, 154 pat: str | None = None, 155 service_account_key: str | None = None, 156 transport: httpx.BaseTransport | None = None, 157 ) -> None: 158 """Initialize the Scrapbox API client. 159 160 Authentication is optional for public projects. Only one credential is ever 161 sent: given more than one, a personal access token wins over a service 162 account key, which in turn wins over a cookie. 163 164 Args: 165 connect_sid: Scrapbox authentication cookie (connect.sid). 166 pat: Scrapbox personal access token, sent as the `x-personal-access-token` 167 header. 168 service_account_key: Access key of a service account, sent as the 169 `x-service-account-access-key` header. A service account is registered 170 on one project of a Business plan and can read and write only that 171 one: any other project, even a public one, answers 400. It stands for 172 no user, so `get_me` and `get_projects` are out of its reach, and 173 `get_project` refuses it as well. 174 transport: Transport used by the underlying HTTP client. Intended for 175 tests, which pass an `httpx.MockTransport` so that header handling 176 is still exercised. 177 """ 178 self.pat = pat 179 self.service_account_key = None if pat else service_account_key 180 self.connect_sid = None if pat or service_account_key else connect_sid 181 self.client = httpx.Client( 182 cookies={"connect.sid": self.connect_sid} if self.connect_sid else None, 183 follow_redirects=True, 184 transport=transport, 185 ) 186 if self.pat or self.service_account_key: 187 # Attach the credential per request instead of as a default header: 188 # get_file() follows redirects to third-party hosts (Gyazo), which must 189 # not receive it. 190 self.client.event_hooks["request"].append(self._attach_credential) 191 192 def _attach_credential(self, request: httpx.Request) -> None: 193 """Attach the header credential to requests sent to Scrapbox. 194 195 Args: 196 request: The outgoing request. 197 """ 198 if request.url.host != SCRAPBOX_HOST: 199 return 200 if self.pat: 201 request.headers[PAT_HEADER] = self.pat 202 elif self.service_account_key: 203 request.headers[SERVICE_ACCOUNT_HEADER] = self.service_account_key 204 205 def __enter__(self: Self) -> Self: 206 """Enter the runtime context related to this object.""" 207 return self 208 209 def __exit__(self, typ: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, /) -> None: 210 """Exit the runtime context related to this object.""" 211 self.client.close() 212 213 def close(self) -> None: 214 """Close the HTTP client.""" 215 self.client.close() 216 217 @staticmethod 218 def _raise_for_status(response: httpx.Response) -> None: 219 """Turn an error response into an exception. 220 221 The status code alone rarely says what went wrong -- a service account asked 222 for the wrong project gets a bare 400 -- so the API's own explanation is 223 carried into the error message. 224 225 Args: 226 response: The response to inspect. 227 228 Raises: 229 SearchServerUpdatingError: If the search backend is being updated. 230 httpx.HTTPStatusError: If the response carries any other error status. 231 """ 232 if response.status_code == SearchServerUpdatingError.STATUS_CODE: 233 # The name adds nothing here: the exception class already carries it. 234 try: 235 message = response.json().get("message") 236 except ValueError: 237 message = None 238 raise SearchServerUpdatingError(message) 239 try: 240 response.raise_for_status() 241 except httpx.HTTPStatusError as e: 242 detail = error_detail(response) 243 if detail is None: 244 raise 245 msg = f"{e}\n{detail}" 246 raise httpx.HTTPStatusError(msg, request=e.request, response=e.response) from None 247 248 def _get(self, path: str, params: Mapping[str, Any] | None = None) -> httpx.Response: 249 """Send a GET request to a path under `BASE_URL`. 250 251 Args: 252 path: Path below `BASE_URL`, starting with a slash. 253 params: Query parameters. Entries are sent as given. 254 255 Returns: 256 The successful response. 257 """ 258 response = self.client.get(f"{self.BASE_URL}{path}", params=params) 259 self._raise_for_status(response) 260 return response 261 262 def _get_json(self, path: str, params: Mapping[str, Any] | None = None) -> Any: # noqa: ANN401 263 """Send a GET request and decode the JSON body. 264 265 Args: 266 path: Path below `BASE_URL`, starting with a slash. 267 params: Query parameters. Entries are sent as given. 268 269 Returns: 270 The decoded JSON body. 271 """ 272 return self._get(path, params).json() 273 274 def _post_json(self, path: str, payload: Mapping[str, Any]) -> Any: # noqa: ANN401 275 """Send a POST request and decode the JSON body. 276 277 Write endpoints take a header credential -- a personal access token or a 278 service account access key -- and refuse a cookie with HTTP 403, so the 279 request is not even sent without one. 280 281 Args: 282 path: Path below `BASE_URL`, starting with a slash. 283 payload: JSON body to send. 284 285 Returns: 286 The decoded JSON body. 287 288 Raises: 289 PersonalAccessTokenRequiredError: If neither a personal access token nor a 290 service account access key is set. 291 """ 292 if not (self.pat or self.service_account_key): 293 raise PersonalAccessTokenRequiredError(path) 294 response = self.client.post(f"{self.BASE_URL}{path}", json=dict(payload)) 295 self._raise_for_status(response) 296 return response.json() 297 298 def get_pages( 299 self, 300 project_name: str, 301 skip: int = 0, 302 limit: int = 100, 303 sort: PageSort | None = None, 304 filter_value: str | None = None, 305 ) -> PageListResponse: 306 """Get a list of pages from a project. 307 308 The page bodies are not included. At most 1000 pages come back per request, 309 so `skip` is how a whole project is walked. 310 311 Args: 312 project_name: The name of the project. 313 skip: Number of pages to skip (default: 0). 314 limit: Number of pages to retrieve (default: 100, max: 1000). 315 sort: Order of the returned pages (default: the API's own, `updated`). 316 filter_value: Keep only the pages carrying a `[<value>.icon]` reference, 317 plus the pages that user has edited. Use the login name, not the 318 display name. 319 320 Returns: 321 PageListResponse: The response containing the page list. 322 323 Raises: 324 ValueError: If `limit` is outside 1 to `MAX_PAGE_SIZE`. 325 """ 326 check_page_size(limit, "limit") 327 params: dict[str, Any] = {"skip": skip, "limit": limit} 328 if sort is not None: 329 params["sort"] = sort 330 if filter_value is not None: 331 params["filterType"] = "icon" 332 params["filterValue"] = filter_value 333 return PageListResponse.model_validate(self._get_json(f"/pages/{project_name}", params)) 334 335 def get_page(self, project_name: str, page_title: str) -> PageDetail: 336 """Get detailed information about a specific page. 337 338 Args: 339 project_name: The name of the project. 340 page_title: The title of the page. 341 342 Returns: 343 PageDetail: The detailed information about the page. 344 """ 345 encoded_title = quote(page_title, safe="") 346 return PageDetail.model_validate(self._get_json(f"/pages/{project_name}/{encoded_title}")) 347 348 def get_page_v2(self, project_name: str, page_title: str) -> PageDetailV2: 349 """Get detailed information about a page from the v2 endpoint. 350 351 Compared with `get_page`, this carries the normalized `links_lc` /`icons_lc` / 352 `project_links_lc` fields but no embedded related pages; use `get_links_1hop` 353 and `get_links_2hop` for those. 354 355 Args: 356 project_name: The name of the project. 357 page_title: The title of the page. 358 359 Returns: 360 PageDetailV2: The detailed information about the page. 361 """ 362 encoded_title = quote(page_title, safe="") 363 return PageDetailV2.model_validate(self._get_json(f"/pages/v2/{project_name}/{encoded_title}")) 364 365 def get_links_1hop( # noqa: PLR0913 - one parameter per query parameter the endpoint takes 366 self, 367 project_name: str, 368 page_title: str, 369 search: str | None = None, 370 *, 371 match_any: bool = False, 372 per_page: int | None = None, 373 next_id: str | None = None, 374 ) -> Links1hopResponse: 375 """Get one page of the 1-hop neighbourhood of a page. 376 377 Use `iter_links_1hop` to walk a neighbourhood larger than one page. 378 379 Args: 380 project_name: The name of the project. 381 page_title: The title of the page. 382 search: Keep only the neighbours whose body matches this query. 383 match_any: Match pages containing any of the words in `search` instead of 384 all of them. 385 per_page: Number of neighbours per page (default: the API's own, 1000). 386 Must be between 1 and `MAX_PAGE_SIZE`. 387 next_id: Continue after this entry, as reported by `pagination.next_id` 388 of the previous page. 389 390 Returns: 391 Links1hopResponse: The neighbouring pages. 392 393 Raises: 394 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 395 """ 396 encoded_title = quote(page_title, safe="") 397 return Links1hopResponse.model_validate( 398 self._get_json( 399 f"/pages/v2/{project_name}/{encoded_title}/links1hop", 400 self._related_params(search, match_any=match_any, per_page=per_page, next_id=next_id), 401 ) 402 ) 403 404 def get_links_2hop( # noqa: PLR0913 - one parameter per query parameter the endpoint takes 405 self, 406 project_name: str, 407 page_title: str, 408 search: str | None = None, 409 *, 410 match_any: bool = False, 411 per_page: int | None = None, 412 next_id: str | None = None, 413 ) -> Links2hopResponse: 414 """Get one page of the 2-hop neighbourhood of a page. 415 416 The direct 1-hop neighbours are not included. Use `iter_links_2hop` to walk a 417 neighbourhood larger than one page. 418 419 Args: 420 project_name: The name of the project. 421 page_title: The title of the page. 422 search: Keep only the neighbours whose body matches this query. 423 match_any: Match pages containing any of the words in `search` instead of 424 all of them. 425 per_page: Number of neighbours per page (default: the API's own, 1000). 426 Must be between 1 and `MAX_PAGE_SIZE`. 427 next_id: Continue after this entry, as reported by `pagination.next_id` 428 of the previous page. 429 430 Returns: 431 Links2hopResponse: The neighbouring pages. 432 433 Raises: 434 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 435 """ 436 encoded_title = quote(page_title, safe="") 437 return Links2hopResponse.model_validate( 438 self._get_json( 439 f"/pages/v2/{project_name}/{encoded_title}/links2hop", 440 self._related_params(search, match_any=match_any, per_page=per_page, next_id=next_id), 441 ) 442 ) 443 444 def iter_links_1hop( 445 self, 446 project_name: str, 447 page_title: str, 448 search: str | None = None, 449 *, 450 match_any: bool = False, 451 per_page: int | None = None, 452 ) -> Iterator[LinkPage]: 453 """Iterate over the whole 1-hop neighbourhood of a page. 454 455 A single response holds at most 1000 neighbours, so a larger neighbourhood is 456 walked with the cursor the API reports. Pages are fetched as they are consumed. 457 458 Args: 459 project_name: The name of the project. 460 page_title: The title of the page. 461 search: Keep only the neighbours whose body matches this query. 462 match_any: Match pages containing any of the words in `search` instead of 463 all of them. 464 per_page: Number of neighbours fetched per request (default: the API's 465 own, 1000). Must be between 1 and `MAX_PAGE_SIZE`. 466 467 Returns: 468 An iterator over each neighbouring page, in the order the API returns 469 them. 470 471 Raises: 472 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 473 """ 474 if per_page is not None: 475 check_page_size(per_page, "per_page") 476 return self._iter_links( 477 lambda next_id: self.get_links_1hop( 478 project_name, page_title, search, match_any=match_any, per_page=per_page, next_id=next_id 479 ), 480 lambda response: response.links1hop, 481 ) 482 483 def iter_links_2hop( 484 self, 485 project_name: str, 486 page_title: str, 487 search: str | None = None, 488 *, 489 match_any: bool = False, 490 per_page: int | None = None, 491 ) -> Iterator[LinkPage]: 492 """Iterate over the whole 2-hop neighbourhood of a page. 493 494 The direct 1-hop neighbours are not included. 495 496 Args: 497 project_name: The name of the project. 498 page_title: The title of the page. 499 search: Keep only the neighbours whose body matches this query. 500 match_any: Match pages containing any of the words in `search` instead of 501 all of them. 502 per_page: Number of neighbours fetched per request (default: the API's 503 own, 1000). Must be between 1 and `MAX_PAGE_SIZE`. 504 505 Returns: 506 An iterator over each neighbouring page, in the order the API returns 507 them. 508 509 Raises: 510 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 511 """ 512 if per_page is not None: 513 check_page_size(per_page, "per_page") 514 return self._iter_links( 515 lambda next_id: self.get_links_2hop( 516 project_name, page_title, search, match_any=match_any, per_page=per_page, next_id=next_id 517 ), 518 lambda response: response.links2hop, 519 ) 520 521 @staticmethod 522 def _iter_links[T: Links1hopResponse | Links2hopResponse]( 523 fetch: Callable[[str | None], T], 524 entries: Callable[[T], list[LinkPage]], 525 ) -> Iterator[LinkPage]: 526 """Walk a related pages endpoint until its cursor runs out. 527 528 A page narrowed by `search` can come back with fewer entries than asked for, 529 or none at all, while the cursor still points further on: the filter applies 530 within a page rather than to the whole neighbourhood. Only `has_next` decides 531 whether to stop. 532 533 Args: 534 fetch: Fetches one page, given the cursor to continue from. 535 entries: Reads the neighbours out of a fetched page. 536 537 Yields: 538 LinkPage: Each neighbouring page. 539 """ 540 next_id: str | None = None 541 while True: 542 response = fetch(next_id) 543 yield from entries(response) 544 pagination = response.pagination 545 if pagination is None or not pagination.has_next or pagination.next_id is None: 546 return 547 if pagination.next_id == next_id: 548 # The cursor has stopped advancing; continuing would loop forever. 549 return 550 next_id = pagination.next_id 551 552 @staticmethod 553 def _related_params( 554 search: str | None, 555 *, 556 match_any: bool, 557 per_page: int | None = None, 558 next_id: str | None = None, 559 ) -> dict[str, Any]: 560 """Build the query parameters shared by the related pages endpoints. 561 562 Args: 563 search: Full-text query to filter the neighbours with. 564 match_any: Whether to match any word instead of all of them. 565 per_page: Number of neighbours per page. 566 next_id: Cursor to continue from. 567 568 Returns: 569 The query parameters to send. 570 571 Raises: 572 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 573 """ 574 params: dict[str, Any] = {} 575 if search is not None: 576 params["search"] = search 577 if match_any: 578 params["op"] = "or" 579 if per_page is not None: 580 params["perPage"] = check_page_size(per_page, "per_page") 581 if next_id is not None: 582 params["nextId"] = next_id 583 return params 584 585 def search_pages( 586 self, 587 project_name: str, 588 query: str, 589 *, 590 match_any: bool = False, 591 sort: SearchSort | None = None, 592 ) -> SearchResponse: 593 """Search the full text of the pages in a project. 594 595 Args: 596 project_name: The name of the project. 597 query: The search query. 598 match_any: Match pages containing any of the words instead of all of them. 599 sort: Order of the results (default: the API's own, `pageRank`). 600 601 Returns: 602 SearchResponse: The matching pages. 603 """ 604 params: dict[str, Any] = {"q": query} 605 if match_any: 606 params["op"] = "or" 607 if sort is not None: 608 params["sort"] = sort 609 return SearchResponse.model_validate(self._get_json(f"/pages/{project_name}/search/query", params)) 610 611 def search_titles_by_vector(self, project_name: str, query: str) -> VectorSearchResponse: 612 """Search pages by vector similarity. 613 614 Only page titles and the link notations in page bodies are searched; ordinary 615 body text is not. 616 617 Args: 618 project_name: The name of the project. 619 query: The search query. 620 621 Returns: 622 VectorSearchResponse: The matching pages, most similar first. 623 624 Raises: 625 SearchServerUpdatingError: If the search backend is temporarily updating. 626 Retrying later usually succeeds. 627 """ 628 return VectorSearchResponse.model_validate( 629 self._get_json(f"/pages/{project_name}/search/vector/titles", {"q": query}) 630 ) 631 632 def get_commits(self, project_name: str, page_id: str, since: str | None = None) -> CommitsResponse: 633 """Get the edit history of a page. 634 635 The history is keyed by page id rather than title, so it can be followed 636 across renames. 637 638 Args: 639 project_name: The name of the project. 640 page_id: The immutable id of the page. 641 since: Return only the commits after this commit id. Omit for the whole 642 history. 643 644 Returns: 645 CommitsResponse: The commits, oldest first. 646 """ 647 params = {"head": since} if since is not None else None 648 return CommitsResponse.model_validate(self._get_json(f"/commits/{project_name}/{page_id}", params)) 649 650 def get_project_users(self, project_name: str) -> ProjectUsersResponse: 651 """Get the members of a project. 652 653 Args: 654 project_name: The name of the project. 655 656 Returns: 657 ProjectUsersResponse: Current members, departed members and service accounts. 658 """ 659 return ProjectUsersResponse.model_validate(self._get_json(f"/projects/{project_name}/users")) 660 661 def get_projects(self) -> ProjectsResponse: 662 """Get the projects the authenticated user belongs to. 663 664 Requires authentication. 665 666 Returns: 667 ProjectsResponse: The projects. 668 """ 669 return ProjectsResponse.model_validate(self._get_json("/projects")) 670 671 def get_project(self, project_name: str) -> ProjectDetail: 672 """Get a single project by name. 673 674 Unlike `get_projects`, this needs no authentication for a public project, and 675 carries the project's settings and member list rather than the counters. 676 677 A service account is refused here with HTTP 401, even for the project it 678 belongs to, though `get_project_users` on that same project works. 679 680 Args: 681 project_name: The name of the project. 682 683 Returns: 684 ProjectDetail: The project. 685 """ 686 return ProjectDetail.model_validate(self._get_json(f"/projects/{project_name}")) 687 688 def get_me(self) -> Me: 689 """Get the authenticated user. 690 691 Requires authentication. The `name` shown here, not `display_name`, is what 692 `get_pages(filter_value=...)` expects. 693 694 Returns: 695 Me: The authenticated user. 696 697 Raises: 698 NotAuthenticatedError: If no credential was accepted. This endpoint does 699 not answer 401: without one it answers 200 with `{"isGuest": true}`, 700 which carries no user to return. 701 """ 702 payload = self._get_json("/users/me") 703 if "id" not in payload: 704 raise NotAuthenticatedError 705 return Me.model_validate(payload) 706 707 def get_file_info(self, file_id: str) -> FileInfo: 708 """Get the metadata of a file uploaded to a project. 709 710 Args: 711 file_id: The file id, optionally with an extension, or the full file URL. 712 713 Returns: 714 FileInfo: The metadata, including any text extracted from the file. 715 """ 716 return FileInfo.model_validate(self._get_json(f"/gcs/{bare_file_id(file_id)}/info")) 717 718 def preview_page_edit( 719 self, 720 project_name: str, 721 changes: Sequence[PageChange], 722 page_id: str | None = None, 723 ) -> EditPreviewResponse: 724 """Dry-run an edit and get a preview id for it. 725 726 Nothing is written until the returned preview id is passed to 727 `submit_page_edit`, and the preview expires a few minutes after it is issued. 728 Use `scrapbox.edits.changes_from_ops` to build `changes`. 729 730 Args: 731 project_name: The name of the project. 732 changes: The changes to apply, in order. 733 page_id: The id of the page to edit. Omit to create a new page. 734 735 Returns: 736 EditPreviewResponse: The preview id and the resulting page. 737 738 Raises: 739 PersonalAccessTokenRequiredError: If neither a personal access token nor a 740 service account access key is set. 741 """ 742 payload: dict[str, Any] = { 743 "changes": [ 744 change if isinstance(change, dict) else change.model_dump(by_alias=True, exclude_none=True) 745 for change in changes 746 ] 747 } 748 if page_id is not None: 749 payload["pageId"] = page_id 750 return EditPreviewResponse.model_validate( 751 self._post_json(f"/pages/v2/{project_name}/page-edit-for-ai/preview", payload) 752 ) 753 754 def submit_page_edit(self, project_name: str, preview_id: str) -> EditSubmitResponse: 755 """Commit an edit that was previewed earlier. 756 757 A preview id can only be submitted once, and the project must be the one the 758 preview was created for. 759 760 Args: 761 project_name: The name of the project. 762 preview_id: The preview id returned by `preview_page_edit`. 763 764 Returns: 765 EditSubmitResponse: The created commit and the page written to. 766 767 Raises: 768 PersonalAccessTokenRequiredError: If neither a personal access token nor a 769 service account access key is set. 770 """ 771 return EditSubmitResponse.model_validate( 772 self._post_json(f"/pages/v2/{project_name}/page-edit-for-ai/submit", {"previewId": preview_id}) 773 ) 774 775 def get_page_text(self, project_name: str, page_title: str) -> str: 776 """Get the text content of a page. 777 778 Args: 779 project_name: The name of the project. 780 page_title: The title of the page. 781 782 Returns: 783 str: The text content of the page. 784 """ 785 encoded_title = quote(page_title, safe="") 786 return self._get(f"/pages/{project_name}/{encoded_title}/text").text 787 788 def get_page_icon_url(self, project_name: str, page_title: str) -> str: 789 """Get the icon image URL for a page. 790 791 This method returns the redirect destination URL of the page icon. 792 793 Args: 794 project_name: The name of the project. 795 page_title: The title of the page. 796 797 Returns: 798 str: The URL of the icon image. 799 """ 800 encoded_title = quote(page_title, safe="") 801 url = f"{self.BASE_URL}/pages/{project_name}/{encoded_title}/icon" 802 803 response = self.client.get(url, follow_redirects=False) 804 805 if response.status_code == httpx.codes.FOUND: 806 return response.headers.get("location", "") 807 if response.status_code == httpx.codes.OK: 808 return url 809 response.raise_for_status() 810 return url 811 812 def get_file(self, file_id: str, *, thumbnail: bool = False) -> bytes: 813 """Get a file uploaded to Scrapbox. 814 815 Args: 816 file_id: The file ID (e.g., "1a2b3c4d5e6f7g8h9i0j.JPG") 817 or full URL (e.g., "https://scrapbox.io/files/1a2b3c4d5e6f7g8h9i0j.JPG" 818 or "https://gyazo.com/1a2b3c4d5e6f7g8h9i0j1a2b3c4d5e6f"). 819 thumbnail: Fetch the scaled down version. Files that have no thumbnail 820 (anything but JPEG and PNG) come back at full size. Ignored for Gyazo 821 URLs, which are resolved through oEmbed instead. 822 823 Returns: 824 bytes: The binary data of the file. 825 """ 826 url = file_id if file_id.startswith(("http://", "https://")) else f"https://scrapbox.io/files/{file_id}" 827 828 parsed_url = urlparse(url) 829 is_gyazo = "gyazo.com" in (parsed_url.hostname or "") 830 params = {"type": "thumbnail"} if thumbnail and not is_gyazo else None 831 if is_gyazo: 832 # If URL already has a file extension (e.g., .mp4, .jpg), directly convert to i.gyazo.com 833 path = parsed_url.path.strip("/") 834 if "." in path.split("/")[-1]: # Check if last path segment has extension 835 url = f"https://i.gyazo.com/{path}" 836 else: 837 # Use oEmbed API to get the actual file URL 838 json = self._get_json("/oembed-proxy/gyazo", {"url": url}) 839 if (oembed_type := json.get("type")) not in ("photo", "video"): 840 msg = f"Unsupported Gyazo oEmbed type: {oembed_type}" 841 raise ValueError(msg) 842 oembed_data = GyazoOEmbedResponse.model_validate(json) 843 if isinstance(oembed_data.root, GyazoOEmbedResponsePhoto): 844 url = oembed_data.root.url 845 else: # video 846 # Extract Gyazo ID from the original URL and construct direct video URL 847 gyazo_id = parsed_url.path.strip("/") 848 url = f"https://i.gyazo.com/{gyazo_id}.mp4" 849 response = self.client.get(url, params=params) 850 response.raise_for_status() 851 852 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.
151 def __init__( 152 self, 153 connect_sid: str | None = None, 154 pat: str | None = None, 155 service_account_key: str | None = None, 156 transport: httpx.BaseTransport | None = None, 157 ) -> None: 158 """Initialize the Scrapbox API client. 159 160 Authentication is optional for public projects. Only one credential is ever 161 sent: given more than one, a personal access token wins over a service 162 account key, which in turn wins over a cookie. 163 164 Args: 165 connect_sid: Scrapbox authentication cookie (connect.sid). 166 pat: Scrapbox personal access token, sent as the `x-personal-access-token` 167 header. 168 service_account_key: Access key of a service account, sent as the 169 `x-service-account-access-key` header. A service account is registered 170 on one project of a Business plan and can read and write only that 171 one: any other project, even a public one, answers 400. It stands for 172 no user, so `get_me` and `get_projects` are out of its reach, and 173 `get_project` refuses it as well. 174 transport: Transport used by the underlying HTTP client. Intended for 175 tests, which pass an `httpx.MockTransport` so that header handling 176 is still exercised. 177 """ 178 self.pat = pat 179 self.service_account_key = None if pat else service_account_key 180 self.connect_sid = None if pat or service_account_key else connect_sid 181 self.client = httpx.Client( 182 cookies={"connect.sid": self.connect_sid} if self.connect_sid else None, 183 follow_redirects=True, 184 transport=transport, 185 ) 186 if self.pat or self.service_account_key: 187 # Attach the credential per request instead of as a default header: 188 # get_file() follows redirects to third-party hosts (Gyazo), which must 189 # not receive it. 190 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
httpx.MockTransportso that header handling is still exercised.
298 def get_pages( 299 self, 300 project_name: str, 301 skip: int = 0, 302 limit: int = 100, 303 sort: PageSort | None = None, 304 filter_value: str | None = None, 305 ) -> PageListResponse: 306 """Get a list of pages from a project. 307 308 The page bodies are not included. At most 1000 pages come back per request, 309 so `skip` is how a whole project is walked. 310 311 Args: 312 project_name: The name of the project. 313 skip: Number of pages to skip (default: 0). 314 limit: Number of pages to retrieve (default: 100, max: 1000). 315 sort: Order of the returned pages (default: the API's own, `updated`). 316 filter_value: Keep only the pages carrying a `[<value>.icon]` reference, 317 plus the pages that user has edited. Use the login name, not the 318 display name. 319 320 Returns: 321 PageListResponse: The response containing the page list. 322 323 Raises: 324 ValueError: If `limit` is outside 1 to `MAX_PAGE_SIZE`. 325 """ 326 check_page_size(limit, "limit") 327 params: dict[str, Any] = {"skip": skip, "limit": limit} 328 if sort is not None: 329 params["sort"] = sort 330 if filter_value is not None: 331 params["filterType"] = "icon" 332 params["filterValue"] = filter_value 333 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.
335 def get_page(self, project_name: str, page_title: str) -> PageDetail: 336 """Get detailed information about a specific page. 337 338 Args: 339 project_name: The name of the project. 340 page_title: The title of the page. 341 342 Returns: 343 PageDetail: The detailed information about the page. 344 """ 345 encoded_title = quote(page_title, safe="") 346 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.
348 def get_page_v2(self, project_name: str, page_title: str) -> PageDetailV2: 349 """Get detailed information about a page from the v2 endpoint. 350 351 Compared with `get_page`, this carries the normalized `links_lc` /`icons_lc` / 352 `project_links_lc` fields but no embedded related pages; use `get_links_1hop` 353 and `get_links_2hop` for those. 354 355 Args: 356 project_name: The name of the project. 357 page_title: The title of the page. 358 359 Returns: 360 PageDetailV2: The detailed information about the page. 361 """ 362 encoded_title = quote(page_title, safe="") 363 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.
365 def get_links_1hop( # noqa: PLR0913 - one parameter per query parameter the endpoint takes 366 self, 367 project_name: str, 368 page_title: str, 369 search: str | None = None, 370 *, 371 match_any: bool = False, 372 per_page: int | None = None, 373 next_id: str | None = None, 374 ) -> Links1hopResponse: 375 """Get one page of the 1-hop neighbourhood of a page. 376 377 Use `iter_links_1hop` to walk a neighbourhood larger than one page. 378 379 Args: 380 project_name: The name of the project. 381 page_title: The title of the page. 382 search: Keep only the neighbours whose body matches this query. 383 match_any: Match pages containing any of the words in `search` instead of 384 all of them. 385 per_page: Number of neighbours per page (default: the API's own, 1000). 386 Must be between 1 and `MAX_PAGE_SIZE`. 387 next_id: Continue after this entry, as reported by `pagination.next_id` 388 of the previous page. 389 390 Returns: 391 Links1hopResponse: The neighbouring pages. 392 393 Raises: 394 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 395 """ 396 encoded_title = quote(page_title, safe="") 397 return Links1hopResponse.model_validate( 398 self._get_json( 399 f"/pages/v2/{project_name}/{encoded_title}/links1hop", 400 self._related_params(search, match_any=match_any, per_page=per_page, next_id=next_id), 401 ) 402 )
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.
404 def get_links_2hop( # noqa: PLR0913 - one parameter per query parameter the endpoint takes 405 self, 406 project_name: str, 407 page_title: str, 408 search: str | None = None, 409 *, 410 match_any: bool = False, 411 per_page: int | None = None, 412 next_id: str | None = None, 413 ) -> Links2hopResponse: 414 """Get one page of the 2-hop neighbourhood of a page. 415 416 The direct 1-hop neighbours are not included. Use `iter_links_2hop` to walk a 417 neighbourhood larger than one page. 418 419 Args: 420 project_name: The name of the project. 421 page_title: The title of the page. 422 search: Keep only the neighbours whose body matches this query. 423 match_any: Match pages containing any of the words in `search` instead of 424 all of them. 425 per_page: Number of neighbours per page (default: the API's own, 1000). 426 Must be between 1 and `MAX_PAGE_SIZE`. 427 next_id: Continue after this entry, as reported by `pagination.next_id` 428 of the previous page. 429 430 Returns: 431 Links2hopResponse: The neighbouring pages. 432 433 Raises: 434 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 435 """ 436 encoded_title = quote(page_title, safe="") 437 return Links2hopResponse.model_validate( 438 self._get_json( 439 f"/pages/v2/{project_name}/{encoded_title}/links2hop", 440 self._related_params(search, match_any=match_any, per_page=per_page, next_id=next_id), 441 ) 442 )
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.
444 def iter_links_1hop( 445 self, 446 project_name: str, 447 page_title: str, 448 search: str | None = None, 449 *, 450 match_any: bool = False, 451 per_page: int | None = None, 452 ) -> Iterator[LinkPage]: 453 """Iterate over the whole 1-hop neighbourhood of a page. 454 455 A single response holds at most 1000 neighbours, so a larger neighbourhood is 456 walked with the cursor the API reports. Pages are fetched as they are consumed. 457 458 Args: 459 project_name: The name of the project. 460 page_title: The title of the page. 461 search: Keep only the neighbours whose body matches this query. 462 match_any: Match pages containing any of the words in `search` instead of 463 all of them. 464 per_page: Number of neighbours fetched per request (default: the API's 465 own, 1000). Must be between 1 and `MAX_PAGE_SIZE`. 466 467 Returns: 468 An iterator over each neighbouring page, in the order the API returns 469 them. 470 471 Raises: 472 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 473 """ 474 if per_page is not None: 475 check_page_size(per_page, "per_page") 476 return self._iter_links( 477 lambda next_id: self.get_links_1hop( 478 project_name, page_title, search, match_any=match_any, per_page=per_page, next_id=next_id 479 ), 480 lambda response: response.links1hop, 481 )
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.
483 def iter_links_2hop( 484 self, 485 project_name: str, 486 page_title: str, 487 search: str | None = None, 488 *, 489 match_any: bool = False, 490 per_page: int | None = None, 491 ) -> Iterator[LinkPage]: 492 """Iterate over the whole 2-hop neighbourhood of a page. 493 494 The direct 1-hop neighbours are not included. 495 496 Args: 497 project_name: The name of the project. 498 page_title: The title of the page. 499 search: Keep only the neighbours whose body matches this query. 500 match_any: Match pages containing any of the words in `search` instead of 501 all of them. 502 per_page: Number of neighbours fetched per request (default: the API's 503 own, 1000). Must be between 1 and `MAX_PAGE_SIZE`. 504 505 Returns: 506 An iterator over each neighbouring page, in the order the API returns 507 them. 508 509 Raises: 510 ValueError: If `per_page` is outside 1 to `MAX_PAGE_SIZE`. 511 """ 512 if per_page is not None: 513 check_page_size(per_page, "per_page") 514 return self._iter_links( 515 lambda next_id: self.get_links_2hop( 516 project_name, page_title, search, match_any=match_any, per_page=per_page, next_id=next_id 517 ), 518 lambda response: response.links2hop, 519 )
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.
585 def search_pages( 586 self, 587 project_name: str, 588 query: str, 589 *, 590 match_any: bool = False, 591 sort: SearchSort | None = None, 592 ) -> SearchResponse: 593 """Search the full text of the pages in a project. 594 595 Args: 596 project_name: The name of the project. 597 query: The search query. 598 match_any: Match pages containing any of the words instead of all of them. 599 sort: Order of the results (default: the API's own, `pageRank`). 600 601 Returns: 602 SearchResponse: The matching pages. 603 """ 604 params: dict[str, Any] = {"q": query} 605 if match_any: 606 params["op"] = "or" 607 if sort is not None: 608 params["sort"] = sort 609 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.
611 def search_titles_by_vector(self, project_name: str, query: str) -> VectorSearchResponse: 612 """Search pages by vector similarity. 613 614 Only page titles and the link notations in page bodies are searched; ordinary 615 body text is not. 616 617 Args: 618 project_name: The name of the project. 619 query: The search query. 620 621 Returns: 622 VectorSearchResponse: The matching pages, most similar first. 623 624 Raises: 625 SearchServerUpdatingError: If the search backend is temporarily updating. 626 Retrying later usually succeeds. 627 """ 628 return VectorSearchResponse.model_validate( 629 self._get_json(f"/pages/{project_name}/search/vector/titles", {"q": query}) 630 )
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.
632 def get_commits(self, project_name: str, page_id: str, since: str | None = None) -> CommitsResponse: 633 """Get the edit history of a page. 634 635 The history is keyed by page id rather than title, so it can be followed 636 across renames. 637 638 Args: 639 project_name: The name of the project. 640 page_id: The immutable id of the page. 641 since: Return only the commits after this commit id. Omit for the whole 642 history. 643 644 Returns: 645 CommitsResponse: The commits, oldest first. 646 """ 647 params = {"head": since} if since is not None else None 648 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.
650 def get_project_users(self, project_name: str) -> ProjectUsersResponse: 651 """Get the members of a project. 652 653 Args: 654 project_name: The name of the project. 655 656 Returns: 657 ProjectUsersResponse: Current members, departed members and service accounts. 658 """ 659 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.
661 def get_projects(self) -> ProjectsResponse: 662 """Get the projects the authenticated user belongs to. 663 664 Requires authentication. 665 666 Returns: 667 ProjectsResponse: The projects. 668 """ 669 return ProjectsResponse.model_validate(self._get_json("/projects"))
Get the projects the authenticated user belongs to.
Requires authentication.
Returns:
ProjectsResponse: The projects.
671 def get_project(self, project_name: str) -> ProjectDetail: 672 """Get a single project by name. 673 674 Unlike `get_projects`, this needs no authentication for a public project, and 675 carries the project's settings and member list rather than the counters. 676 677 A service account is refused here with HTTP 401, even for the project it 678 belongs to, though `get_project_users` on that same project works. 679 680 Args: 681 project_name: The name of the project. 682 683 Returns: 684 ProjectDetail: The project. 685 """ 686 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.
688 def get_me(self) -> Me: 689 """Get the authenticated user. 690 691 Requires authentication. The `name` shown here, not `display_name`, is what 692 `get_pages(filter_value=...)` expects. 693 694 Returns: 695 Me: The authenticated user. 696 697 Raises: 698 NotAuthenticatedError: If no credential was accepted. This endpoint does 699 not answer 401: without one it answers 200 with `{"isGuest": true}`, 700 which carries no user to return. 701 """ 702 payload = self._get_json("/users/me") 703 if "id" not in payload: 704 raise NotAuthenticatedError 705 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.
707 def get_file_info(self, file_id: str) -> FileInfo: 708 """Get the metadata of a file uploaded to a project. 709 710 Args: 711 file_id: The file id, optionally with an extension, or the full file URL. 712 713 Returns: 714 FileInfo: The metadata, including any text extracted from the file. 715 """ 716 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.
718 def preview_page_edit( 719 self, 720 project_name: str, 721 changes: Sequence[PageChange], 722 page_id: str | None = None, 723 ) -> EditPreviewResponse: 724 """Dry-run an edit and get a preview id for it. 725 726 Nothing is written until the returned preview id is passed to 727 `submit_page_edit`, and the preview expires a few minutes after it is issued. 728 Use `scrapbox.edits.changes_from_ops` to build `changes`. 729 730 Args: 731 project_name: The name of the project. 732 changes: The changes to apply, in order. 733 page_id: The id of the page to edit. Omit to create a new page. 734 735 Returns: 736 EditPreviewResponse: The preview id and the resulting page. 737 738 Raises: 739 PersonalAccessTokenRequiredError: If neither a personal access token nor a 740 service account access key is set. 741 """ 742 payload: dict[str, Any] = { 743 "changes": [ 744 change if isinstance(change, dict) else change.model_dump(by_alias=True, exclude_none=True) 745 for change in changes 746 ] 747 } 748 if page_id is not None: 749 payload["pageId"] = page_id 750 return EditPreviewResponse.model_validate( 751 self._post_json(f"/pages/v2/{project_name}/page-edit-for-ai/preview", payload) 752 )
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.
754 def submit_page_edit(self, project_name: str, preview_id: str) -> EditSubmitResponse: 755 """Commit an edit that was previewed earlier. 756 757 A preview id can only be submitted once, and the project must be the one the 758 preview was created for. 759 760 Args: 761 project_name: The name of the project. 762 preview_id: The preview id returned by `preview_page_edit`. 763 764 Returns: 765 EditSubmitResponse: The created commit and the page written to. 766 767 Raises: 768 PersonalAccessTokenRequiredError: If neither a personal access token nor a 769 service account access key is set. 770 """ 771 return EditSubmitResponse.model_validate( 772 self._post_json(f"/pages/v2/{project_name}/page-edit-for-ai/submit", {"previewId": preview_id}) 773 )
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.
775 def get_page_text(self, project_name: str, page_title: str) -> str: 776 """Get the text content of a page. 777 778 Args: 779 project_name: The name of the project. 780 page_title: The title of the page. 781 782 Returns: 783 str: The text content of the page. 784 """ 785 encoded_title = quote(page_title, safe="") 786 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.
788 def get_page_icon_url(self, project_name: str, page_title: str) -> str: 789 """Get the icon image URL for a page. 790 791 This method returns the redirect destination URL of the page icon. 792 793 Args: 794 project_name: The name of the project. 795 page_title: The title of the page. 796 797 Returns: 798 str: The URL of the icon image. 799 """ 800 encoded_title = quote(page_title, safe="") 801 url = f"{self.BASE_URL}/pages/{project_name}/{encoded_title}/icon" 802 803 response = self.client.get(url, follow_redirects=False) 804 805 if response.status_code == httpx.codes.FOUND: 806 return response.headers.get("location", "") 807 if response.status_code == httpx.codes.OK: 808 return url 809 response.raise_for_status() 810 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.
812 def get_file(self, file_id: str, *, thumbnail: bool = False) -> bytes: 813 """Get a file uploaded to Scrapbox. 814 815 Args: 816 file_id: The file ID (e.g., "1a2b3c4d5e6f7g8h9i0j.JPG") 817 or full URL (e.g., "https://scrapbox.io/files/1a2b3c4d5e6f7g8h9i0j.JPG" 818 or "https://gyazo.com/1a2b3c4d5e6f7g8h9i0j1a2b3c4d5e6f"). 819 thumbnail: Fetch the scaled down version. Files that have no thumbnail 820 (anything but JPEG and PNG) come back at full size. Ignored for Gyazo 821 URLs, which are resolved through oEmbed instead. 822 823 Returns: 824 bytes: The binary data of the file. 825 """ 826 url = file_id if file_id.startswith(("http://", "https://")) else f"https://scrapbox.io/files/{file_id}" 827 828 parsed_url = urlparse(url) 829 is_gyazo = "gyazo.com" in (parsed_url.hostname or "") 830 params = {"type": "thumbnail"} if thumbnail and not is_gyazo else None 831 if is_gyazo: 832 # If URL already has a file extension (e.g., .mp4, .jpg), directly convert to i.gyazo.com 833 path = parsed_url.path.strip("/") 834 if "." in path.split("/")[-1]: # Check if last path segment has extension 835 url = f"https://i.gyazo.com/{path}" 836 else: 837 # Use oEmbed API to get the actual file URL 838 json = self._get_json("/oembed-proxy/gyazo", {"url": url}) 839 if (oembed_type := json.get("type")) not in ("photo", "video"): 840 msg = f"Unsupported Gyazo oEmbed type: {oembed_type}" 841 raise ValueError(msg) 842 oembed_data = GyazoOEmbedResponse.model_validate(json) 843 if isinstance(oembed_data.root, GyazoOEmbedResponsePhoto): 844 url = oembed_data.root.url 845 else: # video 846 # Extract Gyazo ID from the original URL and construct direct video URL 847 gyazo_id = parsed_url.path.strip("/") 848 url = f"https://i.gyazo.com/{gyazo_id}.mp4" 849 response = self.client.get(url, params=params) 850 response.raise_for_status() 851 852 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.