Database
 sql >> Base de Dados >  >> RDS >> Database

Migrando do AnswerHub para o WordPress:um conto de 10 tecnologias


Recentemente, lançamos um novo site de suporte, onde você pode fazer perguntas, enviar comentários sobre produtos ou solicitações de recursos ou abrir tíquetes de suporte. Parte do objetivo era centralizar todos os locais onde estávamos atendendo a comunidade. Isso incluiu o site de perguntas e respostas SQLPerformance.com, onde Paul White, Hugo Kornelis e muitos outros têm ajudado a resolver suas questões mais complicadas de ajuste de consulta e plano de execução, desde fevereiro de 2013. Digo a você com sentimentos contraditórios que o O site de perguntas e respostas foi encerrado.

Há uma vantagem, no entanto. Agora você pode fazer essas perguntas difíceis no novo fórum de suporte. Se você está procurando o conteúdo antigo, bem, ele ainda está lá, mas parece um pouco diferente. Por uma variedade de razões que não vou abordar hoje, uma vez que decidimos encerrar o site de perguntas e respostas original, decidimos simplesmente hospedar todo o conteúdo existente em um site WordPress somente leitura, em vez de migrá-lo para o back-end do novo sítio.

Esta postagem não é sobre os motivos por trás dessa decisão.

Eu me senti muito mal com a rapidez com que o site de respostas teve que ficar offline, o DNS mudou e o conteúdo migrou. Como um banner de aviso foi implementado no site, mas o AnswerHub não o tornou visível, isso foi um choque para muitos usuários. Então, eu queria ter certeza de manter adequadamente o máximo de conteúdo possível, e queria que estivesse certo. Este post está aqui porque achei que seria interessante falar sobre o processo real, quantas peças diferentes de tecnologia estavam envolvidas para realizá-lo e mostrar o resultado. Não espero que nenhum de vocês se beneficie desse ponta a ponta, pois esse é um caminho de migração relativamente obscuro, mas mais como um exemplo de unir várias tecnologias para realizar uma tarefa. Também serve como um bom lembrete para mim mesmo de que muitas coisas não acabam sendo tão fáceis quanto parecem antes de você começar.

O TL;DR é o seguinte:gastei muito tempo e esforço para fazer o conteúdo arquivado parecer bom, embora ainda esteja tentando recuperar as últimas postagens que chegaram no final. Usei essas tecnologias:
  1. Perl
  2. SQL Server
  3. PowerShell
  4. Transmitir (FTP)
  5. HTML
  6. CSS
  7. C#
  8. MarkdownSharp
  9. phpMyAdmin
  10. MySQL

Daí o título. Se você quer um grande pedaço dos detalhes sangrentos, aqui estão eles. Se você tiver alguma dúvida ou feedback, entre em contato ou comente abaixo.

O AnswerHub forneceu um arquivo de despejo de 665 MB do banco de dados MySQL que hospedava o conteúdo de perguntas e respostas. Cada editor que tentei engasgou com isso, então primeiro tive que dividi-lo em um arquivo por tabela usando este prático script Perl de Jared Cheney. As tabelas que eu precisava eram chamadas de network11_nodes (perguntas, respostas e comentários), network11_authoritables (usuários) e network11_managed_files (todos os anexos, incluindo uploads de planos):perl extract_sql.pl -t network11_nodes -r dump.sql>> nodes.sql
perl extract_sql.pl -t network11_authoritables -r dump.sql>> users.sql
perl extract_sql.pl -t network11_managed_files -r dump.sql>> files.sql
Agora, eles não eram extremamente rápidos para carregar no SSMS, mas pelo menos eu poderia usar Ctrl +H para alterar (por exemplo) isso:
CREATE TABLE `network11_managed_files` (
  `c_id` bigint(20) NOT NULL,
  ...
);
 
INSERT INTO `network11_managed_files` (`c_id`, ...) VALUES (1, ...);

Para isso:
CREATE TABLE dbo.files
(
  c_id bigint NOT NULL,
  ...
);
 
INSERT dbo.files (c_id, ...) VALUES (1, ...);

Então eu poderia carregar os dados no SQL Server para que eu pudesse manipulá-los. E acredite, eu manipulei.

Em seguida, tive que recuperar todos os anexos. Veja, o arquivo de despejo do MySQL que recebi do fornecedor continha um zilhão de INSERT instruções, mas nenhum dos arquivos de plano reais que os usuários enviaram — o banco de dados tinha apenas os caminhos relativos para os arquivos. Usei o T-SQL para criar uma série de comandos do PowerShell que chamariam Invoke-WebRequest para recuperar todos os arquivos e armazená-los localmente (muitas maneiras de esfolar este gato, mas isso foi muito fácil). A partir disso:
SELECT 'Invoke-WebRequest -Uri '
  + '"$($url)' + RTRIM(c_id) + '-' + c_name + '"'
  + ' -OutFile "E:\s\temp\' + RTRIM(c_id) + '-' + c_name + '";'
  FROM dbo.files
  WHERE LOWER(c_mime_type) LIKE 'application/%';

Isso gerou esse conjunto de comandos (junto com um pré-comando para resolver esse problema de TLS); a coisa toda correu bem rápido, mas eu não recomendo essa abordagem para qualquer combinação de {massive set of files} e/ou {low bandwidth}:
$AllProtocols = [System.Net.SecurityProtocolType]'Ssl3,Tls,Tls11,Tls12';
[System.Net.ServicePointManager]::SecurityProtocol = $AllProtocols;
$u = "https://answers.sqlperformance.com/s/temp/";
 
Invoke-WebRequest -Uri "$($u)/1-proc.pesession"   -OutFile "E:\s\temp\1-proc.pesession";
Invoke-WebRequest -Uri "$($u)/14-test.pesession"  -OutFile "E:\s\temp\14-test.pesession";
Invoke-WebRequest -Uri "$($u)/15-a.QueryAnalysis" -OutFile "E:\s\temp\15-a.QueryAnalysis";
...

Isso baixou quase todos os anexos, mas, reconhecidamente, alguns foram perdidos devido a erros no site antigo quando foram carregados inicialmente. Portanto, no novo site, você pode ver ocasionalmente uma referência a um anexo que não existe.

Então eu usei o Panic Transmit 5 para fazer o upload do temp pasta para o novo site e agora, quando o conteúdo for carregado, links para /s/temp/1-proc.pesession continuará trabalhando.

Em seguida, mudei para SSL. Para solicitar um certificado no novo site WordPress, tivemos que atualizar o DNS para answers.sqlperformance.com para apontar para o CNAME em nosso host WordPress, WPEngine. Foi uma espécie de galinha e ovo aqui - tivemos que sofrer algum tempo de inatividade para URLs https, que falhariam por nenhum certificado no novo site. Tudo bem porque o certificado do site antigo havia expirado, então, na verdade, não estávamos em pior situação. Eu também tive que esperar para fazer isso até que eu tivesse baixado todos os arquivos do site antigo, porque uma vez que o DNS virasse, não haveria como chegar até eles, exceto por alguma porta dos fundos.

Enquanto esperava a propagação do DNS, comecei a trabalhar na lógica para colocar todas as perguntas, respostas e comentários em algo consumível no WordPress. Não apenas os esquemas de tabela eram diferentes do WordPress, os tipos de entidades também são bem diferentes. Minha visão era combinar cada pergunta – e quaisquer respostas e/ou comentários – em um único post.

A parte complicada é que a tabela de nós contém apenas todos os três tipos de conteúdo na mesma tabela, com referências pai e pai original ("mestre"). Seu código de front-end provavelmente usa algum tipo de cursor para percorrer e exibir o conteúdo em ordem hierárquica e cronológica. Eu não teria esse luxo no WordPress, então tive que encadear o HTML de uma só vez. Apenas como exemplo, aqui está a aparência dos dados:
SELECT c_type, c_id, c_parent, oParent = c_originalParent, c_creation_date, c_title
  FROM dbo.nodes 
  WHERE c_originalParent = 285;
 
/*
c_type      c_id    c_parent  oParent  c_creation_date   accepted  c_title
----------  ------  --------  -------  ----------------  --------  -------------------------
question    285     NULL      285      2013-02-13 16:30            why is the MERGE JOIN ...
answer      287     285       285      2013-02-14 01:15  1         NULL
comment     289     285       285      2013-02-14 13:35            NULL
answer      293     285       285      2013-02-14 18:22            NULL
comment     294     287       285      2013-02-14 18:29            NULL
comment     298     285       285      2013-02-14 20:40            NULL
comment     299     298       285      2013-02-14 18:29            NULL
*/

Eu não podia ordenar por id, ou tipo, ou por pai, pois às vezes um comentário vinha mais tarde em uma resposta anterior, a primeira resposta nem sempre seria a resposta aceita e assim por diante. Eu queria essa saída (onde ++ representa um nível de recuo):
/*
c_type        c_id    c_parent  oParent  c_creation_date   reason
----------    ------  --------  -------  ----------------  -------------------------
question      285     NULL      285      2013-02-13 16:30  question is ALWAYS first
++comment     289     285       285      2013-02-14 13:35  comments on the question before answers
answer        287     285       285      2013-02-14 01:15  first answer (accepted = 1)
++comment     294     287       285      2013-02-14 18:29  first comment on first answer
++comment     298     287       285      2013-02-14 20:40  second comment on first answer
++++comment   299     298       285      2013-02-14 18:29  reply to second comment on first answer
answer        293     285       285      2013-02-14 18:22  second answer
*/

Comecei a escrever um CTE recursivo e, parcialmente devido ao excesso de Rekorderlig naquela noite, recrutei a ajuda do colega Gerente de Produto, Andy Mallon (@AMtwo). Ele me ajudou a preparar essa consulta, que retornaria as postagens em sua ordem de exibição adequada (e você pode experimentar este trecho, alterando os pais e/ou a resposta aceita, para ver se a ordem correta ainda será retornada):
DECLARE @foo TABLE
(
  c_type varchar(255), 
  c_id int, 
  c_parent int, 
  oParent int,
  accepted bit
);
 
INSERT @foo(c_type, c_id, c_parent, oParent, accepted) VALUES
('question', 285, NULL, 285, 0),
('answer',   287, 285 , 285, 1),
('comment',  289, 285 , 285, 0),
('comment',  294, 287 , 285, 0),
('comment',  298, 287 , 285, 0),
('comment',  299, 298 , 285, 0),
('answer',   293, 285 , 285, 0);
 
;WITH cte AS 
(
  SELECT 
    lvl = 0,
    f.c_type,
    f.c_id, f.c_parent, f.oParent,
    Sort = CONVERT(varchar(255),RIGHT('00000' + CONVERT(varchar(5),f.c_id),5))
  FROM @foo AS f WHERE f.c_parent IS NULL
  UNION ALL
  SELECT 
    lvl = c.lvl + 1,
    c_type = CONVERT(varchar(255), CASE
        WHEN f.accepted = 1 THEN 'accepted answer'
        WHEN f.c_type = 'comment' THEN c.c_type + ' ' + f.c_type
        ELSE f.c_type
      END),
    f.c_id, f.c_parent, f.oParent,
    Sort = CONVERT(varchar(255),c.Sort + RIGHT('00000' + CONVERT(varchar(5),f.c_id),5))
  FROM @foo AS f INNER JOIN cte AS c ON c.c_id = f.c_parent
)
SELECT lvl = CASE lvl WHEN 0 THEN 1 ELSE lvl END, c_type, c_id, c_parent, oParent, Sort
FROM cte
ORDER BY 
  oParent,
  CASE
    WHEN c_type LIKE 'question%'        THEN 1 -- it's a question *or* a comment on the question
    WHEN c_type LIKE 'accepted answer%' THEN 2 -- accepted answer *or* comment on accepted answer
    ELSE 3 END,
  Sort;

Resultados:
/*
lvl  c_type                            c_id        c_parent    oParent     Sort
---- --------------------------------- ----------- ----------- ----------- --------------------
1    question                          285         NULL        285         00285               
1    question comment                  289         285         285         0028500289          
1    accepted answer                   287         285         285         0028500287          
2    accepted answer comment           294         287         285         002850028700294     
2    accepted answer comment           298         287         285         002850028700298     
3    accepted answer comment comment   299         298         285         00285002870029800299
1    answer                            293         285         285         0028500293     
*/

Gênio. Eu localizei uma dúzia ou mais de outros, e fiquei feliz por estar passando para a próxima etapa. Agradeci profusamente a Andy várias vezes, mas deixe-me fazer de novo:Obrigado Andy!

Agora que eu poderia retornar todo o conjunto na ordem que eu gostaria, eu tive que realizar alguma manipulação da saída para aplicar elementos HTML e nomes de classe que me permitiriam marcar perguntas, respostas, comentários e recuo de forma significativa. O objetivo final era uma saída que se parecia com isso (e lembre-se, este é um dos casos mais simples):
<div class="question">
  <span class="authorq" title=" Author : author name ">
    <i class="fas fa-user"></i>Author name</span> 
  <span class="createdq" title=" February 13th, 2013 ">
    <i class="fas fa-calendar-alt"></i>2013-02-13 16:30:36</span>
 
  <div class=mainbodyq>I don't understand why the merge operator is passing over 4million 
  rows to the hash match operator when there is only 41K and 19K from other operators.
 
	<div class=attach><i class="fas fa-file"></i>
	  <a target="_blank" href="/s/temp/254-tmp4DA0.queryanalysis" rel="noopener noreferrer">
      /s/temp/254-tmp4DA0.queryanalysis</a>
	</div>
  </div>
 
  <div class="comment indent1 ">
    <div class=linecomment>
	  <span class="authorc" title=" Author : author name ">
	    <i class="fas fa-user"></i>author name</span>
	  <span class="createdc" title=" February 14th, 2013 ">
	    <i class="fas fa-calendar-alt"></i>2013-02-14 13:35:39</span>
	</div>
    <div class=mainbodyc>
	  I am still trying to understand the significant amount of rows from the MERGE operator. 
	  Unless it's a result of a Cartesian product from the two inputs then finally the WHERE 
	  predicate is applied to filter out the unmatched rows leaving the 4 million row count.
    </div>
  </div>
  <div class="answer indent1 [accepted]">
    <div class=lineanswer>
	  <span class="authora" title=" Author : author name ">
	    <i class="fas fa-user"></i>author name</span>
	  <span class="createda" title=" February 14th, 2013 ">
	    <i class="fas fa-calendar-alt"></i>2013-02-14 01:15:42</span>
	</div>
    <div class=mainbodya>
	    The reason for the large number of rows can be seen in the Plan Explorer tool tip for 
		the Merge Join operator:
 
	    <img src="/s/temp/259-sp.png" alt="Merge Join tool tip" />
	  	...
	</div>
  </div>
</div>

Não vou percorrer o número ridículo de iterações pelas quais tive que passar para chegar a uma forma confiável dessa saída para todos os mais de 5.000 itens (o que se traduziu em quase 1.000 postagens depois que tudo foi colado). Além disso, eu precisava gerá-los na forma de INSERT declarações que eu poderia colar no phpMyAdmin no site WordPress, o que significava aderir ao seu diagrama de sintaxe bizarro. Essas declarações precisavam incluir outras informações adicionais exigidas pelo WordPress, mas não presentes ou precisas nos dados de origem (como post_type ). E esse console de administração expiraria devido a muitos dados, então eu tive que dividi-lo em ~ 750 inserções por vez. Aqui está o procedimento com o qual acabei (isso não é realmente para aprender nada específico, apenas uma demonstração de quanta manipulação dos dados importados foi necessária):
CREATE /* OR ALTER */ PROCEDURE dbo.BuildMySQLInserts
  @LowerBound int = 1, 
  @UpperBound int = 750
AS
BEGIN
  SET NOCOUNT ON;
 
  ;WITH CTE AS 
  (
    SELECT lvl = 0,
            [type] = CONVERT(varchar(100),f.[type]),
            f.id,
            f.parent,
            f.master_parent,
            created = CONVERT(char(10), f.created, 120) + ' ' 
			        + CONVERT(char(8),  f.created, 108),
            f.state,
            Sort = CONVERT(varchar(100),RIGHT('0000000000' 
			     + CONVERT(varchar(10),f.id),10))
    FROM dbo.foo AS f
    WHERE f.type = 'question' 
      AND master_parent BETWEEN @LowerBound AND @UpperBound
    UNION ALL
    SELECT lvl = c.lvl + 1,
            CONVERT(varchar(100),CASE
                WHEN f.[state] = '[accepted]' THEN 'accepted answer'
                WHEN f.type = 'comment' THEN c.type + ' ' + f.type
                ELSE f.type
            END),
            f.id,
            f.parent,
            f.master_parent,
            created = CONVERT(char(10), f.created, 120) + ' ' 
			        + CONVERT(char(8), f.created, 108),
            f.state,
            Sort = CONVERT(varchar(100),c.sort + RIGHT('0000000000' 
			     + CONVERT(varchar(10),f.id),10))
    FROM dbo.foo AS f
    JOIN CTE AS c ON c.id = f.parent
)
SELECT 
  master_parent, 
  prefix = CASE WHEN lvl = 0 THEN 
    CONVERT(varchar(11), master_parent) + ', 3, ''' + created + ''', ''' 
	+ created + ''',''' END, 
  bodypre = '<div class="' + COALESCE(c_type, RTRIM(LEFT([type],8))) 
	  + CASE WHEN c_type <> 'question' THEN ' indent' + RTRIM(lvl) 
	  + COALESCE(' ' + [state], '') ELSE '' END + '">'
	  + CASE WHEN c_type <> 'question' THEN 
	    '<div class=line' + c_type + '>' ELSE '' END 
	  + '<span class="author' + LEFT(c_type, 1) + '" title=" Author : ' 
	  + REPLACE(REPLACE(Fullname,'''','\'''),'"','') 
	  + ' "><i class="fas fa-user"></i>' + REPLACE(Fullname,'''','\''') --"
	  + '</span> <span class="created' + LEFT(c_type,1) + '" title=" ' 
	  + DATENAME(MONTH, c_creation_date) + ' ' + RTRIM(DAY(c_creation_date)) 
	  + CASE 
        WHEN DAY(c_creation_date) IN (1,21,31) THEN 'st'
        WHEN DAY(c_creation_date) IN (2,22) THEN 'nd'
        WHEN DAY(c_creation_date) IN (3,23) THEN 'rd' ELSE 'th' END
        + ', ' + RTRIM(YEAR(c_creation_date)) 
      + ' "><i class="fas fa-calendar-alt"></i>' + created + '</span>'
      + CASE WHEN c_type <> 'question' THEN '</div>' ELSE '' END,
  body = '<div class=mainbody' + left(c_type,1) + '>' 
	  + REPLACE(REPLACE(c_body, char(39), '\' + char(39)), '’', '\' + char(39)),
  bodypost = COALESCE(urls, '') + '</div></div>',--' 
	  + CASE WHEN c_type = 'question' THEN '</div>' ELSE '' END, 
  suffix = ''',''' + REPLACE(n.c_title, '''', '\''') + ''','''',''publish'',
	  ''closed'',''closed'','''',''' + REPLACE(n.c_plug, '''', '\''') 
	  + ''','''','''',''' + created + ''',''' + created + ''','''',0,
	  ''https://answers.sqlperformance.com/?p=' + CONVERT(varchar(11), master_parent) 
	  + ''', 0, ''post'','''',0);',
  rn = RTRIM(ROW_NUMBER() OVER (PARTITION BY master_parent 
      ORDER BY master_parent,
      CASE
        WHEN [type] LIKE 'question%' THEN 1
        WHEN [type] LIKE 'accepted answer%' THEN 2
        ELSE 3
      END,
      Sort)), 
  c = RTRIM(COUNT(*) OVER (PARTITION BY master_parent))
FROM CTE
LEFT OUTER JOIN dbo.network11_nodes AS n
ON cte.id = n.c_id
LEFT OUTER JOIN dbo.Users AS u
ON n.c_author = u.UserID
LEFT OUTER JOIN 
(
  SELECT NodeID, urls = STRING_AGG('<div class=attach>
    <i class="fas fa-file' 
	+ CASE WHEN c_mime_type IN ('image/jpeg','image/png') 
      THEN '-image' ELSE '' END 
    + '"></i><a target="_blank" href=' + url + ' rel="noopener noreferrer">' + url + '</a></div>', '\n') 
  FROM dbo.Attachments 
  GROUP BY NodeID
) AS a
ON n.c_id = a.NodeID
ORDER BY master_parent,
  CASE
    WHEN [type] LIKE 'question%' THEN 1
    WHEN [type] LIKE 'accepted answer%' THEN 2
    ELSE 3
  END,
  Sort;
END
GO

A saída disso não está completa e ainda não está pronta para ser inserida no WordPress:

Saída de amostra (clique para ampliar)

Eu precisaria de alguma ajuda adicional do C# para transformar o conteúdo real (incluindo markdown) em HTML e CSS que eu pudesse controlar melhor e escrever a saída (um monte de INSERT instruções que incluíam um monte de código HTML) para arquivos no disco que eu poderia abrir e colar no phpMyAdmin. Para o HTML, texto simples + markdown que começou assim:
Há um [post aqui no blog][1] que fala sobre isso, e também [este post](https://somewhere).

SELECT algo de dbo.sometable;

[1]:https://em outro lugar

Precisaria se tornar isso:
Há uma postagem de blog aqui que fala sobre isso, e também esta postagem .

SELECIONE algo de dbo.sometable;


Para conseguir isso, recrutei a ajuda do MarkdownSharp, uma biblioteca de código aberto originada no Stack Overflow que lida com grande parte da conversão de markdown para HTML. Foi um bom ajuste para minhas necessidades, mas não perfeito; Eu ainda teria que realizar mais manipulações:
  • MarkdownSharp não permite coisas como target=_blank , então eu mesmo teria que injetá-los após o processamento;
  • código (qualquer coisa prefixada com quatro espaços) herda os wrappers
    using System.Text;
    using System.Data;
    using System.Data.SqlClient;
    using MarkdownSharp;
    using System.IO;
     
    namespace AnswerHubMigrator
    {
      class Program
      {
        static void Main(string[] args)
        {
          StringBuilder output;
          string suffix = "";
          string thisfile = "";
     
          // pass two arguments on the command line, e.g. 1, 750
          int LowerBound = int.Parse(args[0]);
          int UpperBound = int.Parse(args[1]);
     
          // auto-expand URLs, and only accept bold/italic markdown
          // when it completely surrounds an entire word
          var options = new MarkdownOptions
          {
            AutoHyperlink = true,
            StrictBoldItalic = true
          };
          MarkdownSharp.Markdown mark = new MarkdownSharp.Markdown(options);
     
          using (var conn = new SqlConnection("Server=.\\SQL2017;Integrated Security=true"))
          using (var cmd = new SqlCommand("MigrateDB.dbo.BuildMySQLInserts", conn))
          {
     
            cmd.CommandType = CommandType.StoredProcedure;
            cmd.Parameters.Add("@LowerBound", SqlDbType.Int).Value = LowerBound;
            cmd.Parameters.Add("@UpperBound", SqlDbType.Int).Value = UpperBound;
            conn.Open();
            using (var reader = cmd.ExecuteReader())
            {
              // use a StringBuilder to dump output to a file
              output = new StringBuilder();
              while (reader.Read())
              {
                // on first pass, make a new delete/insert
                // delete is to make the commands idempotent
                if (reader["rn"].Equals("1"))
                {
     
                  // for each master parent, I would create a
                  // new WordPress post, inheriting the parent ID
                  output.Append("DELETE FROM `wp_posts` WHERE ID = ");
                  output.Append(reader["master_parent"].ToString());
                  output.Append("; INSERT INTO `wp_posts` (`ID`, `post_author`, ");
                  output.Append("`post_date`, `post_date_gmt`, `post_content`, ");
                  output.Append("`post_title`, `post_excerpt`, `post_status`, ");
                  output.Append("`comment_status`, `ping_status`, `post_password`,");
                  output.Append(" `post_name`, `to_ping`, `pinged`, `post_modified`,");
                  output.Append(" `post_modified_gmt`, `post_content_filtered`, ");
                  output.Append("`post_parent`, `guid`, `menu_order`, `post_type`, ");
                  output.Append("`post_mime_type`, `comment_count`) VALUES (");
     
                  // I'm sure some of the above columns are optional, but identifying
                  // those would not be a valuable use of time IMHO
     
                  output.Append(reader["prefix"]);
     
                  // hold on to the additional values until last row
                  suffix = reader["suffix"].ToString();
                }
     
                // manipulate the body content to be WordPress and INSERT statement-friendly
                string body = reader["body"].ToString().Replace(@"\n", "\n");
                body = mark.Transform(body).Replace("href=", "target=_blank href=");
                body = body.Replace("<p>", "").Replace("</p>", "");
                body = body.Replace("<pre><code>", "<pre lang=\"tsql\">");
                body = body.Replace("</code></"+"pre>", "</"+"pre>");
                body = body.Replace(@"'", "\'").Replace(@"’", "\'");
     
                body = reader["bodypre"].ToString() + body.Replace("\n", @"\n");
                body += reader["bodypost"].ToString();
                body = body.Replace("&lt;", "<").Replace("&gt;", ">");
                output.Append(body);
     
                // if we are on the last row, add additional values from the first row
                if (reader["c"].Equals(reader["rn"]))
                {
                  output.Append(suffix);
                }
              }
     
              thisfile = UpperBound.ToString();
              using (StreamWriter w = new StreamWriter(@"C:\wp\" + thisfile + ".sql"))
              {
                w.WriteLine(output);
                w.Flush();
              }
            }
          }
        }
      }
    }

    Sim, isso é um monte de código feio, mas finalmente me levou ao conjunto de saída que não faria o phpMyAdmin vomitar, e que o WordPress apresentaria bem (o suficiente). Eu simplesmente chamei o programa C# várias vezes com os diferentes intervalos de parâmetros:
    AnswerHubMigrator    1  750
    AnswerHubMigrator  751 1500
    AnswerHubMigrator 1501 2250
    ...

    Então eu abri cada um dos arquivos, colei-os no phpMyAdmin e apertei GO:
    phpMyAdmin (clique para ampliar)
    Claro que eu tive que adicionar um pouco de CSS no WordPress para ajudar a diferenciar entre perguntas, comentários e respostas, e também recuar comentários para mostrar respostas a perguntas e respostas, aninhar comentários respondendo a comentários e assim por diante. Aqui está a aparência de um trecho quando você analisa as perguntas de um mês:
    Tijolo de pergunta (clique para ampliar)
    E, em seguida, uma postagem de exemplo, mostrando imagens incorporadas, vários anexos, comentários aninhados e uma resposta:
    Exemplo de pergunta e resposta (clique para ir até lá)
    Ainda estou tentando recuperar algumas postagens que foram enviadas ao site depois que o último backup foi feito, mas convido você a navegar. Por favor, avise-nos se você encontrar algo faltando ou fora do lugar, ou apenas para nos dizer que o conteúdo ainda é útil para você. Esperamos reintroduzir a funcionalidade de upload do plano a partir do Plan Explorer, mas isso exigirá algum trabalho de API no novo site de suporte, portanto, não tenho um ETA para você hoje.
      Answers.SQLPerformance.com