damon-devtools 0.4.0__tar.gz → 0.6.0__tar.gz
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.
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/PKG-INFO +4 -1
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/damon_devtools.egg-info/PKG-INFO +4 -1
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/damon_devtools.egg-info/SOURCES.txt +5 -1
- damon_devtools-0.6.0/damon_devtools.egg-info/requires.txt +4 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/__init__.py +35 -29
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/apkpull/core.py +46 -1
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/apkpull/ui.py +88 -23
- damon_devtools-0.6.0/devtools/tools/url2md/__init__.py +0 -0
- damon_devtools-0.6.0/devtools/tools/url2md/__main__.py +5 -0
- damon_devtools-0.6.0/devtools/tools/url2md/cli.py +111 -0
- damon_devtools-0.6.0/devtools/tools/url2md/core.py +92 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/pyproject.toml +24 -24
- damon_devtools-0.4.0/damon_devtools.egg-info/requires.txt +0 -1
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/README.md +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/damon_devtools.egg-info/dependency_links.txt +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/damon_devtools.egg-info/entry_points.txt +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/damon_devtools.egg-info/top_level.txt +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/__init__.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/__main__.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/launcher.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/apkpull/__init__.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/apkpull/__main__.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/apkpull/models.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/portkill/__init__.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/portkill/__main__.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/portkill/core.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/portkill/models.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/portkill/ui.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/removebg/__init__.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/removebg/__main__.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/removebg/cli.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/tools/removebg/core.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/devtools/types.py +0 -0
- {damon_devtools-0.4.0 → damon_devtools-0.6.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: damon-devtools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Developer CLI tools — portkill, removebg, apkpull
|
|
5
5
|
Author: damon
|
|
6
6
|
License: MIT
|
|
@@ -8,6 +8,9 @@ Keywords: cli,devtools,android,apk,adb
|
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
Requires-Dist: psutil>=5.9
|
|
11
|
+
Requires-Dist: markdownify
|
|
12
|
+
Requires-Dist: beautifulsoup4
|
|
13
|
+
Requires-Dist: requests
|
|
11
14
|
|
|
12
15
|
# devtools
|
|
13
16
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: damon-devtools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Developer CLI tools — portkill, removebg, apkpull
|
|
5
5
|
Author: damon
|
|
6
6
|
License: MIT
|
|
@@ -8,6 +8,9 @@ Keywords: cli,devtools,android,apk,adb
|
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
Requires-Dist: psutil>=5.9
|
|
11
|
+
Requires-Dist: markdownify
|
|
12
|
+
Requires-Dist: beautifulsoup4
|
|
13
|
+
Requires-Dist: requests
|
|
11
14
|
|
|
12
15
|
# devtools
|
|
13
16
|
|
|
@@ -24,4 +24,8 @@ devtools/tools/portkill/ui.py
|
|
|
24
24
|
devtools/tools/removebg/__init__.py
|
|
25
25
|
devtools/tools/removebg/__main__.py
|
|
26
26
|
devtools/tools/removebg/cli.py
|
|
27
|
-
devtools/tools/removebg/core.py
|
|
27
|
+
devtools/tools/removebg/core.py
|
|
28
|
+
devtools/tools/url2md/__init__.py
|
|
29
|
+
devtools/tools/url2md/__main__.py
|
|
30
|
+
devtools/tools/url2md/cli.py
|
|
31
|
+
devtools/tools/url2md/core.py
|
|
@@ -1,29 +1,35 @@
|
|
|
1
|
-
"""Tools registry for devtools"""
|
|
2
|
-
from devtools.types import Tool
|
|
3
|
-
|
|
4
|
-
TOOLS = [
|
|
5
|
-
Tool(
|
|
6
|
-
name="portkill",
|
|
7
|
-
category="进程/端口",
|
|
8
|
-
description="按端口杀 Node.js 进程",
|
|
9
|
-
module="devtools.tools.portkill",
|
|
10
|
-
),
|
|
11
|
-
Tool(
|
|
12
|
-
name="removebg",
|
|
13
|
-
category="图像处理",
|
|
14
|
-
description="批量删除图片背景",
|
|
15
|
-
module="devtools.tools.removebg",
|
|
16
|
-
),
|
|
17
|
-
Tool(
|
|
18
|
-
name="
|
|
19
|
-
category="
|
|
20
|
-
description="
|
|
21
|
-
module="devtools.tools.
|
|
22
|
-
),
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
"""Tools registry for devtools"""
|
|
2
|
+
from devtools.types import Tool
|
|
3
|
+
|
|
4
|
+
TOOLS = [
|
|
5
|
+
Tool(
|
|
6
|
+
name="portkill",
|
|
7
|
+
category="进程/端口",
|
|
8
|
+
description="按端口杀 Node.js 进程",
|
|
9
|
+
module="devtools.tools.portkill",
|
|
10
|
+
),
|
|
11
|
+
Tool(
|
|
12
|
+
name="removebg",
|
|
13
|
+
category="图像处理",
|
|
14
|
+
description="批量删除图片背景",
|
|
15
|
+
module="devtools.tools.removebg",
|
|
16
|
+
),
|
|
17
|
+
Tool(
|
|
18
|
+
name="url2md",
|
|
19
|
+
category="网页工具",
|
|
20
|
+
description="将网页链接转换为 Markdown 文件",
|
|
21
|
+
module="devtools.tools.url2md",
|
|
22
|
+
),
|
|
23
|
+
Tool(
|
|
24
|
+
name="apkpull",
|
|
25
|
+
category="设备管理",
|
|
26
|
+
description="提取手机 APK 文件",
|
|
27
|
+
module="devtools.tools.apkpull",
|
|
28
|
+
),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
def get_tool(name: str):
|
|
32
|
+
for tool in TOOLS:
|
|
33
|
+
if tool.name == name:
|
|
34
|
+
return tool
|
|
35
|
+
return None
|
|
@@ -58,6 +58,49 @@ def get_label_cache() -> dict[str, str]:
|
|
|
58
58
|
_label_cache = load_label_cache()
|
|
59
59
|
return _label_cache
|
|
60
60
|
|
|
61
|
+
# Selected device serial (set by user via CLI or interactive selection)
|
|
62
|
+
selected_device: str | None = None
|
|
63
|
+
|
|
64
|
+
def set_selected_device(serial: str | None) -> None:
|
|
65
|
+
"""设置当前选中的设备"""
|
|
66
|
+
global selected_device
|
|
67
|
+
selected_device = serial
|
|
68
|
+
|
|
69
|
+
def get_connected_devices() -> list[tuple[str, str, str]]:
|
|
70
|
+
"""返回 [(serial, brand, model), ...],仅返回 state=device 的设备"""
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["adb", "devices"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
74
|
+
)
|
|
75
|
+
devices: list[tuple[str, str, str]] = []
|
|
76
|
+
for line in result.stdout.strip().split("\n")[1:]:
|
|
77
|
+
if line.strip() and not line.startswith("*"):
|
|
78
|
+
parts = line.split()
|
|
79
|
+
if len(parts) >= 2 and parts[1] == "device":
|
|
80
|
+
serial = parts[0]
|
|
81
|
+
brand, model = get_device_info(serial)
|
|
82
|
+
devices.append((serial, brand, model))
|
|
83
|
+
return devices
|
|
84
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
def get_device_info(serial: str) -> tuple[str, str]:
|
|
88
|
+
"""获取指定设备的 (品牌, 型号)"""
|
|
89
|
+
try:
|
|
90
|
+
brand_result = subprocess.run(
|
|
91
|
+
["adb", "-s", serial, "shell", "getprop", "ro.product.brand"],
|
|
92
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
93
|
+
)
|
|
94
|
+
model_result = subprocess.run(
|
|
95
|
+
["adb", "-s", serial, "shell", "getprop", "ro.product.model"],
|
|
96
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
|
|
97
|
+
)
|
|
98
|
+
brand = brand_result.stdout.strip() or "Unknown"
|
|
99
|
+
model = model_result.stdout.strip() or "Unknown"
|
|
100
|
+
return brand, model
|
|
101
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
102
|
+
return "Unknown", "Unknown"
|
|
103
|
+
|
|
61
104
|
def check_adb() -> bool:
|
|
62
105
|
"""检查 ADB 是否可用"""
|
|
63
106
|
try:
|
|
@@ -67,7 +110,9 @@ def check_adb() -> bool:
|
|
|
67
110
|
return False
|
|
68
111
|
|
|
69
112
|
def get_device_serial() -> str | None:
|
|
70
|
-
"""
|
|
113
|
+
"""获取设备 serial:有选中设备时返回之,否则返回第一个已连接设备"""
|
|
114
|
+
if selected_device:
|
|
115
|
+
return selected_device
|
|
71
116
|
try:
|
|
72
117
|
result = subprocess.run(["adb", "devices"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=True)
|
|
73
118
|
lines = result.stdout.strip().split("\n")
|
|
@@ -113,9 +113,59 @@ def run(args: list[str] | None = None):
|
|
|
113
113
|
parser = argparse.ArgumentParser(prog="apkpull", description="Pull APK from Android device")
|
|
114
114
|
parser.add_argument("dest_dir", nargs="?", default=os.getcwd(), help="目标目录 (默认当前目录)")
|
|
115
115
|
parser.add_argument("-s", "--search", metavar="KEYWORD", help="模糊搜索应用名/包名并退出")
|
|
116
|
+
parser.add_argument("-n", "--no-merge", action="store_true", help="不合并 split APK,直接导出所有分片")
|
|
117
|
+
parser.add_argument("-d", "--device", metavar="SERIAL", help="指定设备序列号,跳过设备选择")
|
|
116
118
|
|
|
117
119
|
parsed = parser.parse_args(args)
|
|
118
120
|
|
|
121
|
+
# Device selection
|
|
122
|
+
from devtools.tools.apkpull.core import get_connected_devices, set_selected_device, check_adb
|
|
123
|
+
|
|
124
|
+
if not check_adb():
|
|
125
|
+
print("错误: 未找到 adb 命令,请确保 Android SDK platform-tools 已安装并加入 PATH")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
devices = get_connected_devices()
|
|
129
|
+
if not devices:
|
|
130
|
+
print("错误: 未检测到设备,请确保手机已通过 USB 连接并开启了 USB 调试模式")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
if parsed.device:
|
|
134
|
+
# CLI指定的设备
|
|
135
|
+
serials = [d[0] for d in devices]
|
|
136
|
+
if parsed.device not in serials:
|
|
137
|
+
print(f"错误: 指定设备 {parsed.device} 未找到或未连接")
|
|
138
|
+
return
|
|
139
|
+
serial = parsed.device
|
|
140
|
+
_, brand, model = devices[serials.index(serial)]
|
|
141
|
+
set_selected_device(serial)
|
|
142
|
+
print(f"已选择: {brand} {model} ({serial})")
|
|
143
|
+
elif len(devices) == 1:
|
|
144
|
+
# 单设备,自动使用
|
|
145
|
+
serial, brand, model = devices[0]
|
|
146
|
+
set_selected_device(serial)
|
|
147
|
+
print(f"已连接: {brand} {model} ({serial})")
|
|
148
|
+
else:
|
|
149
|
+
# 多设备,交互选择
|
|
150
|
+
print()
|
|
151
|
+
print("已检测到多个设备:")
|
|
152
|
+
for i, (s, brand, model) in enumerate(devices):
|
|
153
|
+
print(f" {i + 1}. {s} {brand} {model}")
|
|
154
|
+
while True:
|
|
155
|
+
try:
|
|
156
|
+
default = "1"
|
|
157
|
+
user_input = input(f"\n请选择目标设备 [{default}]: ").strip() or default
|
|
158
|
+
idx = int(user_input) - 1
|
|
159
|
+
if 0 <= idx < len(devices):
|
|
160
|
+
break
|
|
161
|
+
print("无效序号,请重试")
|
|
162
|
+
except (ValueError, EOFError, KeyboardInterrupt):
|
|
163
|
+
print("\n取消")
|
|
164
|
+
return
|
|
165
|
+
serial, brand, model = devices[idx]
|
|
166
|
+
set_selected_device(serial)
|
|
167
|
+
print(f"已选择: {brand} {model} ({serial})")
|
|
168
|
+
|
|
119
169
|
packages = scan_packages_with_labels()
|
|
120
170
|
if not packages:
|
|
121
171
|
return
|
|
@@ -177,31 +227,46 @@ def run(args: list[str] | None = None):
|
|
|
177
227
|
else:
|
|
178
228
|
failed.append((p.label, "拉取失败"))
|
|
179
229
|
else:
|
|
180
|
-
# Split APK
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
try:
|
|
185
|
-
os.makedirs(tmp_dir, exist_ok=True)
|
|
186
|
-
all_ok = True
|
|
230
|
+
# Split APK
|
|
231
|
+
if parsed.no_merge:
|
|
232
|
+
# No merge - export each split APK directly
|
|
233
|
+
print(f"\n检测到 split APK (共 {len(apk_paths)} 个分片),正在导出所有分片(不合并)...")
|
|
187
234
|
for i, apk_path in enumerate(apk_paths):
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
failed.append((p.label, f"拉取分片 {i+1} 失败"))
|
|
191
|
-
all_ok = False
|
|
192
|
-
break
|
|
193
|
-
tmp_apks.append(tmp_file)
|
|
194
|
-
|
|
195
|
-
if all_ok:
|
|
196
|
-
if merge_split_apks(tmp_apks, dest_apk):
|
|
197
|
-
succeeded.append(p.label)
|
|
235
|
+
if i == 0:
|
|
236
|
+
out_path = dest_apk # base APK: app.apk
|
|
198
237
|
else:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
238
|
+
out_path = os.path.join(parsed.dest_dir, f"{sanitize_filename(file_base)}-split-{i}.apk")
|
|
239
|
+
if pull_apk(apk_path, out_path):
|
|
240
|
+
print(f" 已导出: {os.path.basename(out_path)}")
|
|
241
|
+
else:
|
|
242
|
+
failed.append((p.label, f"导出分片 {i+1} 失败"))
|
|
243
|
+
succeeded.append(p.label)
|
|
244
|
+
else:
|
|
245
|
+
# Merge - pull all to temp dir, merge, then cleanup
|
|
246
|
+
print(f"\n检测到 split APK (共 {len(apk_paths)} 个分片),正在拉取并合并...")
|
|
247
|
+
tmp_apks: list[str] = []
|
|
248
|
+
tmp_dir = os.path.join(parsed.dest_dir, f".tmp_{sanitize_filename(file_base)}")
|
|
249
|
+
try:
|
|
250
|
+
os.makedirs(tmp_dir, exist_ok=True)
|
|
251
|
+
all_ok = True
|
|
252
|
+
for i, apk_path in enumerate(apk_paths):
|
|
253
|
+
tmp_file = os.path.join(tmp_dir, f"split_{i}.apk")
|
|
254
|
+
if not pull_apk(apk_path, tmp_file):
|
|
255
|
+
failed.append((p.label, f"拉取分片 {i+1} 失败"))
|
|
256
|
+
all_ok = False
|
|
257
|
+
break
|
|
258
|
+
tmp_apks.append(tmp_file)
|
|
259
|
+
|
|
260
|
+
if all_ok:
|
|
261
|
+
if merge_split_apks(tmp_apks, dest_apk):
|
|
262
|
+
succeeded.append(p.label)
|
|
263
|
+
else:
|
|
264
|
+
failed.append((p.label, "合并 APK 失败"))
|
|
265
|
+
finally:
|
|
266
|
+
# Cleanup temp files
|
|
267
|
+
import shutil
|
|
268
|
+
if os.path.isdir(tmp_dir):
|
|
269
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
205
270
|
|
|
206
271
|
print()
|
|
207
272
|
if succeeded:
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""CLI entry point for url2md."""
|
|
2
|
+
import argparse
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from devtools.tools.url2md.core import fetch_and_convert
|
|
8
|
+
|
|
9
|
+
INVALID_FILENAME_CHARS = re.escape(r'/\:*?"<>|')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(args: list[str] | None = None):
|
|
13
|
+
if args is None:
|
|
14
|
+
args = sys.argv[1:]
|
|
15
|
+
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="python -m devtools url2md",
|
|
18
|
+
description="Convert URLs to markdown files",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"-o", "--output",
|
|
22
|
+
type=Path,
|
|
23
|
+
default=None,
|
|
24
|
+
metavar="DIR",
|
|
25
|
+
help="Output directory (default: current directory)",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"urls",
|
|
29
|
+
nargs="+",
|
|
30
|
+
metavar="URL-OR-FILE",
|
|
31
|
+
help="URL or file containing URLs (one per line)",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
ns = parser.parse_args(args)
|
|
35
|
+
|
|
36
|
+
# Resolve all URLs
|
|
37
|
+
all_urls: list[str] = []
|
|
38
|
+
for item in ns.urls:
|
|
39
|
+
path = Path(item)
|
|
40
|
+
if path.exists() and path.is_file():
|
|
41
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
42
|
+
for line in lines:
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if line:
|
|
45
|
+
all_urls.append(line)
|
|
46
|
+
else:
|
|
47
|
+
all_urls.append(item)
|
|
48
|
+
|
|
49
|
+
if not all_urls:
|
|
50
|
+
print("No URLs to process.", file=sys.stderr)
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
# Create output dir if specified
|
|
54
|
+
output_dir = ns.output or Path(".")
|
|
55
|
+
if output_dir != Path("."):
|
|
56
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
succeeded: list[str] = []
|
|
59
|
+
failed: list[tuple[str, str]] = []
|
|
60
|
+
|
|
61
|
+
print(f"Processing {len(all_urls)} URL(s)...")
|
|
62
|
+
|
|
63
|
+
for url in all_urls:
|
|
64
|
+
try:
|
|
65
|
+
result = fetch_and_convert(url)
|
|
66
|
+
filename = _sanitize_filename(result["title"]) or _url_to_fallback_title(url)
|
|
67
|
+
if not filename.endswith(".md"):
|
|
68
|
+
filename += ".md"
|
|
69
|
+
|
|
70
|
+
date_line = ""
|
|
71
|
+
if result["date"]:
|
|
72
|
+
date_line = f"\nPublished: {result['date']}"
|
|
73
|
+
|
|
74
|
+
content = (
|
|
75
|
+
f"# {result['title']}\n\n"
|
|
76
|
+
f"{result['body_md']}\n\n"
|
|
77
|
+
f"---\n\n"
|
|
78
|
+
f"Source: [{result['source_url']}]({result['source_url']}){date_line}\n"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
out_path = output_dir / filename
|
|
82
|
+
out_path.write_text(content, encoding="utf-8")
|
|
83
|
+
succeeded.append(url)
|
|
84
|
+
print(f" OK: {url} -> {out_path.name}")
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
failed.append((url, str(e)))
|
|
88
|
+
print(f" FAIL: {url} — {e}", file=sys.stderr)
|
|
89
|
+
|
|
90
|
+
# Summary
|
|
91
|
+
total = len(succeeded) + len(failed)
|
|
92
|
+
print(f"\nProcessed {total} URL(s): {len(succeeded)} succeeded, {len(failed)} failed")
|
|
93
|
+
|
|
94
|
+
if failed:
|
|
95
|
+
print("\nFailed URLs:")
|
|
96
|
+
for url, err in failed:
|
|
97
|
+
print(f" {url}: {err}")
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _sanitize_filename(title: str) -> str:
|
|
102
|
+
# Replace any char that's invalid in Windows/Unix filenames
|
|
103
|
+
return re.sub(r'[/\\:*?"<>|]', "_", title).strip()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _url_to_fallback_title(url: str) -> str:
|
|
107
|
+
from urllib.parse import urlparse
|
|
108
|
+
parsed = urlparse(url)
|
|
109
|
+
path = parsed.path.strip("/").replace("/", "_")
|
|
110
|
+
host = parsed.netloc.replace(":", "_")
|
|
111
|
+
return f"{host}_{path}" if path else host
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Core logic for fetching and converting URLs to markdown."""
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
requests.packages.urllib3.disable_warnings()
|
|
8
|
+
from bs4 import BeautifulSoup
|
|
9
|
+
from markdownify import markdownify
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fetch_and_convert(url: str) -> dict:
|
|
13
|
+
"""
|
|
14
|
+
Fetch a URL and convert it to markdown.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
dict with keys: title, body_md, source_url, date, url
|
|
18
|
+
date is None if not found.
|
|
19
|
+
"""
|
|
20
|
+
response = requests.get(url, timeout=15, verify=False)
|
|
21
|
+
response.raise_for_status()
|
|
22
|
+
response.encoding = response.apparent_encoding or "utf-8"
|
|
23
|
+
|
|
24
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
25
|
+
|
|
26
|
+
title = _extract_title(soup)
|
|
27
|
+
body_md = _extract_body(soup)
|
|
28
|
+
date = _extract_date(soup)
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
"title": title,
|
|
32
|
+
"body_md": body_md,
|
|
33
|
+
"source_url": url,
|
|
34
|
+
"date": date,
|
|
35
|
+
"url": url,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _extract_title(soup: BeautifulSoup) -> str:
|
|
40
|
+
# Try <title> tag first
|
|
41
|
+
title_tag = soup.find("title")
|
|
42
|
+
if title_tag and title_tag.get_text(strip=True):
|
|
43
|
+
return title_tag.get_text(strip=True)
|
|
44
|
+
|
|
45
|
+
# Fallback: first <h1>
|
|
46
|
+
h1 = soup.find("h1")
|
|
47
|
+
if h1 and h1.get_text(strip=True):
|
|
48
|
+
return h1.get_text(strip=True)
|
|
49
|
+
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_body(soup: BeautifulSoup) -> str:
|
|
54
|
+
# markdownify converts the whole document, which is fine for v1
|
|
55
|
+
return markdownify(str(soup))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_date(soup: BeautifulSoup) -> Optional[str]:
|
|
59
|
+
# Try <time> element with datetime attribute
|
|
60
|
+
time_tag = soup.find("time")
|
|
61
|
+
if time_tag and time_tag.get("datetime"):
|
|
62
|
+
try:
|
|
63
|
+
dt = datetime.fromisoformat(time_tag["datetime"].strip())
|
|
64
|
+
return dt.strftime("%Y-%m-%d")
|
|
65
|
+
except ValueError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# Try meta tags
|
|
69
|
+
for meta_name in ("datePublished", "pubdate", "article:published_time"):
|
|
70
|
+
meta = soup.find("meta", attrs={"property": meta_name}) or soup.find("meta", attrs={"name": meta_name})
|
|
71
|
+
if meta and meta.get("content"):
|
|
72
|
+
return _parse_date(meta["content"])
|
|
73
|
+
|
|
74
|
+
# Try first paragraph with a date pattern
|
|
75
|
+
first_p = soup.find("p")
|
|
76
|
+
if first_p:
|
|
77
|
+
text = first_p.get_text()
|
|
78
|
+
date_match = re.search(r"(\d{4}[-/]\d{2}[-/]\d{2})", text)
|
|
79
|
+
if date_match:
|
|
80
|
+
return _parse_date(date_match.group(1))
|
|
81
|
+
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_date(date_str: str) -> Optional[str]:
|
|
86
|
+
"""Try to parse a date string into YYYY-MM-DD."""
|
|
87
|
+
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"):
|
|
88
|
+
try:
|
|
89
|
+
return datetime.strptime(date_str.strip(), fmt).strftime("%Y-%m-%d")
|
|
90
|
+
except ValueError:
|
|
91
|
+
pass
|
|
92
|
+
return None
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "damon-devtools"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Developer CLI tools — portkill, removebg, apkpull"
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
requires-python = ">=3.10"
|
|
7
|
-
dependencies = ["psutil>=5.9"]
|
|
8
|
-
license = {text = "MIT"}
|
|
9
|
-
authors = [
|
|
10
|
-
{name = "damon"},
|
|
11
|
-
]
|
|
12
|
-
keywords = ["cli", "devtools", "android", "apk", "adb"]
|
|
13
|
-
|
|
14
|
-
[project.scripts]
|
|
15
|
-
devtools = "devtools.__main__:main"
|
|
16
|
-
|
|
17
|
-
[tool.setuptools.packages.find]
|
|
18
|
-
where = ["."]
|
|
19
|
-
include = ["devtools*"]
|
|
20
|
-
exclude = ["dotfiles*", "tests*", "build*", "dist*"]
|
|
21
|
-
|
|
22
|
-
[build-system]
|
|
23
|
-
requires = ["setuptools>=61.0"]
|
|
24
|
-
build-backend = "setuptools.build_meta"
|
|
1
|
+
[project]
|
|
2
|
+
name = "damon-devtools"
|
|
3
|
+
version = "0.6.0"
|
|
4
|
+
description = "Developer CLI tools — portkill, removebg, apkpull"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = ["psutil>=5.9", "markdownify", "beautifulsoup4", "requests"]
|
|
8
|
+
license = {text = "MIT"}
|
|
9
|
+
authors = [
|
|
10
|
+
{name = "damon"},
|
|
11
|
+
]
|
|
12
|
+
keywords = ["cli", "devtools", "android", "apk", "adb"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
devtools = "devtools.__main__:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["."]
|
|
19
|
+
include = ["devtools*"]
|
|
20
|
+
exclude = ["dotfiles*", "tests*", "build*", "dist*"]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["setuptools>=61.0"]
|
|
24
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
psutil>=5.9
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|