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.
- markitai/__init__.py +3 -0
- markitai/batch.py +1316 -0
- markitai/cli.py +3979 -0
- markitai/config.py +602 -0
- markitai/config.schema.json +748 -0
- markitai/constants.py +222 -0
- markitai/converter/__init__.py +49 -0
- markitai/converter/_patches.py +98 -0
- markitai/converter/base.py +164 -0
- markitai/converter/image.py +181 -0
- markitai/converter/legacy.py +606 -0
- markitai/converter/office.py +526 -0
- markitai/converter/pdf.py +679 -0
- markitai/converter/text.py +63 -0
- markitai/fetch.py +1725 -0
- markitai/image.py +1335 -0
- markitai/json_order.py +550 -0
- markitai/llm.py +4339 -0
- markitai/ocr.py +347 -0
- markitai/prompts/__init__.py +159 -0
- markitai/prompts/cleaner.md +93 -0
- markitai/prompts/document_enhance.md +77 -0
- markitai/prompts/document_enhance_complete.md +65 -0
- markitai/prompts/document_process.md +60 -0
- markitai/prompts/frontmatter.md +28 -0
- markitai/prompts/image_analysis.md +21 -0
- markitai/prompts/image_caption.md +8 -0
- markitai/prompts/image_description.md +13 -0
- markitai/prompts/page_content.md +17 -0
- markitai/prompts/url_enhance.md +78 -0
- markitai/security.py +286 -0
- markitai/types.py +30 -0
- markitai/urls.py +187 -0
- markitai/utils/__init__.py +33 -0
- markitai/utils/executor.py +69 -0
- markitai/utils/mime.py +85 -0
- markitai/utils/office.py +262 -0
- markitai/utils/output.py +53 -0
- markitai/utils/paths.py +81 -0
- markitai/utils/text.py +359 -0
- markitai/workflow/__init__.py +37 -0
- markitai/workflow/core.py +760 -0
- markitai/workflow/helpers.py +509 -0
- markitai/workflow/single.py +369 -0
- markitai-0.3.0.dist-info/METADATA +159 -0
- markitai-0.3.0.dist-info/RECORD +48 -0
- markitai-0.3.0.dist-info/WHEEL +4 -0
- 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
|
+
- 为 `` 图片添加简短 alt text
|
|
24
|
+
|
|
25
|
+
【空行规范】
|
|
26
|
+
- 标题(#)前后各保留一个空行
|
|
27
|
+
- 列表块/表格前后各保留一个空行
|
|
28
|
+
- 段落间保留一个空行,删除多余空行
|
|
29
|
+
|
|
30
|
+
## 禁止事项
|
|
31
|
+
|
|
32
|
+
- **禁止翻译任何内容** - 原文是什么语言就保留什么语言
|
|
33
|
+
- **禁止删除任何段落或内容** - 只删除明显的残留/垃圾
|
|
34
|
+
- **禁止移动内容位置** - 保持原有顺序
|
|
35
|
+
- **禁止重写或改述内容** - 保留原文
|
|
36
|
+
- **禁止添加新内容** - 只做清理
|
|
37
|
+
- **禁止用代码块包裹输出** - 直接输出纯 Markdown,不要用 \`\`\`markdown 包裹
|
|
38
|
+
- **必须保留所有链接** - `[文本](url)` 原样保留,URL 不得修改
|
|
39
|
+
- **必须保留所有图片引用位置** - `` 位置不变,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
|
+
- 正确: ``
|
|
51
|
+
- 错误: `)` (多余的右括号)
|
|
52
|
+
- 错误: `))` (多余的右括号)
|
|
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,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
|
+
- 示例:原文 `` → 输出必须是 ``
|
|
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)
|