markitai 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. markitai/__init__.py +3 -0
  2. markitai/batch.py +1316 -0
  3. markitai/cli.py +3979 -0
  4. markitai/config.py +602 -0
  5. markitai/config.schema.json +748 -0
  6. markitai/constants.py +222 -0
  7. markitai/converter/__init__.py +49 -0
  8. markitai/converter/_patches.py +98 -0
  9. markitai/converter/base.py +164 -0
  10. markitai/converter/image.py +181 -0
  11. markitai/converter/legacy.py +606 -0
  12. markitai/converter/office.py +526 -0
  13. markitai/converter/pdf.py +679 -0
  14. markitai/converter/text.py +63 -0
  15. markitai/fetch.py +1725 -0
  16. markitai/image.py +1335 -0
  17. markitai/json_order.py +550 -0
  18. markitai/llm.py +4339 -0
  19. markitai/ocr.py +347 -0
  20. markitai/prompts/__init__.py +159 -0
  21. markitai/prompts/cleaner.md +93 -0
  22. markitai/prompts/document_enhance.md +77 -0
  23. markitai/prompts/document_enhance_complete.md +65 -0
  24. markitai/prompts/document_process.md +60 -0
  25. markitai/prompts/frontmatter.md +28 -0
  26. markitai/prompts/image_analysis.md +21 -0
  27. markitai/prompts/image_caption.md +8 -0
  28. markitai/prompts/image_description.md +13 -0
  29. markitai/prompts/page_content.md +17 -0
  30. markitai/prompts/url_enhance.md +78 -0
  31. markitai/security.py +286 -0
  32. markitai/types.py +30 -0
  33. markitai/urls.py +187 -0
  34. markitai/utils/__init__.py +33 -0
  35. markitai/utils/executor.py +69 -0
  36. markitai/utils/mime.py +85 -0
  37. markitai/utils/office.py +262 -0
  38. markitai/utils/output.py +53 -0
  39. markitai/utils/paths.py +81 -0
  40. markitai/utils/text.py +359 -0
  41. markitai/workflow/__init__.py +37 -0
  42. markitai/workflow/core.py +760 -0
  43. markitai/workflow/helpers.py +509 -0
  44. markitai/workflow/single.py +369 -0
  45. markitai-0.3.0.dist-info/METADATA +159 -0
  46. markitai-0.3.0.dist-info/RECORD +48 -0
  47. markitai-0.3.0.dist-info/WHEEL +4 -0
  48. markitai-0.3.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,65 @@
1
+ 你是一个文档格式清理专家。你的任务是清理提取文本中的格式问题,同时保持内容完整性。
2
+
3
+ 你会收到:
4
+ 1. **提取的文本**:程序提取的内容(文本精确,包含链接、表格、图片引用)
5
+ 2. **页面图片**:版式和结构的视觉参考
6
+
7
+ ## 核心原则
8
+
9
+ - **禁止翻译**:原文是什么语言就保留什么语言,禁止将中文翻译成英文或反过来
10
+ - **禁止改写**:保留原文的用词和表达方式,只做格式调整
11
+
12
+ ## 任务 1: 格式清理
13
+
14
+ 【删除残留】
15
+ - 删除图表提取残留的孤立数字行(如单独一行的 "12", "10", "8" 等)
16
+ - 删除 PPT 页眉页脚(重复出现的短文本 + 页码)
17
+ - 删除无意义的重复标题
18
+
19
+ 【格式修正】
20
+ - 参考页面图片修正标题层级(##、###等)
21
+ - 修正列表格式(缩进、符号)
22
+ - 修正表格结构
23
+ - 为 `![](assets/...)` 图片添加简短 alt text
24
+
25
+ 【空行规范】
26
+ - 标题(#)前后各保留一个空行
27
+ - 列表块/表格前后各保留一个空行
28
+ - 段落间保留一个空行,删除多余空行
29
+
30
+ ## 禁止事项
31
+
32
+ - **禁止翻译任何内容** - 原文是什么语言就保留什么语言
33
+ - **禁止删除任何段落或内容** - 只删除明显的残留/垃圾
34
+ - **禁止移动内容位置** - 保持原有顺序
35
+ - **禁止重写或改述内容** - 保留原文
36
+ - **禁止添加新内容** - 只做清理
37
+ - **禁止用代码块包裹输出** - 直接输出纯 Markdown,不要用 \`\`\`markdown 包裹
38
+ - **必须保留所有链接** - `[文本](url)` 原样保留,URL 不得修改
39
+ - **必须保留所有图片引用位置** - `![...](assets/...)` 位置不变,URL 不得修改
40
+ - **禁止修改任何 URL** - 图片链接和超链接的 URL 必须与原文完全一致
41
+ - **禁止编造 URL** - 绝对不能猜测、推断或生成原文中不存在的 URL
42
+ - **必须保留幻灯片标记** - `<!-- Slide number: X -->` 原样保留在每个 slide 内容开头,位置不变,不要添加新的 slide 注释
43
+ - **必须保留页码标记** - `<!-- Page number: X -->` 原样保留在每页内容开头,位置不变,不要添加新的页码注释
44
+ - **所有 `__MARKITAI_*__` 占位符必须原样保留**(如 `__MARKITAI_IMG_0__`、`__MARKITAI_SLIDENUM_0__`、`__MARKITAI_PAGENUM_0__`、`__MARKITAI_PAGE_0__`)- 这些是系统内部标记,位置和内容都不能改变
45
+ - **禁止输出页面/图片标记** - 不要输出 `## Page X Image:`、`__MARKITAI_PAGE_LABEL_X__`、`__MARKITAI_IMG_LABEL_X__` 等系统内部标记
46
+
47
+ ## 图片语法规范
48
+
49
+ 图片引用必须严格遵循 Markdown 语法,**不要添加多余的括号**:
50
+ - 正确: `![alt text](assets/image.jpg)`
51
+ - 错误: `![alt text](assets/image.jpg))` (多余的右括号)
52
+ - 错误: `![alt text](assets/image.jpg)))` (多余的右括号)
53
+
54
+ ## 任务 2: 元数据生成
55
+
56
+ 根据文档内容生成以下元数据:
57
+ - title: 文章标题(从内容提取,简洁准确)
58
+ - description: 全文摘要(100字以内)
59
+ - tags: 相关标签数组(3-5个)
60
+
61
+ **输出语言必须与源文档保持一致**
62
+
63
+ ---
64
+
65
+ 源文件: {source}
@@ -0,0 +1,60 @@
1
+ 请处理以下 Markdown 文档,完成两个任务:
2
+
3
+ ## 任务 1: 格式优化
4
+
5
+ 【核心原则】
6
+ - **禁止翻译**:原文是什么语言就保留什么语言,禁止将中文翻译成英文或反过来
7
+ - **禁止改写**:保留原文的用词和表达方式,只做格式调整
8
+
9
+ 【清理规范】
10
+ - 保留幻灯片标记(如 `<!-- Slide number: X -->`),不要添加新的 slide 注释,删除其他 HTML 注释
11
+ - 删除 PPT 页眉页脚(通常是重复出现的短文本 + 页码)
12
+ - 删除图表残留的孤立数字行
13
+ - 删除无意义的重复标题
14
+
15
+ 【空行规范】
16
+ - 标题(#)前后各保留一个空行
17
+ - 代码块(```)前后各保留一个空行
18
+ - 列表块前后各保留一个空行
19
+ - 表格前后各保留一个空行
20
+ - 段落间保留一个空行,删除多余空行
21
+
22
+ 【标点与强调】
23
+ - 英文内容用英文标点,中文内容用中文标点
24
+ - 闭合强调标记放在标点内侧
25
+ - 合并连续强调标记
26
+ - 粗体/斜体标记与中文之间不加空格
27
+
28
+ 【列表规范】
29
+ - 无序列表统一使用 - 符号
30
+ - 有序列表使用 1. 2. 3. 格式
31
+ - 嵌套列表缩进 2 空格
32
+
33
+ 【段落规范】
34
+ - 合并不应断行的段落(同一句话被错误换行)
35
+ - 保留有意义的换行(如诗歌、地址、引用)
36
+
37
+ 【表格规范】
38
+ - 若列头为空且内容语义清晰,可根据语义补充列头(使用与表格数据一致的语言)
39
+ - 若第一列是纯数字行号且无列头,补充列头时使用与表格数据一致的语言
40
+
41
+ 【必须保留】
42
+ - 代码块内容(原样)
43
+ - 表格行列结构(列头补充除外)
44
+ - 链接和图片语法
45
+ - 原文内容(禁止翻译或改写)
46
+ - **所有 `__MARKITAI_*__` 占位符必须原样保留**
47
+
48
+ ## 任务 2: 元数据生成
49
+
50
+ 根据文档内容生成以下元数据(使用 {language}):
51
+ - title: 文章标题(从内容提取,简洁准确)
52
+ - description: 全文摘要(100字以内)
53
+ - tags: 相关标签数组(3-5个)
54
+
55
+ ---
56
+
57
+ 源文件: {source}
58
+
59
+ 文档内容:
60
+ {content}
@@ -0,0 +1,28 @@
1
+ **⚠️ CRITICAL LANGUAGE RULE: Output language = {language}**
2
+ If English → title/description/tags must be in English.
3
+ If Chinese → title/description/tags 必须使用中文。
4
+
5
+ ---
6
+
7
+ 根据以下 Markdown 内容生成 YAML frontmatter 元数据。
8
+
9
+ 【必填字段】
10
+ - title: 文章标题(从内容提取,简洁准确)
11
+ - source: {source}
12
+ - description: 全文摘要(100字以内)
13
+ - tags: 相关标签数组(3-5个)
14
+
15
+ 【输出要求】
16
+ - 直接输出纯 YAML,不要包裹在代码块中
17
+ - 不要添加 ```yaml 或 ``` 标记
18
+ - 不要添加 --- 分隔符
19
+ - 不要添加任何解释或说明
20
+
21
+ **⚠️ 关键:输出语言必须与源文档保持一致**
22
+ - 如果源文档是**英文**,title/description/tags 必须用**英文**
23
+ - 如果源文档是**中文**,title/description/tags 必须用**中文**
24
+ - 示例:英文文档 → `title: Data Overview`, `tags: [data, excel]`
25
+ - 示例:中文文档 → `title: 数据概览`, `tags: [数据, 表格]`
26
+
27
+ 内容:
28
+ {content}
@@ -0,0 +1,21 @@
1
+ 请分析这张图片,生成三部分内容:
2
+
3
+ ## 1. Caption(简短描述)
4
+ - 长度:10-30个字
5
+ - 用作 Markdown 图片的 alt 文本
6
+ - 简洁概括图片主要内容
7
+
8
+ ## 2. Description(详细描述)
9
+ - 描述图片中的主要元素和场景
10
+ - 如果是图表,请解读数据含义
11
+ - 如果是截图,请描述界面内容
12
+ - 使用 Markdown 格式,用 ## 和 ### 组织内容
13
+ - **标题(#)前后各保留一个空行**(标题与上下文本之间都需要空行)
14
+
15
+ ## 3. Extracted Text(提取文字)
16
+ - 如果图片中包含文字,请完整提取
17
+ - **保留原图片中的文字排版布局**(换行、缩进、对齐等)
18
+ - 如果是表格,使用 Markdown 表格格式
19
+ - 如果图片中没有文字,输出 null
20
+
21
+ **输出语言必须与源文档保持一致** - 英文文档用英文,中文文档用中文
@@ -0,0 +1,8 @@
1
+ 请为这张图片生成一个简短的描述,用作 Markdown 图片的 alt 文本。
2
+
3
+ 要求:
4
+ - 长度:10-30个字
5
+ - 描述图片的主要内容
6
+ - **输出语言必须与源文档保持一致** - 英文文档用英文,中文文档用中文
7
+
8
+ 直接输出描述文本,不要添加任何解释。
@@ -0,0 +1,13 @@
1
+ 请详细描述这张图片的内容。
2
+
3
+ 要求:
4
+ 1. 描述图片中的主要元素和场景
5
+ 2. 如果有文字,请识别并列出
6
+ 3. 如果是图表,请解读数据含义
7
+ 4. 如果是截图,请描述界面内容
8
+
9
+ 输出格式要求:
10
+ - 使用 Markdown 格式
11
+ - 使用二级标题(##)和三级标题(###)组织内容
12
+ - 不要使用一级标题(#)
13
+ - 直接输出内容,不要添加开头说明
@@ -0,0 +1,17 @@
1
+ 你是一个文档内容提取专家。你的任务是将文档页面图片转换为结构清晰的 Markdown 文本。
2
+
3
+ 要求:
4
+ 1. 提取页面图片中的所有文本内容
5
+ 2. 保持文档结构(标题、段落、列表、表格)
6
+ 3. 表格转换为 Markdown 表格格式
7
+ 4. 图表/图形用 markdown 图片语法描述:`![Chart: brief description]()`
8
+ 5. 内嵌图片用 markdown 图片语法描述:`![Image: brief description]()`
9
+ 6. 忽略页码、页眉页脚、装饰元素
10
+
11
+ 输出格式:
12
+ - 使用正确的 Markdown 标题层级(##、###等)
13
+ - 不要使用一级标题(#)
14
+ - 列表使用正确格式(- 或 1. 2. 3.)
15
+ - 表格使用 Markdown 表格语法
16
+ - **输出语言必须与源文档保持一致** - 按原文语言提取和描述
17
+ - 仅输出提取的内容,不要添加说明或元注释
@@ -0,0 +1,78 @@
1
+ 你是一个网页内容清理专家。你的任务是清理从网页抓取的内容,去除噪音,保留核心内容。
2
+
3
+ 你会收到:
4
+ 1. **抓取的文本**:程序从网页抓取的 Markdown 内容
5
+ 2. **页面截图**:网页的视觉参考(如果有)
6
+
7
+ ## 核心原则 - 必须严格遵守
8
+
9
+ - **禁止翻译(CRITICAL - DO NOT TRANSLATE)**:
10
+ - 英文输入 → 英文输出(English in → English out)
11
+ - 中文输入 → 中文输出(中文输入 → 中文输出)
12
+ - 绝对禁止将任何语言翻译成另一种语言
13
+ - 违反此规则将导致输出无效
14
+ - **禁止改写**:保留原文的用词和表达方式,只做格式调整
15
+
16
+ ## 任务 1: 内容清理
17
+
18
+ 【删除网页噪音】
19
+ - 删除导航菜单、侧边栏内容
20
+ - 删除页眉页脚(如版权声明、站点链接、"Powered by" 等)
21
+ - 删除 Cookie 提示、弹窗提示文本
22
+ - 删除广告相关内容
23
+ - 删除社交分享按钮文本(如 "分享到 Twitter", "Like", "Share" 等)
24
+ - 删除评论区(除非是文章核心内容)
25
+ - 删除 "相关文章"、"推荐阅读"、"You might also enjoy" 等推荐链接
26
+ - 删除订阅提示、Newsletter 注册、"Sign up" 提示等
27
+ - 删除网站底部信息:版权声明、主题信息、访问统计、"TOP" 返回顶部链接
28
+ - 删除 Terms of Service、Privacy Policy 等法律链接
29
+
30
+ 【社交媒体特殊处理】
31
+ - Twitter/X:删除重复的推文内容(同一条推文可能被抓取多次),只保留一份完整的
32
+ - 删除 "Don't miss what's happening"、"New to X?" 等平台提示
33
+ - 删除互动统计文本(如 "56 replies, 28 reposts, 319 likes")
34
+
35
+ 【格式修正】
36
+ - 参考页面截图修正标题层级(##、###等)
37
+ - 修正列表格式(缩进、符号)
38
+ - 修正表格结构
39
+ - 为 `![](...)` 图片添加简短 alt text(基于截图上下文)
40
+ - 修复换行的链接格式:将 `[文本\n\n描述](url)` 合并为 `[文本](url)`
41
+
42
+ 【空行规范】
43
+ - 标题(#)前后各保留一个空行
44
+ - 列表块/表格前后各保留一个空行
45
+ - 段落间保留一个空行,删除多余空行
46
+
47
+ ## 禁止事项
48
+
49
+ - **禁止翻译任何内容** - 原文是什么语言就保留什么语言
50
+ - **禁止删除文章正文内容** - 只删除明显的网页噪音
51
+ - **禁止移动正文内容位置** - 保持原有顺序
52
+ - **禁止重写或改述内容** - 保留原文
53
+ - **禁止添加新内容** - 只做清理
54
+ - **禁止用代码块包裹输出** - 直接输出纯 Markdown,不要用 \`\`\`markdown 包裹
55
+ - **必须保留所有链接** - `[文本](url)` 原样保留,URL 不得修改
56
+ - **必须保留所有图片引用** - `![...](...)` 位置不变,URL 不得修改
57
+
58
+ ## URL 保护 - CRITICAL
59
+
60
+ - **禁止修改任何 URL** - 图片链接和超链接的 URL 必须与原文完全一致
61
+ - **禁止编造 URL** - 绝对不能猜测、推断或生成原文中不存在的 URL
62
+ - **禁止替换 URL** - 即使 URL 看起来"不正确"或"过时",也必须保留原样
63
+ - 示例:原文 `![](https://old-cdn.com/image.jpg)` → 输出必须是 `![](https://old-cdn.com/image.jpg)`
64
+ - 不要根据页面上下文"推测"更合理的 URL
65
+ - 违反此规则将导致输出无效
66
+
67
+ ## 任务 2: 元数据生成
68
+
69
+ 根据网页内容生成以下元数据:
70
+ - title: 文章/页面标题(从内容提取,简洁准确)
71
+ - description: 内容摘要(100字以内)
72
+ - tags: 相关标签数组(3-5个)
73
+
74
+ **输出语言必须与源内容保持一致**
75
+
76
+ ---
77
+
78
+ 来源 URL: {source}
markitai/security.py ADDED
@@ -0,0 +1,286 @@
1
+ """Security utilities for Markitai."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import tempfile
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from loguru import logger
14
+
15
+ from markitai.constants import DEFAULT_JSON_INDENT
16
+
17
+
18
+ def atomic_write_text(
19
+ path: Path,
20
+ content: str,
21
+ encoding: str = "utf-8",
22
+ ) -> None:
23
+ """Write text to file atomically using temp file + rename.
24
+
25
+ This prevents partial writes and ensures file integrity even if
26
+ the process is interrupted during write.
27
+
28
+ Args:
29
+ path: Target file path
30
+ content: Text content to write
31
+ encoding: Text encoding (default: utf-8)
32
+ """
33
+ path = Path(path)
34
+ parent = path.parent
35
+ parent.mkdir(parents=True, exist_ok=True)
36
+
37
+ # Create temp file in same directory (ensures same filesystem for rename)
38
+ fd, tmp_path = tempfile.mkstemp(
39
+ suffix=".tmp",
40
+ prefix=f".{path.name}.",
41
+ dir=parent,
42
+ )
43
+ try:
44
+ with os.fdopen(fd, "w", encoding=encoding) as f:
45
+ f.write(content)
46
+ # Atomic rename (POSIX guarantees atomicity on same filesystem)
47
+ os.replace(tmp_path, path)
48
+ except Exception:
49
+ # Clean up temp file on error
50
+ try:
51
+ os.unlink(tmp_path)
52
+ except OSError:
53
+ pass
54
+ raise
55
+
56
+
57
+ def atomic_write_json(
58
+ path: Path,
59
+ obj: Any,
60
+ indent: int = DEFAULT_JSON_INDENT,
61
+ ensure_ascii: bool = False,
62
+ order_func: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
63
+ ) -> None:
64
+ """Write JSON to file atomically.
65
+
66
+ Args:
67
+ path: Target file path
68
+ obj: Object to serialize as JSON
69
+ indent: JSON indentation (default: 2)
70
+ ensure_ascii: If True, escape non-ASCII characters (default: False)
71
+ order_func: Optional function to order/transform dict before serialization
72
+ """
73
+ if order_func is not None and isinstance(obj, dict):
74
+ obj = order_func(obj)
75
+ content = json.dumps(obj, indent=indent, ensure_ascii=ensure_ascii)
76
+ atomic_write_text(path, content, encoding="utf-8")
77
+
78
+
79
+ async def atomic_write_text_async(
80
+ path: Path,
81
+ content: str,
82
+ encoding: str = "utf-8",
83
+ ) -> None:
84
+ """Write text to file atomically using temp file + rename (async version).
85
+
86
+ This prevents partial writes and ensures file integrity even if
87
+ the process is interrupted during write.
88
+
89
+ Args:
90
+ path: Target file path
91
+ content: Text content to write
92
+ encoding: Text encoding (default: utf-8)
93
+ """
94
+ import aiofiles
95
+ import aiofiles.os
96
+
97
+ path = Path(path)
98
+ parent = path.parent
99
+ parent.mkdir(parents=True, exist_ok=True)
100
+
101
+ # Create temp file in same directory (ensures same filesystem for rename)
102
+ fd, tmp_path = tempfile.mkstemp(
103
+ suffix=".tmp",
104
+ prefix=f".{path.name}.",
105
+ dir=parent,
106
+ )
107
+ try:
108
+ # Close fd and use aiofiles for async write
109
+ os.close(fd)
110
+ async with aiofiles.open(tmp_path, "w", encoding=encoding) as f:
111
+ await f.write(content)
112
+ # Atomic rename (POSIX guarantees atomicity on same filesystem)
113
+ await aiofiles.os.replace(tmp_path, path)
114
+ except Exception:
115
+ # Clean up temp file on error
116
+ try:
117
+ await aiofiles.os.remove(tmp_path)
118
+ except OSError:
119
+ pass
120
+ raise
121
+
122
+
123
+ async def atomic_write_json_async(
124
+ path: Path,
125
+ obj: Any,
126
+ indent: int = DEFAULT_JSON_INDENT,
127
+ ensure_ascii: bool = False,
128
+ ) -> None:
129
+ """Write JSON to file atomically (async version).
130
+
131
+ Args:
132
+ path: Target file path
133
+ obj: Object to serialize as JSON
134
+ indent: JSON indentation (default: 2)
135
+ ensure_ascii: If True, escape non-ASCII characters (default: False)
136
+ """
137
+ content = json.dumps(obj, indent=indent, ensure_ascii=ensure_ascii)
138
+ await atomic_write_text_async(path, content, encoding="utf-8")
139
+
140
+
141
+ async def write_bytes_async(path: Path, data: bytes) -> None:
142
+ """Write bytes to file asynchronously.
143
+
144
+ Args:
145
+ path: Target file path
146
+ data: Bytes to write
147
+ """
148
+ import aiofiles
149
+
150
+ path = Path(path)
151
+ path.parent.mkdir(parents=True, exist_ok=True)
152
+
153
+ async with aiofiles.open(path, "wb") as f:
154
+ await f.write(data)
155
+
156
+
157
+ def escape_glob_pattern(s: str) -> str:
158
+ """Escape special glob characters in a string.
159
+
160
+ Args:
161
+ s: String that may contain glob special characters
162
+
163
+ Returns:
164
+ Escaped string safe for use in glob patterns
165
+ """
166
+ # Escape glob special characters: [ ] * ?
167
+ return s.translate(
168
+ str.maketrans(
169
+ {
170
+ "[": "[[]",
171
+ "]": "[]]",
172
+ "*": "[*]",
173
+ "?": "[?]",
174
+ }
175
+ )
176
+ )
177
+
178
+
179
+ def validate_path_within_base(path: Path, base_dir: Path) -> Path:
180
+ """Validate that a path is within the base directory.
181
+
182
+ Args:
183
+ path: Path to validate
184
+ base_dir: Base directory that path must be within
185
+
186
+ Returns:
187
+ Resolved absolute path
188
+
189
+ Raises:
190
+ ValueError: If path is outside base directory
191
+ """
192
+ resolved = path.resolve()
193
+ base_resolved = base_dir.resolve()
194
+
195
+ try:
196
+ resolved.relative_to(base_resolved)
197
+ except ValueError:
198
+ raise ValueError(f"Path traversal detected: {path} is outside {base_dir}")
199
+
200
+ return resolved
201
+
202
+
203
+ def check_symlink_safety(path: Path, allow_symlinks: bool = False) -> None:
204
+ """Check if a path involves symlinks at any level.
205
+
206
+ This function checks not just the final path, but all parent directories
207
+ to detect nested symlinks that could be used for path traversal.
208
+
209
+ Args:
210
+ path: Path to check
211
+ allow_symlinks: If False, raises error on symlinks
212
+
213
+ Raises:
214
+ ValueError: If symlinks are not allowed and any path component is a symlink
215
+ """
216
+ # Check the path itself
217
+ if path.is_symlink():
218
+ if not allow_symlinks:
219
+ target = path.readlink()
220
+ raise ValueError(f"Symlink not allowed: {path} -> {target}")
221
+ else:
222
+ logger.warning(f"Symlink detected: {path} -> {path.readlink()}")
223
+ return # If symlinks allowed, no need to check further
224
+
225
+ # Check all parent directories for nested symlinks
226
+ if not allow_symlinks:
227
+ checked_parts: list[Path] = []
228
+ for part in path.parts:
229
+ checked_parts.append(
230
+ Path(part) if not checked_parts else checked_parts[-1] / part
231
+ )
232
+ current_path = checked_parts[-1]
233
+ # Only check if path exists and is absolute enough to be meaningful
234
+ if (
235
+ len(checked_parts) > 1
236
+ and current_path.exists()
237
+ and current_path.is_symlink()
238
+ ):
239
+ target = current_path.readlink()
240
+ raise ValueError(
241
+ f"Nested symlink not allowed: {current_path} -> {target} (in path {path})"
242
+ )
243
+
244
+
245
+ def sanitize_error_message(error: Exception) -> str:
246
+ """Sanitize error message to remove sensitive information.
247
+
248
+ Args:
249
+ error: Exception to sanitize
250
+
251
+ Returns:
252
+ Sanitized error message
253
+ """
254
+ msg = str(error)
255
+
256
+ # Remove absolute paths (Unix style)
257
+ msg = re.sub(r"/[a-zA-Z0-9_\-./]+", "[PATH]", msg)
258
+
259
+ # Remove absolute paths (Windows style)
260
+ msg = re.sub(r"[A-Za-z]:\\[a-zA-Z0-9_\-\\. ]+", "[PATH]", msg)
261
+
262
+ # Remove potential usernames in paths
263
+ msg = re.sub(r"/home/[^/\s]+/", "/home/[USER]/", msg)
264
+ msg = re.sub(r"C:\\Users\\[^\\]+\\", r"C:\\Users\\[USER]\\", msg)
265
+
266
+ return msg
267
+
268
+
269
+ def validate_file_size(path: Path, max_size_bytes: int) -> None:
270
+ """Validate that a file is within size limits.
271
+
272
+ Args:
273
+ path: Path to file
274
+ max_size_bytes: Maximum allowed size in bytes
275
+
276
+ Raises:
277
+ ValueError: If file exceeds size limit
278
+ """
279
+ if not path.exists():
280
+ return
281
+
282
+ size = path.stat().st_size
283
+ if size > max_size_bytes:
284
+ raise ValueError(
285
+ f"File too large: {path.name} is {size} bytes (max: {max_size_bytes} bytes)"
286
+ )
markitai/types.py ADDED
@@ -0,0 +1,30 @@
1
+ """Common type definitions for Markitai."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+
7
+
8
+ class ModelUsageStats(TypedDict):
9
+ """Statistics for a single LLM model's usage."""
10
+
11
+ requests: int
12
+ input_tokens: int
13
+ output_tokens: int
14
+ cost_usd: float
15
+
16
+
17
+ # Type alias for LLM usage by model
18
+ # Format: {"model_name": {"requests": N, "input_tokens": N, "output_tokens": N, "cost_usd": F}}
19
+ LLMUsageByModel = dict[str, ModelUsageStats]
20
+
21
+
22
+ class AssetDescription(TypedDict, total=False):
23
+ """Description of an extracted asset (image)."""
24
+
25
+ asset: str # Asset file path
26
+ alt: str # Short alt text
27
+ desc: str # Detailed description
28
+ text: str | None # Extracted text (optional)
29
+ llm_usage: LLMUsageByModel # LLM usage for this asset (optional)
30
+ created: str # Creation timestamp (optional)