📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user