Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions openviking/parse/accessors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,22 @@ def cleanup(self) -> None:
if not self.is_temporary:
return

if not self.path.exists():
cleanup_path_value = self.meta.get("_cleanup_path")
cleanup_path = Path(cleanup_path_value) if cleanup_path_value else self.path

if not cleanup_path.exists():
return

try:
if self.path.is_dir():
shutil.rmtree(self.path, ignore_errors=True)
if cleanup_path.is_dir():
shutil.rmtree(cleanup_path, ignore_errors=True)
else:
self.path.unlink(missing_ok=True)
cleanup_path.unlink(missing_ok=True)
except Exception as e:
from openviking_cli.utils.logger import get_logger

logger = get_logger(__name__)
logger.warning(f"[LocalResource] Failed to cleanup resource {self.path}: {e}")
logger.warning(f"[LocalResource] Failed to cleanup resource {cleanup_path}: {e}")

def __enter__(self) -> "LocalResource":
"""Support context manager protocol."""
Expand Down
210 changes: 199 additions & 11 deletions openviking/parse/accessors/feishu_accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
Install with: pip install 'openviking[bot-feishu]'
"""

import asyncio
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -20,9 +22,12 @@
from openviking_cli.utils.logger import get_logger

from .base import DataAccessor, LocalResource, SourceType
from .mime_types import get_preferred_extension

logger = get_logger(__name__)

_FEISHU_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(feishu://image/([^)]+)\)")


def _getattr_safe(obj, key: str, default=None):
"""Get attribute from SDK object or dict, with safe fallback."""
Expand Down Expand Up @@ -185,26 +190,48 @@ async def access(self, source: Union[str, Path], **kwargs) -> LocalResource:
feishu_access_token=feishu_access_token,
)

# Create temporary file
temp_file = tempfile.NamedTemporaryFile(
mode="w",
suffix=".md",
prefix="ov_feishu_",
delete=False,
# lark-oapi media downloads are synchronous; run them off the event
# loop so a slow Feishu request cannot block unrelated async work.
markdown_content, downloaded_images = await asyncio.to_thread(
self._resolve_image_refs,
doc.markdown_content,
feishu_access_token=feishu_access_token,
)
temp_file.write(doc.markdown_content)
temp_file.close()

# Build metadata
meta = {
"feishu_doc_type": doc.doc_type,
"feishu_token": doc.token,
"feishu_title": doc.title,
"original_filename": doc.title,
**doc.meta,
}

if downloaded_images:
temp_dir = Path(tempfile.mkdtemp(prefix="ov_feishu_"))
markdown_path = temp_dir / "document.md"
markdown_path.write_text(markdown_content, encoding="utf-8")
for rel_path, image_bytes in downloaded_images.items():
image_path = temp_dir / rel_path
image_path.parent.mkdir(parents=True, exist_ok=True)
image_path.write_bytes(image_bytes)
meta["_cleanup_path"] = str(temp_dir)
local_path = markdown_path
else:
# Create temporary file
temp_file = tempfile.NamedTemporaryFile(
mode="w",
suffix=".md",
prefix="ov_feishu_",
delete=False,
encoding="utf-8",
)
temp_file.write(markdown_content)
temp_file.close()
local_path = Path(temp_file.name)

return LocalResource(
path=Path(temp_file.name),
path=local_path,
source_type=SourceType.FEISHU,
original_source=source_str,
meta=meta,
Expand All @@ -226,8 +253,6 @@ async def _fetch_document(

This method extracts and adapts the logic from FeishuParser.parse().
"""
import asyncio

doc_type, token = self._parse_feishu_url(url)
title = None
meta = {}
Expand Down Expand Up @@ -590,6 +615,169 @@ def _handle_image(block, block_map: Dict = None, **_) -> Optional[str]:
alt_text = getattr(image, "alt", "") or "image"
return f"![{alt_text}](feishu://image/{file_token})"

# Image byte-magic signatures → file extension. Sniffed from the raw bytes
# first, since the actual content is authoritative over a (possibly generic
# or wrong) Content-Type header.
_IMAGE_MAGIC = (
(b"\x89PNG\r\n\x1a\n", ".png"),
(b"\xff\xd8\xff", ".jpg"),
(b"GIF87a", ".gif"),
(b"GIF89a", ".gif"),
(b"BM", ".bmp"),
)

@classmethod
def _guess_image_ext(cls, content: bytes, content_type: Optional[str]) -> str:
"""Infer an image file extension from the bytes, then Content-Type.

Feishu media are not guaranteed to be PNG, so we avoid a hardcoded
extension that would misrepresent JPEG/WebP/GIF bytes to downstream
consumers (e.g. emitting JPEG bytes as ``data:image/png``). Byte magic
is checked first because the payload is authoritative; the response
Content-Type is only a fallback for formats we do not sniff here.
"""
# WebP: "RIFF....WEBP"
if len(content) >= 12 and content[:4] == b"RIFF" and content[8:12] == b"WEBP":
return ".webp"
for magic, ext in cls._IMAGE_MAGIC:
if content.startswith(magic):
return ext
if content_type:
ext = get_preferred_extension(content_type)
if ext:
return ext
return ".png"

@staticmethod
def _image_filename(file_token: str, ext: str = ".png") -> str:
"""Return a conservative local filename for a Feishu media token."""
safe_token = re.sub(r"[^A-Za-z0-9_.-]+", "_", file_token).strip("._")
if not ext.startswith("."):
ext = f".{ext}"
return f"{safe_token or 'image'}{ext}"

def _download_image(
self,
file_token: str,
*,
feishu_access_token: Optional[str] = None,
) -> Optional[Tuple[bytes, Optional[str]]]:
"""Download an image from Feishu Drive API by file token.

Returns a ``(content, content_type)`` tuple, or ``None`` on failure.
"""
import lark_oapi as lark

client = self._get_client(use_user_token=bool(feishu_access_token))
# Match the auth mode used to fetch the document: with a user access
# token the request must advertise USER, otherwise lark-oapi never
# injects it (see lark_oapi.core.token.auth.verify) and the download
# silently fails — dropping images from user-token imports.
token_type = (
lark.AccessTokenType.USER
if feishu_access_token
else lark.AccessTokenType.TENANT
)
raw_req = (
lark.BaseRequest.builder()
.http_method(lark.HttpMethod.GET)
.uri(f"/open-apis/drive/v1/medias/{file_token}/download")
.token_types({token_type})
.build()
)
option = self._user_request_option(feishu_access_token)

try:
raw_resp = client.request(raw_req) if option is None else client.request(raw_req, option)
except Exception as exc:
logger.warning("[FeishuAccessor] Error downloading image %s: %s", file_token, exc)
return None

if not raw_resp.success():
raw = getattr(raw_resp, "raw", None)
http_status = getattr(raw, "status_code", None)
detail = getattr(raw_resp, "msg", "") or f"HTTP {http_status}"
if http_status == 403:
detail = (
f"{detail} (missing Feishu permission docs:document.media:download)"
)
logger.warning(
"[FeishuAccessor] Failed to download image %s: code=%s, http=%s, msg=%s",
file_token,
getattr(raw_resp, "code", None),
http_status,
detail,
)
return None

raw = getattr(raw_resp, "raw", None)
content = getattr(raw, "content", None)
if not content:
logger.warning("[FeishuAccessor] Empty image response for %s", file_token)
return None
return content, self._response_content_type(raw)

@staticmethod
def _response_content_type(raw) -> Optional[str]:
"""Best-effort extraction of the Content-Type header from a lark raw response."""
headers = getattr(raw, "headers", None)
if not headers:
return None
# lark's raw.headers may be a plain dict or a case-insensitive mapping.
try:
get = headers.get
except AttributeError:
return None
return get("Content-Type") or get("content-type")

def _resolve_image_refs(
self,
markdown: str,
*,
feishu_access_token: Optional[str] = None,
) -> Tuple[str, Dict[str, bytes]]:
"""Download Feishu image refs and rewrite them to local relative paths."""
config = self._get_config()
if not getattr(config, "download_images", True):
return markdown, {}

matches = list(_FEISHU_IMAGE_RE.finditer(markdown))
if not matches:
return markdown, {}

token_to_rel_path: Dict[str, str] = {}
downloaded_images: Dict[str, bytes] = {}
for match in matches:
file_token = match.group(2)
if file_token in token_to_rel_path:
continue

downloaded = self._download_image(
file_token,
feishu_access_token=feishu_access_token,
)
if downloaded is None:
continue
image_bytes, content_type = downloaded

ext = self._guess_image_ext(image_bytes, content_type)
rel_path = f"images/{self._image_filename(file_token, ext)}"
token_to_rel_path[file_token] = rel_path
downloaded_images[rel_path] = image_bytes

if not downloaded_images:
return markdown, {}

def _replace(match: re.Match[str]) -> str:
alt_text = match.group(1)
file_token = match.group(2)
rel_path = token_to_rel_path.get(file_token)
if not rel_path:
return match.group(0)
return f"![{alt_text}]({rel_path})"

return _FEISHU_IMAGE_RE.sub(_replace, markdown), downloaded_images

def _extract_block_text(self, block, attr_name: str) -> str:
"""Extract text from a block's named attribute (e.g. block.text, block.heading2)."""
content_obj = getattr(block, attr_name, None)
Expand Down
4 changes: 1 addition & 3 deletions openviking_cli/utils/config/parser_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,9 +507,7 @@ class FeishuConfig(ParserConfig):
domain: str = "https://open.feishu.cn"
max_rows_per_sheet: int = 1000
max_records_per_table: int = 1000
download_images: bool = (
True # TODO: not yet implemented, reserved for future image download support
)
download_images: bool = True
request_timeout: float = (
30.0 # TODO: not yet passed to lark-oapi client, reserved for future use
)
Expand Down
Loading
Loading