Guide · Confluence Cloud

How to export Questions for Confluence Cloud data

An exporter for Questions for Confluence in Cloud is a feature request on Atlassian's tracker, marked Future Consideration (CONFCLOUD-51723). You don't have to wait for it. Questions for Confluence stores its questions and answers as Confluence content, and the Confluence REST API can read that content. This guide shows how.

Where Questions for Confluence keeps its data

Questions for Confluence stores everything as Confluence content of its own types. This is what we found on a Confluence Cloud site:

  • Questions are content of the type ac:com.atlassian.confluence.plugins.confluence-questions:question.
  • Answers are content of the type ac:com.atlassian.confluence.plugins.confluence-questions:answer. The container of an answer is its question.
  • Text is in body.storage, Confluence's storage format (HTML). The author and date are in history.createdBy and history.createdDate.
  • Topics are ordinary labels on the question.
  • The accepted answer is a content property on the question, atl_cq_accepted_answer, with a value like {"answer_id": "1376269"}.

Because it is Confluence content, the API only returns what your own account may see. Use an account that can see every space with questions.

What you need

  • An API token for your Atlassian account, made at id.atlassian.com (Atlassian: Manage API tokens for your Atlassian account). Treat it like a password and revoke it when you're done.
  • Basic auth: your account email as the user name and the token as the password (Atlassian: Basic auth for REST APIs).
  • Python 3, only for the script below. It uses nothing outside the standard library.

Try it with curl

This asks Confluence's search for the first five questions, with their text, topics, space and accepted answer:

curl -s -u "you@example.com:$API_TOKEN" -G \
  "https://your-site.atlassian.net/wiki/rest/api/content/search" \
  --data-urlencode 'cql=type = "ac:com.atlassian.confluence.plugins.confluence-questions:question" order by created asc' \
  --data-urlencode 'expand=body.storage,history,metadata.labels,space,metadata.properties.atl_cq_accepted_answer' \
  --data-urlencode 'limit=5'

Each result is one question: id, title, space, the question in body.storage.value, the topics in metadata.labels and, when there is one, the accepted answer's ID in metadata.properties.atl_cq_accepted_answer.value.answer_id. For the answers, put the answer type in the CQL and use expand=body.storage,history,container,space; container.id is the ID of the question.

When there are more results, the response has _links.next: a link with a cursor to the next page (Atlassian: Confluence REST API, search).

Export everything with a short script

This script reads every question and answer your account can see, page by page, and writes two files: qfc-export.json with everything Confluence returned, and qfc-export.csv with one row per question and one per answer. For a question, the accepted column holds the ID of the accepted answer; for an answer, it says yes when that answer is the accepted one.

# Export Questions for Confluence Cloud: questions and answers to JSON and CSV.
# Run: SITE=your-site.atlassian.net EMAIL=you@example.com API_TOKEN=... python3 export_qfc.py
import base64, csv, json, os, urllib.parse, urllib.request

SITE = os.environ["SITE"]
AUTH = base64.b64encode(f"{os.environ['EMAIL']}:{os.environ['API_TOKEN']}".encode()).decode()
QFC = "ac:com.atlassian.confluence.plugins.confluence-questions"


def search(kind, expand):
    """All content of one Questions type, oldest first, page by page."""
    params = {"cql": f'type = "{QFC}:{kind}" order by created asc', "expand": expand, "limit": 50}
    url = f"https://{SITE}/wiki/rest/api/content/search?" + urllib.parse.urlencode(params)
    while url:
        request = urllib.request.Request(url, headers={"Authorization": f"Basic {AUTH}", "Accept": "application/json"})
        with urllib.request.urlopen(request) as response:
            data = json.load(response)
        yield from data["results"]
        links = data.get("_links", {})
        url = links.get("base", f"https://{SITE}/wiki") + links["next"] if links.get("next") else None


questions = list(search("question", "body.storage,history,metadata.labels,space,metadata.properties.atl_cq_accepted_answer"))
answers = list(search("answer", "body.storage,history,container,space"))


def accepted_answer_id(question):
    prop = question.get("metadata", {}).get("properties", {}).get("atl_cq_accepted_answer") or {}
    return str((prop.get("value") or {}).get("answer_id", ""))


def row(item, kind, question_id, accepted):
    history = item.get("history", {})
    labels = item.get("metadata", {}).get("labels", {}).get("results", [])
    return {
        "type": kind,
        "id": item["id"],
        "question_id": question_id,
        "space": item.get("space", {}).get("key", ""),
        "title": item.get("title", ""),
        "author": history.get("createdBy", {}).get("displayName", ""),
        "created": history.get("createdDate", ""),
        "topics": " ".join(label["name"] for label in labels),
        "accepted": accepted,
        "body_html": item.get("body", {}).get("storage", {}).get("value", ""),
    }


answers_by_question = {}
for answer in answers:
    answers_by_question.setdefault(str(answer.get("container", {}).get("id")), []).append(answer)

rows = []
for question in questions:
    accepted = accepted_answer_id(question)
    rows.append(row(question, "question", question["id"], accepted))
    for answer in answers_by_question.get(question["id"], []):
        rows.append(row(answer, "answer", question["id"], "yes" if answer["id"] == accepted else ""))

with open("qfc-export.json", "w", encoding="utf-8") as f:
    json.dump({"questions": questions, "answers": answers}, f, indent=2, ensure_ascii=False)
with open("qfc-export.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["type", "id", "question_id", "space", "title", "author",
                                           "created", "topics", "accepted", "body_html"])
    writer.writeheader()
    writer.writerows(rows)
print(f"{len(questions)} questions, {len(answers)} answers")

The text stays in Confluence's storage format (HTML), so anything you import elsewhere needs to handle HTML.

Moving to another app instead

Full disclosure: I build AskSpace at Dortus. If you want to move your questions rather than keep a copy, AskSpace reads Questions for Confluence on the same site directly: questions, answers, topics and accepted answers. You see a preview before anything is saved, and you can undo the import afterwards. Nothing changes in Questions for Confluence. AskSpace is listed on the Atlassian Marketplace; Getting started with AskSpace has the steps, and AskSpace vs Questions for Confluence compares the two.

Questions

Did the script not work on your site, or did we miss something? Email support@dortus.com.

AskSpace by Dortus

Questions and answers per Confluence space

Ask, answer, vote and mark one best answer, with separate rules for who may ask and who may answer. Runs on Atlassian Forge; your questions are stored by Atlassian, not on Dortus servers.

About AskSpace

Available on the Atlassian Marketplace.

See also: Q&A in Confluence Cloud: your options