scrapbox

scrapbox-client

PyPI version CI

A client for Scrapbox (Helpfeel Cosense)

日本語版: README.ja.md

Documentation

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() and get_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)
class Commit(scrapbox.models.ScrapboxModel):
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.

id: str = PydanticUndefined
kind: str | None = None
changes: list[scrapbox.models.InsertChange | scrapbox.models.UpdateChange | scrapbox.models.DeleteChange | scrapbox.models.TitleChange | dict[str, typing.Any]] = PydanticUndefined
parent_id: str | None = None
page_id: str | None = None
user_id: str | None = None
created: int | None = None
class CommitsResponse(scrapbox.models.ScrapboxModel):
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.

commits: list[Commit] = PydanticUndefined
class EditPreviewResponse(scrapbox.models.ScrapboxModel):
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.

preview_id: str = PydanticUndefined
expire_at: str | None = None
page_preview: scrapbox.models.PagePreview | None = None
class EditSubmitResponse(scrapbox.models.ScrapboxModel):
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.

commit_id: str = PydanticUndefined
page: scrapbox.models.SubmittedPage | None = None
class FileInfo(scrapbox.models.ScrapboxModel):
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.

id: str = PydanticUndefined
project_name: str | None = None
text: str | None = None

Text extracted from the file (OCR of an image, body of a PDF), truncated by the API.

originalname: str | None = None
content_type: str | None = None
size: int | None = None
class GyazoOEmbedResponse(pydantic.main.BaseModel, typing.Generic[~RootModelRootType]):
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.

See: https://gyazo.com/api/docs/image#oembed

class Line(scrapbox.models.ScrapboxModel):
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.

id: str = PydanticUndefined
text: str = PydanticUndefined
user_id: str = PydanticUndefined
created: int = PydanticUndefined
updated: int = PydanticUndefined
class LinkPage(scrapbox.models.ScrapboxModel):
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.

id: str = PydanticUndefined
title: str = PydanticUndefined
title_lc: str | None = None
image: str | None = None
descriptions: list[str] = PydanticUndefined
linked: int | None = None
page_rank: float | None = None
views: int | None = None
lines_count: int | None = None
chars_count: int | None = None
created: int | None = None
updated: int | None = None
accessed: int | None = None
last_accessed: int | None = None
user: User | None = None
last_update_user: User | None = None
users: list[User] = PydanticUndefined
infobox_definition: list[str] | None = None
infobox_result: list[scrapbox.models.InfoboxResult] | None = None
search: Any = None

Search highlight information, present only when the request carried a query.

class Links1hopResponse(scrapbox.models.ScrapboxModel):
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.

links1hop: list[LinkPage] = PydanticUndefined
chars_count: int | None = None
kcs_control_tags_lc: list[str] = PydanticUndefined
synonyms: list[typing.Any] = PydanticUndefined
search_backend: str | None = None
pagination: scrapbox.models.Pagination | None = None
class Links2hopResponse(scrapbox.models.ScrapboxModel):
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.

links2hop: list[LinkPage] = PydanticUndefined
hidden_headwords_lc: list[str] = PydanticUndefined
synonyms: list[typing.Any] = PydanticUndefined
search_backend: str | None = None
pagination: scrapbox.models.Pagination | None = None
class Me(scrapbox.User):
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.

provider: str | None = None
page_filters: list[scrapbox.models.PageFilter] = PydanticUndefined
created: int | None = None
updated: int | None = None
is_guest: bool | None = None
config: dict[str, typing.Any] = PydanticUndefined
class NotAuthenticatedError(scrapbox.ScrapboxError):
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.

NotAuthenticatedError()
39    def __init__(self) -> None:
40        """Initialize the error."""
41        super().__init__(
42            "Not authenticated. Pass pat= or connect_sid= to ScrapboxClient.",
43        )

Initialize the error.

class PageDetail(scrapbox.models.PageBase):
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.

related_pages: scrapbox.models.RelatedPages | None = None
class PageDetailV2(scrapbox.models.PageBase):
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.

icons_lc: list[str] = PydanticUndefined
class PageListItem(scrapbox.models.ScrapboxModel):
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.

id: str = PydanticUndefined
title: str = PydanticUndefined
image: str | None = None
descriptions: list[str] = PydanticUndefined
user: User = PydanticUndefined
last_update_user: User | None = None
pin: int = PydanticUndefined
views: int = PydanticUndefined
linked: int = PydanticUndefined
created: int = PydanticUndefined
updated: int = PydanticUndefined
accessed: int = PydanticUndefined
lines_count: int = PydanticUndefined
chars_count: int = PydanticUndefined
helpfeels: list[str] = PydanticUndefined
class PageListResponse(scrapbox.models.ScrapboxModel):
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.

project_name: str = PydanticUndefined
skip: int = PydanticUndefined
limit: int = PydanticUndefined
count: int = PydanticUndefined
pages: list[PageListItem] = PydanticUndefined
class PersonalAccessTokenRequiredError(scrapbox.ScrapboxError):
 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.

PersonalAccessTokenRequiredError(endpoint: str)
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.
endpoint
class Project(scrapbox.models.ScrapboxModel):
 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.

id: str = PydanticUndefined
name: str = PydanticUndefined
display_name: str | None = None
public_visible: bool | None = None
login_strategies: list[str] = PydanticUndefined
plan: str | None = None
additional_plans: dict[str, bool] = PydanticUndefined
alert: dict[str, Any] | None = None
users_count: int | None = None
is_member: bool | None = None
billing_id: str | None = None
created: int | None = None
updated: int | None = None
is_owner: bool | None = None
is_admin: bool | None = None
admins_count: int | None = None
class ProjectDetail(scrapbox.Project):
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.

theme: str | None = None
image: str | None = None
gyazo_teams_name: str | None = None
translation: bool | None = None
infobox: bool | None = None
disable_realtime_collaboration: bool | None = None
users: list[User] = PydanticUndefined

Members of the project. A public project answers with id and name alone.

class ProjectUsersResponse(scrapbox.models.ScrapboxModel):
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.

users: list[scrapbox.models.ProjectMember] = PydanticUndefined
member_snapshots: list[scrapbox.models.MemberSnapshot] = PydanticUndefined
service_accounts: list[scrapbox.models.ServiceAccount] = PydanticUndefined
service_account_snapshots: list[scrapbox.models.ServiceAccount] = PydanticUndefined
class ProjectsResponse(scrapbox.models.ScrapboxModel):
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.

projects: list[Project] = PydanticUndefined
class ScrapboxClient:
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.

ScrapboxClient( connect_sid: str | None = None, pat: str | None = None, service_account_key: str | None = None, transport: httpx2.BaseTransport | None = None)
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-token header.
  • service_account_key: Access key of a service account, sent as the x-service-account-access-key header. 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, so get_me and get_projects are out of its reach, and get_project refuses it as well.
  • transport: Transport used by the underlying HTTP client. Intended for tests, which pass an httpx2.MockTransport so that header handling is still exercised.
BASE_URL = 'https://scrapbox.io/api'
pat
service_account_key
connect_sid
client
def close(self) -> None:
223    def close(self) -> None:
224        """Close the HTTP client."""
225        self.client.close()

Close the HTTP client.

def get_pages( self, project_name: str, skip: int = 0, limit: int = 100, sort: Literal['updated', 'created', 'accessed', 'linked', 'views', 'title'] | None = None, filter_value: str | None = None) -> PageListResponse:
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 limit is outside 1 to MAX_PAGE_SIZE.
def get_page(self, project_name: str, page_title: str) -> PageDetail:
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.

def get_page_v2(self, project_name: str, page_title: str) -> PageDetailV2:
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.

def search_pages( self, project_name: str, query: str, *, match_any: bool = False, sort: Literal['pageRank', 'updated'] | None = None) -> SearchResponse:
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.

def search_titles_by_vector( self, project_name: str, query: str) -> VectorSearchResponse:
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.
def get_commits( self, project_name: str, page_id: str, since: str | None = None) -> CommitsResponse:
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.

def get_project_users(self, project_name: str) -> ProjectUsersResponse:
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.

def get_projects(self) -> ProjectsResponse:
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.

def get_project(self, project_name: str) -> ProjectDetail:
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.

def get_me(self) -> Me:
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.
def get_file_info(self, file_id: str) -> FileInfo:
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.

def preview_page_edit(unknown):
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.
def submit_page_edit( self, project_name: str, preview_id: str) -> EditSubmitResponse:
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.
def get_page_text(self, project_name: str, page_title: str) -> str:
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.

def get_page_icon_url(self, project_name: str, page_title: str) -> str:
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.

def get_file(self, file_id: str, *, thumbnail: bool = False) -> bytes:
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.

class ScrapboxError(builtins.Exception):
5class ScrapboxError(Exception):
6    """Base class for every error raised by this package."""

Base class for every error raised by this package.

class SearchResponse(scrapbox.models.ScrapboxModel):
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.

project_name: str | None = None
search_query: str | None = None
query: Any = None
field: str | None = None
backend: str | None = None
count: int | None = None
limit: int | None = None
exists_exact_title_match: bool | None = None
pages: list[scrapbox.models.SearchResultPage] = PydanticUndefined
class SearchServerUpdatingError(scrapbox.ScrapboxError):
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.

SearchServerUpdatingError(message: str | None = None)
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.
STATUS_CODE = 490

Non-standard HTTP status code used for this condition.

class User(scrapbox.models.ScrapboxModel):
20class User(ScrapboxModel):
21    """User information.
22
23    Only `id` is always present: a public project answers with `id` and `name` alone.
24    """
25
26    id: str
27    name: str | None = None
28    display_name: str | None = None
29    photo: str | None = None
30    email: str | None = None

User information.

Only id is always present: a public project answers with id and name alone.

id: str = PydanticUndefined
name: str | None = None
display_name: str | None = None
photo: str | None = None
email: str | None = None
class VectorSearchResponse(scrapbox.models.ScrapboxModel):
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.

pages: list[scrapbox.models.VectorSearchPage] = PydanticUndefined
def changes_from_ops( ops: Sequence[Mapping[str, typing.Any]]) -> list[scrapbox.models.InsertChange | scrapbox.models.UpdateChange | scrapbox.models.DeleteChange | scrapbox.models.TitleChange | dict[str, typing.Any]]:
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 ops is not a sequence of objects, or a field has the wrong type.
  • ValueError: If an op is malformed.
def new_line_id() -> str:
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.