diff --git a/.github/workflows/debug.html b/.github/workflows/debug.html new file mode 100644 index 0000000..407ccac --- /dev/null +++ b/.github/workflows/debug.html @@ -0,0 +1,10 @@ +- name: Upload debug artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: note-debug-${{ github.run_id }} + path: | + post.log + debug.html + debug.png + trace.zip diff --git a/.github/workflows/note-perplexity.yaml b/.github/workflows/note-perplexity.yaml index ebdcaea..2549653 100644 --- a/.github/workflows/note-perplexity.yaml +++ b/.github/workflows/note-perplexity.yaml @@ -1,22 +1,10 @@ -name: Note Workflow (Perplexity) +name: Note Workflow (SEO URL -> note) on: workflow_dispatch: inputs: - theme: - description: '記事テーマ' - required: true - type: string - target: - description: '想定読者(ペルソナ)' - required: true - type: string - message: - description: '読者に伝えたい核メッセージ' - required: true - type: string - cta: - description: '読後のアクション(CTA)' + article_url: + description: '投稿元のSEO記事URL' required: true type: string tags: @@ -48,20 +36,16 @@ env: TZ: Asia/Tokyo jobs: - research: - name: Research (Perplexity Search API) + fetch: + name: Fetch SEO article (Extract title/body) runs-on: ubuntu-latest env: - PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - THEME: ${{ github.event.inputs.theme }} - TARGET: ${{ github.event.inputs.target }} + article_url: ${{ github.event.inputs.article_url }} + INPUT_TAGS: ${{ github.event.inputs.tags }} outputs: - research_b64: ${{ steps.collect.outputs.research_b64 }} + final_b64: ${{ steps.collect.outputs.final_b64 }} + title: ${{ steps.collect.outputs.title }} steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -70,395 +54,98 @@ jobs: - name: Install dependencies run: | npm init -y - npm i @perplexity-ai/perplexity_ai ai @ai-sdk/anthropic + npm i jsdom @mozilla/readability - - name: Research with Perplexity API + - name: Extract article run: | - cat > research.mjs <<'EOF' - import Perplexity from '@perplexity-ai/perplexity_ai'; - import { generateText } from 'ai'; - import { anthropic } from '@ai-sdk/anthropic'; + cat > fetch.mjs <<'EOF' + import { JSDOM } from 'jsdom'; + import { Readability } from '@mozilla/readability'; import fs from 'fs'; - const theme = process.env.THEME || ''; - const target = process.env.TARGET || ''; - const today = new Date().toISOString().slice(0,10); - const artifactsDir = '.note-artifacts'; - fs.mkdirSync(artifactsDir, { recursive: true }); - - // Perplexity APIクライアント初期化 - const perplexity = new Perplexity({ - apiKey: process.env.PERPLEXITY_API_KEY - }); - - async function main() { - try { - // 複数の検索クエリを実行 - const queries = [ - `${theme} 最新情報 ${today}`, - `${theme} トレンド 動向`, - `${theme} 事例 実践`, - `${theme} 課題 解決策`, - `${theme} 将来 展望` - ]; - - console.log('Perplexity検索を開始...'); - const searchResults = []; - - for (const query of queries) { - console.log(`検索中: ${query}`); - const search = await perplexity.search.create({ - query: query, - maxResults: 5, - maxTokensPerPage: 2048, - country: 'JP' - }); - searchResults.push({ query, results: search.results }); - } + function normalizeUrl(raw) { + const s = String(raw || '').trim(); + if (!s) return ''; + const half = s.replace(/?/g, '?').replace(/#/g, '#').replace(/&/g, '&'); + const encoded = encodeURI(half); + return new URL(encoded).toString(); + } - // 検索結果をMarkdown形式でフォーマット - let researchMd = `# リサーチレポート: ${theme}\n\n`; - researchMd += `**作成日**: ${today}\n`; - researchMd += `**対象読者**: ${target}\n\n`; - researchMd += `---\n\n`; - - for (const { query, results } of searchResults) { - researchMd += `## ${query}\n\n`; - - if (Array.isArray(results) && results.length > 0) { - for (const result of results) { - researchMd += `### ${result.title}\n\n`; - researchMd += `**URL**: [${result.url}](${result.url})\n\n`; - if (result.date) { - researchMd += `**日付**: ${result.date}\n\n`; - } - if (result.snippet) { - researchMd += `${result.snippet}\n\n`; - } - researchMd += `---\n\n`; - } - } else { - researchMd += `検索結果なし\n\n`; - } - } + const raw = process.env.article_url || ''; + const url = normalizeUrl(raw); + if (!url) { + console.error('article_url is empty'); + process.exit(1); + } - // Claudeで検索結果を要約・構造化 - console.log('Claudeで検索結果を分析・構造化中...'); - const modelName = 'claude-sonnet-4-20250514'; - const systemPrompt = [ - 'あなたは最新情報の分析と構造化に特化した超一流のリサーチャーです。', - '提供された検索結果を基に、事実ベースで信頼性の高いリサーチレポートを作成してください。', - '出典は本文内にMarkdownリンクで必ず埋め込むこと。', - '十分な分量(目安: 2,000語以上)で、各節に適切な見出しと構造を持たせてください。', - '一次情報(公的機関・規格・論文・公式発表)を優先し、情報源を明記してください。' - ].join('\n'); - - const userPrompt = [ - `以下の検索結果を基に、テーマ「${theme}」に関する包括的なリサーチレポートを作成してください。`, - ``, - `**対象読者**: ${target}`, - `**現在日付**: ${today}`, - ``, - `## 検索結果`, - ``, - researchMd, - ``, - `## 要件`, - ``, - `1. 検索結果から重要な情報を抽出し、論理的に構造化する`, - `2. 各主張には必ず出典のMarkdownリンクを埋め込む`, - `3. 最新のトレンド、具体的な事例、課題と解決策、将来展望を含める`, - `4. 対象読者(${target})に分かりやすく説明する`, - `5. 情報の信頼性を評価し、一次情報を優先する`, - ``, - `**重要**: 途中経過や確認質問は一切せず、最終レポートのみを返してください。` - ].join('\n'); - - const { text } = await generateText({ - model: anthropic(modelName), - system: systemPrompt, - prompt: userPrompt, - temperature: 0.3, - maxTokens: 30000 - }); + const res = await fetch(url, { + redirect: 'follow', + headers: { + 'User-Agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml' + } + }); - const finalReport = text || researchMd; + const finalUrl = res.url; // ★これが超重要(redirect後のURL) + console.log('DEBUG_FETCH_FINAL_URL=', finalUrl) - // 保存 - fs.writeFileSync(`${artifactsDir}/research.md`, finalReport); - - // トレース用に生データも保存 - const traceData = { - theme, - target, - date: today, - queries, - searchResults, - finalReport: finalReport.substring(0, 500) + '...' - }; - fs.writeFileSync(`${artifactsDir}/research_trace.json`, JSON.stringify(traceData, null, 2)); - - console.log('リサーチ完了!'); - - } catch (error) { - console.error('エラー:', error); - // エラー時は検索結果のみを保存 - const fallbackReport = `# リサーチレポート: ${theme}\n\n**エラーが発生しました**: ${error.message}\n\n検索は部分的に完了している可能性があります。`; - fs.writeFileSync(`${artifactsDir}/research.md`, fallbackReport); - throw error; - } + if (!res.ok) { + console.error('fetch failed:', res.status, res.statusText); + process.exit(1); } - await main(); - EOF - node research.mjs + const html = await res.text(); - - 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 - - - name: Upload research artifacts - uses: actions/upload-artifact@v4 - with: - name: research-artifacts - path: | - .note-artifacts/research.md - .note-artifacts/research_trace.json + const dom = new JSDOM(html, { url: finalUrl }); + const reader = new Readability(dom.window.document); + const article = reader.parse(); - write: - name: Write (Claude Sonnet 4.0) - needs: research - runs-on: ubuntu-latest - 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 }} - outputs: - title: ${{ steps.collect.outputs.title }} - draft_json_b64: ${{ steps.collect.outputs.draft_json_b64 }} - steps: - - name: Checkout - uses: actions/checkout@v4 + if (!article || !article.content) { + console.error('Readability failed to extract content'); + process.exit(1); + } - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' + const inputTags = (process.env.INPUT_TAGS || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); - - name: Install AI SDK - run: | - npm init -y - npm i ai @ai-sdk/anthropic + const attributionHtml = + `

出典: ${finalUrl}


`; - - 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 + const out = { + title: (article.title || '').trim() || '(タイトル取得失敗)', + body_html: attributionHtml + article.content, + tags: inputTags, + source_url: url + }; - - name: Generate draft (title/body/tags) - 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-20250514'; - 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 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)); + fs.writeFileSync('final.json', JSON.stringify(out, null, 2)); + console.log('Extracted:', out.title); EOF - node write.mjs + node fetch.mjs - - name: Collect draft - id: collect + - name: Verify extracted files 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 + ls -la + test -f final.json && echo "final.json exists" || (echo "final.json missing" && exit 1) - - name: Upload draft artifact + - name: Upload extracted artifact uses: actions/upload-artifact@v4 with: - name: draft-artifact - path: .note-artifacts/draft.json - - factcheck: - name: Fact-check (Tavily) - needs: write - runs-on: ubuntu-latest - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} - TITLE: ${{ needs.write.outputs.title }} - 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 - 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-20250514'; - 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)); - } + name: extracted-article + if-no-files-found: error + path: | + final.json - await main(); - EOF - node factcheck.mjs - - name: Upload fact-check artifact - uses: actions/upload-artifact@v4 - with: - name: final-artifact - path: .note-artifacts/final.json - name: Collect final 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) + title=$(node -e "console.log(JSON.parse(require('fs').readFileSync('final.json','utf8')).title)") + b64=$(base64 -w 0 final.json 2>/dev/null || base64 final.json) echo "title<> $GITHUB_OUTPUT echo "$title" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT @@ -468,12 +155,12 @@ jobs: post: name: Post to note.com (Playwright) - needs: factcheck + needs: fetch if: ${{ github.event.inputs.dry_run != 'true' }} runs-on: ubuntu-latest env: IS_PUBLIC: ${{ github.event.inputs.is_public }} - STATE_JSON: ${{ secrets.NOTE_STORAGE_STATE_JSON }} + STATE_B64: ${{ secrets.NOTE_STORAGE_STATE_B64 }} START_URL: https://editor.note.com/new outputs: final_url: ${{ steps.publish.outputs.published_url || steps.publish.outputs.draft_url }} @@ -489,15 +176,16 @@ jobs: - name: Install Playwright run: | npm init -y - npm i playwright marked + npm i playwright 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) + test -n "$STATE_B64" || (echo "ERROR: NOTE_STORAGE_STATE_B64 secret is not set" && exit 1) mkdir -p "$RUNNER_TEMP" - echo "$STATE_JSON" > "$RUNNER_TEMP/note-state.json" + echo "$STATE_B64" | base64 -d > "$RUNNER_TEMP/note-state.json" + node -e "JSON.parse(require('fs').readFileSync('$RUNNER_TEMP/note-state.json','utf8')); console.log('storageState JSON OK')" echo "STATE_PATH=$RUNNER_TEMP/note-state.json" >> $GITHUB_OUTPUT - name: Ensure jq (post) @@ -510,7 +198,7 @@ jobs: - name: Restore final id: draft env: - FINAL_B64: ${{ needs.factcheck.outputs.final_b64 }} + FINAL_B64: ${{ needs.fetch.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 @@ -523,272 +211,374 @@ jobs: TITLE: ${{ steps.draft.outputs.TITLE }} TAGS: ${{ steps.draft.outputs.TAGS }} STATE_PATH: ${{ steps.state.outputs.STATE_PATH }} + run: | - # 本文は後続スクリプト内でMarkdownリンク→素URL化などの前処理を行う + : > post.log 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 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())}`; } - 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 sanitizeTitle(t) { + let s = String(t || '').trim(); + s = s.replace(/^#+\s*/, ''); + s = s.replace(/^"+|"+$/g, '').replace(/^'+|'+$/g, ''); + if (!s) s = 'タイトル(自動生成)'; + return s; } - 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}`;} + + // HTMLを「本文として安全に入るテキスト」に変換(最小限) + function htmlToText(html) { + let s = String(html || ''); + // br / p / li / hr を改行に寄せる + s = s.replace(/<\s*br\s*\/?\s*>/gi, '\n'); + s = s.replace(/<\s*\/p\s*>/gi, '\n\n'); + s = s.replace(/<\s*p(\s+[^>]*)?>/gi, ''); + s = s.replace(/<\s*\/li\s*>/gi, '\n'); + s = s.replace(/<\s*li(\s+[^>]*)?>/gi, '・'); + s = s.replace(/<\s*hr\s*\/?\s*>/gi, '\n\n---\n\n'); + + // aタグは「テキスト (URL)」にする(軽い) + s = s.replace(/]*href="([^"]+)"[^>]*>(.*?)<\/a>/gi, (_, href, text) => { + const t = String(text || '').replace(/<[^>]+>/g, '').trim(); + const u = String(href || '').trim(); + if (!t) return u; + return `${t} (${u})`; }); + + // それ以外のタグを落とす + s = s.replace(/<[^>]+>/g, ''); + + // HTMLエンティティ軽く戻す + s = s + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + + // 連続空白/改行を整形 + s = s.replace(/\r/g, ''); + s = s.replace(/[ \t]+\n/g, '\n'); + s = s.replace(/\n{3,}/g, '\n\n'); + return s.trim() + '\n'; } - 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 rawFinal = JSON.parse(fs.readFileSync('final.json', 'utf8')); + const rawBodyHtml = String(rawFinal.body_html || ''); + if (!rawBodyHtml.trim()) { + console.error('body_html is empty in final.json'); + process.exit(1); + } + + const TITLE = sanitizeTitle(rawTitle); + const BODY_TEXT = htmlToText(rawBodyHtml); + console.log('DEBUG_BODY_TEXT_LEN=', BODY_TEXT.length); + console.log('DEBUG_BODY_TEXT_HEAD=', BODY_TEXT.slice(0, 200)); + + + const ssDir = path.join(os.tmpdir(), 'note-screenshots'); + fs.mkdirSync(ssDir, { recursive: true }); + const SS_PATH = path.join(ssDir, `note-post-${nowStr()}.png`); + + let browser, context, page; + + try { + browser = await chromium.launch({ + headless: true, + args: [ + '--lang=ja-JP', + '--disable-blink-features=AutomationControlled', + '--no-sandbox', + ], + }); + + context = await browser.newContext({ + storageState: STATE_PATH, + locale: 'ja-JP', + viewport: { width: 1365, height: 900 }, + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }); + + await context.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); + + await context.tracing.start({ screenshots: true, snapshots: true, sources: true }); + + page = await context.newPage(); + page.setDefaultTimeout(180000); + + page.on('console', msg => console.log('BROWSER_CONSOLE:', msg.type(), msg.text())); + page.on('pageerror', err => console.log('BROWSER_PAGEERROR:', err.message)); + page.on('requestfailed', req => console.log('REQ_FAILED:', req.url(), req.failure()?.errorText)); + + const rawState = fs.readFileSync(STATE_PATH, 'utf8'); + console.log('DEBUG_STATE_BYTES=', rawState.length); + + const cookies0 = await context.cookies(); + const noteCookies0 = cookies0.filter(c => (c.domain || '').includes('note.com')); + console.log('DEBUG_NOTE_COOKIE_COUNT(before goto)=', noteCookies0.length); + console.log('DEBUG_NOTE_COOKIE_NAMES(before goto)=', noteCookies0.map(c => c.name).slice(0, 30).join(',')); + + // 認証確認(200が返るならログインは通ってる) + const resp = await context.request.get('https://note.com/api/v2/current_user'); + console.log('DEBUG_current_user_status=', resp.status()); + const txt = await resp.text(); + console.log('DEBUG_current_user_body_head=', txt.slice(0, 200)); + + await page.goto(START_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); + + console.log('DEBUG_URL_AFTER_GOTO=' + page.url()); + + // /new から /notes/.../edit へ遷移するのを待つ + const urlNow = page.url(); + const isEditUrl = /\/notes\/[^/]+\/edit\/?/i.test(urlNow); + if (!isEditUrl) { + await page.waitForURL(/\/notes\/[^/]+\/edit\/?/i, { timeout: 60000 }).catch(() => {}); + } + console.log('DEBUG_URL_AFTER_WAIT_EDIT=' + page.url()); + + await page.screenshot({ path: SS_PATH, fullPage: true }); + console.log('SCREENSHOT=' + SS_PATH); + + // ===== 本文エディタ(ProseMirror)を特定 ===== + const bodyCandidates = [ + // data-testid 系(noteが持ってることが多い) + '[data-testid*="editor" i] .ProseMirror[contenteditable="true"]', + '[data-testid*="body" i] .ProseMirror[contenteditable="true"]', + + // よくある構造 + 'main .ProseMirror[contenteditable="true"]', + 'article .ProseMirror[contenteditable="true"]', + + // 最終手段:ProseMirror複数なら「2つ目以降」を本文とみなす + '.ProseMirror[contenteditable="true"]', + ]; + + let bodyBox = null; + + // 1) セレクタ候補を上から試す + for (const sel of bodyCandidates) { + const loc = page.locator(sel); + const n = await loc.count().catch(() => 0); + if (n === 0) continue; + + // selが広すぎる場合を考慮して「画面内で大きい要素」を本文とみなす + let best = null; + let bestArea = 0; + for (let i = 0; i < n; i++) { + const el = loc.nth(i); + if (!(await el.isVisible().catch(() => false))) continue; + const box = await el.boundingBox().catch(() => null); + if (!box) continue; + const area = box.width * box.height; + if (area > bestArea) { + bestArea = area; + best = el; + } + } + if (best) { bodyBox = best; break; } + } + + if (!bodyBox) { + fs.writeFileSync('debug-body.html', await page.content()); + await page.screenshot({ path: 'debug-body.png', fullPage: true }); + throw new Error('Body editor not found'); + } + + await bodyBox.waitFor({ state: 'visible', timeout: 60000 }); + console.log('DEBUG_BODY_BOX_FOUND=', await bodyBox.evaluate(el => el.className)); + + + // ===== タイトルを特定(あなたのスクショでは本文とは別要素)===== + const titleCandidates = [ + // input/textarea 系 + 'input[placeholder*="タイトル"]', + 'textarea[placeholder*="タイトル"]', + 'input[name*="title" i]', + 'textarea[name*="title" i]', + + // aria + 'input[aria-label*="タイトル"]', + 'textarea[aria-label*="タイトル"]', + + // data-testid 系(noteが変えても拾いやすい) + '[data-testid*="title" i] input', + '[data-testid*="title" i] textarea', + '[data-testid*="title" i]', + + // 最終手段:画面上部の見出しっぽい入力 + 'input[type="text"]', + ]; + + let titleEl = null; + for (const sel of titleCandidates) { + const loc = page.locator(sel).first(); + if (await loc.isVisible().catch(() => false)) { titleEl = loc; break; } + } + + if (!titleEl) { + fs.writeFileSync('debug-title.html', await page.content()); + await page.screenshot({ path: 'debug-title.png', fullPage: true }); + throw new Error('Title element not found (candidates exhausted)'); + } + + const titleTag = await titleEl.evaluate(el => el.tagName.toLowerCase()); + if (titleTag === 'input' || titleTag === 'textarea') { + await titleEl.fill(TITLE); + } else { + await titleEl.click({ force: true }); + await page.keyboard.press('Control+A'); + await page.keyboard.type(TITLE, { delay: 5 }); + } + + // ===== 本文入力(HTMLではなくテキストで入れる:最重要)===== + await bodyBox.click({ force: true }); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + + // ProseMirrorは insertHTML が不安定なので insertText で流し込む + // ===== 本文入力 ===== + await bodyBox.click({ force: true }); + + // 全消し + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + + // execCommand(insertText)で分割投入(ProseMirrorが拾いやすい) + async function insertByExecCommand(text) { + const chunk = 800; + for (let i = 0; i < text.length; i += chunk) { + const part = text.slice(i, i + chunk); + + await page.evaluate((t) => { + // activeElement に対して insertText + document.execCommand('insertText', false, t); + }, part); + + if (i % (chunk * 20) === 0) await page.waitForTimeout(50); + } + } + + // フォーカスを強制(念のため) + await bodyBox.evaluate(el => el.focus()); + + await insertByExecCommand(BODY_TEXT); + + await page.waitForTimeout(1500); + + if (!IS_PUBLIC) { + // 下書き保存(ボタンが見えない場合があるので複数候補) + const saveBtn = page.locator('button:has-text("下書き保存"), button:has-text("下書きを保存"), [aria-label*="下書き保存"]').first(); + if (await saveBtn.count()) { + await saveBtn.click({ force: true }).catch(() => {}); + } + await page.waitForTimeout(2000); + + await page.screenshot({ path: SS_PATH, fullPage: true }); + console.log('DRAFT_URL=' + page.url()); + console.log('SCREENSHOT=' + SS_PATH); + return; // main() 内なので合法 } - }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); - } + const bodyTextNow = await bodyBox.evaluate(el => el.innerText || ''); + console.log('DEBUG_BODY_AFTER_LEN=', bodyTextNow.trim().length); + if (bodyTextNow.trim().length < 20) { + await page.screenshot({ path: 'debug-body-after.png', fullPage: true }); + throw new Error('Body seems not inserted (too short). Check selectors.'); + } - 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(()=>{}); } + + // ===== 公開フロー(必要なら後で調整)===== + const proceed = page.locator('button:has-text("公開に進む")').first(); + await proceed.waitFor({ state: 'visible' }); + 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); + } + } + + const publishBtn = page.locator('button:has-text("投稿する")').first(); + await publishBtn.waitFor({ state: 'visible' }); + await publishBtn.click({ force: true }); + + await page.waitForTimeout(5000); await page.screenshot({ path: SS_PATH, fullPage: true }); - console.log('DRAFT_URL=' + page.url()); + + console.log('PUBLISHED_URL=' + page.url()); console.log('SCREENSHOT=' + SS_PATH); - process.exit(0); - } - - 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); } + } finally { + try { await context?.tracing?.stop({ path: 'trace.zip' }); } catch {} + try { await page?.close(); } catch {} + try { await context?.close(); } catch {} + try { await browser?.close(); } catch {} } - - 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{} } + + // ここで実行(returnもOK) + main().catch(e => { + console.error(e); + process.exit(1); + }); EOF - node post.mjs | tee post.log + + node post.mjs 2>&1 | tee -a post.log + + mkdir -p note-screenshots || true + cp -r /tmp/note-screenshots/* note-screenshots/ 2>/dev/null || true + 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 != '' }} - uses: actions/upload-artifact@v4 - with: - name: note-screenshot - path: ${{ steps.publish.outputs.screenshot }}