前置作業 首先,前往 Meta 開發者平台 ,建立一個應用程式。例如:
應用程式名稱:post-bot
新增使用案例:「存取 Threads API」
商家:「我還不想連結商家資產管理組合」
完成後,點選「建立應用程式」按鈕。
新增測試人員 點選「應用程式角色」頁籤,點選「新增用戶」,點選「Threads 測試人員」,輸入自己的 Threads 帳號用戶名稱,最後點選「新增」。
設定 Threads 存取權限 進到 Threads 平台,點選「設定」,點選「帳號」,點選「網站權限」,點選「邀請」,接受來自應用程式 post-bot
的存取請求。
測試 回到 Meta 開發者平台,點選「測試」頁籤,點選「開啟 GraphQL API 測試工具」按鈕。
將 Meta 應用程式指定為 post-bot
,點選「Generate Threads Access Token」按鈕,即可取得一個暫時性的存取令牌。
點選提交,響應如下:
1 2 3 4 { "id" : "29361808173406421" , "name" : "Memo Chou" }
建立專案 建立專案。
1 2 mkdir threads-api-oauth-examplecd threads-api-oauth-example
建立虛擬環境 建立虛擬環境。
啟動虛擬環境。
1 source .venv/bin/activate
初始化版本控制 建立 .gitignore
檔。
1 2 3 .venv/ __pycache__/ .env
初始化版本控制。
將所有修改添加到暫存區。
提交修改。
1 git commit -m "Initial commit"
指定遠端儲存庫位址。
推送程式碼到遠端儲存庫。
安裝 Ruff 格式化工具 新增 requirements.txt
檔。
修改 requirements.txt
檔,添加 ruff
依賴套件。
安裝依賴套件。
1 pip install -r requirements.txt
新增 ruff.toml
檔。
1 2 3 4 5 line-length = 120 indent-width = 4 [format] quote-style = "double"
新增 .vscode/settings.json
檔。
1 2 3 4 5 6 7 8 { "editor.formatOnSave" : true , "editor.codeActionsOnSave" : { "source.fixAll" : "explicit" , "source.organizeImports" : "explicit" } , "editor.defaultFormatter" : "charliermarsh.ruff" }
提交修改。
1 2 git add . git commit -m "Add ruff dependency"
實作 修改 requirements.txt
檔,添加 fastapi[standard]
和 requests
依賴套件。
1 2 3 4 ruff fastapi[standard] requests dotenv
安裝依賴套件。
1 pip install -r requirements.txt
新增 .env.example
檔。
1 2 3 4 THREADS_API_URL=https://graph.threads.net THREADS_CLIENT_SECRET= THREADS_APP_ID= THREADS_APP_SECRET=
新增 .env
檔。
1 2 3 4 THREADS_API_URL=https://graph.threads.net THREADS_CLIENT_SECRET=your-threads-client-secret THREADS_APP_ID=your-threads-app-id THREADS_APP_SECRET=your-threads-app-secret
新增 main.py
檔。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 import osimport requestsfrom dotenv import load_dotenvfrom fastapi import FastAPI, HTTPExceptionfrom fastapi.responses import HTMLResponse, JSONResponsefrom fastapi.staticfiles import StaticFilesfrom pydantic import BaseModelload_dotenv(override=True ) THREADS_CLIENT_SECRET = os.getenv("THREADS_CLIENT_SECRET" ) THREADS_APP_ID = os.getenv("THREADS_APP_ID" ) THREADS_APP_SECRET = os.getenv("THREADS_APP_SECRET" ) THREADS_API_URL = os.getenv("THREADS_API_URL" ) app = FastAPI() app.mount("/static" , StaticFiles(directory="static" ), name="static" ) @app.get("/auth" , response_class=HTMLResponse ) def read_index (): with open (os.path.join("static" , "index.html" )) as f: return HTMLResponse(content=f.read()) @app.get("/auth/callback" , response_class=HTMLResponse ) def read_callback (): with open (os.path.join("static" , "index.html" )) as f: return HTMLResponse(content=f.read()) @app.get("/" ) def read_root (): return {"Hello" : "World" } class TokenRequest (BaseModel ): code: str redirect_uri: str @app.post("/access-token" ) def get_token (request: TokenRequest ): payload = { "client_id" : THREADS_APP_ID, "client_secret" : THREADS_APP_SECRET, "redirect_uri" : request.redirect_uri, "code" : request.code, "grant_type" : "authorization_code" , } try : response = requests.post(f"{THREADS_API_URL} /oauth/access_token" , data=payload) response.raise_for_status() return JSONResponse(content=response.json()) except requests.exceptions.RequestException as e: raise HTTPException(status_code=400 , detail=str (e)) @app.get("/long-lived-access-token" ) def get_long_lived_token (access_token: str ): params = { "grant_type" : "th_exchange_token" , "client_secret" : THREADS_CLIENT_SECRET, "access_token" : access_token, } try : response = requests.get(f"{THREADS_API_URL} /access_token" , params=params) response.raise_for_status() return JSONResponse(content=response.json()) except requests.exceptions.RequestException as e: raise HTTPException(status_code=400 , detail=str (e))
新增 static/index.html
檔。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Threads OAuth</title > <link rel ="stylesheet" href ="/static/app.css" > </head > <body > <h1 > Threads OAuth</h1 > <button onclick ="getAccessToken()" > Get Access Token</button > <button onclick ="getLongLivedAccessToken()" > Get Long-Lived Access Token</button > <div > Message: <br > <div id ="message" > </div > Access Token: <br > <div id ="access-token" > </div > Long-Lived Access Token: <br > <div id ="long-lived-access-token" > </div > </div > <script src ="/static/app.js" > </script > </body > </html >
新增 app.css
檔。
1 2 3 4 div { margin : 20px 0 ; word-break : break-all; }
新增 app.js
檔。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 const THREADS_APP_ID = '9030817430362187' ;const THREADS_AUTH_URL = 'https://threads.net/oauth/authorize' ;const messageElement = document .getElementById ('message' );const accessTokenElement = document .getElementById ('access-token' );const longLivedAccessTokenElement = document .getElementById ('long-lived-access-token' );const getAccessToken = ( ) => { const url = new URL (THREADS_AUTH_URL ); url.search = new URLSearchParams ({ client_id : THREADS_APP_ID , redirect_uri : `${window .location.origin} /auth/callback` , scope : 'threads_basic,threads_content_publish' , response_type : 'code' , }).toString (); window .location .href = url.toString (); }; const getLongLivedAccessToken = async ( ) => { messageElement.innerText = 'Retrieving long-lived access token...' ; try { const response = await fetch (`/long-lived-access-token?access_token=${accessTokenElement.innerText} ` ); const data = await response.json (); if (data.access_token ) { longLivedAccessTokenElement.innerText = data.access_token ; } messageElement.innerText = data.detail || 'Retrieved long-lived access token successfully!' ; } catch (error) { messageElement.innerText = error.message ; } }; window .onload = async () => { const urlParams = new URLSearchParams (window .location .search ); const code = urlParams.get ('code' ); if (code) { messageElement.innerText = 'Retrieving access token...' ; try { const response = await fetch ('/access-token' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' , }, body : JSON .stringify ({ code, redirect_uri : `${window .location.origin} /auth/callback` , }), }); const data = await response.json (); if (data.access_token ) { accessTokenElement.innerText = data.access_token ; } messageElement.innerText = data.detail || 'Retrieved access token successfully!' ; } catch (error) { messageElement.innerText = error.message ; } } };
啟動伺服器。
使用 ngrok
指令,啟動一個 HTTP 代理伺服器,將本地埠映射到外部網址。
設定回呼網址 進到 Meta 開發者平台,進到「自訂使用案例」頁面,點選「設定」頁籤,完成以下設定:
測試前端 前往 https://random.ngrok-free.app/auth 瀏覽,並點選「Login with Threads」按鈕,完成登入。
程式碼
參考資料