📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-06-23 16:03:00 +00:00
parent c4c6a41c21
commit 59e15f8999
557 changed files with 10501 additions and 3168 deletions
@@ -138,6 +138,99 @@ _POSTS_COLUMNS = frozenset({
"hashtags", "template_id", "status", "scheduled_at", "published_at",
"ig_media_id", "ig_container_id", "permalink", "error_msg", "created_at",
})
_POST_STATUSES = frozenset({
"draft", "approved", "scheduled", "container_created", "published", "failed",
})
_MEDIA_TYPES = frozenset({"PHOTO", "VIDEO", "REEL", "STORY", "CAROUSEL"})
_MEDIA_TYPE_ALIASES = {
"IMAGE": "PHOTO",
"REELS": "REEL",
"STORIES": "STORY",
"CAROUSEL_ALBUM": "CAROUSEL",
}
_POSTS_INSERT_COLUMNS = (
"account_id", "media_type", "media_url", "local_path", "caption",
"hashtags", "template_id", "status", "scheduled_at", "published_at",
"ig_media_id", "ig_container_id", "permalink", "error_msg",
)
_POSTS_UPDATE_COLUMNS = (
"media_type", "media_url", "local_path", "caption", "hashtags",
"template_id", "status", "scheduled_at", "published_at", "ig_media_id",
"ig_container_id", "permalink", "error_msg",
)
_INSERT_POST_SQL = """
INSERT INTO posts (
account_id, media_type, media_url, local_path, caption, hashtags,
template_id, status, scheduled_at, published_at, ig_media_id,
ig_container_id, permalink, error_msg
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
_UPDATE_POST_SQL = """
UPDATE posts SET
media_type = ?,
media_url = ?,
local_path = ?,
caption = ?,
hashtags = ?,
template_id = ?,
status = ?,
scheduled_at = ?,
published_at = ?,
ig_media_id = ?,
ig_container_id = ?,
permalink = ?,
error_msg = ?
WHERE id = ?
"""
def _quote_identifier(name: str, allowed: frozenset[str]) -> str:
"""Quote a SQLite identifier after checking it against an allowlist."""
if name not in allowed:
raise ValueError(f"Invalid column name: {name}")
return '"' + name.replace('"', '""') + '"'
def normalize_post_status(status: str) -> str:
value = str(status).strip().lower()
if value not in _POST_STATUSES:
raise ValueError(f"Invalid post status: {status}")
return value
def normalize_media_type(media_type: str) -> str:
value = str(media_type).strip().upper()
value = _MEDIA_TYPE_ALIASES.get(value, value)
if value not in _MEDIA_TYPES:
raise ValueError(f"Invalid media type: {media_type}")
return value
def _positive_int(value: Any, field: str) -> int:
number = int(value)
if number < 1:
raise ValueError(f"{field} must be a positive integer")
return number
def _bounded_int(value: Any, field: str, *, minimum: int, maximum: int) -> int:
number = int(value)
if number < minimum or number > maximum:
raise ValueError(f"{field} must be between {minimum} and {maximum}")
return number
def _normalize_post_data(data: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(data)
if "media_type" in normalized and normalized["media_type"] is not None:
normalized["media_type"] = normalize_media_type(normalized["media_type"])
if "status" in normalized and normalized["status"] is not None:
normalized["status"] = normalize_post_status(normalized["status"])
if "account_id" in normalized and normalized["account_id"] is not None:
normalized["account_id"] = _positive_int(normalized["account_id"], "account_id")
if "template_id" in normalized and normalized["template_id"] is not None:
normalized["template_id"] = _positive_int(normalized["template_id"], "template_id")
return normalized
class Database:
@@ -211,30 +304,33 @@ class Database:
def insert_post(self, data: Dict[str, Any]) -> int:
"""Cria um novo post (draft por padrão). Retorna o id."""
keys = [k for k in data.keys() if k != "id" and k in _POSTS_COLUMNS]
if not keys:
raise ValueError("No valid columns provided for insert_post")
placeholders = ", ".join("?" for _ in keys)
columns = ", ".join(keys)
values = [data[k] for k in keys]
sql = f"INSERT INTO posts ({columns}) VALUES ({placeholders})"
data = _normalize_post_data(data)
unknown = set(data) - _POSTS_COLUMNS - {"id"}
if unknown:
raise ValueError(f"Invalid columns for insert_post: {', '.join(sorted(unknown))}")
values = [data.get(column) for column in _POSTS_INSERT_COLUMNS]
with self._connect() as conn:
cursor = conn.execute(sql, values)
cursor = conn.execute(_INSERT_POST_SQL, values)
return cursor.lastrowid
def update_post_status(self, post_id: int, status: str, **extra) -> None:
"""Atualiza status de um post e campos adicionais."""
sets = ["status = ?"]
params: list = [status]
for k, v in extra.items():
if k not in _POSTS_COLUMNS:
raise ValueError(f"Invalid column name for update_post_status: {k}")
sets.append(f"{k} = ?")
params.append(v)
params.append(post_id)
sql = f"UPDATE posts SET {', '.join(sets)} WHERE id = ?"
post_id = _positive_int(post_id, "post_id")
status = normalize_post_status(status)
extra = _normalize_post_data(extra)
unknown = set(extra) - _POSTS_COLUMNS
if unknown:
raise ValueError(f"Invalid columns for update_post_status: {', '.join(sorted(unknown))}")
with self._connect() as conn:
conn.execute(sql, params)
row = conn.execute("SELECT * FROM posts WHERE id = ?", [post_id]).fetchone()
if not row:
raise ValueError(f"Post {post_id} not found")
merged = dict(row)
merged.update(extra)
merged["status"] = status
params = [merged.get(column) for column in _POSTS_UPDATE_COLUMNS]
params.append(post_id)
conn.execute(_UPDATE_POST_SQL, params)
def get_posts(
self,
@@ -246,11 +342,15 @@ class Database:
conditions = []
params: list = []
if account_id:
account_id = _positive_int(account_id, "account_id")
conditions.append("account_id = ?")
params.append(account_id)
if status:
status = normalize_post_status(status)
conditions.append("status = ?")
params.append(status)
limit = _bounded_int(limit, "limit", minimum=1, maximum=1000)
offset = _bounded_int(offset, "offset", minimum=0, maximum=100000)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
sql = f"SELECT * FROM posts {where} ORDER BY created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
@@ -260,6 +360,7 @@ class Database:
def get_posts_for_publishing(self, account_id: int) -> List[Dict[str, Any]]:
"""Posts aprovados/agendados prontos para publicar."""
account_id = _positive_int(account_id, "account_id")
now = datetime.now(timezone.utc).isoformat()
sql = """
SELECT * FROM posts
@@ -275,6 +376,7 @@ class Database:
return [dict(r) for r in rows]
def get_post_by_id(self, post_id: int) -> Optional[Dict[str, Any]]:
post_id = _positive_int(post_id, "post_id")
with self._connect() as conn:
row = conn.execute("SELECT * FROM posts WHERE id = ?", [post_id]).fetchone()
return dict(row) if row else None
@@ -19,11 +19,36 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from config import EXPORTS_DIR
from db import Database
_db = None
db = Database()
db.init()
def get_db():
global _db
if _db is None:
from db import Database
_db = Database()
_db.init()
return _db
def safe_output_dir(output: str | Path) -> Path:
output_dir = Path(output).expanduser().resolve()
skill_dir = Path(__file__).resolve().parents[1]
try:
output_dir.relative_to(skill_dir)
except ValueError:
return output_dir
raise ValueError("Refusing to export inside the skill source directory")
def self_test() -> None:
skill_dir = Path(__file__).resolve().parents[1]
safe_output_dir(skill_dir.parent / "instagram-exports")
try:
safe_output_dir(skill_dir / "scripts" / "exports")
except ValueError:
return
raise AssertionError("accepted export directory inside skill source")
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:
def get_data(data_type: str) -> tuple:
"""Retorna (records, name) para o tipo de dados."""
conn = db._connect()
conn = get_db()._connect()
if data_type == "posts":
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:
def main():
parser = argparse.ArgumentParser(description="Exportar dados do Instagram")
parser.add_argument("--type", required=True,
parser.add_argument("--type", required=False,
choices=["posts", "comments", "insights", "user_insights", "templates", "actions", "all"],
help="Tipo de dados")
parser.add_argument("--format", default="csv", choices=["json", "jsonl", "csv", "all"],
help="Formato (default: csv)")
parser.add_argument("--output", default=str(EXPORTS_DIR), help=f"Diretório (default: {EXPORTS_DIR})")
default_exports_dir = Path(__file__).resolve().parents[1] / "data" / "exports"
parser.add_argument("--output", default=str(default_exports_dir), help=f"Diretório (default: {default_exports_dir})")
parser.add_argument("--self-test", action="store_true", help="Run safety self-checks")
args = parser.parse_args()
output_dir = Path(args.output)
if args.self_test:
self_test()
return
if not args.type:
parser.error("--type is required unless --self-test is used")
output_dir = safe_output_dir(args.output)
if args.type == "all":
for dtype in ["posts", "comments", "insights", "user_insights", "templates", "actions"]:
@@ -30,7 +30,7 @@ sys.path.insert(0, str(Path(__file__).parent))
from api_client import InstagramAPI
from auth import auto_refresh_if_needed
from db import Database
from db import Database, normalize_media_type
from governance import GovernanceManager
db = Database()
@@ -173,12 +173,13 @@ async def publish_video(
as_draft: bool = False,
) -> dict:
"""Publica vídeo, reel ou story de vídeo."""
media_type = normalize_media_type(media_type)
video_url = await upload_if_local(api, video)
if as_draft:
post_id = db.insert_post({
"account_id": api.account_id,
"media_type": media_type.upper(),
"media_type": media_type,
"media_url": video_url,
"local_path": video if _is_local_file(video) else None,
"caption": caption,
@@ -195,7 +196,7 @@ async def publish_video(
)
# Step 1: Container
ig_type = {"VIDEO": "VIDEO", "REEL": "REELS", "STORY": "STORIES"}[media_type.upper()]
ig_type = {"VIDEO": "VIDEO", "REEL": "REELS", "STORY": "STORIES"}[media_type]
container = await api.create_media_container(
media_type=ig_type,
video_url=video_url,
@@ -205,8 +206,8 @@ async def publish_video(
container_id = container["id"]
post_id = db.insert_post({
"account_id": api.account_id,
"media_type": media_type.upper(),
"account_id": api.account_id,
"media_type": media_type,
"media_url": video_url,
"caption": caption,
"status": "container_created",
@@ -386,7 +387,6 @@ async def run(args) -> None:
# Aplicar template se especificado
if args.template:
from db import Database
tpl = Database().get_template_by_name(args.template)
if tpl:
caption = tpl["caption_template"]
@@ -397,7 +397,7 @@ async def run(args) -> None:
variables = dict(v.split("=", 1) for v in args.vars)
caption = _apply_template(caption, variables)
media_type = args.type.upper()
media_type = normalize_media_type(args.type)
if media_type == "PHOTO":
result = await publish_photo(api, args.image, caption, as_draft=args.draft)
@@ -22,7 +22,7 @@ sys.path.insert(0, str(Path(__file__).parent))
from api_client import InstagramAPI
from auth import auto_refresh_if_needed
from db import Database
from db import Database, normalize_media_type
logging.basicConfig(
level=logging.INFO,
@@ -58,7 +58,7 @@ async def sync_media(api: InstagramAPI, limit: int = 50) -> dict:
if m["id"] not in existing_ig_ids:
db.insert_post({
"account_id": api.account_id,
"media_type": m.get("media_type", "IMAGE"),
"media_type": normalize_media_type(m.get("media_type", "IMAGE")),
"media_url": m.get("media_url", ""),
"caption": m.get("caption", ""),
"status": "published",
@@ -18,7 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent))
from api_client import InstagramAPI
from auth import auto_refresh_if_needed
from db import Database
from db import Database, normalize_media_type, normalize_post_status
from governance import GovernanceManager, RateLimitExceeded
db = Database()
@@ -45,15 +45,17 @@ async def process_pending() -> None:
for post in posts:
post_id = post["id"]
post_status = normalize_post_status(post["status"])
media_type = normalize_media_type(post["media_type"])
try:
gov.check_rate_limit(f"publish_{post['media_type'].lower()}", account["id"])
gov.check_rate_limit(f"publish_{media_type.lower()}", account["id"])
except RateLimitExceeded as e:
results.append({"post_id": post_id, "status": "rate_limited", "error": str(e)})
break
try:
# Recovery: se já tem container criado, tenta publicar direto
if post["status"] == "container_created" and post.get("ig_container_id"):
if post_status == "container_created" and post.get("ig_container_id"):
result = await api.publish_media(post["ig_container_id"])
ig_media_id = result.get("id")
details = await api.get_media_details(ig_media_id)
@@ -70,9 +72,8 @@ async def process_pending() -> None:
media_url = post.get("media_url", "")
if not media_url and post.get("local_path"):
media_url = await api.upload_to_imgur(post["local_path"])
db.update_post_status(post_id, post["status"], media_url=media_url)
db.update_post_status(post_id, post_status, media_url=media_url)
media_type = post["media_type"].upper()
ig_type_map = {"PHOTO": "IMAGE", "VIDEO": "VIDEO", "REEL": "REELS", "STORY": "STORIES"}
ig_type = ig_type_map.get(media_type, "IMAGE")
@@ -146,39 +146,86 @@
});
}
function td(text) {
const cell = document.createElement('td');
cell.textContent = text == null || text === '' ? '-' : String(text);
return cell;
}
function safeURL(url) {
try {
const parsed = new URL(url, window.location.href);
return /^https?:$/.test(parsed.protocol) ? parsed.href : '';
} catch (e) {
return '';
}
}
function emptyRow(tbody, cols, text) {
tbody.replaceChildren();
const tr = document.createElement('tr');
const cell = td(text);
cell.colSpan = cols;
tr.appendChild(cell);
tbody.appendChild(tr);
}
async function loadPosts() {
const data = await fetchJSON('/api/posts?limit=20');
const tbody = document.getElementById('posts-body');
const posts = data.data || [];
if (!posts.length) { tbody.innerHTML = '<tr><td colspan="5">Sem posts no banco.</td></tr>'; return; }
if (!posts.length) { emptyRow(tbody, 5, 'Sem posts no banco.'); return; }
tbody.innerHTML = posts.map(p => {
const badgeClass = `badge-${p.status}`;
tbody.replaceChildren();
posts.forEach(p => {
const status = String(p.status || '-');
const badgeClass = `badge-${status.replace(/[^a-z0-9_-]/gi, '')}`;
const caption = (p.caption || '').substring(0, 60) + ((p.caption||'').length > 60 ? '...' : '');
const date = p.published_at || p.created_at || '';
const link = p.permalink ? `<a href="${p.permalink}" target="_blank">Ver</a>` : '-';
return `<tr>
<td>${p.media_type || '-'}</td>
<td>${caption || '-'}</td>
<td><span class="badge ${badgeClass}">${p.status}</span></td>
<td>${date ? date.substring(0, 16) : '-'}</td>
<td>${link}</td>
</tr>`;
}).join('');
const tr = document.createElement('tr');
tr.appendChild(td(p.media_type || '-'));
tr.appendChild(td(caption || '-'));
const statusCell = document.createElement('td');
const badge = document.createElement('span');
badge.className = `badge ${badgeClass}`;
badge.textContent = status;
statusCell.appendChild(badge);
tr.appendChild(statusCell);
tr.appendChild(td(date ? date.substring(0, 16) : '-'));
const linkCell = document.createElement('td');
const href = p.permalink ? safeURL(p.permalink) : '';
if (href) {
const link = document.createElement('a');
link.href = href;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = 'Ver';
linkCell.appendChild(link);
} else {
linkCell.textContent = '-';
}
tr.appendChild(linkCell);
tbody.appendChild(tr);
});
}
async function loadActions() {
const data = await fetchJSON('/api/actions?limit=15');
const tbody = document.getElementById('actions-body');
const actions = data.data || [];
if (!actions.length) { tbody.innerHTML = '<tr><td colspan="3">Sem ações registradas.</td></tr>'; return; }
if (!actions.length) { emptyRow(tbody, 3, 'Sem ações registradas.'); return; }
tbody.innerHTML = actions.map(a => {
tbody.replaceChildren();
actions.forEach(a => {
const date = a.created_at ? a.created_at.substring(0, 16) : '-';
let details = '-';
try { const p = JSON.parse(a.params || '{}'); details = Object.entries(p).map(([k,v]) => `${k}: ${v}`).join(', '); } catch(e) {}
return `<tr><td>${a.action}</td><td>${date}</td><td>${(details||'').substring(0, 80)}</td></tr>`;
}).join('');
const tr = document.createElement('tr');
tr.appendChild(td(a.action));
tr.appendChild(td(date));
tr.appendChild(td((details || '').substring(0, 80)));
tbody.appendChild(tr);
});
}
// Load everything