diff --git a/.github/workflows/note.yaml b/.github/workflows/note.yaml index ddddc82..1bba0df 100644 --- a/.github/workflows/note.yaml +++ b/.github/workflows/note.yaml @@ -1,6 +1,8 @@ name: Note Workflow on: + schedule: + - cron: '0 22 * * *' workflow_dispatch: inputs: theme: @@ -48,327 +50,330 @@ env: TZ: Asia/Tokyo jobs: + setup: + name: Setup Theme + runs-on: ubuntu-latest + outputs: + theme: ${{ steps.theme.outputs.theme }} + target: ${{ steps.theme.outputs.target }} + message: ${{ steps.theme.outputs.message }} + cta: ${{ steps.theme.outputs.cta }} + tags: ${{ steps.theme.outputs.tags }} + is_public: ${{ steps.theme.outputs.is_public }} + dry_run: ${{ steps.theme.outputs.dry_run }} + steps: + - name: Resolve theme + id: theme + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "theme=${{ github.event.inputs.theme }}" >> "$GITHUB_OUTPUT" + echo "target=${{ github.event.inputs.target }}" >> "$GITHUB_OUTPUT" + echo "message=${{ github.event.inputs.message }}" >> "$GITHUB_OUTPUT" + echo "cta=${{ github.event.inputs.cta }}" >> "$GITHUB_OUTPUT" + echo "tags=${{ github.event.inputs.tags }}" >> "$GITHUB_OUTPUT" + echo "is_public=${{ github.event.inputs.is_public }}" >> "$GITHUB_OUTPUT" + echo "dry_run=${{ github.event.inputs.dry_run }}" >> "$GITHUB_OUTPUT" + else + DAY=$(date +%u) + case $DAY in + 1) + THEME="まつ毛エクステ マツエクspice 大阪 モチがいい" + TARGET="大阪でまつ毛エクステを探している20〜40代の女性" + MESSAGE="大阪のまつ毛エクステ専門サロンマツエクspiceは、モチのよさと仕上がりの美しさで人気。自分に合ったエクステの選び方も丁寧に提案してもらえる" + CTA="ご予約はInstagramプロフィールのリンクから👇 @spice_eyelash_ をフォローして、リットリンクより簡単予約!" + TAGS="まつ毛エクステ,大阪マツエク,マツエクspice,モチいいマツエク,まつげエクステ大阪" + ;; + 2) + THEME="まつ毛パーマ マツエクspice 大阪 持ちがいい 時短" + TARGET="まつ毛パーマを初めて検討している大阪在住の働く女性" + MESSAGE="マツエクspiceのまつ毛パーマは施術が早くて持ちもよく、毎朝のメイク時間を大幅に短縮できる。初めての方でも安心して受けられる" + CTA="ご予約はInstagramプロフィールのリンクから👇 @spice_eyelash_ をフォローして、リットリンクより簡単予約!" + TAGS="まつ毛パーマ,大阪まつ毛,マツエクspice,時短メイク,まつ毛カール大阪" + ;; + 3) + THEME="マツエクspice まつ毛エクステ 大阪 安全 オーガニック" + TARGET="目元の安全性や素材にこだわる大阪在住の女性" + MESSAGE="マツエクspiceでは目元への負担を最小限に抑えた安全な施術を提供。敏感な方でも安心して通えるまつ毛エクステ専門サロン" + CTA="ご予約はInstagramプロフィールのリンクから👇 @spice_eyelash_ をフォローして、リットリンクより簡単予約!" + TAGS="まつ毛エクステ大阪,マツエクspice,安全マツエク,大阪美容,敏感肌マツエク" + ;; + 4) + THEME="マツエクspice 大阪 眉毛 アイブロウ 美眉デザイン" + TARGET="眉毛の形・薄さ・左右差に悩む大阪在住の女性" + MESSAGE="マツエクspiceではまつ毛エクステに加えて眉毛デザインも対応。顔のバランスに合わせた美眉を提案し、目元全体の印象を格上げできる" + CTA="ご予約はInstagramプロフィールのリンクから👇 @spice_eyelash_ をフォローして、リットリンクより簡単予約!" + TAGS="アイブロウ大阪,眉毛サロン,マツエクspice,美眉デザイン,大阪眉毛" + ;; + 5) + THEME="まつ毛エクステ マツエクspice 大阪 コスパ 安い 高品質" + TARGET="まつ毛エクステのコスパを重視する大阪在住の女性" + MESSAGE="マツエクspiceは高品質な施術をリーズナブルな価格で提供。安いだけでなく仕上がりと持ちにもこだわる、大阪でコスパ最強のまつ毛エクステサロン" + CTA="ご予約はInstagramプロフィールのリンクから👇 @spice_eyelash_ をフォローして、リットリンクより簡単予約!" + TAGS="まつ毛エクステ安い,大阪マツエク,マツエクspice,コスパマツエク,まつげエクステ大阪" + ;; + 6) + THEME="マツエクspice まつ毛エクステ 大阪 当日予約 早い" + TARGET="忙しくてなかなか予約が取れない大阪在住の女性" + MESSAGE="マツエクspiceは当日予約にも対応。施術も早くて丁寧なので、忙しい女性でも気軽に通えるまつ毛エクステ専門サロン" + CTA="ご予約はInstagramプロフィールのリンクから👇 @spice_eyelash_ をフォローして、リットリンクより簡単予約!" + TAGS="当日予約マツエク,大阪マツエク,マツエクspice,時短まつ毛,まつ毛エクステ大阪" + ;; + 7) + THEME="マツエクspice まつ毛エクステ まつ毛パーマ 眉毛 大阪 比較 選び方" + TARGET="まつ毛エクステ・まつ毛パーマ・眉毛どれにするか迷っている大阪在住の女性" + MESSAGE="マツエクspiceではまつ毛エクステ・まつ毛パーマ・眉毛デザインをすべて対応。それぞれの特徴と自分に向いているメニューをプロが丁寧に案内してくれる" + CTA="ご予約はInstagramプロフィールのリンクから👇 @spice_eyelash_ をフォローして、リットリンクより簡単予約!" + TAGS="大阪マツエク,まつ毛パーマ,マツエクspice,アイブロウ大阪,まつ毛エクステ比較" + ;; + esac + echo "theme=$THEME" >> "$GITHUB_OUTPUT" + echo "target=$TARGET" >> "$GITHUB_OUTPUT" + echo "message=$MESSAGE" >> "$GITHUB_OUTPUT" + echo "cta=$CTA" >> "$GITHUB_OUTPUT" + echo "tags=$TAGS" >> "$GITHUB_OUTPUT" + echo "is_public=true" >> "$GITHUB_OUTPUT" + echo "dry_run=false" >> "$GITHUB_OUTPUT" + fi + research: - name: Research (CCSDK WebSearch/WebFetch) + name: Research (Tavily) runs-on: ubuntu-latest + needs: setup env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - THEME: ${{ github.event.inputs.theme }} - TARGET: ${{ github.event.inputs.target }} + TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} + THEME: ${{ needs.setup.outputs.theme }} + TARGET: ${{ needs.setup.outputs.target }} + MESSAGE: ${{ needs.setup.outputs.message }} outputs: research_b64: ${{ steps.collect.outputs.research_b64 }} steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - - name: Install Claude Code SDK - run: | - npm init -y - npm i @anthropic-ai/claude-code - - - name: Research with Claude Code SDK + - name: Research with Tavily run: | cat > research.mjs <<'EOF' - import { query } from '@anthropic-ai/claude-code'; import fs from 'fs'; - const theme = process.env.THEME || ''; + + const apiKey = process.env.TAVILY_API_KEY; + const theme = process.env.THEME || 'まつ毛エクステ マツエクspice 大阪'; const target = process.env.TARGET || ''; - const today = new Date().toISOString().slice(0,10); - const artifactsDir = '.note-artifacts'; - fs.mkdirSync(artifactsDir, { recursive: true }); - const sys = [ - 'あなたは最新情報の収集と要約に特化した超一流のリサーチャーです。', - '事実ベース・一次情報優先・本文内にMarkdownリンクで出典を埋め込むこと。', - '十分な分量(目安: 2,000語以上)。各節で出典を本文に埋め込む。', - 'WebSearch と WebFetch を必ず使用し、一次情報(公的機関・規格・論文・公式)を優先する。', - ].join('\n'); - const userPrompt = `以下のテーマとターゲットに対する最終版のリサーチレポートを作成してください。\n【重要】途中経過や確認質問は一切せず、最終レポートのみを返してください。不明点がある場合は「前提と仮定」セクションで簡潔に仮定を明記してから続行してください。事実ベースで一次情報を最優先し、本文にMarkdownリンクで出典を埋め込んでください。\n---\nテーマ: ${theme}\nターゲット: ${target}\n現在日付: ${today}`; - const messages = []; - for await (const msg of query({ - prompt: userPrompt, - options: { - customSystemPrompt: sys, - allowedTools: ['WebSearch','WebFetch'], - permissionMode: 'acceptEdits', - }, - })) { messages.push(msg); } - const assistantTexts = messages.filter(m=>m.type==='assistant').map(m=>{ - const c=m.message?.content; if(Array.isArray(c)){return c.filter(b=>b?.type==='text').map(b=>b.text).join('\n');} return ''; - }).filter(Boolean).join('\n\n'); - fs.writeFileSync(`${artifactsDir}/research.md`, assistantTexts || ''); - try { fs.writeFileSync(`${artifactsDir}/research_trace.json`, JSON.stringify(messages, null, 2)); } catch {} + const message = process.env.MESSAGE || ''; + + const prompt = `${theme} ${target} ${message}`.trim(); + + const res = await fetch('https://api.tavily.com/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + api_key: apiKey, + query: prompt, + search_depth: 'advanced', + topic: 'general', + max_results: 5, + include_answer: true + }) + }); + + if (!res.ok) { console.error(await res.text()); process.exit(1); } + + const json = await res.json(); + fs.writeFileSync('research.json', JSON.stringify(json, null, 2)); + console.log('research saved'); EOF + node research.mjs - name: Collect research id: collect run: | - b64=$(base64 -w 0 .note-artifacts/research.md 2>/dev/null || base64 .note-artifacts/research.md) - echo "research_b64<> $GITHUB_OUTPUT - echo "$b64" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + echo "research_b64=$(base64 -w 0 research.json)" >> "$GITHUB_OUTPUT" - - name: Upload research artifacts + - name: Upload research artifact uses: actions/upload-artifact@v4 with: - name: research-artifacts - path: | - .note-artifacts/research.md - .note-artifacts/research_trace.json + name: research-json + path: research.json write: - name: Write (Claude Sonnet 4.0) - needs: research + name: Write (Claude) runs-on: ubuntu-latest + needs: [setup, research] env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - THEME: ${{ github.event.inputs.theme }} - TARGET: ${{ github.event.inputs.target }} - MESSAGE: ${{ github.event.inputs.message }} - CTA: ${{ github.event.inputs.cta }} - INPUT_TAGS: ${{ github.event.inputs.tags }} + THEME: ${{ needs.setup.outputs.theme }} + TARGET: ${{ needs.setup.outputs.target }} + MESSAGE: ${{ needs.setup.outputs.message }} + CTA: ${{ needs.setup.outputs.cta }} + TAGS: ${{ needs.setup.outputs.tags }} + RESEARCH_B64: ${{ needs.research.outputs.research_b64 }} outputs: - title: ${{ steps.collect.outputs.title }} - draft_json_b64: ${{ steps.collect.outputs.draft_json_b64 }} + article_b64: ${{ steps.collect.outputs.article_b64 }} steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - - name: Install AI SDK - run: | - npm init -y - npm i ai @ai-sdk/anthropic - - - name: Restore research - env: - RESEARCH_B64: ${{ needs.research.outputs.research_b64 }} - run: | - mkdir -p .note-artifacts - echo "$RESEARCH_B64" | base64 -d > .note-artifacts/research.md || echo "$RESEARCH_B64" | base64 --decode > .note-artifacts/research.md - - - name: Generate draft (title/body/tags) + - name: Write article with Claude run: | cat > write.mjs <<'EOF' - import { generateText } from 'ai'; - import { anthropic } from '@ai-sdk/anthropic'; import fs from 'fs'; - const theme=process.env.THEME||''; const target=process.env.TARGET||''; const message=process.env.MESSAGE||''; const cta=process.env.CTA||''; - const inputTags=(process.env.INPUT_TAGS||'').split(',').map(s=>s.trim()).filter(Boolean); - const researchReport=fs.readFileSync('.note-artifacts/research.md','utf8'); - const modelName='claude-sonnet-4-5-20250929'; - function extractJsonFlexible(raw){const t=(raw||'').trim().replace(/\u200B/g,'');try{return JSON.parse(t);}catch{}const m=t.match(/```[a-zA-Z]*\s*([\s\S]*?)\s*```/);if(m&&m[1]){try{return JSON.parse(m[1].trim());}catch{}}const f=t.indexOf('{'),l=t.lastIndexOf('}');if(f!==-1&&l!==-1&&l>f){const c=t.slice(f,l+1);try{return JSON.parse(c);}catch{}}return null;} - async function repairJson(raw){const sys='入力から {"title":string,"draftBody":string,"tags":string[]} のJSONのみ返答。';const {text}=await generateText({model:anthropic(modelName),system:sys,prompt:String(raw),temperature:0,maxTokens:8000});return extractJsonFlexible(text||'');} - function sanitizeTitle(t){ - let s=String(t||'').trim(); - // フェンスや見出し、引用符を除去 - s=s.replace(/^```[a-zA-Z0-9_-]*\s*$/,'').replace(/^```$/,''); - s=s.replace(/^#+\s*/,''); - s=s.replace(/^"+|"+$/g,'').replace(/^'+|'+$/g,''); - s=s.replace(/^`+|`+$/g,''); - s=s.replace(/^json$/i,'').trim(); - if(!s) s='タイトル(自動生成)'; - return s; - } - function deriveTitleFromText(text){ - const lines=(text||'').split(/\r?\n/).map(l=>l.trim()).filter(Boolean); - const firstReal=lines.find(l=>!/^```/.test(l))||lines[0]||''; - return sanitizeTitle(firstReal); + + const apiKey = process.env.ANTHROPIC_API_KEY; + const theme = process.env.THEME || ''; + const target = process.env.TARGET || ''; + const message = process.env.MESSAGE || ''; + const cta = process.env.CTA || ''; + const tags = process.env.TAGS || ''; + const research = JSON.parse(Buffer.from(process.env.RESEARCH_B64, 'base64').toString('utf8')); + + const systemPrompt = ` + あなたは日本語のnote記事を書くプロの編集者です。 + 大阪にある「まつ毛エクステ専門サロン マツエクspice」の集客を目的としたnote記事を作成してください。 + MEO(地域検索最適化)とLLMO(AI検索最適化)を意識した記事を作成してください。 + サロン名「マツエクspice」を記事内に自然に盛り込み、読者が予約・問い合わせをしたくなる内容にしてください。 + 出力は必ずJSONのみ。 + 形式: + { + "title": "記事タイトル", + "body": "note本文。見出しや改行を含む", + "tags": ["タグ1","タグ2","タグ3"] } - const sysWrite='note.com向け長文記事の生成。JSON {title,draftBody,tags[]} で返答。draftBodyは6000〜9000文字を目安に十分な分量で、章ごとに小見出しと箇条書きを適切に含めること。'; - const prompt=[`{テーマ}: ${theme}`,`{ペルソナ}: ${target}`,`{リサーチ内容}: ${researchReport}`,`{伝えたいこと}: ${message}`,`{読後のアクション}: ${cta}`].join('\n'); - const {text}=await generateText({model:anthropic(modelName),system:sysWrite,prompt,temperature:0.7,maxTokens:30000}); - let obj=extractJsonFlexible(text||'')||await repairJson(text||''); - let title, draftBody, tags; if(obj){title=sanitizeTitle(obj.title); draftBody=String(obj.draftBody||'').trim(); tags=Array.isArray(obj.tags)?obj.tags.map(String):[]} - if(!title||!draftBody){ title=deriveTitleFromText(text||''); const lines=(text||'').split(/\r?\n/); draftBody=lines.slice(1).join('\n').trim()||(text||''); tags=[]} - if(inputTags.length){tags=Array.from(new Set([...(tags||[]),...inputTags]));} - fs.writeFileSync('.note-artifacts/draft.json',JSON.stringify({title,draftBody,tags},null,2)); + `.trim(); + + const userPrompt = ` + 記事テーマ: ${theme} + 想定読者: ${target} + 読者に伝えたい核メッセージ: ${message} + 読後のアクション: ${cta} + 希望タグ: ${tags} + リサーチ結果: ${JSON.stringify(research, null, 2)} + 条件: + - 日本語・読みやすい自然な文章・誇張しすぎない + - サロン名「マツエクspice」を冒頭・本文・締めくくりに自然に含める + - 見出しを使う(H2・H3)・最後にCTAを入れる + - tags は配列で3〜5個 + - MEO対策:「大阪」「マツエクspice」を自然に文中に含める + - LLMO対策:「〜とは」「〜の選び方」「〜のポイント」など質問形式・解説形式の見出しを使う + - キーワード「モチ」「早い」「安い」「コスパ」を自然に含める + - 文字数は1500〜2500文字 + - 記事末尾のCTAは「Instagramをフォローしてプロフィールのリットリンクから予約」という流れで締めくくる + - マツエクspiceの公式Instagramアカウント @spice_eyelash_ を明記する + `.trim(); + + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5', + max_tokens: 4000, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }] + }) + }); + + if (!res.ok) { console.error(await res.text()); process.exit(1); } + + const json = await res.json(); + const text = (json.content?.[0]?.text || '').replace(/```json|```/g, '').trim(); + fs.writeFileSync('article_raw.txt', text); + + let parsed; + try { parsed = JSON.parse(text); } + catch { console.error('Claude output was not valid JSON'); console.error(text); process.exit(1); } + + fs.writeFileSync('article.json', JSON.stringify(parsed, null, 2)); + console.log('article saved'); EOF + node write.mjs - - name: Collect draft + - name: Collect article id: collect run: | - title=$(node -e "console.log(JSON.parse(require('fs').readFileSync('.note-artifacts/draft.json','utf8')).title)") - b64=$(base64 -w 0 .note-artifacts/draft.json 2>/dev/null || base64 .note-artifacts/draft.json) - echo "title<> $GITHUB_OUTPUT - echo "$title" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - echo "draft_json_b64<> $GITHUB_OUTPUT - echo "$b64" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Upload draft artifact + echo "article_b64=$(base64 -w 0 article.json)" >> "$GITHUB_OUTPUT" + + - name: Upload article artifact uses: actions/upload-artifact@v4 with: - name: draft-artifact - path: .note-artifacts/draft.json + name: article-json + path: | + article.json + article_raw.txt factcheck: name: Fact-check (Tavily) - needs: write runs-on: ubuntu-latest + needs: write env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} - TITLE: ${{ needs.write.outputs.title }} + ARTICLE_B64: ${{ needs.write.outputs.article_b64 }} outputs: - title: ${{ steps.collect.outputs.title }} final_b64: ${{ steps.collect.outputs.final_b64 }} steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - - name: Install AI SDK - run: | - npm init -y - npm i ai @ai-sdk/anthropic - - - name: Restore draft json - env: - DRAFT_JSON_B64: ${{ needs.write.outputs.draft_json_b64 }} - run: | - mkdir -p .note-artifacts - echo "$DRAFT_JSON_B64" | base64 -d > .note-artifacts/draft.json || echo "$DRAFT_JSON_B64" | base64 --decode > .note-artifacts/draft.json - - - name: Fact-check with Tavily + - name: Fact-check article run: | cat > factcheck.mjs <<'EOF' - import { generateText } from 'ai'; - import { anthropic } from '@ai-sdk/anthropic'; import fs from 'fs'; - const draft=JSON.parse(fs.readFileSync('.note-artifacts/draft.json','utf8')); - const modelName='claude-sonnet-4-5-20250929'; - const TAVILY_API_KEY=process.env.TAVILY_API_KEY||''; - if(!TAVILY_API_KEY){ console.error('TAVILY_API_KEY is not set'); process.exit(1); } - - function extractJsonFlexible(raw){ - const t=(raw||'').trim().replace(/\u200B/g,''); - // try object - try{ const o=JSON.parse(t); return o; }catch{} - const fence=t.match(/```[a-zA-Z]*\s*([\s\S]*?)\s*```/); if(fence&&fence[1]){ try{ return JSON.parse(fence[1].trim()); }catch{} } - // try object slice - let f=t.indexOf('{'), l=t.lastIndexOf('}'); if(f!==-1&&l!==-1&&l>f){ const cand=t.slice(f,l+1); try{ return JSON.parse(cand); }catch{} } - // try array slice - f=t.indexOf('['); l=t.lastIndexOf(']'); if(f!==-1&&l!==-1&&l>f){ const cand=t.slice(f,l+1); try{ return JSON.parse(cand); }catch{} } - return null; - } - function stripCodeFence(s){ - const t=String(s||'').trim(); - const m=t.match(/^```[a-zA-Z0-9_-]*\s*([\s\S]*?)\s*```\s*$/); if(m&&m[1]) return m[1].trim(); - return t; - } - - async function proposeQueries(body){ - const sys='あなたは事実検証の専門家です。入力本文から検証が必要な固有名詞・数値・主張を抽出し、Tavily検索用に日本語の検索クエリを最大10件の配列で返してください。出力はJSON配列のみ。'; - const { text } = await generateText({ model: anthropic(modelName), system: sys, prompt: String(body), temperature: 0, maxTokens: 2000 }); - const arr = extractJsonFlexible(text||''); - return Array.isArray(arr) ? arr.map(String).filter(Boolean).slice(0,10) : []; - } - - async function tavilySearch(q){ - const res = await fetch('https://api.tavily.com/search', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ api_key: TAVILY_API_KEY, query: q, search_depth: 'advanced', max_results: 5, include_answer: true }) - }); - if(!res.ok){ return { query:q, results:[], answer:null }; } - const json = await res.json().catch(()=>({})); - return { query:q, results: Array.isArray(json.results)? json.results: [], answer: json.answer || null }; - } - - function formatEvidence(items){ - const lines = []; - for(const it of items){ - lines.push(`### 検索: ${it.query}`); - if(it.answer){ lines.push(`要約: ${it.answer}`); } - for(const r of it.results||[]){ - const t = (r.title||'').toString(); - const u = (r.url||'').toString(); - const c = (r.content||'').toString().slice(0,500); - lines.push(`- [${t}](${u})\n ${c}`); - } - lines.push(''); - } - return lines.join('\n'); - } - async function main(){ - const queries = await proposeQueries(draft.draftBody||''); - const results = []; - for(const q of queries){ results.push(await tavilySearch(q)); } - const evidence = formatEvidence(results); - const sys=[ - 'あなたは事実検証の専門家です。以下の原稿(note記事の下書き)に対し、提供されたエビデンス(Tavily検索結果)に基づき、', - '誤情報の修正・低信頼出典の置換・信頼できる一次情報の本文内Markdownリンク埋め込みを行って、修正後の本文のみ返してください。', - '文体・構成は原稿を尊重し、必要に応じて本文末尾に参考文献セクションを追加してください。', - ].join('\n'); - const prompt = [ - '## 原稿', String(draft.draftBody||''), '', '## エビデンス(Tavily検索結果)', evidence - ].join('\n\n'); - const { text } = await generateText({ model: anthropic(modelName), system: sys, prompt, temperature: 0.3, maxTokens: 30000 }); - let body = stripCodeFence(text||''); - let title = process.env.TITLE || draft.title || ''; - let tags = Array.isArray(draft.tags)? draft.tags: []; - const obj = extractJsonFlexible(body); - if (obj && typeof obj === 'object' && !Array.isArray(obj)) { - if (obj.title) title = String(obj.title); - const candidates = [obj.body, obj.draftBody, obj.content, obj.text]; - const chosen = candidates.find(v=>typeof v==='string' && v.trim()); - if (chosen) body = String(chosen); - if (Array.isArray(obj.tags)) tags = obj.tags.map(String); - } - body = stripCodeFence(body); - const out = { title, body, tags }; - fs.writeFileSync('.note-artifacts/final.json', JSON.stringify(out,null,2)); - } - - await main(); + const apiKey = process.env.TAVILY_API_KEY; + const article = JSON.parse(Buffer.from(process.env.ARTICLE_B64, 'base64').toString('utf8')); + const query = `${article.title}\n\n${article.body.slice(0, 300)}`; + + const res = await fetch('https://api.tavily.com/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + api_key: apiKey, + query, + search_depth: 'advanced', + topic: 'general', + max_results: 5, + include_answer: true + }) + }); + + if (!res.ok) { console.error(await res.text()); process.exit(1); } + + const fact = await res.json(); + fs.writeFileSync('final_article.json', JSON.stringify({ ...article, factcheck: fact }, null, 2)); + console.log('final article saved'); EOF - node factcheck.mjs - - name: Upload fact-check artifact - uses: actions/upload-artifact@v4 - with: - name: final-artifact - path: .note-artifacts/final.json + node factcheck.mjs - - name: Collect final + - name: Collect final article id: collect run: | - title=$(node -e "console.log(JSON.parse(require('fs').readFileSync('.note-artifacts/final.json','utf8')).title)") - b64=$(base64 -w 0 .note-artifacts/final.json 2>/dev/null || base64 .note-artifacts/final.json) - echo "title<> $GITHUB_OUTPUT - echo "$title" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - echo "final_b64<> $GITHUB_OUTPUT - echo "$b64" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + echo "final_b64=$(base64 -w 0 final_article.json)" >> "$GITHUB_OUTPUT" + + - name: Upload final article artifact + uses: actions/upload-artifact@v4 + with: + name: final-article-json + path: final_article.json post: name: Post to note.com (Playwright) - needs: factcheck - if: ${{ github.event.inputs.dry_run != 'true' }} runs-on: ubuntu-latest + needs: [setup, factcheck] + if: ${{ needs.setup.outputs.dry_run == 'false' }} env: - IS_PUBLIC: ${{ github.event.inputs.is_public }} - STATE_JSON: ${{ secrets.NOTE_STORAGE_STATE_JSON }} - START_URL: https://editor.note.com/new - outputs: - final_url: ${{ steps.publish.outputs.published_url || steps.publish.outputs.draft_url }} + NOTE_STORAGE_STATE_JSON: ${{ secrets.NOTE_STORAGE_STATE_JSON }} + FINAL_B64: ${{ needs.factcheck.outputs.final_b64 }} + IS_PUBLIC: ${{ needs.setup.outputs.is_public }} steps: - name: Checkout uses: actions/checkout@v4 @@ -381,307 +386,250 @@ jobs: - name: Install Playwright run: | npm init -y - npm i playwright marked - npx playwright install --with-deps chromium | cat - - - name: Prepare storageState - id: state - run: | - test -n "$STATE_JSON" || (echo "ERROR: NOTE_STORAGE_STATE_JSON secret is not set" && exit 1) - mkdir -p "$RUNNER_TEMP" - echo "$STATE_JSON" > "$RUNNER_TEMP/note-state.json" - echo "STATE_PATH=$RUNNER_TEMP/note-state.json" >> $GITHUB_OUTPUT + npm install playwright + npx playwright install --with-deps chromium - - name: Ensure jq (post) + - name: Restore storage state run: | - if ! command -v jq >/dev/null 2>&1; then - sudo apt-get update -y - sudo apt-get install -y jq - fi + cat > note-state.json <<'EOF' + ${{ secrets.NOTE_STORAGE_STATE_JSON }} + EOF - - name: Restore final - id: draft - env: - FINAL_B64: ${{ needs.factcheck.outputs.final_b64 }} - run: | - test -n "$FINAL_B64" || { echo "final_b64 output is empty"; exit 1; } - echo "$FINAL_B64" | base64 -d > final.json || echo "$FINAL_B64" | base64 --decode > final.json - echo "TITLE=$(jq -r .title final.json)" >> $GITHUB_OUTPUT - echo "TAGS=$(jq -r '.tags | join(", ")' final.json)" >> $GITHUB_OUTPUT - - - name: Publish via Playwright (draft or public) - id: publish - env: - TITLE: ${{ steps.draft.outputs.TITLE }} - TAGS: ${{ steps.draft.outputs.TAGS }} - STATE_PATH: ${{ steps.state.outputs.STATE_PATH }} + - name: Post to note run: | - # 本文は後続スクリプト内でMarkdownリンク→素URL化などの前処理を行う cat > post.mjs <<'EOF' import { chromium } from 'playwright'; - import { marked } from 'marked'; - import fs from 'fs'; - import os from 'os'; - import path from 'path'; - - function nowStr(){ const d=new Date(); const z=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}-${z(d.getMonth()+1)}-${z(d.getDate())}_${z(d.getHours())}-${z(d.getMinutes())}-${z(d.getSeconds())}`; } - - const STATE_PATH=process.env.STATE_PATH; - const START_URL=process.env.START_URL||'https://editor.note.com/new'; - const rawTitle=process.env.TITLE||''; - const rawFinal=JSON.parse(fs.readFileSync('final.json','utf8')); - const rawBody=String(rawFinal.body||''); - const TAGS=process.env.TAGS||''; - const IS_PUBLIC=String(process.env.IS_PUBLIC||'false')==='true'; - - if(!fs.existsSync(STATE_PATH)){ console.error('storageState not found:', STATE_PATH); process.exit(1); } - - const ssDir=path.join(os.tmpdir(),'note-screenshots'); fs.mkdirSync(ssDir,{recursive:true}); const SS_PATH=path.join(ssDir,`note-post-${nowStr()}.png`); - - function sanitizeTitle(t){ - let s=String(t||'').trim(); - s=s.replace(/^```[a-zA-Z0-9_-]*\s*$/,'').replace(/^```$/,''); - s=s.replace(/^#+\s*/,''); - s=s.replace(/^"+|"+$/g,'').replace(/^'+|'+$/g,''); - s=s.replace(/^`+|`+$/g,''); - s=s.replace(/^json$/i,'').trim(); - // タイトルが波括弧や記号のみの時は無効として扱う - if (/^[\{\}\[\]\(\)\s]*$/.test(s)) s=''; - if(!s) s='タイトル(自動生成)'; - return s; - } - function deriveTitleFromMarkdown(md){ - const lines=String(md||'').split(/\r?\n/); - for (const line of lines){ - const l=line.trim(); - if(!l) continue; - const m=l.match(/^#{1,3}\s+(.+)/); if(m) return sanitizeTitle(m[1]); - if(!/^```|^>|^\* |^- |^\d+\. /.test(l)) return sanitizeTitle(l); - } - return ''; - } - function normalizeBullets(md){ - // 先頭の中黒・ビュレットを箇条書きに正規化 - return String(md||'') - .replace(/^\s*[•・]\s?/gm,'- ') - .replace(/^\s*◦\s?/gm,' - '); - } - function unwrapParagraphs(md){ - // 段落中の不必要な改行をスペースへ(見出し/リスト/引用/コードは除外) - const lines=String(md||'').split(/\r?\n/); - const out=[]; let buf=''; let inFence=false; - for(const raw of lines){ - const line=raw.replace(/\u200B/g,''); - if(/^```/.test(line)){ inFence=!inFence; buf+=line+'\n'; continue; } - if(inFence){ buf+=line+'\n'; continue; } - if(/^\s*$/.test(line)){ if(buf) out.push(buf.trim()); out.push(''); buf=''; continue; } - // 箇条書きや番号付きの字下げ改行を一行に連結 - if(/^(#{1,6}\s|[-*+]\s|\d+\.\s|>\s)/.test(line)){ - if(buf){ out.push(buf.trim()); buf=''; } - // 次の数行が連続して単語単位の改行の場合は連結 - out.push(line.replace(/\s+$/,'')); - continue; - } - // 行頭が1文字や数文字で改行されているケース(縦伸び)を連結 - if(buf){ buf += (/[。.!?)]$/.test(buf) ? '\n' : ' ') + line.trim(); } - else { buf = line.trim(); } - } - if(buf) out.push(buf.trim()); - return out.join('\n'); - } - function preferBareUrls(md){ - const embedDomains=['openai.com','youtube.com','youtu.be','x.com','twitter.com','speakerdeck.com','slideshare.net','google.com','maps.app.goo.gl','gist.github.com']; - return String(md||'').replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,(m,text,url)=>{ - try{ - const u=new URL(url); const host=u.hostname.replace(/^www\./,''); - const isEmbed = embedDomains.some(d=>host.endsWith(d) || (url.includes('google.com/maps') && d.includes('google.com'))); - return isEmbed ? `${text}\n${url}\n` : `${text} (${url})`; - }catch{return `${text} ${url}`;} + + async function main() { + const data = JSON.parse(Buffer.from(process.env.FINAL_B64, 'base64').toString('utf8')); + const statePath = './note-state.json'; + const isPublic = process.env.IS_PUBLIC === 'true'; + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + storageState: statePath, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }); - } - function isGarbageLine(line){ - return /^[\s\{\}\[\]\(\)`]+$/.test(line || ''); - } - function normalizeListItemSoftBreaks(md){ - const lines=String(md||'').split(/\r?\n/); - const out=[]; let inItem=false; - const listStartRe=/^(\s*)(?:[-*+]\s|\d+\.\s)/; - for (let i=0;i{ const t=b.trim(); return t.length>0 && !isGarbageLine(t); }); - } - function mdToHtml(block){ - // JSONが紛れ込んでしまった場合は本文候補のみ抽出 - try{ - const maybe = JSON.parse(block); - if (maybe && typeof maybe==='object' && !Array.isArray(maybe)){ - const candidates=[maybe.body, maybe.draftBody, maybe.content, maybe.text]; - const chosen=candidates.find(v=>typeof v==='string' && v.trim()); - if (chosen) block = String(chosen); + const page = await context.newPage(); + + let step = 0; + const shot = async (label) => { + await page.screenshot({ path: `debug_${String(++step).padStart(2,'0')}_${label}.png`, fullPage: true }); + console.log(`[screenshot] ${label}`); + }; + + // ── モーダルをJSで強制削除するヘルパー関数 ── + const dismissModal = async () => { + try { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + + const removed = await page.evaluate(() => { + let count = 0; + // ReactModalPortalを直接削除 + document.querySelectorAll('.ReactModalPortal').forEach(el => { + el.remove(); + count++; + }); + // assistants_confirmationを含む画像の親モーダルを削除 + document.querySelectorAll('img[src*="assistants_confirmation"], img[src*="confirmation"]').forEach(img => { + let el = img; + for (let i = 0; i < 8; i++) { + if (!el.parentElement) break; + el = el.parentElement; + if (el.tagName === 'BODY') break; + const style = window.getComputedStyle(el); + if (style.position === 'fixed' || style.position === 'absolute') { + el.remove(); + count++; + break; + } + } + }); + // pointer-eventsをブロックしているオーバーレイを削除 + document.querySelectorAll('[class*="overlay"], [class*="Overlay"], [class*="backdrop"], [class*="Backdrop"]').forEach(el => { + const style = window.getComputedStyle(el); + if (style.position === 'fixed' && parseInt(style.zIndex) > 10) { + el.remove(); + count++; + } + }); + return count; + }); + console.log(`dismissModal: removed ${removed} element(s)`); + await page.waitForTimeout(300); + } catch (e) { + console.log('dismissModal skipped:', e.message); } - }catch{} - const isList = /^\s*(?:[-*+]\s|\d+\.\s)/.test(block); - return String(marked.parse(block, { gfm:true, breaks: !isList, mangle:false, headerIds:false }) || ''); - } - function htmlFromMarkdown(md){ - // 全文を一括でHTML化(段落ベース)。リスト中の意図しない
を避けるため breaks=false - return String(marked.parse(md, { gfm:true, breaks:false, mangle:false, headerIds:false }) || ''); - } - async function insertHTML(page, locator, html){ - await locator.click(); - await locator.evaluate((el, html) => { - el.focus(); - const sel = window.getSelection(); - const range = document.createRange(); - range.selectNodeContents(el); - range.collapse(false); - sel.removeAllRanges(); - sel.addRange(range); - document.execCommand('insertHTML', false, html); - }, html); - } + }; - let TITLE=sanitizeTitle(rawTitle); - let preBody = preferBareUrls(rawBody); - preBody = normalizeBullets(preBody); - preBody = normalizeListItemSoftBreaks(preBody); - preBody = unwrapParagraphs(preBody); - if(!TITLE || TITLE==='タイトル(自動生成)'){ - const d=deriveTitleFromMarkdown(preBody); - if(d) TITLE=d; - } - const blocks = splitMarkdownBlocks(preBody); - - let browser, context, page; - try{ - browser = await chromium.launch({ headless: true, args: ['--lang=ja-JP'] }); - context = await browser.newContext({ storageState: STATE_PATH, locale: 'ja-JP' }); - page = await context.newPage(); - page.setDefaultTimeout(180000); - - await page.goto(START_URL, { waitUntil: 'domcontentloaded' }); - await page.waitForSelector('textarea[placeholder*="タイトル"]'); - await page.fill('textarea[placeholder*="タイトル"]', TITLE); - - const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first(); - await bodyBox.waitFor({ state: 'visible' }); - const htmlAll = htmlFromMarkdown(preBody); - let pasted = false; try { - const origin = new URL(START_URL).origin; - await context.grantPermissions(['clipboard-read','clipboard-write'], { origin }); - await page.evaluate(async (html, plain) => { - const item = new ClipboardItem({ - 'text/html': new Blob([html], { type: 'text/html' }), - 'text/plain': new Blob([plain], { type: 'text/plain' }), - }); - await navigator.clipboard.write([item]); - }, htmlAll, preBody); - await bodyBox.click(); - await page.keyboard.press('Control+V'); - await page.waitForTimeout(200); - pasted = true; - } catch (e) { - // クリップボード権限が無い場合のフォールバック - } - if (!pasted) { - // 一括HTML挿入フォールバック - await insertHTML(page, bodyBox, htmlAll); - await page.waitForTimeout(100); - } + console.log('Navigating to editor.note.com/new...'); + await page.goto('https://editor.note.com/new', { waitUntil: 'domcontentloaded' }); + console.log('URL:', page.url()); + + console.log('Waiting 15s for React to render...'); + await page.waitForTimeout(15000); + await shot('01_after_wait'); + + // ── ★ モーダルを強制削除(ページ読み込み直後) ── + await dismissModal(); + await shot('02_after_dismiss_modal'); + + const editableCount = await page.evaluate(() => + document.querySelectorAll('[contenteditable]').length + ); + console.log(`Found ${editableCount} contenteditable elements`); + + if (editableCount === 0) { + const snippet = await page.evaluate(() => document.body.innerHTML.slice(0, 800)); + console.log('HTML snippet:', snippet); + throw new Error(`Editor did not load. URL: ${page.url()}`); + } - if(!IS_PUBLIC){ - const saveBtn = page.locator('button:has-text("下書き保存"), [aria-label*="下書き保存"]').first(); - await saveBtn.waitFor({ state: 'visible' }); - if(await saveBtn.isEnabled()) { await saveBtn.click(); await page.locator('text=保存しました').waitFor({ timeout: 4000 }).catch(()=>{}); } - await page.screenshot({ path: SS_PATH, fullPage: true }); - console.log('DRAFT_URL=' + page.url()); - console.log('SCREENSHOT=' + SS_PATH); - process.exit(0); - } + // ── ① タイトル入力 ── + console.log('Filling title...'); + const allEditables = page.locator('[contenteditable]'); + await allEditables.nth(0).click(); + await page.waitForTimeout(300); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(data.title, { delay: 30 }); + await shot('03_after_title'); + + // ── ② 本文入力 ── + console.log('Filling body...'); + const count = await allEditables.count(); + if (count >= 2) { + await allEditables.nth(1).click(); + } else { + await page.keyboard.press('Tab'); + } + await page.waitForTimeout(300); + await page.keyboard.type(data.body, { delay: 10 }); + await shot('04_after_body'); + await page.waitForTimeout(2000); + + // ── ③ ボタンを全部ログ出力 ── + const allButtons = await page.evaluate(() => + Array.from(document.querySelectorAll('button')).map(b => b.innerText.trim()).filter(Boolean) + ); + console.log('[all buttons]', JSON.stringify(allButtons)); + + // ── ★ モーダルを強制削除(ボタンクリック前) ── + await dismissModal(); + await shot('05_before_save'); + + if (isPublic) { + const publishPatterns = [ + /公開する/, + /公開設定/, + /投稿する/, + /publish/i, + /post/i + ]; + + let clicked = false; + for (const pattern of publishPatterns) { + const btn = page.getByRole('button', { name: pattern }).first(); + if (await btn.count() > 0) { + console.log(`Clicking publish button: ${pattern}`); + await btn.click(); + clicked = true; + break; + } + } + + if (!clicked) { + const btn = page.locator('button').filter({ hasText: /公開|投稿|publish/i }).first(); + if (await btn.count() > 0) { + await btn.click(); + clicked = true; + } + } + + if (!clicked) { + throw new Error('Publish button not found. Check screenshots and button list above.'); + } + + await page.waitForTimeout(3000); + await shot('06_after_publish_click'); + + // ── ★ モーダルを強制削除(確認ダイアログ前) ── + await dismissModal(); + + const confirmPatterns = [/公開する/, /確認/, /はい/, /OK/i]; + for (const pattern of confirmPatterns) { + const btn = page.getByRole('button', { name: pattern }).first(); + if (await btn.count() > 0) { + console.log(`Clicking confirm button: ${pattern}`); + await btn.click(); + break; + } + } + await shot('07_after_confirm'); + + } else { + const draftPatterns = [ + /下書き保存/, + /下書き/, + /保存/, + /save/i + ]; + + let clicked = false; + for (const pattern of draftPatterns) { + const btn = page.getByRole('button', { name: pattern }).first(); + if (await btn.count() > 0) { + console.log(`Clicking draft button: ${pattern}`); + await btn.click(); + clicked = true; + break; + } + } + + if (!clicked) { + const btn = page.locator('button').filter({ hasText: /下書き|保存|save/i }).first(); + if (await btn.count() > 0) { + await btn.click(); + clicked = true; + } + } + + if (!clicked) { + throw new Error('Save button not found. Check screenshots and button list above.'); + } + + await shot('06_after_save'); + } - const proceed = page.locator('button:has-text("公開に進む")').first(); - await proceed.waitFor({ state: 'visible' }); - for (let i=0;i<20;i++){ if (await proceed.isEnabled()) break; await page.waitForTimeout(100); } - await proceed.click({ force: true }); - - await Promise.race([ - page.waitForURL(/\/publish/i).catch(() => {}), - page.locator('button:has-text("投稿する")').first().waitFor({ state: 'visible' }).catch(() => {}), - ]); - - const tags=(TAGS||'').split(/[\n,]/).map(s=>s.trim()).filter(Boolean); - if(tags.length){ - let tagInput=page.locator('input[placeholder*="ハッシュタグ"]'); - if(!(await tagInput.count())) tagInput=page.locator('input[role="combobox"]').first(); - await tagInput.waitFor({ state: 'visible' }); - for(const t of tags){ await tagInput.click(); await tagInput.fill(t); await page.keyboard.press('Enter'); await page.waitForTimeout(120); } - } + await page.waitForTimeout(3000); + console.log('Done.'); - const publishBtn = page.locator('button:has-text("投稿する")').first(); - await publishBtn.waitFor({ state: 'visible' }); - for (let i=0;i<20;i++){ if (await publishBtn.isEnabled()) break; await page.waitForTimeout(100); } - await publishBtn.click({ force: true }); - - await Promise.race([ - page.waitForURL(u => !/\/publish/i.test(typeof u === 'string' ? u : u.toString()), { timeout: 20000 }).catch(() => {}), - page.locator('text=投稿しました').first().waitFor({ timeout: 8000 }).catch(() => {}), - page.waitForTimeout(5000), - ]); - - await page.screenshot({ path: SS_PATH, fullPage: true }); - const finalUrl=page.url(); - console.log('PUBLISHED_URL=' + finalUrl); - console.log('SCREENSHOT=' + SS_PATH); - } finally { - try{ await page?.close(); }catch{} - try{ await context?.close(); }catch{} - try{ await browser?.close(); }catch{} + } catch (err) { + await shot('error'); + console.error('Post failed:', err.message); + throw err; + } finally { + await context.storageState({ path: statePath }); + await browser.close(); + } } + + main(); EOF - node post.mjs | tee post.log - url=$(grep '^PUBLISHED_URL=' post.log | tail -n1 | cut -d'=' -f2-) - draft=$(grep '^DRAFT_URL=' post.log | tail -n1 | cut -d'=' -f2-) - shot=$(grep '^SCREENSHOT=' post.log | tail -n1 | cut -d'=' -f2-) - if [ -n "$url" ]; then echo "published_url=$url" >> $GITHUB_OUTPUT; fi - if [ -n "$draft" ]; then echo "draft_url=$draft" >> $GITHUB_OUTPUT; fi - if [ -n "$shot" ]; then echo "screenshot=$shot" >> $GITHUB_OUTPUT; fi - - - name: Upload screenshot (if any) - if: ${{ steps.publish.outputs.screenshot != '' }} + + node post.mjs + + - name: Upload debug screenshots + if: always() uses: actions/upload-artifact@v4 with: - name: note-screenshot - path: ${{ steps.publish.outputs.screenshot }} + name: debug-screenshots + path: debug_*.png + - name: Upload updated state + uses: actions/upload-artifact@v4 + with: + name: updated-note-state + path: note-state.json diff --git a/login-note.mjs b/login-note.mjs deleted file mode 100644 index 231b2bf..0000000 --- a/login-note.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { chromium } from 'playwright'; -import fs from 'fs'; - -const STATE_PATH = './note-state.json'; - -// 手動ログインのため環境変数は不要 - -const wait = (ms) => new Promise(r => setTimeout(r, ms)); - -(async () => { - const browser = await chromium.launch({ headless: false }); - const context = await browser.newContext(); - const page = await context.newPage(); - - await page.goto('https://note.com/login'); - - console.log('手動でログインしてください。ログイン完了を自動検知します...'); - - // ログイン完了を自動検知(note.comのトップページに遷移するまで待機) - try { - await page.waitForURL(/note\.com\/?$/, { timeout: 300000 }); // 5分待機 - console.log('ログイン完了を検知しました!'); - } catch (error) { - console.log('ログイン完了の検知に失敗しました。手動でEnterキーを押してください。'); - await new Promise(resolve => { - process.stdin.once('data', () => { - resolve(); - }); - }); - } - - console.log('ログイン状態を保存中...'); - - // 保存 - await context.storageState({ path: STATE_PATH }); - console.log('Saved:', STATE_PATH); - - await browser.close(); -})();