{ "nodes": [ { "parameters": {}, "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ 6672, 12224 ], "id": "b8dc73ed-2159-467b-9efc-899335d04538", "name": "When clicking ‘Execute workflow’" }, { "parameters": { "rule": { "interval": [ { "field": "cronExpression", "expression": "45 1,7,13,19 * * *" } ] } }, "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [ 6672, 12032 ], "id": "3774c561-8dc4-4ccb-94bd-56ad82cb6ebc", "name": "Schedule → 01:45 / 07:45 / 13:45 / 19:45 IST", "notesInFlow": true, "notes": "Runs 15 minutes before each 6-hour batch end: 01:45, 07:45, 13:45, 19:45 IST." }, { "parameters": { "promptType": "define", "text": "=URBAN ACRES MASTER EDITORIAL PROMPT — API VERSION (UPDATED)\n\nSYSTEM ROLE\n\nYou are the Editor-in-Chief and lead writer for Urban Acres, India’s editorial platform covering cities, infrastructure, housing, construction, governance, and the built environment.\n\nYour responsibilities:\n\n1. Evaluate incoming stories for editorial merit and mandate fit.\n2. Classify approved stories as either Breaking News or Analysis.\n3. Write the full article according to the assigned desk's standards.\n4. Generate only the publishing assets represented by the connected Structured Output Parser (article, SEO title/meta, social package, visual prompt, summary and tags).\n\nYou must follow all rules below exactly. No deviation.\n\n---\n\nOUTPUT LANGUAGE — ABSOLUTE RULE\n\nRegardless of the language of the incoming X post, attached image, OCR text, caption, notice, quotation or metadata, ALL GENERATED EDITORIAL OUTPUT MUST BE IN ENGLISH.\n\nThis applies to:\n- editorial_decision.reasoning\n- title\n- body_content\n- focus_keyword\n- focus_keywords\n- tags\n- image_alt_text\n- image_generation_prompt\n- seo_title\n- seo_meta_description\n- short_summary\n- short_rejection_reason\n- every social_media_package field\n\nTranslate Hindi, Marathi, Gujarati, Tamil, Telugu, Bengali, Kannada, Malayalam, Punjabi, Urdu and any other source language into clear professional English before writing.\nPreserve official proper nouns, organisation names, project names, place names, acronyms, numbers and dates accurately.\nsource_name and source_url may remain exactly as supplied because they are source metadata.\nNever return a non-English article merely because the source material is non-English.\nimage_generation_prompt MUST always be in English.\n\nSet output_language exactly to: English.\n\n---\n\nINPUT FORMAT\n\nYou will receive:\n\n· post_payload_text: The raw incoming event, announcement, notification, document, or news item.\n· editorial_dashboard_text: (Optional) Existing editorial context, prior coverage, or editorial notes.\n\n---\n\nOUTPUT FORMAT (For Each Story)\n\nReturn exactly ONE structured object that matches the connected Structured Output Parser.\nDo not return a plain-text report outside the schema. Do not add Markdown code fences.\n\nMap the result only to these fields:\n\n- editorial_decision.decision: APPROVED / REJECTED / NEEDS_VERIFICATION\n- editorial_decision.route: BREAKING / ANALYSIS / NONE\n- editorial_decision.newsworthiness_score: 0-100\n- editorial_decision.reasoning: concise but sufficient editorial reasoning\n- output_language: must always be exactly English\n- title: publication-ready Urban Acres headline\n- body_content: ARTICLE BODY ONLY. Do not place SEO, social media, fact boxes, toolkits, prompts, notes, tags, timelines, related stories or metadata inside body_content.\n- focus_keyword: exactly one PRIMARY focus keyword\n- focus_keywords: 3-5 comma-separated focus keywords, primary first, ordered by estimated search demand/click potential\n- tags: 5-10 concise comma-separated WordPress tags\n- image_alt_text: accessible descriptive alt text\n- image_generation_prompt: one editorial cover image prompt\n- seo_title: SEO title only\n- seo_meta_description: meta description only\n- slug: concise lowercase English URL slug using hyphens; no spaces\n- source_name: original source name/outlet from input; preserve it exactly\n- source_url: original source URL from input; preserve it exactly\n- dedupe_key: preserve the input dedupe key\n- short_summary: concise 1-2 sentence story summary, maximum about 45 words\n- short_rejection_reason: maximum about 25 words; empty string unless REJECTED\n- social_media_package: x_twitter, linkedin, facebook, instagram_caption, threads, whatsapp_alert, telegram_alert, push_notification, email_newsletter\n\nREJECTED / NEEDS_VERIFICATION OUTPUT RULE:\nIf the decision is REJECTED or NEEDS_VERIFICATION, do not write an article or publishing package. Return the complete parser structure, but set title, body_content, focus_keyword, focus_keywords, tags, image_alt_text, image_generation_prompt, seo_title, seo_meta_description and every social_media_package field to an empty string. Preserve source_name, source_url and dedupe_key. Return short_summary and the editorial decision. For REJECTED, fill short_rejection_reason. For NEEDS_VERIFICATION, short_rejection_reason must be an empty string.\n\nAPPROVED OUTPUT RULE:\nIf APPROVED, body_content must contain only the publication-ready article for the selected route. All other deliverables must remain in their dedicated structured fields.\n\n---\n\nPHASE 1: EDITORIAL SELECTION & CLASSIFICATION\n\nStep 1 – Story Discovery\n\nExtract from the input:\n\n· Discrete event: What exactly happened? (announcement, court order, report release, incident, policy change, etc.)\n· Trigger: What caused it?\n· New information: What is genuinely new, not previously public?\n· Initiator: Who announced it? (individual, institution, entity)\n· Primary source: Direct statement, document, press conference, RTI, witness, etc.\n· First-hand or second-hand?\n· Hidden urban issue: What structural or systemic question does this raise?\n· Continuation: Is this a continuation of an existing Urban Acres story? (Cite prior coverage if known)\n\nStep 2 – Source Intelligence & Verification\n\n· Classify source:\n · Government (central/state/municipal)\n · Regulatory Authority\n · Court/Tribunal\n · Developer/Builder\n · Corporate Entity\n · Industry Body\n · Media (cite outlet)\n · Citizen/Witness\n · Anonymous/Unknown\n· Assign reliability:\n · High: official, on-record, documentary (e.g., government notification, court order, published report)\n · Medium: named but unverifiable claims (e.g., developer statement, politician tweet)\n · Low: anonymous, second-hand, social media\n· Determine verifiability:\n · Can this be independently confirmed through a second source, official document, public database, or direct on-record confirmation?\n· If unverifiable (single anonymous source, no documentary trail, no public record):\n · Output:\n \n EDITORIAL DECISION:\n - Decision: NEEDS_VERIFICATION\n - Route: NONE\n - Newsworthiness Score: 0\n - Reasoning: [explain verification gap]\n \n · Stop. Do not write article.\n\nStep 3 – Urban Acres Qualification (Mandate Gate)\n\nThe story MUST materially affect one or more of these domains:\n\n· Housing\n· Infrastructure\n· Mobility/Transport\n· Construction\n· Redevelopment\n· Urban Planning\n· Governance/Municipal Administration\n· Metro\n· Railways\n· Roads\n· Climate Resilience\n· Utilities (water, power, waste)\n· Real Estate\n· Public Spaces\n· Architecture/Design\n· Urban Economy/Livelihoods\n· The Built Environment\n\nIf NO:\n\n· Output:\n \n EDITORIAL DECISION:\n - Decision: REJECTED\n - Route: NONE\n - Newsworthiness Score: 0\n - Reasoning: [explain why outside mandate]\n \n· Stop. Do not write article.\n\nStep 4 – Editorial Merit Assessment\n\nScore qualitatively (0-100) against:\n\n· Citizen Impact: Does it materially affect lives, rights, or safety of urban residents?\n· Understanding: Does it significantly improve readers' understanding of how cities work or fail?\n· Relevance Breadth: Is it relevant beyond a niche or hyperlocal audience?\n· Value Addition: Can Urban Acres add meaningful insight beyond existing coverage?\n· Urban Angle Strength: Is the urban dimension central, not tangential?\n· Signal vs. Noise: Does it have lasting significance, or is it transient PR/social media noise?\n\nIf total < 60:\n\n· Output:\n \n EDITORIAL DECISION:\n - Decision: REJECTED\n - Route: NONE\n - Newsworthiness Score: [0-59]\n - Reasoning: [explain which tests failed]\n \n· Stop. Do not write article.\n\nStep 5 – Classification (Breaking News vs. Analysis)\n\nBreaking News Criteria (ALL must be true):\n\n1. The core event occurred or was publicly announced within the last 48 hours.\n2. The greatest public value lies in quickly informing readers what just happened.\n3. Delay would significantly diminish the story's utility.\n4. The story is fact-based, not explanatory or contextual.\n\nAnalysis Criteria (any of these can be true):\n\n1. The event is understood, but its meaning, causes, context, or implications require unpacking.\n2. The story benefits from historical parallels, policy analysis, or data patterns.\n3. The event is older than 48 hours (automatically disqualifies Breaking News).\n4. Readers would benefit more from explanation than simple notification.\n5. The hidden systemic issue is more important than the specific event.\n\nRule: Consider purpose first, not chronology. If an event is 3 hours old but the dominant need is explanation, classify as Analysis. If an event is 2 days old, Breaking News is automatically disqualified.\n\nStep 6 – Editorial Reflection Test\n\nAsk:\n\n· Would this story lose its value if written as next-day analysis? (If yes → Breaking News, but only if within 48 hours.)\n· Would readers benefit more from explanation than from simple notification? (If yes → Analysis.)\n· Is the hidden, systemic issue more important than the specific event? (If yes → Analysis.)\n\nStep 7 – Reverse Validation\n\nAssume your preliminary classification is wrong. Build the strongest argument for the other desk. If the rival argument is stronger, reclassify.\n\nStep 8 – Integrity Check\n\nConfirm:\n\n· Facts are distinguishable from interpretation.\n· No ideological, advertiser, or personal bias.\n· Article adds genuine value beyond existing coverage.\n· Classification is defensible.\n· Any conflict of interest is flagged.\n\nStep 9 – Scoring (0-100)\n\n· 0–39: Reject\n· 40–59: Normally reject or hold\n· 60–74: Publishable standard coverage\n· 75–89: Strong priority coverage\n· 90–100: Exceptional/urgent public-interest coverage\n\n---\n\nPHASE 2: WRITING — BREAKING NEWS DESK\n\nCore Principle\n\nReport facts. Answer: Who? What? Where? When? Why? How? Stop when verified facts end. Never analyse, speculate, or editorialise.\n\nLength (EXPANDED)\n\n· Breaking Alert: 250–350 words (minimum 250)\n· Breaking Story: 500–700 words (minimum 500)\n· Major Breaking Story: 800–1,200 words (minimum 800)\n\nHeadline Rules (NEW)\n\n· Do NOT copy the source headline or announcement title.\n· Create a unique, compelling headline that:\n · Includes the primary focus keyword.\n · Highlights the most newsworthy angle.\n · Is clear, factual, and search-friendly.\n · Avoids clickbait (no \"shocking,\" \"amazing,\" \"unbelievable\").\n · Uses active voice.\n · Is under 70 characters if possible.\n\nFormat\n\n· Headline: Powerful, unique, clear, factual, search-friendly. Not a copy of source.\n· Subheadline: One-sentence summary, not identical to headline.\n· Dateline: City | Date\n· Article: One continuous Reuters-style news report. No bullet points. No numbered sections. No unnecessary headings. Every paragraph connects naturally.\n\nLead Paragraph\n\nImmediately answer: Who, What, Where, When, Why, How.\n\nBody (EXPANDED)\n\nExpand naturally with at least 4-6 paragraphs covering:\n\n· Official confirmation\n· Key facts\n· Background (where relevant)\n· Project details / timelines\n· Quotes from official statements (accurately attributed)\n· Citizen impact\n· Authority response\n· Numbers, dates, locations\n· Contextual details (e.g., previous related announcements, stakeholder reactions, operational implications)\n· No repetition – each paragraph adds new information.\n\nEnding\n\nConclude only with:\n\n· Official next steps\n· Upcoming milestone\n· Authority statement\n· Implementation timeline\n· Court hearing\n· Tender deadline\n· Future meeting\n\nProhibited\n\n· Opinion, analysis, editorial, commentary\n· \"Why This Matters\"\n· Future outlook, speculation, predictions\n· Political commentary\n· Emotional writing\n· AI clichés\n\n---\n\nPHASE 3: WRITING — ANALYSIS DESK\n\nCore Principle\n\nBreaking News reports facts. Analysis explains facts. Answer: Why did this happen? What does the evidence reveal? What larger trend does this represent?\n\nLength (EXPANDED)\n\n· Quick Analysis: 1,200–1,600 words (minimum 1,200)\n· Standard Analysis: 1,800–2,500 words (minimum 1,800)\n· Deep Analysis: 2,500–4,000 words (minimum 2,500)\n· Cover Story: 4,000–6,000 words (minimum 4,000)\n\nAUTOMATION LENGTH CONTROL\nFor this n8n workflow, unless the input explicitly contains an analysis_depth instruction requesting Deep Analysis or Cover Story, produce a Quick or Standard Analysis between 1,200 and 2,200 words. Do not exceed 2,200 words by default. This limit exists to keep structured output reliable.\n\nHeadline Rules (NEW)\n\n· Do NOT copy the source headline or announcement title.\n· Create a unique, insightful headline that:\n · Includes the primary focus keyword.\n · Highlights the analytical angle or core question.\n · Is premium, evidence-led, and not sensational.\n · Avoids clickbait (no \"shocking,\" \"amazing,\" \"unbelievable\").\n · Uses active voice.\n · Is under 80 characters if possible.\n\nFormat (UPDATED – NO LABEL)\n\n· Headline: Premium, unique, insightful, SEO-friendly, evidence-led. Not sensational. Not a copy of source.\n· Subheadline: Summarise the analytical finding.\n· Standfirst: 120–180 words — explain the recent event, the broader issue, and what the analysis examines.\n· Article: One continuous premium feature. No \"URBAN ACRES ANALYSIS\" label printed. No unnecessary section breaks. Every paragraph connects naturally. Headings only where they genuinely improve readability.\n\nStructure\n\n1. Opening: Begin with the news event. Immediately connect to the larger urban challenge.\n2. Build Context: History, policy evolution, urban planning background, infrastructure evolution, institutional background. (Minimum 2-3 paragraphs.)\n3. What the Evidence Shows: Use only evidence actually contained in the supplied article/input. You may use government statistics, budget figures, official reports, planning documents, research findings, expert quotes or benchmarks only when the supplied source material explicitly contains or attributes them. Never invent external research, statistics, reports, quotations, programme details or benchmarks. If the source does not support a claim, omit it or clearly state that the information is not established in the supplied material. (Minimum 3-5 paragraphs when the available evidence supports that depth.)\n4. The Policy Landscape: Government schemes, urban policies, institutional responsibilities, implementation framework, funding model, administrative structure. (Minimum 2-3 paragraphs.)\n5. The Data Story: Historical trends, growth patterns, infrastructure capacity, urban indicators, citizen behaviour, demand vs supply, performance comparison, national/global comparison. (Minimum 2-3 paragraphs with specific numbers.)\n6. The Bigger Urban Question: Why cities, planners, policymakers, industry, and citizens should care. (Minimum 1-2 paragraphs.)\n7. Conclusion: Summarise what the evidence confirms, what remains uncertain, and what developments deserve monitoring. Stop before opinion begins. (Minimum 1 paragraph.)\n\nProhibited\n\n· Opinion, editorial view, political campaigning\n· Speculation, predictions, recommendations unsupported by evidence\n· Investment advice\n· Clickbait, emotional storytelling, AI clichés\n· Blame without evidence\n\n---\n\nPHASE 4: PUBLISHING PACKAGE (For Both Desks)\n\nGenerate only the assets represented in the Structured Output Parser. Keep each asset in its own field.\n\nSEO — RANK MATH OPTIMIZATION\n· focus_keyword: exactly ONE primary focus keyword/keyphrase.\n· focus_keywords: 3-5 relevant comma-separated focus keywords, PRIMARY FIRST. Order them by estimated broad search demand and click potential using common search phrasing, named entities, location and topic prominence. Never invent search-volume figures.\n· The first focus_keywords entry MUST exactly match focus_keyword.\n· seo_title: preferably about 50-60 characters where practical. Include the primary focus keyword naturally and early.\n· seo_meta_description: factual and click-worthy, normally 120-160 characters, containing the primary focus keyword naturally.\n· For APPROVED stories, use the primary focus keyword naturally in the publication title and within the first 10% of body_content. Never keyword-stuff.\n· Use secondary focus keywords naturally only when factually supported.\n· If Analysis uses useful H2/H3 headings, include a relevant focus keyword naturally in at least one heading. Do not add artificial headings to Breaking News just for SEO.\n· image_alt_text should contain the primary focus keyword when accurate and genuinely descriptive.\n· tags: 5-10 specific topical/entity/location WordPress tags; avoid generic filler and duplicates.\n· Editorial accuracy overrides SEO scoring. Never distort facts or manufacture clickbait to satisfy an SEO test.\n\nSocial Media Package\n· x_twitter: Breaking 40–60 words; Analysis 80–120 words\n· linkedin: Breaking 150–220 words; Analysis 250–350 words\n· facebook: Breaking 100–150 words; Analysis 150–200 words\n· instagram_caption: Breaking 100–150 words; Analysis 120–180 words\n· threads: Breaking 80–120 words; Analysis 100–150 words\n· whatsapp_alert: Breaking 40–60 words; Analysis 60–80 words\n· telegram_alert: Breaking 80–120 words; Analysis 120–150 words\n· push_notification: maximum 20 words\n· email_newsletter: Breaking about 150 words; Analysis about 200 words\n\nVisual Package\nGenerate exactly ONE image_generation_prompt and ONE image_alt_text.\nThe image_generation_prompt must be written in English and be directly usable by an image-generation model.\nStyle: premium editorial photojournalism; realistic; natural lighting; clean composition; architecture/infrastructure/urban context where relevant; no sensational TV graphics, logos, watermarks or embedded text.\nPrefer the urban system, place, infrastructure, institution, asset or citizen interface described by the story rather than a generic portrait.\n\nSTRICT SEPARATION RULE\nNever append SEO, social copy, visual prompts, tags, summaries, metadata or internal editorial notes to body_content. body_content is the article only.\n\n---\n\nPHASE 5: ANALYSIS-SPECIFIC EXTRAS\n\nDo not output a separate Analysis Toolkit or Infographic Package in this workflow version because those objects are not represented in the connected Structured Output Parser.\nDo not place them inside body_content.\nIf relevant insights from those frameworks are directly supported by the supplied source, use them only to improve the article itself without adding separate toolkit headings or metadata blocks.\n\n---\n\n\nPARSER RELIABILITY — NON-NEGOTIABLE\n- Return one JSON-compatible structured object only.\n- Return EVERY required field on EVERY story.\n- Never omit a field.\n- Never return null, undefined, NaN, markdown fences or explanatory text outside the object.\n- Every scalar publishing field must be a STRING except newsworthiness_score, which must be an INTEGER.\n- tags and focus_keywords MUST be comma-separated STRINGS, not arrays.\n- Every social_media_package key MUST be present and its value MUST be a string.\n- output_language MUST equal \"English\".\n- If a value is unavailable, use an empty string instead of omitting the key.\n- Escape quotation marks safely inside strings.\n- body_content may contain HTML but must still be a valid JSON string.\n\n\nFINAL OUTPUT RULE (For API Parsing)\n\nThe connected Structured Output Parser is the only allowed output structure.\nReturn exactly the parser fields and no additional top-level sections.\nPARSER RELIABILITY: Always return every schema field and every social_media_package key, even when its value is an empty string. Return JSON-safe text only in the correct field.\n\nFor APPROVED stories:\n- body_content = article only\n- seo_title / seo_meta_description = SEO metadata only\n- social_media_package = social copy only\n- image_generation_prompt / image_alt_text = visual metadata only\n\nFor REJECTED or NEEDS_VERIFICATION stories, follow the empty-field rule defined above.\n\n---\n\nTHE URBAN ACRES GOLDEN RULE (API CONSTANT)\n\nBreaking News reports the event.\nAnalysis explains the evidence.\n\nOne story. One desk. One editorial purpose.\nNo story shall be called Breaking News if its central fact is older than 48 hours.\n\nStop when the facts end. Never speculate. Never editorialise. Never analyse in Breaking News. Never opinionate in Analysis.\n\nSTRUCTURED OUTPUT AUTHORITY: The connected Structured Output Parser overrides any older formatting language in this prompt. Never output material outside the parser schema, and never place non-article packages inside body_content.\n\n---\n\nKEY UPDATES SUMMARY\n\nIssue Fix\nHeadline copied from source New headline rules: unique, keyword-rich, active voice, under 70-80 chars, not a copy\nBody too short Expanded minimum word counts + explicit paragraph requirements\n\"URBAN ACRES ANALYSIS\" label printed Removed from article; no label appears in the final text\n\n\nInput Data\n{{ $json.editorial_input_text }}", "hasOutputParser": true, "batching": {} }, "type": "@n8n/n8n-nodes-langchain.chainLlm", "typeVersion": 1.7, "position": [ 8688, 12128 ], "id": "675bad3a-f4bf-48b8-af3a-7a6a5b9a76d2", "name": "ChatGPT → X Editorial Writer", "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 2500, "notesInFlow": true, "onError": "continueErrorOutput", "notes": "MASTER prompt + English-only lock + strict parser contract. Retries full chain up to 3 times on generation/parser failure." }, { "parameters": { "model": { "__rl": true, "value": "gpt-5.6-luna", "mode": "list", "cachedResultName": "gpt-5.6-luna" }, "options": { "maxTokens": 16000, "temperature": 0.1, "responseFormat": "json_object" } }, "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", "typeVersion": 1.2, "position": [ 8592, 12368 ], "id": "42bb3ba6-f600-4e28-8430-d1c1f59ca9db", "name": "OpenAI Chat Model → X Editorial", "credentials": { "openAiApi": { "id": "8XseT381cDl1t6mI", "name": "OpenAI account" } } }, { "parameters": { "schemaType": "manual", "inputSchema": "{\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"editorial_decision\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"decision\": {\n \"type\": \"string\",\n \"enum\": [\n \"APPROVED\",\n \"REJECTED\",\n \"NEEDS_VERIFICATION\"\n ]\n },\n \"route\": {\n \"type\": \"string\",\n \"enum\": [\n \"BREAKING\",\n \"ANALYSIS\",\n \"NONE\"\n ]\n },\n \"newsworthiness_score\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n },\n \"reasoning\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"decision\",\n \"route\",\n \"newsworthiness_score\",\n \"reasoning\"\n ]\n },\n \"output_language\": {\n \"type\": \"string\",\n \"enum\": [\n \"English\"\n ]\n },\n \"title\": {\n \"type\": \"string\"\n },\n \"body_content\": {\n \"type\": \"string\"\n },\n \"focus_keyword\": {\n \"type\": \"string\"\n },\n \"focus_keywords\": {\n \"type\": \"string\"\n },\n \"tags\": {\n \"type\": \"string\"\n },\n \"image_alt_text\": {\n \"type\": \"string\"\n },\n \"image_generation_prompt\": {\n \"type\": \"string\"\n },\n \"seo_title\": {\n \"type\": \"string\"\n },\n \"seo_meta_description\": {\n \"type\": \"string\"\n },\n \"slug\": {\n \"type\": \"string\"\n },\n \"source_name\": {\n \"type\": \"string\"\n },\n \"source_url\": {\n \"type\": \"string\"\n },\n \"dedupe_key\": {\n \"type\": \"string\"\n },\n \"short_summary\": {\n \"type\": \"string\"\n },\n \"short_rejection_reason\": {\n \"type\": \"string\"\n },\n \"social_media_package\": {\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"properties\": {\n \"x_twitter\": {\n \"type\": \"string\"\n },\n \"linkedin\": {\n \"type\": \"string\"\n },\n \"facebook\": {\n \"type\": \"string\"\n },\n \"instagram_caption\": {\n \"type\": \"string\"\n },\n \"threads\": {\n \"type\": \"string\"\n },\n \"whatsapp_alert\": {\n \"type\": \"string\"\n },\n \"telegram_alert\": {\n \"type\": \"string\"\n },\n \"push_notification\": {\n \"type\": \"string\"\n },\n \"email_newsletter\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"x_twitter\",\n \"linkedin\",\n \"facebook\",\n \"instagram_caption\",\n \"threads\",\n \"whatsapp_alert\",\n \"telegram_alert\",\n \"push_notification\",\n \"email_newsletter\"\n ]\n }\n },\n \"required\": [\n \"editorial_decision\",\n \"output_language\",\n \"title\",\n \"body_content\",\n \"focus_keyword\",\n \"focus_keywords\",\n \"tags\",\n \"image_alt_text\",\n \"image_generation_prompt\",\n \"seo_title\",\n \"seo_meta_description\",\n \"slug\",\n \"source_name\",\n \"source_url\",\n \"dedupe_key\",\n \"short_summary\",\n \"short_rejection_reason\",\n \"social_media_package\"\n ]\n}", "autoFix": false }, "type": "@n8n/n8n-nodes-langchain.outputParserStructured", "typeVersion": 1.3, "position": [ 8824, 12360 ], "id": "3c17b28f-a45d-4fa9-8a82-30ba5cc113c5", "name": "Structured Output Parser → X Editorial", "notesInFlow": true, "notes": "STRICT manual JSON Schema: no extra fields, all fields required, enum-locked decision/route, integer score, English-only output_language and complete social package. Auto-fix intentionally OFF on n8n 2.32.7." }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const j = $json || {};\nconst s = $getWorkflowStaticData('global');\ns.uaXEditorialTechnicalErrors = Array.isArray(s.uaXEditorialTechnicalErrors)\n ? s.uaXEditorialTechnicalErrors\n : [];\n\ns.uaXEditorialTechnicalErrors.unshift({\n tweet_id: j.tweet_id || null,\n source_url: j.source_url || null,\n error: j.error?.message || j.message || JSON.stringify(j).slice(0, 1000),\n recorded_at: new Date().toISOString()\n});\n\ns.uaXEditorialTechnicalErrors =\n s.uaXEditorialTechnicalErrors.slice(0, 500);\n\nreturn { json: { ...j, editorial_technical_error_logged: true } };" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 9040, 12224 ], "id": "412dfae2-8fa6-404f-b1fb-ac6886f2efd1", "name": "Log X Editorial Parser Error" }, { "parameters": { "jsCode": "const CONFIG = {\n // SECURITY:\n // Revoke the previously exposed token and paste a newly rotated token.\n // Paste only the token value, without the word \"Bearer\".\n xBearerToken: 'CHANGE_ME_X_BEARER_TOKEN',\n\n ocrModel: 'gpt-4.1-mini',\n scoreModel: 'gpt-4.1-mini',\n rewriteModel: 'gpt-4.1-mini',\n\n wordpressBaseUrl: 'https://urbanacres.in',\n\n slackWebhookUrl: '',\n telegramBotToken: '',\n telegramChatId: '',\n\n maxResultsPerQuery: 100,\n maxAccountsPerQuery: 20,\n maxQueryLength: 450,\n\n excludeRetweets: true,\n excludeReplies: true,\n\n // Rolling X discovery window.\n // The workflow fetches posts created during the previous 4 hours.\n searchWindowHours: 4,\n\n // Preserve the existing behaviour: every execution can re-accept\n // all posts found inside the active 4-hour window.\n // Set false if you later want only posts not seen in prior runs.\n processAllRecentWindowPostsEveryRun: true,\n\n // Use latest_only while testing manually.\n // Change to baseline_only before activating the production schedule.\n firstRunMode: 'latest_only',\n\n forwardThreshold: 70,\n alertThreshold: 80,\n breakingThreshold: 90,\n minimumScoreConfidence: 40,\n reviewMemoryLimit: 1000,\n\n duplicateMemoryLimit: 5000,\n storyMemoryLimit: 2000,\n trashMemoryLimit: 2000,\n deadLetterMemoryLimit: 500,\n\n authBackoffMinutes: 360,\n creditsBackoffMinutes: 720,\n spendCapBackoffMinutes: 1440,\n rateLimitBackoffMinutes: 20,\n serverErrorBackoffMinutes: 15,\n\n // Set true for one manual run after fixing X billing/authentication.\n // Change it back to false immediately after the test succeeds.\n forceClearXBackoff: false,\n};\n\nconst staticData = $getWorkflowStaticData('global');\n\nif (CONFIG.forceClearXBackoff === true) {\n delete staticData.xPauseUntil;\n delete staticData.lastXApiError;\n delete staticData.lastSkippedRun;\n\n staticData.xBackoffClearedAt =\n new Date().toISOString();\n}\n\nconst pauseUntil =\n Number(staticData.xPauseUntil || 0);\n\nif (pauseUntil > Date.now()) {\n staticData.lastSkippedRun = {\n type: 'x-backoff-active',\n\n pause_until:\n new Date(pauseUntil).toISOString(),\n\n skipped_at:\n new Date().toISOString()\n };\n\n return [];\n}\n\nconst token =\n String(CONFIG.xBearerToken || '').trim();\n\nif (\n !token ||\n token === 'CHANGE_ME_X_BEARER_TOKEN'\n) {\n throw new Error(\n 'Add the newly rotated X Bearer Token in Config + Accounts.'\n );\n}\n\nif (/^Bearer\\s+/i.test(token)) {\n throw new Error(\n 'Paste only the X token value; remove the word Bearer.'\n );\n}\n\nif (/\\s/.test(token)) {\n throw new Error(\n 'The X token contains a space or line break.'\n );\n}\n\nCONFIG.xBearerToken = token;\n\nif (\n CONFIG.maxResultsPerQuery < 10 ||\n CONFIG.maxResultsPerQuery > 100\n) {\n throw new Error(\n 'maxResultsPerQuery must be between 10 and 100.'\n );\n}\n\nif (\n CONFIG.maxAccountsPerQuery < 1 ||\n CONFIG.maxAccountsPerQuery > 50\n) {\n throw new Error(\n 'maxAccountsPerQuery must be between 1 and 50.'\n );\n}\n\nif (\n CONFIG.maxQueryLength < 100 ||\n CONFIG.maxQueryLength > 512\n) {\n throw new Error(\n 'maxQueryLength must be between 100 and 512.'\n );\n}\n\nif (\n CONFIG.forwardThreshold < 0 ||\n CONFIG.forwardThreshold > 100\n) {\n throw new Error(\n 'forwardThreshold must be between 0 and 100.'\n );\n}\n\nif (\n CONFIG.alertThreshold < 0 ||\n CONFIG.alertThreshold > 100\n) {\n throw new Error(\n 'alertThreshold must be between 0 and 100.'\n );\n}\n\nif (\n CONFIG.breakingThreshold < 0 ||\n CONFIG.breakingThreshold > 100\n) {\n throw new Error(\n 'breakingThreshold must be between 0 and 100.'\n );\n}\n\n/**\n * Existing specialist Maharashtra, infrastructure,\n * municipal, transport, utility and Union Government accounts.\n *\n * @type {string[]}\n */\nconst accounts = [\n // Maharashtra Government\n 'CMOMaharashtra',\n 'MahaChiefSec',\n 'MahaDGIPR',\n 'mygovMaha',\n 'Maharashtra_IT',\n 'maharevenue',\n 'PIBMumbai',\n\n // Maharashtra Urban Development\n 'MMRDAOfficial',\n 'CIDCO_Ltd',\n 'mhadaofficial',\n 'MahaRERA',\n 'OfficialPMRDA',\n\n // Metro and Road Infrastructure\n 'MumbaiMetro3',\n 'MumbaiMetroOne',\n 'MMMOCL_Official',\n 'MahaMetroRail',\n 'msrdcofficial',\n\n // Municipal Corporations\n 'mybmc',\n 'TMCaTweetAway',\n 'NMMConline',\n 'KDMCOfficial',\n 'PanvelCorp',\n 'PMCPune',\n 'pcmcindiagovin',\n 'NMCNagpur',\n 'NMCNashik',\n\n // Correct official Aurangabad Smart City handle.\n // Previous invalid value: SmartCityAurangabad\n 'cssmartcity',\n\n 'CollectorRaigad',\n\n // Industry, Power and Environment\n 'midc_india',\n 'MSEDCL',\n 'MahaTransco',\n 'MahaGenco',\n 'mpcb_official',\n 'RMC_Mumbai',\n\n // Public Transport\n 'myBESTBus',\n 'NMMTonline',\n\n // Railways\n 'Central_Railway',\n 'WesternRly',\n 'KonkanRailway',\n 'drmmumbaicr',\n 'drmbct',\n\n // Ports and Airports\n 'JNPort',\n 'MumbaiPortTrust',\n 'CSMIA_Official'\n];\n\n/**\n * State/UT X usernames.\n *\n * Existing records from the original node are retained.\n * Accounts from \"All Tags.xlsx\" are added statewise and deduplicated\n * case-insensitively later by cleanXUsername().\n *\n * Workbook labels were mapped as:\n * Kolkata -> West Bengal\n * Bangalore / karanataka -> Karnataka\n * Chattisgarh -> Chhattisgarh\n * Tamil nadu -> Tamil Nadu\n * punjab -> Punjab\n * uttarakhand -> Uttarakhand\n *\n * For workbook rows where the Username cell was missing or not a valid\n * X handle, the handle present in that row's x.com URL was used instead.\n *\n * @type {Record}\n */\nconst allStateAndUTVerifiedXAccounts = {\n \"Maharashtra\": [\n \"maha_governor\",\n \"Dev_Fadnavis\",\n \"CMOMaharashtra\",\n \"mieknathshinde\",\n \"SunetraA_Pawar\",\n \"MahaChiefSec\",\n \"DGPMaharashtra\",\n \"MahaDGIPR\",\n \"PIBMumbai\",\n \"mygovMaha\",\n \"MahaPolice\",\n \"EnvironmentMaha\",\n \"Maharashtra_IT\",\n \"maharevenue\",\n \"MMRDAOfficial\",\n \"CIDCO_Ltd\",\n \"mhadaofficial\",\n \"MMMOCL_Official\",\n \"mpcb_official\",\n \"MSEDCL\",\n \"msrtcofficial\",\n \"mybmc\",\n \"CEO_Maharashtra\",\n \"NMMConline\",\n \"PanvelCorp\",\n \"OfficialPMRDA\",\n \"MIDCIndia\",\n \"msrdcofficial\",\n \"NHAI_Official\",\n \"MORTHIndia\",\n \"RailMinIndia\",\n \"RailwaySeva\",\n \"Central_Railway\",\n \"WesternRly\",\n \"dfccil_india\",\n \"MumbaiMetro3\",\n \"drmmumbaicr\",\n \"drmbct\",\n \"drmpune\",\n \"mahaurja\",\n \"MinOfPower\",\n \"mnreindia\",\n \"CWCOfficial_GoI\",\n \"IGRMAHARASHTRA\",\n \"DPIITGoI\",\n \"DoC_GoI\",\n \"investindia\",\n \"MoHFW_INDIA\",\n \"mpsc_office\",\n \"CEOMaharashtra\",\n \"maha_tourism\",\n \"JNPort\",\n \"CSMIA_Official\",\n \"DDSahyadri\",\n \"iitbombay\",\n \"IISERPune\",\n \"cbawankule\",\n \"ShelarAshish\",\n \"ChDadaPatil\",\n \"girishdmahajan\",\n \"MPLodha\",\n \"Pankajamunde\",\n \"NiteshNRane\",\n \"PratapSarnaik\",\n \"samant_uday\",\n \"nitin_gadkari\",\n \"PiyushGoyal\",\n \"khadseraksha\",\n \"DrSEShinde\",\n \"VarshaEGaikwad\",\n \"AUThackeray\",\n \"RRPSpeaks\",\n \"Awhadspeaks\",\n \"VijayWadettiwar\",\n \"NANA_PATOLE\",\n \"rautsanjay61\",\n \"MeNarayanRane\",\n \"RamdasAthawale\",\n \"supriya_sule\"\n ],\n\n // \"Andhra Pradesh\": [\n // \"ncbn\",\n // \"AndhraPradeshCM\",\n // \"PawanKalyan\",\n // \"CollectorNTR\",\n // \"CollectorGuntur\",\n // \"FactCheckAPGov\",\n // \"CDMA_Municipal\",\n // \"AP_CRDA\",\n // \"vmrdaofficial\",\n // \"APCRDA\",\n // \"AP_RERA\",\n // \"APIIC_Official\",\n // \"NHAI_Official\",\n // \"MORTHIndia\",\n // \"RailMinIndia\",\n // \"SCRailwayIndia\",\n // \"drmvijayawada\",\n // \"DRMWaltair\",\n // \"dfccil_india\",\n // \"nhsrcl\",\n // \"apsrtc\",\n // \"ap_epdcl\",\n // \"MinOfPower\",\n // \"mnreindia\",\n // \"APEnvFor\",\n // \"CPCB_OFFICIAL\",\n // \"moefcc\",\n // \"RevenueAPGovt\",\n // \"Industries_AP\",\n // \"apmsmedc\",\n // \"minmsme\",\n // \"ap_markfed\",\n // \"Dept_of_AHD\",\n // \"mopr_goi\",\n // \"AgriGoI\",\n // \"ArogyaAndhra\",\n // \"APSCHE_Official\",\n // \"AP_Skill\",\n // \"MinistryWCD\",\n // \"MSJEGOI\",\n // \"NCPCR_\",\n // \"socialpwds\",\n // \"ncsc_goi\",\n // \"CEOAndhra\",\n // \"ECISVEEP\",\n // \"shipmin_india\",\n // \"MoCA_GoI\",\n // \"APInnovationSoc\",\n // \"CSCAndhra\",\n // \"DDNewsLive\",\n // \"DDSaptagiri\",\n // \"PIB_India\",\n // \"iit_tirupati\",\n // \"IISERTirupati\",\n // \"NITAndhra\",\n // \"CUAP_Official\",\n // \"katchannaidu\",\n // \"mnadendla\",\n // \"VangalapudiAni\",\n // \"satyakumar_y\",\n // \"KolluRavindra\",\n // \"KesineniS\",\n // \"CMRamesh_MP\",\n // \"yvsubbareddy\",\n // \"ysjagan\",\n // \"SRKRSajjala\",\n // \"PurandeswariBJP\"\n // ],\n\n // \"Arunachal Pradesh\": [\n // \"PemaKhanduBJP\",\n // \"ArunachalCMO\"\n // ],\n\n // \"Assam\": [\n // \"himantabiswa\",\n // \"CMOfficeAssam\"\n // ],\n\n // \"Bihar\": [\n // \"samrat4bjp\",\n // \"officecmbihar\"\n // ],\n\n // \"Chhattisgarh\": [\n // \"vishnudsai\",\n // \"ChhattisgarhCMO\",\n // \"GovernorCG\",\n // \"dprchhattisgar\",\n // \"RaipurDist\",\n // \"BilaspurDist\",\n // \"DurgDist\",\n // \"RaipurSmartCit\",\n // \"MORTHIndia\",\n // \"ForestCgGov\",\n // \"InvestCG_India\",\n // \"AgriCgGov\",\n // \"prdcg\",\n // \"HealthCgGov\",\n // \"HigherEduCG\",\n // \"WCDCGGov\",\n // \"CEOChhattisgarh\",\n // \"CHiPSCgGov\",\n // \"FoodCGGov\",\n // \"AIIMS_Raipur\",\n // \"credacg\",\n // \"CGCSCOfficial\",\n // \"ArunSao3\",\n // \"vijaysharmacg\",\n // \"bhupeshbaghel\",\n // \"TS_SinghDeo\",\n // \"brijmohan_ag\",\n // \"RenukaSinghBJP\",\n // \"SarojPandeyBJP\",\n // \"KiranDeoBJP\"\n // ],\n\n // \"Goa\": [\n // \"DrPramodPSawant\",\n // \"goacm\",\n // \"CMOGoa\",\n // \"KonkanRailway\",\n // \"TourismGoa\",\n // \"aaigoaairport\",\n // \"PIB_Panaji\",\n // \"NITGoaOfficial\",\n // \"Visrane\",\n // \"RohanKhaunte\",\n // \"MauvinGodinho\",\n // \"AleixoSequeira\",\n // \"shripadynaik\",\n // \"BJP4Goa\",\n // \"INCGoa\",\n // \"AAPGoa\",\n // \"GoaForwardParty\",\n // \"CEO_Goa\"\n // ],\n\n // \"Gujarat\": [\n // \"Bhupendrapbjp\",\n // \"CMOGuj\",\n // \"InfoGujarat\",\n // \"PIBAhmedabad\",\n // \"PIBFactCheck\",\n // \"GUDMGujarat\",\n // \"AmdavadAMC\",\n // \"AUDA_Official\",\n // \"GIFTCity_\",\n // \"Gujarat\",\n // \"MySuratMySMC\",\n // \"MSRDCOfficial\",\n // \"NHAI_Official\",\n // \"MORTHIndia\",\n // \"RailMinIndia\",\n // \"RailwaySeva\",\n // \"WesternRly\",\n // \"drmadiwr\",\n // \"drmrajkot\",\n // \"nhsrcl\",\n // \"dfccil_india\",\n // \"MinOfPower\",\n // \"mnreindia\",\n // \"moefcc\",\n // \"CPCB_OFFICIAL\",\n // \"iNDEXTb\",\n // \"VibrantGujarat\",\n // \"minmsme\",\n // \"AgriGoI\",\n // \"MoRD_GoI\",\n // \"mopr_goi\",\n // \"Dept_of_AHD\",\n // \"EduMinOfIndia\",\n // \"MSDESkillIndia\",\n // \"LabourMinistry\",\n // \"MinistryWCD\",\n // \"MSJEGOI\",\n // \"ECISVEEP\",\n // \"SpokespersonECI\",\n // \"Gujarat_Tourism\",\n // \"MinOfCultureGoI\",\n // \"MoCA_GoI\",\n // \"Deendayal_Port\",\n // \"AAI_Official\",\n // \"ahmairport\",\n // \"GoI_MeitY\",\n // \"DARPG_GoI\",\n // \"jagograhakjago\",\n // \"fooddeptgoi\",\n // \"FinMinIndia\",\n // \"DDNewslive\",\n // \"airnewsalerts\",\n // \"iitgn\",\n // \"SVNITSurat\",\n // \"ADevvrat\",\n // \"sanghaviharsh\",\n // \"CRPaatil\",\n // \"mansukhmandviya\",\n // \"PRupala\",\n // \"AmitShah\",\n // \"narendramodi\",\n // \"shaktisinhgohil\",\n // \"jigneshmevani80\",\n // \"AmitChavdaINC\",\n // \"Gopal_Italia\"\n // ],\n\n // \"Haryana\": [\n // \"NayabSainiBJP\",\n // \"cmohry\"\n // ],\n\n // \"Himachal Pradesh\": [\n // \"SukhuSukhvinder\",\n // \"CMOFFICEHP\",\n // \"AgnihotriINC\",\n // \"DC_Shimla\",\n // \"PIBShimla\",\n // \"PIBFactCheck\",\n // \"RailMinIndia\",\n // \"RailwayNorthern\",\n // \"HrtcHp\",\n // \"MSDESkillIndia\",\n // \"MinistryWCD\",\n // \"MSJEGOI\",\n // \"ECISVEEP\",\n // \"SpokespersonECI\",\n // \"ceohimachal\",\n // \"IITMandiiHub\",\n // \"NITHamirpur\",\n // \"jairamthakurbjp\",\n // \"ianuragthakur\",\n // \"KanganaTeam\"\n // ],\n\n // \"Jharkhand\": [\n // \"HemantSorenJMM\",\n // \"JharkhandCMO\"\n // ],\n\n // \"Karnataka\": [\n // \"DKShivakumar\",\n // \"CMofKarnataka\",\n // \"siddaramaiah\",\n // \"KarnatakaVarthe\",\n // \"ceo_karnataka\",\n // \"OfficialBMRCL\",\n // \"mysurucitycorp\",\n // \"BDAOfficialGok\",\n // \"ICCCBengaluru\",\n // \"NHAI_Official\",\n // \"MORTHIndia\",\n // \"SWRRLY\",\n // \"drmsbc\",\n // \"KridePrm\",\n // \"KSRTC_Journeys\",\n // \"BMTC_BENGALURU\",\n // \"tdkarnataka\",\n // \"nw_krtc\",\n // \"mnreindia\",\n // \"NammaBESCOM\",\n // \"cescmysore\",\n // \"Gescom_official\",\n // \"chairmanbwssb\",\n // \"karnatakaforest\",\n // \"investkarnataka\",\n // \"minmsme\",\n // \"AgriGoI\",\n // \"MoRD_GoI\",\n // \"mopr_goi\",\n // \"Dept_of_AHD\",\n // \"MoHFW_INDIA\",\n // \"MoHUA_India\",\n // \"DHFWKA\",\n // \"KEA_karnataka\",\n // \"DSERT_Karnataka\",\n // \"NTA_Exams\",\n // \"EduMinOfIndia\",\n // \"AICTE_INDIA\",\n // \"iiscbangalore\",\n // \"NLSIUofficial\",\n // \"iimb_official\",\n // \"NIMHANS_BLR\",\n // \"ShobhaBJP\",\n // \"PCMohanMP\",\n // \"Tejasvi_Surya\",\n // \"BYVijayendra\"\n // ],\n\n // \"Kerala\": [\n // \"vdsatheesan\"\n // ],\n\n // \"Madhya Pradesh\": [\n // \"DrMohanYadav51\",\n // \"CMMadhyaPradesh\"\n // ],\n\n // \"Manipur\": [\n // \"YKhemchandSingh\"\n // ],\n\n // \"Meghalaya\": [\n // \"SangmaConrad\"\n // ],\n\n // \"Mizoram\": [\n // \"Lal_Duhoma\",\n // \"CMOMizoram\"\n // ],\n\n // \"Nagaland\": [\n // \"Neiphiu_Rio\",\n // \"CmoNagaland\"\n // ],\n\n // \"Odisha\": [\n // \"MohanMOdisha\",\n // \"CMO_Odisha\",\n // \"GovernorOdisha\",\n // \"SecyChief\",\n // \"IPR_Odisha\",\n // \"PIBBhubaneswar\",\n // \"HUDDeptOdisha\",\n // \"bmcbbsr\",\n // \"smc_sambalpur\",\n // \"BDABBSR\",\n // \"BSCL_BBSR\",\n // \"PWD_Odisha\",\n // \"CTOdisha\",\n // \"CRUT_BBSR\",\n // \"EnergyOdisha\",\n // \"GRIDCO_Odisha\",\n // \"OPTCL_Odisha\",\n // \"OREDA_Odisha\",\n // \"TPCentralOdish\",\n // \"OdishaWater\",\n // \"watco_odisha\",\n // \"OSWSM_Odisha\",\n // \"ForestDeptt\",\n // \"spcbOdisha\",\n // \"InvestOdisha\",\n // \"IDCO_Odisha\",\n // \"MSMEOdisha\",\n // \"startupodisha\",\n // \"krushibibhag\",\n // \"odishaliveliho\",\n // \"PRDeptOdisha\",\n // \"ORMAS_Odisha\",\n // \"HFWOdisha\",\n // \"NHMOdisha\",\n // \"SMEOdisha\",\n // \"DHE_Odisha\",\n // \"SDTE_Odisha\",\n // \"Skill_Odisha\",\n // \"DirectorateEmpO\",\n // \"WCDOdisha\",\n // \"mission_shakti\",\n // \"odisha_tourism\",\n // \"paradip_port\",\n // \"aaibpiairport\",\n // \"EIT_Odisha\",\n // \"OSWAN_Odisha\",\n // \"OdishaGov\",\n // \"FdOdisha\",\n // \"OdishaVigilance\",\n // \"AIRNewsAlerts\",\n // \"nitrourkela\",\n // \"icarindia\",\n // \"iitbbs\",\n // \"CSIR_IMMT\",\n // \"OSRTC\",\n // \"KVSinghDeo1\",\n // \"PravatiPOdisha\",\n // \"Naveen_Odisha\",\n // \"manmohansamal\"\n // ],\n\n // \"Punjab\": [\n // \"BhagwantMann\",\n // \"CMOPbIndia\",\n // \"CMOPb\",\n // \"LudhianaDC\",\n // \"PbGovtIndia\",\n // \"PIBFactCheck\",\n // \"OfficialGMADA\",\n // \"MORTHIndia\",\n // \"NHAI_Official\",\n // \"RailwayNorthern\",\n // \"IRCTCofficial\",\n // \"RailTel\",\n // \"dfccil_india\",\n // \"RailVikas\",\n // \"IrconOfficial\",\n // \"nhsrcl\",\n // \"PSPCLPb\",\n // \"ppcbgovt\",\n // \"invest_punjab\",\n // \"PSIDCIndia\",\n // \"harjotbains\",\n // \"TheCEOPunjab\",\n // \"ECISVEEP\",\n // \"SpokespersonECI\",\n // \"FCI_India\"\n // ],\n\n // \"Rajasthan\": [\n // \"BhajanlalBjp\",\n // \"RajCMO\",\n // \"CMORajasthan\",\n // \"RajGovOfficial\",\n // \"KumariDiya\",\n // \"DrPremBairwa\",\n // \"UdaipurDm\",\n // \"DmBharatpur\",\n // \"MORTHIndia\",\n // \"NHAI_Official\",\n // \"RailMinIndia\",\n // \"RailwaySeva\",\n // \"NWRailways\",\n // \"drmjodhpur\",\n // \"drmajmer\",\n // \"drmbikaner\",\n // \"OfficialJMRC\",\n // \"dfccil_india\",\n // \"RLDA_India\",\n // \"RSPCB_official\",\n // \"CeoRajasthan\",\n // \"SpokespersonECI\",\n // \"ECISVEEP\",\n // \"my_rajasthan\",\n // \"AAI_Official\",\n // \"Jaipur_Airport\",\n // \"MoCA_GoI\",\n // \"DoITCRaj\",\n // \"DIPRRajasthan\",\n // \"PIBJaipur\",\n // \"PIB_India\",\n // \"DDNewslive\",\n // \"airnewsalerts\",\n // \"CURAJOfficial\",\n // \"AIIMS_Jodhpur\",\n // \"GovindDotasra\",\n // \"ashokgehlot51\",\n // \"VasundharaBJP\",\n // \"gssjodhpur\",\n // \"arjunrammeghwal\",\n // \"byadavbjp\",\n // \"cpjoshibjp\",\n // \"hanumanbeniwal\",\n // \"TikaRamJullyINC\",\n // \"Rajendra4BJP\",\n // \"SachinPilot\",\n // \"DrKirodilalBJP\",\n // \"AvinashGehlotB\",\n // \"rajeduofficial\"\n // ],\n\n // \"Sikkim\": [\n // \"PSTamangGolay\"\n // ],\n\n // \"Tamil Nadu\": [\n // \"actorvijay\",\n // \"CMOTamilnadu\",\n // \"TNDIPRNEWS\",\n // \"TNeGAOfficial\",\n // \"chennaicorp\",\n // \"CHN_Metro_Water\",\n // \"Guidance_TN\",\n // \"cmrlofficial\",\n // \"TANGEDCO_Offcl\",\n // \"aaichnairport\",\n // \"GMSRailway\",\n // \"DrmChennai\",\n // \"NHAI_Official\",\n // \"RailMinIndia\",\n // \"RailwaySeva\",\n // \"DRMSalem\",\n // \"IRCTCofficial\",\n // \"MtcChennai\",\n // \"TRBRajaa\",\n // \"NHM_TN\",\n // \"Subramanian_ma\",\n // \"TNPSC_Office\",\n // \"ECISVEEP\",\n // \"SpokespersonECI\",\n // \"TNElectionsCEO\",\n // \"tntourismoffcl\",\n // \"PortofChennai\",\n // \"AAI_Official\"\n // ],\n\n // \"Telangana\": [\n // \"revanth_anumula\",\n // \"TelanganaCMO\",\n // \"tg_governor\",\n // \"Collector_HYD\",\n // \"Collector_KNR\",\n // \"collector_wgl\",\n // \"Collector_MBNR\",\n // \"CollectorPDPL\",\n // \"CollectorADB\",\n // \"TelanganaDIPR\",\n // \"PIBHyderabad\",\n // \"PIBFactCheck\",\n // \"cdmatelangana\",\n // \"HMDA_Gov\",\n // \"GHMCOnline\",\n // \"HMWSSBOnline\",\n // \"Comm_HYDRAA\",\n // \"tgiccc_tg\",\n // \"NHAI_Official\",\n // \"MORTHIndia\",\n // \"RailMinIndia\",\n // \"RailwaySeva\",\n // \"ltmhyd\",\n // \"RailVikas\",\n // \"dfccil_india\",\n // \"nhsrcl\",\n // \"RailTel\",\n // \"CONCOR_INDIA\",\n // \"HMRLHydMetro\",\n // \"HYDTP\",\n // \"TGSPDCL\",\n // \"THubHyd\",\n // \"teamTGIC\",\n // \"TWorksHyd\",\n // \"HorticultureTS\",\n // \"FisheriesTS\",\n // \"TelanganaHealth\",\n // \"NIMSHYD\",\n // \"TSPSCofficial\",\n // \"JNTUHofficial\",\n // \"CEO_Telangana\",\n // \"ECISVEEP\",\n // \"SpokespersonECI\",\n // \"TravelTelanga1\",\n // \"MoCA_GoI\",\n // \"AAI_Official\",\n // \"RGIAHyd\",\n // \"TFiberOfficial\",\n // \"TGInnovation\",\n // \"TSATNetwork\",\n // \"cfs_telangana\",\n // \"DDYadagiri\",\n // \"DDNewslive\",\n // \"airnewsalerts\",\n // \"IPRTelangana\",\n // \"IITHyderabad\",\n // \"NITWarangal\",\n // \"HydUniv\",\n // \"manuuhyd\",\n // \"PJTSAU\",\n // \"NIRDPR_India\",\n // \"ccmb_csir\",\n // \"csiriict\",\n // \"ICMRNIN\",\n // \"icarnaarm\",\n // \"ICRISAT\",\n // \"SCCLmines\",\n // \"Bhatti_Mallu\",\n // \"kishanreddybjp\",\n // \"OffDSB\",\n // \"Ponnam_INC\",\n // \"iamkondasurekha\",\n // \"seethakkaMLA\",\n // \"DamodarINC\",\n // \"asadowaisi\",\n // \"RenukaCCongress\",\n // \"BRSHarish\",\n // \"KTRBRS\",\n // \"TigerRajaSingh\",\n // \"kunamneni_cpi\"\n // ],\n\n // \"Tripura\": [\n // \"DrManikSaha2\",\n // \"tripura_cmo\"\n // ],\n\n // \"Uttar Pradesh\": [\n // \"myogiadityanath\",\n // \"CMOfficeUP\"\n // ],\n\n // \"Uttarakhand\": [\n // \"pushkardhami\",\n // \"ukcmo\",\n // \"DmNainital\",\n // \"uttarakhandcops\",\n // \"UTDBofficial\",\n // \"DIPR_UK\",\n // \"diprdehradun\",\n // \"PIBDehradun\",\n // \"RailwaySeva\",\n // \"dfccil_india\",\n // \"nerailwaygkp\",\n // \"MinOfPower\",\n // \"CEO_Uttarakhand\",\n // \"MinOfCultureGoI\",\n // \"MoCA_GoI\",\n // \"AAI_Official\",\n // \"IWAI_ShipMin\",\n // \"GoI_MeitY\",\n // \"DARPG_GoI\",\n // \"FCI_India\",\n // \"ukcmhelpline\",\n // \"airnewsalerts\"\n // ],\n\n // \"West Bengal\": [\n // \"SuvenduWB\",\n // \"cmowb2026\",\n // \"CMOfficeWB\",\n // \"MamataOfficial\",\n // \"PIBKolkata\",\n // \"PIBFactCheck\",\n // \"kmc_kolkata\",\n // \"amrut_MoHUA\",\n // \"RailMinIndia\",\n // \"RailwaySeva\",\n // \"EasternRailway\",\n // \"serailwaykol\",\n // \"metrorailwaykol\",\n // \"IRCTCofficial\",\n // \"IrconOfficial\",\n // \"drmhowrah\",\n // \"drmasansol\",\n // \"_WBTC\",\n // \"KPTrafficDept\",\n // \"MinOfPower\",\n // \"mnreindia\",\n // \"cleanganganmcg\",\n // \"CGWB_CHQ\",\n // \"DPIITGoI\",\n // \"CimGOI\",\n // \"DoC_GoI\",\n // \"investindia\",\n // \"minmsme\",\n // \"MoHFW_INDIA\",\n // \"OfficialWBSCTE\",\n // \"Skill_India_WB\",\n // \"MinistryWCD\",\n // \"MSJEGOI\",\n // \"CEOWestBengal\",\n // \"ECISVEEP\",\n // \"SpokespersonECI\",\n // \"aaikolairport\",\n // \"fooddeptgoi\",\n // \"PIB_India\",\n // \"DDNewslive\",\n // \"IITKgp\",\n // \"visvabharati\",\n // \"CalcuttaUniv\",\n // \"RBU_Official\",\n // \"IACS_Kolkata\",\n // \"GeologyIndia\",\n // \"UdKmda\",\n // \"WBPolice\",\n // \"BJP4Bengal\",\n // \"INCWestBengal\",\n // \"AITCofficial\",\n // \"amitmalviya\",\n // \"DrSukantaBJP\",\n // \"MahuaMoitra\",\n // \"abhishekaitc\",\n // \"derekobrienmp\"\n // ],\n\n // \"Andaman & Nicobar\": [],\n\n // \"Chandigarh\": [],\n\n // \"Dadra Nagar Haveli and Daman and Diu\": [],\n\n // \"Delhi (NCT)\": [\n // \"gupta_rekha\",\n // \"CMODelhi\"\n // ],\n\n // \"Jammu & Kashmir\": [\n // \"OmarAbdullah\",\n // \"CM_JnK\"\n // ],\n\n // \"Ladakh\": [],\n\n // \"Lakshadweep\": [],\n\n // \"Puducherry\": [\n // \"CM_Puducherry\"\n // ]\n};\n\n/**\n * Flatten every state/UT account array.\n *\n * @type {string[]}\n */\nconst verifiedSheetAccounts = [];\n\nfor (\n const stateAccountList\n of Object.values(allStateAndUTVerifiedXAccounts)\n) {\n verifiedSheetAccounts.push(...stateAccountList);\n}\n\n/**\n * Normalises usernames supplied as:\n *\n * username\n * @username\n * https://x.com/username\n * https://twitter.com/username\n *\n * @param {unknown} value\n * @returns {string}\n */\nfunction cleanXUsername(value) {\n return String(value || '')\n .trim()\n .replace(/^@/, '')\n .replace(\n /^https?:\\/\\/(?:www\\.)?(?:x|twitter)\\.com\\//i,\n ''\n )\n .split('?')[0]\n .split('#')[0]\n .split('/')[0]\n .trim();\n}\n\n/**\n * X usernames may contain letters, numbers and underscores\n * and must be between 1 and 15 characters.\n *\n * @param {string} username\n * @returns {boolean}\n */\nfunction isValidXUsername(username) {\n return /^[A-Za-z0-9_]{1,15}$/.test(username);\n}\n\n/**\n * Clean and deduplicate accounts extracted from the workbook.\n *\n * @type {string[]}\n */\nconst cleanedVerifiedSheetAccounts = [\n ...new Set(\n verifiedSheetAccounts\n .map(cleanXUsername)\n .filter(Boolean)\n .filter(isValidXUsername)\n )\n];\n\n/**\n * Combine existing specialist accounts with verified\n * state and Union Territory accounts.\n *\n * @type {string[]}\n */\nconst cleanedAccounts = [\n ...new Set(\n [\n ...accounts,\n ...cleanedVerifiedSheetAccounts\n ]\n .map(cleanXUsername)\n .filter(Boolean)\n .filter(isValidXUsername)\n )\n];\n\nif (!cleanedAccounts.length) {\n throw new Error(\n 'Add at least one valid X username.'\n );\n}\n\n/**\n * Find invalid values before returning configuration.\n *\n * @type {string[]}\n */\nconst allRawAccounts = [\n ...accounts,\n ...verifiedSheetAccounts\n];\n\nconst invalidAccounts = [\n ...new Set(\n allRawAccounts\n .map(cleanXUsername)\n .filter(Boolean)\n .filter(\n username =>\n !isValidXUsername(username)\n )\n )\n];\n\nif (invalidAccounts.length > 0) {\n throw new Error(\n `Invalid X usernames found: ${invalidAccounts.join(', ')}`\n );\n}\n\n/**\n * Create state and Union Territory coverage metadata.\n *\n * @type {string[]}\n */\nconst stateAndUTNames =\n Object.keys(allStateAndUTVerifiedXAccounts);\n\nconst statesAndUTsWithAccounts =\n stateAndUTNames.filter(\n stateOrUT =>\n allStateAndUTVerifiedXAccounts[stateOrUT].length > 0\n );\n\nconst statesAndUTsWithoutAccounts =\n stateAndUTNames.filter(\n stateOrUT =>\n allStateAndUTVerifiedXAccounts[stateOrUT].length === 0\n );\n\n/**\n * Return configuration for downstream nodes.\n */\nreturn [\n {\n json: {\n config: CONFIG,\n\n accounts:\n cleanedAccounts,\n\n total_unique_accounts:\n cleanedAccounts.length,\n\n existing_specialist_accounts:\n [\n ...new Set(\n accounts\n .map(cleanXUsername)\n .filter(Boolean)\n .filter(isValidXUsername)\n )\n ].length,\n\n verified_sheet_unique_accounts:\n cleanedVerifiedSheetAccounts.length,\n\n states_and_uts_in_verified_sheet:\n stateAndUTNames.length,\n\n states_and_uts_with_x_accounts:\n statesAndUTsWithAccounts.length,\n\n states_and_uts_without_x_accounts:\n statesAndUTsWithoutAccounts,\n\n verified_accounts_by_state_ut:\n allStateAndUTVerifiedXAccounts,\n\n invalid_accounts:\n invalidAccounts,\n\n run_started_at:\n new Date().toISOString()\n }\n }\n];" }, "id": "78956f9d-be28-48a8-a107-d7c2d3fef69d", "name": "Config + X Accounts6", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 6896, 12128 ], "notesInFlow": true, "notes": "Production configuration: 6-hour high-watermark X fetch, direct OpenAI Responses API for editorial generation, generated featured image, and Rank Math REST bridge." }, { "parameters": { "jsCode": "const input = $input.first().json;\nconst config = input.config || {};\nconst accounts = Array.isArray(input.accounts) ? input.accounts : [];\n\nconst staticData = $getWorkflowStaticData('global');\n\n/**\n * URBAN ACRES — X HIGH-WATERMARK SEARCH WINDOW\n *\n * Normal behaviour:\n * start_time = previous successful X fetch cutoff\n * end_time = this run's cutoff\n *\n * Safety:\n * - Never look back more than 6 hours.\n * - Use a small indexing delay so tweets created at the exact request\n * moment are not lost due to X indexing latency.\n * - The next successful run begins exactly after this run's end_time.\n */\n\nconst workflowRunTime = new Date();\nconst workflowRunMs = workflowRunTime.getTime();\n\nconst maxLookbackHours = Math.max(\n 1,\n Math.min(\n 6,\n Number(config.maxLookbackHours || 6)\n )\n);\n\nconst maxLookbackMs =\n maxLookbackHours * 60 * 60 * 1000;\n\nconst indexingSafetySeconds = Math.max(\n 0,\n Math.min(\n 120,\n Number(config.indexingSafetySeconds || 30)\n )\n);\n\nconst indexingSafetyMs =\n indexingSafetySeconds * 1000;\n\n// Freeze the query slightly behind the workflow clock.\nconst searchEndMs =\n workflowRunMs - indexingSafetyMs;\n\n// The first ever run (or a reset/long outage) may look back only 6 hours.\nconst fallbackStartMs =\n searchEndMs - maxLookbackMs;\n\nconst savedLastFetchRaw =\n String(staticData.uaXLastSuccessfulFetchAt || '').trim();\n\nconst savedLastFetchMs =\n Date.parse(savedLastFetchRaw);\n\nconst hasValidLastFetch =\n Number.isFinite(savedLastFetchMs) &&\n savedLastFetchMs < searchEndMs;\n\n// Critical rule:\n// - Continue from last successful fetch when it is inside the allowed 6-hour range.\n// - If it is older than 6 hours, cap at 6 hours; do NOT pull older posts.\nconst searchStartMs =\n hasValidLastFetch\n ? Math.max(savedLastFetchMs, fallbackStartMs)\n : fallbackStartMs;\n\nif (searchStartMs >= searchEndMs) {\n staticData.uaXLastSkippedWindow = {\n reason: 'No positive X search interval after applying last-fetch watermark.',\n last_successful_fetch_at: savedLastFetchRaw || null,\n search_start_time: new Date(searchStartMs).toISOString(),\n search_end_time: new Date(searchEndMs).toISOString(),\n skipped_at: workflowRunTime.toISOString()\n };\n\n return [];\n}\n\nconst searchStartTime = new Date(searchStartMs)\n .toISOString()\n .replace(/\\.\\d{3}Z$/, 'Z');\n\nconst searchEndTime = new Date(searchEndMs)\n .toISOString()\n .replace(/\\.\\d{3}Z$/, 'Z');\n\nconst searchWindowMinutes = Math.max(\n 1,\n Math.ceil(\n (searchEndMs - searchStartMs) /\n (60 * 1000)\n )\n);\n\nconst searchWindowHours =\n Number(\n (\n (searchEndMs - searchStartMs) /\n (60 * 60 * 1000)\n ).toFixed(3)\n );\n\nconst makeQuery = usernames => {\n const authorQuery = usernames\n .map(username => `from:${username}`)\n .join(' OR ');\n\n const filters = [];\n\n if (config.excludeRetweets) {\n filters.push('-is:retweet');\n }\n\n if (config.excludeReplies) {\n filters.push('-is:reply');\n }\n\n return `(${authorQuery})${\n filters.length ? ` ${filters.join(' ')}` : ''\n }`;\n};\n\nconst hash = value => {\n let h = 2166136261;\n\n for (let index = 0; index < value.length; index++) {\n h ^= value.charCodeAt(index);\n h = Math.imul(h, 16777619);\n }\n\n return (h >>> 0).toString(16);\n};\n\nconst buildQueryString = parameters =>\n Object.entries(parameters)\n .filter(\n ([, value]) =>\n value !== undefined &&\n value !== null &&\n value !== ''\n )\n .map(\n ([key, value]) =>\n `${encodeURIComponent(key)}=${encodeURIComponent(\n String(value)\n )}`\n )\n .join('&');\n\nconst groupedAccounts = [];\nlet currentGroup = [];\n\nfor (const username of accounts) {\n const candidateGroup = [...currentGroup, username];\n const candidateQuery = makeQuery(candidateGroup);\n\n const exceedsAccountLimit =\n candidateGroup.length >\n Number(config.maxAccountsPerQuery || 20);\n\n const exceedsQueryLimit =\n candidateQuery.length >\n Number(config.maxQueryLength || 450);\n\n if (\n currentGroup.length &&\n (exceedsAccountLimit || exceedsQueryLimit)\n ) {\n groupedAccounts.push(currentGroup);\n currentGroup = [username];\n } else {\n currentGroup = candidateGroup;\n }\n}\n\nif (currentGroup.length) {\n groupedAccounts.push(currentGroup);\n}\n\nconst expectedGroups = groupedAccounts.length;\n\nstaticData.uaXCurrentFetchWindow = {\n search_start_time: searchStartTime,\n search_end_time: searchEndTime,\n workflow_run_time: workflowRunTime.toISOString(),\n max_lookback_hours: maxLookbackHours,\n effective_window_hours: searchWindowHours,\n effective_window_minutes: searchWindowMinutes,\n previous_successful_fetch_at:\n hasValidLastFetch\n ? new Date(savedLastFetchMs).toISOString()\n : null,\n used_fallback_start:\n !hasValidLastFetch ||\n savedLastFetchMs < fallbackStartMs,\n expected_query_groups: expectedGroups\n};\n\nreturn groupedAccounts.map((groupAccounts, index) => {\n const query = makeQuery(groupAccounts);\n\n const groupId =\n `group-${index + 1}-${hash(groupAccounts.join('|'))}`;\n\n const parameters = {\n query,\n\n // X Recent Search uses RFC3339 UTC timestamps.\n start_time: searchStartTime,\n end_time: searchEndTime,\n\n max_results: Math.max(\n 10,\n Math.min(\n 100,\n Number(config.maxResultsPerQuery || 100)\n )\n ),\n\n 'tweet.fields': [\n 'id',\n 'text',\n 'author_id',\n 'created_at',\n 'lang',\n 'conversation_id',\n 'public_metrics',\n 'referenced_tweets',\n 'attachments',\n 'entities',\n 'possibly_sensitive',\n 'source'\n ].join(','),\n\n expansions:\n 'author_id,attachments.media_keys',\n\n 'user.fields': [\n 'id',\n 'name',\n 'username',\n 'verified',\n 'profile_image_url',\n 'description',\n 'location'\n ].join(','),\n\n 'media.fields': [\n 'media_key',\n 'type',\n 'url',\n 'preview_image_url',\n 'width',\n 'height',\n 'alt_text',\n 'duration_ms',\n 'public_metrics'\n ].join(','),\n\n sort_order: 'recency'\n };\n\n return {\n json: {\n config,\n\n group_id: groupId,\n group_index: index + 1,\n expected_query_groups: expectedGroups,\n group_accounts: groupAccounts,\n\n x_query: query,\n\n previous_successful_fetch_at:\n hasValidLastFetch\n ? new Date(savedLastFetchMs).toISOString()\n : null,\n\n search_start_time: searchStartTime,\n search_end_time: searchEndTime,\n workflow_run_time: workflowRunTime.toISOString(),\n\n search_timezone: 'Asia/Kolkata',\n\n search_window:\n `last_fetch_to_now_max_${maxLookbackHours}h`,\n\n search_window_hours:\n searchWindowHours,\n\n search_window_minutes:\n searchWindowMinutes,\n\n indexing_safety_seconds:\n indexingSafetySeconds,\n\n request_url:\n `https://api.x.com/2/tweets/search/recent?${buildQueryString(\n parameters\n )}`\n }\n };\n});" }, "id": "c5570bc6-70e9-4e73-9cd5-e444bbed7102", "name": "Build X Search Groups6", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 7120, 12128 ], "notesInFlow": true, "notes": "Uses a persistent high-watermark. start_time is the last successful fetch cutoff, capped to a maximum 6-hour lookback. end_time is this run's cutoff." }, { "parameters": { "url": "={{ $json.request_url }}", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", "value": "={{ 'Bearer ' + String($json.config.xBearerToken).trim() }}" } ] }, "options": { "response": { "response": { "fullResponse": true, "neverError": true, "responseFormat": "json" } }, "pagination": { "pagination": { "parameters": { "parameters": [ { "name": "next_token", "value": "={{ $response.body.meta.next_token }}" } ] }, "paginationCompleteWhen": "other", "completeExpression": "={{ !($response.body && $response.body.meta && $response.body.meta.next_token) }}", "limitPagesFetched": true, "requestInterval": 250 } }, "timeout": 30000 } }, "id": "f8418f18-2815-493f-a088-bb656fb63f87", "name": "Search Recent X Posts6", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 7344, 12128 ], "alwaysOutputData": true }, { "parameters": { "jsCode": "/**\n * URBAN ACRES — X RESPONSE HANDLER WITH LAST-FETCH HIGH-WATERMARK\n *\n * Accepts only:\n * search_start_time < tweet.created_at <= search_end_time\n *\n * On a completely successful X fetch:\n * uaXLastSuccessfulFetchAt = search_end_time\n *\n * If ANY X API page/group fails:\n * the high-watermark is NOT advanced.\n * Next run re-queries the same interval (subject to the 6-hour cap),\n * while uaSeenTweetIds prevents already-processed posts from being duplicated.\n */\n\nconst incomingItems = $input.all();\nconst staticData = $getWorkflowStaticData('global');\n\nconst config =\n $('Config + X Accounts6').first().json.config || {};\n\nconst runMeta =\n $('Build X Search Groups6').first().json || {};\n\nconst searchStartTime =\n String(runMeta.search_start_time || '').trim();\n\nconst searchEndTime =\n String(runMeta.search_end_time || '').trim();\n\nconst searchStartMs =\n Date.parse(searchStartTime);\n\nconst searchEndMs =\n Date.parse(searchEndTime);\n\nif (\n !Number.isFinite(searchStartMs) ||\n !Number.isFinite(searchEndMs) ||\n searchStartMs >= searchEndMs\n) {\n throw new Error(\n 'Build X Search Groups did not provide a valid search_start_time/search_end_time.'\n );\n}\n\nstaticData.uaSeenTweetIds =\n Array.isArray(staticData.uaSeenTweetIds)\n ? staticData.uaSeenTweetIds\n : [];\n\nstaticData.uaXApiErrors =\n Array.isArray(staticData.uaXApiErrors)\n ? staticData.uaXApiErrors\n : [];\n\nconst previouslyProcessedIds = new Set(\n staticData.uaSeenTweetIds.map(String)\n);\n\nconst seenThisExecution = new Set();\nconst newlyRememberedIds = [];\n\nconst output = [];\nconst successfulResponses = [];\n\nconst processedAt = new Date();\n\nconst IST_OFFSET_MINUTES = 330;\nconst IST_OFFSET_MS =\n IST_OFFSET_MINUTES * 60 * 1000;\n\nlet totalTweetsReceived = 0;\nlet outsideWindowSkipped = 0;\nlet duplicateTweetsSkipped = 0;\nlet invalidTweetsSkipped = 0;\nlet apiErrorCount = 0;\n\nfunction parseMaybeJson(value) {\n if (typeof value !== 'string') {\n return value;\n }\n\n const trimmed = value.trim();\n\n if (!trimmed) {\n return {};\n }\n\n try {\n return JSON.parse(trimmed);\n } catch {\n return {\n raw_text: trimmed\n };\n }\n}\n\nfunction normaliseResponse(rawValue) {\n const raw =\n rawValue && typeof rawValue === 'object'\n ? rawValue\n : {};\n\n let body;\n\n if (\n Object.prototype.hasOwnProperty.call(raw, 'body')\n ) {\n body = parseMaybeJson(raw.body);\n } else if (\n raw.error &&\n Object.prototype.hasOwnProperty.call(\n raw.error,\n 'body'\n )\n ) {\n body = parseMaybeJson(raw.error.body);\n } else {\n body = raw;\n }\n\n if (!body || typeof body !== 'object') {\n body = {\n raw_body: body\n };\n }\n\n const statusCode = Number(\n raw.statusCode ??\n raw.status ??\n raw.error?.statusCode ??\n raw.error?.status ??\n body.status ??\n body.statusCode ??\n 0\n );\n\n const headers =\n raw.headers &&\n typeof raw.headers === 'object'\n ? raw.headers\n : {};\n\n return {\n statusCode,\n headers,\n body\n };\n}\n\nfunction getHeader(headers, name) {\n const target =\n String(name).toLowerCase();\n\n for (\n const [key, value]\n of Object.entries(headers || {})\n ) {\n if (\n String(key).toLowerCase() === target\n ) {\n return value;\n }\n }\n\n return undefined;\n}\n\nfunction formatISTDate(timestampMs) {\n const date = new Date(\n timestampMs + IST_OFFSET_MS\n );\n\n const year =\n date.getUTCFullYear();\n\n const month =\n String(date.getUTCMonth() + 1)\n .padStart(2, '0');\n\n const day =\n String(date.getUTCDate())\n .padStart(2, '0');\n\n return `${year}-${month}-${day}`;\n}\n\nfunction registerApiError({\n statusCode,\n headers,\n body\n}) {\n apiErrorCount++;\n\n const detail = String(\n body.detail ??\n body.message ??\n body.error?.message ??\n body.title ??\n 'Unknown X API error'\n );\n\n const type = String(\n body.type ??\n body.error?.type ??\n ''\n );\n\n const detailLower =\n detail.toLowerCase();\n\n const typeLower =\n type.toLowerCase();\n\n let errorType = 'x-api-error';\n let backoffMinutes = 60;\n let pauseUntil =\n Date.now() +\n backoffMinutes * 60 * 1000;\n\n if (\n statusCode === 402 ||\n typeLower.includes('credits-depleted') ||\n detailLower.includes('credits depleted')\n ) {\n errorType = 'x-credits-depleted';\n\n backoffMinutes = Number(\n config.creditsBackoffMinutes || 720\n );\n\n pauseUntil =\n Date.now() +\n backoffMinutes * 60 * 1000;\n } else if (\n statusCode === 403 &&\n (\n typeLower.includes('spend-cap-reached') ||\n detailLower.includes('spend cap') ||\n detailLower.includes('monthly spend cap')\n )\n ) {\n errorType = 'x-spend-cap-reached';\n\n backoffMinutes = Number(\n config.spendCapBackoffMinutes || 1440\n );\n\n pauseUntil =\n Date.now() +\n backoffMinutes * 60 * 1000;\n } else if (\n statusCode === 401 ||\n (\n statusCode === 403 &&\n !typeLower.includes('spend-cap-reached')\n )\n ) {\n errorType = 'x-auth-or-access-error';\n\n backoffMinutes = Number(\n config.authBackoffMinutes || 360\n );\n\n pauseUntil =\n Date.now() +\n backoffMinutes * 60 * 1000;\n } else if (statusCode === 429) {\n errorType = 'x-rate-limit';\n\n const resetSeconds = Number(\n getHeader(\n headers,\n 'x-rate-limit-reset'\n ) || 0\n );\n\n const fallbackPause =\n Date.now() +\n Number(\n config.rateLimitBackoffMinutes || 20\n ) *\n 60 *\n 1000;\n\n pauseUntil =\n resetSeconds > 0\n ? Math.max(\n resetSeconds * 1000 +\n 60 * 1000,\n fallbackPause\n )\n : fallbackPause;\n } else if (statusCode >= 500) {\n errorType = 'x-server-error';\n\n backoffMinutes = Number(\n config.serverErrorBackoffMinutes || 15\n );\n\n pauseUntil =\n Date.now() +\n backoffMinutes * 60 * 1000;\n }\n\n staticData.xPauseUntil =\n Math.max(\n Number(staticData.xPauseUntil || 0),\n pauseUntil\n );\n\n const errorRecord = {\n type: errorType,\n status: statusCode || null,\n detail,\n api_problem_type: type || null,\n pause_until:\n new Date(pauseUntil).toISOString(),\n search_start_time: searchStartTime,\n search_end_time: searchEndTime,\n recorded_at:\n processedAt.toISOString()\n };\n\n staticData.lastXApiError =\n errorRecord;\n\n staticData.uaXApiErrors.unshift(\n errorRecord\n );\n\n staticData.uaXApiErrors =\n staticData.uaXApiErrors.slice(\n 0,\n 100\n );\n}\n\n// ----------------------------------------------------\n// NORMALISE X API RESPONSES\n// ----------------------------------------------------\n\nfor (const item of incomingItems) {\n const response =\n normaliseResponse(\n item.json || {}\n );\n\n const {\n statusCode,\n headers,\n body\n } = response;\n\n const containsErrorBody =\n statusCode >= 400 ||\n Boolean(body.title) ||\n Boolean(body.detail) ||\n Boolean(body.error) ||\n (\n Array.isArray(body.errors) &&\n body.errors.length > 0\n );\n\n if (containsErrorBody) {\n registerApiError({\n statusCode,\n headers,\n body\n });\n\n continue;\n }\n\n if (\n statusCode > 0 &&\n (\n statusCode < 200 ||\n statusCode >= 300\n )\n ) {\n registerApiError({\n statusCode,\n headers,\n body\n });\n\n continue;\n }\n\n // A valid 2xx response is successful even when it contains zero tweets.\n successfulResponses.push(body);\n}\n\n// ----------------------------------------------------\n// EXTRACT POSTS\n// ----------------------------------------------------\n\nfor (\n const response\n of successfulResponses\n) {\n const tweets =\n Array.isArray(response.data)\n ? response.data\n : [];\n\n totalTweetsReceived +=\n tweets.length;\n\n const includedUsers =\n Array.isArray(\n response.includes?.users\n )\n ? response.includes.users\n : [];\n\n const includedMedia =\n Array.isArray(\n response.includes?.media\n )\n ? response.includes.media\n : [];\n\n const usersMap =\n new Map(\n includedUsers.map(\n user => [\n String(user.id || ''),\n user\n ]\n )\n );\n\n const mediaMap =\n new Map(\n includedMedia.map(\n media => [\n String(\n media.media_key || ''\n ),\n media\n ]\n )\n );\n\n for (const tweet of tweets) {\n const tweetId =\n String(\n tweet.id || ''\n ).trim();\n\n const postedAt =\n tweet.created_at || null;\n\n const postedAtMs =\n Date.parse(\n postedAt || ''\n );\n\n if (\n !tweetId ||\n !Number.isFinite(postedAtMs)\n ) {\n invalidTweetsSkipped++;\n continue;\n }\n\n // Exact high-watermark interval:\n // previous cutoff < tweet <= this cutoff\n if (\n postedAtMs <= searchStartMs ||\n postedAtMs > searchEndMs\n ) {\n outsideWindowSkipped++;\n continue;\n }\n\n if (\n seenThisExecution.has(tweetId)\n ) {\n duplicateTweetsSkipped++;\n continue;\n }\n\n // Persistent duplicate protection.\n if (\n previouslyProcessedIds.has(tweetId)\n ) {\n duplicateTweetsSkipped++;\n continue;\n }\n\n const author =\n usersMap.get(\n String(\n tweet.author_id || ''\n )\n ) || {};\n\n const mediaKeys =\n Array.isArray(\n tweet.attachments?.media_keys\n )\n ? tweet.attachments.media_keys\n : [];\n\n const media =\n mediaKeys\n .map(key =>\n mediaMap.get(\n String(key)\n )\n )\n .filter(Boolean)\n .map(mediaItem => ({\n media_key:\n mediaItem.media_key || null,\n\n type:\n mediaItem.type || null,\n\n url:\n mediaItem.type === 'photo'\n ? mediaItem.url || null\n : mediaItem.preview_image_url ||\n mediaItem.url ||\n null,\n\n original_url:\n mediaItem.url || null,\n\n preview_image_url:\n mediaItem.preview_image_url || null,\n\n width:\n mediaItem.width || null,\n\n height:\n mediaItem.height || null,\n\n alt_text:\n mediaItem.alt_text || null,\n\n duration_ms:\n mediaItem.duration_ms || null,\n\n public_metrics:\n mediaItem.public_metrics || {}\n }));\n\n // Visual evidence URLs available to OpenAI vision.\n // Includes direct X photos, video/GIF preview frames, and rich-link preview images.\n const mediaVisualUrls =\n media\n .map(mediaItem => {\n if (mediaItem.type === 'photo') {\n return mediaItem.url || mediaItem.original_url || null;\n }\n\n return (\n mediaItem.preview_image_url ||\n mediaItem.url ||\n null\n );\n })\n .filter(Boolean);\n\n const entityVisualUrls =\n (\n Array.isArray(tweet.entities?.urls)\n ? tweet.entities.urls\n : []\n )\n .flatMap(urlEntity => {\n const images =\n Array.isArray(urlEntity?.images)\n ? urlEntity.images\n : [];\n\n return images\n .map(image => image?.url || null)\n .filter(Boolean);\n });\n\n const imageUrls = [\n ...new Set([\n ...mediaVisualUrls,\n ...entityVisualUrls\n ])\n ].slice(0, 4);\n\n const username =\n String(\n author.username ||\n 'unknown'\n );\n\n const postPayload = {\n tweet_id:\n tweetId,\n\n post_content:\n String(\n tweet.text || ''\n ).trim(),\n\n posted_at:\n postedAt,\n\n post_date:\n formatISTDate(\n postedAtMs\n ),\n\n source_type:\n 'official-social-media',\n\n source_platform:\n 'X',\n\n source_url:\n tweetId &&\n username !== 'unknown'\n ? `https://x.com/${username}/status/${tweetId}`\n : null,\n\n author: {\n id:\n String(\n tweet.author_id || ''\n ),\n\n username,\n\n name:\n author.name ||\n username,\n\n verified:\n author.verified ??\n null,\n\n profile_image_url:\n author.profile_image_url ||\n null,\n\n description:\n author.description ||\n null,\n\n location:\n author.location ||\n null\n },\n\n public_metrics:\n tweet.public_metrics || {},\n\n language:\n tweet.lang || null,\n\n conversation_id:\n tweet.conversation_id || null,\n\n referenced_tweets:\n Array.isArray(\n tweet.referenced_tweets\n )\n ? tweet.referenced_tweets\n : [],\n\n entities:\n tweet.entities || {},\n\n image_urls:\n imageUrls,\n\n primary_image_url:\n imageUrls[0] || null,\n\n media,\n\n fetch_window: {\n search_start_time:\n searchStartTime,\n\n search_end_time:\n searchEndTime,\n\n max_lookback_hours:\n Number(\n config.maxLookbackHours || 6\n )\n }\n };\n\n output.push({\n json: {\n post_payload:\n postPayload,\n\n post_payload_text:\n JSON.stringify(\n postPayload\n ),\n\n tweet_id:\n postPayload.tweet_id,\n\n post_content:\n postPayload.post_content,\n\n posted_at:\n postPayload.posted_at,\n\n post_date:\n postPayload.post_date,\n\n source_url:\n postPayload.source_url,\n\n image_urls:\n postPayload.image_urls,\n\n primary_image_url:\n postPayload.primary_image_url,\n\n author_username:\n postPayload.author.username,\n\n author_name:\n postPayload.author.name,\n\n public_metrics:\n postPayload.public_metrics,\n\n search_start_time:\n searchStartTime,\n\n search_end_time:\n searchEndTime,\n\n search_timezone:\n 'Asia/Kolkata',\n\n fetch_mode:\n 'last-successful-fetch-high-watermark',\n\n processed_at:\n processedAt.toISOString()\n }\n });\n\n seenThisExecution.add(\n tweetId\n );\n\n newlyRememberedIds.push(\n tweetId\n );\n }\n}\n\n// ----------------------------------------------------\n// UPDATE PERSISTENT MEMORY\n// ----------------------------------------------------\n\nstaticData.uaSeenTweetIds = [\n ...new Set([\n ...newlyRememberedIds,\n ...staticData.uaSeenTweetIds.map(String)\n ])\n].slice(\n 0,\n Number(\n config.duplicateMemoryLimit ||\n 5000\n )\n);\n\n// Advance the fetch watermark ONLY if the complete X request batch\n// completed without an API error. If any group/page fails, keep the\n// previous watermark so the next run can safely retry.\nif (\n apiErrorCount === 0 &&\n successfulResponses.length > 0\n) {\n staticData.uaXLastSuccessfulFetchAt =\n new Date(\n searchEndMs\n ).toISOString();\n\n staticData.uaXLastSuccessfulFetchRecordedAt =\n processedAt.toISOString();\n}\n\nstaticData.uaLastDiscoveryRun =\n processedAt.toISOString();\n\nstaticData.uaLastDiscoveryCount =\n output.length;\n\nstaticData.uaLastDiscoveryStats = {\n api_items:\n incomingItems.length,\n\n successful_api_items:\n successfulResponses.length,\n\n api_errors:\n apiErrorCount,\n\n received:\n totalTweetsReceived,\n\n accepted:\n output.length,\n\n outside_window_skipped:\n outsideWindowSkipped,\n\n duplicate_skipped:\n duplicateTweetsSkipped,\n\n invalid_skipped:\n invalidTweetsSkipped,\n\n previous_successful_fetch_at:\n runMeta.previous_successful_fetch_at ||\n null,\n\n search_start_time:\n searchStartTime,\n\n search_end_time:\n searchEndTime,\n\n effective_window_hours:\n runMeta.search_window_hours ||\n null,\n\n max_lookback_hours:\n Number(\n config.maxLookbackHours || 6\n ),\n\n high_watermark_advanced:\n apiErrorCount === 0 &&\n successfulResponses.length > 0,\n\n new_high_watermark:\n (\n apiErrorCount === 0 &&\n successfulResponses.length > 0\n )\n ? new Date(\n searchEndMs\n ).toISOString()\n : staticData.uaXLastSuccessfulFetchAt ||\n null,\n\n recorded_at:\n processedAt.toISOString()\n};\n\nreturn output;" }, "id": "b49a7030-bc45-4454-ad06-e817ff0615eb", "name": "Handle X Errors + Prepare Fresh Posts6", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 7568, 12128 ], "notesInFlow": true, "notes": "High-watermark X fetch + persistent dedupe. Visual evidence now includes photos, video/GIF preview frames and X link-preview images." }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "3e30e9ae-255a-4c6e-884e-68ac31ae05af", "leftValue": "={{ Array.isArray($json.image_urls) && $json.image_urls.length > 0 }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "id": "1076b69e-0fdb-4d4a-82d6-e41fe60c11f5", "name": "Has Attached X Images?4", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 7792, 12128 ], "notesInFlow": true, "notes": "Routes posts with usable X visual evidence (photo, video/GIF preview frame, or link preview image) through OpenAI vision before editorial writing." }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const src = $('Handle X Errors + Prepare Fresh Posts6').all()[$itemIndex]?.json || {};\nconst raw = $json || {};\n\nfunction firstString(value, depth = 0) {\n if (depth > 8) return '';\n\n if (typeof value === 'string' && value.trim()) {\n return value.trim();\n }\n\n if (Array.isArray(value)) {\n for (const item of value) {\n const found = firstString(item, depth + 1);\n if (found) return found;\n }\n return '';\n }\n\n if (value && typeof value === 'object') {\n const preferred = [\n 'output_text',\n 'text',\n 'content',\n 'message',\n 'response',\n 'answer',\n 'description'\n ];\n\n for (const key of preferred) {\n if (Object.prototype.hasOwnProperty.call(value, key)) {\n const found = firstString(value[key], depth + 1);\n if (found) return found;\n }\n }\n\n for (const nested of Object.values(value)) {\n const found = firstString(nested, depth + 1);\n if (found) return found;\n }\n }\n\n return '';\n}\n\nconst fallback = {\n has_images: Array.isArray(src.image_urls) && src.image_urls.length > 0,\n has_readable_text: false,\n image_summary: '',\n ocr_text: '',\n visible_dates: [],\n visible_numbers: [],\n visible_names: [],\n visible_organisations: [],\n visible_locations: [],\n images: []\n};\n\nlet visualEvidence = fallback;\nlet visionError = null;\n\ntry {\n let text = firstString(raw)\n .replace(/^```json\\s*/i, '')\n .replace(/^```\\s*/i, '')\n .replace(/\\s*```$/i, '')\n .trim();\n\n if (!text) {\n throw new Error('Native OpenAI Analyze Image returned no readable text.');\n }\n\n const parsed = JSON.parse(text);\n\n if (!parsed || typeof parsed !== 'object') {\n throw new Error('Vision output was not a JSON object.');\n }\n\n visualEvidence = {\n ...fallback,\n ...parsed\n };\n} catch (error) {\n visionError = error.message;\n}\n\nreturn {\n json: {\n ...src,\n visual_evidence: visualEvidence,\n vision_status: visionError ? 'fallback' : 'success',\n vision_error: visionError\n }\n};" }, "id": "77d66e6e-f643-44e5-9b3c-a695692ce4f5", "name": "Parse X Image Evidence4", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 8240, 12032 ] }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "return { json: { ...$json, visual_evidence: { has_images:false, has_readable_text:false, image_summary:'', ocr_text:'', visible_dates:[], visible_numbers:[], visible_names:[], visible_organisations:[], visible_locations:[], images:[] }, vision_status:'no-images', vision_error:null } };" }, "id": "fdb061f8-8937-47f4-87ff-5171a728af62", "name": "No X Image Evidence4", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 8240, 12224 ] }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const j = $json || {};\nconst payload = j.post_payload || {};\nconst evidence = j.visual_evidence || {};\n\nconst combinedInput = {\n post_payload_text: j.post_payload_text || JSON.stringify(payload),\n editorial_dashboard_text: j.editorial_dashboard_text || '',\n post_payload: payload,\n visual_evidence: evidence,\n source_rule: 'Treat the X post text and attached-image evidence as the supplied source material. Do not invent external facts.'\n};\n\nreturn {\n json: {\n ...j,\n editorial_input: combinedInput,\n editorial_input_text: JSON.stringify(combinedInput),\n article_payload_text: JSON.stringify(combinedInput)\n }\n};" }, "id": "c68460f8-9c56-4b91-b26a-6141918cc1a9", "name": "Prepare Multimodal Editorial Input4", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 8464, 12128 ], "notesInFlow": true, "notes": "Combines original X post text/metadata with OCR + visual evidence from attached images." }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const sourceItems = $('Prepare Multimodal Editorial Input4').all();\nconst src = sourceItems[$itemIndex]?.json || {};\n\nlet out = $json?.output ?? $json;\nif (typeof out === 'string') {\n const cleaned = out.replace(/^```json\\s*/i,'').replace(/^```\\s*/i,'').replace(/\\s*```$/i,'').trim();\n try { out = JSON.parse(cleaned); } catch { out = {}; }\n}\nif (!out || typeof out !== 'object') out = {};\n\nfunction clean(v){ return String(v ?? '').trim(); }\nfunction slugify(v){ return clean(v).toLowerCase().normalize('NFKD').replace(/[\\u0300-\\u036f]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,80); }\nfunction stripPublicLabels(html){\n let value=clean(html).replace(/^```(?:html)?\\s*/i,'').replace(/\\s*```$/i,'').trim();\n const labels=['FULL ARTICLE','BREAKING NEWS','URBAN ACRES ANALYSIS','HEADLINE','SUBHEADLINE','DATELINE','PRIMARY FOCUS KEYWORD','SEO STRATEGY','EDITORIAL DECISION','FACT BOX','FIVE KEY FACTS','TIMELINE','RELATED STORIES','STORY TAGS','SOCIAL MEDIA PACKAGE','EDITORIAL COVER IMAGE PROMPT',\"EDITOR'S TOOLKIT\",'ANALYSIS TOOLKIT','INFOGRAPHIC PACKAGE'];\n for (const label of labels){\n const esc=label.replace(/[.*+?^${}()|[\\]\\\\]/g,'\\\\$&');\n value=value.replace(new RegExp(`<(?:p|h1|h2|h3|h4|strong)[^>]*>\\\\s*${esc}\\\\s*:?\\\\s*<\\\\/(?:p|h1|h2|h3|h4|strong)>`,'gi'),'');\n value=value.replace(new RegExp(`^\\\\s*${esc}\\\\s*:?\\\\s*$`,'gmi'),'');\n }\n if (value && !/<(?:p|h2|ul|ol|blockquote)[\\s>]/i.test(value)) {\n value=value.split(/\\n\\s*\\n+/).map(x=>x.trim()).filter(Boolean).map(x=>`

${x.replace(/&/g,'&').replace(//g,'>').replace(/\\n/g,'
')}

`).join('');\n }\n return value.trim();\n}\nfunction plainText(html){ return String(html||'').replace(/<[^>]+>/g,' ').replace(/ /gi,' ').replace(/&/gi,'&').replace(/\\s+/g,' ').trim(); }\n\nconst d=out.editorial_decision||{};\nconst decision=clean(d.decision).toUpperCase();\nconst route=clean(d.route).toUpperCase();\nconst score=Number(d.newsworthiness_score);\nconst body=stripPublicLabels(out.body_content);\nconst wc=plainText(body).split(/\\s+/).filter(Boolean).length;\nconst approved=decision==='APPROVED' && ['BREAKING','ANALYSIS'].includes(route) && Number.isFinite(score) && score>=60 && Boolean(body);\nconst title=clean(out.title);\nconst tags=clean(out.tags).split(',').map(x=>x.trim()).filter(Boolean).slice(0,12);\nconst focusKeywords=clean(out.focus_keywords).split(',').map(x=>x.trim()).filter(Boolean).slice(0,5);\nconst focusKeyword=clean(out.focus_keyword)||focusKeywords[0]||'';\nif (focusKeyword && !focusKeywords.some(x=>x.toLowerCase()===focusKeyword.toLowerCase())) focusKeywords.unshift(focusKeyword);\nconst sourceName=clean(out.source_name)||clean(src.author_name)||clean(src.post_payload?.author?.name);\nconst sourceUrl=clean(out.source_url)||clean(src.source_url)||clean(src.post_payload?.source_url);\nconst dedupeKey=clean(out.dedupe_key)||clean(src.dedupe_key)||clean(src.tweet_id)||clean(src.post_payload?.tweet_id);\n\nreturn {json:{...src,editorial_package:out,editorial_decision:decision||'UNPARSEABLE',route:route||'NONE',story_score:Number.isFinite(score)?score:null,decision_reason:clean(d.reasoning),approved_for_publishing:approved,title,slug:slugify(out.slug||title),excerpt:clean(out.short_summary),body_content:body,body_word_count:wc,focus_keyword:focusKeyword,focus_keywords:focusKeywords,seo_title:clean(out.seo_title),seo_meta_description:clean(out.seo_meta_description),story_tags:tags,image_generation_prompt:clean(out.image_generation_prompt),image_alt_text:clean(out.image_alt_text)||title,source_name:sourceName,source_url:sourceUrl,dedupe_key:dedupeKey,short_summary:clean(out.short_summary),short_rejection_reason:clean(out.short_rejection_reason),social_media_package:(out.social_media_package&&typeof out.social_media_package==='object')?out.social_media_package:{},normalized_at:new Date().toISOString()}};" }, "id": "42faa378-e230-4a41-8dfc-4a3b2f3cb179", "name": "Normalize Structured Editorial Package4", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 9040, 12032 ], "notesInFlow": true, "notes": "Canonical schema normalizer. Uses index-safe source recovery, strips public labels, preserves English language lock, keywords, SEO, source and image fields." }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "392992a7-f651-4f04-a1bc-54890a9e7e1d", "leftValue": "={{ $json.approved_for_publishing }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "id": "0eaa07e0-6dfc-45aa-9379-c8fe868a8c2f", "name": "Approved for Publishing?5", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 9264, 12032 ] }, { "parameters": { "jsCode": "const items=$input.all();const s=$getWorkflowStaticData('global');s.uaEditorialRejects=Array.isArray(s.uaEditorialRejects)?s.uaEditorialRejects:[];for(const item of items){const j=item.json||{};s.uaEditorialRejects.unshift({tweet_id:j.tweet_id||null,source_url:j.source_url||null,decision:j.editorial_decision||null,route:j.route||null,score:j.story_score??null,reason:j.decision_reason||'Not approved for publication.',recorded_at:new Date().toISOString()});}s.uaEditorialRejects=s.uaEditorialRejects.slice(0,1000);return items;" }, "id": "c4973acd-9911-4a0c-b3f3-115947f66175", "name": "Log Rejected or Held6", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 9488, 12128 ] }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const j = $json || {};\nconst c =\n $('Config + X Accounts6').first().json.config || {};\n\nconst safety = `URBAN ACRES EDITORIAL IMAGE REQUIREMENTS:\n- Generate a NEW original 16:9 landscape editorial news image.\n- Prompt and visual instructions are in ENGLISH.\n- Ground the scene only in verified story facts and useful visible context from the source post/images.\n- Realistic Indian urban/infrastructure context, natural lighting, restrained documentary colour grading.\n- No headlines, labels, logos, watermarks, political posters or social-media UI.\n- Do not copy source graphics or reproduce their embedded text.\n- Do not invent completed infrastructure, architectural designs or project stages not established by the story.\n- Avoid glossy advertising renders, futuristic elements, distortions and excessive crowds.`;\n\nlet base =\n String(\n j.image_generation_prompt ||\n ''\n ).trim();\n\nif (!base) {\n base =\n `Create a realistic Indian editorial photojournalism image for the story \"${j.title || 'Urban Acres news story'}\". ` +\n `${j.short_summary || j.excerpt || ''} ` +\n `Show the most relevant real-world urban place, infrastructure asset, public system, institution or citizen interface.`;\n}\n\nconst prompt =\n `${base}\\n\\n${safety}`;\n\nreturn {\n json: {\n ...j,\n\n final_image_prompt:\n prompt,\n\n image_file_name:\n `${j.slug || 'urban-acres-story'}.png`,\n\n image_generation_ready:\n Boolean(prompt),\n\n image_request: {\n model:\n c.imageModel ||\n 'gpt-image-1',\n\n prompt,\n\n size:\n c.imageSize ||\n '1536x1024',\n\n quality:\n c.imageQuality ||\n 'high',\n\n output_format:\n c.imageOutputFormat ||\n 'png',\n\n background:\n 'opaque',\n\n n:\n 1\n }\n }\n};" }, "id": "af7008d0-c6b1-4aec-b89f-406a91b67c7c", "name": "Prepare AI Featured Image4", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 9712, 11936 ] }, { "parameters": { "method": "POST", "url": "https://api.openai.com/v1/images/generations", "authentication": "predefinedCredentialType", "nodeCredentialType": "openAiApi", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Content-Type", "value": "application/json" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ $json.image_request }}", "options": { "response": { "response": { "fullResponse": true, "neverError": true, "responseFormat": "json" } }, "timeout": 240000 } }, "id": "475d2e95-d94f-486d-b253-c24a6d921bb4", "name": "OpenAI - Generate Featured Image4", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 6672, 12464 ], "alwaysOutputData": true, "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 8000, "credentials": { "openAiApi": { "id": "8XseT381cDl1t6mI", "name": "OpenAI account" } } }, { "parameters": { "method": "POST", "url": "={{ $('Config + X Accounts6').first().json.config.wordpressBaseUrl + '/wp-json/wp/v2/media' }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "wordpressApi", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Content-Disposition", "value": "={{ 'attachment; filename=\"' + $binary.data.fileName + '\"' }}" }, { "name": "Content-Type", "value": "={{ $binary.data.mimeType }}" } ] }, "sendBody": true, "contentType": "binaryData", "inputDataFieldName": "data", "options": { "response": { "response": { "responseFormat": "json" } }, "timeout": 120000 } }, "id": "5c38264e-48b9-4a5e-953a-4565d4ce67ba", "name": "WordPress - Upload Generated Featured Image4", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 10384, 11936 ], "alwaysOutputData": false, "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 3000, "notesInFlow": true, "credentials": { "wordpressApi": { "id": "fEYDt1jjJDoKpxrB", "name": "Wordpress account" } }, "notes": "HTTP remains only because the native WordPress node has no Media upload operation. This uploads the generated binary image to /wp-json/wp/v2/media." }, { "parameters": { "jsCode": "const src = $('Prepare Native WordPress Post1').all()[$itemIndex]?.json || {};\nconst wp = $json || {};\n\nconst id = Number(wp.id);\nconst ok = Number.isFinite(id) && id > 0;\n\nif (!ok) {\n throw new Error(\n wp?.message ||\n 'Native WordPress Create Post returned no valid post ID.'\n );\n}\n\nconst s = $getWorkflowStaticData('global');\n\ns.uaXPublications =\n Array.isArray(s.uaXPublications)\n ? s.uaXPublications\n : [];\n\ns.uaXPublications.unshift({\n tweet_id:\n src.tweet_id || null,\n\n source_url:\n src.source_url || null,\n\n wordpress_post_id:\n id,\n\n wordpress_link:\n wp.link || null,\n\n featured_media_id:\n src.featured_media_id || null,\n\n status:\n 'created',\n\n recorded_at:\n new Date().toISOString()\n});\n\ns.uaXPublications =\n s.uaXPublications.slice(\n 0,\n 2000\n );\n\nreturn {\n json: {\n ...src,\n\n wordpress_post_id:\n id,\n\n wordpress_link:\n wp.link || null,\n\n wordpress_result_ok:\n true\n }\n};" }, "id": "b4c490bd-b8e3-43c2-abe6-8dd740f6101c", "name": "Record Publication Result4", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 11056, 11936 ] }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const j = $json || {};\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction escapeRegExp(value) {\n return String(value)\n .replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction escapeHtml(value) {\n return String(value ?? '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\nfunction slugify(value) {\n return clean(value)\n .toLowerCase()\n .normalize('NFKD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '');\n}\n\nfunction containsCI(text, phrase) {\n if (!phrase) return false;\n return new RegExp(\n escapeRegExp(phrase),\n 'i'\n ).test(String(text || ''));\n}\n\nfunction startsWithCI(text, phrase) {\n if (!phrase) return false;\n return new RegExp(\n '^\\\\s*' + escapeRegExp(phrase),\n 'i'\n ).test(String(text || ''));\n}\n\nfunction truncateAtWord(text, maxChars) {\n const value = clean(text);\n\n if (\n !maxChars ||\n value.length <= maxChars\n ) {\n return value;\n }\n\n let cut =\n value.slice(0, maxChars);\n\n const lastSpace =\n cut.lastIndexOf(' ');\n\n if (\n lastSpace > Math.floor(maxChars * 0.7)\n ) {\n cut =\n cut.slice(0, lastSpace);\n }\n\n return cut\n .replace(/[,:;|\\-–—\\s]+$/g, '')\n .trim();\n}\n\nfunction stripTags(html) {\n return String(html || '')\n .replace(//gi, ' ')\n .replace(//gi, ' ')\n .replace(/<[^>]+>/g, ' ')\n .replace(/ /gi, ' ')\n .replace(/&/gi, '&')\n .replace(/'/g, \"'\")\n .replace(/"/g, '\"')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction keywordOccurrences(text, keyword) {\n if (!keyword) return 0;\n\n const matches =\n String(text || '').match(\n new RegExp(\n escapeRegExp(keyword),\n 'gi'\n )\n );\n\n return matches\n ? matches.length\n : 0;\n}\n\nlet focusKeyword =\n clean(j.focus_keyword);\n\nif (!focusKeyword) {\n focusKeyword =\n clean(j.title)\n .split(/\\s+/)\n .slice(0, 5)\n .join(' ');\n}\n\nlet seoTitle =\n clean(\n j.seo_title ||\n j.title\n );\n\nif (\n focusKeyword &&\n !startsWithCI(\n seoTitle,\n focusKeyword\n )\n) {\n seoTitle =\n `${focusKeyword}: ${seoTitle}`;\n}\n\nseoTitle =\n truncateAtWord(\n seoTitle,\n 60\n );\n\nlet seoDescription =\n clean(\n j.seo_meta_description ||\n j.excerpt ||\n j.short_summary ||\n j.title\n );\n\nif (\n focusKeyword &&\n !containsCI(\n seoDescription,\n focusKeyword\n )\n) {\n seoDescription =\n `${focusKeyword}: ${seoDescription}`;\n}\n\nseoDescription =\n truncateAtWord(\n seoDescription,\n 160\n );\n\nconst focusSlug =\n slugify(\n focusKeyword\n );\n\nlet existingSlug =\n slugify(\n j.slug ||\n j.title\n );\n\nif (\n focusSlug &&\n existingSlug.startsWith(\n focusSlug\n )\n) {\n existingSlug =\n existingSlug.slice(\n focusSlug.length\n ).replace(/^-+/, '');\n}\n\nlet slug =\n focusSlug\n ? (\n existingSlug\n ? `${focusSlug}-${existingSlug}`\n : focusSlug\n )\n : existingSlug;\n\nslug =\n slug\n .slice(0, 60)\n .replace(/-+$/g, '');\n\nlet imageAltText =\n clean(\n j.image_alt_text ||\n j.title\n );\n\nlet imageGenerationPrompt =\n clean(\n j.image_generation_prompt\n );\n\nif (!imageGenerationPrompt) {\n imageGenerationPrompt =\n `Create a realistic 16:9 editorial photojournalism image in India for the story \"${clean(j.title)}\". ` +\n `Show the relevant urban place, infrastructure, public institution, construction asset, mobility system or citizen interface described by the verified story facts. ` +\n `Natural lighting, credible documentary composition, no text, no logos, no watermarks, no sensational graphics, no invented completed infrastructure.`;\n}\n\nif (\n focusKeyword &&\n !containsCI(\n imageAltText,\n focusKeyword\n )\n) {\n imageAltText =\n `${focusKeyword} — ${imageAltText}`;\n}\n\nimageAltText =\n truncateAtWord(\n imageAltText,\n 180\n );\n\nlet body =\n String(\n j.body_content ||\n ''\n ).trim();\n\n// Ensure keyword occurs at the beginning of the public content.\n// Only apply when the model missed the hard SEO rule.\nif (\n body &&\n focusKeyword\n) {\n const plainBefore =\n stripTags(body);\n\n if (\n !startsWithCI(\n plainBefore,\n focusKeyword\n )\n ) {\n const escapedKeyword =\n escapeHtml(\n focusKeyword\n );\n\n if (/]*>/i.test(body)) {\n body =\n body.replace(\n /]*)>/i,\n `${escapedKeyword}: `\n );\n } else {\n body =\n `

${escapedKeyword}: ${body}

`;\n }\n }\n}\n\n// For Analysis, ensure one useful H2 contains the exact keyword.\n// Breaking remains Reuters-style unless it already has a heading.\nif (\n body &&\n focusKeyword &&\n String(j.route || '')\n .toUpperCase() === 'ANALYSIS'\n) {\n const h2s =\n body.match(\n /]*>[\\s\\S]*?<\\/h2>/gi\n ) || [];\n\n const h2HasKeyword =\n h2s.some(\n heading =>\n containsCI(\n stripTags(heading),\n focusKeyword\n )\n );\n\n if (!h2HasKeyword) {\n const escapedKeyword =\n escapeHtml(\n focusKeyword\n );\n\n const heading =\n `

${escapedKeyword}: What the evidence shows

`;\n\n let paragraphCount = 0;\n\n body =\n body.replace(\n /<\\/p>/gi,\n match => {\n paragraphCount++;\n\n if (\n paragraphCount === 3\n ) {\n return (\n match +\n heading\n );\n }\n\n return match;\n }\n );\n\n if (\n paragraphCount < 3\n ) {\n body += heading;\n }\n }\n}\n\nconst bodyPlain =\n stripTags(body);\n\nconst words =\n bodyPlain\n ? bodyPlain\n .split(/\\s+/)\n .filter(Boolean)\n .length\n : 0;\n\nconst occurrences =\n keywordOccurrences(\n bodyPlain,\n focusKeyword\n );\n\nconst keywordWordCount =\n focusKeyword\n ? focusKeyword\n .split(/\\s+/)\n .filter(Boolean)\n .length\n : 0;\n\nconst densityPercent =\n words > 0 &&\n keywordWordCount > 0\n ? Number(\n (\n (\n occurrences *\n keywordWordCount /\n words\n ) *\n 100\n ).toFixed(2)\n )\n : 0;\n\nreturn {\n json: {\n ...j,\n\n focus_keyword:\n focusKeyword,\n\n seo_title:\n seoTitle,\n\n seo_meta_description:\n seoDescription,\n\n slug,\n\n body_content:\n body,\n\n image_alt_text:\n imageAltText,\n\n image_generation_prompt:\n imageGenerationPrompt,\n\n rank_math_focus_keyword:\n focusKeyword,\n\n seo_package_ready:\n Boolean(\n focusKeyword &&\n seoTitle &&\n seoDescription &&\n slug &&\n imageAltText &&\n imageGenerationPrompt\n ),\n\n seo_guard: {\n focus_keyword_in_seo_title:\n containsCI(\n seoTitle,\n focusKeyword\n ),\n\n focus_keyword_at_seo_title_start:\n startsWithCI(\n seoTitle,\n focusKeyword\n ),\n\n focus_keyword_in_meta:\n containsCI(\n seoDescription,\n focusKeyword\n ),\n\n focus_keyword_in_slug:\n focusSlug\n ? slug.includes(focusSlug)\n : false,\n\n focus_keyword_at_content_start:\n startsWithCI(\n bodyPlain,\n focusKeyword\n ),\n\n focus_keyword_in_content:\n containsCI(\n bodyPlain,\n focusKeyword\n ),\n\n focus_keyword_in_h2:\n (\n body.match(\n /]*>[\\s\\S]*?<\\/h2>/gi\n ) || []\n ).some(\n heading =>\n containsCI(\n stripTags(heading),\n focusKeyword\n )\n ),\n\n focus_keyword_in_image_alt:\n containsCI(\n imageAltText,\n focusKeyword\n ),\n\n seo_title_length:\n seoTitle.length,\n\n meta_description_length:\n seoDescription.length,\n\n slug_length:\n slug.length,\n\n content_word_count:\n words,\n\n focus_keyword_occurrences:\n occurrences,\n\n estimated_keyword_density_percent:\n densityPercent\n }\n }\n};" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 9488, 11936 ], "id": "00a6889f-5f04-4eb7-a16c-7928d121b37f", "name": "SEO Guard → Rank Math Ready2", "notesInFlow": true, "notes": "Deterministic Rank Math guard. Guarantees focus keyword, title/meta/slug, image alt and a usable English image-generation prompt before publishing." }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const j = $json || {};\nconst c =\n $('Config + X Accounts6').first().json.config || {};\n\nconst postId =\n Number(\n j.wordpress_post_id\n );\n\nif (\n !Number.isFinite(postId) ||\n postId <= 0\n) {\n throw new Error(\n 'Cannot sync Rank Math: valid wordpress_post_id is missing.'\n );\n}\n\nconst tags =\n Array.isArray(j.story_tags)\n ? j.story_tags\n .map(v =>\n String(v || '').trim()\n )\n .filter(Boolean)\n .slice(0, 12)\n : [];\n\nreturn {\n json: {\n ...j,\n\n rank_math_request_body: {\n post_id:\n postId,\n\n rank_math_title:\n String(\n j.seo_title ||\n j.title ||\n ''\n ).trim(),\n\n rank_math_description:\n String(\n j.seo_meta_description ||\n j.excerpt ||\n ''\n ).trim(),\n\n rank_math_focus_keyword:\n (\n Array.isArray(j.focus_keywords) &&\n j.focus_keywords.length\n )\n ? j.focus_keywords\n .map(v => String(v || '').trim())\n .filter(Boolean)\n .slice(0, 5)\n .join(', ')\n : String(\n j.focus_keyword ||\n ''\n ).trim(),\n\n rank_math_pillar_content:\n 'off',\n\n tags,\n\n featured_media_id:\n Number(\n j.featured_media_id ||\n 0\n ),\n\n image_alt_text:\n String(\n j.image_alt_text ||\n ''\n ).trim(),\n\n post_excerpt:\n String(\n j.excerpt ||\n j.short_summary ||\n ''\n ).trim(),\n\n post_slug:\n String(\n j.slug ||\n ''\n ).trim(),\n\n source_url:\n String(\n j.source_url ||\n ''\n ).trim()\n },\n\n rank_math_bridge_url:\n String(\n c.wordpressBaseUrl ||\n ''\n ).replace(/\\/+$/,'') +\n String(\n c.rankMathBridgePath ||\n '/wp-json/urban-acres/v1/news-seo-tags'\n )\n }\n};" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 11280, 11936 ], "id": "36f34029-7236-451f-8832-fcc6dadfe266", "name": "Prepare Rank Math SEO Sync2", "notesInFlow": true, "notes": "Prepares Rank Math title/meta, 3-5 focus keywords (primary first), tags, slug, excerpt, featured-media ID and generated-image alt text." }, { "parameters": { "method": "POST", "url": "={{ $json.rank_math_bridge_url }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "wordpressApi", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Content-Type", "value": "application/json" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ $json.rank_math_request_body }}", "options": { "response": { "response": { "responseFormat": "json" } }, "timeout": 60000 } }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 11504, 11936 ], "id": "467320c8-5538-4945-99fd-368db9083c22", "name": "WordPress - Sync Rank Math SEO2", "alwaysOutputData": true, "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 3000, "notesInFlow": true, "credentials": { "wordpressApi": { "id": "fEYDt1jjJDoKpxrB", "name": "Wordpress account" } }, "notes": "Custom Urban Acres WordPress bridge for Rank Math metadata, tags, excerpt, featured-image assignment and image alt text. Native WordPress has no Rank Math operation." }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const src =\n $('Prepare Rank Math SEO Sync2').all()[$itemIndex]?.json ||\n {};\n\nconst body =\n $json ||\n {};\n\nconst ok =\n body?.success === true;\n\nconst featuredExpected =\n Number(\n src.featured_media_id ||\n 0\n );\n\nconst featuredActual =\n Number(\n body?.featured_media_id ||\n 0\n );\n\nconst featuredAttached =\n body?.featured_image_attached === true ||\n (\n featuredExpected > 0 &&\n featuredActual === featuredExpected\n );\n\nconst focusKeywordStored =\n String(\n body?.rank_math_focus_keyword ||\n ''\n ).trim();\n\nconst seoTitleStored =\n String(\n body?.rank_math_title ||\n ''\n ).trim();\n\nconst seoDescriptionStored =\n String(\n body?.rank_math_description ||\n ''\n ).trim();\n\nconst seoMetaApplied =\n Boolean(\n focusKeywordStored &&\n seoTitleStored &&\n seoDescriptionStored\n );\n\nif (!ok) {\n throw new Error(\n body?.message ||\n body?.code ||\n 'Rank Math/featured-image bridge returned success=false.'\n );\n}\n\nif (!featuredAttached) {\n throw new Error(\n `Featured image verification failed. Expected media ID ${featuredExpected}, WordPress returned ${featuredActual}.`\n );\n}\n\nif (!seoMetaApplied) {\n throw new Error(\n 'Rank Math verification failed: focus keyword, SEO title or meta description was not stored.'\n );\n}\n\nconst staticData =\n $getWorkflowStaticData(\n 'global'\n );\n\nstaticData.uaRankMathSyncs =\n Array.isArray(\n staticData.uaRankMathSyncs\n )\n ? staticData.uaRankMathSyncs\n : [];\n\nstaticData.uaRankMathSyncs.unshift({\n tweet_id:\n src.tweet_id ||\n null,\n\n wordpress_post_id:\n src.wordpress_post_id ||\n null,\n\n success:\n true,\n\n focus_keyword:\n src.focus_keyword ||\n null,\n\n seo_checks:\n body?.seo_checks ||\n null,\n\n featured_media_id:\n featuredActual,\n\n featured_image_attached:\n true,\n\n recorded_at:\n new Date().toISOString()\n});\n\nstaticData.uaRankMathSyncs =\n staticData.uaRankMathSyncs.slice(\n 0,\n 2000\n );\n\nreturn {\n json: {\n ...src,\n\n rank_math_sync_ok:\n true,\n\n rank_math_response:\n body,\n\n rank_math_seo_checks:\n body?.seo_checks ||\n null,\n\n featured_image_attached:\n true,\n\n seo_meta_verified:\n true\n }\n};" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 11728, 11936 ], "id": "a71d2d5f-d7ed-461c-88a0-ce4ef12a6d95", "name": "Record Rank Math SEO Result2", "notesInFlow": true, "notes": "Hard verification. Workflow now errors visibly if Rank Math metadata or featured image was not actually applied instead of silently continuing." }, { "parameters": { "resource": "image", "operation": "analyze", "modelId": { "__rl": true, "value": "gpt-4.1-mini", "mode": "id" }, "text": "=You are Urban Acres' visual evidence reader.\n\nRead every attached X image as source evidence for a newsroom story.\n\nReturn ONLY valid JSON using exactly this shape:\n{\n \"has_images\": true,\n \"has_readable_text\": true,\n \"image_summary\": \"concise factual visual summary\",\n \"ocr_text\": \"all clearly readable text, preserving spellings and figures\",\n \"visible_dates\": [\"...\"],\n \"visible_numbers\": [\"...\"],\n \"visible_names\": [\"...\"],\n \"visible_organisations\": [\"...\"],\n \"visible_locations\": [\"...\"],\n \"images\": [\n {\n \"image_url\": \"source image URL if known\",\n \"description\": \"visible factual description\",\n \"extracted_text\": \"readable text\",\n \"confidence_note\": \"uncertainty or conflict note\"\n }\n ]\n}\n\nRules:\n- Write image_summary, description and confidence_note fields in English regardless of the source-image language.\n- Keep ocr_text/extracted_text faithful to the visible source text; the editorial writer will translate it to English.\n- Extract clearly readable text, dates, numbers, names, organisations and locations.\n- Describe only visible evidence relevant to the X post.\n- Preserve spellings and figures exactly when readable.\n- Do not infer missing text or unseen project details.\n- If an image contains a map, notice, table, poster or announcement, capture its factual content.\n- If image evidence conflicts with post text, record the discrepancy instead of guessing.\n\nSOURCE X POST:\n{{ $json.post_content }}\n\nSOURCE URL:\n{{ $json.source_url }}", "imageUrls": "={{ ($json.image_urls || []).slice(0, 4).join(',') }}", "options": { "detail": "high", "maxTokens": 1800 } }, "type": "@n8n/n8n-nodes-langchain.openAi", "typeVersion": 2.1, "position": [ 8016, 12032 ], "id": "a869ec57-3e54-477b-ab53-923030e69731", "name": "OpenAI → Analyze Attached X Images1", "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 2500, "notesInFlow": true, "credentials": { "openAiApi": { "id": "8XseT381cDl1t6mI", "name": "OpenAI account" } }, "notes": "Native OpenAI Image → Analyze operation. No HTTP wrapper." }, { "parameters": { "resource": "image", "model": "gpt-image-1", "prompt": "={{ $json.final_image_prompt }}", "options": { "quality": "high", "size": "1536x1024" } }, "type": "@n8n/n8n-nodes-langchain.openAi", "typeVersion": 2.1, "position": [ 9936, 11936 ], "id": "6b263dd5-c1c1-42df-bc1b-22b8b9d3eb23", "name": "OpenAI → Generate Featured Image1", "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 4000, "notesInFlow": true, "credentials": { "openAiApi": { "id": "8XseT381cDl1t6mI", "name": "OpenAI account" } }, "notes": "Native OpenAI Image → Generate. Outputs binary directly into field data." }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const srcItems =\n $('Prepare AI Featured Image4').all();\n\nconst src =\n srcItems[$itemIndex]?.json ||\n {};\n\n// IMPORTANT:\n// $binary is NOT available inside the n8n Code node.\n// Use $input.item.binary instead.\nconst inputItem =\n $input.item;\n\nconst inputBinary =\n inputItem?.binary ||\n {};\n\nif (!inputBinary.data) {\n throw new Error(\n 'Generated image binary is missing from input field \"data\".'\n );\n}\n\nconst fileName =\n String(\n src.image_file_name ||\n inputBinary.data.fileName ||\n 'urban-acres-featured-image.png'\n ).trim();\n\nconst binary = {\n ...inputBinary,\n\n data: {\n ...inputBinary.data,\n\n fileName:\n fileName.toLowerCase().endsWith('.png')\n ? fileName\n : `${fileName}.png`,\n\n mimeType:\n inputBinary.data.mimeType ||\n 'image/png',\n\n fileExtension:\n inputBinary.data.fileExtension ||\n 'png'\n }\n};\n\nreturn {\n json: {\n ...src,\n\n generated_image_ready:\n true,\n\n generated_image_binary_field:\n 'data',\n\n generated_image_file_name:\n binary.data.fileName,\n\n generated_image_mime_type:\n binary.data.mimeType\n },\n\n binary\n};" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 10160, 11936 ], "id": "2fb45d6d-ab51-4815-92ba-3fbf06e8ce34", "name": "Prepare Generated Image Binary1", "notesInFlow": true, "notes": "FIXED for n8n Code node: reads binary via $input.item.binary, not $binary. Preserves the generated PNG and assigns a WordPress-safe filename." }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "const src = $('Prepare Generated Image Binary1').all()[$itemIndex]?.json || {};\nconst media = $json || {};\n\nconst mediaId = Number(media.id);\n\nif (!Number.isFinite(mediaId) || mediaId <= 0) {\n throw new Error(\n media?.message ||\n 'WordPress media upload did not return a valid media ID.'\n );\n}\n\nconst config =\n $('Config + X Accounts6').first().json.config || {};\n\nreturn {\n json: {\n ...src,\n\n featured_media_id:\n mediaId,\n\n featured_media_url:\n media.source_url ||\n media.guid?.rendered ||\n null,\n\n wordpress_status:\n config.wordpressStatus ||\n 'draft',\n\n wordpress_content:\n src.body_content,\n\n wordpress_title:\n src.title,\n\n wordpress_slug:\n src.slug\n }\n};" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 10608, 11936 ], "id": "417da720-d217-46f0-9ac8-b005db390764", "name": "Prepare Native WordPress Post1" }, { "parameters": { "title": "={{ $json.wordpress_title }}", "additionalFields": { "content": "={{ $json.wordpress_content }}", "slug": "={{ $json.wordpress_slug }}", "status": "={{ $json.wordpress_status }}", "format": "standard" } }, "type": "n8n-nodes-base.wordpress", "typeVersion": 1, "position": [ 10832, 11936 ], "id": "f06e7952-4de7-4e49-8703-ddcc538463f2", "name": "WordPress → Create Draft1", "retryOnFail": true, "maxTries": 2, "waitBetweenTries": 3000, "notesInFlow": true, "credentials": { "wordpressApi": { "id": "fEYDt1jjJDoKpxrB", "name": "Wordpress account" } }, "notes": "Native WordPress Post → Create operation. Replaces the HTTP post-creation block." } ], "connections": { "When clicking ‘Execute workflow’": { "main": [ [ { "node": "Config + X Accounts6", "type": "main", "index": 0 } ] ] }, "Schedule → 01:45 / 07:45 / 13:45 / 19:45 IST": { "main": [ [ { "node": "Config + X Accounts6", "type": "main", "index": 0 } ] ] }, "ChatGPT → X Editorial Writer": { "main": [ [ { "node": "Normalize Structured Editorial Package4", "type": "main", "index": 0 } ], [ { "node": "Log X Editorial Parser Error", "type": "main", "index": 0 } ] ] }, "OpenAI Chat Model → X Editorial": { "ai_languageModel": [ [ { "node": "ChatGPT → X Editorial Writer", "type": "ai_languageModel", "index": 0 } ] ] }, "Structured Output Parser → X Editorial": { "ai_outputParser": [ [ { "node": "ChatGPT → X Editorial Writer", "type": "ai_outputParser", "index": 0 } ] ] }, "Config + X Accounts6": { "main": [ [ { "node": "Build X Search Groups6", "type": "main", "index": 0 } ] ] }, "Build X Search Groups6": { "main": [ [ { "node": "Search Recent X Posts6", "type": "main", "index": 0 } ] ] }, "Search Recent X Posts6": { "main": [ [ { "node": "Handle X Errors + Prepare Fresh Posts6", "type": "main", "index": 0 } ] ] }, "Handle X Errors + Prepare Fresh Posts6": { "main": [ [ { "node": "Has Attached X Images?4", "type": "main", "index": 0 } ] ] }, "Has Attached X Images?4": { "main": [ [ { "node": "OpenAI → Analyze Attached X Images1", "type": "main", "index": 0 } ], [ { "node": "No X Image Evidence4", "type": "main", "index": 0 } ] ] }, "Parse X Image Evidence4": { "main": [ [ { "node": "Prepare Multimodal Editorial Input4", "type": "main", "index": 0 } ] ] }, "No X Image Evidence4": { "main": [ [ { "node": "Prepare Multimodal Editorial Input4", "type": "main", "index": 0 } ] ] }, "Prepare Multimodal Editorial Input4": { "main": [ [ { "node": "ChatGPT → X Editorial Writer", "type": "main", "index": 0 } ] ] }, "Normalize Structured Editorial Package4": { "main": [ [ { "node": "Approved for Publishing?5", "type": "main", "index": 0 } ] ] }, "Approved for Publishing?5": { "main": [ [ { "node": "SEO Guard → Rank Math Ready2", "type": "main", "index": 0 } ], [ { "node": "Log Rejected or Held6", "type": "main", "index": 0 } ] ] }, "Prepare AI Featured Image4": { "main": [ [ { "node": "OpenAI → Generate Featured Image1", "type": "main", "index": 0 } ] ] }, "WordPress - Upload Generated Featured Image4": { "main": [ [ { "node": "Prepare Native WordPress Post1", "type": "main", "index": 0 } ] ] }, "Record Publication Result4": { "main": [ [ { "node": "Prepare Rank Math SEO Sync2", "type": "main", "index": 0 } ] ] }, "SEO Guard → Rank Math Ready2": { "main": [ [ { "node": "Prepare AI Featured Image4", "type": "main", "index": 0 } ] ] }, "Prepare Rank Math SEO Sync2": { "main": [ [ { "node": "WordPress - Sync Rank Math SEO2", "type": "main", "index": 0 } ] ] }, "WordPress - Sync Rank Math SEO2": { "main": [ [ { "node": "Record Rank Math SEO Result2", "type": "main", "index": 0 } ] ] }, "OpenAI → Analyze Attached X Images1": { "main": [ [ { "node": "Parse X Image Evidence4", "type": "main", "index": 0 } ] ] }, "OpenAI → Generate Featured Image1": { "main": [ [ { "node": "Prepare Generated Image Binary1", "type": "main", "index": 0 } ] ] }, "Prepare Generated Image Binary1": { "main": [ [ { "node": "WordPress - Upload Generated Featured Image4", "type": "main", "index": 0 } ] ] }, "Prepare Native WordPress Post1": { "main": [ [ { "node": "WordPress → Create Draft1", "type": "main", "index": 0 } ] ] }, "WordPress → Create Draft1": { "main": [ [ { "node": "Record Publication Result4", "type": "main", "index": 0 } ] ] } }, "pinData": {}, "meta": { "templateCredsSetupCompleted": true, "instanceId": "da61948d16e66322b38da2910677be2692ef9be4aa663a2dd88e3adcaaf956e0" }, "active": false, "name": "Urban Acres X — Strong Parser + English + Image + Rank Math v1" } Lakshadweep Tenders – mmrtoday.com https://mmrtoday.com Tue, 21 Jul 2026 08:53:22 +0000 en-US hourly 1 https://wordpress.org/?v=7.1