
- 26 installs
- Updated July 13, 2026
- hoangvantuan/claude-plugin
Publish Facebook wall or group posts with images and friend tags from your coding agent via PinchTab instead of manual browser work.
About
Facebook is a PinchTab-backed agent skill for solo builders who want to push finished posts to a personal wall or group without leaving the terminal. It assumes PinchTab is installed, the server is running, and a named profile already holds a Facebook session; first-time setup uses a headed browser login, then reuse cookies on headless runs. Operators enable `security.allowEvaluate` for reliable image upload and text input. The agent runs `fb-post.sh` with content and optional group URL, profile name, and wall user id defaults. It fits the launch distribution lane when you have copy and assets ready—not when you still need ideation or long-form writing. macOS users benefit from coreutils for timeout handling; pure-bash fallbacks exist elsewhere. Treat it as a narrow integration on top of PinchTab, not a general marketing strategy skill.
- Posts to personal wall or Facebook group using PinchTab profile sessions
- Supports image attachment and tagging via browser evaluate (requires allowEvaluate)
- Shell wrapper `scripts/fb-post.sh` with positional or `--content` / `--profile` / `--group` flags
- Headed login flow guidance when no saved Facebook cookies on the profile
- Explicit scope: not for drafting copy (social-post) or generic browser tasks (pinchtab)
Facebook by the numbers
- 26 all-time installs (skills.sh)
- Ranked #1,246 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hoangvantuan/claude-plugin --skill facebookAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 26 |
|---|---|
| Security audit | 1 / 3 scanners passed |
| Last updated | July 13, 2026 |
| Repository | hoangvantuan/claude-plugin ↗ |
What it does
Publish Facebook wall or group posts with images and friend tags from your coding agent via PinchTab instead of manual browser work.
Files
Facebook Automation via PinchTab
Đăng bài Facebook (wall/group) + đính kèm ảnh + tag bạn bè qua PinchTab browser control.
Prerequisites
- PinchTab installed và server đang chạy (
pinchtab server &) - Một PinchTab profile có Facebook login session (cookies đã lưu từ lần login thủ công trước)
- Bật
pinchtab config set security.allowEvaluate true(bắt buộc cho image upload, khuyến khích cho text input) - macOS: khuyến khích
brew install coreutils(cung cấpgtimeout). Không bắt buộc, script có pure-bash fallback
Nếu user chưa login: hướng dẫn start headed instance → login thủ công → reuse profile.
Script Usage
Script: scripts/fb-post.sh
# Positional syntax
bash scripts/fb-post.sh "<content>" [options]
# Named syntax
bash scripts/fb-post.sh --content "<content>" [options]Parameters:
| Param | Required | Default | Description |
|---|---|---|---|
<content> | Yes | — | Post content (positional arg hoặc --content) |
--profile | No | default | PinchTab profile name |
--user-id | No | 100003782705460 | Facebook numeric ID (wall mode) |
--group | No | — | Group slug hoặc full URL → post vào group thay vì wall |
--title | No | — | Tiêu đề bài viết (chỉ group, hiển thị bold phía trên nội dung) |
--image | No | — | Đường dẫn ảnh đính kèm (dùng nhiều lần cho nhiều ảnh, tối đa 8) |
--tag | No | — | Tên hiển thị của bạn bè cần tag |
--tag-id | No | — | Facebook ID của bạn bè (chính xác hơn search theo tên) |
--publish | No | false | true = đăng ngay, false = chỉ soạn (user review trước) |
--mode | No | headless | headed = hiển thị browser, headless = chạy nền |
--debug | No | false | Chụp screenshot mỗi bước vào /tmp/ (flag, không cần value: --debug) |
--dry-run | No | — | Log actions mà không mở browser (kiểm tra params) |
--keep-instance | No | — | Giữ browser instance sau khi xong (dùng cho chain nhiều posts) |
Post lên Wall
bash scripts/fb-post.sh "Hello world!"
bash scripts/fb-post.sh "Hello!" --tag "Hoang Van Tuan" --publish truePost với ảnh đính kèm
bash scripts/fb-post.sh "Check this out!" --image /path/to/photo.jpg --publish true
bash scripts/fb-post.sh "Ảnh đẹp!" --image photo1.jpg --image photo2.jpg --tag "Ngoc" --group tuhoccungai --publish truePost vào Group
bash scripts/fb-post.sh "Hello group!" --group tuhoccungai --publish true
bash scripts/fb-post.sh "Nội dung" --group "https://www.facebook.com/groups/tuhoccungai" --keep-instance --publish true
bash scripts/fb-post.sh "Nội dung bài viết" --title "[Series AI Agent] Vòng lặp nghĩ-làm" --group tuhoccungai --publish trueExit Codes & Lỗi thường gặp
| Code | Meaning | Nguyên nhân & cách xử lý |
|---|---|---|
0 | Thành công | — |
1 | Sai arguments | Thiếu content, --user-id nhận vanity URL thay vì numeric ID, file ảnh không tồn tại hoặc sai format (script validate magic bytes trước khi mở browser) |
2 | Instance failure | PinchTab chưa chạy (pinchtab server &) hoặc profile không tồn tại |
3 | Element not found | FB UI thay đổi, group URL sai, không có quyền. Dùng --debug kiểm tra screenshot |
4 | Publish failed | Nút đăng không tìm thấy |
5 | Upload failed | security.allowEvaluate tắt → pinchtab config set security.allowEvaluate true. Ảnh dispatch nhưng không attach → verify blob: URL trong 10s |
6 | Missing dependency | Thiếu binary cần thiết (pinchtab, python3, curl, xxd). Log ghi rõ binary nào thiếu và cách cài |
Lỗi AI hay mắc (không báo exit code):
- Quên
--publish true— default làfalse, user phải xác nhận trước khi thêm - Tag sai người — nhiều người trùng tên, dùng
--tag-idcho chính xác - Content chứa quotes — wrap bằng double quotes, escape
"bên trong - Fast failure (< 1s) — script báo "timed out" nhưng thực ra binary không tồn tại hoặc server chưa chạy. Xem exit code và thời gian thực tế trong log
Chi tiết kỹ thuật
Cách script hoạt động, instance lifecycle, troubleshooting → internals. Đọc khi cần debug hoặc xử lý sự cố.
Facebook Skill — Internals
Chi tiết kỹ thuật bên trong script. Đọc khi cần debug, xử lý sự cố, hoặc hiểu cách script hoạt động.
Dependencies
Required (script exit 6 nếu thiếu):
| Binary | Mục đích | Cài đặt |
|---|---|---|
pinchtab | Browser automation | npm install -g @nicepkg/pinchtab |
python3 | JSON parsing, image encoding, helper scripts | macOS có sẵn |
curl | HTTP API calls | macOS có sẵn |
xxd | Image magic byte validation (chỉ khi có --image) | brew install vim |
Optional (script có fallback nếu thiếu):
| Binary | Mục đích | Cài đặt | Fallback |
|---|---|---|---|
timeout / gtimeout | Giới hạn thời gian chạy lệnh | brew install coreutils | Pure-bash: background process + watchdog kill |
Timeout Behavior
Script dùng _timeout shim thay vì gọi timeout trực tiếp. Shim thử theo thứ tự: timeout → gtimeout → pure-bash fallback. Fallback dùng background process + watchdog sleep, hoạt động trên mọi bash 4+ nhưng không chính xác bằng GNU timeout (không phân biệt exit-by-timeout vs exit-by-error).
Fast-Fail Detection
Khi nav_new_tab fail, script đo thời gian thực tế:
- < 3s (nhưng timeout khai báo 30s): log "failed immediately", gợi ý kiểm tra binary/server/network
- exit 127: log "command not found", exit 6
- >= 3s: log "timed out" bình thường
Mục đích: phân biệt "service chậm" (timeout thật) vs "tool không tồn tại" (fail tức thì).
Strict Mode
Script dùng set -uo pipefail (không có -e). Lý do: nhiều command dùng || true pattern. Bù lại, script validate tất cả critical binaries ở đầu (trước khi mở browser). Nếu không có -e, command-not-found (exit 127) không kill script. Dependency check giúp bắt lỗi này sớm.
How It Works
Script dùng PinchTab accessibility snapshot để tìm UI elements theo role và multi-language keywords (Tiếng Việt + English). Mỗi lần tìm element sẽ thử tất cả ngôn ngữ cho đến khi match — không cần cấu hình locale.
0. Fail-fast checks — validate image magic bytes (JPEG/PNG/GIF/WebP), kiểm tra allowEvaluate TRƯỚC khi mở browser 1. Start/reuse browser — kiểm tra instance qua GET /instances/{id} API (status: running), restart nếu stale. Export PINCHTAB_INSTANCE 2. Navigate — dùng pinchtab nav <url> --new-tab --print-tab-id để tạo tab mới (tránh stale tab mặc định). Export PINCHTAB_TAB từ tab ID trả về 3. Validate page — group mode kiểm tra create-post button tồn tại (early error nếu URL sai hoặc không có quyền) 4. Open post dialog — click button với retry logic: verify textbox xuất hiện, retry tối đa 3 lần 5. Type content — dùng pinchtab focus + pinchtab keyboard inserttext để giữ line breaks trong contenteditable. Fallback sang CLI type nếu fail 6. Attach images (optional) — focus composer, dùng pinchtab eval với ClipboardEvent + DataTransfer chứa ảnh base64 inline. Verify "Gỡ file đính kèm" xuất hiện trước khi tiếp tục 7. Tag friend (optional) — mở tag dialog, search theo tên, chọn bằng keyboard (ArrowDown + Enter); dùng --tag-id cho precise match, ưu tiên "Bạn bè" (friends) 8. Publish or hold — dùng exact match cho nút "Đăng"/"Post" để tránh click nhầm "Đăng ẩn danh"
Wall vs Group — Điểm khác biệt chính
| Aspect | Wall | Group |
|---|---|---|
| Navigation | facebook.com/me hoặc profile ID | Group URL (slug hoặc full) |
| Create post button | "nghĩ gì" / "what's on your mind" | "viết gì" / "write something" |
| Page validation | Không | Kiểm tra create-post button tồn tại |
| Title field | Không hỗ trợ | Có (--title), rich text H1 formatting |
Các bước còn lại (textbox, tagging, publish) hoạt động giống nhau.
Image Upload Flow
Khi --image được cung cấp:
1. Magic byte validation — kiểm tra 4 byte đầu file: FFD8FF (JPEG), 89504E47 (PNG), 47494638 (GIF), 52494646 (WebP/RIFF). File sai format (vd: HTML lưu thành .jpg) bị reject trước khi mở browser 2. Permission check — verify security.allowEvaluate qua GET /api/config. Bắt buộc cho image upload. Hard fail nếu tắt 3. Focus composer — pinchtab focus vào composer textbox (đã mở từ Step 4) 4. Eval ClipboardEvent paste — Python đọc file ảnh → base64 encode → build JS snippet → gửi qua HTTP /evaluate API. JS tạo ClipboardEvent("paste") với DataTransfer chứa image File object → dispatch lên document.activeElement. Chi tiết xem "### Eval Paste Method" 5. Post-upload verification — poll accessibility snapshot tối đa 10s, tìm: blob: URL, nút "Gỡ file"/"Remove file", hoặc role=img có blob/scontent URL
Eval Paste Method
Upload ảnh qua pinchtab eval (HTTP /evaluate API) + ClipboardEvent + DataTransfer.
Tại sao dùng eval thay vì CDP paste hay file picker:
- File picker (click "Ảnh/video" → OS dialog) — CDP Cmd+V paste vào file picker text field, không upload ảnh
- CDP paste (clipboard + dispatchKeyEvent) — phụ thuộc macOS clipboard, Chrome debug port,
wsmodule. Không ổn định - Eval ClipboardEvent — gửi trực tiếp qua HTTP API, không phụ thuộc clipboard hay debug port. Base64 inline trong JS → FB composer nhận paste event và attach ảnh
Flow:
1. Python đọc binary file, encode base64 2. Build JS: atob(base64) → Uint8Array → Blob → File → DataTransfer → ClipboardEvent("paste") → dispatch 3. Gửi JS qua POST /evaluate (HTTP API, không giới hạn shell ARG_MAX) 4. PinchTab evaluate JS trong page context → FB nhận paste event với image data
Bật quyền cần thiết:
pinchtab config set security.allowEvaluate trueText Input
pinchtab focus <ref> + pinchtab keyboard inserttext "<content>" — giữ nguyên newlines trong contenteditable. Fallback sang pinchtab type nếu fail.
Tab Management
Sau instance start, env PINCHTAB_TAB mặc định trỏ tab cũ (stale). Navigation không có --new-tab sẽ 404. Script xử lý:
1. Export PINCHTAB_INSTANCE ngay sau instance start/reuse 2. Mọi navigation dùng pinchtab nav <url> --new-tab --print-tab-id 3. Capture tab ID mới, export PINCHTAB_TAB
Quirks quan trọng:
pinchtab nav(không phảipinchtab navigate)pinchtab navchỉ nhận 1 arg (url), instance qua envPINCHTAB_INSTANCE--new-tabbắt buộc cho lần nav đầu tiên sau instance start
Instance Health Check
Script health-check 2 tầng:
1. API status: GET /instances/{id}, kiểm tra status == "running". Timeout 5s 2. Snap smoke-test: pinchtab snap --compact=false với timeout 5s, verify trả về nodes > 0. Bắt trường hợp instance "running" nhưng Chrome đã crash hoặc không có tab nào
Nếu một trong hai fail thì coi instance là stale, stop và start mới.
Chrome Lifecycle
pinchtab instance stop stop PinchTab instance nhưng không kill Chrome process. Chrome tiếp tục chạy ngầm với profile dir. Hệ quả:
- Composer state (dialog đã mở, text đã nhập) từ run trước còn lại trong Chrome
- Run tiếp theo có thể thấy state cũ (textbox đã mở, create-post button biến mất)
Script xử lý:
- Normal exit: chỉ stop PinchTab instance
- Error exit (exit code != 0): stop instance +
pgrep/killChrome process matching profile name. Tránh leftover state gây lỗi run tiếp theo - Group validation: check cả create-post button VÀ composer textbox đã mở (handle leftover state)
Nếu gặp vấn đề persistent, kill Chrome thủ công:
pkill -f "chrome.*default" # thay "default" bằng profile nameInstance Lifecycle
Scripts tự động reuse instance đang chạy. Dùng --keep-instance để giữ browser sau khi script kết thúc — hữu ích khi chain nhiều posts.
Start instance thủ công (nếu chưa có):
# Headed — visible browser
pinchtab instance start --profile default --mode headed
# Headless — background (default)
pinchtab instance start --profile default --mode headlessStop instance — stop khi xong để free resources:
pinchtab instance list
pinchtab instance stop <instance_id>Nếu instance stale (commands timeout), scripts tự detect và restart. Force-restart thủ công: stop rồi start lại.
Troubleshooting
| Issue | Solution |
|---|---|
| "Cannot find button" | Facebook UI có thể đã thay đổi. Dùng --debug và check screenshots + pinchtab snap. |
| Session expired | Re-login thủ công ở headed mode để refresh cookies trong profile. |
| Browser won't start | Đảm bảo pinchtab server đang chạy và không có instance conflict. |
| Stale instance | Script detect qua /instances/{id} API (status != running) và restart tự động. |
| Stale tab (404 khi nav) | Script dùng --new-tab --print-tab-id tự động. Nếu vẫn lỗi: check PINCHTAB_INSTANCE env. |
| Wrong person tagged | Dùng --tag-id <facebook_id> thay vì chỉ search theo tên. |
| Bad profile name | Script báo lỗi rõ với exit code 2. Verify profile tồn tại trong PinchTab. |
| Group not accessible | Script validate group page sớm (exit code 3). Check URL và membership. |
| Dialog didn't open | Script tự retry click 3 lần. Check --debug screenshots. |
| Upload failed (exit 5) | Bật security.allowEvaluate: pinchtab config set security.allowEvaluate true. |
| Image not attached | Script verify blob:/nút "Gỡ file" trong 10s. Nếu fail: check file format (magic bytes), allowEvaluate. |
| Invalid image format | Script check magic bytes trước khi mở browser. File .jpg thật ra là HTML sẽ bị reject ngay. |
| Text bị cắt/mất line breaks | Bật security.allowEvaluate: pinchtab config set security.allowEvaluate true. |
| Missing dependency (exit 6) | Script báo rõ binary nào thiếu + cách cài. Chạy lại sau khi cài xong. |
| Fast failure (< 1s nhưng log ghi "timeout") | Không phải timeout thật. Kiểm tra: binary tồn tại? Server đang chạy? Script phân biệt tự động từ v2 (log ghi "failed immediately" thay vì "timed out"). |
timeout not found trên macOS | Script tự fallback sang gtimeout hoặc pure-bash. Khuyến khích brew install coreutils cho chính xác hơn. |
Known Limitations (PinchTab)
| Limitation | Workaround |
|---|---|
security.allowEvaluate mặc định off | Cần bật thủ công. Áp dụng ngay, không cần restart server |
| PinchTab 0.10+ nested config key | GET /api/config trả config.security.allowEvaluate (nested) thay vì flat key. Script hỗ trợ cả hai format |
pinchtab snap --instance <id> không tồn tại | Dùng GET /instances/{id} API để health-check thay vì CLI snap |
| PINCHTAB_TAB stale sau instance start | Script dùng --new-tab --print-tab-id khi nav, tự export tab ID mới |
| Eval payload lớn (ảnh > 5MB) | Base64 ~6.7MB cho ảnh 5MB. Gửi qua HTTP API (không giới hạn ARG_MAX). PinchTab xử lý OK |
| FB Lexical editor | pinchtab keyboard inserttext giữ newlines. Nếu fail, fallback sang CLI type (có thể mất format) |
| Default headless mode | Dùng --mode headed nếu cần nhìn browser. Headed có quirk snap discovery |
#!/bin/bash
# =============================================================
# Facebook Post (Wall / Group) + Images + Tag User via PinchTab
# =============================================================
# Usage (positional):
# fb-post.sh <content> [options]
#
# Usage (named):
# fb-post.sh --content "Noi dung" --group tuhoccungai --publish true
#
# Examples:
# fb-post.sh "Hello world!"
# fb-post.sh "Hello group!" --group tuhoccungai --publish true
# fb-post.sh "Tag post" --tag "Ngoc" --tag-id 12345 --publish false
# fb-post.sh "With photo" --image photo.jpg --publish true
# fb-post.sh "Album" --image a.jpg --image b.jpg --image c.jpg
# =============================================================
set -uo pipefail
# ---- Exit codes ----
EXIT_OK=0
EXIT_ARGS=1
EXIT_INSTANCE=2
EXIT_ELEMENT=3
EXIT_PUBLISH=4
EXIT_UPLOAD=5
EXIT_DEPS=6
# ---- Structured logging (architecture C) ----
log() {
local level="$1"; shift
printf "[%s] [%-5s] %s\n" "$(date '+%H:%M:%S')" "$level" "$*" >&2
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
# ---- Dependency helpers ----
require_cmd() {
local cmd="$1" hint="${2:-}"
if ! command -v "$cmd" &>/dev/null; then
log_error "Required command not found: $cmd"
[[ -n "$hint" ]] && log_error " Install: $hint"
exit $EXIT_DEPS
fi
}
_TIMEOUT_CMD=""
_TIMEOUT_WARNED="false"
_timeout() {
local duration="$1"; shift
if [[ -z "$_TIMEOUT_CMD" ]]; then
if command -v timeout &>/dev/null; then
_TIMEOUT_CMD="timeout"
elif command -v gtimeout &>/dev/null; then
_TIMEOUT_CMD="gtimeout"
else
_TIMEOUT_CMD="fallback"
if [[ "$_TIMEOUT_WARNED" == "false" ]]; then
log_warn "timeout/gtimeout not found, using pure-bash fallback (install: brew install coreutils)"
_TIMEOUT_WARNED="true"
fi
fi
fi
if [[ "$_TIMEOUT_CMD" == "fallback" ]]; then
"$@" &
local pid=$!
( sleep "$duration" && kill "$pid" 2>/dev/null ) &
local watchdog=$!
wait "$pid" 2>/dev/null
local ret=$?
kill "$watchdog" 2>/dev/null
wait "$watchdog" 2>/dev/null 2>&1
return $ret
else
"$_TIMEOUT_CMD" "$duration" "$@"
fi
}
# ---- Default config ----
PROFILE="default"
USER_ID="100003782705460"
GROUP=""
CONTENT=""
POST_TITLE=""
TAG_NAME=""
TAG_ID=""
IMAGES=()
PUBLISH="false"
MODE="headless"
DEBUG="false"
DRY_RUN="false"
KEEP_INSTANCE="false"
DEBUG_DIR="/tmp/fb-post-debug-$(date +%s)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# ---- Parse arguments (positional + named) ----
# First non-option argument is content (positional arg support)
if [[ $# -gt 0 && "${1:0:2}" != "--" ]]; then
CONTENT="$1"; shift
fi
while [[ $# -gt 0 ]]; do
case $1 in
--profile) PROFILE="$2"; shift 2 ;;
--user-id) USER_ID="$2"; shift 2 ;;
--group) GROUP="$2"; shift 2 ;;
--content) CONTENT="$2"; shift 2 ;;
--title) POST_TITLE="$2"; shift 2 ;;
--tag) TAG_NAME="$2"; shift 2 ;;
--tag-id) TAG_ID="$2"; shift 2 ;;
--image) IMAGES+=("$2"); shift 2 ;;
--publish) PUBLISH="$2"; shift 2 ;;
--mode) MODE="$2"; shift 2 ;;
--debug)
if [[ $# -ge 2 && "${2:0:2}" != "--" ]]; then
DEBUG="$2"; shift 2
else
DEBUG="true"; shift
fi ;;
--dry-run) DRY_RUN="true"; shift ;;
--keep-instance) KEEP_INSTANCE="true"; shift ;;
*) log_error "Unknown option: $1"; exit $EXIT_ARGS ;;
esac
done
if [[ -z "$CONTENT" ]]; then
log_error "Required: content (positional arg or --content <content>)"
echo "Usage: fb-post.sh <content> [options]"
echo " fb-post.sh --content <content> [options]"
exit $EXIT_ARGS
fi
# Validate and resolve image paths
if [[ ${#IMAGES[@]} -gt 0 ]]; then
for i in "${!IMAGES[@]}"; do
IMAGES[$i]=$(python3 -c "import os,sys; print(os.path.abspath(sys.argv[1]))" "${IMAGES[$i]}")
if [[ ! -f "${IMAGES[$i]}" ]]; then
log_error "Image file not found: ${IMAGES[$i]}"
exit $EXIT_ARGS
fi
done
fi
# Determine posting mode: group or wall
if [[ -n "$GROUP" ]]; then
POST_MODE="group"
if [[ "$GROUP" == http* ]]; then
GROUP_URL="$GROUP"
else
GROUP_URL="https://www.facebook.com/groups/$GROUP"
fi
else
POST_MODE="wall"
fi
# ---- PinchTab connection (needed by dry-run and main flow) ----
TOKEN=$(PINCHTAB_CONFIG="$HOME/.pinchtab/config.json" python3 -c "
import json, os
print(json.load(open(os.environ['PINCHTAB_CONFIG']))['server']['token'])
")
BASE="http://localhost:9867"
AUTH="Authorization: Bearer $TOKEN"
if [[ "$DRY_RUN" == "true" ]]; then
log_info "[DRY-RUN] Mode enabled — no browser actions will be performed"
log_info "[DRY-RUN] Post mode: $POST_MODE"
log_info "[DRY-RUN] Content: ${CONTENT:0:80}..."
[[ -n "$POST_TITLE" ]] && log_info "[DRY-RUN] Title: $POST_TITLE"
[[ ${#IMAGES[@]} -gt 0 ]] && log_info "[DRY-RUN] Images: ${IMAGES[*]}"
[[ -n "$TAG_NAME" ]] && log_info "[DRY-RUN] Tag: $TAG_NAME (ID: ${TAG_ID:-none})"
[[ -n "$GROUP" ]] && log_info "[DRY-RUN] Group: ${GROUP_URL:-$GROUP}"
log_info "[DRY-RUN] Publish: $PUBLISH"
# Validate profile exists in PinchTab (requires server running)
PROFILE_CHECK=$(curl -s "$BASE/profiles" -H "$AUTH" 2>/dev/null \
| SNAP_PROFILE="$PROFILE" python3 -c "
import sys, json, os
profile = os.environ['SNAP_PROFILE']
try:
profiles = json.load(sys.stdin)
# Support both list format and {data: [...]} format
if isinstance(profiles, dict):
profiles = profiles.get('data', [])
found = any(p.get('name') == profile for p in profiles)
print('found' if found else 'missing')
except: print('unknown')
" 2>/dev/null) || true
if [[ "$PROFILE_CHECK" == "missing" ]]; then
log_error "[DRY-RUN] Profile '$PROFILE' not found in PinchTab!"
exit $EXIT_INSTANCE
elif [[ "$PROFILE_CHECK" == "found" ]]; then
log_info "[DRY-RUN] Profile '$PROFILE' verified OK"
else
log_warn "[DRY-RUN] Cannot verify profile (server not running?)"
fi
exit $EXIT_OK
fi
# ---- Multi-language keywords for Facebook UI elements ----
KW_CREATE_POST_WALL="nghĩ gì|what's on your mind"
KW_CREATE_POST_GROUP="viết gì|write something"
KW_POST_TEXTBOX="bài viết|create a post|what's on your mind|nghĩ gì|viết gì"
KW_TAG_OTHERS="gắn thẻ người khác|gắn thẻ|tag people|tag others"
KW_SEARCH="tìm kiếm|search"
KW_DONE="xong|done"
KW_PUBLISH="đăng|post"
KW_FRIEND="bạn bè|friend"
if [[ "$POST_MODE" == "group" ]]; then
KW_CREATE_POST="$KW_CREATE_POST_GROUP"
else
KW_CREATE_POST="$KW_CREATE_POST_WALL"
fi
# ---- Helpers ----
# ---- Human-like random delay to avoid bot detection ----
# Usage: human_delay <min_ms> <max_ms>
# Sleeps for a random duration between min and max milliseconds
human_delay() {
local min_ms="${1:-500}" max_ms="${2:-1500}"
local range=$((max_ms - min_ms))
local delay_ms=$((min_ms + RANDOM % (range + 1)))
local delay_s
delay_s=$(python3 -c "print($delay_ms / 1000.0)")
sleep "$delay_s"
}
debug_screenshot() {
if [[ "$DEBUG" == "true" ]]; then
mkdir -p "$DEBUG_DIR"
local step_name="$1"
local output_path="$DEBUG_DIR/${step_name}.png"
if _timeout 10 pinchtab screenshot -o "$output_path" 2>/dev/null; then
if [[ -s "$output_path" ]]; then
log_info "[debug] Screenshot: $output_path"
else
log_warn "[debug] Screenshot empty: $output_path"
rm -f "$output_path"
fi
else
log_warn "[debug] Screenshot failed for step: $step_name"
fi
fi
}
# Element search using external Python helper (architecture A — no inline Python escaping issues)
# Uses stdin pipe instead of env var to avoid OS env size limits on large pages
snap_find_multi() {
local role="$1" keywords="$2"
pinchtab snap --compact=false 2>/dev/null \
| SNAP_ROLE="$role" SNAP_KW="$keywords" SNAP_MODE="multi" \
python3 "$SCRIPT_DIR/snap-helpers.py"
}
snap_find_exact() {
local role="$1" keywords="$2"
pinchtab snap --compact=false 2>/dev/null \
| SNAP_ROLE="$role" SNAP_KW="$keywords" SNAP_MODE="exact" \
python3 "$SCRIPT_DIR/snap-helpers.py"
}
safe_snap() {
_timeout 10 pinchtab snap --compact=false 2>/dev/null || echo '{"nodes":[]}'
}
snap_find_button_multi() { snap_find_multi "button" "$1"; }
snap_find_textbox_multi() { snap_find_multi "textbox" "$1"; }
# Poll-based wait (fix #6: || true to prevent set -e killing script)
wait_for_element_multi() {
local role="$1" keywords="$2" timeout="${3:-10}" elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local ref
ref=$(snap_find_multi "$role" "$keywords") || true
if [[ -n "$ref" ]]; then
echo "$ref"
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
return 1
}
wait_for_element_exact() {
local role="$1" keywords="$2" timeout="${3:-10}" elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local ref
ref=$(snap_find_exact "$role" "$keywords") || true
if [[ -n "$ref" ]]; then
echo "$ref"
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
return 1
}
instance_health_check() {
local inst_id="$1"
local status
status=$(curl -s --max-time 5 "$BASE/instances/$inst_id" -H "$AUTH" 2>/dev/null \
| python3 -c "
import sys, json
try:
resp = json.load(sys.stdin)
d = resp.get('data', resp) if isinstance(resp, dict) else resp
print(d.get('status', ''))
except: pass
" 2>/dev/null) || true
[[ "$status" != "running" ]] && return 1
local snap_result
snap_result=$(PINCHTAB_INSTANCE="$inst_id" _timeout 5 pinchtab snap --compact=false 2>/dev/null \
| python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
nodes = data.get('nodes', [])
print('ok' if len(nodes) > 0 else 'empty')
except: print('fail')
" 2>/dev/null) || true
[[ "$snap_result" == "ok" ]]
}
safe_click() {
local ref="$1"
if ! pinchtab click "$ref" 2>&1 | grep -qi "not focusable"; then
return 0
fi
log_warn "Element not focusable, using keyboard fallback"
pinchtab press ArrowDown >/dev/null 2>&1
human_delay 300 700
pinchtab press Enter >/dev/null 2>&1
}
check_evaluate_permission() {
curl -s "$BASE/api/config" -H "$AUTH" 2>/dev/null \
| python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if isinstance(data, dict):
d = data.get('data', data)
val = d.get('security.allowEvaluate')
if val is None:
val = (d.get('config', {}).get('security', {}).get('allowEvaluate')
or d.get('security', {}).get('allowEvaluate'))
print('true' if val else 'false')
else: print('unknown')
except: print('unknown')
" 2>/dev/null
}
validate_image_file() {
local file="$1"
local header
header=$(xxd -p -l 4 "$file" 2>/dev/null) || return 1
case "$header" in
ffd8ff*) return 0 ;; # JPEG
89504e47) return 0 ;; # PNG
47494638) return 0 ;; # GIF
52494646) return 0 ;; # WebP (RIFF container)
*) return 1 ;;
esac
}
verify_image_attached() {
local timeout="${1:-5}" elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local found
found=$(safe_snap \
| python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
nodes = data.get('nodes', []) if isinstance(data, dict) else []
for n in nodes:
name = (n.get('name','') + ' ' + n.get('description','')).lower()
role = n.get('role', '')
value = n.get('value', '').lower()
# Signal 1: blob: URL in name or value (actual image preview)
if 'blob:' in name or 'blob:' in value:
print('attached'); break
# Signal 2: removal button (only exists when image is attached)
if role == 'button' and any(k in name for k in ['gỡ file','remove file','gỡ ảnh','remove photo']):
print('attached'); break
# Signal 3: img element with blob or scontent URL (uploaded image thumbnail)
if role == 'img' and ('blob:' in value or 'scontent' in value):
print('attached'); break
# Signal 4: edit-photo button (only appears after image attached)
if role == 'button':
if ('chỉnh sửa' in name and ('ảnh' in name or 'tất cả' in name)):
print('attached'); break
if ('edit' in name and ('photo' in name or 'all' in name)):
print('attached'); break
except: pass
" 2>/dev/null) || true
if [[ "$found" == "attached" ]]; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
return 1
}
# Click with retry (fix #7: verify element appeared, retry up to max_retries)
click_and_verify() {
local click_ref="$1" verify_role="$2" verify_kw="$3"
local max_retries="${4:-3}" verify_timeout="${5:-10}"
local attempt=1
while [[ $attempt -le $max_retries ]]; do
pinchtab click "$click_ref" >/dev/null 2>&1
local result
result=$(wait_for_element_multi "$verify_role" "$verify_kw" "$verify_timeout") || true
if [[ -n "$result" ]]; then
echo "$result"
return 0
fi
log_warn "Click attempt $attempt/$max_retries: expected element not found, retrying..."
human_delay 800 1500
attempt=$((attempt + 1))
done
return 1
}
pinchtab_cmd() {
_timeout 15 pinchtab "$@"
}
nav_new_tab() {
local url="$1"
local tab_id ret=0
local t_start t_end elapsed
t_start=$(python3 -c "import time; print(int(time.time()))")
tab_id=$(_timeout 30 pinchtab nav "$url" --new-tab --print-tab-id 2>/dev/null) || ret=$?
if [[ -n "$tab_id" && "$tab_id" != "" ]]; then
export PINCHTAB_TAB="$tab_id"
log_info "Tab: $tab_id"
return 0
fi
t_end=$(python3 -c "import time; print(int(time.time()))")
elapsed=$((t_end - t_start))
if [[ $ret -eq 127 ]]; then
log_error "Command not found in pipeline (exit 127). Check pinchtab is installed."
exit $EXIT_DEPS
elif [[ $elapsed -lt 3 ]]; then
log_error "Navigation failed immediately (${elapsed}s, expected up to 30s): $url"
log_error "Likely cause: pinchtab binary issue, server not running, or network error"
else
log_error "Navigation timed out (${elapsed}s): $url"
fi
debug_screenshot "nav-fail"
exit $EXIT_ELEMENT
}
resize_image_if_needed() {
local file_path="$1"
local max_dim="${2:-1920}"
local max_bytes="${3:-3145728}" # 3MB
local file_size
file_size=$(stat -f%z "$file_path" 2>/dev/null || stat -c%s "$file_path" 2>/dev/null || echo 0)
if [[ "$file_size" -le "$max_bytes" ]]; then
printf '%s' "$file_path"
return 0
fi
local ext
ext=$(printf '%s' "${file_path##*.}" | tr '[:upper:]' '[:lower:]')
if [[ "$ext" == "gif" ]]; then
log_warn "GIF $(basename "$file_path") is ${file_size} bytes, skipping resize"
printf '%s' "$file_path"
return 0
fi
local resized="/tmp/fb_resized_$(date +%s)_$(basename "$file_path")"
cp "$file_path" "$resized"
if sips -Z "$max_dim" "$resized" >/dev/null 2>&1; then
local new_size
new_size=$(stat -f%z "$resized" 2>/dev/null || stat -c%s "$resized" 2>/dev/null || echo 0)
log_info "Resized $(basename "$file_path"): ${file_size} -> ${new_size} bytes (max ${max_dim}px)"
printf '%s' "$resized"
else
log_warn "sips resize failed, using original"
rm -f "$resized"
printf '%s' "$file_path"
fi
}
upload_image_via_eval() {
local file_path="$1"
local mime_type="image/jpeg"
local ext
ext=$(printf '%s' "${file_path##*.}" | tr '[:upper:]' '[:lower:]')
case "$ext" in
png) mime_type="image/png" ;;
gif) mime_type="image/gif" ;;
webp) mime_type="image/webp" ;;
esac
IMG_PATH="$file_path" IMG_MIME="$mime_type" \
API_BASE="$BASE" API_TOKEN="$TOKEN" python3 << 'PYEOF'
import os, json, urllib.request, base64, sys
file_path = os.environ['IMG_PATH']
mime = os.environ['IMG_MIME']
name = os.path.basename(file_path)
with open(file_path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode()
js = """(function() {
var b = atob(%s);
var a = new Uint8Array(b.length);
for (var i = 0; i < b.length; i++) a[i] = b.charCodeAt(i);
var blob = new Blob([a], {type: %s});
var file = new File([blob], %s, {type: %s});
var dt = new DataTransfer();
dt.items.add(file);
var evt = new ClipboardEvent("paste", {clipboardData: dt, bubbles: true, cancelable: true});
document.activeElement.dispatchEvent(evt);
return "dispatched";
})()""" % (json.dumps(b64), json.dumps(mime), json.dumps(name), json.dumps(mime))
payload = json.dumps({'expression': js}).encode()
req = urllib.request.Request(
os.environ['API_BASE'] + '/evaluate',
data=payload,
headers={
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + os.environ['API_TOKEN']
}
)
try:
resp = urllib.request.urlopen(req, timeout=60).read().decode()
result = json.loads(resp)
if 'dispatched' in str(result.get('result', '')):
sys.exit(0)
else:
print('Unexpected eval result: ' + str(result), file=sys.stderr)
sys.exit(1)
except Exception as e:
print('Eval request failed: ' + str(e), file=sys.stderr)
sys.exit(1)
PYEOF
}
detect_user_profile() {
nav_new_tab "https://www.facebook.com/me"
sleep 3
local current_url
current_url=$(pinchtab snap --compact=false 2>/dev/null | python3 -c "
import sys, json
data = json.load(sys.stdin)
print(data.get('url',''))
" 2>/dev/null) || true
# Try extracting numeric ID from URL (profile.php?id=...) or page JS
if [[ "$current_url" == *"profile.php?id="* ]]; then
echo "$current_url"
return
fi
# Vanity URL: extract numeric ID via JS evaluation
local numeric_id
numeric_id=$(EVAL_BASE="$BASE" EVAL_TOKEN="$TOKEN" python3 -c "
import os, json, urllib.request, re
js = 'document.querySelector(\"meta[property=\\\\\"al:android:url\\\\\"]\")?.content || \"\"'
payload = json.dumps({'expression': js}).encode()
req = urllib.request.Request(os.environ['EVAL_BASE'] + '/evaluate', data=payload,
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + os.environ['EVAL_TOKEN']})
resp = urllib.request.urlopen(req).read().decode()
m = re.search(r'fb://profile/(\d+)', json.loads(resp).get('result',''))
print(m.group(1) if m else '')
" 2>/dev/null) || true
if [[ -n "$numeric_id" ]]; then
echo "https://www.facebook.com/profile.php?id=$numeric_id"
else
echo "$current_url"
fi
}
# ---- Instance lifecycle (fix #9: --keep-instance support) ----
CREATED_INSTANCE="false"
cleanup() {
local exit_code=$?
if [[ "$KEEP_INSTANCE" == "true" ]]; then
log_info "Keeping instance alive (--keep-instance)"
return
fi
if [[ -n "${INST:-}" ]]; then
if [[ "$MODE" == "headed" || "$CREATED_INSTANCE" == "true" ]]; then
echo "" >&2
log_info "Stopping instance $INST..."
pinchtab instance stop "$INST" 2>/dev/null || true
if [[ $exit_code -ne 0 ]]; then
local chrome_pids
chrome_pids=$(pgrep -f "chrome.*${PROFILE}" 2>/dev/null) || true
if [[ -n "$chrome_pids" ]]; then
log_warn "Error exit ($exit_code): killing Chrome for profile '$PROFILE' to prevent leftover state"
echo "$chrome_pids" | xargs kill 2>/dev/null || true
fi
fi
log_info "Instance stopped."
fi
fi
}
trap cleanup EXIT
# Fix #4: validate profile exists before parsing JSON
start_new_instance() {
local raw_output
raw_output=$(pinchtab instance start --profile "$PROFILE" --mode "$MODE" 2>&1) || true
INST=$(echo "$raw_output" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
print(data.get('id', ''))
except (json.JSONDecodeError, KeyError):
print('')
" 2>/dev/null) || true
if [[ -z "$INST" ]]; then
log_error "Cannot start instance with profile '$PROFILE'. Check profile exists."
log_error "Raw output: $raw_output"
exit $EXIT_INSTANCE
fi
CREATED_INSTANCE="true"
log_info "Created instance: $INST"
sleep 3
}
# =============================================================
# DEPENDENCY CHECK: validate required binaries before any work
# =============================================================
require_cmd pinchtab "npm install -g @nicepkg/pinchtab"
require_cmd python3
require_cmd curl
if [[ ${#IMAGES[@]} -gt 0 ]]; then
require_cmd xxd "part of vim package (brew install vim)"
fi
# =============================================================
# FAIL-FAST: validate inputs before touching browser
# =============================================================
if [[ ${#IMAGES[@]} -gt 0 ]]; then
for img in "${IMAGES[@]}"; do
if ! validate_image_file "$img"; then
log_error "Invalid image (not JPEG/PNG/GIF/WebP): $img"
log_error "File may be corrupted or wrong format (e.g. HTML saved as .jpg)"
exit $EXIT_ARGS
fi
done
log_info "All ${#IMAGES[@]} image(s) validated (magic bytes OK)"
fi
EVAL_OK=$(check_evaluate_permission) || true
if [[ "$EVAL_OK" == "false" ]]; then
if [[ ${#IMAGES[@]} -gt 0 ]]; then
log_error "security.allowEvaluate is required for image upload. Enable with:"
log_error " pinchtab config set security.allowEvaluate true"
exit $EXIT_UPLOAD
else
log_warn "security.allowEvaluate is disabled. Text input will use CLI fallback."
log_warn "For best results: pinchtab config set security.allowEvaluate true"
fi
fi
# =============================================================
# STEP 1: Start browser instance with health check
# =============================================================
log_info "[1/7] Starting browser with profile: $PROFILE"
# Fix #5: use env vars for Python inline
EXISTING=$(curl -s "$BASE/instances" -H "$AUTH" 2>/dev/null \
| SNAP_PROFILE="$PROFILE" python3 -c "
import sys, json, os
profile = os.environ['SNAP_PROFILE']
try:
for i in json.load(sys.stdin):
if i.get('profileName') == profile and i.get('status') == 'running':
print(i['id']); break
except (json.JSONDecodeError, KeyError, TypeError): pass
" 2>/dev/null) || true
if [[ -n "$EXISTING" ]]; then
if instance_health_check "$EXISTING"; then
INST="$EXISTING"
log_info "Reusing healthy instance: $INST"
else
log_warn "Instance $EXISTING is stale, restarting..."
pinchtab instance stop "$EXISTING" 2>/dev/null || true
sleep 1
start_new_instance
fi
else
start_new_instance
fi
export PINCHTAB_INSTANCE="$INST"
log_info "PINCHTAB_INSTANCE=$INST"
# =============================================================
# STEP 2: Navigate to target (wall or group)
# =============================================================
if [[ "$POST_MODE" == "group" ]]; then
log_info "[2/7] Opening group: $GROUP_URL"
nav_new_tab "$GROUP_URL"
else
log_info "[2/7] Opening profile page"
if [[ -n "$USER_ID" ]]; then
nav_new_tab "https://www.facebook.com/profile.php?id=$USER_ID"
else
log_info "No --user-id provided, detecting from logged-in session..."
PROFILE_URL=$(detect_user_profile)
if [[ -z "$PROFILE_URL" ]]; then
log_error "Cannot detect profile. Provide --user-id or ensure Facebook is logged in."
exit $EXIT_INSTANCE
fi
log_info "Detected profile: $PROFILE_URL"
fi
fi
# Wait for page to load (fix #8: poll-based instead of fixed sleep)
PAGE_TITLE=""
for i in $(seq 1 15); do
PAGE_TITLE=$(pinchtab snap --compact=false 2>/dev/null | python3 -c "
import sys, json
nodes = json.load(sys.stdin).get('nodes',[])
print(nodes[0].get('name','') if nodes else '')
" 2>/dev/null) || true
[[ -n "$PAGE_TITLE" ]] && break
sleep 1
done
log_info "Page: $PAGE_TITLE"
debug_screenshot "02-page-loaded"
# Validate group page: create-post button OR composer already open (leftover from previous run)
COMPOSER_OPEN=""
if [[ "$POST_MODE" == "group" ]]; then
GROUP_CHECK=$(snap_find_button_multi "$KW_CREATE_POST") || true
if [[ -z "$GROUP_CHECK" ]]; then
COMPOSER_OPEN=$(snap_find_textbox_multi "$KW_POST_TEXTBOX") || true
if [[ -z "$COMPOSER_OPEN" ]]; then
log_error "Cannot access group or not a member. Check group URL: $GROUP_URL"
debug_screenshot "02-error-invalid-group"
exit $EXIT_ELEMENT
fi
log_info "Composer already open (reused state from previous run)"
fi
fi
# =============================================================
# STEP 3: Open "Create post" dialog (fix #7: click with retry)
# =============================================================
log_info "[3/7] Opening create post dialog"
if [[ -n "$COMPOSER_OPEN" ]]; then
TXT_POST="$COMPOSER_OPEN"
log_info "Using existing composer textbox"
else
BTN_CREATE=$(wait_for_element_multi "button" "$KW_CREATE_POST" 10) || true
if [[ -z "$BTN_CREATE" ]]; then
log_error "Cannot find create post button in any supported language."
debug_screenshot "03-error-no-button"
exit $EXIT_ELEMENT
fi
# Click and verify textbox appeared (fix #7 + #8: retry + smart wait)
TXT_POST=$(click_and_verify "$BTN_CREATE" "textbox" "$KW_POST_TEXTBOX" 3 10) || true
if [[ -z "$TXT_POST" ]]; then
log_error "Cannot find post textbox after clicking create post button."
debug_screenshot "03-error-no-textbox"
exit $EXIT_ELEMENT
fi
fi
debug_screenshot "03-post-dialog"
# =============================================================
# STEP 3.5: Fill title with H1 formatting (group only, optional)
# Lexical editor converts "# " prefix to H1 automatically
# =============================================================
if [[ -n "$POST_TITLE" && "$POST_MODE" == "group" ]]; then
log_info "[3.5/7] Filling group post title (H1 via markdown)"
pinchtab_cmd focus "$TXT_POST" >/dev/null 2>&1
human_delay 400 900
pinchtab_cmd type "$TXT_POST" "# " >/dev/null 2>&1
human_delay 300 600
if ! pinchtab_cmd keyboard inserttext "$POST_TITLE" 2>/dev/null; then
pinchtab_cmd type "$TXT_POST" "$POST_TITLE" >/dev/null 2>&1 || true
fi
human_delay 500 1000
pinchtab_cmd type "$TXT_POST" $'\n' >/dev/null 2>&1
human_delay 300 600
log_info "Title entered: ${POST_TITLE:0:60}"
debug_screenshot "03.5-title-entered"
elif [[ -n "$POST_TITLE" && "$POST_MODE" != "group" ]]; then
log_warn "Title is only supported for group posts, ignoring --title"
fi
# =============================================================
# STEP 4: Type post content
# =============================================================
log_info "[4/7] Typing post content"
pinchtab_cmd focus "$TXT_POST" >/dev/null 2>&1
human_delay 400 900
if ! _timeout 30 pinchtab keyboard inserttext "$CONTENT" 2>/dev/null; then
log_warn "keyboard inserttext failed or timed out, falling back to CLI type"
pinchtab_cmd type "$TXT_POST" "$CONTENT" >/dev/null 2>&1 || true
fi
human_delay 800 2000
log_info "Content entered (${#CONTENT} chars)"
debug_screenshot "04-content-entered"
# =============================================================
# STEP 5: Attach images (optional)
# =============================================================
if [[ ${#IMAGES[@]} -gt 0 ]]; then
log_info "[5/7] Attaching ${#IMAGES[@]} image(s) via eval ClipboardEvent"
# Focus composer textbox before pasting
pinchtab_cmd focus "$TXT_POST" >/dev/null 2>&1
human_delay 500 1000
UPLOAD_COUNT=0
for img in "${IMAGES[@]}"; do
log_info "Uploading: $(basename "$img")"
local actual_img
actual_img=$(resize_image_if_needed "$img")
if ! upload_image_via_eval "$actual_img"; then
log_error "Eval paste failed for: $img"
debug_screenshot "05-error-eval-$UPLOAD_COUNT"
[[ "$actual_img" != "$img" ]] && rm -f "$actual_img"
exit $EXIT_UPLOAD
fi
[[ "$actual_img" != "$img" ]] && rm -f "$actual_img"
UPLOAD_COUNT=$((UPLOAD_COUNT + 1))
human_delay 2000 4000
log_info "Attached ($UPLOAD_COUNT/${#IMAGES[@]}): $(basename "$img")"
debug_screenshot "05-attached-$UPLOAD_COUNT"
done
log_info "Verifying image attachment..."
if ! verify_image_attached 10; then
log_warn "First verification pass failed, retrying with longer timeout..."
debug_screenshot "05-verify-retry"
if ! verify_image_attached 15; then
log_warn "Images dispatched OK but verification could not confirm attachment (known false-negative). Proceeding anyway."
debug_screenshot "05-verify-unconfirmed"
fi
fi
debug_screenshot "05-images-verified"
log_info "All ${#IMAGES[@]} image(s) attached and verified"
else
log_info "[5/7] No images (skipped)"
fi
# =============================================================
# STEP 6: Tag user (optional)
# =============================================================
if [[ -n "$TAG_NAME" ]]; then
log_info "[6/7] Tagging: $TAG_NAME"
BTN_TAG=$(wait_for_element_multi "button" "$KW_TAG_OTHERS" 10) || true
if [[ -z "$BTN_TAG" ]]; then
log_warn "Tag button not found after 10s, skipping."
else
# Click tag button and verify search textbox appeared (retry up to 3 times)
TXT_SEARCH=$(click_and_verify "$BTN_TAG" "textbox" "$KW_SEARCH" 3 8) || true
if [[ -n "$TXT_SEARCH" ]]; then
pinchtab click "$TXT_SEARCH" >/dev/null 2>&1
human_delay 300 800
pinchtab type "$TXT_SEARCH" "$TAG_NAME" >/dev/null 2>&1
# Wait for search results to populate (Facebook AJAX)
human_delay 1500 3000
debug_screenshot "06-tag-search-results"
# Fix #5 + #11: use external Python helper, pipe snap data via stdin
TAG_REF=$(pinchtab snap --compact=false 2>/dev/null \
| TAG_NAME="$TAG_NAME" TAG_ID="$TAG_ID" FRIEND_KW="$KW_FRIEND" \
python3 "$SCRIPT_DIR/tag-search.py") || true
if [[ -n "$TAG_REF" ]]; then
safe_click "$TAG_REF"
else
if [[ -n "$TAG_ID" ]]; then
log_warn "Tag ID $TAG_ID not found in results, selecting first result"
fi
pinchtab press ArrowDown >/dev/null 2>&1
human_delay 200 600
pinchtab press Enter >/dev/null 2>&1
fi
human_delay 1500 2500
BTN_DONE=$(snap_find_button_multi "$KW_DONE") || true
if [[ -n "$BTN_DONE" ]]; then
pinchtab click "$BTN_DONE" >/dev/null 2>&1
human_delay 800 1500
log_info "Tagged successfully"
fi
fi
fi
else
log_info "[6/7] No tagging (skipped)"
fi
# =============================================================
# STEP 6.5: Wait for link thumbnail preview (if content has URL)
# =============================================================
if [[ "$CONTENT" =~ (https?://[^[:space:]]+) ]]; then
DETECTED_URL="${BASH_REMATCH[1]}"
HTTP_STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 -L "$DETECTED_URL" 2>/dev/null) || true
if [[ -n "$HTTP_STATUS" && "$HTTP_STATUS" -ge 400 ]] 2>/dev/null; then
log_warn "Link returns HTTP $HTTP_STATUS: $DETECTED_URL (preview may show error)"
fi
log_info "Link detected, waiting 5s for thumbnail preview..."
sleep 5
debug_screenshot "06.5-thumbnail-wait"
fi
# =============================================================
# STEP 7: Publish or hold
# =============================================================
if [[ "$PUBLISH" == "true" ]]; then
log_info "[7/7] Publishing..."
BTN_POST=$(wait_for_element_exact "button" "$KW_PUBLISH" 5) || true
if [[ -n "$BTN_POST" ]]; then
pinchtab click "$BTN_POST" >/dev/null 2>&1
human_delay 2000 4000
log_info "Post published!"
debug_screenshot "07-published"
else
log_error "Publish button not found"
debug_screenshot "07-error-no-publish"
exit $EXIT_PUBLISH
fi
else
log_info "[7/7] Post ready, NOT PUBLISHED."
log_info "Open browser and click 'Post' when ready."
debug_screenshot "07-ready"
fi
if [[ "$DEBUG" == "true" ]]; then
echo ""
log_info "Debug screenshots saved to: $DEBUG_DIR"
fi
echo ""
log_info "Done!"
exit $EXIT_OK
#!/usr/bin/env python3
"""
PinchTab snap helpers — find UI elements by role & keywords.
All inputs via environment variables to avoid shell string escaping issues.
Environment variables:
SNAP_JSON - JSON string from `pinchtab snap` (stdin if empty)
SNAP_ROLE - element role to search for (e.g. "button", "textbox")
SNAP_KW - pipe-separated keywords (e.g. "nghĩ gì|what's on your mind")
SNAP_MODE - "multi" (substring match, default) or "exact" (exact match)
SNAP_FIELD - "ref" (default) or "name" — what to print on match
"""
import json
import os
import sys
import unicodedata
def norm(s: str) -> str:
return unicodedata.normalize("NFD", s.lower()).strip()
def main():
raw = os.environ.get("SNAP_JSON", "")
if not raw:
raw = sys.stdin.read()
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
sys.exit(1)
nodes = data.get("nodes", [])
role = os.environ.get("SNAP_ROLE", "")
keywords = os.environ.get("SNAP_KW", "").split("|")
mode = os.environ.get("SNAP_MODE", "multi")
field = os.environ.get("SNAP_FIELD", "ref")
for kw in keywords:
kw_n = norm(kw.strip())
if not kw_n:
continue
for n in nodes:
if role and n.get("role") != role:
continue
name_n = norm(n.get("name", ""))
if mode == "exact":
if name_n == kw_n:
print(n.get(field, n.get("ref", "")))
return
else:
if kw_n in name_n:
print(n.get(field, n.get("ref", "")))
return
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Search tag results from PinchTab snap — prioritize friends over non-friends.
All inputs via environment variables to avoid shell string escaping.
Environment variables:
SNAP_JSON - JSON from `pinchtab snap` (stdin if empty)
TAG_NAME - friend name to search for
TAG_ID - (optional) Facebook ID for precise matching
FRIEND_KW - pipe-separated friend keywords (e.g. "bạn bè|friend")
"""
import json
import os
import sys
import unicodedata
def norm(s: str) -> str:
return unicodedata.normalize("NFD", s.lower()).strip()
def main():
raw = os.environ.get("SNAP_JSON", "")
if not raw:
raw = sys.stdin.read()
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
sys.exit(1)
nodes = data.get("nodes", [])
tag_id = os.environ.get("TAG_ID", "")
tag_name = norm(os.environ.get("TAG_NAME", ""))
friend_keywords = os.environ.get("FRIEND_KW", "bạn bè|friend").split("|")
# Mode 1: match by Facebook ID — search ALL string fields of each node
# Facebook accessibility tree may embed IDs in ref, url, name, description, or other fields
if tag_id:
for n in nodes:
for val in n.values():
if isinstance(val, str) and tag_id in val:
print(n.get("ref", ""))
return
# ID not found — fall through to name-based matching instead of giving up
if not tag_name:
return
# Mode 2: match by name, prioritize friends
friend_ref = None
first_ref = None
for n in nodes:
name = n.get("name", "")
desc = n.get("description", "")
if tag_name in norm(name) or tag_name in norm(desc):
if first_ref is None:
first_ref = n.get("ref", "")
desc_l = desc.lower()
if any(fk.strip() in desc_l for fk in friend_keywords):
friend_ref = n.get("ref", "")
break
print(friend_ref or first_ref or "")
if __name__ == "__main__":
main()
Related skills
FAQ
Is Facebook safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.