opencode-skills-collection 3.1.4 → 3.1.5
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.
- package/bundled-skills/.antigravity-install-manifest.json +1 -1
- package/bundled-skills/007/scripts/full_audit.py +10 -3
- package/bundled-skills/2slides-ppt-generator/requirements.txt +3 -1
- package/bundled-skills/2slides-ppt-generator/scripts/download_slides_pages_voices.py +24 -0
- package/bundled-skills/2slides-ppt-generator/scripts/get_job_status.py +18 -1
- package/bundled-skills/agent-creator/SKILL.md +15 -1
- package/bundled-skills/agent-orchestrator/scripts/scan_registry.py +4 -4
- package/bundled-skills/android-dev/references/hybrid.md +3 -3
- package/bundled-skills/android-dev/references/react-native.md +14 -8
- package/bundled-skills/competitor-analysis/scripts/compile_report.mjs +82 -20
- package/bundled-skills/diary/requirements.txt +3 -1
- package/bundled-skills/docs/users/getting-started.md +1 -1
- package/bundled-skills/docx-official/ooxml/scripts/validation/base.py +19 -1
- package/bundled-skills/docx-official/ooxml/scripts/validation/docx.py +20 -1
- package/bundled-skills/docx-official/ooxml/scripts/validation/redlining.py +21 -5
- package/bundled-skills/ecl-harness-engineer/references/environment-detection-guide.md +1 -1
- package/bundled-skills/hugging-face-model-trainer/scripts/convert_to_gguf.py +44 -23
- package/bundled-skills/instagram/scripts/db.py +120 -18
- package/bundled-skills/instagram/scripts/export.py +41 -8
- package/bundled-skills/instagram/scripts/publish.py +7 -7
- package/bundled-skills/instagram/scripts/run_all.py +2 -2
- package/bundled-skills/instagram/scripts/schedule.py +6 -5
- package/bundled-skills/instagram/static/dashboard.html +63 -16
- package/bundled-skills/junta-leiloeiros/scripts/requirements.txt +1 -1
- package/bundled-skills/k8s-manifest-generator/assets/deployment-template.yaml +20 -8
- package/bundled-skills/k8s-manifest-generator/assets/service-template.yaml +2 -3
- package/bundled-skills/last30days/scripts/lib/reddit_enrich.py +3 -1
- package/bundled-skills/loki-mode/benchmarks/results/2026-01-05-00-49-17/humaneval-solutions/162.py +1 -1
- package/bundled-skills/loki-mode/benchmarks/results/humaneval-loki-solutions/162.py +1 -1
- package/bundled-skills/loki-mode/examples/todo-app-generated/backend/src/index.ts +1 -0
- package/bundled-skills/loop-library/SKILL.md +11 -11
- package/bundled-skills/mcp-builder/scripts/evaluation.py +6 -2
- package/bundled-skills/notebooklm/scripts/run.py +23 -8
- package/bundled-skills/playwright-skill/lib/helpers.js +15 -17
- package/bundled-skills/pptx-official/ooxml/scripts/validation/base.py +19 -1
- package/bundled-skills/pptx-official/ooxml/scripts/validation/docx.py +20 -1
- package/bundled-skills/pptx-official/ooxml/scripts/validation/redlining.py +21 -5
- package/bundled-skills/remote-gpu-trainer/profiles/runpod.md +2 -2
- package/bundled-skills/senior-frontend/scripts/component_generator.py +67 -10
- package/bundled-skills/shopify-development/scripts/requirements.txt +1 -0
- package/bundled-skills/shopify-development/scripts/tests/test_shopify_init.py +13 -9
- package/bundled-skills/skill-installer/scripts/install_skill.py +73 -34
- package/bundled-skills/skill-installer/scripts/package_skill.py +36 -8
- package/bundled-skills/skill-installer/scripts/validate_skill.py +22 -7
- package/bundled-skills/skill-sentinel/scripts/db.py +69 -15
- package/bundled-skills/slack-gif-creator/requirements.txt +3 -2
- package/bundled-skills/stability-ai/scripts/requirements.txt +1 -1
- package/bundled-skills/telegram/assets/boilerplate/nodejs/src/bot-client.ts +1 -0
- package/bundled-skills/telegram/assets/boilerplate/python/requirements.txt +3 -1
- package/bundled-skills/telegram/scripts/send_message.py +39 -9
- package/bundled-skills/webapp-testing/scripts/with_server.py +105 -8
- package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/nodejs/src/index.ts +1 -0
- package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/python/requirements.txt +1 -0
- package/bundled-skills/whatsapp-cloud-api/scripts/setup_project.py +31 -3
- package/bundled-skills/writing-skills/render-graphs.js +30 -5
- package/bundled-skills/youtube-notetaker/reference/artifact.html +29 -18
- package/bundled-skills/youtube-notetaker/scripts/serve.py +49 -8
- package/package.json +1 -1
- package/skills_index.json +5 -3
|
@@ -41,11 +41,36 @@ Dependencies: All required packages are declared in PEP 723 header above.
|
|
|
41
41
|
import os
|
|
42
42
|
import sys
|
|
43
43
|
import torch
|
|
44
|
+
import re
|
|
45
|
+
import shutil
|
|
44
46
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
45
47
|
from peft import PeftModel
|
|
46
48
|
from huggingface_hub import HfApi
|
|
47
49
|
import subprocess
|
|
48
50
|
|
|
51
|
+
HF_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)?$")
|
|
52
|
+
SAFE_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_hf_id(value, name):
|
|
56
|
+
if not HF_ID_RE.match(value or ""):
|
|
57
|
+
raise ValueError(f"{name} must be a Hugging Face model/repo id")
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def safe_filename(value, name):
|
|
62
|
+
if not SAFE_FILENAME_RE.match(value or ""):
|
|
63
|
+
raise ValueError(f"{name} must be a safe filename segment")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def safe_output_file(root, filename):
|
|
68
|
+
root_path = os.path.abspath(root)
|
|
69
|
+
target = os.path.abspath(os.path.join(root_path, filename))
|
|
70
|
+
if os.path.commonpath([root_path, target]) != root_path:
|
|
71
|
+
raise ValueError(f"Output path escapes {root_path}")
|
|
72
|
+
return target
|
|
73
|
+
|
|
49
74
|
|
|
50
75
|
def check_system_dependencies():
|
|
51
76
|
"""Check if required system packages are available."""
|
|
@@ -78,24 +103,19 @@ def run_command(cmd, description):
|
|
|
78
103
|
"""Run a command with error handling."""
|
|
79
104
|
print(f" {description}...")
|
|
80
105
|
try:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
print(f" ❌ Command failed: {' '.join(cmd)}")
|
|
92
|
-
if e.stdout:
|
|
93
|
-
print(f" STDOUT: {e.stdout[:500]}")
|
|
94
|
-
if e.stderr:
|
|
95
|
-
print(f" STDERR: {e.stderr[:500]}")
|
|
106
|
+
args = [str(part) for part in cmd]
|
|
107
|
+
if not args or any("\0" in part for part in args):
|
|
108
|
+
raise ValueError("Command arguments must be non-empty strings without NUL bytes")
|
|
109
|
+
executable = args[0] if os.path.isabs(args[0]) else shutil.which(args[0])
|
|
110
|
+
if not executable:
|
|
111
|
+
raise FileNotFoundError(args[0])
|
|
112
|
+
return_code = os.spawnv(os.P_WAIT, executable, args)
|
|
113
|
+
if return_code == 0:
|
|
114
|
+
return True
|
|
115
|
+
print(f" ❌ Command failed with exit code {return_code}: {' '.join(args)}")
|
|
96
116
|
return False
|
|
97
|
-
except FileNotFoundError:
|
|
98
|
-
print(f" ❌ Command
|
|
117
|
+
except (FileNotFoundError, OSError, ValueError) as e:
|
|
118
|
+
print(f" ❌ Command failed: {e}")
|
|
99
119
|
return False
|
|
100
120
|
|
|
101
121
|
|
|
@@ -108,10 +128,10 @@ if not check_system_dependencies():
|
|
|
108
128
|
sys.exit(1)
|
|
109
129
|
|
|
110
130
|
# Configuration from environment variables
|
|
111
|
-
ADAPTER_MODEL = os.environ.get("ADAPTER_MODEL", "evalstate/qwen-capybara-medium")
|
|
112
|
-
BASE_MODEL = os.environ.get("BASE_MODEL", "Qwen/Qwen2.5-0.5B")
|
|
113
|
-
OUTPUT_REPO = os.environ.get("OUTPUT_REPO", "evalstate/qwen-capybara-medium-gguf")
|
|
114
|
-
username = os.environ.get("HF_USERNAME", ADAPTER_MODEL.split('/')[0])
|
|
131
|
+
ADAPTER_MODEL = require_hf_id(os.environ.get("ADAPTER_MODEL", "evalstate/qwen-capybara-medium"), "ADAPTER_MODEL")
|
|
132
|
+
BASE_MODEL = require_hf_id(os.environ.get("BASE_MODEL", "Qwen/Qwen2.5-0.5B"), "BASE_MODEL")
|
|
133
|
+
OUTPUT_REPO = require_hf_id(os.environ.get("OUTPUT_REPO", "evalstate/qwen-capybara-medium-gguf"), "OUTPUT_REPO")
|
|
134
|
+
username = require_hf_id(os.environ.get("HF_USERNAME", ADAPTER_MODEL.split('/')[0]), "HF_USERNAME")
|
|
115
135
|
|
|
116
136
|
print(f"\n📦 Configuration:")
|
|
117
137
|
print(f" Base model: {BASE_MODEL}")
|
|
@@ -203,7 +223,8 @@ os.makedirs(gguf_output_dir, exist_ok=True)
|
|
|
203
223
|
|
|
204
224
|
convert_script = "/tmp/llama.cpp/convert_hf_to_gguf.py"
|
|
205
225
|
model_name = ADAPTER_MODEL.split('/')[-1]
|
|
206
|
-
|
|
226
|
+
model_name = safe_filename(model_name, "model_name")
|
|
227
|
+
gguf_file = safe_output_file(gguf_output_dir, f"{model_name}-f16.gguf")
|
|
207
228
|
|
|
208
229
|
print(f" Running conversion...")
|
|
209
230
|
if not run_command(
|
|
@@ -259,7 +280,7 @@ quant_formats = [
|
|
|
259
280
|
quantized_files = []
|
|
260
281
|
for quant_type, description in quant_formats:
|
|
261
282
|
print(f" Creating {quant_type} quantization ({description})...")
|
|
262
|
-
quant_file = f"{
|
|
283
|
+
quant_file = safe_output_file(gguf_output_dir, f"{model_name}-{quant_type.lower()}.gguf")
|
|
263
284
|
|
|
264
285
|
if not run_command(
|
|
265
286
|
[quantize_bin, gguf_file, quant_file, quant_type],
|
|
@@ -138,6 +138,99 @@ _POSTS_COLUMNS = frozenset({
|
|
|
138
138
|
"hashtags", "template_id", "status", "scheduled_at", "published_at",
|
|
139
139
|
"ig_media_id", "ig_container_id", "permalink", "error_msg", "created_at",
|
|
140
140
|
})
|
|
141
|
+
_POST_STATUSES = frozenset({
|
|
142
|
+
"draft", "approved", "scheduled", "container_created", "published", "failed",
|
|
143
|
+
})
|
|
144
|
+
_MEDIA_TYPES = frozenset({"PHOTO", "VIDEO", "REEL", "STORY", "CAROUSEL"})
|
|
145
|
+
_MEDIA_TYPE_ALIASES = {
|
|
146
|
+
"IMAGE": "PHOTO",
|
|
147
|
+
"REELS": "REEL",
|
|
148
|
+
"STORIES": "STORY",
|
|
149
|
+
"CAROUSEL_ALBUM": "CAROUSEL",
|
|
150
|
+
}
|
|
151
|
+
_POSTS_INSERT_COLUMNS = (
|
|
152
|
+
"account_id", "media_type", "media_url", "local_path", "caption",
|
|
153
|
+
"hashtags", "template_id", "status", "scheduled_at", "published_at",
|
|
154
|
+
"ig_media_id", "ig_container_id", "permalink", "error_msg",
|
|
155
|
+
)
|
|
156
|
+
_POSTS_UPDATE_COLUMNS = (
|
|
157
|
+
"media_type", "media_url", "local_path", "caption", "hashtags",
|
|
158
|
+
"template_id", "status", "scheduled_at", "published_at", "ig_media_id",
|
|
159
|
+
"ig_container_id", "permalink", "error_msg",
|
|
160
|
+
)
|
|
161
|
+
_INSERT_POST_SQL = """
|
|
162
|
+
INSERT INTO posts (
|
|
163
|
+
account_id, media_type, media_url, local_path, caption, hashtags,
|
|
164
|
+
template_id, status, scheduled_at, published_at, ig_media_id,
|
|
165
|
+
ig_container_id, permalink, error_msg
|
|
166
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
167
|
+
"""
|
|
168
|
+
_UPDATE_POST_SQL = """
|
|
169
|
+
UPDATE posts SET
|
|
170
|
+
media_type = ?,
|
|
171
|
+
media_url = ?,
|
|
172
|
+
local_path = ?,
|
|
173
|
+
caption = ?,
|
|
174
|
+
hashtags = ?,
|
|
175
|
+
template_id = ?,
|
|
176
|
+
status = ?,
|
|
177
|
+
scheduled_at = ?,
|
|
178
|
+
published_at = ?,
|
|
179
|
+
ig_media_id = ?,
|
|
180
|
+
ig_container_id = ?,
|
|
181
|
+
permalink = ?,
|
|
182
|
+
error_msg = ?
|
|
183
|
+
WHERE id = ?
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _quote_identifier(name: str, allowed: frozenset[str]) -> str:
|
|
188
|
+
"""Quote a SQLite identifier after checking it against an allowlist."""
|
|
189
|
+
if name not in allowed:
|
|
190
|
+
raise ValueError(f"Invalid column name: {name}")
|
|
191
|
+
return '"' + name.replace('"', '""') + '"'
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def normalize_post_status(status: str) -> str:
|
|
195
|
+
value = str(status).strip().lower()
|
|
196
|
+
if value not in _POST_STATUSES:
|
|
197
|
+
raise ValueError(f"Invalid post status: {status}")
|
|
198
|
+
return value
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def normalize_media_type(media_type: str) -> str:
|
|
202
|
+
value = str(media_type).strip().upper()
|
|
203
|
+
value = _MEDIA_TYPE_ALIASES.get(value, value)
|
|
204
|
+
if value not in _MEDIA_TYPES:
|
|
205
|
+
raise ValueError(f"Invalid media type: {media_type}")
|
|
206
|
+
return value
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _positive_int(value: Any, field: str) -> int:
|
|
210
|
+
number = int(value)
|
|
211
|
+
if number < 1:
|
|
212
|
+
raise ValueError(f"{field} must be a positive integer")
|
|
213
|
+
return number
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _bounded_int(value: Any, field: str, *, minimum: int, maximum: int) -> int:
|
|
217
|
+
number = int(value)
|
|
218
|
+
if number < minimum or number > maximum:
|
|
219
|
+
raise ValueError(f"{field} must be between {minimum} and {maximum}")
|
|
220
|
+
return number
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _normalize_post_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
224
|
+
normalized = dict(data)
|
|
225
|
+
if "media_type" in normalized and normalized["media_type"] is not None:
|
|
226
|
+
normalized["media_type"] = normalize_media_type(normalized["media_type"])
|
|
227
|
+
if "status" in normalized and normalized["status"] is not None:
|
|
228
|
+
normalized["status"] = normalize_post_status(normalized["status"])
|
|
229
|
+
if "account_id" in normalized and normalized["account_id"] is not None:
|
|
230
|
+
normalized["account_id"] = _positive_int(normalized["account_id"], "account_id")
|
|
231
|
+
if "template_id" in normalized and normalized["template_id"] is not None:
|
|
232
|
+
normalized["template_id"] = _positive_int(normalized["template_id"], "template_id")
|
|
233
|
+
return normalized
|
|
141
234
|
|
|
142
235
|
|
|
143
236
|
class Database:
|
|
@@ -211,30 +304,33 @@ class Database:
|
|
|
211
304
|
|
|
212
305
|
def insert_post(self, data: Dict[str, Any]) -> int:
|
|
213
306
|
"""Cria um novo post (draft por padrão). Retorna o id."""
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
values = [data[k] for k in keys]
|
|
220
|
-
sql = f"INSERT INTO posts ({columns}) VALUES ({placeholders})"
|
|
307
|
+
data = _normalize_post_data(data)
|
|
308
|
+
unknown = set(data) - _POSTS_COLUMNS - {"id"}
|
|
309
|
+
if unknown:
|
|
310
|
+
raise ValueError(f"Invalid columns for insert_post: {', '.join(sorted(unknown))}")
|
|
311
|
+
values = [data.get(column) for column in _POSTS_INSERT_COLUMNS]
|
|
221
312
|
with self._connect() as conn:
|
|
222
|
-
cursor = conn.execute(
|
|
313
|
+
cursor = conn.execute(_INSERT_POST_SQL, values)
|
|
223
314
|
return cursor.lastrowid
|
|
224
315
|
|
|
225
316
|
def update_post_status(self, post_id: int, status: str, **extra) -> None:
|
|
226
317
|
"""Atualiza status de um post e campos adicionais."""
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
params.append(v)
|
|
234
|
-
params.append(post_id)
|
|
235
|
-
sql = f"UPDATE posts SET {', '.join(sets)} WHERE id = ?"
|
|
318
|
+
post_id = _positive_int(post_id, "post_id")
|
|
319
|
+
status = normalize_post_status(status)
|
|
320
|
+
extra = _normalize_post_data(extra)
|
|
321
|
+
unknown = set(extra) - _POSTS_COLUMNS
|
|
322
|
+
if unknown:
|
|
323
|
+
raise ValueError(f"Invalid columns for update_post_status: {', '.join(sorted(unknown))}")
|
|
236
324
|
with self._connect() as conn:
|
|
237
|
-
conn.execute(
|
|
325
|
+
row = conn.execute("SELECT * FROM posts WHERE id = ?", [post_id]).fetchone()
|
|
326
|
+
if not row:
|
|
327
|
+
raise ValueError(f"Post {post_id} not found")
|
|
328
|
+
merged = dict(row)
|
|
329
|
+
merged.update(extra)
|
|
330
|
+
merged["status"] = status
|
|
331
|
+
params = [merged.get(column) for column in _POSTS_UPDATE_COLUMNS]
|
|
332
|
+
params.append(post_id)
|
|
333
|
+
conn.execute(_UPDATE_POST_SQL, params)
|
|
238
334
|
|
|
239
335
|
def get_posts(
|
|
240
336
|
self,
|
|
@@ -246,11 +342,15 @@ class Database:
|
|
|
246
342
|
conditions = []
|
|
247
343
|
params: list = []
|
|
248
344
|
if account_id:
|
|
345
|
+
account_id = _positive_int(account_id, "account_id")
|
|
249
346
|
conditions.append("account_id = ?")
|
|
250
347
|
params.append(account_id)
|
|
251
348
|
if status:
|
|
349
|
+
status = normalize_post_status(status)
|
|
252
350
|
conditions.append("status = ?")
|
|
253
351
|
params.append(status)
|
|
352
|
+
limit = _bounded_int(limit, "limit", minimum=1, maximum=1000)
|
|
353
|
+
offset = _bounded_int(offset, "offset", minimum=0, maximum=100000)
|
|
254
354
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
255
355
|
sql = f"SELECT * FROM posts {where} ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
|
256
356
|
params.extend([limit, offset])
|
|
@@ -260,6 +360,7 @@ class Database:
|
|
|
260
360
|
|
|
261
361
|
def get_posts_for_publishing(self, account_id: int) -> List[Dict[str, Any]]:
|
|
262
362
|
"""Posts aprovados/agendados prontos para publicar."""
|
|
363
|
+
account_id = _positive_int(account_id, "account_id")
|
|
263
364
|
now = datetime.now(timezone.utc).isoformat()
|
|
264
365
|
sql = """
|
|
265
366
|
SELECT * FROM posts
|
|
@@ -275,6 +376,7 @@ class Database:
|
|
|
275
376
|
return [dict(r) for r in rows]
|
|
276
377
|
|
|
277
378
|
def get_post_by_id(self, post_id: int) -> Optional[Dict[str, Any]]:
|
|
379
|
+
post_id = _positive_int(post_id, "post_id")
|
|
278
380
|
with self._connect() as conn:
|
|
279
381
|
row = conn.execute("SELECT * FROM posts WHERE id = ?", [post_id]).fetchone()
|
|
280
382
|
return dict(row) if row else None
|
|
@@ -19,11 +19,36 @@ from pathlib import Path
|
|
|
19
19
|
|
|
20
20
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
from db import Database
|
|
22
|
+
_db = None
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
|
|
25
|
+
def get_db():
|
|
26
|
+
global _db
|
|
27
|
+
if _db is None:
|
|
28
|
+
from db import Database
|
|
29
|
+
_db = Database()
|
|
30
|
+
_db.init()
|
|
31
|
+
return _db
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def safe_output_dir(output: str | Path) -> Path:
|
|
35
|
+
output_dir = Path(output).expanduser().resolve()
|
|
36
|
+
skill_dir = Path(__file__).resolve().parents[1]
|
|
37
|
+
try:
|
|
38
|
+
output_dir.relative_to(skill_dir)
|
|
39
|
+
except ValueError:
|
|
40
|
+
return output_dir
|
|
41
|
+
raise ValueError("Refusing to export inside the skill source directory")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def self_test() -> None:
|
|
45
|
+
skill_dir = Path(__file__).resolve().parents[1]
|
|
46
|
+
safe_output_dir(skill_dir.parent / "instagram-exports")
|
|
47
|
+
try:
|
|
48
|
+
safe_output_dir(skill_dir / "scripts" / "exports")
|
|
49
|
+
except ValueError:
|
|
50
|
+
return
|
|
51
|
+
raise AssertionError("accepted export directory inside skill source")
|
|
27
52
|
|
|
28
53
|
|
|
29
54
|
def export_json(records: list, output_dir: Path, name: str) -> Path:
|
|
@@ -67,7 +92,7 @@ def export_csv_file(records: list, output_dir: Path, name: str) -> Path:
|
|
|
67
92
|
|
|
68
93
|
def get_data(data_type: str) -> tuple:
|
|
69
94
|
"""Retorna (records, name) para o tipo de dados."""
|
|
70
|
-
conn =
|
|
95
|
+
conn = get_db()._connect()
|
|
71
96
|
|
|
72
97
|
if data_type == "posts":
|
|
73
98
|
rows = conn.execute("SELECT * FROM posts ORDER BY created_at DESC").fetchall()
|
|
@@ -109,15 +134,23 @@ def do_export(records: list, name: str, fmt: str, output_dir: Path) -> None:
|
|
|
109
134
|
|
|
110
135
|
def main():
|
|
111
136
|
parser = argparse.ArgumentParser(description="Exportar dados do Instagram")
|
|
112
|
-
parser.add_argument("--type", required=
|
|
137
|
+
parser.add_argument("--type", required=False,
|
|
113
138
|
choices=["posts", "comments", "insights", "user_insights", "templates", "actions", "all"],
|
|
114
139
|
help="Tipo de dados")
|
|
115
140
|
parser.add_argument("--format", default="csv", choices=["json", "jsonl", "csv", "all"],
|
|
116
141
|
help="Formato (default: csv)")
|
|
117
|
-
|
|
142
|
+
default_exports_dir = Path(__file__).resolve().parents[1] / "data" / "exports"
|
|
143
|
+
parser.add_argument("--output", default=str(default_exports_dir), help=f"Diretório (default: {default_exports_dir})")
|
|
144
|
+
parser.add_argument("--self-test", action="store_true", help="Run safety self-checks")
|
|
118
145
|
args = parser.parse_args()
|
|
119
146
|
|
|
120
|
-
|
|
147
|
+
if args.self_test:
|
|
148
|
+
self_test()
|
|
149
|
+
return
|
|
150
|
+
if not args.type:
|
|
151
|
+
parser.error("--type is required unless --self-test is used")
|
|
152
|
+
|
|
153
|
+
output_dir = safe_output_dir(args.output)
|
|
121
154
|
|
|
122
155
|
if args.type == "all":
|
|
123
156
|
for dtype in ["posts", "comments", "insights", "user_insights", "templates", "actions"]:
|
|
@@ -30,7 +30,7 @@ sys.path.insert(0, str(Path(__file__).parent))
|
|
|
30
30
|
|
|
31
31
|
from api_client import InstagramAPI
|
|
32
32
|
from auth import auto_refresh_if_needed
|
|
33
|
-
from db import Database
|
|
33
|
+
from db import Database, normalize_media_type
|
|
34
34
|
from governance import GovernanceManager
|
|
35
35
|
|
|
36
36
|
db = Database()
|
|
@@ -173,12 +173,13 @@ async def publish_video(
|
|
|
173
173
|
as_draft: bool = False,
|
|
174
174
|
) -> dict:
|
|
175
175
|
"""Publica vídeo, reel ou story de vídeo."""
|
|
176
|
+
media_type = normalize_media_type(media_type)
|
|
176
177
|
video_url = await upload_if_local(api, video)
|
|
177
178
|
|
|
178
179
|
if as_draft:
|
|
179
180
|
post_id = db.insert_post({
|
|
180
181
|
"account_id": api.account_id,
|
|
181
|
-
"media_type": media_type
|
|
182
|
+
"media_type": media_type,
|
|
182
183
|
"media_url": video_url,
|
|
183
184
|
"local_path": video if _is_local_file(video) else None,
|
|
184
185
|
"caption": caption,
|
|
@@ -195,7 +196,7 @@ async def publish_video(
|
|
|
195
196
|
)
|
|
196
197
|
|
|
197
198
|
# Step 1: Container
|
|
198
|
-
ig_type = {"VIDEO": "VIDEO", "REEL": "REELS", "STORY": "STORIES"}[media_type
|
|
199
|
+
ig_type = {"VIDEO": "VIDEO", "REEL": "REELS", "STORY": "STORIES"}[media_type]
|
|
199
200
|
container = await api.create_media_container(
|
|
200
201
|
media_type=ig_type,
|
|
201
202
|
video_url=video_url,
|
|
@@ -205,8 +206,8 @@ async def publish_video(
|
|
|
205
206
|
container_id = container["id"]
|
|
206
207
|
|
|
207
208
|
post_id = db.insert_post({
|
|
208
|
-
|
|
209
|
-
|
|
209
|
+
"account_id": api.account_id,
|
|
210
|
+
"media_type": media_type,
|
|
210
211
|
"media_url": video_url,
|
|
211
212
|
"caption": caption,
|
|
212
213
|
"status": "container_created",
|
|
@@ -386,7 +387,6 @@ async def run(args) -> None:
|
|
|
386
387
|
|
|
387
388
|
# Aplicar template se especificado
|
|
388
389
|
if args.template:
|
|
389
|
-
from db import Database
|
|
390
390
|
tpl = Database().get_template_by_name(args.template)
|
|
391
391
|
if tpl:
|
|
392
392
|
caption = tpl["caption_template"]
|
|
@@ -397,7 +397,7 @@ async def run(args) -> None:
|
|
|
397
397
|
variables = dict(v.split("=", 1) for v in args.vars)
|
|
398
398
|
caption = _apply_template(caption, variables)
|
|
399
399
|
|
|
400
|
-
media_type = args.type
|
|
400
|
+
media_type = normalize_media_type(args.type)
|
|
401
401
|
|
|
402
402
|
if media_type == "PHOTO":
|
|
403
403
|
result = await publish_photo(api, args.image, caption, as_draft=args.draft)
|
|
@@ -22,7 +22,7 @@ sys.path.insert(0, str(Path(__file__).parent))
|
|
|
22
22
|
|
|
23
23
|
from api_client import InstagramAPI
|
|
24
24
|
from auth import auto_refresh_if_needed
|
|
25
|
-
from db import Database
|
|
25
|
+
from db import Database, normalize_media_type
|
|
26
26
|
|
|
27
27
|
logging.basicConfig(
|
|
28
28
|
level=logging.INFO,
|
|
@@ -58,7 +58,7 @@ async def sync_media(api: InstagramAPI, limit: int = 50) -> dict:
|
|
|
58
58
|
if m["id"] not in existing_ig_ids:
|
|
59
59
|
db.insert_post({
|
|
60
60
|
"account_id": api.account_id,
|
|
61
|
-
"media_type": m.get("media_type", "IMAGE"),
|
|
61
|
+
"media_type": normalize_media_type(m.get("media_type", "IMAGE")),
|
|
62
62
|
"media_url": m.get("media_url", ""),
|
|
63
63
|
"caption": m.get("caption", ""),
|
|
64
64
|
"status": "published",
|
|
@@ -18,7 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent))
|
|
|
18
18
|
|
|
19
19
|
from api_client import InstagramAPI
|
|
20
20
|
from auth import auto_refresh_if_needed
|
|
21
|
-
from db import Database
|
|
21
|
+
from db import Database, normalize_media_type, normalize_post_status
|
|
22
22
|
from governance import GovernanceManager, RateLimitExceeded
|
|
23
23
|
|
|
24
24
|
db = Database()
|
|
@@ -45,15 +45,17 @@ async def process_pending() -> None:
|
|
|
45
45
|
|
|
46
46
|
for post in posts:
|
|
47
47
|
post_id = post["id"]
|
|
48
|
+
post_status = normalize_post_status(post["status"])
|
|
49
|
+
media_type = normalize_media_type(post["media_type"])
|
|
48
50
|
try:
|
|
49
|
-
gov.check_rate_limit(f"publish_{
|
|
51
|
+
gov.check_rate_limit(f"publish_{media_type.lower()}", account["id"])
|
|
50
52
|
except RateLimitExceeded as e:
|
|
51
53
|
results.append({"post_id": post_id, "status": "rate_limited", "error": str(e)})
|
|
52
54
|
break
|
|
53
55
|
|
|
54
56
|
try:
|
|
55
57
|
# Recovery: se já tem container criado, tenta publicar direto
|
|
56
|
-
if
|
|
58
|
+
if post_status == "container_created" and post.get("ig_container_id"):
|
|
57
59
|
result = await api.publish_media(post["ig_container_id"])
|
|
58
60
|
ig_media_id = result.get("id")
|
|
59
61
|
details = await api.get_media_details(ig_media_id)
|
|
@@ -70,9 +72,8 @@ async def process_pending() -> None:
|
|
|
70
72
|
media_url = post.get("media_url", "")
|
|
71
73
|
if not media_url and post.get("local_path"):
|
|
72
74
|
media_url = await api.upload_to_imgur(post["local_path"])
|
|
73
|
-
db.update_post_status(post_id,
|
|
75
|
+
db.update_post_status(post_id, post_status, media_url=media_url)
|
|
74
76
|
|
|
75
|
-
media_type = post["media_type"].upper()
|
|
76
77
|
ig_type_map = {"PHOTO": "IMAGE", "VIDEO": "VIDEO", "REEL": "REELS", "STORY": "STORIES"}
|
|
77
78
|
ig_type = ig_type_map.get(media_type, "IMAGE")
|
|
78
79
|
|
|
@@ -146,39 +146,86 @@
|
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
function td(text) {
|
|
150
|
+
const cell = document.createElement('td');
|
|
151
|
+
cell.textContent = text == null || text === '' ? '-' : String(text);
|
|
152
|
+
return cell;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function safeURL(url) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = new URL(url, window.location.href);
|
|
158
|
+
return /^https?:$/.test(parsed.protocol) ? parsed.href : '';
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function emptyRow(tbody, cols, text) {
|
|
165
|
+
tbody.replaceChildren();
|
|
166
|
+
const tr = document.createElement('tr');
|
|
167
|
+
const cell = td(text);
|
|
168
|
+
cell.colSpan = cols;
|
|
169
|
+
tr.appendChild(cell);
|
|
170
|
+
tbody.appendChild(tr);
|
|
171
|
+
}
|
|
172
|
+
|
|
149
173
|
async function loadPosts() {
|
|
150
174
|
const data = await fetchJSON('/api/posts?limit=20');
|
|
151
175
|
const tbody = document.getElementById('posts-body');
|
|
152
176
|
const posts = data.data || [];
|
|
153
|
-
if (!posts.length) { tbody
|
|
177
|
+
if (!posts.length) { emptyRow(tbody, 5, 'Sem posts no banco.'); return; }
|
|
154
178
|
|
|
155
|
-
tbody.
|
|
156
|
-
|
|
179
|
+
tbody.replaceChildren();
|
|
180
|
+
posts.forEach(p => {
|
|
181
|
+
const status = String(p.status || '-');
|
|
182
|
+
const badgeClass = `badge-${status.replace(/[^a-z0-9_-]/gi, '')}`;
|
|
157
183
|
const caption = (p.caption || '').substring(0, 60) + ((p.caption||'').length > 60 ? '...' : '');
|
|
158
184
|
const date = p.published_at || p.created_at || '';
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
185
|
+
const tr = document.createElement('tr');
|
|
186
|
+
tr.appendChild(td(p.media_type || '-'));
|
|
187
|
+
tr.appendChild(td(caption || '-'));
|
|
188
|
+
const statusCell = document.createElement('td');
|
|
189
|
+
const badge = document.createElement('span');
|
|
190
|
+
badge.className = `badge ${badgeClass}`;
|
|
191
|
+
badge.textContent = status;
|
|
192
|
+
statusCell.appendChild(badge);
|
|
193
|
+
tr.appendChild(statusCell);
|
|
194
|
+
tr.appendChild(td(date ? date.substring(0, 16) : '-'));
|
|
195
|
+
const linkCell = document.createElement('td');
|
|
196
|
+
const href = p.permalink ? safeURL(p.permalink) : '';
|
|
197
|
+
if (href) {
|
|
198
|
+
const link = document.createElement('a');
|
|
199
|
+
link.href = href;
|
|
200
|
+
link.target = '_blank';
|
|
201
|
+
link.rel = 'noopener noreferrer';
|
|
202
|
+
link.textContent = 'Ver';
|
|
203
|
+
linkCell.appendChild(link);
|
|
204
|
+
} else {
|
|
205
|
+
linkCell.textContent = '-';
|
|
206
|
+
}
|
|
207
|
+
tr.appendChild(linkCell);
|
|
208
|
+
tbody.appendChild(tr);
|
|
209
|
+
});
|
|
168
210
|
}
|
|
169
211
|
|
|
170
212
|
async function loadActions() {
|
|
171
213
|
const data = await fetchJSON('/api/actions?limit=15');
|
|
172
214
|
const tbody = document.getElementById('actions-body');
|
|
173
215
|
const actions = data.data || [];
|
|
174
|
-
if (!actions.length) { tbody
|
|
216
|
+
if (!actions.length) { emptyRow(tbody, 3, 'Sem ações registradas.'); return; }
|
|
175
217
|
|
|
176
|
-
tbody.
|
|
218
|
+
tbody.replaceChildren();
|
|
219
|
+
actions.forEach(a => {
|
|
177
220
|
const date = a.created_at ? a.created_at.substring(0, 16) : '-';
|
|
178
221
|
let details = '-';
|
|
179
222
|
try { const p = JSON.parse(a.params || '{}'); details = Object.entries(p).map(([k,v]) => `${k}: ${v}`).join(', '); } catch(e) {}
|
|
180
|
-
|
|
181
|
-
|
|
223
|
+
const tr = document.createElement('tr');
|
|
224
|
+
tr.appendChild(td(a.action));
|
|
225
|
+
tr.appendChild(td(date));
|
|
226
|
+
tr.appendChild(td((details || '').substring(0, 80)));
|
|
227
|
+
tbody.appendChild(tr);
|
|
228
|
+
});
|
|
182
229
|
}
|
|
183
230
|
|
|
184
231
|
// Load everything
|
|
@@ -51,26 +51,38 @@ spec:
|
|
|
51
51
|
# Pod-level security context
|
|
52
52
|
securityContext:
|
|
53
53
|
runAsNonRoot: true
|
|
54
|
-
runAsUser:
|
|
55
|
-
runAsGroup:
|
|
56
|
-
fsGroup:
|
|
54
|
+
runAsUser: 10001
|
|
55
|
+
runAsGroup: 10001
|
|
56
|
+
fsGroup: 10001
|
|
57
57
|
seccompProfile:
|
|
58
58
|
type: RuntimeDefault
|
|
59
59
|
|
|
60
60
|
# Init containers (optional)
|
|
61
61
|
initContainers:
|
|
62
62
|
- name: init-wait
|
|
63
|
-
image: busybox:1.
|
|
63
|
+
image: busybox:1.37.0
|
|
64
|
+
imagePullPolicy: Always
|
|
64
65
|
command: ['sh', '-c', 'echo "Initializing..."']
|
|
66
|
+
resources:
|
|
67
|
+
requests:
|
|
68
|
+
memory: "32Mi"
|
|
69
|
+
cpu: "25m"
|
|
70
|
+
limits:
|
|
71
|
+
memory: "64Mi"
|
|
72
|
+
cpu: "50m"
|
|
65
73
|
securityContext:
|
|
66
74
|
allowPrivilegeEscalation: false
|
|
75
|
+
readOnlyRootFilesystem: true
|
|
67
76
|
runAsNonRoot: true
|
|
68
|
-
runAsUser:
|
|
77
|
+
runAsUser: 10001
|
|
78
|
+
capabilities:
|
|
79
|
+
drop:
|
|
80
|
+
- ALL
|
|
69
81
|
|
|
70
82
|
containers:
|
|
71
83
|
- name: <container-name>
|
|
72
|
-
image: <registry>/<image
|
|
73
|
-
imagePullPolicy:
|
|
84
|
+
image: <registry>/<image>@sha256:<digest>
|
|
85
|
+
imagePullPolicy: Always
|
|
74
86
|
|
|
75
87
|
ports:
|
|
76
88
|
- name: http
|
|
@@ -155,7 +167,7 @@ spec:
|
|
|
155
167
|
allowPrivilegeEscalation: false
|
|
156
168
|
readOnlyRootFilesystem: true
|
|
157
169
|
runAsNonRoot: true
|
|
158
|
-
runAsUser:
|
|
170
|
+
runAsUser: 10001
|
|
159
171
|
capabilities:
|
|
160
172
|
drop:
|
|
161
173
|
- ALL
|