opencode-skills-collection 3.1.3 → 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.
Files changed (59) hide show
  1. package/bundled-skills/.antigravity-install-manifest.json +1 -1
  2. package/bundled-skills/007/scripts/full_audit.py +10 -3
  3. package/bundled-skills/2slides-ppt-generator/requirements.txt +3 -1
  4. package/bundled-skills/2slides-ppt-generator/scripts/download_slides_pages_voices.py +24 -0
  5. package/bundled-skills/2slides-ppt-generator/scripts/get_job_status.py +18 -1
  6. package/bundled-skills/agent-creator/SKILL.md +15 -1
  7. package/bundled-skills/agent-orchestrator/scripts/scan_registry.py +4 -4
  8. package/bundled-skills/android-dev/references/hybrid.md +3 -3
  9. package/bundled-skills/android-dev/references/react-native.md +14 -8
  10. package/bundled-skills/competitor-analysis/scripts/compile_report.mjs +82 -20
  11. package/bundled-skills/diary/requirements.txt +3 -1
  12. package/bundled-skills/docs/users/getting-started.md +1 -1
  13. package/bundled-skills/docx-official/ooxml/scripts/validation/base.py +19 -1
  14. package/bundled-skills/docx-official/ooxml/scripts/validation/docx.py +20 -1
  15. package/bundled-skills/docx-official/ooxml/scripts/validation/redlining.py +21 -5
  16. package/bundled-skills/ecl-harness-engineer/references/environment-detection-guide.md +1 -1
  17. package/bundled-skills/hugging-face-model-trainer/scripts/convert_to_gguf.py +44 -23
  18. package/bundled-skills/instagram/scripts/db.py +120 -18
  19. package/bundled-skills/instagram/scripts/export.py +41 -8
  20. package/bundled-skills/instagram/scripts/publish.py +7 -7
  21. package/bundled-skills/instagram/scripts/run_all.py +2 -2
  22. package/bundled-skills/instagram/scripts/schedule.py +6 -5
  23. package/bundled-skills/instagram/static/dashboard.html +63 -16
  24. package/bundled-skills/junta-leiloeiros/scripts/requirements.txt +1 -1
  25. package/bundled-skills/k8s-manifest-generator/assets/deployment-template.yaml +20 -8
  26. package/bundled-skills/k8s-manifest-generator/assets/service-template.yaml +2 -3
  27. package/bundled-skills/last30days/scripts/lib/reddit_enrich.py +3 -1
  28. package/bundled-skills/loki-mode/benchmarks/results/2026-01-05-00-49-17/humaneval-solutions/162.py +1 -1
  29. package/bundled-skills/loki-mode/benchmarks/results/humaneval-loki-solutions/162.py +1 -1
  30. package/bundled-skills/loki-mode/examples/todo-app-generated/backend/src/index.ts +1 -0
  31. package/bundled-skills/loop-library/SKILL.md +11 -11
  32. package/bundled-skills/mcp-builder/scripts/evaluation.py +6 -2
  33. package/bundled-skills/notebooklm/scripts/run.py +23 -8
  34. package/bundled-skills/playwright-skill/lib/helpers.js +15 -17
  35. package/bundled-skills/pptx-official/ooxml/scripts/validation/base.py +19 -1
  36. package/bundled-skills/pptx-official/ooxml/scripts/validation/docx.py +20 -1
  37. package/bundled-skills/pptx-official/ooxml/scripts/validation/redlining.py +21 -5
  38. package/bundled-skills/remote-gpu-trainer/profiles/runpod.md +2 -2
  39. package/bundled-skills/senior-frontend/scripts/component_generator.py +67 -10
  40. package/bundled-skills/shopify-development/scripts/requirements.txt +1 -0
  41. package/bundled-skills/shopify-development/scripts/tests/test_shopify_init.py +13 -9
  42. package/bundled-skills/skill-installer/scripts/install_skill.py +73 -34
  43. package/bundled-skills/skill-installer/scripts/package_skill.py +36 -8
  44. package/bundled-skills/skill-installer/scripts/validate_skill.py +22 -7
  45. package/bundled-skills/skill-sentinel/scripts/db.py +69 -15
  46. package/bundled-skills/slack-gif-creator/requirements.txt +3 -2
  47. package/bundled-skills/stability-ai/scripts/requirements.txt +1 -1
  48. package/bundled-skills/telegram/assets/boilerplate/nodejs/src/bot-client.ts +1 -0
  49. package/bundled-skills/telegram/assets/boilerplate/python/requirements.txt +3 -1
  50. package/bundled-skills/telegram/scripts/send_message.py +39 -9
  51. package/bundled-skills/webapp-testing/scripts/with_server.py +105 -8
  52. package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/nodejs/src/index.ts +1 -0
  53. package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/python/requirements.txt +1 -0
  54. package/bundled-skills/whatsapp-cloud-api/scripts/setup_project.py +31 -3
  55. package/bundled-skills/writing-skills/render-graphs.js +30 -5
  56. package/bundled-skills/youtube-notetaker/reference/artifact.html +29 -18
  57. package/bundled-skills/youtube-notetaker/scripts/serve.py +49 -8
  58. package/package.json +1 -1
  59. 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
- result = subprocess.run(
82
- cmd,
83
- check=True,
84
- capture_output=True,
85
- text=True
86
- )
87
- if result.stdout:
88
- print(f" {result.stdout[:200]}") # Show first 200 chars
89
- return True
90
- except subprocess.CalledProcessError as e:
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 not found: {cmd[0]}")
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
- gguf_file = f"{gguf_output_dir}/{model_name}-f16.gguf"
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"{gguf_output_dir}/{model_name}-{quant_type.lower()}.gguf"
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
- keys = [k for k in data.keys() if k != "id" and k in _POSTS_COLUMNS]
215
- if not keys:
216
- raise ValueError("No valid columns provided for insert_post")
217
- placeholders = ", ".join("?" for _ in keys)
218
- columns = ", ".join(keys)
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(sql, values)
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
- sets = ["status = ?"]
228
- params: list = [status]
229
- for k, v in extra.items():
230
- if k not in _POSTS_COLUMNS:
231
- raise ValueError(f"Invalid column name for update_post_status: {k}")
232
- sets.append(f"{k} = ?")
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(sql, params)
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
- from config import EXPORTS_DIR
23
- from db import Database
22
+ _db = None
24
23
 
25
- db = Database()
26
- db.init()
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 = db._connect()
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=True,
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
- parser.add_argument("--output", default=str(EXPORTS_DIR), help=f"Diretório (default: {EXPORTS_DIR})")
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
- output_dir = Path(args.output)
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.upper(),
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.upper()]
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
- "account_id": api.account_id,
209
- "media_type": media_type.upper(),
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.upper()
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_{post['media_type'].lower()}", account["id"])
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 post["status"] == "container_created" and post.get("ig_container_id"):
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, post["status"], media_url=media_url)
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.innerHTML = '<tr><td colspan="5">Sem posts no banco.</td></tr>'; return; }
177
+ if (!posts.length) { emptyRow(tbody, 5, 'Sem posts no banco.'); return; }
154
178
 
155
- tbody.innerHTML = posts.map(p => {
156
- const badgeClass = `badge-${p.status}`;
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 link = p.permalink ? `<a href="${p.permalink}" target="_blank">Ver</a>` : '-';
160
- return `<tr>
161
- <td>${p.media_type || '-'}</td>
162
- <td>${caption || '-'}</td>
163
- <td><span class="badge ${badgeClass}">${p.status}</span></td>
164
- <td>${date ? date.substring(0, 16) : '-'}</td>
165
- <td>${link}</td>
166
- </tr>`;
167
- }).join('');
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.innerHTML = '<tr><td colspan="3">Sem ações registradas.</td></tr>'; return; }
216
+ if (!actions.length) { emptyRow(tbody, 3, 'Sem ações registradas.'); return; }
175
217
 
176
- tbody.innerHTML = actions.map(a => {
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
- return `<tr><td>${a.action}</td><td>${date}</td><td>${(details||'').substring(0, 80)}</td></tr>`;
181
- }).join('');
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
@@ -1,7 +1,7 @@
1
1
  # Dependências principais
2
2
  httpx>=0.27.0
3
3
  beautifulsoup4>=4.12.0
4
- lxml>=5.0.0
4
+ lxml>=6.1.0
5
5
 
6
6
  # API
7
7
  fastapi>=0.111.0
@@ -51,26 +51,38 @@ spec:
51
51
  # Pod-level security context
52
52
  securityContext:
53
53
  runAsNonRoot: true
54
- runAsUser: 1000
55
- runAsGroup: 1000
56
- fsGroup: 1000
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.36
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: 1000
77
+ runAsUser: 10001
78
+ capabilities:
79
+ drop:
80
+ - ALL
69
81
 
70
82
  containers:
71
83
  - name: <container-name>
72
- image: <registry>/<image>:<tag> # Never use :latest
73
- imagePullPolicy: IfNotPresent
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: 1000
170
+ runAsUser: 10001
159
171
  capabilities:
160
172
  drop:
161
173
  - ALL