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

Scrapbox API client.

This client provides methods to interact with the Scrapbox API, including retrieving page lists, page details, page text, and files.

ScrapboxClient( connect_sid: str | None = None, pat: str | None = None, service_account_key: str | None = None, transport: httpx.BaseTransport | None = None)
151    def __init__(
152        self,
153        connect_sid: str | None = None,
154        pat: str | None = None,
155        service_account_key: str | None = None,
156        transport: httpx.BaseTransport | None = None,
157    ) -> None:
158        """Initialize the Scrapbox API client.
159
160        Authentication is optional for public projects. Only one credential is ever
161        sent: given more than one, a personal access token wins over a service
162        account key, which in turn wins over a cookie.
163
164        Args:
165            connect_sid: Scrapbox authentication cookie (connect.sid).
166            pat: Scrapbox personal access token, sent as the `x-personal-access-token`
167                header.
168            service_account_key: Access key of a service account, sent as the
169                `x-service-account-access-key` header. A service account is registered
170                on one project of a Business plan and can read and write only that
171                one: any other project, even a public one, answers 400. It stands for
172                no user, so `get_me` and `get_projects` are out of its reach, and
173                `get_project` refuses it as well.
174            transport: Transport used by the underlying HTTP client. Intended for
175                tests, which pass an `httpx.MockTransport` so that header handling
176                is still exercised.
177        """
178        self.pat = pat
179        self.service_account_key = None if pat else service_account_key
180        self.connect_sid = None if pat or service_account_key else connect_sid
181        self.client = httpx.Client(
182            cookies={"connect.sid": self.connect_sid} if self.connect_sid else None,
183            follow_redirects=True,
184            transport=transport,
185        )
186        if self.pat or self.service_account_key:
187            # Attach the credential per request instead of as a default header:
188            # get_file() follows redirects to third-party hosts (Gyazo), which must
189            # not receive it.
190            self.client.event_hooks["request"].append(self._attach_credential)

Initialize the Scrapbox API client.

Authentication is optional for public projects. Only one credential is ever sent: given more than one, a personal access token wins over a service account key, which in turn wins over a cookie.

Arguments:
  • connect_sid: Scrapbox authentication cookie (connect.sid).
  • pat: Scrapbox personal access token, sent as the x-personal-access-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 httpx.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:
213    def close(self) -> None:
214        """Close the HTTP client."""
215        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:
298    def get_pages(
299        self,
300        project_name: str,
301        skip: int = 0,
302        limit: int = 100,
303        sort: PageSort | None = None,
304        filter_value: str | None = None,
305    ) -> PageListResponse:
306        """Get a list of pages from a project.
307
308        The page bodies are not included. At most 1000 pages come back per request,
309        so `skip` is how a whole project is walked.
310
311        Args:
312            project_name: The name of the project.
313            skip: Number of pages to skip (default: 0).
314            limit: Number of pages to retrieve (default: 100, max: 1000).
315            sort: Order of the returned pages (default: the API's own, `updated`).
316            filter_value: Keep only the pages carrying a `[<value>.icon]` reference,
317                plus the pages that user has edited. Use the login name, not the
318                display name.
319
320        Returns:
321            PageListResponse: The response containing the page list.
322
323        Raises:
324            ValueError: If `limit` is outside 1 to `MAX_PAGE_SIZE`.
325        """
326        check_page_size(limit, "limit")
327        params: dict[str, Any] = {"skip": skip, "limit": limit}
328        if sort is not None:
329            params["sort"] = sort
330        if filter_value is not None:
331            params["filterType"] = "icon"
332            params["filterValue"] = filter_value
333        return PageListResponse.model_validate(self._get_json(f"/pages/{project_name}", params))

Get a list of pages from a project.

The page bodies are not included. At most 1000 pages come back per request, so skip is how a whole project is walked.

Arguments:
  • project_name: The name of the project.
  • skip: Number of pages to skip (default: 0).
  • limit: Number of pages to retrieve (default: 100, max: 1000).
  • sort: Order of the returned pages (default: the API's own, updated).
  • filter_value: Keep only the pages carrying a [<value>.icon] reference, plus the pages that user has edited. Use the login name, not the display name.
Returns:

PageListResponse: The response containing the page list.

Raises:
  • ValueError: If limit is outside 1 to MAX_PAGE_SIZE.
def get_page(self, project_name: str, page_title: str) -> PageDetail:
335    def get_page(self, project_name: str, page_title: str) -> PageDetail:
336        """Get detailed information about a specific page.
337
338        Args:
339            project_name: The name of the project.
340            page_title: The title of the page.
341
342        Returns:
343            PageDetail: The detailed information about the page.
344        """
345        encoded_title = quote(page_title, safe="")
346        return PageDetail.model_validate(self._get_json(f"/pages/{project_name}/{encoded_title}"))

Get detailed information about a specific page.

Arguments:
  • project_name: The name of the project.
  • page_title: The title of the page.
Returns:

PageDetail: The detailed information about the page.

def get_page_v2(self, project_name: str, page_title: str) -> PageDetailV2:
348    def get_page_v2(self, project_name: str, page_title: str) -> PageDetailV2:
349        """Get detailed information about a page from the v2 endpoint.
350
351        Compared with `get_page`, this carries the normalized `links_lc` /`icons_lc` /
352        `project_links_lc` fields but no embedded related pages; use `get_links_1hop`
353        and `get_links_2hop` for those.
354
355        Args:
356            project_name: The name of the project.
357            page_title: The title of the page.
358
359        Returns:
360            PageDetailV2: The detailed information about the page.
361        """
362        encoded_title = quote(page_title, safe="")
363        return PageDetailV2.model_validate(self._get_json(f"/pages/v2/{project_name}/{encoded_title}"))

Get detailed information about a page from the v2 endpoint.

Compared with get_page, this carries the normalized links_lc /icons_lc / project_links_lc fields but no embedded related pages; use get_links_1hop and get_links_2hop for those.

Arguments:
  • project_name: The name of the project.
  • page_title: The title of the page.
Returns:

PageDetailV2: The detailed information about the page.

def search_pages( self, project_name: str, query: str, *, match_any: bool = False, sort: Literal['pageRank', 'updated'] | None = None) -> SearchResponse:
585    def search_pages(
586        self,
587        project_name: str,
588        query: str,
589        *,
590        match_any: bool = False,
591        sort: SearchSort | None = None,
592    ) -> SearchResponse:
593        """Search the full text of the pages in a project.
594
595        Args:
596            project_name: The name of the project.
597            query: The search query.
598            match_any: Match pages containing any of the words instead of all of them.
599            sort: Order of the results (default: the API's own, `pageRank`).
600
601        Returns:
602            SearchResponse: The matching pages.
603        """
604        params: dict[str, Any] = {"q": query}
605        if match_any:
606            params["op"] = "or"
607        if sort is not None:
608            params["sort"] = sort
609        return SearchResponse.model_validate(self._get_json(f"/pages/{project_name}/search/query", params))

Search the full text of the pages in a project.

Arguments:
  • project_name: The name of the project.
  • query: The search query.
  • match_any: Match pages containing any of the words instead of all of them.
  • sort: Order of the results (default: the API's own, pageRank).
Returns:

SearchResponse: The matching pages.

def search_titles_by_vector( self, project_name: str, query: str) -> VectorSearchResponse:
611    def search_titles_by_vector(self, project_name: str, query: str) -> VectorSearchResponse:
612        """Search pages by vector similarity.
613
614        Only page titles and the link notations in page bodies are searched; ordinary
615        body text is not.
616
617        Args:
618            project_name: The name of the project.
619            query: The search query.
620
621        Returns:
622            VectorSearchResponse: The matching pages, most similar first.
623
624        Raises:
625            SearchServerUpdatingError: If the search backend is temporarily updating.
626                Retrying later usually succeeds.
627        """
628        return VectorSearchResponse.model_validate(
629            self._get_json(f"/pages/{project_name}/search/vector/titles", {"q": query})
630        )

Search pages by vector similarity.

Only page titles and the link notations in page bodies are searched; ordinary body text is not.

Arguments:
  • project_name: The name of the project.
  • query: The search query.
Returns:

VectorSearchResponse: The matching pages, most similar first.

Raises:
  • SearchServerUpdatingError: If the search backend is temporarily updating. Retrying later usually succeeds.
def get_commits( self, project_name: str, page_id: str, since: str | None = None) -> CommitsResponse:
632    def get_commits(self, project_name: str, page_id: str, since: str | None = None) -> CommitsResponse:
633        """Get the edit history of a page.
634
635        The history is keyed by page id rather than title, so it can be followed
636        across renames.
637
638        Args:
639            project_name: The name of the project.
640            page_id: The immutable id of the page.
641            since: Return only the commits after this commit id. Omit for the whole
642                history.
643
644        Returns:
645            CommitsResponse: The commits, oldest first.
646        """
647        params = {"head": since} if since is not None else None
648        return CommitsResponse.model_validate(self._get_json(f"/commits/{project_name}/{page_id}", params))

Get the edit history of a page.

The history is keyed by page id rather than title, so it can be followed across renames.

Arguments:
  • project_name: The name of the project.
  • page_id: The immutable id of the page.
  • since: Return only the commits after this commit id. Omit for the whole history.
Returns:

CommitsResponse: The commits, oldest first.

def get_project_users(self, project_name: str) -> ProjectUsersResponse:
650    def get_project_users(self, project_name: str) -> ProjectUsersResponse:
651        """Get the members of a project.
652
653        Args:
654            project_name: The name of the project.
655
656        Returns:
657            ProjectUsersResponse: Current members, departed members and service accounts.
658        """
659        return ProjectUsersResponse.model_validate(self._get_json(f"/projects/{project_name}/users"))

Get the members of a project.

Arguments:
  • project_name: The name of the project.
Returns:

ProjectUsersResponse: Current members, departed members and service accounts.

def get_projects(self) -> ProjectsResponse:
661    def get_projects(self) -> ProjectsResponse:
662        """Get the projects the authenticated user belongs to.
663
664        Requires authentication.
665
666        Returns:
667            ProjectsResponse: The projects.
668        """
669        return ProjectsResponse.model_validate(self._get_json("/projects"))

Get the projects the authenticated user belongs to.

Requires authentication.

Returns:

ProjectsResponse: The projects.

def get_project(self, project_name: str) -> ProjectDetail:
671    def get_project(self, project_name: str) -> ProjectDetail:
672        """Get a single project by name.
673
674        Unlike `get_projects`, this needs no authentication for a public project, and
675        carries the project's settings and member list rather than the counters.
676
677        A service account is refused here with HTTP 401, even for the project it
678        belongs to, though `get_project_users` on that same project works.
679
680        Args:
681            project_name: The name of the project.
682
683        Returns:
684            ProjectDetail: The project.
685        """
686        return ProjectDetail.model_validate(self._get_json(f"/projects/{project_name}"))

Get a single project by name.

Unlike get_projects, this needs no authentication for a public project, and carries the project's settings and member list rather than the counters.

A service account is refused here with HTTP 401, even for the project it belongs to, though get_project_users on that same project works.

Arguments:
  • project_name: The name of the project.
Returns:

ProjectDetail: The project.

def get_me(self) -> Me:
688    def get_me(self) -> Me:
689        """Get the authenticated user.
690
691        Requires authentication. The `name` shown here, not `display_name`, is what
692        `get_pages(filter_value=...)` expects.
693
694        Returns:
695            Me: The authenticated user.
696
697        Raises:
698            NotAuthenticatedError: If no credential was accepted. This endpoint does
699                not answer 401: without one it answers 200 with `{"isGuest": true}`,
700                which carries no user to return.
701        """
702        payload = self._get_json("/users/me")
703        if "id" not in payload:
704            raise NotAuthenticatedError
705        return Me.model_validate(payload)

Get the authenticated user.

Requires authentication. The name shown here, not display_name, is what get_pages(filter_value=...) expects.

Returns:

Me: The authenticated user.

Raises:
  • NotAuthenticatedError: If no credential was accepted. This endpoint does not answer 401: without one it answers 200 with {"isGuest": true}, which carries no user to return.
def get_file_info(self, file_id: str) -> FileInfo:
707    def get_file_info(self, file_id: str) -> FileInfo:
708        """Get the metadata of a file uploaded to a project.
709
710        Args:
711            file_id: The file id, optionally with an extension, or the full file URL.
712
713        Returns:
714            FileInfo: The metadata, including any text extracted from the file.
715        """
716        return FileInfo.model_validate(self._get_json(f"/gcs/{bare_file_id(file_id)}/info"))

Get the metadata of a file uploaded to a project.

Arguments:
  • file_id: The file id, optionally with an extension, or the full file URL.
Returns:

FileInfo: The metadata, including any text extracted from the file.

def preview_page_edit(unknown):
718    def preview_page_edit(
719        self,
720        project_name: str,
721        changes: Sequence[PageChange],
722        page_id: str | None = None,
723    ) -> EditPreviewResponse:
724        """Dry-run an edit and get a preview id for it.
725
726        Nothing is written until the returned preview id is passed to
727        `submit_page_edit`, and the preview expires a few minutes after it is issued.
728        Use `scrapbox.edits.changes_from_ops` to build `changes`.
729
730        Args:
731            project_name: The name of the project.
732            changes: The changes to apply, in order.
733            page_id: The id of the page to edit. Omit to create a new page.
734
735        Returns:
736            EditPreviewResponse: The preview id and the resulting page.
737
738        Raises:
739            PersonalAccessTokenRequiredError: If neither a personal access token nor a
740                service account access key is set.
741        """
742        payload: dict[str, Any] = {
743            "changes": [
744                change if isinstance(change, dict) else change.model_dump(by_alias=True, exclude_none=True)
745                for change in changes
746            ]
747        }
748        if page_id is not None:
749            payload["pageId"] = page_id
750        return EditPreviewResponse.model_validate(
751            self._post_json(f"/pages/v2/{project_name}/page-edit-for-ai/preview", payload)
752        )

Dry-run an edit and get a preview id for it.

Nothing is written until the returned preview id is passed to submit_page_edit, and the preview expires a few minutes after it is issued. Use scrapbox.edits.changes_from_ops to build changes.

Arguments:
  • project_name: The name of the project.
  • changes: The changes to apply, in order.
  • page_id: The id of the page to edit. Omit to create a new page.
Returns:

EditPreviewResponse: The preview id and the resulting page.

Raises:
  • PersonalAccessTokenRequiredError: If neither a personal access token nor a service account access key is set.
def submit_page_edit( self, project_name: str, preview_id: str) -> EditSubmitResponse:
754    def submit_page_edit(self, project_name: str, preview_id: str) -> EditSubmitResponse:
755        """Commit an edit that was previewed earlier.
756
757        A preview id can only be submitted once, and the project must be the one the
758        preview was created for.
759
760        Args:
761            project_name: The name of the project.
762            preview_id: The preview id returned by `preview_page_edit`.
763
764        Returns:
765            EditSubmitResponse: The created commit and the page written to.
766
767        Raises:
768            PersonalAccessTokenRequiredError: If neither a personal access token nor a
769                service account access key is set.
770        """
771        return EditSubmitResponse.model_validate(
772            self._post_json(f"/pages/v2/{project_name}/page-edit-for-ai/submit", {"previewId": preview_id})
773        )

Commit an edit that was previewed earlier.

A preview id can only be submitted once, and the project must be the one the preview was created for.

Arguments:
  • project_name: The name of the project.
  • preview_id: The preview id returned by preview_page_edit.
Returns:

EditSubmitResponse: The created commit and the page written to.

Raises:
  • PersonalAccessTokenRequiredError: If neither a personal access token nor a service account access key is set.
def get_page_text(self, project_name: str, page_title: str) -> str:
775    def get_page_text(self, project_name: str, page_title: str) -> str:
776        """Get the text content of a page.
777
778        Args:
779            project_name: The name of the project.
780            page_title: The title of the page.
781
782        Returns:
783            str: The text content of the page.
784        """
785        encoded_title = quote(page_title, safe="")
786        return self._get(f"/pages/{project_name}/{encoded_title}/text").text

Get the text content of a page.

Arguments:
  • project_name: The name of the project.
  • page_title: The title of the page.
Returns:

str: The text content of the page.

def get_page_icon_url(self, project_name: str, page_title: str) -> str:
788    def get_page_icon_url(self, project_name: str, page_title: str) -> str:
789        """Get the icon image URL for a page.
790
791        This method returns the redirect destination URL of the page icon.
792
793        Args:
794            project_name: The name of the project.
795            page_title: The title of the page.
796
797        Returns:
798            str: The URL of the icon image.
799        """
800        encoded_title = quote(page_title, safe="")
801        url = f"{self.BASE_URL}/pages/{project_name}/{encoded_title}/icon"
802
803        response = self.client.get(url, follow_redirects=False)
804
805        if response.status_code == httpx.codes.FOUND:
806            return response.headers.get("location", "")
807        if response.status_code == httpx.codes.OK:
808            return url
809        response.raise_for_status()
810        return url

Get the icon image URL for a page.

This method returns the redirect destination URL of the page icon.

Arguments:
  • project_name: The name of the project.
  • page_title: The title of the page.
Returns:

str: The URL of the icon image.

def get_file(self, file_id: str, *, thumbnail: bool = False) -> bytes:
812    def get_file(self, file_id: str, *, thumbnail: bool = False) -> bytes:
813        """Get a file uploaded to Scrapbox.
814
815        Args:
816            file_id: The file ID (e.g., "1a2b3c4d5e6f7g8h9i0j.JPG")
817                or full URL (e.g., "https://scrapbox.io/files/1a2b3c4d5e6f7g8h9i0j.JPG"
818                or "https://gyazo.com/1a2b3c4d5e6f7g8h9i0j1a2b3c4d5e6f").
819            thumbnail: Fetch the scaled down version. Files that have no thumbnail
820                (anything but JPEG and PNG) come back at full size. Ignored for Gyazo
821                URLs, which are resolved through oEmbed instead.
822
823        Returns:
824            bytes: The binary data of the file.
825        """
826        url = file_id if file_id.startswith(("http://", "https://")) else f"https://scrapbox.io/files/{file_id}"
827
828        parsed_url = urlparse(url)
829        is_gyazo = "gyazo.com" in (parsed_url.hostname or "")
830        params = {"type": "thumbnail"} if thumbnail and not is_gyazo else None
831        if is_gyazo:
832            # If URL already has a file extension (e.g., .mp4, .jpg), directly convert to i.gyazo.com
833            path = parsed_url.path.strip("/")
834            if "." in path.split("/")[-1]:  # Check if last path segment has extension
835                url = f"https://i.gyazo.com/{path}"
836            else:
837                # Use oEmbed API to get the actual file URL
838                json = self._get_json("/oembed-proxy/gyazo", {"url": url})
839                if (oembed_type := json.get("type")) not in ("photo", "video"):
840                    msg = f"Unsupported Gyazo oEmbed type: {oembed_type}"
841                    raise ValueError(msg)
842                oembed_data = GyazoOEmbedResponse.model_validate(json)
843                if isinstance(oembed_data.root, GyazoOEmbedResponsePhoto):
844                    url = oembed_data.root.url
845                else:  # video
846                    # Extract Gyazo ID from the original URL and construct direct video URL
847                    gyazo_id = parsed_url.path.strip("/")
848                    url = f"https://i.gyazo.com/{gyazo_id}.mp4"
849        response = self.client.get(url, params=params)
850        response.raise_for_status()
851
852        return response.content

Get a file uploaded to Scrapbox.

Arguments:
  • file_id: The file ID (e.g., "1a2b3c4d5e6f7g8h9i0j.JPG") or full URL (e.g., "https://scrapbox.io/files/1a2b3c4d5e6f7g8h9i0j.JPG" or "https://gyazo.com/1a2b3c4d5e6f7g8h9i0j1a2b3c4d5e6f").
  • thumbnail: Fetch the scaled down version. Files that have no thumbnail (anything but JPEG and PNG) come back at full size. Ignored for Gyazo URLs, which are resolved through oEmbed instead.
Returns:

bytes: The binary data of the file.

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.