Skip to content

Commit 02df505

Browse files
feat(karakeep): add parse_karakeep recursive fan-out type
Resolves a Karakeep selector to bookmarks, materializes each bookmark's stored artifact to a temp file via the cached resolver, and emits one local_html/txt/pdf DocDict per bookmark (loading_failure=warn so one bad bookmark does not abort the selection). Registered in recursive_types_func_mapping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fa6ecbc commit 02df505

1 file changed

Lines changed: 128 additions & 0 deletions

File tree

wdoc/utils/load_recursive.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import os
23
import re
34
import tempfile
45
from loguru import logger
@@ -574,6 +575,132 @@ def _write_text(name: str, text: str) -> str:
574575
return doclist
575576

576577

578+
def parse_karakeep(
579+
cli_kwargs: dict,
580+
path: Union[str, Path],
581+
karakeep_api_endpoint: Optional[str] = None,
582+
karakeep_api_key: Optional[str] = None,
583+
karakeep_verify_ssl: bool = True,
584+
karakeep_content_source: Literal["auto", "native", "wdoc"] = "auto",
585+
**extra_args,
586+
) -> List[DocDict]:
587+
"""
588+
Turn a DocDict that has `filetype==karakeep` into one DocDict per loadable
589+
bookmark of the selected Karakeep source.
590+
591+
The `path` carries the selector (see `parse_selector`): a list name by
592+
default, or one of `tag:...`, `search:...`, `ids:...`, `library`/`*`,
593+
`favourites`, `archived`. Each bookmark resolves to a single sub-document:
594+
- a `local_html` doc for a link bookmark's stored crawled html;
595+
- a `txt` doc for a text bookmark or an asset's pre-extracted text;
596+
- a `pdf` doc for a downloaded stored pdf/archive asset.
597+
A compact metadata header (title / url / author / tags / note / summary) is
598+
prepended to text and html docs. The live bookmarked url is never re-fetched.
599+
600+
Args:
601+
cli_kwargs: Base CLI arguments to inherit
602+
path: The Karakeep selector (see above)
603+
karakeep_api_endpoint: Karakeep API endpoint, else KARAKEEP_PYTHON_API_ENDPOINT
604+
karakeep_api_key: Karakeep api key, else KARAKEEP_PYTHON_API_KEY
605+
karakeep_verify_ssl: verify the instance's TLS certificate
606+
karakeep_content_source: 'auto' (stored text/html, else stored pdf), 'native'
607+
(stored extracted text/html only), or 'wdoc' (prefer the stored
608+
pdf/archive asset, parsed by wdoc's loaders). None re-fetch the live url.
609+
**extra_args: Additional arguments to pass to each document
610+
611+
Returns:
612+
List of DocDict objects, each a loadable sub-document
613+
"""
614+
from wdoc.utils.loaders.karakeep import (
615+
bookmark_web_url,
616+
cached_karakeep_content,
617+
get_karakeep_client,
618+
parse_selector,
619+
resolve_bookmarks,
620+
)
621+
622+
logger.info(f"Loading karakeep selector: '{path}'")
623+
selector = parse_selector(path)
624+
resolved_endpoint = karakeep_api_endpoint or os.environ.get(
625+
"KARAKEEP_PYTHON_API_ENDPOINT"
626+
)
627+
client = get_karakeep_client(
628+
api_endpoint=karakeep_api_endpoint,
629+
api_key=karakeep_api_key,
630+
verify_ssl=karakeep_verify_ssl,
631+
)
632+
bookmarks = resolve_bookmarks(client, selector)
633+
assert bookmarks, f"No Karakeep bookmarks found for selector '{path}'"
634+
635+
temp_dir = Path(tempfile.mkdtemp(prefix="wdoc_karakeep_"))
636+
recur_parent_id = str(uuid.uuid4())
637+
doclist: List[DocDict] = []
638+
639+
def _emit(filetype: str, location: str, title: str, subitem: str):
640+
doc_kwargs = cli_kwargs.copy()
641+
for k in list(doc_kwargs.keys()):
642+
if k.startswith("karakeep_"):
643+
del doc_kwargs[k]
644+
doc_kwargs.pop("filetype", None)
645+
doc_kwargs["path"] = location
646+
doc_kwargs["filetype"] = filetype
647+
doc_kwargs["title"] = title
648+
doc_kwargs["subitem_link"] = subitem or ""
649+
doc_kwargs.update(extra_args)
650+
doc_kwargs["recur_parent_id"] = recur_parent_id
651+
# A fan-out over a whole library will inevitably hit individual
652+
# bookmarks that fail to load (an empty crawl, a corrupt asset, ...).
653+
# Degrade gracefully: warn and skip that sub-document instead of
654+
# crashing the entire selection. The batch still raises if every single
655+
# sub-document fails.
656+
doc_kwargs["loading_failure"] = "warn"
657+
doclist.append(DocDict(doc_kwargs))
658+
659+
for bookmark in bookmarks:
660+
bid = bookmark.get("id")
661+
title = (
662+
bookmark.get("title")
663+
or (bookmark.get("content") or {}).get("title")
664+
or "Untitled"
665+
)
666+
try:
667+
descriptor = cached_karakeep_content(
668+
bid,
669+
bookmark.get("modifiedAt"),
670+
karakeep_content_source,
671+
resolved_endpoint,
672+
client=client,
673+
bookmark=bookmark,
674+
)
675+
except Exception as err:
676+
logger.warning(f"Could not resolve Karakeep bookmark {bid}: {err}")
677+
continue
678+
if not descriptor:
679+
logger.warning(
680+
f"Karakeep bookmark {bid} ('{title}') has no usable stored "
681+
f"content for source '{karakeep_content_source}', skipping"
682+
)
683+
continue
684+
685+
out = temp_dir / f"{bid}{descriptor['suffix']}"
686+
if descriptor["data"] is not None:
687+
out.write_bytes(descriptor["data"])
688+
else:
689+
out.write_text(descriptor["text"])
690+
_emit(
691+
descriptor["filetype"],
692+
str(out),
693+
title,
694+
bookmark_web_url(bookmark, resolved_endpoint),
695+
)
696+
697+
assert doclist, (
698+
f"Karakeep selector '{path}' produced no loadable documents "
699+
f"(bookmarks found: {len(bookmarks)})"
700+
)
701+
return doclist
702+
703+
577704
@memoizer
578705
def parse_load_functions(load_functions: Tuple[str, ...]) -> bytes:
579706
load_functions = list(load_functions)
@@ -601,5 +728,6 @@ def parse_load_functions(load_functions: Tuple[str, ...]) -> bytes:
601728
"youtube_playlist": parse_youtube_playlist,
602729
"ddg": parse_ddg_search,
603730
"zotero": parse_zotero,
731+
"karakeep": parse_karakeep,
604732
"auto": None,
605733
}

0 commit comments

Comments
 (0)