受託の単価交渉で「実績ありますか?」と聞かれて、URLを5本コピペして送って終わり——これをやめます。
この記事を読み終えると、ZennとQiitaの公開APIからあなたの全記事の反応数を取得し、トピック別に集計した「信頼残高シート(JSON + Markdown)」を生成するPythonスクリプトと、それをGitHub Actionsで毎朝自動更新する仕組みが動きます。商談前に最新の数字付きポートフォリオURLを1本渡せる状態を作るのが目的です。
ZennとQiitaの「発信」が受託単価に効く理由と、信頼残高という考え方
結論から断定します。受託単価は「技術力」ではなく「説明コストの低さ」で決まります。発注側があなたを採用する稟議を通すとき、上司への説明材料が外部にすでに存在しているかどうかが効きます。Zenn/Qiitaの記事・いいね・ストックは、その説明材料を発注側の代わりに事前に積み上げた残高です。
ここで言う「信頼残高」は精神論ではなく、次の3つの実測値で構成される計算可能な指標として扱います。
-
被反応量: Qiitaの
likes_count/stocks_count、Zennのliked_count - 継続性: 投稿が途切れていないか(直近90日の投稿密度)
- テーマの一貫性: 受託で取りたい領域(例: Python/AWS/CI)にタグが寄っているか
バラバラのURL集ではなく、この3点を1枚のシートにまとめて渡すと、発注側の「この人を採用していいか」の判断コストが下がります。以下、これを自動生成します。
QiitaとZennの公開APIから自分の記事を取得するPythonスクリプト
まず両プラットフォームから自分の記事を引いてきます。Qiitaは認証なしでもユーザー記事一覧が叩けますが、レート制限が未認証で60リクエスト/時と低いので、QIITA_TOKEN(個人アクセストークン)を入れると1000リクエスト/時まで上がります。Zennは公式の安定APIを公開していないため、ここでは公開されている https://zenn.dev/api/articles?username=... を使い、壊れても全体が落ちないよう例外で握りつぶす設計にします。
# fetch_stats.py
import os
import time
import json
import requests
QIITA_USER = os.environ["QIITA_USER"]
ZENN_USER = os.environ["ZENN_USER"]
QIITA_TOKEN = os.environ.get("QIITA_TOKEN") # 任意。あると60→1000req/h
def fetch_qiita():
headers = {"Authorization": f"Bearer {QIITA_TOKEN}"} if QIITA_TOKEN else {}
items, page = [], 1
while True:
url = f"https://qiita.com/api/v2/users/{QIITA_USER}/items"
r = requests.get(url, headers=headers,
params={"page": page, "per_page": 100}, timeout=20)
r.raise_for_status()
batch = r.json()
if not batch:
break
for it in batch:
items.append({
"platform": "qiita",
"title": it["title"],
"url": it["url"],
"likes": it.get("likes_count", 0),
"stocks": it.get("stocks_count", 0),
"tags": [t["name"].lower() for t in it.get("tags", [])],
"created_at": it["created_at"],
})
page += 1
time.sleep(1) # レート制限へのマナー
return items
def fetch_zenn():
# Zennは非公式エンドポイント。失敗してもZennぶんだけ空で返す
try:
url = "https://zenn.dev/api/articles"
r = requests.get(url, params={"username": ZENN_USER, "order": "latest"},
timeout=20)
r.raise_for_status()
articles = r.json().get("articles", [])
except Exception as e:
print(f"[warn] zenn fetch failed: {e}")
return []
out = []
for a in articles:
out.append({
"platform": "zenn",
"title": a["title"],
"url": f"https://zenn.dev{a['path']}",
"likes": a.get("liked_count", 0),
"stocks": 0, # Zennにstock概念はないので0埋め
"tags": [], # 一覧APIにtopicsが無い場合があるため空
"created_at": a.get("published_at") or a.get("created_at"),
})
return out
if __name__ == "__main__":
all_items = fetch_qiita() + fetch_zenn()
with open("raw_items.json", "w", encoding="utf-8") as f:
json.dump(all_items, f, ensure_ascii=False, indent=2)
print(f"fetched {len(all_items)} items")
ここで実際にハマった失敗を1つ。最初 time.sleep を入れずに while True でQiitaを回したところ、記事が100本を超えるユーザーで未認証60req/h上限に即達して403が返り、raise_for_status() が例外を投げてZennの取得まで巻き添えで落ちました。Qiitaのページングは「空配列が返るまで」で止めますが、per_page=100 上限を忘れて per_page=200 を渡すと400 Bad Requestになります(Qiitaの上限は100)。この2点は仕様として固定値なので、最初から守るのが安全です。
Python集計で信頼残高スコアを出し、受託タグへの偏りを判定する
結論。集計の肝は「総いいね数」ではなく「取りたい領域への一貫性」です。受託で python/aws/github-actions を取りたいのに、いいねの大半が雑記記事についていたら、それは交渉材料になりません。そこで、ターゲットタグに当たった記事の反応だけを別カウントします。
# build_sheet.py
import json
from collections import Counter
from datetime import datetime, timezone, timedelta
TARGET_TAGS = {"python", "aws", "github-actions", "docker", "typescript"}
with open("raw_items.json", encoding="utf-8") as f:
items = json.load(f)
def parse(dt: str):
# 末尾Zや+09:00を許容してaware datetimeへ
return datetime.fromisoformat(dt.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
total_likes = sum(i["likes"] for i in items)
total_stocks = sum(i["stocks"] for i in items)
# ターゲット領域に当たった記事だけを抽出
on_target = [i for i in items if TARGET_TAGS & set(i["tags"])]
target_likes = sum(i["likes"] for i in on_target)
# 継続性: 直近90日の投稿本数
recent = [i for i in items if (now - parse(i["created_at"])) <= timedelta(days=90)]
# タグ頻度トップ5
tag_freq = Counter(t for i in items for t in i["tags"]).most_common(5)
# 一貫性スコア = ターゲット領域いいね / 全いいね(0除算回避)
consistency = round(target_likes / total_likes, 2) if total_likes else 0.0
summary = {
"articles": len(items),
"total_likes": total_likes,
"total_stocks": total_stocks,
"recent_90d_posts": len(recent),
"target_consistency": consistency,
"top_tags": tag_freq,
}
# 受託訴求用Markdownを生成
lines = ["# 発信実績サマリー(自動更新)\n"]
lines.append(f"- 総記事数: **{summary['articles']}**")
lines.append(f"- 総いいね: **{total_likes}** / 総ストック: **{total_stocks}**")
lines.append(f"- 直近90日の投稿: **{len(recent)}本**")
lines.append(f"- 受託対象領域への一貫性: **{consistency}**(1.0に近いほど専門特化)")
lines.append("\n## 反応の多い記事 Top5\n")
for i in sorted(items, key=lambda x: x["likes"], reverse=True)[:5]:
lines.append(f"- [{i['title']}]({i['url']}) — 👍{i['likes']} 🔖{i['stocks']}")
with open("PORTFOLIO.md", "w", encoding="utf-8") as f:
f.write("\n".join(lines))
with open("summary.json", "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
print(json.dumps(summary, ensure_ascii=False, indent=2))
ここでの設計上の注意は datetime の比較です。Qiitaの created_at は +09:00 付き、Zennの published_at も基本aware(タイムゾーン付き)ですが、datetime.now()(naive)と引き算すると TypeError: can't subtract offset-naive and offset-aware datetimes で落ちます。必ず now(timezone.utc) 側もawareに揃えるのがポイントで、上のコードはそこを踏まえています。target_consistency を0除算から守る三項も、いいね0本の初期状態で ZeroDivisionError を出さないための実用上の保険です。
GitHub Actionsでスケジュール実行し、PORTFOLIO.mdを自動コミットする
結論。このシートは手で更新しないこと。商談直前に「数字が古い」と気づくのが最悪なので、GitHub Actionsの schedule で毎朝回し、変化があればコミットまで自動化します。
# .github/workflows/portfolio.yml
name: update-portfolio
on:
schedule:
- cron: "0 22 * * *" # UTC22:00 = JST07:00
workflow_dispatch: {} # 手動実行ボタンも残す
permissions:
contents: write # コミットに必須。これが無いと403で落ちる
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install requests
- name: fetch & build
env:
QIITA_USER: ${{ vars.QIITA_USER }}
ZENN_USER: ${{ vars.ZENN_USER }}
QIITA_TOKEN: ${{ secrets.QIITA_TOKEN }}
run: |
python fetch_stats.py
python build_sheet.py
- name: commit if changed
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add PORTFOLIO.md summary.json
git diff --staged --quiet || git commit -m "chore: update portfolio $(date -u +%F)"
git push
ここも実体験の落とし穴。permissions: contents: write を書き忘れると、最後の git push が remote: Permission to ... denied / 403で落ちます。デフォルトの GITHUB_TOKEN は読み取り権限しか持たないことがあるためです。さらに git commit を素で書くと、差分が無い日に「nothing to commit」でステップ全体が失敗扱いになります。上の git diff --staged --quiet || git commit ... は「差分があるときだけコミット」のイディオムで、無風の日に赤くならないための定番です。
もう1つ。cron: "0 22 * * *" はUTC基準なのでJSTでは翌朝7時です。ここを 0 7 と書いて「なぜ夕方に動くんだ」と悩むのは通過儀礼なので、コメントで換算を残しておくと未来の自分が助かります。
このPORTFOLIO.mdを受託導線に変え、固定費削減やふるさと納税の文脈で回収する
生成された PORTFOLIO.md のRaw URL、またはGitHub Pagesで公開したページを、Zenn/Qiitaのプロフィールと営業DMの定型文に貼ります。「実績を口頭で説明する」から「最新数字付きの1URLを渡す」へ変わるだけで、発注側の確認コストが下がります。
そして発信を続ける個人開発者にとって、もう一段効くのが固定費の最適化です。受託の利益率は売上だけでなく支出で決まります。自宅開発環境の通信費(格安SIM×光回線のセット割)を見直す、あるいは事業の経費・寄附を新NISAやふるさと納税で整理しておくと、同じ売上でも手残りが変わります。発信で受託単価を上げる導線を作ったら、入口(単価)と同時に出口(固定費)も締めるのが個人開発者の現実解です。
固定費・寄附まわりの具体的な比較は、本ブログの「格安SIM×光回線セット割の世帯人数別シミュレーション」「ふるさと納税ポータル5社の還元率再集計」の記事に計測リンク付きでまとめています。
まとめではなく、次の一手: Zennのtopicsをタグ集計に足す
最後に拡張の宿題を置きます。今回のZenn取得はタグを空配列で埋めましたが、各記事の詳細API(/api/articles/{slug})を叩けば topics が取れます。fetch_zenn の中で詳細を1本ずつ引き、TARGET_TAGS 判定にZennも乗せれば、一貫性スコアの精度が上がります。ただし詳細を全記事ぶん叩くとリクエストが増えるので、time.sleep と「前回取得済みslugはスキップ」のキャッシュを必ず入れてください。レート制限で巻き添え死する失敗は、一度やれば二度とやりたくなくなります。